queries = $queries; $this->router = $router; $this->parser = $parser; $this->validator = $validator; $this->compiler = $compiler; $this->metadata = $metadata; $this->mailshots = $mailshots; } /** @return list> */ public function list(): array { return $this->queries->all(); } /** @return array|null */ public function get(int $id): ?array { return $this->queries->find($id); } /** @return array */ 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 */ 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 */ 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 */ public function preview(string $dsl, int $limit = 50): array { $limit = max(1, min(200, $limit)); return $this->queryRows($dsl, $limit); } /** @return array */ public function review(string $dsl): array { return $this->queryRows($dsl, null); } /** @return array */ 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> */ public function sourceFields(): array { $result = []; foreach ($this->metadata->allKnownSources() as $source) { $result[$source] = $this->metadata->sourceFields($source); } return $result; } /** @return list */ 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 */ 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 */ 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; } }