From 535a798d62b6bf5a1aa4156f96252ba9836724b9 Mon Sep 17 00:00:00 2001 From: Adrian Stephens Date: Wed, 24 Jun 2026 20:17:05 +0100 Subject: [PATCH] testing dsl changes --- docs/todo.md | 10 +- tests/unit/test_dsl_recent_changes.php | 247 +++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_dsl_recent_changes.php diff --git a/docs/todo.md b/docs/todo.md index 9e6075e..ee17a2f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,3 +1,9 @@ -Split advertiser name into forename and surname +Split advertiser name into forename and surname - done in twig in message -Add ad sizes into mailmerge +Add ad sizes into mailmerge - done + +Why does filter ads and advertisers where issue(125) and ads.Price != 0 and not (advertisers.Categories contains 'discount') identify fewer records than expected. - donei + + + +tig diff --git a/tests/unit/test_dsl_recent_changes.php b/tests/unit/test_dsl_recent_changes.php new file mode 100644 index 0000000..bf0d223 --- /dev/null +++ b/tests/unit/test_dsl_recent_changes.php @@ -0,0 +1,247 @@ +> */ + 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";