diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index b3b6a818..170743a1 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -466,8 +466,9 @@ and the time zone to UTC on each connection. ##### Custom PDO driver `PdoConfig::forSqlite()` and `PdoConfig::forMysql()` are conveniences over the generic `PdoConfig::forDriver()`, -which builds a config for any PDO driver. Supply your `PdoDialectInterface`, a `FilterTranslatorInterface`, the DSN, -and the required PDO driver extension, then tune the rest with the setters: +which builds a config for any PDO driver. Supply your `PdoDialectInterface`, the DSN, and the required PDO driver +extension, then tune the rest with the setters. Your dialect's `createFilterTranslator()` supplies the paired +`FilterTranslatorInterface` (with the substring index injected): ```php use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; @@ -475,7 +476,6 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; $config = PdoConfig::forDriver( new MyPostgresDialect(), - new MyPostgresFilterTranslator(), 'pgsql:host=localhost;dbname=ldap', 'pdo_pgsql', ) diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php index e5e5e8e5..2eba63c8 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/MysqlDialect.php @@ -13,6 +13,10 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\MysqlFilterTranslator; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; + /** * MySQL/MariaDB SQL for PdoStorage; requires MySQL 8.0+ or MariaDB 10.6+. * @@ -24,6 +28,11 @@ final class MysqlDialect implements PdoDialectInterface use PdoJournalDialectTrait; use PdoSchemaTrait; + public function createFilterTranslator(?SubstringIndexInterface $substringIndex): FilterTranslatorInterface + { + return new MysqlFilterTranslator($substringIndex); + } + /** * @todo Replace VALUES() with row alias syntax once MariaDB supports it. */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php index c8369c55..5b37c6e1 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php @@ -13,6 +13,9 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; + /** * The full database-specific SQL a PdoStorage needs. * @@ -20,6 +23,11 @@ */ interface PdoDialectInterface extends PdoEntryDialectInterface, PdoJournalDialectInterface { + /** + * The filter translator paired with this dialect, with the (optional) substring index injected for query narrowing. + */ + public function createFilterTranslator(?SubstringIndexInterface $substringIndex): FilterTranslatorInterface; + /** * The full schema (all tables) as a runnable SQL script. */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php index 62fac1d5..ae9a6781 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php @@ -13,6 +13,9 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; use PDO; /** @@ -26,6 +29,11 @@ final class SqliteDialect implements PdoDialectInterface use PdoJournalDialectTrait; use PdoSchemaTrait; + public function createFilterTranslator(?SubstringIndexInterface $substringIndex): FilterTranslatorInterface + { + return new SqliteFilterTranslator($substringIndex); + } + /** * `BEGIN IMMEDIATE` acquires the reserved lock up front so concurrent writers wait (honoring `busy_timeout`) * instead of racing, which returns SQLITE_BUSY immediately to avoid deadlock. diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php index 35523c1e..c8ed6699 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php @@ -16,9 +16,6 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\MysqlDialect; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\MysqlFilterTranslator; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\TrigramSubstringIndex; use PDO; @@ -39,7 +36,6 @@ final class PdoConfig */ private function __construct( private PdoDialectInterface $dialect, - private FilterTranslatorInterface $translator, private string $dsn, private ?string $username, private ?string $password, @@ -55,7 +51,6 @@ public static function forSqlite(string $path): self { return new self( dialect: new SqliteDialect(), - translator: new SqliteFilterTranslator(), dsn: 'sqlite:' . $path, username: null, password: null, @@ -80,7 +75,6 @@ public static function forMysql( ): self { return new self( dialect: new MysqlDialect(), - translator: new MysqlFilterTranslator(), dsn: $dsn, username: $username, password: $password, @@ -102,13 +96,11 @@ public static function forMysql( */ public static function forDriver( PdoDialectInterface $dialect, - FilterTranslatorInterface $translator, string $dsn, string $driverExtension, ): self { return new self( dialect: $dialect, - translator: $translator, dsn: $dsn, username: null, password: null, @@ -131,18 +123,6 @@ public function setDialect(PdoDialectInterface $dialect): self return $this; } - public function getTranslator(): FilterTranslatorInterface - { - return $this->translator; - } - - public function setTranslator(FilterTranslatorInterface $translator): self - { - $this->translator = $translator; - - return $this; - } - public function getDsn(): string { return $this->dsn; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactory.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactory.php index 7a83d83d..0f47944c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactory.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactory.php @@ -61,7 +61,7 @@ private static function shared(PdoConfig $config): PdoStorage $open(), $open, ), - $config->getTranslator(), + $config->getDialect()->createFilterTranslator($config->getSubstringIndex()), $config->getDialect(), $config->getSubstringIndex(), ); @@ -71,7 +71,7 @@ private static function perCoroutine(PdoConfig $config): PdoStorage { return new PdoStorage( new CoroutinePdoConnectionProvider(static fn(): PDO => self::open($config)), - $config->getTranslator(), + $config->getDialect()->createFilterTranslator($config->getSubstringIndex()), $config->getDialect(), $config->getSubstringIndex(), ); diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php index c424f66d..5ff33c02 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/MysqlFilterTranslator.php @@ -13,6 +13,8 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; + /** * MySQL/MariaDB SQL WHERE translator for LDAP filters; targets the `entry_attribute_values` sidecar index. * @@ -22,6 +24,11 @@ final class MysqlFilterTranslator implements FilterTranslatorInterface { use SqlFilterTranslatorTrait; + public function __construct(?SubstringIndexInterface $substringIndex = null) + { + $this->substringIndex = $substringIndex; + } + private function buildPresenceCheck(string $attribute): string { return <<indexedSubstring( + $attribute, + $startsWith, + $contains, + $endsWith, + ); + if ($indexed !== null) { + return $indexed; + } + // Prefix-anchored LIKE is the only valid superset under truncation; other fragments fall back to presence + PHP re-eval. $alias = $this->valueAlias(); @@ -179,6 +192,48 @@ private function translateSubstring(SubstringFilter $filter): ?SqlFilterResult ); } + /** + * The substring index's candidate-narrowing predicate for an infix/suffix filter, or null when it does not apply. + * + * @param array $contains + */ + private function indexedSubstring( + string $attribute, + ?string $startsWith, + array $contains, + ?string $endsWith, + ): ?SqlFilterResult { + if ($startsWith !== null || $this->substringIndex === null) { + return null; + } + + return $this->substringIndex->buildSubstringPredicate( + $attribute, + $this->substringFragments( + $contains, + $endsWith, + ), + ); + } + + /** + * @param array $contains + * + * @return list + */ + private function substringFragments( + array $contains, + ?string $endsWith, + ): array { + $fragments = array_values($contains); + + if ($endsWith !== null) { + $fragments[] = $endsWith; + } + + return $fragments; + } + private function isExactEquality(string $value): bool { return Text::isAscii($value) diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php index 905707a9..2bcd9a3b 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqlFilter/SqliteFilterTranslator.php @@ -13,6 +13,8 @@ namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; + /** * SQLite SQL WHERE translator for LDAP filters; targets the `entry_attribute_values` sidecar index. * @@ -22,6 +24,11 @@ final class SqliteFilterTranslator implements FilterTranslatorInterface { use SqlFilterTranslatorTrait; + public function __construct(?SubstringIndexInterface $substringIndex = null) + { + $this->substringIndex = $substringIndex; + } + private function buildPresenceCheck(string $attribute): string { return << $fragments + */ + public function buildSubstringPredicate( + string $attributeLower, + array $fragments, + ): ?SqlFilterResult; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php index a6e5c9a7..d2262b8f 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterResult; /** * Portable substring index: a generic trigram table usable across every PDO dialect. @@ -50,6 +51,15 @@ final class TrigramSubstringIndex implements SubstringIndexInterface VALUES %s SQL; + private const PREDICATE_SQL = << Indexed attribute names, lowercased. */ @@ -98,6 +108,47 @@ public function maintain( ); } + public function buildSubstringPredicate( + string $attributeLower, + array $fragments, + ): ?SqlFilterResult { + if (!isset($this->attributes[$attributeLower])) { + return null; + } + + $trigrams = []; + foreach ($fragments as $fragment) { + foreach (Trigrams::of($fragment) as $trigram) { + $trigrams[] = $trigram; + } + } + $trigrams = array_values(array_unique($trigrams)); + + if ($trigrams === []) { + return null; + } + + $markers = implode( + ', ', + array_fill( + 0, + count($trigrams), + '?', + ), + ); + + return new SqlFilterResult( + sprintf( + self::PREDICATE_SQL, + $markers, + count($trigrams), + ), + [$attributeLower, ...$trigrams], + isExact: false, + referencedAttributes: [$attributeLower], + ); + } + /** * @return list */ diff --git a/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php index aa6f1184..610c9bd9 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php @@ -188,6 +188,31 @@ public function test_store_writes_trigram_rows_for_indexed_attributes(): void ); } + public function test_infix_search_finds_matches_and_rejects_trigram_over_selection(): void + { + $this->subject->add( + new AddCommand(new Entry( + new Dn('uid=match,dc=example,dc=com'), + new Attribute('uid', 'match'), + new Attribute('cn', 'blacksmith'), + )), + $this->context(), + ); + $this->subject->add( + new AddCommand(new Entry( + new Dn('uid=scatter,dc=example,dc=com'), + new Attribute('uid', 'scatter'), + new Attribute('cn', 'smi mit ith'), + )), + $this->context(), + ); + + self::assertSame( + ['uid=match,dc=example,dc=com'], + $this->searchDns(Filters::contains('cn', 'smith')), + ); + } + public function test_disabling_initialize_skips_schema_creation(): void { // A named shared-cache in-memory database, so a probe connection sees the same schema. diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php index 1de2b371..ab105d43 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SqliteFilterTranslatorTest.php @@ -24,6 +24,7 @@ use FreeDSx\Ldap\Search\Filter\PresentFilter; use FreeDSx\Ldap\Search\Filter\SubstringFilter; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\TrigramSubstringIndex; use FreeDSx\Ldap\Server\Backend\Storage\Exception\InvalidAttributeException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -394,6 +395,76 @@ public function test_substring_starts_with_only_emits_prefix_like(): void self::assertTrue($result->isExact); } + public function test_contains_uses_the_substring_index_when_present(): void + { + $translator = new SqliteFilterTranslator(new TrigramSubstringIndex(['cn'])); + + $result = $translator->translate(new SubstringFilter( + 'cn', + null, + null, + 'smith', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + 'entry_attribute_trigrams', + $result->sql, + ); + self::assertFalse($result->isExact); + } + + public function test_suffix_uses_the_substring_index_when_present(): void + { + $translator = new SqliteFilterTranslator(new TrigramSubstringIndex(['cn'])); + + $result = $translator->translate(new SubstringFilter( + 'cn', + null, + 'ith', + )); + + self::assertNotNull($result); + self::assertStringContainsString( + 'entry_attribute_trigrams', + $result->sql, + ); + self::assertFalse($result->isExact); + } + + public function test_contains_falls_back_to_presence_without_a_substring_index(): void + { + $result = $this->subject->translate(new SubstringFilter( + 'cn', + null, + null, + 'smith', + )); + + self::assertNotNull($result); + self::assertStringNotContainsString( + 'entry_attribute_trigrams', + $result->sql, + ); + } + + public function test_prefix_ignores_the_substring_index(): void + { + $translator = new SqliteFilterTranslator(new TrigramSubstringIndex(['cn'])); + + $result = $translator->translate(new SubstringFilter( + 'cn', + 'smi', + )); + + self::assertNotNull($result); + self::assertStringNotContainsString( + 'entry_attribute_trigrams', + $result->sql, + ); + self::assertTrue($result->isExact); + } + public function test_substring_ends_with_only_falls_back_to_presence_inexact(): void { $result = $this->subject->translate(new SubstringFilter( diff --git a/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndexTest.php b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndexTest.php index 165bbbe6..3de255b8 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndexTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndexTest.php @@ -128,6 +128,40 @@ public function test_maintain_pools_distinct_trigrams_across_values(): void ); } + public function test_build_substring_predicate_declines_an_unindexed_attribute(): void + { + self::assertNull( + (new TrigramSubstringIndex(['cn']))->buildSubstringPredicate('mail', ['smith']), + ); + } + + public function test_build_substring_predicate_declines_when_no_fragment_yields_a_trigram(): void + { + self::assertNull( + (new TrigramSubstringIndex(['cn']))->buildSubstringPredicate('cn', ['ab']), + ); + } + + public function test_build_substring_predicate_narrows_by_trigrams(): void + { + $result = (new TrigramSubstringIndex(['cn']))->buildSubstringPredicate('cn', ['smith']); + + self::assertNotNull($result); + self::assertStringContainsString( + 'entry_attribute_trigrams', + $result->sql, + ); + self::assertStringContainsString( + 'HAVING COUNT(DISTINCT trigram) = 3', + $result->sql, + ); + self::assertFalse($result->isExact); + self::assertSame( + ['cn', 'smi', 'mit', 'ith'], + $result->params, + ); + } + public function test_schema_statements_load_the_trigram_schema(): void { $statements = (new TrigramSubstringIndex())->schemaStatements(new SqliteDialect());