> */ private array $fields = [ 'MixedSchema.MixedTable' => ['MixedId', 'Label'], 'OtherSchema.OtherTable' => ['ParentId', 'Description'], ]; public function sourceExists(string $source): bool { return isset($this->fields[$source]); } public function isBuiltInSource(string $source): bool { return false; } public function sourceFields(string $source): array { return $this->fields[$source] ?? []; } public function hasEmailField(string $source): bool { return false; } public function joinPath(string $left, string $right): ?array { if ($left === 'MixedSchema.MixedTable' && $right === 'OtherSchema.OtherTable') { return [ 'left' => 'MixedSchema.MixedTable.MixedId', 'right' => 'OtherSchema.OtherTable.ParentId', ]; } return null; } public function allKnownSources(): array { return array_keys($this->fields); } public function sourceTable(string $source): ?string { return $this->sourceExists($source) ? $source : null; } public function fieldSql(string $source, string $field, string $alias): ?string { if (!in_array($field, $this->fields[$source] ?? [], true)) { return null; } return $alias . '.`' . str_replace('`', '``', $field) . '`'; } }; $customDsl = 'MixedSchema.MixedTable and OtherSchema.OtherTable ' . 'where MixedSchema.MixedTable.MixedId = OtherSchema.OtherTable.ParentId'; $customAst = $parser->parse($customDsl); feca_assert_same( $customAst['sources'], ['MixedSchema.MixedTable', 'OtherSchema.OtherTable'], 'Expected custom schema and table case to be preserved' ); feca_assert_same( $customAst['where'][0]['lhs']['source'] ?? null, 'MixedSchema.MixedTable', 'Expected left field source case to be preserved' ); feca_assert_same( $customAst['where'][0]['rhs']['source'] ?? null, 'OtherSchema.OtherTable', 'Expected right field source case to be preserved' ); $customValidator = new DslValidator($customMetadata); $customValidation = $customValidator->validate($customAst); feca_assert_same($customValidation['errors'], [], 'Expected two custom sources to validate'); $customSql = (string) ((new DslCompiler($customMetadata))->compile($customAst)['sql'] ?? ''); feca_assert_contains( $customSql, 'INNER JOIN `OtherSchema`.`OtherTable` AS s_otherschema_othertable ' . 'ON s_mixedschema_mixedtable.`MixedId` = s_otherschema_othertable.`ParentId`', 'Expected schema-qualified join references to split at their final dot' ); feca_assert_contains( $customSql, "COALESCE(s_mixedschema_mixedtable.`MixedId`, '') = " . "COALESCE(s_otherschema_othertable.`ParentId`, '')", 'Expected schema-qualified field comparison to use NULL-as-empty semantics' ); $comparisonMetadata = new class implements SourceMetadataProvider { public function sourceExists(string $source): bool { return $source === 'records'; } public function isBuiltInSource(string $source): bool { return $source === 'records'; } public function sourceFields(string $source): array { return $source === 'records' ? ['left_value', 'right_value'] : []; } public function hasEmailField(string $source): bool { return false; } public function joinPath(string $left, string $right): ?array { return null; } public function allKnownSources(): array { return ['records']; } public function sourceTable(string $source): ?string { return $source === 'records' ? 'records' : null; } public function fieldSql(string $source, string $field, string $alias): ?string { if ($source !== 'records' || !in_array($field, $this->sourceFields($source), true)) { return null; } return $alias . '.`' . $field . '`'; } }; $comparisonCompiler = new DslCompiler($comparisonMetadata); $literalCases = [ "records.left_value = 'text'" => ["COALESCE(s_records.`left_value`, '') = ?", 'text'], "records.left_value != 'text'" => ["COALESCE(s_records.`left_value`, '') != ?", 'text'], "records.left_value contains 'text'" => ["COALESCE(s_records.`left_value`, '') LIKE ?", '%text%'], "records.left_value starts-with 'text'" => ["COALESCE(s_records.`left_value`, '') LIKE ?", 'text%'], "records.left_value ends-with 'text'" => ["COALESCE(s_records.`left_value`, '') LIKE ?", '%text'], ]; foreach ($literalCases as $predicate => [$sqlNeedle, $expectedParam]) { $compiled = $comparisonCompiler->compile($parser->parse('records where ' . $predicate)); feca_assert_contains((string) $compiled['sql'], $sqlNeedle, 'Expected literal comparison to normalize NULL'); feca_assert_same($compiled['params'], [$expectedParam], 'Expected literal comparison parameter'); } foreach (['=', '!='] as $operator) { $compiled = $comparisonCompiler->compile( $parser->parse("records where records.left_value {$operator} records.right_value") ); feca_assert_contains( (string) $compiled['sql'], "COALESCE(s_records.`left_value`, '') {$operator} COALESCE(s_records.`right_value`, '')", 'Expected field comparison to normalize NULL on both sides' ); feca_assert_same($compiled['params'], [], 'Expected no field comparison parameters'); } $router = new class implements DatabaseRouter { public function mailshotsPdo(): PDO { throw new RuntimeException('Unexpected mailshots database access'); } public function membersPdo(): PDO { throw new RuntimeException('Unexpected members database access'); } public function mailshotsDbName(): string { return 'mailshots_test'; } public function membersDbName(): string { return 'members_test'; } public function fenDbName(): string { return 'FenCaseSensitive'; } }; $databaseMetadata = new DatabaseSourceMetadataProvider($router); feca_assert_same($databaseMetadata->sourceExists('ad_sizes'), true, 'Expected ad_sizes to be a known source'); feca_assert_same($databaseMetadata->isBuiltInSource('ad_sizes'), true, 'Expected ad_sizes to be built in'); feca_assert_same( $databaseMetadata->sourceTable('ad_sizes'), 'FenCaseSensitive.Ad_Sizes', 'Expected ad_sizes to map to the configured FEN database and physical table' ); feca_assert_same( $databaseMetadata->joinPath('ads', 'ad_sizes'), ['left' => 'ads.AdSize', 'right' => 'ad_sizes.SizeName'], 'Expected forward ads/ad_sizes join metadata' ); feca_assert_same( $databaseMetadata->joinPath('ad_sizes', 'ads'), ['left' => 'ad_sizes.SizeName', 'right' => 'ads.AdSize'], 'Expected reverse ads/ad_sizes join metadata' ); echo "Recent DSL change regression tests passed\n";