diff --git a/docs/Server/Database-Schema.md b/docs/Server/Database-Schema.md index a0c6fa12..9305b8f2 100644 --- a/docs/Server/Database-Schema.md +++ b/docs/Server/Database-Schema.md @@ -29,7 +29,7 @@ The schema ships in the package under `resources/schema`: schema changes. Point your migration tool at these files, or copy them into your project. If you would rather get the baseline as a -string in code, `SqliteStorage::schemaDdl()` and `MysqlStorage::schemaDdl()` return the same content. +string in code, `PdoStorage::schemaDdl(new SqliteDialect())` and `PdoStorage::schemaDdl(new MysqlDialect())` return the same content. ## Managing the Schema Yourself @@ -37,11 +37,12 @@ For a managed database you usually want to apply schema changes with your own to DDL on startup. Turn automatic setup off with the `initializeSchema` flag on the storage factory: ```php -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; -$storage = SqliteStorage::forPcntl( - '/var/lib/freedsx/directory.sqlite', - initializeSchema: false, +$storage = PdoStorageFactory::forPcntl( + PdoConfig::forSqlite('/var/lib/freedsx/directory.sqlite') + ->setInitializeSchema(false), ); ``` diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index 43617a89..b3b6a818 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -12,8 +12,8 @@ General LDAP Server Usage * [Built-In Storage Implementations](#built-in-storage-implementations) * [InMemoryStorage](#inmemorystorage) * [JsonFileStorage](#jsonfilestorage) - * [SqliteStorage](#sqlitestorage) - * [MysqlStorage](#mysqlstorage) + * [SQLite](#sqlite) + * [MySQL](#mysql) * [Custom Filter Evaluation](#custom-filter-evaluation) * [LDIF Data](#ldif-data) * [Seeding Initial Entries](#seeding-initial-entries) @@ -338,7 +338,7 @@ An in-memory, array-backed storage implementation. Suitable for: > Under the PCNTL runner, `InMemoryStorage` is **not safe for multi-client write workloads**. Each forked child receives > its own copy of the store at fork time. Written data is not propagated. > -> Use one of `JsonFileStorage`, `SqliteStorage`, or `MysqlStorage` if you need write behavior and use PCNTL. +> Use `JsonFileStorage` or a `PdoStorage` (via `PdoStorageFactory` with a SQLite/MySQL `PdoConfig`) if you need write behavior and use PCNTL. ```php use FreeDSx\Ldap\Entry\Attribute; @@ -398,56 +398,64 @@ JSON format: } ``` -#### SqliteStorage +#### SQLite -A SQLite-backed storage implementation that persists the directory in a SQLite database file via PDO. Suitable for +A SQLite-backed `PdoStorage` that persists the directory in a SQLite database file via PDO. Suitable for use cases that need durable persistence across restarts with support for concurrent access: - **PCNTL**: a single shared PDO connection is inherited by all forked child processes. SQLite WAL mode handles concurrent access at the OS level. - **Swoole**: each coroutine gets its own PDO connection to avoid blocking. -Use the named constructor that matches your server runner: +Build a `PdoConfig::forSqlite()` and pass it to the `PdoStorageFactory` method that matches your server runner: ```php use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\ServerOptions; // PCNTL runner — WAL journal mode, shared connection -$server = new LdapServer((new ServerOptions())->setStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite'))); +$server = new LdapServer((new ServerOptions())->setStorage( + PdoStorageFactory::forPcntl(PdoConfig::forSqlite('/var/lib/myapp/ldap.sqlite')) +)); $server->run(); // Swoole runner — WAL journal mode, per-coroutine connection -$server = new LdapServer((new ServerOptions())->setStorage(SqliteStorage::forSwoole('/var/lib/myapp/ldap.sqlite'))); +$server = new LdapServer((new ServerOptions())->setStorage( + PdoStorageFactory::forSwoole(PdoConfig::forSqlite('/var/lib/myapp/ldap.sqlite')) +)); $server->run(); ``` Use `:memory:` as the path to run a non-persistent, in-process SQLite database (useful for testing): ```php -$server = new LdapServer((new ServerOptions())->setStorage(SqliteStorage::forPcntl(':memory:'))); +$server = new LdapServer((new ServerOptions())->setStorage( + PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')) +)); ``` -#### MysqlStorage +#### MySQL -A MySQL/MariaDB-backed storage implementation. Requires MySQL 8.0+ or MariaDB 10.6+. +A MySQL/MariaDB-backed `PdoStorage`. Requires MySQL 8.0+ or MariaDB 10.6+. -Use the named constructor that matches your server runner: +Build a `PdoConfig::forMysql()` and pass it to the `PdoStorageFactory` method that matches your server runner: ```php use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\ServerOptions; // PCNTL runner — shared connection across forked processes $server = new LdapServer((new ServerOptions())->setStorage( - MysqlStorage::forPcntl('mysql:host=localhost;dbname=ldap', 'user', 'secret') + PdoStorageFactory::forPcntl(PdoConfig::forMysql('mysql:host=localhost;dbname=ldap', 'user', 'secret')) )); $server->run(); // Swoole runner — per-coroutine connection $server = new LdapServer((new ServerOptions())->setStorage( - MysqlStorage::forSwoole('mysql:host=localhost;dbname=ldap', 'user', 'secret') + PdoStorageFactory::forSwoole(PdoConfig::forMysql('mysql:host=localhost;dbname=ldap', 'user', 'secret')) )); $server->run(); ``` @@ -457,73 +465,25 @@ and the time zone to UTC on each connection. ##### Custom PDO driver -`SqliteStorage` and `MysqlStorage` are both factories for `PdoStorage`, which is the generic PDO-backed -implementation. To support a different database engine, implement `PdoStorageFactoryInterface` with -`PdoStorageFactoryTrait` and supply the appropriate `PdoDialectInterface`, filter translator, and -connection opener: +`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: ```php -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactoryInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactoryTrait; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; -use PDO; - -final class PostgresStorage implements PdoStorageFactoryInterface -{ - use PdoStorageFactoryTrait; - - public function __construct( - private readonly string $dsn, - private readonly string $username, - #[\SensitiveParameter] - private readonly string $password, - ) { - } - - public static function forPcntl( - string $dsn, - string $username, - #[\SensitiveParameter] - string $password, - ): PdoStorage { - return (new self($dsn, $username, $password))->createShared(); - } +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; - public static function forSwoole( - string $dsn, - string $username, - #[\SensitiveParameter] - string $password, - ): PdoStorage { - return (new self($dsn, $username, $password))->createPerCoroutine(); - } - - protected function dialect(): PdoDialectInterface - { - return new MyPostgresDialect(); - } - - protected function translator(): FilterTranslatorInterface - { - return new MyPostgresFilterTranslator(); - } - - protected function openConnection(PdoDialectInterface $dialect): PDO - { - $pdo = new PDO( - $this->dsn, - $this->username, - $this->password, - [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], - ); +$config = PdoConfig::forDriver( + new MyPostgresDialect(), + new MyPostgresFilterTranslator(), + 'pgsql:host=localhost;dbname=ldap', + 'pdo_pgsql', +) + ->setUsername('user') + ->setPassword('secret') + ->setSessionStatements(["SET client_encoding = 'UTF8'"]); - PdoStorage::initialize($pdo, $dialect); - - return $pdo; - } -} +$storage = PdoStorageFactory::forPcntl($config); ``` ### Custom Filter Evaluation @@ -580,11 +540,12 @@ other source (database, remote URL, gzip stream, etc.). LDIF output uses the par use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\ServerOptions; $server = new LdapServer( - (new ServerOptions())->setStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite')), + (new ServerOptions())->setStorage(PdoStorageFactory::forPcntl(PdoConfig::forSqlite('/var/lib/myapp/ldap.sqlite'))), ); $server->seed( new FileLdifLoader('/etc/myapp/initial-data.ldif'), @@ -650,11 +611,12 @@ entries exactly as they were. ```php use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Ldif\Output\FileLdifOutput; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\ServerOptions; $server = new LdapServer( - (new ServerOptions())->setStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite')), + (new ServerOptions())->setStorage(PdoStorageFactory::forPcntl(PdoConfig::forSqlite('/var/lib/myapp/ldap.sqlite'))), ); $server->dump(new FileLdifOutput('/var/backups/ldap-snapshot.ldif')); ``` diff --git a/docs/Server/Replication.md b/docs/Server/Replication.md index 2d9cc6ca..e0302874 100644 --- a/docs/Server/Replication.md +++ b/docs/Server/Replication.md @@ -31,7 +31,8 @@ use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\AccessControl\Target\Target; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\ServerOptions; // The one sync-specific grant: the privileged sync control, over the base to sync. @@ -45,7 +46,7 @@ $syncGrant = ControlRule::allow( $aclRules = $myAclRules->withControlRules($syncGrant); // A storage that is visible across connections (see Storage and Runner Requirements below). -$storage = SqliteStorage::forPcntl('/var/lib/freedsx/directory.sqlite'); +$storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite('/var/lib/freedsx/directory.sqlite')); $options = (new ServerOptions()) ->setSyncEnabled(true) @@ -170,7 +171,8 @@ use FreeDSx\Ldap\ClientOptions; use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Operations; use FreeDSx\Ldap\ReplicaConfig; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\Sync\Consumer\Checkpoint\FileReplicationCheckpoint; use FreeDSx\Ldap\ServerOptions; @@ -190,7 +192,7 @@ $replicaConfig = new ReplicaConfig( $replicaConfig->setBind(Operations::bind('cn=replica,dc=example,dc=com', 'secret')); $options = ServerOptions::forReplica($replicaConfig) - ->setStorage(SqliteStorage::forPcntl('/var/lib/freedsx/replica.sqlite')); + ->setStorage(PdoStorageFactory::forPcntl(PdoConfig::forSqlite('/var/lib/freedsx/replica.sqlite'))); (new LdapServer($options))->run(); ``` diff --git a/resources/schema/mysql/trigram.sql b/resources/schema/mysql/trigram.sql new file mode 100644 index 00000000..c9e7abbf --- /dev/null +++ b/resources/schema/mysql/trigram.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS entry_attribute_trigrams ( + entry_lc_dn VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + attr_name_lower VARCHAR(255) NOT NULL, + trigram VARCHAR(3) NOT NULL, + INDEX idx_trgm_attr (attr_name_lower, trigram), + INDEX idx_trgm_entry (entry_lc_dn), + CONSTRAINT fk_trgm_entry FOREIGN KEY (entry_lc_dn) + REFERENCES entries(lc_dn) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/resources/schema/sqlite/trigram.sql b/resources/schema/sqlite/trigram.sql new file mode 100644 index 00000000..c2868b27 --- /dev/null +++ b/resources/schema/sqlite/trigram.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS entry_attribute_trigrams ( + entry_lc_dn TEXT NOT NULL, + attr_name_lower TEXT NOT NULL, + trigram TEXT NOT NULL, + FOREIGN KEY (entry_lc_dn) REFERENCES entries(lc_dn) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_trgm_attr ON entry_attribute_trigrams (attr_name_lower, trigram); + +CREATE INDEX IF NOT EXISTS idx_trgm_entry ON entry_attribute_trigrams (entry_lc_dn); 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 e7d63e64..c8369c55 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoDialectInterface.php @@ -31,4 +31,11 @@ public function schemaSql(): string; * @return list */ public function schemaStatements(): array; + + /** + * The statements of a named auxiliary schema (e.g. an optional substring index), for strategies to apply their own DDL. + * + * @return list + */ + public function schemaStatementsNamed(string $name): array; } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoSchemaTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoSchemaTrait.php index 7a815da6..552b40cd 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoSchemaTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/PdoSchemaTrait.php @@ -22,9 +22,11 @@ */ trait PdoSchemaTrait { + private const BASELINE_SCHEMA = 'baseline'; + public function schemaSql(): string { - return $this->schemaFile() + return $this->schemaFile(self::BASELINE_SCHEMA) ->sql(); } @@ -33,7 +35,16 @@ public function schemaSql(): string */ public function schemaStatements(): array { - return $this->schemaFile() + return $this->schemaFile(self::BASELINE_SCHEMA) + ->statements(); + } + + /** + * @return list + */ + public function schemaStatementsNamed(string $name): array + { + return $this->schemaFile($name) ->statements(); } @@ -42,10 +53,10 @@ public function schemaStatements(): array */ abstract protected function schemaName(): string; - private function schemaFile(): SchemaFile + private function schemaFile(string $name): SchemaFile { return new SchemaFile(Resources::path( - 'schema/' . $this->schemaName() . '/baseline.sql', + 'schema/' . $this->schemaName() . '/' . $name . '.sql', )); } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php deleted file mode 100644 index 5b3d7543..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/MysqlStorage.php +++ /dev/null @@ -1,122 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; - -use FreeDSx\Ldap\Exception\RuntimeException; -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\Pdo\PdoStorageFactoryInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactoryTrait; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\MysqlFilterTranslator; -use PDO; -use SensitiveParameter; - -/** - * MySQL/MariaDB factory for PdoStorage; use forPcntl()/forSwoole() to select the runner. Requires MySQL 8.0+ or MariaDB 10.6+. - * - * @api - * - * @author Chad Sikorra - */ -final class MysqlStorage implements PdoStorageFactoryInterface -{ - use PdoStorageFactoryTrait; - - public function __construct( - private readonly string $dsn, - private readonly string $username, - #[SensitiveParameter] - private readonly string $password, - private readonly bool $initializeSchema = true, - ) {} - - public static function forPcntl( - string $dsn, - string $username, - #[SensitiveParameter] - string $password, - bool $initializeSchema = true, - ): PdoStorage { - return (new self( - $dsn, - $username, - $password, - $initializeSchema, - ))->createShared(); - } - - public static function forSwoole( - string $dsn, - string $username, - #[SensitiveParameter] - string $password, - bool $initializeSchema = true, - ): PdoStorage { - return (new self( - $dsn, - $username, - $password, - $initializeSchema, - ))->createPerCoroutine(); - } - - /** - * The MySQL schema as a runnable SQL script, to export or apply with your own migration tooling. - */ - public static function schemaDdl(): string - { - return PdoStorage::schemaDdl(new MysqlDialect()); - } - - protected function dialect(): PdoDialectInterface - { - return new MysqlDialect(); - } - - protected function translator(): FilterTranslatorInterface - { - return new MysqlFilterTranslator(); - } - - protected function openConnection(PdoDialectInterface $dialect): PDO - { - if (!extension_loaded('pdo_mysql')) { - throw new RuntimeException( - 'The "pdo_mysql" extension is required for the MySQL storage backend.', - ); - } - - $pdo = new PDO( - $this->dsn, - $this->username, - $this->password, - [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => false, - ], - ); - $pdo->exec("SET NAMES utf8mb4"); - $pdo->exec("SET time_zone = '+00:00'"); - - if ($this->initializeSchema) { - PdoStorage::initialize( - $pdo, - $dialect, - ); - } - - return $pdo; - } -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php new file mode 100644 index 00000000..35523c1e --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php @@ -0,0 +1,267 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo; + +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; +use SensitiveParameter; + +/** + * Full connection + options for a PdoStorage; build one with forSqlite()/forMysql() and tune it with the setters. + * + * @api + * + * @author Chad Sikorra + */ +final class PdoConfig +{ + /** + * @param array $pdoOptions + * @param list $sessionStatements + */ + private function __construct( + private PdoDialectInterface $dialect, + private FilterTranslatorInterface $translator, + private string $dsn, + private ?string $username, + private ?string $password, + private array $pdoOptions, + private array $sessionStatements, + private bool $serializeSwooleWrites, + private string $driverExtension, + private bool $initializeSchema = true, + private ?SubstringIndexInterface $substringIndex = new TrigramSubstringIndex(), + ) {} + + public static function forSqlite(string $path): self + { + return new self( + dialect: new SqliteDialect(), + translator: new SqliteFilterTranslator(), + dsn: 'sqlite:' . $path, + username: null, + password: null, + pdoOptions: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + sessionStatements: [ + 'PRAGMA busy_timeout = 5000', + 'PRAGMA synchronous = NORMAL', + 'PRAGMA journal_mode = WAL', + 'PRAGMA foreign_keys = ON', + ], + serializeSwooleWrites: true, + driverExtension: 'pdo_sqlite', + ); + } + + public static function forMysql( + string $dsn, + #[SensitiveParameter] + string $username, + #[SensitiveParameter] + string $password, + ): self { + return new self( + dialect: new MysqlDialect(), + translator: new MysqlFilterTranslator(), + dsn: $dsn, + username: $username, + password: $password, + pdoOptions: [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + ], + sessionStatements: [ + 'SET NAMES utf8mb4', + "SET time_zone = '+00:00'", + ], + serializeSwooleWrites: false, + driverExtension: 'pdo_mysql', + ); + } + + /** + * Generic constructor for a custom database engine; tune username/password/pdoOptions/sessionStatements via the setters. + */ + 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, + pdoOptions: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + sessionStatements: [], + serializeSwooleWrites: false, + driverExtension: $driverExtension, + ); + } + + public function getDialect(): PdoDialectInterface + { + return $this->dialect; + } + + public function setDialect(PdoDialectInterface $dialect): self + { + $this->dialect = $dialect; + + 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; + } + + public function setDsn(string $dsn): self + { + $this->dsn = $dsn; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(?string $username): self + { + $this->username = $username; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword( + #[SensitiveParameter] + ?string $password, + ): self { + $this->password = $password; + + return $this; + } + + /** + * @return array + */ + public function getPdoOptions(): array + { + return $this->pdoOptions; + } + + /** + * @param array $pdoOptions + */ + public function setPdoOptions(array $pdoOptions): self + { + $this->pdoOptions = $pdoOptions; + + return $this; + } + + /** + * @return list + */ + public function getSessionStatements(): array + { + return $this->sessionStatements; + } + + /** + * @param list $sessionStatements + */ + public function setSessionStatements(array $sessionStatements): self + { + $this->sessionStatements = $sessionStatements; + + return $this; + } + + public function getSerializeSwooleWrites(): bool + { + return $this->serializeSwooleWrites; + } + + public function setSerializeSwooleWrites(bool $serializeSwooleWrites): self + { + $this->serializeSwooleWrites = $serializeSwooleWrites; + + return $this; + } + + public function getDriverExtension(): string + { + return $this->driverExtension; + } + + public function setDriverExtension(string $driverExtension): self + { + $this->driverExtension = $driverExtension; + + return $this; + } + + public function getInitializeSchema(): bool + { + return $this->initializeSchema; + } + + public function setInitializeSchema(bool $initializeSchema): self + { + $this->initializeSchema = $initializeSchema; + + return $this; + } + + public function getSubstringIndex(): ?SubstringIndexInterface + { + return $this->substringIndex; + } + + public function setSubstringIndex(?SubstringIndexInterface $substringIndex): self + { + $this->substringIndex = $substringIndex; + + return $this; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactory.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactory.php new file mode 100644 index 00000000..7a83d83d --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactory.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo; + +use Closure; +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Writer\SwooleWriterQueue; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Writer\WriteSerializingStorage; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use PDO; + +/** + * Builds a PdoStorage from a PdoConfig, selecting the runner: forPcntl() (shared) or forSwoole() (per-coroutine). + * + * @api + * + * @author Chad Sikorra + */ +final class PdoStorageFactory +{ + public static function forPcntl(PdoConfig $config): PdoStorage + { + return self::shared($config); + } + + public static function forSwoole(PdoConfig $config): EntryStorageInterface + { + if (!$config->getSerializeSwooleWrites()) { + return self::perCoroutine($config); + } + + $writes = self::shared($config); + + return new WriteSerializingStorage( + reads: self::perCoroutine($config), + writes: $writes, + queue: new SwooleWriterQueue( + batchWrapper: static fn(Closure $cb) => $writes->atomic(static fn() => $cb()), + ), + ); + } + + private static function shared(PdoConfig $config): PdoStorage + { + $open = static fn(): PDO => self::open($config); + + return new PdoStorage( + new SharedPdoConnectionProvider( + $open(), + $open, + ), + $config->getTranslator(), + $config->getDialect(), + $config->getSubstringIndex(), + ); + } + + private static function perCoroutine(PdoConfig $config): PdoStorage + { + return new PdoStorage( + new CoroutinePdoConnectionProvider(static fn(): PDO => self::open($config)), + $config->getTranslator(), + $config->getDialect(), + $config->getSubstringIndex(), + ); + } + + private static function open(PdoConfig $config): PDO + { + if (!extension_loaded($config->getDriverExtension())) { + throw new RuntimeException(sprintf( + 'The "%s" extension is required for this PDO storage backend.', + $config->getDriverExtension(), + )); + } + + $pdo = new PDO( + $config->getDsn(), + $config->getUsername(), + $config->getPassword(), + $config->getPdoOptions(), + ); + + foreach ($config->getSessionStatements() as $statement) { + $pdo->exec($statement); + } + + if ($config->getInitializeSchema()) { + PdoStorage::initialize( + $pdo, + $config->getDialect(), + $config->getSubstringIndex(), + ); + } + + return $pdo; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactoryInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactoryInterface.php deleted file mode 100644 index 42295880..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactoryInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo; - -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; - -/** - * Constructs a configured PdoStorage for a specific database driver (e.g. SqliteStorage::forPcntl('/path/to/db.sqlite')). - * - * @api - * - * @author Chad Sikorra - */ -interface PdoStorageFactoryInterface -{ - public function create(): PdoStorage; -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactoryTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactoryTrait.php deleted file mode 100644 index b09a41c4..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoStorageFactoryTrait.php +++ /dev/null @@ -1,67 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo; - -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; -use PDO; - -/** - * Shared PdoStorage wiring: concrete factories provide dialect, translator, and connection opener; the trait picks the provider. - * - * @author Chad Sikorra - */ -trait PdoStorageFactoryTrait -{ - /** - * @api - */ - public function create(): PdoStorage - { - return $this->createShared(); - } - - abstract protected function dialect(): PdoDialectInterface; - - abstract protected function translator(): FilterTranslatorInterface; - - abstract protected function openConnection(PdoDialectInterface $dialect): PDO; - - protected function createShared(): PdoStorage - { - $dialect = $this->dialect(); - $factory = fn(): PDO => $this->openConnection($dialect); - - return new PdoStorage( - new SharedPdoConnectionProvider( - $factory(), - $factory, - ), - $this->translator(), - $dialect, - ); - } - - protected function createPerCoroutine(): PdoStorage - { - $dialect = $this->dialect(); - - return new PdoStorage( - new CoroutinePdoConnectionProvider(fn(): PDO => $this->openConnection($dialect)), - $this->translator(), - $dialect, - ); - } -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php index fc53a01c..87009034 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/PdoStorage.php @@ -32,6 +32,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Exception\StorageIoException; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterUtility; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use FreeDSx\Ldap\Server\Backend\Storage\Exception\TimeLimitExceededException; @@ -43,7 +44,7 @@ use Throwable; /** - * PDO-backed storage; pass a PdoDialectInterface + PdoConnectionProviderInterface, or use SqliteStorage / MysqlStorage factories. + * PDO-backed storage; build one with PdoStorageFactory::forPcntl()/forSwoole() from a PdoConfig::forSqlite()/forMysql(). * * When injecting a pre-built PDO, wrap it in SharedPdoConnectionProvider and call PdoStorage::initialize($pdo, $dialect) first. * @@ -73,6 +74,7 @@ public function __construct( private readonly PdoConnectionProviderInterface $provider, private readonly FilterTranslatorInterface $translator, private readonly PdoDialectInterface $dialect, + private readonly ?SubstringIndexInterface $substringIndex = null, ) { if (!extension_loaded('mbstring')) { throw new RuntimeException( @@ -96,6 +98,7 @@ public function reset(): void public static function initialize( PDO $pdo, PdoDialectInterface $dialect, + ?SubstringIndexInterface $substringIndex = null, ): void { $pdo->setAttribute( PDO::ATTR_ERRMODE, @@ -106,7 +109,12 @@ public static function initialize( PDO::FETCH_ASSOC, ); - foreach ($dialect->schemaStatements() as $statement) { + $statements = [ + ...$dialect->schemaStatements(), + ...($substringIndex?->schemaStatements($dialect) ?? []), + ]; + + foreach ($statements as $statement) { $pdo->exec($statement); } } @@ -201,6 +209,17 @@ public function store(Entry $entry): void $lcDn, $entry, ); + + $this->substringIndex?->maintain( + $lcDn, + $entry, + function (string $sql, array $params): void { + $this->prepareAndExecute( + $sql, + $params, + ); + }, + ); }); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php deleted file mode 100644 index a3af24dc..00000000 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SqliteStorage.php +++ /dev/null @@ -1,124 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter; - -use Closure; -use FreeDSx\Ldap\Exception\RuntimeException; -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\Pdo\PdoStorageFactoryInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactoryTrait; -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\Writer\SwooleWriterQueue; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Writer\WriteSerializingStorage; -use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; -use PDO; - -/** - * SQLite factory for PdoStorage; use SqliteStorage::forPcntl() or ::forSwoole() to select the runner. - * - * @api - * - * @author Chad Sikorra - */ -final class SqliteStorage implements PdoStorageFactoryInterface -{ - use PdoStorageFactoryTrait; - - private const DB_CONNECTION_PREFIX = 'sqlite:'; - - private const PRAGMA_JOURNAL_MODE_WAL = 'PRAGMA journal_mode = WAL'; - - private const PRAGMA_SYNCHRONOUS_NORMAL = 'PRAGMA synchronous = NORMAL'; - - private const PRAGMA_BUSY_TIMEOUT_MS = 'PRAGMA busy_timeout = 5000'; - - private const PRAGMA_FOREIGN_KEYS_ON = 'PRAGMA foreign_keys = ON'; - - public function __construct( - private readonly string $dbPath, - private readonly bool $initializeSchema = true, - ) {} - - public static function forPcntl( - string $dbPath, - bool $initializeSchema = true, - ): PdoStorage { - return (new self($dbPath, $initializeSchema))->createShared(); - } - - public static function forSwoole( - string $dbPath, - bool $initializeSchema = true, - ): EntryStorageInterface { - $factory = new self($dbPath, $initializeSchema); - $writesStorage = $factory->createShared(); - - return new WriteSerializingStorage( - reads: $factory->createPerCoroutine(), - writes: $writesStorage, - queue: new SwooleWriterQueue( - batchWrapper: static fn(Closure $cb) => $writesStorage->atomic(static fn() => $cb()), - ), - ); - } - - /** - * The SQLite schema as a runnable SQL script, to export or apply with your own migration tooling. - */ - public static function schemaDdl(): string - { - return PdoStorage::schemaDdl(new SqliteDialect()); - } - - protected function dialect(): PdoDialectInterface - { - return new SqliteDialect(); - } - - protected function translator(): FilterTranslatorInterface - { - return new SqliteFilterTranslator(); - } - - protected function openConnection(PdoDialectInterface $dialect): PDO - { - if (!extension_loaded('pdo_sqlite')) { - throw new RuntimeException( - 'The "pdo_sqlite" extension is required for the SQLite storage backend.', - ); - } - - $pdo = new PDO( - self::DB_CONNECTION_PREFIX . $this->dbPath, - null, - null, - [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], - ); - $pdo->exec(self::PRAGMA_BUSY_TIMEOUT_MS); - $pdo->exec(self::PRAGMA_SYNCHRONOUS_NORMAL); - $pdo->exec(self::PRAGMA_JOURNAL_MODE_WAL); - $pdo->exec(self::PRAGMA_FOREIGN_KEYS_ON); - - if ($this->initializeSchema) { - PdoStorage::initialize( - $pdo, - $dialect, - ); - } - - return $pdo; - } -} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexInterface.php new file mode 100644 index 00000000..3c7945c5 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; + +/** + * A pluggable substring-search index for the PDO backend. + * + * @author Chad Sikorra + */ +interface SubstringIndexInterface +{ + /** + * DDL that creates this strategy's schema, applied after the baseline when the strategy is attached. + * + * @return list + */ + public function schemaStatements(PdoDialectInterface $dialect): array; + + /** + * Re-index one entry, running each write through the executor inside the caller's transaction. + * + * @param callable(string $sql, list $params): void $execute + */ + public function maintain( + string $lcDn, + Entry $entry, + callable $execute, + ): void; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php new file mode 100644 index 00000000..a6e5c9a7 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndex.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; + +/** + * Portable substring index: a generic trigram table usable across every PDO dialect. + * + * @author Chad Sikorra + */ +final class TrigramSubstringIndex implements SubstringIndexInterface +{ + /** + * Attributes indexed by default: the common name/identity attributes typically searched by substring. + * + * @var list + */ + public const DEFAULT_ATTRIBUTES = [ + 'cn', + 'sn', + 'givenName', + 'displayName', + 'uid', + 'mail', + 'ou', + ]; + + private const SCHEMA_NAME = 'trigram'; + + private const DELETE_SQL = << Indexed attribute names, lowercased. + */ + private readonly array $attributes; + + /** + * @param list $attributes + */ + public function __construct(array $attributes = self::DEFAULT_ATTRIBUTES) + { + $set = []; + + foreach ($attributes as $attribute) { + $set[strtolower($attribute)] = true; + } + + $this->attributes = $set; + } + + public function schemaStatements(PdoDialectInterface $dialect): array + { + return $dialect->schemaStatementsNamed(self::SCHEMA_NAME); + } + + public function maintain( + string $lcDn, + Entry $entry, + callable $execute, + ): void { + $execute( + self::DELETE_SQL, + [$lcDn], + ); + + $rows = $this->rowsFor($lcDn, $entry); + if ($rows === []) { + return; + } + + $execute( + sprintf( + self::INSERT_SQL, + $this->placeholders(count($rows)), + ), + $this->flatten($rows), + ); + } + + /** + * @return list + */ + private function rowsFor( + string $lcDn, + Entry $entry, + ): array { + $rows = []; + + foreach ($entry->getAttributes() as $attribute) { + $attrLower = strtolower($attribute->getName()); + if (!isset($this->attributes[$attrLower])) { + continue; + } + + $trigrams = []; + foreach ($attribute->getValues() as $value) { + foreach (Trigrams::of($value) as $trigram) { + $trigrams[] = $trigram; + } + } + + foreach (array_unique($trigrams) as $trigram) { + $rows[] = [$lcDn, $attrLower, $trigram]; + } + } + + return $rows; + } + + private function placeholders(int $count): string + { + return implode( + ', ', + array_fill( + 0, + $count, + '(?, ?, ?)', + ), + ); + } + + /** + * @param list $rows + * + * @return list + */ + private function flatten(array $rows): array + { + $params = []; + + foreach ($rows as $row) { + $params[] = $row[0]; + $params[] = $row[1]; + $params[] = $row[2]; + } + + return $params; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Trigrams.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Trigrams.php new file mode 100644 index 00000000..7e90c832 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Trigrams.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Schema\Text; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterUtility; + +/** + * Extracts the distinct 3-character windows (trigrams) of a value for substring-index candidate narrowing. + * + * @author Chad Sikorra + */ +final class Trigrams +{ + private const SIZE = 3; + + /** + * Distinct trigrams of the folded (lowercased, truncated) value; empty for non-UTF-8 or values shorter than 3 chars. + * + * @return list + */ + public static function of(string $value): array + { + if (!Text::isUtf8($value)) { + return []; + } + + $folded = mb_substr( + mb_strtolower($value, 'UTF-8'), + 0, + SqlFilterUtility::MAX_INDEXED_VALUE_CHARS, + 'UTF-8', + ); + + $chars = mb_str_split($folded, 1, 'UTF-8'); + $count = count($chars); + + if ($count < self::SIZE) { + return []; + } + + $grams = []; + for ($i = 0; $i + self::SIZE <= $count; $i++) { + $grams[] = $chars[$i] . $chars[$i + 1] . $chars[$i + 2]; + } + + return array_values(array_unique($grams)); + } +} diff --git a/tests/integration/Storage/LdapBackendSqliteStorageTest.php b/tests/integration/Storage/LdapBackendSqliteStorageTest.php index bde9fc38..772eca21 100644 --- a/tests/integration/Storage/LdapBackendSqliteStorageTest.php +++ b/tests/integration/Storage/LdapBackendSqliteStorageTest.php @@ -19,11 +19,11 @@ use FreeDSx\Ldap\Search\Filters; /** - * Runs the full LdapBackendStorageTest suite against SqliteStorage, + * Runs the full LdapBackendStorageTest suite against the SQLite-backed PdoStorage, * and adds a test that verifies writes persist across separate client connections. * * Uses the same ldap-backend-storage.php bootstrap script with the 'sqlite' handler, - * which seeds a SqliteStorage and recreates the database file on each startup. + * which seeds a SQLite-backed PdoStorage and recreates the database file on each startup. * * Each mutating test restarts the server so the database is recreated cleanly, * preventing cross-test pollution. diff --git a/tests/integration/Sync/SyncReplPcntlPersistTest.php b/tests/integration/Sync/SyncReplPcntlPersistTest.php index 3be20c3e..693f70fc 100644 --- a/tests/integration/Sync/SyncReplPcntlPersistTest.php +++ b/tests/integration/Sync/SyncReplPcntlPersistTest.php @@ -14,7 +14,7 @@ namespace Tests\Integration\FreeDSx\Ldap\Sync; /** - * refreshAndPersist under the PCNTL runner, with SqliteStorage supplying the cross-process journal. + * refreshAndPersist under the PCNTL runner, with the SQLite-backed PdoStorage supplying the cross-process journal. */ final class SyncReplPcntlPersistTest extends SyncReplPersistTestCase { diff --git a/tests/performance/Server/ServerManager.php b/tests/performance/Server/ServerManager.php index 6bd2ebd6..b887c4ad 100644 --- a/tests/performance/Server/ServerManager.php +++ b/tests/performance/Server/ServerManager.php @@ -46,7 +46,7 @@ public function start(): void throw new RuntimeException('ServerManager::start() called twice.'); } - $command = ['php', '-dpcov.enabled=0']; + $command = ['php', '-dpcov.enabled=0', '-dmemory_limit=-1']; if ($this->config->jit) { $command[] = '-dopcache.enable_cli=1'; $command[] = '-dopcache.jit_buffer_size=128M'; diff --git a/tests/performance/Workload/Worker.php b/tests/performance/Workload/Worker.php index 9e1756b5..47fd09d5 100644 --- a/tests/performance/Workload/Worker.php +++ b/tests/performance/Workload/Worker.php @@ -171,6 +171,9 @@ private function dispatch(LdapClient $client, string $op): void 'search-read' => $this->doSearchRead($client), 'search-eq' => $this->doSearchEq($client), 'search-sub' => $this->doSearchSub($client), + 'search-substr' => $this->doSearchSubstr($client), + 'search-suffix' => $this->doSearchSuffix($client), + 'search-range' => $this->doSearchRange($client), 'search-list' => $this->doSearchList($client), 'compare' => $this->doCompare($client), 'add' => $this->doAdd($client), @@ -232,6 +235,53 @@ private function doSearchList(LdapClient $client): void $client->search($request); } + private function doSearchSubstr(LdapClient $client): void + { + $filter = $this->config->seedEntries > 0 + ? Filters::contains('mail', "seed-{$this->randomSeedIdx()}@") + : Filters::contains('mail', '@'); + + $client->search( + Operations::search($filter) + ->base($this->config->baseDn) + ->useSubtreeScope(), + ); + } + + private function doSearchSuffix(LdapClient $client): void + { + $filter = $this->config->seedEntries > 0 + ? Filters::endsWith('cn', "d-{$this->randomSeedIdx()}") + : Filters::endsWith('cn', 'e'); + + $client->search( + Operations::search($filter) + ->base($this->config->baseDn) + ->useSubtreeScope(), + ); + } + + private function doSearchRange(LdapClient $client): void + { + $threshold = $this->config->seedEntries > 0 + ? 1000 + max(1, $this->config->seedEntries - 99) + : 1000; + + $client->search( + Operations::search(Filters::greaterThanOrEqual('uidNumber', (string) $threshold)) + ->base($this->config->baseDn) + ->useSubtreeScope(), + ); + } + + private function randomSeedIdx(): int + { + return mt_rand( + 1, + $this->config->seedEntries, + ); + } + private function randomReadDn(): string { if ($this->config->seedEntries > 0 && mt_rand(1, 100) <= 80) { diff --git a/tests/performance/Workload/WorkloadMix.php b/tests/performance/Workload/WorkloadMix.php index 0f7d7dfa..f24568e3 100644 --- a/tests/performance/Workload/WorkloadMix.php +++ b/tests/performance/Workload/WorkloadMix.php @@ -25,6 +25,9 @@ final class WorkloadMix 'search-read', 'search-eq', 'search-sub', + 'search-substr', + 'search-suffix', + 'search-range', 'search-list', 'compare', 'add', diff --git a/tests/support/LdapBackendStorageCommand.php b/tests/support/LdapBackendStorageCommand.php index 74eeda8a..bfb32836 100644 --- a/tests/support/LdapBackendStorageCommand.php +++ b/tests/support/LdapBackendStorageCommand.php @@ -16,8 +16,8 @@ use FreeDSx\Ldap\Server\AccessControl\Target\Target; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter; use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\ServerOptions; @@ -263,8 +263,8 @@ protected function execute( } $adapter = $runner === 'swoole' - ? SqliteStorage::forSwoole($dbPath) - : SqliteStorage::forPcntl($dbPath); + ? PdoStorageFactory::forSwoole(PdoConfig::forSqlite($dbPath)) + : PdoStorageFactory::forPcntl(PdoConfig::forSqlite($dbPath)); $importer = new LdapImporter($adapter); @@ -286,13 +286,14 @@ protected function execute( $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); + $cleanup->exec('DROP TABLE IF EXISTS entry_attribute_trigrams'); $cleanup->exec('DROP TABLE IF EXISTS entry_attribute_values'); $cleanup->exec('DROP TABLE IF EXISTS entries'); unset($cleanup); $adapter = $runner === 'swoole' - ? MysqlStorage::forSwoole($dsn, $user, $password) - : MysqlStorage::forPcntl($dsn, $user, $password); + ? PdoStorageFactory::forSwoole(PdoConfig::forMysql($dsn, $user, $password)) + : PdoStorageFactory::forPcntl(PdoConfig::forMysql($dsn, $user, $password)); $importer = new LdapImporter($adapter); diff --git a/tests/support/LdapReplicaCommand.php b/tests/support/LdapReplicaCommand.php index 74d256aa..ee6c1b12 100644 --- a/tests/support/LdapReplicaCommand.php +++ b/tests/support/LdapReplicaCommand.php @@ -9,7 +9,8 @@ use FreeDSx\Ldap\Operations; use FreeDSx\Ldap\ReplicaConfig; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use FreeDSx\Ldap\ServerOptions; use Symfony\Component\Console\Command\Command; @@ -157,7 +158,7 @@ private function createReplicaStorage( } return $swoole - ? SqliteStorage::forSwoole($dbPath) - : SqliteStorage::forPcntl($dbPath); + ? PdoStorageFactory::forSwoole(PdoConfig::forSqlite($dbPath)) + : PdoStorageFactory::forPcntl(PdoConfig::forSqlite($dbPath)); } } diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index 1b9b88e6..5ca2aa7b 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -19,8 +19,8 @@ use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRules; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter; use FreeDSx\Ldap\ServerOptions; @@ -359,8 +359,8 @@ private function createStorage( } return $swoole - ? SqliteStorage::forSwoole($dbPath) - : SqliteStorage::forPcntl($dbPath); + ? PdoStorageFactory::forSwoole(PdoConfig::forSqlite($dbPath)) + : PdoStorageFactory::forPcntl(PdoConfig::forSqlite($dbPath)); } if ($storageType === 'mysql') { @@ -379,8 +379,8 @@ private function createStorage( unset($cleanup); return $swoole - ? MysqlStorage::forSwoole($dsn, $user, $password) - : MysqlStorage::forPcntl($dsn, $user, $password); + ? PdoStorageFactory::forSwoole(PdoConfig::forMysql($dsn, $user, $password)) + : PdoStorageFactory::forPcntl(PdoConfig::forMysql($dsn, $user, $password)); } return new InMemoryStorage(); diff --git a/tests/unit/Server/Backend/Storage/Adapter/MysqlStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/MysqlStorageTest.php deleted file mode 100644 index a3180738..00000000 --- a/tests/unit/Server/Backend/Storage/Adapter/MysqlStorageTest.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter; - -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; -use PHPUnit\Framework\TestCase; - -final class MysqlStorageTest extends TestCase -{ - public function test_schema_ddl_exports_the_mysql_baseline(): void - { - $ddl = MysqlStorage::schemaDdl(); - - self::assertStringContainsString( - 'CREATE TABLE IF NOT EXISTS entries', - $ddl, - ); - self::assertStringContainsString( - 'ENGINE=InnoDB', - $ddl, - ); - } -} diff --git a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php similarity index 91% rename from tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php rename to tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php index da5d503b..aa6f1184 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/SqliteStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/PdoStorageTest.php @@ -23,11 +23,15 @@ use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\MysqlDialect; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqliteFilterTranslator; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\TrigramSubstringIndex; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\SharedPdoConnectionProvider; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\FilterTranslatorInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoStorageFactory; use FreeDSx\Ldap\Protocol\Authorization\AuthzId; use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeJournalingInterface; use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; @@ -49,7 +53,7 @@ use RuntimeException; use Tests\Support\FreeDSx\Ldap\Journal\JournalingStorageContractTests; -final class SqliteStorageTest extends TestCase +final class PdoStorageTest extends TestCase { use JournalingStorageContractTests; @@ -67,7 +71,7 @@ protected function setUp(): void new Attribute('userPassword', 'secret'), ); - $this->storage = SqliteStorage::forPcntl(':memory:'); + $this->storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); $this->subject = new WritableStorageBackend($this->storage); $this->subject->add( new AddCommand( @@ -108,7 +112,7 @@ public function test_initialize_creates_the_baseline_schema(): void public function test_schema_ddl_exports_the_sqlite_baseline(): void { - $ddl = SqliteStorage::schemaDdl(); + $ddl = PdoStorage::schemaDdl(new SqliteDialect()); self::assertStringContainsString( 'CREATE TABLE IF NOT EXISTS entries', @@ -120,15 +124,79 @@ public function test_schema_ddl_exports_the_sqlite_baseline(): void ); } + public function test_schema_ddl_exports_the_mysql_baseline(): void + { + $ddl = PdoStorage::schemaDdl(new MysqlDialect()); + + self::assertStringContainsString( + 'CREATE TABLE IF NOT EXISTS entries', + $ddl, + ); + self::assertStringContainsString( + 'ENGINE=InnoDB', + $ddl, + ); + } + + public function test_initialize_with_a_substring_index_creates_its_table(): void + { + $pdo = new PDO('sqlite::memory:'); + + PdoStorage::initialize( + $pdo, + new SqliteDialect(), + new TrigramSubstringIndex(), + ); + + self::assertContains( + 'entry_attribute_trigrams', + $this->tableNames($pdo), + ); + } + + public function test_store_writes_trigram_rows_for_indexed_attributes(): void + { + $pdo = new PDO('sqlite::memory:'); + $index = new TrigramSubstringIndex(); + PdoStorage::initialize( + $pdo, + new SqliteDialect(), + $index, + ); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider( + $pdo, + fn(): PDO => $pdo, + ), + new SqliteFilterTranslator(), + new SqliteDialect(), + $index, + ); + $storage->store(new Entry( + new Dn('cn=Smith,dc=example,dc=com'), + new Attribute('cn', 'Smith'), + )); + + $count = $pdo->query( + "SELECT COUNT(*) FROM entry_attribute_trigrams WHERE trigram = 'smi'", + ); + self::assertNotFalse($count); + self::assertSame( + 1, + (int) $count->fetchColumn(), + ); + } + public function test_disabling_initialize_skips_schema_creation(): void { // A named shared-cache in-memory database, so a probe connection sees the same schema. $dsn = 'file:freedsx_init_off?mode=memory&cache=shared'; // Hold the storage's connection open so the shared in-memory database survives the probe read. - $storage = SqliteStorage::forPcntl( - $dsn, - false, + $storage = PdoStorageFactory::forPcntl( + PdoConfig::forSqlite($dsn) + ->setInitializeSchema(false), ); $probe = new PDO( @@ -178,7 +246,7 @@ public function test_get_returns_null_for_missing_dn(): void public function test_get_on_empty_database_returns_null(): void { - $storage = SqliteStorage::forPcntl(':memory:'); + $storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); $backend = new WritableStorageBackend($storage); self::assertNull($backend->get(new Dn('cn=Alice,dc=example,dc=com'))); @@ -432,7 +500,7 @@ public function test_search_matches_mixed_case_attribute_via_lowercase_filter(): public function test_search_inexact_filter_trips_lookthrough_limit(): void { - $storage = SqliteStorage::forPcntl(':memory:'); + $storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); $backend = new WritableStorageBackend( $storage, new SearchLimits(maxSearchLookthrough: 2), @@ -459,7 +527,7 @@ public function test_search_inexact_filter_trips_lookthrough_limit(): void public function test_search_exact_filter_is_not_subject_to_lookthrough(): void { - $storage = SqliteStorage::forPcntl(':memory:'); + $storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); $backend = new WritableStorageBackend( $storage, new SearchLimits(maxSearchLookthrough: 1), @@ -795,7 +863,7 @@ public function test_add_translates_dn_too_long_to_admin_limit_exceeded(): void public function test_subtree_does_not_match_escaped_comma_suffix_collision(): void { - $storage = SqliteStorage::forPcntl(':memory:'); + $storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); $backend = new WritableStorageBackend($storage); $base = new Entry( @@ -827,7 +895,7 @@ public function test_subtree_does_not_match_escaped_comma_suffix_collision(): vo public function test_subtree_includes_entries_with_escaped_comma_under_correct_parent(): void { - $storage = SqliteStorage::forPcntl(':memory:'); + $storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); $backend = new WritableStorageBackend($storage); $base = new Entry( @@ -916,7 +984,7 @@ public function test_naming_contexts_returns_entries_whose_parent_is_missing_in_ public function test_naming_contexts_is_empty_when_storage_is_empty(): void { - $emptyStorage = SqliteStorage::forPcntl(':memory:'); + $emptyStorage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); self::assertSame( [], @@ -926,7 +994,7 @@ public function test_naming_contexts_is_empty_when_storage_is_empty(): void public function test_a_journal_append_rolls_back_with_the_enclosing_write_transaction(): void { - $storage = SqliteStorage::forPcntl(':memory:'); + $storage = PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); $storage->configureJournal(new ChangeJournalConfig()); try { @@ -955,7 +1023,7 @@ public function test_a_journal_append_rolls_back_with_the_enclosing_write_transa protected function makeJournalingStorage(): ChangeJournalingInterface { - return SqliteStorage::forPcntl(':memory:'); + return PdoStorageFactory::forPcntl(PdoConfig::forSqlite(':memory:')); } /** diff --git a/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndexTest.php b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndexTest.php new file mode 100644 index 00000000..165bbbe6 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramSubstringIndexTest.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\TrigramSubstringIndex; +use PHPUnit\Framework\TestCase; + +final class TrigramSubstringIndexTest extends TestCase +{ + private const DN = 'cn=smith,dc=example,dc=com'; + + /** + * @var list}> + */ + private array $executed = []; + + protected function setUp(): void + { + $this->executed = []; + } + + public function test_maintain_deletes_then_inserts_the_trigram_rows(): void + { + (new TrigramSubstringIndex(['cn']))->maintain( + self::DN, + new Entry( + new Dn(self::DN), + new Attribute('cn', 'smith'), + ), + $this->recorder(), + ); + + self::assertStringContainsString( + 'DELETE FROM entry_attribute_trigrams', + $this->executed[0][0], + ); + self::assertSame( + [self::DN], + $this->executed[0][1], + ); + self::assertStringContainsString( + 'INSERT INTO entry_attribute_trigrams', + $this->executed[1][0], + ); + self::assertSame( + [ + self::DN, 'cn', 'smi', + self::DN, 'cn', 'mit', + self::DN, 'cn', 'ith', + ], + $this->executed[1][1], + ); + } + + public function test_maintain_only_deletes_when_no_indexed_values_exist(): void + { + (new TrigramSubstringIndex(['cn']))->maintain( + self::DN, + new Entry( + new Dn(self::DN), + new Attribute('uid', 'smith'), + ), + $this->recorder(), + ); + + self::assertCount( + 1, + $this->executed, + ); + self::assertStringContainsString( + 'DELETE FROM entry_attribute_trigrams', + $this->executed[0][0], + ); + } + + public function test_maintain_indexes_only_configured_attributes(): void + { + (new TrigramSubstringIndex(['cn']))->maintain( + self::DN, + new Entry( + new Dn(self::DN), + new Attribute('cn', 'abc'), + new Attribute('description', 'ignored'), + ), + $this->recorder(), + ); + + self::assertSame( + [self::DN, 'cn', 'abc'], + $this->executed[1][1], + ); + } + + public function test_maintain_pools_distinct_trigrams_across_values(): void + { + (new TrigramSubstringIndex(['cn']))->maintain( + self::DN, + new Entry( + new Dn(self::DN), + new Attribute('cn', 'smith', 'smithy'), + ), + $this->recorder(), + ); + + self::assertSame( + [ + self::DN, 'cn', 'smi', + self::DN, 'cn', 'mit', + self::DN, 'cn', 'ith', + self::DN, 'cn', 'thy', + ], + $this->executed[1][1], + ); + } + + public function test_schema_statements_load_the_trigram_schema(): void + { + $statements = (new TrigramSubstringIndex())->schemaStatements(new SqliteDialect()); + + self::assertStringContainsString( + 'entry_attribute_trigrams', + implode("\n", $statements), + ); + } + + /** + * @return callable(string, list): void + */ + private function recorder(): callable + { + return function (string $sql, array $params): void { + $this->executed[] = [$sql, $params]; + }; + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramsTest.php b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramsTest.php new file mode 100644 index 00000000..f85a1f38 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/TrigramsTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\Trigrams; +use PHPUnit\Framework\TestCase; + +final class TrigramsTest extends TestCase +{ + public function test_it_extracts_distinct_trigrams(): void + { + self::assertSame( + ['smi', 'mit', 'ith'], + Trigrams::of('smith'), + ); + } + + public function test_it_lowercases_before_extracting(): void + { + self::assertSame( + ['smi', 'mit', 'ith'], + Trigrams::of('SMITH'), + ); + } + + public function test_it_returns_empty_for_values_shorter_than_three_chars(): void + { + self::assertSame( + [], + Trigrams::of('ab'), + ); + self::assertSame( + [], + Trigrams::of(''), + ); + } + + public function test_it_deduplicates_repeated_trigrams(): void + { + self::assertSame( + ['aaa'], + Trigrams::of('aaaa'), + ); + } + + public function test_it_preserves_numeric_trigrams_as_strings(): void + { + self::assertSame( + ['012', '123'], + Trigrams::of('0123'), + ); + } + + public function test_it_windows_multibyte_characters_by_code_point(): void + { + self::assertSame( + ['naï', 'aïv', 'ïve'], + Trigrams::of('naïve'), + ); + } + + public function test_it_returns_empty_for_non_utf8_values(): void + { + self::assertSame( + [], + Trigrams::of("\xff\xfe\xfd\xfc"), + ); + } +}