290 lines
9.0 KiB
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;
|
|
}
|
|
}
|