feca-mailshots-plugin/feca_mailshots_plugin/src/Application/DataSourceService.php

290 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
namespace FecaMailshots\Application;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseRouter;
use FecaMailshots\Repository\MailshotRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use PDO;
final class DataSourceService
{
private const NAME_MAX_LENGTH = 100;
private MailshotQueryRepository $queries;
private DatabaseRouter $router;
private DslParser $parser;
private DslValidator $validator;
private DslCompiler $compiler;
private SourceMetadataProvider $metadata;
private MailshotRepository $mailshots;
public function __construct(
MailshotQueryRepository $queries,
DatabaseRouter $router,
DslParser $parser,
DslValidator $validator,
DslCompiler $compiler,
SourceMetadataProvider $metadata,
MailshotRepository $mailshots
) {
$this->queries = $queries;
$this->router = $router;
$this->parser = $parser;
$this->validator = $validator;
$this->compiler = $compiler;
$this->metadata = $metadata;
$this->mailshots = $mailshots;
}
/** @return list<array<string, mixed>> */
public function list(): array
{
return $this->queries->all();
}
/** @return array<string, mixed>|null */
public function get(int $id): ?array
{
return $this->queries->find($id);
}
/** @return array<string, mixed> */
public function validateDsl(string $dsl): array
{
$ast = $this->parser->parse($dsl);
$validation = $this->validator->validate($ast);
$compiled = null;
if ($validation['errors'] === []) {
$compiled = $this->compiler->compile($ast);
}
return [
'ast' => $ast,
'errors' => $validation['errors'],
'warnings' => $validation['warnings'],
'expected_fields' => $validation['expected_fields'],
'compiled_sql' => $compiled['sql'] ?? null,
];
}
/** @return array<string, mixed> */
public function save(?int $id, string $name, string $dsl): array
{
$name = trim($name);
$dsl = trim($dsl);
if ($name === '') {
return ['ok' => false, 'errors' => ['Name is required.']];
}
if ($dsl === '') {
return ['ok' => false, 'errors' => ['DSL sentence is required.']];
}
$validation = $this->validateDsl($dsl);
if ($validation['errors'] !== []) {
return ['ok' => false, 'errors' => $validation['errors'], 'warnings' => $validation['warnings']];
}
if ($id === null) {
$id = $this->queries->create(['name' => $name, 'dsl_text' => $dsl]);
} else {
$this->queries->update($id, ['name' => $name, 'dsl_text' => $dsl]);
}
return ['ok' => true, 'id' => $id, 'warnings' => $validation['warnings']];
}
public function delete(int $id): void
{
$row = $this->queries->find($id);
if ($row === null) {
return;
}
$name = trim((string) ($row['name'] ?? ''));
if ($name !== '' && $this->mailshots->countByDataSource($name) > 0) {
throw new \RuntimeException('Cannot delete data source: one or more mailshots reference this data source.');
}
$this->queries->delete($id);
}
/** @return array<string, mixed> */
public function duplicate(int $id): array
{
$source = $this->queries->find($id);
if ($source === null) {
return ['ok' => false, 'errors' => ['Data source not found.']];
}
$newId = $this->queries->create([
'name' => $this->duplicateName((string) ($source['name'] ?? ''), $id),
'dsl_text' => (string) ($source['dsl_text'] ?? ''),
]);
return ['ok' => true, 'id' => $newId];
}
/** @return array<string, mixed> */
public function preview(string $dsl, int $limit = 50): array
{
$limit = max(1, min(200, $limit));
return $this->queryRows($dsl, $limit);
}
/** @return array<string, mixed> */
public function review(string $dsl): array
{
return $this->queryRows($dsl, null);
}
/** @return array<string, mixed> */
private function queryRows(string $dsl, ?int $limit): array
{
$validation = $this->validateDsl($dsl);
if ($validation['errors'] !== []) {
return ['errors' => $validation['errors'], 'warnings' => $validation['warnings'], 'rows' => [], 'count' => 0, 'columns' => []];
}
$ast = $validation['ast'];
$compiled = $this->compiler->compile($ast);
$countSql = 'SELECT COUNT(*) FROM (' . $compiled['sql'] . ') AS q';
$stmtCount = $this->router->membersPdo()->prepare($countSql);
$stmtCount->execute($compiled['params']);
$count = (int) $stmtCount->fetchColumn();
$previewSql = $compiled['sql'];
if ($limit !== null) {
$previewSql .= ' LIMIT ' . $limit;
}
$stmtRows = $this->router->membersPdo()->prepare($previewSql);
$stmtRows->execute($compiled['params']);
$rows = $stmtRows->fetchAll(PDO::FETCH_ASSOC);
$columnSet = [];
foreach ($rows as $row) {
foreach (array_keys($row) as $key) {
$columnSet[$key] = true;
}
}
return [
'errors' => [],
'warnings' => $validation['warnings'],
'count' => $count,
'rows' => $rows,
'columns' => array_keys($columnSet),
'expected_fields' => $validation['expected_fields'],
];
}
/** @return array<string, list<string>> */
public function sourceFields(): array
{
$result = [];
foreach ($this->metadata->allKnownSources() as $source) {
$result[$source] = $this->metadata->sourceFields($source);
}
return $result;
}
/** @return list<string> */
public function listSchemas(): array
{
$schemas = [];
$membersSchema = $this->router->membersDbName();
if ($membersSchema !== '') {
$schemas[] = $membersSchema;
}
$fenSchema = $this->router->fenDbName();
if ($fenSchema !== '') {
$schemas[] = $fenSchema;
}
$sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name';
$rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$name = (string) ($row['schema_name'] ?? '');
if ($name === '' || in_array($name, ['information_schema', 'performance_schema', 'mysql', 'sys'], true)) {
continue;
}
$schemas[] = $name;
}
$schemas = array_values(array_unique($schemas));
sort($schemas, SORT_NATURAL | SORT_FLAG_CASE);
return $schemas;
}
/** @return list<string> */
public function listTables(string $schema): array
{
$schema = trim($schema);
if ($schema === '') {
return [];
}
// Shared-hosting users may not have information_schema access.
// Use explicit SHOW TABLES for the selected schema.
$sql = 'SHOW TABLES FROM `' . str_replace('`', '``', $schema) . '`';
$rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_NUM);
$tables = [];
foreach ($rows as $row) {
if (isset($row[0]) && trim((string) $row[0]) !== '') {
$tables[] = (string) $row[0];
}
}
sort($tables, SORT_NATURAL | SORT_FLAG_CASE);
return $tables;
}
/** @return list<string> */
public function sourceFieldsForSource(string $source): array
{
$source = trim($source);
if ($source === '') {
return [];
}
return $this->metadata->sourceFields($source);
}
private function duplicateName(string $name, int $sourceId): string
{
$base = trim($name);
if ($base === '') {
$base = 'Data source #' . $sourceId;
}
$existing = [];
foreach ($this->queries->all() as $row) {
$existing[strtolower(trim((string) ($row['name'] ?? '')))] = true;
}
$prefix = 'Copy of ';
$candidate = $this->truncateName($prefix . $base);
if (!isset($existing[strtolower($candidate)])) {
return $candidate;
}
for ($i = 2; $i < 1000; $i++) {
$suffix = ' (' . $i . ')';
$candidate = $this->truncateName($prefix . $base, $suffix);
if (!isset($existing[strtolower($candidate)])) {
return $candidate;
}
}
return $this->truncateName($prefix . $base, ' (' . time() . ')');
}
private function truncateName(string $value, string $suffix = ''): string
{
$suffixLength = strlen($suffix);
$baseMax = max(1, self::NAME_MAX_LENGTH - $suffixLength);
$base = substr($value, 0, $baseMax);
return rtrim($base) . $suffix;
}
}