diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 000000000..54ccb7e9b --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,6 @@ +{ + "MD007": { "indent": 4 }, + "MD013": false, + "MD033": false, + "MD060": false +} \ No newline at end of file diff --git a/docs/book/adapter.md b/docs/book/adapter.md index d68dbc18e..28b7acaad 100644 --- a/docs/book/adapter.md +++ b/docs/book/adapter.md @@ -1,179 +1,128 @@ # Adapters -`PhpDb\Adapter\Adapter` is the central object of the laminas-db component. It is -responsible for adapting any code written in or for laminas-db to the targeted PHP -extensions and vendor databases. In doing this, it creates an abstraction layer -for the PHP extensions in the `Driver` subnamespace of `PhpDb\Adapter`. It -also creates a lightweight "Platform" abstraction layer, for the various -idiosyncrasies that each vendor-specific platform might have in its SQL/RDBMS -implementation, separate from the driver implementations. +`PhpDb\Adapter\Adapter` is the central component that provides a unified +interface to different PHP PDO extensions and database vendors. It abstracts +both the database driver (connection management) and platform-specific SQL +dialects. -## Creating an adapter using configuration +## Package Architecture -Create an adapter by instantiating the `PhpDb\Adapter\Adapter` class. The most -common use case, while not the most explicit, is to pass an array of -configuration to the `Adapter`: +Starting with version 0.4.x, PhpDb uses a modular package architecture. The core +`php-db/phpdb` package provides: -```php -use PhpDb\Adapter\Adapter; +- Base adapter and interfaces +- Abstract PDO driver classes +- Platform abstractions +- SQL abstraction layer +- Result set handling +- Table and Row gateway implementations -$adapter = new Adapter($configArray); -``` +Database-specific drivers are provided as separate packages: -This driver array is an abstraction for the extension level required parameters. -Here is a table for the key-value pairs that should be in configuration array. - -Key | Is Required? | Value ----------- | ---------------------- | ----- -`driver` | required | `Mysqli`, `Sqlsrv`, `Pdo_Sqlite`, `Pdo_Mysql`, `Pdo`(= Other PDO Driver) -`database` | generally required | the name of the database (schema) -`username` | generally required | the connection username -`password` | generally required | the connection password -`hostname` | not generally required | the IP address or hostname to connect to -`port` | not generally required | the port to connect to (if applicable) -`charset` | not generally required | the character set to use - -> ### Options are adapter-dependent -> -> Other names will work as well. Effectively, if the PHP manual uses a -> particular naming, this naming will be supported by the associated driver. For -> example, `dbname` in most cases will also work for 'database'. Another -> example is that in the case of `Sqlsrv`, `UID` will work in place of -> `username`. Which format you choose is up to you, but the above table -> represents the official abstraction names. - -For example, a MySQL connection using ext/mysqli: - -```php -$adapter = new PhpDb\Adapter\Adapter([ - 'driver' => 'Mysqli', - 'database' => 'laminas_db_example', - 'username' => 'developer', - 'password' => 'developer-password', -]); -``` +| Package | Database | Status | +| ------- | -------- | ------ | +| `php-db/mysql` | MySQL/MariaDB | Available | +| `php-db/sqlite` | SQLite | Available | +| `php-db/postgres` | PostgreSQL | Coming Soon | -Another example, of a Sqlite connection via PDO: +## Quick Start -```php -$adapter = new PhpDb\Adapter\Adapter([ - 'driver' => 'Pdo_Sqlite', - 'database' => 'path/to/sqlite.db', +```php title="MySQL Connection" +use PhpDb\Adapter\Adapter; +use PhpDb\Mysql\Driver\Mysql; +use PhpDb\Mysql\Platform\Mysql as MysqlPlatform; + +$driver = new Mysql([ + 'database' => 'my_database', + 'username' => 'my_user', + 'password' => 'my_password', + 'hostname' => 'localhost', ]); -``` -Another example, of an IBM i DB2 connection via IbmDb2: - -```php -$adapter = new PhpDb\Adapter\Adapter([ - 'database' => '*LOCAL', // or name from WRKRDBDIRE, may be serial # - 'driver' => 'IbmDb2', - 'driver_options' => [ - 'autocommit' => DB2_AUTOCOMMIT_ON, - 'i5_naming' => DB2_I5_NAMING_ON, - 'i5_libl' => 'SCHEMA1 SCHEMA2 SCHEMA3', - ], - 'username' => '__USER__', - 'password' => '__PASS__', - // 'persistent' => true, - 'platform' => 'IbmDb2', - 'platform_options' => ['quote_identifiers' => false], -]); +$adapter = new Adapter($driver, new MysqlPlatform()); ``` -Another example, of an IBM i DB2 connection via PDO: - -```php -$adapter = new PhpDb\Adapter\Adapter([ - 'dsn' => 'ibm:DB_NAME', // DB_NAME is from WRKRDBDIRE, may be serial # - 'driver' => 'pdo', - 'driver_options' => [ - // PDO::ATTR_PERSISTENT => true, - PDO::ATTR_AUTOCOMMIT => true, - PDO::I5_ATTR_DBC_SYS_NAMING => true, - PDO::I5_ATTR_DBC_CURLIB => '', - PDO::I5_ATTR_DBC_LIBL => 'SCHEMA1 SCHEMA2 SCHEMA3', - ], - 'username' => '__USER__', - 'password' => '__PASS__', - 'platform' => 'IbmDb2', - 'platform_options' => ['quote_identifiers' => false], +```php title="SQLite Connection" +use PhpDb\Adapter\Adapter; +use PhpDb\Sqlite\Driver\Sqlite; +use PhpDb\Sqlite\Platform\Sqlite as SqlitePlatform; + +$driver = new Sqlite([ + 'database' => '/path/to/database.sqlite', ]); + +$adapter = new Adapter($driver, new SqlitePlatform()); ``` -It is important to know that by using this style of adapter creation, the -`Adapter` will attempt to create any dependencies that were not explicitly -provided. A `Driver` object will be created from the configuration array -provided in the constructor. A `Platform` object will be created based off the -type of `Driver` class that was instantiated. And lastly, a default `ResultSet` -object is created and utilized. Any of these objects can be injected, to do -this, see the next section. - -The list of officially supported drivers: - -- `IbmDb2`: The ext/ibm_db2 driver -- `Mysqli`: The ext/mysqli driver -- `Oci8`: The ext/oci8 driver -- `Pgsql`: The ext/pgsql driver -- `Sqlsrv`: The ext/sqlsrv driver (from Microsoft) -- `Pdo_Mysql`: MySQL via the PDO extension -- `Pdo_Sqlite`: SQLite via the PDO extension -- `Pdo_Pgsql`: PostgreSQL via the PDO extension - -## Creating an adapter using dependency injection - -The more mezzio and explicit way of creating an adapter is by injecting all -your dependencies up front. `PhpDb\Adapter\Adapter` uses constructor -injection, and all required dependencies are injected through the constructor, -which has the following signature (in pseudo-code): - -```php -use PhpDb\Adapter\Platform\PlatformInterface; -use PhpDb\ResultSet\ResultSet; - -class PhpDb\Adapter\Adapter +## The Adapter Class + +The `Adapter` class provides the primary interface for database operations: + +```php title="Adapter Class Interface" +namespace PhpDb\Adapter; + +use PhpDb\ResultSet; + +class Adapter implements AdapterInterface, Profiler\ProfilerAwareInterface, SchemaAwareInterface { public function __construct( - $driver, - PlatformInterface $platform = null, - ResultSet $queryResultSetPrototype = null + Driver\DriverInterface $driver, + Platform\PlatformInterface $platform, + ResultSet\ResultSetInterface $queryResultSetPrototype = new ResultSet\ResultSet(), + ?Profiler\ProfilerInterface $profiler = null ); + + public function getDriver(): Driver\DriverInterface; + public function getPlatform(): Platform\PlatformInterface; + public function getProfiler(): ?Profiler\ProfilerInterface; + public function getQueryResultSetPrototype(): ResultSet\ResultSetInterface; + public function getCurrentSchema(): string|false; + + public function query( + string $sql, + ParameterContainer|array|string $parametersOrQueryMode = self::QUERY_MODE_PREPARE, + ?ResultSet\ResultSetInterface $resultPrototype = null + ): Driver\StatementInterface|ResultSet\ResultSet|Driver\ResultInterface; + + public function createStatement( + ?string $initialSql = null, + ParameterContainer|array|null $initialParameters = null + ): Driver\StatementInterface; } ``` -What can be injected: +### Constructor Parameters -- `$driver`: an array of connection parameters (see above) or an instance of - `PhpDb\Adapter\Driver\DriverInterface`. -- `$platform` (optional): an instance of `PhpDb\Platform\PlatformInterface`; - the default will be created based off the driver implementation. -- `$queryResultSetPrototype` (optional): an instance of - `PhpDb\ResultSet\ResultSet`; to understand this object's role, see the - section below on querying. +- **`$driver`**: A `DriverInterface` implementation from a driver package + (e.g., `PhpDb\Mysql\Driver\Mysql`) +- **`$platform`**: A `PlatformInterface` implementation for SQL dialect + handling +- **`$queryResultSetPrototype`** (optional): Custom `ResultSetInterface` for + query results +- **`$profiler`** (optional): A profiler for query logging and performance + analysis ## Query Preparation By default, `PhpDb\Adapter\Adapter::query()` prefers that you use -"preparation" as a means for processing SQL statements. This generally means +"preparation" as a means for processing SQL statements. This generally means that you will supply a SQL statement containing placeholders for the values, and -separately provide substitutions for those placeholders. As an example: +separately provide substitutions for those placeholders: -```php +```php title="Query with Prepared Statement" $adapter->query('SELECT * FROM `artist` WHERE `id` = ?', [5]); ``` The above example will go through the following steps: -- create a new `Statement` object. -- prepare the array `[5]` into a `ParameterContainer` if necessary. -- inject the `ParameterContainer` into the `Statement` object. -- execute the `Statement` object, producing a `Result` object. -- check the `Result` object to check if the supplied SQL was a result set - producing statement: - - if the query produced a result set, clone the `ResultSet` prototype, - inject the `Result` as its datasource, and return the new `ResultSet` - instance. - - otherwise, return the `Result`. +1. Create a new `Statement` object +2. Prepare the array `[5]` into a `ParameterContainer` if necessary +3. Inject the `ParameterContainer` into the `Statement` object +4. Execute the `Statement` object, producing a `Result` object +5. Check the `Result` object to check if the supplied SQL was a result set + producing statement. If the query produced a result set, clone the + `ResultSet` prototype, inject the `Result` as its datasource, and return + the new `ResultSet` instance. Otherwise, return the `Result`. ## Query Execution @@ -181,10 +130,10 @@ In some cases, you have to execute statements directly without preparation. One possible reason for doing so would be to execute a DDL statement, as most extensions and RDBMS systems are incapable of preparing such statements. -To execute a query without the preparation step, you will need to pass a flag as +To execute a query without the preparation step, pass a flag as the second argument indicating execution is required: -```php +```php title="Executing DDL Statement Without Preparation" $adapter->query( 'ALTER TABLE ADD INDEX(`foo_index`) ON (`foo_column`)', Adapter::QUERY_MODE_EXECUTE @@ -199,12 +148,9 @@ The primary difference to notice is that you must provide the While `query()` is highly useful for one-off and quick querying of a database via the `Adapter`, it generally makes more sense to create a statement and interact with it directly, so that you have greater control over the -prepare-then-execute workflow. To do this, `Adapter` gives you a routine called -`createStatement()` that allows you to create a `Driver` specific `Statement` to -use so you can manage your own prepare-then-execute workflow. +prepare-then-execute workflow: -```php -// with optional parameters to bind up-front: +```php title="Creating and Executing a Statement" $statement = $adapter->createStatement($sql, $optionalParameters); $result = $statement->execute(); ``` @@ -212,84 +158,71 @@ $result = $statement->execute(); ## Using the Driver Object The `Driver` object is the primary place where `PhpDb\Adapter\Adapter` -implements the connection level abstraction specific to a given extension. To -make this possible, each driver is composed of 3 objects: +implements the connection level abstraction specific to a given extension. Each +driver is composed of three objects: - A connection: `PhpDb\Adapter\Driver\ConnectionInterface` - A statement: `PhpDb\Adapter\Driver\StatementInterface` - A result: `PhpDb\Adapter\Driver\ResultInterface` -Each of the built-in drivers practice "prototyping" as a means of creating -objects when new instances are requested. The workflow looks like this: - -- An adapter is created with a set of connection parameters. -- The adapter chooses the proper driver to instantiate (for example, - `PhpDb\Adapter\Driver\Mysqli`) -- That driver class is instantiated. -- If no connection, statement, or result objects are injected, defaults are - instantiated. - -This driver is now ready to be called on when particular workflows are -requested. Here is what the `Driver` API looks like: - -```php +```php title="Driver Interface Definition" namespace PhpDb\Adapter\Driver; interface DriverInterface { - const PARAMETERIZATION_POSITIONAL = 'positional'; - const PARAMETERIZATION_NAMED = 'named'; - const NAME_FORMAT_CAMELCASE = 'camelCase'; - const NAME_FORMAT_NATURAL = 'natural'; - - public function getDatabasePlatformName(string $nameFormat = self::NAME_FORMAT_CAMELCASE) : string; - public function checkEnvironment() : bool; - public function getConnection() : ConnectionInterface; - public function createStatement(string|resource $sqlOrResource = null) : StatementInterface; - public function createResult(resource $resource) : ResultInterface; - public function getPrepareType() :string; - public function formatParameterName(string $name, $type = null) : string; - public function getLastGeneratedValue() : mixed; + public const PARAMETERIZATION_POSITIONAL = 'positional'; + public const PARAMETERIZATION_NAMED = 'named'; + public const NAME_FORMAT_CAMELCASE = 'camelCase'; + public const NAME_FORMAT_NATURAL = 'natural'; + + public function getDatabasePlatformName( + string $nameFormat = self::NAME_FORMAT_CAMELCASE + ): string; + public function checkEnvironment(): bool; + public function getConnection(): ConnectionInterface; + public function createStatement($sqlOrResource = null): StatementInterface; + public function createResult($resource): ResultInterface; + public function getPrepareType(): string; + public function formatParameterName(string $name, ?string $type = null): string; + public function getLastGeneratedValue(): int|string|bool|null; } ``` -From this `DriverInterface`, you can +From this `DriverInterface`, you can: - Determine the name of the platform this driver supports (useful for choosing - the proper platform object). -- Check that the environment can support this driver. -- Return the `Connection` instance. + the proper platform object) +- Check that the environment can support this driver +- Return the `Connection` instance - Create a `Statement` instance which is optionally seeded by an SQL statement - (this will generally be a clone of a prototypical statement object). + (this will generally be a clone of a prototypical statement object) - Create a `Result` object which is optionally seeded by a statement resource (this will generally be a clone of a prototypical result object) - Format parameter names; this is important to distinguish the difference between the various ways parameters are named between extensions -- Retrieve the overall last generated value (such as an auto-increment value). - -Now let's turn to the `Statement` API: +- Retrieve the overall last generated value (such as an auto-increment value) -```php +```php title="Statement Interface Definition" namespace PhpDb\Adapter\Driver; interface StatementInterface extends StatementContainerInterface { - public function getResource() : resource; - public function prepare($sql = null) : void; - public function isPrepared() : bool; - public function execute(null|array|ParameterContainer $parameters = null) : ResultInterface; + public function getResource(): mixed; + public function prepare(?string $sql = null): void; + public function isPrepared(): bool; + public function execute(?array|ParameterContainer $parameters = null): ResultInterface; /** Inherited from StatementContainerInterface */ - public function setSql(string $sql) : void; - public function getSql() : string; - public function setParameterContainer(ParameterContainer $parameterContainer) : void; - public function getParameterContainer() : ParameterContainer; + public function setSql(string $sql): void; + public function getSql(): string; + public function setParameterContainer( + ParameterContainer $parameterContainer + ): void; + public function getParameterContainer(): ParameterContainer; } ``` -And finally, the `Result` API: - -```php +```php title="Result Interface Definition" namespace PhpDb\Adapter\Driver; use Countable; @@ -297,12 +230,12 @@ use Iterator; interface ResultInterface extends Countable, Iterator { - public function buffer() : void; - public function isQueryResult() : bool; - public function getAffectedRows() : int; - public function getGeneratedValue() : mixed; - public function getResource() : resource; - public function getFieldCount() : int; + public function buffer(): void; + public function isQueryResult(): bool; + public function getAffectedRows(): int; + public function getGeneratedValue(): mixed; + public function getResource(): mixed; + public function getFieldCount(): int; } ``` @@ -311,24 +244,26 @@ interface ResultInterface extends Countable, Iterator The `Platform` object provides an API to assist in crafting queries in a way that is specific to the SQL implementation of a particular vendor. The object handles nuances such as how identifiers or values are quoted, or what the -identifier separator character is. To get an idea of the capabilities, the -interface for a platform object looks like this: +identifier separator character is: -```php +```php title="Platform Interface Definition" namespace PhpDb\Adapter\Platform; interface PlatformInterface { - public function getName() : string; - public function getQuoteIdentifierSymbol() : string; - public function quoteIdentifier(string $identifier) : string; - public function quoteIdentifierChain(string|string[] $identiferChain) : string; - public function getQuoteValueSymbol() : string; - public function quoteValue(string $value) : string; - public function quoteTrustedValue(string $value) : string; - public function quoteValueList(string|string[] $valueList) : string; - public function getIdentifierSeparator() : string; - public function quoteIdentifierInFragment(string $identifier, array $additionalSafeWords = []) : string; + public function getName(): string; + public function getQuoteIdentifierSymbol(): string; + public function quoteIdentifier(string $identifier): string; + public function quoteIdentifierChain(array|string $identifierChain): string; + public function getQuoteValueSymbol(): string; + public function quoteValue(string $value): string; + public function quoteTrustedValue(int|float|string|bool $value): ?string; + public function quoteValueList(array|string $valueList): string; + public function getIdentifierSeparator(): string; + public function quoteIdentifierInFragment( + string $identifier, + array $additionalSafeWords = [] + ): string; } ``` @@ -336,18 +271,14 @@ While you can directly instantiate a `Platform` object, generally speaking, it is easier to get the proper `Platform` instance from the configured adapter (by default the `Platform` type will match the underlying driver implementation): -```php +```php title="Getting Platform from Adapter" $platform = $adapter->getPlatform(); // or $platform = $adapter->platform; // magic property access ``` -The following are examples of `Platform` usage: - -```php -// $adapter is a PhpDb\Adapter\Adapter instance; -// $platform is a PhpDb\Adapter\Platform\Sql92 instance. +```php title="Quoting Identifiers and Values" $platform = $adapter->getPlatform(); // "first_name" @@ -365,7 +296,7 @@ echo $platform->getQuoteValueSymbol(); // 'myvalue' echo $platform->quoteValue('myvalue'); -// 'value', 'Foo O\\'Bar' +// 'value', 'Foo O\'Bar' echo $platform->quoteValueList(['value', "Foo O'Bar"]); // . @@ -383,10 +314,9 @@ echo $platform->quoteIdentifierInFragment('(foo.bar = boo.baz)', ['(', ')', '='] The `ParameterContainer` object is a container for the various parameters that need to be passed into a `Statement` object to fulfill all the various -parameterized parts of the SQL statement. This object implements the -`ArrayAccess` interface. Below is the `ParameterContainer` API: +parameterized parts of the SQL statement: -```php +```php title="ParameterContainer Class Interface" namespace PhpDb\Adapter; use ArrayAccess; @@ -396,72 +326,124 @@ use Iterator; class ParameterContainer implements Iterator, ArrayAccess, Countable { - public function __construct(array $data = []) - - /** methods to interact with values */ - public function offsetExists(string|int $name) : bool; - public function offsetGet(string|int $name) : mixed; - public function offsetSetReference(string|int $name, string|int $from) : void; - public function offsetSet(string|int $name, mixed $value, mixed $errata = null, int $maxLength = null) : void; - public function offsetUnset(string|int $name) : void; - - /** set values from array (will reset first) */ - public function setFromArray(array $data) : ParameterContainer; - - /** methods to interact with value errata */ - public function offsetSetErrata(string|int $name, mixed $errata) : void; - public function offsetGetErrata(string|int $name) : mixed; - public function offsetHasErrata(string|int $name) : bool; - public function offsetUnsetErrata(string|int $name) : void; - - /** errata only iterator */ - public function getErrataIterator() : ArrayIterator; - - /** get array with named keys */ - public function getNamedArray() : array; - - /** get array with int keys, ordered by position */ - public function getPositionalArray() : array; - - /** iterator: */ - public function count() : int; - public function current() : mixed; - public function next() : mixed; - public function key() : string|int; - public function valid() : bool; - public function rewind() : void; - - /** merge existing array of parameters with existing parameters */ - public function merge(array $parameters) : ParameterContainer; + public function __construct(array $data = []); + + /** Methods to interact with values */ + public function offsetExists(string|int $name): bool; + public function offsetGet(string|int $name): mixed; + public function offsetSetReference(string|int $name, string|int $from): void; + public function offsetSet( + string|int $name, + mixed $value, + mixed $errata = null, + int $maxLength = null + ): void; + public function offsetUnset(string|int $name): void; + + /** Set values from array (will reset first) */ + public function setFromArray(array $data): ParameterContainer; + + /** Methods to interact with value errata */ + public function offsetSetErrata(string|int $name, mixed $errata): void; + public function offsetGetErrata(string|int $name): mixed; + public function offsetHasErrata(string|int $name): bool; + public function offsetUnsetErrata(string|int $name): void; + + /** Errata only iterator */ + public function getErrataIterator(): ArrayIterator; + + /** Get array with named keys */ + public function getNamedArray(): array; + + /** Get array with int keys, ordered by position */ + public function getPositionalArray(): array; + + /** Iterator methods */ + public function count(): int; + public function current(): mixed; + public function next(): void; + public function key(): string|int; + public function valid(): bool; + public function rewind(): void; + + /** Merge existing array of parameters with existing parameters */ + public function merge(array $parameters): ParameterContainer; } ``` +### Parameter Type Binding + In addition to handling parameter names and values, the container will assist in -tracking parameter types for PHP type to SQL type handling. For example, it -might be important that: +tracking parameter types for PHP type to SQL type handling: -```php +```php title="Setting Parameter Without Type" $container->offsetSet('limit', 5); ``` -be bound as an integer. To achieve this, pass in the -`ParameterContainer::TYPE_INTEGER` constant as the 3rd parameter: +To bind as an integer, pass the `ParameterContainer::TYPE_INTEGER` constant as +the 3rd parameter: -```php +```php title="Setting Parameter with Type Binding" $container->offsetSet('limit', 5, $container::TYPE_INTEGER); ``` This will ensure that if the underlying driver supports typing of bound parameters, that this translated information will also be passed along to the -actual php database driver. +actual PHP database driver. + +## Driver Features + +Drivers can provide optional features through the `DriverFeatureProviderInterface`: + +```php title="DriverFeatureProviderInterface Definition" +namespace PhpDb\Adapter\Driver\Feature; + +interface DriverFeatureProviderInterface +{ + /** @param DriverFeatureInterface[] $features */ + public function addFeatures(array $features): DriverFeatureProviderInterface; + public function addFeature(DriverFeatureInterface $feature): DriverFeatureProviderInterface; + public function getFeature(string $name): DriverFeatureInterface|false; +} +``` + +Features allow driver packages to extend functionality without modifying the core +interfaces. Each driver package may define its own features specific to the +database platform. + +## Profiling + +The adapter supports profiling through the `ProfilerInterface`: -## Examples +```php title="Setting Up a Profiler" +use PhpDb\Adapter\Profiler\Profiler; -Creating a `Driver`, a vendor-portable query, and preparing and iterating the -result: +$profiler = new Profiler(); +$adapter = new Adapter($driver, $platform, profiler: $profiler); -```php -$adapter = new PhpDb\Adapter\Adapter($driverConfig); +// Execute queries... +$result = $adapter->query('SELECT * FROM users'); + +// Get profiler data +$profiles = $profiler->getProfiles(); +``` + +## Complete Example + +Creating a driver, a vendor-portable query, and preparing and iterating the result: + +```php title="Full Workflow Example with Adapter" +use PhpDb\Adapter\Adapter; +use PhpDb\Mysql\Driver\Mysql; +use PhpDb\Mysql\Platform\Mysql as MysqlPlatform; + +$driver = new Mysql([ + 'database' => 'my_database', + 'username' => 'my_user', + 'password' => 'my_password', +]); + +$adapter = new Adapter($driver, new MysqlPlatform()); $qi = function ($name) use ($adapter) { return $adapter->platform->quoteIdentifier($name); @@ -483,8 +465,7 @@ $parameters = [ $statement->execute($parameters); -// DATA INSERTED, NOW CHECK - +// DATA UPDATED, NOW CHECK $statement = $adapter->query( 'SELECT * FROM ' . $qi('artist') diff --git a/docs/book/adapters/adapter-aware-trait.md b/docs/book/adapters/adapter-aware-trait.md index 454bfd322..afb823578 100644 --- a/docs/book/adapters/adapter-aware-trait.md +++ b/docs/book/adapters/adapter-aware-trait.md @@ -1,12 +1,7 @@ # AdapterAwareTrait -The trait `PhpDb\Adapter\AdapterAwareTrait`, which provides implementation -for `PhpDb\Adapter\AdapterAwareInterface`, and allowed removal of -duplicated implementations in several components of Laminas or in custom -applications. - -The interface defines only the method `setDbAdapter()` with one parameter for an -instance of `PhpDb\Adapter\Adapter`: +`PhpDb\Adapter\AdapterAwareTrait` provides a standard implementation of +`AdapterAwareInterface` for injecting database adapters into your classes. ```php public function setDbAdapter(\PhpDb\Adapter\Adapter $adapter) : self; @@ -14,8 +9,6 @@ public function setDbAdapter(\PhpDb\Adapter\Adapter $adapter) : self; ## Basic Usage -### Create Class and Add Trait - ```php use PhpDb\Adapter\AdapterAwareTrait; use PhpDb\Adapter\AdapterAwareInterface; @@ -24,28 +17,17 @@ class Example implements AdapterAwareInterface { use AdapterAwareTrait; } -``` - -### Create and Set Adapter - -[Create a database adapter](../adapter.md#creating-an-adapter-using-configuration) and set the adapter to the instance of the `Example` -class: - -```php -$adapter = new PhpDb\Adapter\Adapter([ - 'driver' => 'Pdo_Sqlite', - 'database' => 'path/to/sqlite.db', -]); +// Set adapter (see adapter.md for creation) $example = new Example(); -$example->setAdapter($adapter); +$example->setDbAdapter($adapter); ``` ## AdapterServiceDelegator The [delegator](https://docs.laminas.dev/laminas-servicemanager/delegators/) -`PhpDb\Adapter\AdapterServiceDelegator` can be used to set a database -adapter via the [service manager of laminas-servicemanager](https://docs.laminas.dev/laminas-servicemanager/quick-start/). +`PhpDb\Adapter\AdapterServiceDelegator` can be used to set a database adapter +via the [service manager of laminas-servicemanager](https://docs.laminas.dev/laminas-servicemanager/quick-start/). The delegator tries to fetch a database adapter via the name `PhpDb\Adapter\AdapterInterface` from the service container and sets the @@ -54,8 +36,9 @@ adapter to the requested service. The adapter itself must be an instance of > ### Integration for Mezzio and laminas-mvc based Applications > -> In a Mezzio or laminas-mvc based application the database adapter is already -> registered during the installation with the laminas-component-installer. +> In a Mezzio or laminas-mvc based application the database adapter is +> already registered during the installation with the +> laminas-component-installer. ### Create Class and Use Trait @@ -80,23 +63,29 @@ class Example implements AdapterAwareInterface ### Create and Configure Service Manager -Create and [configured the service manager](https://docs.laminas.dev/laminas-servicemanager/configuring-the-service-manager/): +Create and [configure the service manager]( +https://docs.laminas.dev/laminas-servicemanager/configuring-the-service-manager/): ```php -use Interop\Container\ContainerInterface; +use Psr\Container\ContainerInterface; +use PhpDb\Adapter\Adapter; use PhpDb\Adapter\AdapterInterface; use PhpDb\Adapter\AdapterServiceDelegator; use PhpDb\Adapter\AdapterAwareTrait; use PhpDb\Adapter\AdapterAwareInterface; +use PhpDb\Sqlite\Driver\Sqlite; +use PhpDb\Sqlite\Platform\Sqlite as SqlitePlatform; $serviceManager = new Laminas\ServiceManager\ServiceManager([ 'factories' => [ // Database adapter - AdapterInterface::class => static function(ContainerInterface $container) { - return new PhpDb\Adapter\Adapter([ - 'driver' => 'Pdo_Sqlite', + AdapterInterface::class => static function( + ContainerInterface $container + ) { + $driver = new Sqlite([ 'database' => 'path/to/sqlite.db', ]); + return new Adapter($driver, new SqlitePlatform()); } ], 'invokables' => [ @@ -114,18 +103,19 @@ $serviceManager = new Laminas\ServiceManager\ServiceManager([ ### Get Instance of Class -[Retrieving an instance](https://docs.laminas.dev/laminas-servicemanager/quick-start/#3-retrieving-objects) +[Retrieving an instance]( +https://docs.laminas.dev/laminas-servicemanager/quick-start/#3-retrieving-objects) of the `Example` class with a database adapter: ```php /** @var Example $example */ $example = $serviceManager->get(Example::class); -var_dump($example->getAdapter() instanceof PhpDb\Adapter\Adapter); // true +var_dump( + $example->getAdapter() instanceof PhpDb\Adapter\AdapterInterface +); // true ``` -## Concrete Implementations - -The validators [`Db\RecordExists` and `Db\NoRecordExists`](https://docs.laminas.dev/laminas-validator/validators/db/) -implements the trait and the plugin manager of [laminas-validator](https://docs.laminas.dev/laminas-validator/) -includes the delegator to set the database adapter for both validators. +The [laminas-validator]( +https://docs.laminas.dev/laminas-validator/validators/db/) +`Db\RecordExists` and `Db\NoRecordExists` validators use this pattern. diff --git a/docs/book/application-integration/usage-in-a-laminas-mvc-application.md b/docs/book/application-integration/usage-in-a-laminas-mvc-application.md index 6a5e51242..da7d49ebd 100644 --- a/docs/book/application-integration/usage-in-a-laminas-mvc-application.md +++ b/docs/book/application-integration/usage-in-a-laminas-mvc-application.md @@ -1,64 +1,81 @@ # Usage in a laminas-mvc Application -The minimal installation for a laminas-mvc based application doesn't include any database features. +For installation instructions, see [Installation](../index.md#installation). -## When installing the Laminas MVC Skeleton Application +## Configuration -While `Composer` is [installing the MVC Application](https://docs.laminas.dev/laminas-mvc/quick-start/#install-the-laminas-mvc-skeleton-application), you can add the `laminas-db` package while prompted. +The adapter factory is already wired into the service manager. You only +need to provide the `db` configuration in `config/autoload/db.global.php`: -## Adding to an existing Laminas MVC Skeleton Application +```php title="config/autoload/db.global.php" + [ - 'driver' => 'Pdo', - 'adapters' => [ - sqliteAdapter::class => [ - 'driver' => 'Pdo', - 'dsn' => 'sqlite:data/sample.sqlite', - ], + 'driver' => Pdo::class, + 'connection' => [ + 'hostname' => (string) getenv('DB_HOSTNAME') ?: 'localhost', + 'username' => (string) getenv('DB_USERNAME'), + 'password' => (string) getenv('DB_PASSWORD'), + 'database' => (string) getenv('DB_DATABASE'), + 'port' => (string) getenv('DB_PORT') ?: '3306', + 'charset' => 'utf8', + 'driver_options' => [], + ], + 'options' => [ + 'buffer_results' => false, ], ], ]; ``` -The `data/` filepath for the sqlite file is the default `data/` directory from the Laminas MVC application. +### Named Adapters -### Working with a MySQL database +For applications requiring multiple database connections (e.g., read/write +separation), use named adapters: -Unlike a sqlite database, the MySQL database adapter requires a MySQL server. +```php title="config/autoload/db.global.php" + [ - 'driver' => 'Pdo', 'adapters' => [ - mysqlAdapter::class => [ - 'driver' => 'Pdo', - 'dsn' => 'mysql:dbname=your_database_name;host=your_mysql_host;charset=utf8', - 'username' => 'your_mysql_username', - 'password' => 'your_mysql_password', - 'driver_options' => [ - PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES \'UTF8\'' + 'ReadAdapter' => [ + 'driver' => Pdo::class, + 'connection' => [ + 'hostname' => (string) getenv('DB_READ_HOSTNAME') ?: 'localhost', + 'username' => (string) getenv('DB_READ_USERNAME'), + 'password' => (string) getenv('DB_READ_PASSWORD'), + 'database' => (string) getenv('DB_READ_DATABASE'), + 'port' => (string) getenv('DB_READ_PORT') ?: '3306', + 'charset' => 'utf8', + 'driver_options' => [], + ], + 'options' => [ + 'buffer_results' => true, + ], + ], + 'WriteAdapter' => [ + 'driver' => Pdo::class, + 'connection' => [ + 'hostname' => (string) getenv('DB_WRITE_HOSTNAME') ?: 'localhost', + 'username' => (string) getenv('DB_WRITE_USERNAME'), + 'password' => (string) getenv('DB_WRITE_PASSWORD'), + 'database' => (string) getenv('DB_WRITE_DATABASE'), + 'port' => (string) getenv('DB_WRITE_PORT') ?: '3306', + 'charset' => 'utf8', + 'driver_options' => [], + ], + 'options' => [ + 'buffer_results' => false, ], ], ], @@ -66,157 +83,113 @@ return [ ]; ``` -## Working with the adapter +## Working with the Adapter -Once you have configured an adapter, as in the above examples, you now have a `PhpDb\Adapter\Adapter` available to your application. +### Container-Managed Instantiation -A factory for a class that consumes an adapter can pull the adapter by the name used in configuration. -As an example, for the sqlite database configured earlier, we could write the following: +Once configured, retrieve the adapter from the service manager: -```php -use sqliteAdapter ; +```php title="Retrieving the adapter from the service container" +use PhpDb\Adapter\AdapterInterface; -$adapter = $container->get(sqliteAdapter::class) ; +$adapter = $container->get(AdapterInterface::class); ``` -For the MySQL Database configured earlier: +### Manual Instantiation -```php -use mysqlAdapter ; +If you need to create an adapter without the container: -$adapter = $container->get(mysqlAdapter::class) ; +```php +use PhpDb\Adapter\Adapter; +use PhpDb\Mysql\Driver\Mysql; +use PhpDb\Mysql\Platform\Mysql as MysqlPlatform; + +$driver = new Mysql([ + 'hostname' => 'localhost', + 'database' => 'my_database', + 'username' => 'my_username', + 'password' => 'my_password', +]); + +$adapter = new Adapter($driver, new MysqlPlatform()); ``` -You can read more about the [adapter in the adapter chapter of the documentation](../adapter.md). - -## Running with Docker - -When working with a MySQL database and when running the application with Docker, some files need to be added or adjusted. +You can read more about the +[adapter in the adapter chapter of the documentation](../adapter.md). -### Adding the MySQL extension to the PHP container +## Adapter-Aware Services with AdapterServiceDelegator -Change the `Dockerfile` to add the PDO MySQL extension to PHP. +If you have services that implement `PhpDb\Adapter\AdapterAwareInterface`, +you can use the `AdapterServiceDelegator` to automatically inject the +database adapter. -```Dockerfile -FROM php:7.3-apache +### Using the Delegator -RUN apt-get update \ - && apt-get install -y git zlib1g-dev libzip-dev \ - && docker-php-ext-install zip pdo_mysql \ - && a2enmod rewrite \ - && sed -i 's!/var/www/html!/var/www/public!g' /etc/apache2/sites-available/000-default.conf \ - && mv /var/www/html /var/www/public \ - && curl -sS https://getcomposer.org/installer \ - | php -- --install-dir=/usr/local/bin --filename=composer +Register the delegator in your service configuration: -WORKDIR /var/www -``` +```php title="Delegator configuration for adapter-aware services" +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Container\AdapterServiceDelegator; -### Adding the mysql container - -Change the `docker-compose.yml` file to add a new container for mysql. - -```yaml - mysql: - image: mysql - ports: - - 3306:3306 - command: - --default-authentication-plugin=mysql_native_password - volumes: - - ./.data/db:/var/lib/mysql - - ./.docker/mysql/:/docker-entrypoint-initdb.d/ - environment: - - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} +return [ + 'service_manager' => [ + 'delegators' => [ + MyDatabaseService::class => [ + new AdapterServiceDelegator(AdapterInterface::class), + ], + ], + ], +]; ``` -Though it is not the topic to explain how to write a `docker-compose.yml` file, a few details need to be highlighted : +### Multiple Adapters -- The name of the container is `mysql`. -- MySQL database files will be stored in the directory `/.data/db/`. -- SQL schemas will need to be added to the `/.docker/mysql/` directory so that Docker will be able to build and populate the database(s). -- The mysql docker image is using the `$MYSQL_ROOT_PASSWORD` environment variable to set the mysql root password. +When using multiple adapters, you can specify which adapter to inject: -### Link the containers +```php title="Delegator configuration for multiple adapters" +use PhpDb\Container\AdapterServiceDelegator; -Now link the mysql container and the laminas container so that the application knows where to find the mysql server. - -```yaml - laminas: - build: - context: . - dockerfile: Dockerfile - ports: - - 8080:80 - volumes: - - .:/var/www - links: - - mysql:mysql +return [ + 'service_manager' => [ + 'delegators' => [ + ReadService::class => [ + new AdapterServiceDelegator('db.reader'), + ], + WriteService::class => [ + new AdapterServiceDelegator('db.writer'), + ], + ], + ], +]; ``` -### Adding phpMyAdmin +### Implementing AdapterAwareInterface -Optionnally, you can also add a container for phpMyAdmin. +Your service class must implement `AdapterAwareInterface`: -```yaml - phpmyadmin: - image: phpmyadmin/phpmyadmin - ports: - - 8081:80 - environment: - - PMA_HOST=${PMA_HOST} -``` - -The image uses the `$PMA_HOST` environment variable to set the host of the mysql server. -The expected value is the name of the mysql container. - -Putting everything together: - -```yaml -version: "2.1" -services: - laminas: - build: - context: . - dockerfile: Dockerfile - ports: - - 8080:80 - volumes: - - .:/var/www - links: - - mysql:mysql - mysql: - image: mysql - ports: - - 3306:3306 - command: - --default-authentication-plugin=mysql_native_password - volumes: - - ./.data/db:/var/lib/mysql - - ./.docker/mysql/:/docker-entrypoint-initdb.d/ - environment: - - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - phpmyadmin: - image: phpmyadmin/phpmyadmin - ports: - - 8081:80 - environment: - - PMA_HOST=${PMA_HOST} -``` +```php title="Implementing AdapterAwareInterface in a service class" +use PhpDb\Adapter\AdapterAwareInterface; +use PhpDb\Adapter\AdapterInterface; -### Defining credentials +class MyDatabaseService implements AdapterAwareInterface +{ + private AdapterInterface $adapter; -The `docker-compose.yml` file uses ENV variables to define the credentials. + public function setDbAdapter(AdapterInterface $adapter): void + { + $this->adapter = $adapter; + } -Docker will read the ENV variables from a `.env` file. - -```env -MYSQL_ROOT_PASSWORD=rootpassword -PMA_HOST=mysql + public function getDbAdapter(): ?AdapterInterface + { + return $this->adapter ?? null; + } +} ``` -### Initiating the database schemas +## Running with Docker -At build, if the `/.data/db` directory is missing, Docker will create the mysql database with any `.sql` files found in the `.docker/mysql/` directory. -(These are the files with the `CREATE DATABASE`, `USE (database)`, and `CREATE TABLE, INSERT INTO` directives defined earlier in this document). -If multiple `.sql` files are present, it is a good idea to safely order the list because Docker will read the files in ascending order. +For Docker deployment instructions including Dockerfiles, +Nginx/Apache configuration, MySQL/PostgreSQL setup, and complete +docker-compose examples, see the +[Docker Deployment Guide](../docker-deployment.md). diff --git a/docs/book/application-integration/usage-in-a-mezzio-application.md b/docs/book/application-integration/usage-in-a-mezzio-application.md new file mode 100644 index 000000000..870d4240b --- /dev/null +++ b/docs/book/application-integration/usage-in-a-mezzio-application.md @@ -0,0 +1,125 @@ +# Usage in a Mezzio Application + +For installation instructions, see [Installation](../index.md#installation). + +## Configuration + +The adapter factory is already wired into the container. You only +need to provide the `db` configuration in `config/autoload/db.global.php`: + +```php title="config/autoload/db.global.php" + [ + 'driver' => Pdo::class, + 'connection' => [ + 'hostname' => (string) getenv('DB_HOSTNAME') ?: 'localhost', + 'username' => (string) getenv('DB_USERNAME'), + 'password' => (string) getenv('DB_PASSWORD'), + 'database' => (string) getenv('DB_DATABASE'), + 'port' => (string) getenv('DB_PORT') ?: '3306', + 'charset' => 'utf8', + 'driver_options' => [], + ], + 'options' => [ + 'buffer_results' => false, + ], + ], +]; +``` + +### Named Adapters + +For applications requiring multiple database connections (e.g., read/write +separation), use named adapters: + +```php title="config/autoload/db.global.php" + [ + 'adapters' => [ + 'ReadAdapter' => [ + 'driver' => Pdo::class, + 'connection' => [ + 'hostname' => (string) getenv('DB_READ_HOSTNAME') ?: 'localhost', + 'username' => (string) getenv('DB_READ_USERNAME'), + 'password' => (string) getenv('DB_READ_PASSWORD'), + 'database' => (string) getenv('DB_READ_DATABASE'), + 'port' => (string) getenv('DB_READ_PORT') ?: '3306', + 'charset' => 'utf8', + 'driver_options' => [], + ], + 'options' => [ + 'buffer_results' => true, + ], + ], + 'WriteAdapter' => [ + 'driver' => Pdo::class, + 'connection' => [ + 'hostname' => (string) getenv('DB_WRITE_HOSTNAME') ?: 'localhost', + 'username' => (string) getenv('DB_WRITE_USERNAME'), + 'password' => (string) getenv('DB_WRITE_PASSWORD'), + 'database' => (string) getenv('DB_WRITE_DATABASE'), + 'port' => (string) getenv('DB_WRITE_PORT') ?: '3306', + 'charset' => 'utf8', + 'driver_options' => [], + ], + 'options' => [ + 'buffer_results' => false, + ], + ], + ], + ], +]; +``` + +## Working with the Adapter + +### Container-Managed Instantiation + +Once configured, retrieve the adapter from the container: + +```php title="Retrieving the adapter from the container" +use PhpDb\Adapter\AdapterInterface; + +$adapter = $container->get(AdapterInterface::class); +``` + +### Manual Instantiation + +If you need to create an adapter without the container: + +```php +use PhpDb\Adapter\Adapter; +use PhpDb\Mysql\Driver\Mysql; +use PhpDb\Mysql\Platform\Mysql as MysqlPlatform; + +$driver = new Mysql([ + 'hostname' => 'localhost', + 'database' => 'my_database', + 'username' => 'my_username', + 'password' => 'my_password', +]); + +$adapter = new Adapter($driver, new MysqlPlatform()); +``` + +You can read more about the +[adapter in the adapter chapter of the documentation](../adapter.md). + +## Running with Docker + +For Docker deployment instructions including Dockerfiles, +Nginx/Apache configuration, MySQL/PostgreSQL setup, and complete +docker-compose examples, see the +[Docker Deployment Guide](../docker-deployment.md). \ No newline at end of file diff --git a/docs/book/docker-deployment.md b/docs/book/docker-deployment.md new file mode 100644 index 000000000..721d2749e --- /dev/null +++ b/docs/book/docker-deployment.md @@ -0,0 +1,293 @@ +# Docker Deployment + +This guide covers Docker deployment for phpdb applications, +applicable to both Laminas MVC and Mezzio frameworks. + +## Web Server Options + +Two web server options are supported: **Nginx with PHP-FPM** +(recommended for production) and **Apache** (simpler for development). + +### Nginx with PHP-FPM + +Create a `Dockerfile` in your project root: + +```dockerfile +FROM php:8.2-fpm-alpine + +RUN apk add --no-cache git zip unzip \ + && docker-php-ext-install pdo_mysql + +WORKDIR /var/www + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +``` + +Create `docker/nginx/default.conf`: + +```nginx +server { + listen 80; + server_name localhost; + root /var/www/public; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME \ + $document_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.ht { + deny all; + } +} +``` + +### Apache + +Create a `Dockerfile` in your project root: + +```dockerfile +FROM php:8.2-apache + +RUN apt-get update \ + && apt-get install -y git zlib1g-dev libzip-dev \ + && docker-php-ext-install zip pdo_mysql \ + && a2enmod rewrite \ + && sed -i 's!/var/www/html!/var/www/public!g' \ + /etc/apache2/sites-available/000-default.conf + +WORKDIR /var/www + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +``` + +## Database Containers + +```yaml title="MySQL" +mysql: + image: mysql:8.0 + ports: + - "3306:3306" + command: \ + --default-authentication-plugin=mysql_native_password + volumes: + - mysql_data:/var/lib/mysql + - ./docker/mysql/init:/docker-entrypoint-initdb.d + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=${DB_DATABASE} + - MYSQL_USER=${DB_USERNAME} + - MYSQL_PASSWORD=${DB_PASSWORD} +``` + +```yaml title="PostgreSQL" +postgres: + image: postgres:15-alpine + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres/init:/docker-entrypoint-initdb.d + environment: + - POSTGRES_DB=${DB_DATABASE} + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} +``` + +For PostgreSQL, add to your Dockerfile: + +```dockerfile +RUN docker-php-ext-install pdo_pgsql +``` + +```yaml title="phpMyAdmin (Optional)" +phpmyadmin: + image: phpmyadmin/phpmyadmin + ports: + - "8081:80" + depends_on: + - mysql + environment: + - PMA_HOST=mysql + - PMA_PORT=3306 +``` + +## Complete Examples + +```yaml title="Nginx + MySQL" +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/var/www + depends_on: + - mysql + environment: + - DB_TYPE=mysql + - DB_DATABASE=${DB_DATABASE} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - DB_HOSTNAME=mysql + - DB_PORT=3306 + + nginx: + image: nginx:alpine + ports: + - "8080:80" + volumes: + - .:/var/www + - ./docker/nginx/default.conf:\ + /etc/nginx/conf.d/default.conf + depends_on: + - app + + mysql: + image: mysql:8.0 + ports: + - "3306:3306" + command: \ + --default-authentication-plugin=mysql_native_password + volumes: + - mysql_data:/var/lib/mysql + - ./docker/mysql/init:/docker-entrypoint-initdb.d + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=${DB_DATABASE} + - MYSQL_USER=${DB_USERNAME} + - MYSQL_PASSWORD=${DB_PASSWORD} + + phpmyadmin: + image: phpmyadmin/phpmyadmin + ports: + - "8081:80" + depends_on: + - mysql + environment: + - PMA_HOST=mysql + - PMA_PORT=3306 + +volumes: + mysql_data: +``` + +```yaml title="Apache + MySQL" +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:80" + volumes: + - .:/var/www + depends_on: + - mysql + environment: + - DB_TYPE=mysql + - DB_DATABASE=${DB_DATABASE} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - DB_HOSTNAME=mysql + - DB_PORT=3306 + + mysql: + image: mysql:8.0 + ports: + - "3306:3306" + command: \ + --default-authentication-plugin=mysql_native_password + volumes: + - mysql_data:/var/lib/mysql + - ./docker/mysql/init:/docker-entrypoint-initdb.d + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=${DB_DATABASE} + - MYSQL_USER=${DB_USERNAME} + - MYSQL_PASSWORD=${DB_PASSWORD} + + phpmyadmin: + image: phpmyadmin/phpmyadmin + ports: + - "8081:80" + depends_on: + - mysql + environment: + - PMA_HOST=mysql + - PMA_PORT=3306 + +volumes: + mysql_data: +``` + +## Environment Variables + +Create a `.env` file in your project root: + +```env +DB_DATABASE=myapp +DB_USERNAME=appuser +DB_PASSWORD=apppassword +MYSQL_ROOT_PASSWORD=rootpassword +``` + +## Database Initialization + +Place SQL files in `./docker/mysql/init/` (or `./docker/postgres/init/` for +PostgreSQL). Files execute in alphanumeric order on first container start. + +Example `docker/mysql/init/01-schema.sql`: + +```sql +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + status ENUM('active', 'inactive') DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_status ON users(status); +``` + +Example `docker/mysql/init/02-seed.sql`: + +```sql +INSERT INTO users (username, email, status) VALUES + ('alice', 'alice@example.com', 'active'), + ('bob', 'bob@example.com', 'active'), + ('charlie', 'charlie@example.com', 'inactive'); +``` + +## Running the Application + +```bash +# Start all services +docker compose up -d + +# Check status +docker compose ps + +# View logs +docker compose logs -f app + +# Stop services +docker compose down +``` + +Access your application at `http://localhost:8080` and phpMyAdmin at +`http://localhost:8081`. diff --git a/docs/book/index.html b/docs/book/index.html deleted file mode 100644 index 4f5faa578..000000000 --- a/docs/book/index.html +++ /dev/null @@ -1,10 +0,0 @@ -
-
-

laminas-db

- -

Database abstraction layer, SQL abstraction, result set abstraction, and RowDataGateway and TableDataGateway implementations.

- -
$ composer require laminas/laminas-db
-
-
- diff --git a/docs/book/index.md b/docs/book/index.md deleted file mode 120000 index fe8400541..000000000 --- a/docs/book/index.md +++ /dev/null @@ -1 +0,0 @@ -../../README.md \ No newline at end of file diff --git a/docs/book/index.md b/docs/book/index.md new file mode 100644 index 000000000..01835a595 --- /dev/null +++ b/docs/book/index.md @@ -0,0 +1,153 @@ +# Introduction + +phpdb is a database abstraction layer providing: + +- **Database adapters** for connecting to various database vendors + (MySQL, PostgreSQL, SQLite, and more) +- **SQL abstraction** for building database-agnostic queries + programmatically +- **DDL abstraction** for creating and modifying database schemas +- **Result set abstraction** for working with query results +- **TableGateway and RowGateway** implementations for the Table Data + Gateway and Row Data Gateway patterns + +## Installation + +Install the driver package(s) for the database(s) you plan to +use via Composer: + +```bash +# For MySQL/MariaDB support +composer require php-db/mysql + +# For SQLite support +composer require php-db/sqlite + +# For PostgreSQL support (coming soon) +composer require php-db/postgres +``` + +This will also install the `php-db/phpdb` package, which provides the core +abstractions and functionality. + +### Mezzio + +phpdb provides a `ConfigProvider` that is automatically registered when using +[laminas-component-installer](https://docs.laminas.dev/laminas-component-installer/). + +If you are not using the component installer, add the following to your +`config/config.php`: + +```php +$aggregator = new ConfigAggregator([ + \PhpDb\ConfigProvider::class, + // ... other providers +]); +``` + +For detailed Mezzio configuration including adapter setup and dependency +injection, see the +[Mezzio integration guide](application-integration/usage-in-a-mezzio-application.md). + +### Laminas MVC + +phpdb provides module configuration that is automatically registered when using +[laminas-component-installer](https://docs.laminas.dev/laminas-component-installer/). + +If you are not using the component installer, add the module to your +`config/modules.config.php`: + +```php +return [ + 'PhpDb', + // ... other modules +]; +``` + +For detailed Laminas MVC configuration including adapter setup and service +manager integration, see the +[Laminas MVC integration guide](application-integration/usage-in-a-laminas-mvc-application.md). + +### Optional Dependencies + +The following packages provide additional functionality: + +- **laminas/laminas-hydrator** - Required for using `HydratingResultSet` to + hydrate result rows into objects +- **laminas/laminas-eventmanager** - Enables event-driven profiling and + logging of database operations + +Install optional dependencies as needed: + +```bash +composer require laminas/laminas-hydrator +composer require laminas/laminas-eventmanager +``` + +## Quick Start + +Once installed and configured, you can start using phpdb immediately: + +```php +use PhpDb\Adapter\Adapter; +use PhpDb\Sql\Sql; + +// Assuming $adapter is configured via your framework's DI container +$sql = new Sql($adapter); + +// Build a SELECT query +$select = $sql->select('users'); +$select->where(['status' => 'active']); +$select->order('created_at DESC'); +$select->limit(10); + +// Execute and iterate results +$statement = $sql->prepareStatementForSqlObject($select); +$results = $statement->execute(); + +foreach ($results as $row) { + echo $row['username'] . "\n"; +} +``` + +Or use the TableGateway for a higher-level abstraction: + +```php +use PhpDb\TableGateway\TableGateway; + +$usersTable = new TableGateway('users', $adapter); + +// Select rows +$activeUsers = $usersTable->select(['status' => 'active']); + +// Insert a new row +$usersTable->insert([ + 'username' => 'newuser', + 'email' => 'newuser@example.com', + 'status' => 'active', +]); + +// Update rows +$usersTable->update( + ['status' => 'inactive'], + ['last_login < ?' => '2024-01-01'] +); + +// Delete rows +$usersTable->delete(['id' => 123]); +``` + +## Documentation Overview + +- **[Adapters](adapter.md)** - Database connection and configuration +- **[SQL Abstraction](sql/intro.md)** - Building SELECT, INSERT, UPDATE, + and DELETE queries +- **[DDL Abstraction](sql-ddl/intro.md)** - Creating and modifying database + schemas +- **[Result Sets](result-set/intro.md)** - Working with query results +- **[Table Gateways](table-gateway.md)** - Table Data Gateway pattern + implementation +- **[Row Gateways](row-gateway.md)** - Row Data Gateway pattern + implementation +- **[Metadata](metadata/intro.md)** - Database introspection and schema + information diff --git a/docs/book/metadata/examples.md b/docs/book/metadata/examples.md new file mode 100644 index 000000000..e3572bc13 --- /dev/null +++ b/docs/book/metadata/examples.md @@ -0,0 +1,282 @@ +# Metadata Examples and Troubleshooting + +## Common Patterns and Best Practices + +```php title="Finding All Tables with a Specific Column" +function findTablesWithColumn( + MetadataInterface $metadata, + string $columnName +): array { + $tables = []; + foreach ($metadata->getTableNames() as $tableName) { + $columnNames = $metadata->getColumnNames($tableName); + if (in_array($columnName, $columnNames, true)) { + $tables[] = $tableName; + } + } + return $tables; +} + +$tablesWithUserId = findTablesWithColumn($metadata, 'user_id'); +``` + +```php title="Discovering Foreign Key Relationships" +function getForeignKeyRelationships( + MetadataInterface $metadata, + string $tableName +): array { + $relationships = []; + $constraints = $metadata->getConstraints($tableName); + + foreach ($constraints as $constraint) { + if (! $constraint->isForeignKey()) { + continue; + } + + $relationships[] = [ + 'constraint' => $constraint->getName(), + 'columns' => $constraint->getColumns(), + 'references' => $constraint->getReferencedTableName(), + 'referenced_columns' => $constraint->getReferencedColumns(), + 'on_update' => $constraint->getUpdateRule(), + 'on_delete' => $constraint->getDeleteRule(), + ]; + } + + return $relationships; +} +``` + +```php title="Generating Schema Documentation" +function generateTableDocumentation( + MetadataInterface $metadata, + string $tableName +): string { + $table = $metadata->getTable($tableName); + $doc = "# Table: $tableName\n\n"; + + $doc .= "## Columns\n\n"; + $doc .= "| Column | Type | Nullable | Default |\n"; + $doc .= "|--------|------|----------|--------|\n"; + + foreach ($table->getColumns() as $column) { + $type = $column->getDataType(); + if ($column->getCharacterMaximumLength()) { + $type .= '(' . $column->getCharacterMaximumLength() . ')'; + } elseif ($column->getNumericPrecision()) { + $type .= '(' . $column->getNumericPrecision(); + if ($column->getNumericScale()) { + $type .= ',' . $column->getNumericScale(); + } + $type .= ')'; + } + + $nullable = $column->isNullable() ? 'YES' : 'NO'; + $default = $column->getColumnDefault() ?? 'NULL'; + + $doc .= "| {$column->getName()} | $type | $nullable | $default |\n"; + } + + $doc .= "\n## Constraints\n\n"; + $constraints = $metadata->getConstraints($tableName); + + foreach ($constraints as $constraint) { + $doc .= "- **{$constraint->getName()}** "; + $doc .= "({$constraint->getType()})\n"; + if ($constraint->hasColumns()) { + $doc .= " - Columns: " . + implode(', ', $constraint->getColumns()) . "\n"; + } + if ($constraint->isForeignKey()) { + $doc .= " - References: "; + $doc .= "{$constraint->getReferencedTableName()}"; + $doc .= "(" . + implode(', ', $constraint->getReferencedColumns()) . + ")\n"; + $doc .= " - ON UPDATE: "; + $doc .= "{$constraint->getUpdateRule()}\n"; + $doc .= " - ON DELETE: "; + $doc .= "{$constraint->getDeleteRule()}\n"; + } + } + + return $doc; +} +``` + +```php title="Comparing Schemas Across Environments" +function compareTables( + MetadataInterface $metadata1, + MetadataInterface $metadata2, + string $tableName +): array { + $differences = []; + + $columns1 = $metadata1->getColumnNames($tableName); + $columns2 = $metadata2->getColumnNames($tableName); + + $missing = array_diff($columns1, $columns2); + if ($missing) { + $differences['missing_columns'] = $missing; + } + + $extra = array_diff($columns2, $columns1); + if ($extra) { + $differences['extra_columns'] = $extra; + } + + return $differences; +} +``` + +```php title="Generating Entity Classes from Metadata" +function generateEntityClass( + MetadataInterface $metadata, + string $tableName +): string { + $columns = $metadata->getColumns($tableName); + $className = str_replace( + ' ', + '', + ucwords(str_replace('_', ' ', $tableName)) + ); + + $code = "getDataType()) { + 'int', 'integer', 'bigint', 'smallint', 'tinyint' + => 'int', + 'decimal', 'float', 'double', 'real' => 'float', + 'bool', 'boolean' => 'bool', + default => 'string', + }; + + $nullable = $column->isNullable() ? '?' : ''; + $property = lcfirst( + str_replace( + ' ', + '', + ucwords(str_replace('_', ' ', $column->getName())) + ) + ); + + $code .= " private {$nullable}{$type} "; + $code .= "\${$property};\n"; + } + + $code .= "}\n"; + return $code; +} +``` + +## Error Handling + +Metadata methods throw `\Exception` when objects are not found: + +```php +try { + $table = $metadata->getTable('nonexistent_table'); +} catch (Exception $e) { + // Handle error +} +``` + +**Exception messages by method:** + +| Method | Message | +| ------ | ------- | +| `getTable()` | Table "name" does not exist | +| `getView()` | View "name" does not exist | +| `getColumn()` | A column by that name was not found | +| `getConstraint()` | Cannot find a constraint by that name | +| `getTrigger()` | Trigger "name" does not exist | + +**Best practice:** Check existence first using `getTableNames()`, +`getColumnNames()`, etc: + +```php +if (in_array('users', $metadata->getTableNames(), true)) { + $table = $metadata->getTable('users'); +} +``` + +### Performance with Large Schemas + +When working with databases that have hundreds of tables, use +`get*Names()` methods instead of retrieving full objects: + +```php title="Efficient Metadata Access for Large Schemas" +$tableNames = $metadata->getTableNames(); +foreach ($tableNames as $tableName) { + $columnNames = $metadata->getColumnNames($tableName); +} +``` + +This is more efficient than: + +```php title="Inefficient Metadata Access Pattern" +$tables = $metadata->getTables(); +foreach ($tables as $table) { + $columns = $table->getColumns(); +} +``` + +### Schema Permission Issues + +If you encounter errors accessing certain tables or schemas, verify database +user permissions: + +```php title="Verifying Schema Access Permissions" +try { + $tables = $metadata->getTableNames('restricted_schema'); +} catch (Exception $e) { + echo 'Access denied or schema does not exist'; +} +``` + +### Caching Metadata + +The metadata component queries the database each time a method is called. +For better performance in production, consider caching the results: + +```php title="Implementing Metadata Caching" +$cache = $container->get('cache'); +$cacheKey = 'metadata_tables'; + +$tables = $cache->get($cacheKey); +if ($tables === null) { + $tables = $metadata->getTables(); + $cache->set($cacheKey, $tables, 3600); +} +``` + +## Platform-Specific Behavior + +### MySQL + +- View definitions include `SELECT` statement exactly as stored +- Supports `AUTO_INCREMENT` in column errata +- Trigger support is comprehensive with full INFORMATION_SCHEMA access +- Check constraints available in MySQL 8.0+ + +### PostgreSQL + +- Schema support is robust, multiple schemas are common +- View `check_option` is well-supported +- Detailed trigger information including conditions +- Sequence information available in column errata + +### SQLite + +- Limited schema support (single default schema) +- View definitions may be formatted differently +- Trigger support varies by SQLite version +- Foreign key enforcement must be enabled separately + +### SQL Server + +- Schema support is robust with `dbo` as default schema +- View definitions may include schema qualifiers +- Trigger information may have platform-specific fields +- Constraint types may include platform-specific values diff --git a/docs/book/metadata/intro.md b/docs/book/metadata/intro.md new file mode 100644 index 000000000..e1ee8d78e --- /dev/null +++ b/docs/book/metadata/intro.md @@ -0,0 +1,437 @@ +# RDBMS Metadata + +`PhpDb\Metadata` is a sub-component of laminas-db that makes it possible to +get metadata information about tables, columns, constraints, triggers, +and other information from a database in a standardized way. The primary +interface for `Metadata` is: + +## MetadataInterface Definition + +```php +namespace PhpDb\Metadata; + +interface MetadataInterface +{ + public function getSchemas() : string[]; + + public function getTableNames( + ?string $schema = null, + bool $includeViews = false + ) : string[]; + public function getTables( + ?string $schema = null, + bool $includeViews = false + ) : Object\TableObject[]; + public function getTable( + string $tableName, + ?string $schema = null + ) : Object\TableObject|Object\ViewObject; + + public function getViewNames(?string $schema = null) : string[]; + public function getViews(?string $schema = null) : Object\ViewObject[]; + public function getView( + string $viewName, + ?string $schema = null + ) : Object\ViewObject|Object\TableObject; + + public function getColumnNames( + string $table, + ?string $schema = null + ) : string[]; + public function getColumns( + string $table, + ?string $schema = null + ) : Object\ColumnObject[]; + public function getColumn( + string $columnName, + string $table, + ?string $schema = null + ) : Object\ColumnObject; + + public function getConstraints( + string $table, + ?string $schema = null + ) : Object\ConstraintObject[]; + public function getConstraint( + string $constraintName, + string $table, + ?string $schema = null + ) : Object\ConstraintObject; + public function getConstraintKeys( + string $constraint, + string $table, + ?string $schema = null + ) : Object\ConstraintKeyObject[]; + + public function getTriggerNames(?string $schema = null) : string[]; + public function getTriggers(?string $schema = null) : Object\TriggerObject[]; + public function getTrigger( + string $triggerName, + ?string $schema = null + ) : Object\TriggerObject; +} +``` + +## Basic Usage + +### Instantiating Metadata + +The `PhpDb\Metadata` component uses platform-specific implementations to +retrieve metadata from your database. The metadata instance is typically +created through dependency injection or directly with an adapter: + +```php title="Creating Metadata from an Adapter" +use PhpDb\Adapter\Adapter; +use PhpDb\Metadata\Source\Factory as MetadataSourceFactory; + +$adapter = new Adapter($config); +$metadata = MetadataSourceFactory::createSourceFromAdapter($adapter); +``` + +### Retrieving Metadata from a DI Container + +Alternatively, when using a dependency injection container: + +```php +use PhpDb\Metadata\MetadataInterface; + +$metadata = $container->get(MetadataInterface::class); +``` + +In most cases, information will come from querying the `INFORMATION_SCHEMA` +tables for the currently accessible schema. + +### Understanding Return Types + +The `get*Names()` methods return arrays of strings: + +```php title="Getting Names of Database Objects" +$tableNames = $metadata->getTableNames(); +$columnNames = $metadata->getColumnNames('users'); +$schemas = $metadata->getSchemas(); +``` + +### Getting Object Instances + +The other methods return value objects specific to the type queried: + +```php +// Returns TableObject or ViewObject +$table = $metadata->getTable('users'); +// Returns ColumnObject +$column = $metadata->getColumn('id', 'users'); +// Returns ConstraintObject +$constraint = $metadata->getConstraint('PRIMARY', 'users'); +``` + +Note that `getTable()` and `getView()` can return either `TableObject` or +`ViewObject` depending on whether the database object is a table or a view. + +```php title="Basic Example" +use PhpDb\Metadata\Source\Factory as MetadataSourceFactory; + +$adapter = new Adapter($config); +$metadata = MetadataSourceFactory::createSourceFromAdapter($adapter); + +$table = $metadata->getTable('users'); + +foreach ($table->getColumns() as $column) { + $nullable = $column->isNullable() ? 'NULL' : 'NOT NULL'; + $default = $column->getColumnDefault(); + + printf( + "%s %s %s%s\n", + $column->getName(), + strtoupper($column->getDataType()), + $nullable, + $default ? " DEFAULT {$default}" : '' + ); +} +``` + +Example output: + +```text +id INT NOT NULL +username VARCHAR NOT NULL +email VARCHAR NOT NULL +created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +bio TEXT NULL +``` + +### Inspecting Table Constraints + +Inspecting constraints: + +```php +$constraints = $metadata->getConstraints('orders'); + +foreach ($constraints as $constraint) { + if ($constraint->isPrimaryKey()) { + printf("PRIMARY KEY (%s)\n", implode(', ', $constraint->getColumns())); + } + + if ($constraint->isForeignKey()) { + printf( + "FOREIGN KEY %s (%s) REFERENCES %s (%s)\n", + $constraint->getName(), + implode(', ', $constraint->getColumns()), + $constraint->getReferencedTableName(), + implode(', ', $constraint->getReferencedColumns()) + ); + } +} +``` + +Example output: + +```text +PRIMARY KEY (id) +FOREIGN KEY fk_orders_customers (customer_id) REFERENCES + customers (id) +FOREIGN KEY fk_orders_products (product_id) REFERENCES products (id) +``` + +## Advanced Usage + +### Working with Schemas + +The `getSchemas()` method returns all available schema names in the +database: + +```php title="Listing All Schemas and Their Tables" +$schemas = $metadata->getSchemas(); +foreach ($schemas as $schema) { + $tables = $metadata->getTableNames($schema); + printf( + "Schema: %s\n Tables: %s\n", + $schema, + implode(', ', $tables) + ); +} +``` + +When the `$schema` parameter is `null`, the metadata component uses the +current default schema from the adapter. You can explicitly specify a schema +for any method: + +```php title="Specifying a Schema Explicitly" +$tables = $metadata->getTableNames('production'); +$columns = $metadata->getColumns('users', 'production'); +$constraints = $metadata->getConstraints('users', 'production'); +``` + +### Working with Views + +Retrieve all views in the current schema: + +```php title="Retrieving View Information" +$viewNames = $metadata->getViewNames(); +foreach ($viewNames as $viewName) { + $view = $metadata->getView($viewName); + printf( + "View: %s\n Updatable: %s\n Check Option: %s\n Definition: %s\n", + $view->getName(), + $view->isUpdatable() ? 'Yes' : 'No', + $view->getCheckOption() ?? 'NONE', + $view->getViewDefinition() + ); +} +``` + +### Distinguishing Between Tables and Views + +Distinguishing between tables and views: + +```php +$table = $metadata->getTable('users'); + +if ($table instanceof \PhpDb\Metadata\Object\ViewObject) { + printf( + "View: %s\nDefinition: %s\n", + $table->getName(), + $table->getViewDefinition() + ); +} else { + printf("Table: %s\n", $table->getName()); +} +``` + +### Including Views in Table Listings + +Include views when getting table names: + +```php +$allTables = $metadata->getTableNames(null, true); +``` + +### Working with Triggers + +Retrieve all triggers and their details: + +```php title="Retrieving Trigger Information" +$triggers = $metadata->getTriggers(); +foreach ($triggers as $trigger) { + printf( + "%s (%s %s on %s)\n Statement: %s\n", + $trigger->getName(), + $trigger->getActionTiming(), + $trigger->getEventManipulation(), + $trigger->getEventObjectTable(), + $trigger->getActionStatement() + ); +} +``` + +The `getEventManipulation()` returns the trigger event: + +- `INSERT` - Trigger fires on INSERT operations +- `UPDATE` - Trigger fires on UPDATE operations +- `DELETE` - Trigger fires on DELETE operations + +The `getActionTiming()` returns when the trigger fires: + +- `BEFORE` - Executes before the triggering statement +- `AFTER` - Executes after the triggering statement + +### Analyzing Foreign Key Relationships + +Get detailed foreign key information using `getConstraintKeys()`: + +```php title="Examining Foreign Key Details" +$constraints = $metadata->getConstraints('orders'); +$foreignKeys = array_filter( + $constraints, + fn($c) => $c->isForeignKey() +); + +foreach ($foreignKeys as $constraint) { + printf("Foreign Key: %s\n", $constraint->getName()); + + $keys = $metadata->getConstraintKeys( + $constraint->getName(), + 'orders' + ); + foreach ($keys as $key) { + printf( + " %s -> %s.%s\n ON UPDATE: %s\n ON DELETE: %s\n", + $key->getColumnName(), + $key->getReferencedTableName(), + $key->getReferencedColumnName(), + $key->getForeignKeyUpdateRule(), + $key->getForeignKeyDeleteRule() + ); + } +} +``` + +Outputs: + +```text +Foreign Key: fk_orders_customers + customer_id -> customers.id + ON UPDATE: CASCADE + ON DELETE: RESTRICT +Foreign Key: fk_orders_products + product_id -> products.id + ON UPDATE: CASCADE + ON DELETE: NO ACTION +``` + +### Column Type Information + +Examine column types and their properties: + +```php title="Examining Column Data Types" +$column = $metadata->getColumn('price', 'products'); + +if ($column->getDataType() === 'decimal') { + $precision = $column->getNumericPrecision(); + $scale = $column->getNumericScale(); + echo "Column is DECIMAL($precision, $scale)" . PHP_EOL; +} + +if ($column->getDataType() === 'varchar') { + $maxLength = $column->getCharacterMaximumLength(); + echo "Column is VARCHAR($maxLength)" . PHP_EOL; +} + +if ($column->getDataType() === 'int') { + $unsigned = $column->isNumericUnsigned() ? 'UNSIGNED' : ''; + echo "Column is INT $unsigned" . PHP_EOL; +} +``` + +### Checking Column Nullability and Defaults + +Check column nullability and defaults: + +```php +$column = $metadata->getColumn('email', 'users'); + +echo 'Nullable: ' . + ($column->isNullable() ? 'YES' : 'NO') . PHP_EOL; +echo 'Default: ' . + ($column->getColumnDefault() ?? 'NULL') . PHP_EOL; +echo 'Position: ' . $column->getOrdinalPosition() . PHP_EOL; +``` + +### The Errata System + +The `ColumnObject` includes an errata system for storing database-specific +metadata not covered by the standard properties: + +```php title="Using the Errata System" +$columns = $metadata->getColumns('users'); +foreach ($columns as $column) { + if ($column->getErrata('auto_increment')) { + echo $column->getName() . ' is AUTO_INCREMENT' . PHP_EOL; + } + + $comment = $column->getErrata('comment'); + if ($comment) { + echo $column->getName() . ': ' . $comment . PHP_EOL; + } +} +``` + +### Setting Errata on Column Objects + +You can also set errata when programmatically creating column objects: + +```php +$column->setErrata('auto_increment', true); +$column->setErrata('comment', 'Primary key for users table'); +$column->setErrata('collation', 'utf8mb4_unicode_ci'); +``` + +### Retrieving All Errata at Once + +Get all errata at once: + +```php +$erratas = $column->getErratas(); +foreach ($erratas as $key => $value) { + echo "$key: $value" . PHP_EOL; +} +``` + +### Fluent Interface Pattern + +All setter methods on value objects return `static`, enabling method chaining: + +```php title="Using Method Chaining with Value Objects" +$column = new ColumnObject('id', 'users'); +$column->setDataType('int') + ->setIsNullable(false) + ->setNumericUnsigned(true) + ->setErrata('auto_increment', true); + +$constraint = new ConstraintObject('fk_user_role', 'users'); +$constraint->setType('FOREIGN KEY') + ->setColumns(['role_id']) + ->setReferencedTableName('roles') + ->setReferencedColumns(['id']) + ->setUpdateRule('CASCADE') + ->setDeleteRule('RESTRICT'); +``` diff --git a/docs/book/metadata/objects.md b/docs/book/metadata/objects.md new file mode 100644 index 000000000..2216745de --- /dev/null +++ b/docs/book/metadata/objects.md @@ -0,0 +1,363 @@ +# Metadata Value Objects + +Metadata returns value objects that provide an interface to help +developers better explore the metadata. Below is the API for the various +value objects: + +## TableObject + +`TableObject` extends `AbstractTableObject` and represents a database +table: + +```php title="TableObject Class Definition" +class PhpDb\Metadata\Object\TableObject extends AbstractTableObject +{ + public function __construct(?string $name = null); + public function setColumns(array $columns): void; + public function getColumns(): ?array; + public function setConstraints(array $constraints): void; + public function getConstraints(): ?array; + public function setName(string $name): void; + public function getName(): ?string; +} +``` + +## ColumnObject + +All setter methods return `static` for fluent interface support: + +```php title="ColumnObject Class Definition" +class PhpDb\Metadata\Object\ColumnObject +{ + public function __construct( + string $name, + string $tableName, + ?string $schemaName = null + ); + + public function setName(string $name): void; + public function getName(): string; + + public function getTableName(): string; + public function setTableName(string $tableName): static; + + public function setSchemaName(string $schemaName): void; + public function getSchemaName(): ?string; + + public function getOrdinalPosition(): ?int; + public function setOrdinalPosition(?int $ordinalPosition): static; + + public function getColumnDefault(): ?string; + public function setColumnDefault( + null|string|int|bool $columnDefault + ): static; + + public function getIsNullable(): ?bool; + public function setIsNullable(?bool $isNullable): static; + public function isNullable(): ?bool; // Alias for getIsNullable() + + public function getDataType(): ?string; + public function setDataType(string $dataType): static; + + public function getCharacterMaximumLength(): ?int; + public function setCharacterMaximumLength( + ?int $characterMaximumLength + ): static; + + public function getCharacterOctetLength(): ?int; + public function setCharacterOctetLength( + ?int $characterOctetLength + ): static; + + public function getNumericPrecision(): ?int; + public function setNumericPrecision(?int $numericPrecision): static; + + public function getNumericScale(): ?int; + public function setNumericScale(?int $numericScale): static; + + public function getNumericUnsigned(): ?bool; + public function setNumericUnsigned( + ?bool $numericUnsigned + ): static; + // Alias for getNumericUnsigned() + public function isNumericUnsigned(): ?bool; + + public function getErratas(): array; + public function setErratas(array $erratas): static; + + public function getErrata(string $errataName): mixed; + public function setErrata(string $errataName, mixed $errataValue): static; +} +``` + +## ConstraintObject + +All setter methods return `static` for fluent interface support: + +```php title="ConstraintObject Class Definition" +class PhpDb\Metadata\Object\ConstraintObject +{ + public function __construct( + string $name, + string $tableName, + ?string $schemaName = null + ); + + public function setName(string $name): void; + public function getName(): string; + + public function setSchemaName(string $schemaName): void; + public function getSchemaName(): ?string; + + public function getTableName(): string; + public function setTableName(string $tableName): static; + + public function setType(string $type): void; + public function getType(): ?string; + + public function hasColumns(): bool; + public function getColumns(): array; + public function setColumns(array $columns): static; + + public function getReferencedTableSchema(): ?string; + public function setReferencedTableSchema( + string $referencedTableSchema + ): static; + + public function getReferencedTableName(): ?string; + public function setReferencedTableName( + string $referencedTableName + ): static; + + public function getReferencedColumns(): ?array; + public function setReferencedColumns( + array $referencedColumns + ): static; + + public function getMatchOption(): ?string; + public function setMatchOption(string $matchOption): static; + + public function getUpdateRule(): ?string; + public function setUpdateRule(string $updateRule): static; + + public function getDeleteRule(): ?string; + public function setDeleteRule(string $deleteRule): static; + + public function getCheckClause(): ?string; + public function setCheckClause(string $checkClause): static; + + // Type checking methods + public function isPrimaryKey(): bool; + public function isUnique(): bool; + public function isForeignKey(): bool; + public function isCheck(): bool; +} +``` + +## ViewObject + +The `ViewObject` extends `AbstractTableObject` and represents database +views. It includes all methods from `TableObject` plus view-specific +properties: + +```php title="ViewObject Class Definition" +class PhpDb\Metadata\Object\ViewObject extends AbstractTableObject +{ + public function __construct(?string $name = null); + public function setName(string $name): void; + public function getName(): ?string; + public function setColumns(array $columns): void; + public function getColumns(): ?array; + public function setConstraints(array $constraints): void; + public function getConstraints(): ?array; + + public function getViewDefinition(): ?string; + public function setViewDefinition(?string $viewDefinition): static; + + public function getCheckOption(): ?string; + public function setCheckOption(?string $checkOption): static; + + public function getIsUpdatable(): ?bool; + public function isUpdatable(): ?bool; + public function setIsUpdatable(?bool $isUpdatable): static; +} +``` + +The `getViewDefinition()` method returns the SQL that creates the view: + +```php title="Retrieving View Definition" +$view = $metadata->getView('active_users'); +echo $view->getViewDefinition(); +``` + +Outputs: + +```sql title="View Definition SQL Output" +SELECT id, name, email FROM users WHERE status = 'active' +``` + +The `getCheckOption()` returns the view's check option: + +- `CASCADED` - Checks for updatability cascade to underlying views +- `LOCAL` - Only checks this view for updatability +- `NONE` - No check option specified + +The `isUpdatable()` method (alias for `getIsUpdatable()`) indicates +whether the view supports INSERT, UPDATE, or DELETE operations. + +## ConstraintKeyObject + +The `ConstraintKeyObject` provides detailed information about individual +columns participating in constraints, particularly useful for foreign key +relationships: + +```php title="ConstraintKeyObject Class Definition" +class PhpDb\Metadata\Object\ConstraintKeyObject +{ + public const FK_CASCADE = 'CASCADE'; + public const FK_SET_NULL = 'SET NULL'; + public const FK_NO_ACTION = 'NO ACTION'; + public const FK_RESTRICT = 'RESTRICT'; + public const FK_SET_DEFAULT = 'SET DEFAULT'; + + public function __construct(string $column); + + public function getColumnName(): string; + public function setColumnName(string $columnName): static; + + public function getOrdinalPosition(): ?int; + public function setOrdinalPosition(int $ordinalPosition): static; + + public function getPositionInUniqueConstraint(): ?bool; + public function setPositionInUniqueConstraint( + bool $positionInUniqueConstraint + ): static; + + public function getReferencedTableSchema(): ?string; + public function setReferencedTableSchema( + string $referencedTableSchema + ): static; + + public function getReferencedTableName(): ?string; + public function setReferencedTableName( + string $referencedTableName + ): static; + + public function getReferencedColumnName(): ?string; + public function setReferencedColumnName( + string $referencedColumnName + ): static; + + public function getForeignKeyUpdateRule(): ?string; + public function setForeignKeyUpdateRule( + string $foreignKeyUpdateRule + ): void; + + public function getForeignKeyDeleteRule(): ?string; + public function setForeignKeyDeleteRule( + string $foreignKeyDeleteRule + ): void; +} +``` + +Constraint keys are retrieved using `getConstraintKeys()`: + +```php title="Iterating Through Foreign Key Constraint Details" +$keys = $metadata->getConstraintKeys( + 'fk_orders_customers', + 'orders' +); +foreach ($keys as $key) { + echo $key->getColumnName() . ' -> ' + . $key->getReferencedTableName() . '.' + . $key->getReferencedColumnName() . PHP_EOL; + echo ' ON UPDATE: ' . + $key->getForeignKeyUpdateRule() . PHP_EOL; + echo ' ON DELETE: ' . + $key->getForeignKeyDeleteRule() . PHP_EOL; +} +``` + +Outputs: + +```text title="Foreign Key Constraint Output" +customer_id -> customers.id + ON UPDATE: CASCADE + ON DELETE: RESTRICT +``` + +## TriggerObject + +All setter methods return `static` for fluent interface support: + +```php title="TriggerObject Class Definition" +class PhpDb\Metadata\Object\TriggerObject +{ + public function getName(): ?string; + public function setName(string $name): static; + + public function getEventManipulation(): ?string; + public function setEventManipulation( + string $eventManipulation + ): static; + + public function getEventObjectCatalog(): ?string; + public function setEventObjectCatalog( + string $eventObjectCatalog + ): static; + + public function getEventObjectSchema(): ?string; + public function setEventObjectSchema( + string $eventObjectSchema + ): static; + + public function getEventObjectTable(): ?string; + public function setEventObjectTable( + string $eventObjectTable + ): static; + + public function getActionOrder(): ?string; + public function setActionOrder(string $actionOrder): static; + + public function getActionCondition(): ?string; + public function setActionCondition( + ?string $actionCondition + ): static; + + public function getActionStatement(): ?string; + public function setActionStatement( + string $actionStatement + ): static; + + public function getActionOrientation(): ?string; + public function setActionOrientation( + string $actionOrientation + ): static; + + public function getActionTiming(): ?string; + public function setActionTiming(string $actionTiming): static; + + public function getActionReferenceOldTable(): ?string; + public function setActionReferenceOldTable( + ?string $actionReferenceOldTable + ): static; + + public function getActionReferenceNewTable(): ?string; + public function setActionReferenceNewTable( + ?string $actionReferenceNewTable + ): static; + + public function getActionReferenceOldRow(): ?string; + public function setActionReferenceOldRow( + string $actionReferenceOldRow + ): static; + + public function getActionReferenceNewRow(): ?string; + public function setActionReferenceNewRow( + string $actionReferenceNewRow + ): static; + + public function getCreated(): ?DateTime; + public function setCreated(?DateTime $created): static; +} +``` diff --git a/docs/book/profiler.md b/docs/book/profiler.md new file mode 100644 index 000000000..92cf67d16 --- /dev/null +++ b/docs/book/profiler.md @@ -0,0 +1,427 @@ +# Profiler + +The profiler component allows you to collect timing information about database +queries executed through phpdb. This is invaluable during development for +identifying slow queries, debugging SQL issues, and integrating with +development tools and logging systems. + +## Basic Usage + +The `Profiler` class implements `ProfilerInterface` and can be attached to any adapter: + +```php +use PhpDb\Adapter\Adapter; +use PhpDb\Adapter\Profiler\Profiler; + +// Create a profiler instance +$profiler = new Profiler(); + +// Attach to an existing adapter +$adapter->setProfiler($profiler); + +// Or pass it during adapter construction +$adapter = new Adapter($driver, $platform, $resultSetPrototype, $profiler); +``` + +Once attached, the profiler automatically tracks all queries executed through +the adapter. + +## Retrieving Profile Data + +After executing queries, you can retrieve profiling information: + +```php title="Get the Last Profile" +$adapter->query('SELECT * FROM users WHERE status = ?', ['active']); + +$lastProfile = $profiler->getLastProfile(); + +// Returns: +// [ +// 'sql' => 'SELECT * FROM users WHERE status = ?', +// 'parameters' => ParameterContainer instance, +// 'start' => 1702054800.123456, // microtime(true) when query started +// 'end' => 1702054800.234567, // microtime(true) when query finished +// 'elapse' => 0.111111, // execution time in seconds +// ] +``` + +```php title="Get All Profiles" +// Execute several queries +$adapter->query('SELECT * FROM users'); +$adapter->query('SELECT * FROM orders WHERE user_id = ?', [42]); +$adapter->query('UPDATE users SET last_login = NOW() WHERE id = ?', [42]); + +// Get all collected profiles +$allProfiles = $profiler->getProfiles(); + +foreach ($allProfiles as $index => $profile) { + echo sprintf( + "Query %d: %s (%.4f seconds)\n", + $index + 1, + $profile['sql'], + $profile['elapse'] + ); +} +``` + +## Profile Data Structure + +Each profile entry contains: + +| Key | Type | Description | +| ------------ | -------------------------- | ------------------------------ | +| `sql` | `string` | The executed SQL query | +| `parameters` | `ParameterContainer\|null` | Bound parameters (if any) | +| `start` | `float` | Query start (Unix timestamp) | +| `end` | `float` | Query end (Unix timestamp) | +| `elapse` | `float` | Execution time in seconds | + +## Integration with Development Tools + +### Logging Slow Queries + +Create a simple slow query logger: + +```php +use PhpDb\Adapter\Profiler\Profiler; +use Psr\Log\LoggerInterface; + +class SlowQueryLogger +{ + public function __construct( + private Profiler $profiler, + private LoggerInterface $logger, + private float $threshold = 1.0 // Log queries taking more than 1 second + ) { + } + + public function checkLastQuery(): void + { + $profile = $this->profiler->getLastProfile(); + + if ($profile && $profile['elapse'] > $this->threshold) { + $this->logger->warning('Slow query detected', [ + 'sql' => $profile['sql'], + 'time' => $profile['elapse'], + 'parameters' => $profile['parameters']?->getNamedArray(), + ]); + } + } + + public function getSlowQueries(): array + { + return array_filter( + $this->profiler->getProfiles(), + fn($profile) => $profile['elapse'] > $this->threshold + ); + } +} +``` + +### Debug Toolbar Integration + +Integrate with debug toolbars by collecting query information: + +```php +class DebugBarCollector +{ + public function __construct( + private Profiler $profiler + ) { + } + + public function collect(): array + { + $profiles = $this->profiler->getProfiles(); + $totalTime = 0; + $queries = []; + + foreach ($profiles as $profile) { + $totalTime += $profile['elapse']; + $queries[] = [ + 'sql' => $profile['sql'], + 'params' => $profile['parameters']?->getNamedArray() ?? [], + 'duration' => round($profile['elapse'] * 1000, 2), + 'duration_str' => sprintf('%.2f ms', $profile['elapse'] * 1000), + ]; + } + + return [ + 'nb_statements' => count($queries), + 'accumulated_duration' => round($totalTime * 1000, 2), + 'accumulated_duration_str' => sprintf('%.2f ms', $totalTime * 1000), + 'statements' => $queries, + ]; + } +} +``` + +### Mezzio Middleware for Request Profiling + +Create middleware to profile all database queries per request: + +```php +use PhpDb\Adapter\Profiler\Profiler; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +class DatabaseProfilingMiddleware implements MiddlewareInterface +{ + public function __construct( + private Profiler $profiler + ) { + } + + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + $response = $handler->handle($request); + + // Add profiling data to response headers in development + if (getenv('APP_ENV') === 'development') { + $profiles = $this->profiler->getProfiles(); + $totalTime = array_sum(array_column($profiles, 'elapse')); + + $response = $response + ->withHeader('X-DB-Query-Count', (string) count($profiles)) + ->withHeader('X-DB-Query-Time', sprintf('%.4f', $totalTime)); + } + + return $response; + } +} +``` + +### Laminas MVC Event Listener + +Attach a listener to log queries after each request: + +```php +use Laminas\Mvc\MvcEvent; +use PhpDb\Adapter\Profiler\Profiler; +use Psr\Log\LoggerInterface; + +class DatabaseProfilerListener +{ + public function __construct( + private Profiler $profiler, + private LoggerInterface $logger + ) { + } + + public function __invoke(MvcEvent $event): void + { + $profiles = $this->profiler->getProfiles(); + + if (empty($profiles)) { + return; + } + + $totalTime = array_sum(array_column($profiles, 'elapse')); + $queryCount = count($profiles); + + $this->logger->debug('Database queries executed', [ + 'count' => $queryCount, + 'total_time' => sprintf('%.4f seconds', $totalTime), + 'queries' => array_map( + fn($p) => ['sql' => $p['sql'], 'time' => $p['elapse']], + $profiles + ), + ]); + } +} +``` + +Register in your module configuration: + +```php +use Laminas\Mvc\MvcEvent; + +class Module +{ + public function onBootstrap(MvcEvent $event): void + { + $eventManager = $event->getApplication()->getEventManager(); + $container = $event->getApplication()->getServiceManager(); + + $eventManager->attach( + MvcEvent::EVENT_FINISH, + $container->get(DatabaseProfilerListener::class) + ); + } +} +``` + +## Custom Profiler Implementation + +You can create custom profilers by implementing `ProfilerInterface`: + +```php +use PhpDb\Adapter\Profiler\ProfilerInterface; +use PhpDb\Adapter\StatementContainerInterface; + +class CustomProfiler implements ProfilerInterface +{ + private array $profiles = []; + private int $currentIndex = 0; + private array $currentProfile = []; + + public function profilerStart($target): self + { + $sql = $target instanceof StatementContainerInterface + ? $target->getSql() + : (string) $target; + + $this->currentProfile = [ + 'sql' => $sql, + 'parameters' => $target instanceof StatementContainerInterface + ? clone $target->getParameterContainer() + : null, + 'start' => hrtime(true), // Use high-resolution time + 'memory_start' => memory_get_usage(true), + ]; + + return $this; + } + + public function profilerFinish(): self + { + $this->currentProfile['end'] = hrtime(true); + $this->currentProfile['memory_end'] = memory_get_usage(true); + $this->currentProfile['elapse'] = + ($this->currentProfile['end'] - $this->currentProfile['start']) / 1e9; + $this->currentProfile['memory_delta'] = + $this->currentProfile['memory_end'] - $this->currentProfile['memory_start']; + + $this->profiles[$this->currentIndex++] = $this->currentProfile; + $this->currentProfile = []; + + return $this; + } + + public function getProfiles(): array + { + return $this->profiles; + } +} +``` + +## ProfilerAwareInterface + +Components that can accept a profiler implement `ProfilerAwareInterface`: + +```php +use PhpDb\Adapter\Profiler\ProfilerAwareInterface; +use PhpDb\Adapter\Profiler\ProfilerInterface; + +class MyDatabaseService implements ProfilerAwareInterface +{ + private ?ProfilerInterface $profiler = null; + + public function setProfiler(ProfilerInterface $profiler): ProfilerAwareInterface + { + $this->profiler = $profiler; + return $this; + } + + public function executeQuery(string $sql): mixed + { + $this->profiler?->profilerStart($sql); + + try { + // Execute query... + $result = $this->doQuery($sql); + return $result; + } finally { + $this->profiler?->profilerFinish(); + } + } +} +``` + +## Best Practices + +### Development vs Production + +Only enable profiling in development environments to avoid performance overhead: + +```php +use PhpDb\Adapter\Profiler\Profiler; + +$profiler = null; +if (getenv('APP_ENV') === 'development') { + $profiler = new Profiler(); +} + +$adapter = new Adapter($driver, $platform, $resultSetPrototype, $profiler); +``` + +### Memory Considerations + +The profiler stores all query profiles in memory. For long-running processes +or batch operations, consider periodically clearing or limiting profiles: + +```php +class LimitedProfiler extends Profiler +{ + private int $maxProfiles; + + public function __construct(int $maxProfiles = 100) + { + $this->maxProfiles = $maxProfiles; + } + + public function profilerFinish(): self + { + parent::profilerFinish(); + + // Keep only the last N profiles + if (count($this->profiles) > $this->maxProfiles) { + $this->profiles = array_slice( + $this->profiles, + -$this->maxProfiles, + preserve_keys: false + ); + $this->currentIndex = count($this->profiles); + } + + return $this; + } +} +``` + +### Combining with Query Logging + +For comprehensive debugging, combine profiling with SQL logging: + +```php +use Psr\Log\LoggerInterface; + +class LoggingProfiler extends Profiler +{ + public function __construct( + private LoggerInterface $logger, + private bool $logAllQueries = false + ) { + } + + public function profilerFinish(): self + { + parent::profilerFinish(); + + $profile = $this->getLastProfile(); + + if ($this->logAllQueries) { + $this->logger->debug('Query executed', [ + 'sql' => $profile['sql'], + 'time' => sprintf('%.4f seconds', $profile['elapse']), + ]); + } + + return $this; + } +} +``` diff --git a/docs/book/result-set.md b/docs/book/result-set.md deleted file mode 100644 index 42f30de0e..000000000 --- a/docs/book/result-set.md +++ /dev/null @@ -1,157 +0,0 @@ -# Result Sets - -`PhpDb\ResultSet` is a sub-component of laminas-db for abstracting the iteration -of results returned from queries producing rowsets. While data sources for this -can be anything that is iterable, generally these will be populated from -`PhpDb\Adapter\Driver\ResultInterface` instances. - -Result sets must implement the `PhpDb\ResultSet\ResultSetInterface`, and all -sub-components of laminas-db that return a result set as part of their API will -assume an instance of a `ResultSetInterface` should be returned. In most cases, -the prototype pattern will be used by consuming object to clone a prototype of -a `ResultSet` and return a specialized `ResultSet` with a specific data source -injected. `ResultSetInterface` is defined as follows: - -```php -use Countable; -use Traversable; - -interface ResultSetInterface extends Traversable, Countable -{ - public function initialize(mixed $dataSource) : void; - public function getFieldCount() : int; -} -``` - -## Quick start - -`PhpDb\ResultSet\ResultSet` is the most basic form of a `ResultSet` object -that will expose each row as either an `ArrayObject`-like object or an array of -row data. By default, `PhpDb\Adapter\Adapter` will use a prototypical -`PhpDb\ResultSet\ResultSet` object for iterating when using the -`PhpDb\Adapter\Adapter::query()` method. - -The following is an example workflow similar to what one might find inside -`PhpDb\Adapter\Adapter::query()`: - -```php -use PhpDb\Adapter\Driver\ResultInterface; -use PhpDb\ResultSet\ResultSet; - -$statement = $driver->createStatement('SELECT * FROM users'); -$statement->prepare(); -$result = $statement->execute($parameters); - -if ($result instanceof ResultInterface && $result->isQueryResult()) { - $resultSet = new ResultSet; - $resultSet->initialize($result); - - foreach ($resultSet as $row) { - echo $row->my_column . PHP_EOL; - } -} -``` - -## Laminas\\Db\\ResultSet\\ResultSet and Laminas\\Db\\ResultSet\\AbstractResultSet - -For most purposes, either an instance of `PhpDb\ResultSet\ResultSet` or a -derivative of `PhpDb\ResultSet\AbstractResultSet` will be used. The -implementation of the `AbstractResultSet` offers the following core -functionality: - -```php -namespace PhpDb\ResultSet; - -use Iterator; - -abstract class AbstractResultSet implements Iterator, ResultSetInterface -{ - public function initialize(array|Iterator|IteratorAggregate|ResultInterface $dataSource) : self; - public function getDataSource() : Iterator|IteratorAggregate|ResultInterface; - public function getFieldCount() : int; - - /** Iterator */ - public function next() : mixed; - public function key() : string|int; - public function current() : mixed; - public function valid() : bool; - public function rewind() : void; - - /** countable */ - public function count() : int; - - /** get rows as array */ - public function toArray() : array; -} -``` - -## Laminas\\Db\\ResultSet\\HydratingResultSet - -`PhpDb\ResultSet\HydratingResultSet` is a more flexible `ResultSet` object -that allows the developer to choose an appropriate "hydration strategy" for -getting row data into a target object. While iterating over results, -`HydratingResultSet` will take a prototype of a target object and clone it once -for each row. The `HydratingResultSet` will then hydrate that clone with the -row data. - -The `HydratingResultSet` depends on -[laminas-hydrator](https://docs.laminas.dev/laminas-hydrator), which you will -need to install: - -```bash -composer require laminas/laminas-hydrator -``` - -In the example below, rows from the database will be iterated, and during -iteration, `HydratingResultSet` will use the `Reflection` based hydrator to -inject the row data directly into the protected members of the cloned -`UserEntity` object: - -```php -use PhpDb\Adapter\Driver\ResultInterface; -use PhpDb\ResultSet\HydratingResultSet; -use Laminas\Hydrator\Reflection as ReflectionHydrator; - -class UserEntity -{ - protected $first_name; - protected $last_name; - - public function getFirstName() - { - return $this->first_name; - } - - public function getLastName() - { - return $this->last_name; - } - - public function setFirstName($firstName) - { - $this->first_name = $firstName; - } - - public function setLastName($lastName) - { - $this->last_name = $lastName; - } -} - -$statement = $driver->createStatement($sql); -$statement->prepare($parameters); -$result = $statement->execute(); - -if ($result instanceof ResultInterface && $result->isQueryResult()) { - $resultSet = new HydratingResultSet(new ReflectionHydrator, new UserEntity); - $resultSet->initialize($result); - - foreach ($resultSet as $user) { - echo $user->getFirstName() . ' ' . $user->getLastName() . PHP_EOL; - } -} -``` - -For more information, see the [laminas-hydrator](https://docs.laminas.dev/laminas-hydrator/) -documentation to get a better sense of the different strategies that can be -employed in order to populate a target object. diff --git a/docs/book/result-set/advanced.md b/docs/book/result-set/advanced.md new file mode 100644 index 000000000..d0398275f --- /dev/null +++ b/docs/book/result-set/advanced.md @@ -0,0 +1,484 @@ +# Result Set API and Advanced Features + +## ResultSet API Reference + +### ResultSet Class + +The `ResultSet` class extends `AbstractResultSet` and provides row data as +either `ArrayObject` instances or plain arrays. + +```php title="ResultSet Class Definition" +namespace PhpDb\ResultSet; + +use ArrayObject; + +class ResultSet extends AbstractResultSet +{ + public function __construct( + ResultSetReturnType $returnType = ResultSetReturnType::ArrayObject, + ?ArrayObject $rowPrototype = null + ); + + public function setRowPrototype( + ArrayObject $rowPrototype + ): ResultSetInterface; + public function getRowPrototype(): ArrayObject; + public function getReturnType(): ResultSetReturnType; +} +``` + +### ResultSetReturnType Enum + +The `ResultSetReturnType` enum provides type-safe return type +configuration: + +```php title="ResultSetReturnType Definition" +namespace PhpDb\ResultSet; + +enum ResultSetReturnType: string +{ + case ArrayObject = 'arrayobject'; + case Array = 'array'; +} +``` + +```php title="Using ResultSetReturnType" +use PhpDb\ResultSet\ResultSet; +use PhpDb\ResultSet\ResultSetReturnType; + +$resultSet = new ResultSet(ResultSetReturnType::ArrayObject); +$resultSet = new ResultSet(ResultSetReturnType::Array); +``` + +#### Constructor Parameters + +**`$returnType`** - Controls how rows are returned: + +- `ResultSetReturnType::ArrayObject` (default) - Returns rows as + ArrayObject instances +- `ResultSetReturnType::Array` - Returns rows as plain PHP arrays + +**`$rowPrototype`** - Custom ArrayObject prototype for row objects +(only used with ArrayObject mode) + +#### Return Type Modes + +**ArrayObject Mode** (default): + +```php title="ArrayObject Mode Example" +$resultSet = new ResultSet(ResultSetReturnType::ArrayObject); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + printf("ID: %d, Name: %s\n", $row->id, $row->name); + printf("Array access also works: %s\n", $row['name']); +} +``` + +**Array Mode:** + +```php title="Array Mode Example" +$resultSet = new ResultSet(ResultSetReturnType::Array); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + printf("ID: %d, Name: %s\n", $row['id'], $row['name']); +} +``` + +The array mode is more memory efficient for large result sets. + +### HydratingResultSet Class + +Complete API for `HydratingResultSet`: + +```php title="HydratingResultSet Class Definition" +namespace PhpDb\ResultSet; + +use Laminas\Hydrator\HydratorInterface; + +class HydratingResultSet extends AbstractResultSet +{ + public function __construct( + ?HydratorInterface $hydrator = null, + ?object $rowPrototype = null + ); + + public function setHydrator( + HydratorInterface $hydrator + ): ResultSetInterface; + public function getHydrator(): HydratorInterface; + + public function setRowPrototype( + object $rowPrototype + ): ResultSetInterface; + public function getRowPrototype(): object; + + public function current(): ?object; + public function toArray(): array; +} +``` + +#### Constructor Defaults + +If no hydrator is provided, `ArraySerializableHydrator` is used by default: + +```php title="Default Hydrator" +$resultSet = new HydratingResultSet(); +``` + +If no object prototype is provided, `ArrayObject` is used: + +```php title="Default Object Prototype" +$resultSet = new HydratingResultSet(new ReflectionHydrator()); +``` + +#### Runtime Hydrator Changes + +You can change the hydration strategy at runtime: + +```php title="Changing Hydrator at Runtime" +use Laminas\Hydrator\ClassMethodsHydrator; +use Laminas\Hydrator\ReflectionHydrator; + +$resultSet = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() +); +$resultSet->initialize($result); + +foreach ($resultSet as $user) { + printf("%s %s\n", $user->getFirstName(), $user->getLastName()); +} + +$resultSet->setHydrator(new ClassMethodsHydrator()); +``` + +## Buffer Management + +Result sets can be buffered to allow multiple iterations and rewinding. By default, +result sets are not buffered until explicitly requested. + +### buffer() Method + +Forces the result set to buffer all rows into memory: + +```php title="Buffering for Multiple Iterations" +$resultSet = new ResultSet(); +$resultSet->initialize($result); +$resultSet->buffer(); + +foreach ($resultSet as $row) { + printf("%s\n", $row->name); +} + +$resultSet->rewind(); + +foreach ($resultSet as $row) { + printf("%s (second iteration)\n", $row->name); +} +``` + +**Important:** Calling `buffer()` after iteration has started throws +`RuntimeException`: + +```php title="Buffer After Iteration Error" +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + break; +} + +$resultSet->buffer(); +``` + +Throws: + +```text +RuntimeException: Buffering must be enabled before iteration is +started +``` + +### isBuffered() Method + +Checks if the result set is currently buffered: + +```php title="Checking Buffer Status" +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +var_dump($resultSet->isBuffered()); + +$resultSet->buffer(); + +var_dump($resultSet->isBuffered()); +``` + +Outputs: + +```text +bool(false) +bool(true) +``` + +### Automatic Buffering + +Arrays and certain data sources are automatically buffered: + +```php title="Array Data Source Auto-Buffering" +$resultSet = new ResultSet(); +$resultSet->initialize([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], +]); + +var_dump($resultSet->isBuffered()); +``` + +Outputs: + +```text +bool(true) +``` + +## ArrayObject Access Patterns + +When using ArrayObject mode (default), rows support both property and array +access: + +```php title="Property and Array Access" +$resultSet = new ResultSet(ResultSetReturnType::ArrayObject); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + printf("Property access: %s\n", $row->username); + printf("Array access: %s\n", $row['username']); + + if (isset($row->email)) { + printf("Email: %s\n", $row->email); + } + + if (isset($row['phone'])) { + printf("Phone: %s\n", $row['phone']); + } +} +``` + +This flexibility comes from `ArrayObject` being constructed with the +`ArrayObject::ARRAY_AS_PROPS` flag. + +### Custom ArrayObject Prototypes + +You can provide a custom ArrayObject subclass: + +```php title="Custom Row Class with Helper Methods" +class CustomRow extends ArrayObject +{ + public function getFullName(): string + { + return $this['first_name'] . ' ' . $this['last_name']; + } +} + +$prototype = new CustomRow([], ArrayObject::ARRAY_AS_PROPS); +$resultSet = new ResultSet( + ResultSetReturnType::ArrayObject, + $prototype +); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + printf("Full name: %s\n", $row->getFullName()); +} +``` + +## The Prototype Pattern + +Result sets use the prototype pattern for efficiency and state isolation. + +### How It Works + +When `Adapter::query()` or `TableGateway::select()` execute, they: + +1. Clone the prototype ResultSet +2. Initialize the clone with fresh data +3. Return the clone + +This ensures each query gets an isolated ResultSet instance: + +```php title="Independent Query Results" +$resultSet1 = $adapter->query('SELECT * FROM users'); +$resultSet2 = $adapter->query('SELECT * FROM posts'); +``` + +Both `$resultSet1` and `$resultSet2` are independent clones with their own +state. + +### Customizing the Prototype + +You can provide a custom ResultSet prototype to the Adapter: + +```php title="Custom Adapter Prototype" +use PhpDb\Adapter\Adapter; +use PhpDb\ResultSet\ResultSet; +use PhpDb\ResultSet\ResultSetReturnType; + +$customResultSet = new ResultSet(ResultSetReturnType::Array); + +$adapter = new Adapter($driver, $platform, $customResultSet); + +$resultSet = $adapter->query('SELECT * FROM users'); +``` + +Now all queries return plain arrays instead of ArrayObject instances. + +### TableGateway Prototype + +TableGateway also uses a ResultSet prototype: + +```php title="TableGateway with HydratingResultSet" +use PhpDb\ResultSet\HydratingResultSet; +use PhpDb\TableGateway\TableGateway; +use Laminas\Hydrator\ReflectionHydrator; + +$prototype = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() +); + +$userTable = new TableGateway('users', $adapter, null, $prototype); + +$users = $userTable->select(['status' => 'active']); + +foreach ($users as $user) { + printf("%s: %s\n", $user->getId(), $user->getEmail()); +} +``` + +## Performance and Memory Management + +### Buffered vs Unbuffered + +**Unbuffered (default):** + +- Memory usage: O(1) per row +- Supports single iteration only +- Cannot rewind without buffering +- Ideal for large result sets processed once + +**Buffered:** + +- Memory usage: O(n) for all rows +- Supports multiple iterations +- Allows rewinding +- Required for `count()` on unbuffered sources +- Required for `toArray()` + +### When to Buffer + +Buffer when you need to: + +```php title="Buffering for Count and Multiple Passes" +$resultSet->buffer(); + +$count = $resultSet->count(); + +foreach ($resultSet as $row) { + processRow($row); +} + +$resultSet->rewind(); + +foreach ($resultSet as $row) { + processRowAgain($row); +} +``` + +Don't buffer for single-pass large result sets: + +```php title="Streaming Large Result Sets" +$resultSet = $adapter->query('SELECT * FROM huge_table'); + +foreach ($resultSet as $row) { + processRow($row); +} +``` + +### Memory Efficiency Comparison + +```php title="Comparing Array vs ArrayObject Mode" +use PhpDb\ResultSet\ResultSetReturnType; + +$arrayMode = new ResultSet(ResultSetReturnType::Array); +$arrayMode->initialize($result); + +$arrayObjectMode = new ResultSet(ResultSetReturnType::ArrayObject); +$arrayObjectMode->initialize($result); +``` + +Array mode uses less memory per row than ArrayObject mode because it avoids +object overhead. + +## Advanced Usage + +### Multiple Hydrators + +Switch hydrators based on context: + +```php title="Conditional Hydrator Selection" +use Laminas\Hydrator\ClassMethodsHydrator; +use Laminas\Hydrator\ReflectionHydrator; + +$resultSet = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() +); + +if ($includePrivateProps) { + $resultSet->setHydrator(new ReflectionHydrator()); +} else { + $resultSet->setHydrator(new ClassMethodsHydrator()); +} +``` + +### Converting to Arrays + +Extract all rows as arrays: + +```php title="Using toArray()" +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +$allRows = $resultSet->toArray(); + +printf("Found %d rows\n", count($allRows)); +``` + +With HydratingResultSet, `toArray()` uses the hydrator's extractor: + +```php title="toArray() with HydratingResultSet" +$resultSet = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() +); +$resultSet->initialize($result); + +$allRows = $resultSet->toArray(); +``` + +Each row is extracted back to an array using the hydrator's `extract()` +method. + +### Accessing Current Row + +Get the current row without iteration: + +```php title="Getting First Row with current()" +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +$firstRow = $resultSet->current(); +``` + +This returns the first row without advancing the iterator. diff --git a/docs/book/result-set/examples.md b/docs/book/result-set/examples.md new file mode 100644 index 000000000..cf4f14843 --- /dev/null +++ b/docs/book/result-set/examples.md @@ -0,0 +1,319 @@ +# Result Set Examples and Troubleshooting + +## Common Patterns and Best Practices + +### Processing Large Result Sets + +For memory efficiency with large result sets: + +```php +$resultSet = $adapter->query('SELECT * FROM large_table'); + +foreach ($resultSet as $row) { + processRow($row); + + if ($someCondition) { + break; + } +} +``` + +Don't buffer or call `toArray()` on large datasets. + +### Reusable Hydrated Entities + +Create a reusable ResultSet prototype: + +```php +function createUserResultSet(): HydratingResultSet +{ + return new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() + ); +} + +$users = $userTable->select(['status' => 'active']); + +foreach ($users as $user) { + printf("%s\n", $user->getEmail()); +} +``` + +### Counting Results + +For accurate counts with unbuffered result sets, buffer first: + +```php +$resultSet = $adapter->query('SELECT * FROM users'); +$resultSet->buffer(); + +printf("Total users: %d\n", $resultSet->count()); + +foreach ($resultSet as $user) { + printf("User: %s\n", $user->username); +} +``` + +```php title="Checking for Empty Results" +$resultSet = $adapter->query('SELECT * FROM users WHERE id = ?', [999]); + +if ($resultSet->count() === 0) { + printf("No users found\n"); +} +``` + +### Multiple Iterations + +When you need to iterate over results multiple times: + +```php +$resultSet = new ResultSet(); +$resultSet->initialize($result); +$resultSet->buffer(); // Must buffer before first iteration + +// First pass - collect IDs +$ids = []; +foreach ($resultSet as $row) { + $ids[] = $row->id; +} + +// Rewind and iterate again +$resultSet->rewind(); + +// Second pass - process data +foreach ($resultSet as $row) { + processRow($row); +} +``` + +### Conditional Hydration + +Choose hydration based on query type: + +```php +use Laminas\Hydrator\ClassMethodsHydrator; +use Laminas\Hydrator\ReflectionHydrator; + +function getResultSet(string $entityClass, bool $useReflection = true): HydratingResultSet +{ + $hydrator = $useReflection + ? new ReflectionHydrator() + : new ClassMethodsHydrator(); + + return new HydratingResultSet($hydrator, new $entityClass()); +} + +$users = $userTable->select(['status' => 'active']); +``` + +### Working with Joins + +When joining tables, use array mode or custom ArrayObject: + +```php +use PhpDb\ResultSet\ResultSetReturnType; + +$resultSet = new ResultSet(ResultSetReturnType::Array); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + $userId = $row['user_id']; + $userName = $row['user_name']; + $orderTotal = $row['order_total']; + + printf("User %s has order total: $%.2f\n", $userName, $orderTotal); +} +``` + +### Transforming Results + +Transform rows during iteration: + +```php +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +$users = []; +foreach ($resultSet as $row) { + $users[] = [ + 'fullName' => $row->first_name . ' ' . $row->last_name, + 'email' => strtolower($row->email), + 'isActive' => (bool) $row->status, + ]; +} +``` + +## Error Handling and Exceptions + +Result sets throw exceptions from the `PhpDb\ResultSet\Exception` namespace. + +### InvalidArgumentException + +**Invalid data source type:** + +```php +use PhpDb\ResultSet\Exception\InvalidArgumentException; + +try { + $resultSet->initialize('invalid'); +} catch (InvalidArgumentException $e) { + printf("Error: %s\n", $e->getMessage()); +} +``` + +**Invalid row prototype:** + +```php +try { + $invalidPrototype = new ArrayObject(); + unset($invalidPrototype->exchangeArray); + $resultSet->setRowPrototype($invalidPrototype); +} catch (InvalidArgumentException $e) { + printf("Error: %s\n", $e->getMessage()); +} +``` + +**Non-object passed to HydratingResultSet:** + +```php +try { + $resultSet->setRowPrototype('not an object'); +} catch (InvalidArgumentException $e) { + printf("Error: %s\n", $e->getMessage()); +} +``` + +### RuntimeException + +**Buffering after iteration started:** + +```php +use PhpDb\ResultSet\Exception\RuntimeException; + +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + break; +} + +try { + $resultSet->buffer(); +} catch (RuntimeException $e) { + printf("Error: %s\n", $e->getMessage()); +} +``` + +**toArray() on non-castable rows:** + +```php +try { + $resultSet->toArray(); +} catch (RuntimeException $e) { + printf("Error: Could not convert row to array\n"); +} +``` + +## Troubleshooting + +### Property Access Not Working + +`$row->column_name` returns null? Ensure using ArrayObject mode (default), +or use array access: `$row['column_name']`. + +### Hydration Failures + +Object properties not populated? Match hydrator to object structure: + +- `ReflectionHydrator` for protected/private properties +- `ClassMethodsHydrator` for public setters + +### Rows Are Empty Objects + +Column names must match property names or setter methods: + +```php +// Database columns: first_name, last_name +class UserEntity +{ + // Matches column name + protected string $first_name; + // For ClassMethodsHydrator + public function setFirstName($value) {} +} +``` + +### toArray() Issues + +Ensure the result set is buffered first: `$resultSet->buffer()`. For +`HydratingResultSet`, the hydrator must have an `extract()` method +(e.g., `ReflectionHydrator`). + +## Performance Tips + +### Use Array Mode for Read-Only Data + +When you don't need object features: + +```php +use PhpDb\ResultSet\ResultSetReturnType; + +$resultSet = new ResultSet(ResultSetReturnType::Array); +$resultSet->initialize($result); +``` + +### Avoid Buffering Large Result Sets + +Process rows one at a time: + +```php +$resultSet = $adapter->query('SELECT * FROM million_rows'); + +foreach ($resultSet as $row) { + // Process each row immediately + yield processRow($row); +} +``` + +```php title="Use Generators for Transformation" +function transformUsers(ResultSetInterface $resultSet): Generator +{ + foreach ($resultSet as $row) { + yield [ + 'name' => $row->first_name . ' ' . $row->last_name, + 'email' => $row->email, + ]; + } +} + +$users = transformUsers($resultSet); +foreach ($users as $user) { + printf("%s: %s\n", $user['name'], $user['email']); +} +``` + +### Limit Queries When Possible + +Reduce data at the database level: + +```php +$resultSet = $adapter->query( + 'SELECT id, name FROM users WHERE active = 1 LIMIT 100' +); +``` + +### Profile Memory Usage + +Monitor memory with large result sets: + +```php +$startMemory = memory_get_usage(); + +foreach ($resultSet as $row) { + processRow($row); +} + +$endMemory = memory_get_usage(); +printf("Memory used: %d bytes\n", $endMemory - $startMemory); +``` diff --git a/docs/book/result-set/intro.md b/docs/book/result-set/intro.md new file mode 100644 index 000000000..9b1fb1d26 --- /dev/null +++ b/docs/book/result-set/intro.md @@ -0,0 +1,162 @@ +# Result Sets + +`PhpDb\ResultSet` abstracts iteration over database query results. Result +sets implement `ResultSetInterface` and are typically populated from +`ResultInterface` instances returned by query execution. Components use the +prototype pattern to clone and specialize result sets with specific data +sources. + +`ResultSetInterface` is defined as follows: + +## ResultSetInterface Definition + +```php +use Countable; +use Traversable; + +interface ResultSetInterface extends Traversable, Countable +{ + public function initialize(iterable $dataSource): ResultSetInterface; + public function getFieldCount(): mixed; + public function setRowPrototype( + ArrayObject $rowPrototype + ): ResultSetInterface; + public function getRowPrototype(): ?object; +} +``` + +## Quick Start + +`PhpDb\ResultSet\ResultSet` is the most basic form of a `ResultSet` object +that will expose each row as either an `ArrayObject`-like object or an array of +row data. By default, `PhpDb\Adapter\Adapter` will use a prototypical +`PhpDb\ResultSet\ResultSet` object for iterating when using the +`PhpDb\Adapter\Adapter::query()` method. + +### Basic Usage + +The following is an example workflow similar to what one might find inside +`PhpDb\Adapter\Adapter::query()`: + +```php +use PhpDb\Adapter\Driver\ResultInterface; +use PhpDb\ResultSet\ResultSet; + +$statement = $driver->createStatement('SELECT * FROM users'); +$statement->prepare(); +$result = $statement->execute($parameters); + +if ($result instanceof ResultInterface && $result->isQueryResult()) { + $resultSet = new ResultSet(); + $resultSet->initialize($result); + + foreach ($resultSet as $row) { + printf("User: %s %s\n", $row->first_name, $row->last_name); + } +} +``` + +## ResultSet Classes + +### AbstractResultSet + +For most purposes, either an instance of `PhpDb\ResultSet\ResultSet` or a +derivative of `PhpDb\ResultSet\AbstractResultSet` will be used. The +implementation of the `AbstractResultSet` offers the following core +functionality: + +```php title="AbstractResultSet API" +namespace PhpDb\ResultSet; + +use Iterator; +use IteratorAggregate; +use PhpDb\Adapter\Driver\ResultInterface; + +abstract class AbstractResultSet implements Iterator, ResultSetInterface +{ + public function initialize( + array|Iterator|IteratorAggregate|ResultInterface $dataSource + ): ResultSetInterface; + public function getDataSource(): + array|Iterator|IteratorAggregate|ResultInterface; + public function getFieldCount(): int; + + public function buffer(): ResultSetInterface; + public function isBuffered(): bool; + + public function next(): void; + public function key(): int; + public function current(): mixed; + public function valid(): bool; + public function rewind(): void; + + public function count(): int; + + public function toArray(): array; +} +``` + +## HydratingResultSet + +`PhpDb\ResultSet\HydratingResultSet` is a more flexible `ResultSet` object +that allows the developer to choose an appropriate "hydration strategy" for +getting row data into a target object. While iterating over results, +`HydratingResultSet` will take a prototype of a target object and clone it +once for each row. The `HydratingResultSet` will then hydrate that clone with +the row data. + +The `HydratingResultSet` depends on +[laminas-hydrator](https://docs.laminas.dev/laminas-hydrator), which you will +need to install: + +```bash title="Installing laminas-hydrator" +composer require laminas/laminas-hydrator +``` + +In the example below, rows from the database will be iterated, and during +iteration, `HydratingResultSet` will use the `Reflection` based hydrator to +inject the row data directly into the protected members of the cloned +`UserEntity` object: + +```php title="Using HydratingResultSet with ReflectionHydrator" +use PhpDb\Adapter\Driver\ResultInterface; +use PhpDb\ResultSet\HydratingResultSet; +use Laminas\Hydrator\Reflection as ReflectionHydrator; + +$statement = $driver->createStatement('SELECT * FROM users'); +$statement->prepare(); +$result = $statement->execute(); + +if ($result instanceof ResultInterface && $result->isQueryResult()) { + $resultSet = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() + ); + $resultSet->initialize($result); + + foreach ($resultSet as $user) { + printf("%s %s\n", $user->getFirstName(), $user->getLastName()); + } +} +``` + +For more information, see the +[laminas-hydrator](https://docs.laminas.dev/laminas-hydrator/) +documentation to get a better sense of the different strategies that can be +employed in order to populate a target object. + +## Data Source Types + +The `initialize()` method accepts arrays, `Iterator`, `IteratorAggregate`, +or `ResultInterface`: + +```php +// Arrays (auto-buffered, allows multiple iterations) +$resultSet->initialize([['id' => 1], ['id' => 2]]); + +// Iterator/IteratorAggregate +$resultSet->initialize(new ArrayIterator($data)); + +// ResultInterface (most common - from query execution) +$resultSet->initialize($statement->execute()); +``` diff --git a/docs/book/row-gateway.md b/docs/book/row-gateway.md index 5f11f0860..cfeb1c467 100644 --- a/docs/book/row-gateway.md +++ b/docs/book/row-gateway.md @@ -1,15 +1,13 @@ # Row Gateways -`PhpDb\RowGateway` is a sub-component of laminas-db that implements the Row Data -Gateway pattern described in the book [Patterns of Enterprise Application -Architecture](http://www.martinfowler.com/books/eaa.html). Row Data Gateways -model individual rows of a database table, and provide methods such as `save()` -and `delete()` that persist the row to the database. Likewise, after a row from -the database is retrieved, it can then be manipulated and `save()`'d back to -the database in the same position (row), or it can be `delete()`'d from the -table. +`PhpDb\RowGateway` implements the +[Row Data Gateway pattern](http://www.martinfowler.com/eaaCatalog/rowDataGateway.html), +an object that wraps a single database row, providing `save()` and `delete()` +methods to persist changes. -`RowGatewayInterface` defines the methods `save()` and `delete()`: +`RowGatewayInterface` defines these methods: + +## RowGatewayInterface Definition ```php namespace PhpDb\RowGateway; @@ -29,11 +27,14 @@ standalone, you need an `Adapter` instance and a set of data to work with. The following demonstrates a basic use case. -```php +```php title="Standalone RowGateway Usage" use PhpDb\RowGateway\RowGateway; // Query the database: -$resultSet = $adapter->query('SELECT * FROM `user` WHERE `id` = ?', [2]); +$resultSet = $adapter->query( + 'SELECT * FROM `user` WHERE `id` = ?', + [2] +); // Get array of data: $rowData = $resultSet->current()->getArrayCopy(); @@ -50,14 +51,15 @@ $rowGateway->save(); $rowGateway->delete(); ``` -The workflow described above is greatly simplified when `RowGateway` is used in -conjunction with the [TableGateway RowGatewayFeature](table-gateway.md#tablegateway-features). -In that paradigm, `select()` operations will produce a `ResultSet` that iterates +The workflow described above is greatly simplified when `RowGateway` is used +in conjunction with the +[TableGateway RowGatewayFeature](table-gateway.md#tablegateway-features). In +that paradigm, `select()` operations will produce a `ResultSet` that iterates `RowGateway` instances. As an example: -```php +```php title="Using RowGateway with TableGateway" use PhpDb\TableGateway\Feature\RowGatewayFeature; use PhpDb\TableGateway\TableGateway; @@ -74,10 +76,10 @@ $artistRow->save(); If you wish to have custom behaviour in your `RowGateway` objects — essentially making them behave similarly to the [ActiveRecord](http://www.martinfowler.com/eaaCatalog/activeRecord.html) -pattern), pass a prototype object implementing the `RowGatewayInterface` to the -`RowGatewayFeature` constructor instead of a primary key: +pattern), pass a prototype object implementing the `RowGatewayInterface` to +the `RowGatewayFeature` constructor instead of a primary key: -```php +```php title="Custom ActiveRecord-Style Implementation" use PhpDb\TableGateway\Feature\RowGatewayFeature; use PhpDb\TableGateway\TableGateway; use PhpDb\RowGateway\RowGatewayInterface; @@ -94,5 +96,9 @@ class Artist implements RowGatewayInterface // ... save() and delete() implementations } -$table = new TableGateway('artist', $adapter, new RowGatewayFeature(new Artist($adapter))); +$table = new TableGateway( + 'artist', + $adapter, + new RowGatewayFeature(new Artist($adapter)) +); ``` diff --git a/docs/book/sql-ddl/advanced.md b/docs/book/sql-ddl/advanced.md new file mode 100644 index 000000000..3110b1ee7 --- /dev/null +++ b/docs/book/sql-ddl/advanced.md @@ -0,0 +1,473 @@ +# Advanced DDL Features + +## Error Handling + +### DDL Error Behavior + +**Important:** DDL objects themselves do **not throw exceptions** during +construction or configuration. They are designed to build up state without +validation. + +Errors typically occur during: + +1. **SQL Generation** - When `buildSqlString()` is called +2. **Execution** - When the adapter executes the DDL statement + +### Exception Types + +DDL-related operations can throw: + +```php +use PhpDb\Sql\Exception\InvalidArgumentException; +use PhpDb\Sql\Exception\RuntimeException; +use PhpDb\Adapter\Exception\InvalidQueryException; +``` + +### Common Error Scenarios + +#### 1. Empty Expression + +```php +use PhpDb\Sql\Expression; + +try { + $expr = new Expression(''); // Throws InvalidArgumentException +} catch (\PhpDb\Sql\Exception\InvalidArgumentException $e) { + echo "Error: " . $e->getMessage(); + // Error: Supplied expression must not be an empty string. +} +``` + +#### 2. SQL Execution Errors + +```php +use PhpDb\Sql\Sql; +use PhpDb\Sql\Ddl\CreateTable; + +$table = new CreateTable('users'); +// ... configure table ... + +$sql = new Sql($adapter); + +try { + $adapter->query( + $sql->buildSqlString($table), + $adapter::QUERY_MODE_EXECUTE + ); +} catch (\Exception $e) { + // Catch execution errors (syntax errors, constraint violations, etc.) + echo "DDL execution failed: " . $e->getMessage(); +} +``` + +#### 3. Platform-Specific Errors + +Different platforms may reject different DDL constructs: + +```php +// SQLite doesn't support DROP CONSTRAINT +$alter = new AlterTable('users'); +$alter->dropConstraint('unique_email'); + +try { + $adapter->query($sql->buildSqlString($alter), $adapter::QUERY_MODE_EXECUTE); +} catch (\Exception $e) { + // SQLite will throw an error: ALTER TABLE syntax does not support DROP CONSTRAINT + echo "Platform error: " . $e->getMessage(); +} +``` + +### Error Handling Best Practices + +#### 1. Wrap DDL Execution in Try-Catch + +```php +function createTable($adapter, $table) { + $sql = new Sql($adapter); + + try { + $adapter->query( + $sql->buildSqlString($table), + $adapter::QUERY_MODE_EXECUTE + ); + return true; + } catch (\PhpDb\Adapter\Exception\InvalidQueryException $e) { + // SQL syntax or execution error + error_log("DDL execution failed: " . $e->getMessage()); + return false; + } catch (\Exception $e) { + // General error + error_log("Unexpected error: " . $e->getMessage()); + return false; + } +} +``` + +#### 2. Validate Platform Capabilities + +```php +function alterTable($adapter, $alterTable) { + $platformName = $adapter->getPlatform()->getName(); + + // Check if platform supports ALTER TABLE ... DROP CONSTRAINT + if ($platformName === 'SQLite' && hasDropConstraint($alterTable)) { + throw new \RuntimeException( + 'SQLite does not support DROP CONSTRAINT in ALTER TABLE' + ); + } + + // Proceed with execution + $sql = new Sql($adapter); + $adapter->query($sql->buildSqlString($alterTable), $adapter::QUERY_MODE_EXECUTE); +} +``` + +#### 3. Transaction Wrapping + +```php +use PhpDb\Adapter\Adapter; + +function executeMigration($adapter, array $ddlObjects) { + $connection = $adapter->getDriver()->getConnection(); + + try { + $connection->beginTransaction(); + + $sql = new Sql($adapter); + foreach ($ddlObjects as $ddl) { + $adapter->query( + $sql->buildSqlString($ddl), + Adapter::QUERY_MODE_EXECUTE + ); + } + + $connection->commit(); + return true; + + } catch (\Exception $e) { + $connection->rollback(); + error_log("Migration failed: " . $e->getMessage()); + return false; + } +} +``` + +### Debugging DDL Issues + +#### Use getRawState() for Inspection + +```php +$table = new CreateTable('users'); +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('name', 255)); + +// Inspect the DDL object state +$state = $table->getRawState(); +print_r($state); + +/* +Array( + [table] => users + [isTemporary] => false + [columns] => Array( + [0] => PhpDb\Sql\Ddl\Column\Integer Object + [1] => PhpDb\Sql\Ddl\Column\Varchar Object + ) + [constraints] => Array() +) +*/ +``` + +#### Generate SQL Without Execution + +```php +$sql = new Sql($adapter); + +// Generate the SQL string to see what will be executed +$sqlString = $sql->buildSqlString($table); +echo $sqlString . "\n"; + +// Review before executing +if (confirmExecution($sqlString)) { + $adapter->query($sqlString, $adapter::QUERY_MODE_EXECUTE); +} +``` + +#### Log DDL Statements + +```php +use PhpDb\Adapter\Adapter; + +function executeDdl($adapter, $ddl, $logger) { + $sql = new Sql($adapter); + $sqlString = $sql->buildSqlString($ddl); + + // Log before execution + $logger->info("Executing DDL: " . $sqlString); + + try { + $adapter->query($sqlString, Adapter::QUERY_MODE_EXECUTE); + $logger->info("DDL executed successfully"); + } catch (\Exception $e) { + $logger->error("DDL execution failed: " . $e->getMessage()); + throw $e; + } +} +``` + +## Best Practices + +### Naming Conventions + +#### Table Names + +```php +// Use plural, lowercase, snake_case +new CreateTable('users'); // Good +new CreateTable('user_roles'); // Good +new CreateTable('order_items'); // Good + +new CreateTable('User'); // Avoid - capitalization issues +new CreateTable('userRole'); // Avoid - camelCase +new CreateTable('user'); // Avoid - singular (debatable) +``` + +#### Column Names + +```php +// Use singular, lowercase, snake_case +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('first_name', 100)); +$table->addColumn(new Column\Integer('user_id')); // Foreign key + +// Avoid +// 'firstName' - camelCase +// 'FirstName' - PascalCase +// 'FIRST_NAME' - all caps +``` + +#### Constraint Names + +```php +// Primary keys: pk_{table} +new Constraint\PrimaryKey('id', 'pk_users'); + +// Foreign keys: fk_{table}_{referenced_table} OR fk_{table}_{column} +new Constraint\ForeignKey('fk_order_customer', 'customer_id', 'customers', 'id'); +new Constraint\ForeignKey('fk_order_user', 'user_id', 'users', 'id'); + +// Unique constraints: unique_{table}_{column} OR unique_{descriptive_name} +new Constraint\UniqueKey('email', 'unique_user_email'); +new Constraint\UniqueKey(['tenant_id', 'username'], 'unique_tenant_username'); + +// Check constraints: check_{descriptive_name} +new Constraint\Check('age >= 18', 'check_adult_age'); +new Constraint\Check('price > 0', 'check_positive_price'); +``` + +#### Index Names + +```php +// idx_{table}_{column(s)} OR idx_{purpose} +new Index('email', 'idx_user_email'); +new Index(['last_name', 'first_name'], 'idx_user_name'); +new Index(['created_at', 'status'], 'idx_recent_active'); +``` + +### Schema Migration Patterns + +#### Pattern 1: Versioned Migrations + +```php +class Migration_001_CreateUsersTable { + public function up($adapter) { + $sql = new Sql($adapter); + $table = new CreateTable('users'); + + $id = new Column\Integer('id'); + $id->setOption('AUTO_INCREMENT', true); + $id->addConstraint(new Constraint\PrimaryKey()); + $table->addColumn($id); + + $table->addColumn(new Column\Varchar('email', 255)); + $table->addConstraint(new Constraint\UniqueKey('email', 'unique_email')); + + $adapter->query($sql->buildSqlString($table), $adapter::QUERY_MODE_EXECUTE); + } + + public function down($adapter) { + $sql = new Sql($adapter); + $drop = new DropTable('users'); + $adapter->query($sql->buildSqlString($drop), $adapter::QUERY_MODE_EXECUTE); + } +} +``` + +#### Pattern 2: Safe Migrations + +```php +// Check if table exists before creating +function safeCreateTable($adapter, $tableName, $ddlObject) { + $sql = new Sql($adapter); + + // Check existence (platform-specific) + $platformName = $adapter->getPlatform()->getName(); + + $exists = false; + if ($platformName === 'MySQL') { + $result = $adapter->query( + "SHOW TABLES LIKE '$tableName'", + $adapter::QUERY_MODE_EXECUTE + ); + $exists = $result->count() > 0; + } + + if (!$exists) { + $adapter->query( + $sql->buildSqlString($ddlObject), + $adapter::QUERY_MODE_EXECUTE + ); + } +} +``` + +#### Pattern 3: Idempotent Migrations + +```php +// Use IF NOT EXISTS (platform-specific) +// Note: PhpDb DDL doesn't support IF NOT EXISTS directly +// You'll need to handle this at the SQL level or check existence first + +function createTableIfNotExists($adapter, $tableName, CreateTable $table) { + $sql = new Sql($adapter); + $platformName = $adapter->getPlatform()->getName(); + + if ($platformName === 'MySQL') { + // Manually construct IF NOT EXISTS + $sqlString = $sql->buildSqlString($table); + $sqlString = str_replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $sqlString); + $adapter->query($sqlString, $adapter::QUERY_MODE_EXECUTE); + } else { + // Fallback: check and create + safeCreateTable($adapter, $tableName, $table); + } +} +``` + +### Performance Considerations + +#### 1. Batch Multiple DDL Operations + +```php +// Bad: Multiple ALTER TABLE statements +$alter1 = new AlterTable('users'); +$alter1->addColumn(new Column\Varchar('phone', 20)); +$adapter->query($sql->buildSqlString($alter1), $adapter::QUERY_MODE_EXECUTE); + +$alter2 = new AlterTable('users'); +$alter2->addColumn(new Column\Varchar('city', 100)); +$adapter->query($sql->buildSqlString($alter2), $adapter::QUERY_MODE_EXECUTE); + +// Good: Single ALTER TABLE with multiple operations +$alter = new AlterTable('users'); +$alter->addColumn(new Column\Varchar('phone', 20)); +$alter->addColumn(new Column\Varchar('city', 100)); +$adapter->query($sql->buildSqlString($alter), $adapter::QUERY_MODE_EXECUTE); +``` + +#### 2. Add Indexes After Bulk Insert + +```php +// For large initial data loads: + +// 1. Create table without indexes +$table = new CreateTable('products'); +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('name', 255)); +// ... more columns ... +$adapter->query($sql->buildSqlString($table), $adapter::QUERY_MODE_EXECUTE); + +// 2. Load data +// ... insert thousands of rows ... + +// 3. Add indexes after data is loaded +$alter = new AlterTable('products'); +$alter->addConstraint(new Index('name', 'idx_name')); +$alter->addConstraint(new Index(['category_id', 'price'], 'idx_category_price')); +$adapter->query($sql->buildSqlString($alter), $adapter::QUERY_MODE_EXECUTE); +``` + +#### 3. Foreign Key Impact + +Foreign keys add overhead to INSERT/UPDATE/DELETE operations: + +```php title="Disabling Foreign Key Checks for Bulk Operations" +// If you need to bulk load data, consider: +// 1. Disable foreign key checks (platform-specific) +// 2. Load data +// 3. Re-enable foreign key checks + +// MySQL example (outside DDL abstraction): +$adapter->query('SET FOREIGN_KEY_CHECKS = 0', $adapter::QUERY_MODE_EXECUTE); +// ... bulk operations ... +$adapter->query('SET FOREIGN_KEY_CHECKS = 1', $adapter::QUERY_MODE_EXECUTE); +``` + +### Testing DDL Changes + +#### 1. Test on Development Copy + +```php +// Always test DDL on a copy of production data +$devAdapter = new Adapter($devConfig); +$prodAdapter = new Adapter($prodConfig); + +// Test migration on dev first +try { + executeMigration($devAdapter, $ddlObjects); + echo "Dev migration successful\n"; + + // If successful, run on production + executeMigration($prodAdapter, $ddlObjects); +} catch (\Exception $e) { + echo "Migration failed on dev: " . $e->getMessage() . "\n"; + // Don't touch production +} +``` + +#### 2. Generate and Review SQL + +```php +// Generate DDL SQL and review before executing +$sql = new Sql($adapter); + +foreach ($ddlObjects as $ddl) { + $sqlString = $sql->buildSqlString($ddl); + echo $sqlString . ";\n\n"; +} + +// Review output, then execute if satisfied +``` + +#### 3. Backup Before DDL + +```php +function executeSafeDdl($adapter, $ddl) { + // 1. Backup (implementation depends on platform) + backupDatabase($adapter); + + // 2. Execute DDL + try { + $sql = new Sql($adapter); + $adapter->query( + $sql->buildSqlString($ddl), + $adapter::QUERY_MODE_EXECUTE + ); + return true; + } catch (\Exception $e) { + // 3. Restore on failure + restoreDatabase($adapter); + throw $e; + } +} +``` diff --git a/docs/book/sql-ddl/alter-drop.md b/docs/book/sql-ddl/alter-drop.md new file mode 100644 index 000000000..c01053505 --- /dev/null +++ b/docs/book/sql-ddl/alter-drop.md @@ -0,0 +1,518 @@ +# Modifying and Dropping Tables + +## AlterTable + +The `AlterTable` class represents an `ALTER TABLE` statement. It provides +methods to modify existing table structures. + +```php title="Basic AlterTable Creation" +use PhpDb\Sql\Ddl\AlterTable; +use PhpDb\Sql\TableIdentifier; + +// Simple +$alter = new AlterTable('users'); + +// With schema +$alter = new AlterTable(new TableIdentifier('users', 'public')); + +// Set after construction +$alter = new AlterTable(); +$alter->setTable('users'); +``` + +### Adding Columns + +Add new columns to an existing table: + +```php +use PhpDb\Sql\Ddl\AlterTable; +use PhpDb\Sql\Ddl\Column; + +$alter = new AlterTable('users'); + +// Add a single column +$alter->addColumn(new Column\Varchar('phone', 20)); + +// Add multiple columns +$alter->addColumn(new Column\Varchar('city', 100)); +$alter->addColumn(new Column\Varchar('country', 2)); +``` + +### SQL Output for Adding Columns + +**Generated SQL:** + +```sql +ALTER TABLE "users" +ADD COLUMN "phone" VARCHAR(20) NOT NULL, +ADD COLUMN "city" VARCHAR(100) NOT NULL, +ADD COLUMN "country" VARCHAR(2) NOT NULL +``` + +### Changing Columns + +Modify existing column definitions: + +```php +$alter = new AlterTable('users'); + +// Change column type or properties +$alter->changeColumn('name', new Column\Varchar('name', 500)); +$alter->changeColumn('age', new Column\Integer('age')); + +// Rename and change at the same time +$alter->changeColumn('name', new Column\Varchar('full_name', 200)); +``` + +### SQL Output for Changing Columns + +**Generated SQL:** + +```sql +ALTER TABLE "users" +CHANGE COLUMN "name" "full_name" VARCHAR(200) NOT NULL +``` + +### Dropping Columns + +Remove columns from a table: + +```php +$alter = new AlterTable('users'); + +$alter->dropColumn('old_field'); +$alter->dropColumn('deprecated_column'); +``` + +### SQL Output for Dropping Columns + +**Generated SQL:** + +```sql +ALTER TABLE "users" +DROP COLUMN "old_field", +DROP COLUMN "deprecated_column" +``` + +### Adding Constraints + +Add table constraints: + +```php +use PhpDb\Sql\Ddl\Constraint; + +$alter = new AlterTable('users'); + +// Add primary key +$alter->addConstraint(new Constraint\PrimaryKey('id')); + +// Add unique constraint +$alter->addConstraint(new Constraint\UniqueKey('email', 'unique_email')); + +// Add foreign key +$alter->addConstraint(new Constraint\ForeignKey( + 'fk_user_department', + 'department_id', + 'departments', + 'id', + 'SET NULL', // ON DELETE + 'CASCADE' // ON UPDATE +)); + +// Add check constraint +$alter->addConstraint(new Constraint\Check('age >= 18', 'check_adult')); +``` + +### Dropping Constraints + +Remove constraints from a table: + +```php +$alter = new AlterTable('users'); + +$alter->dropConstraint('old_unique_key'); +$alter->dropConstraint('fk_old_relation'); +``` + +### SQL Output for Dropping Constraints + +**Generated SQL:** + +```sql +ALTER TABLE "users" +DROP CONSTRAINT "old_unique_key", +DROP CONSTRAINT "fk_old_relation" +``` + +### Adding Indexes + +Add indexes to improve query performance: + +```php +use PhpDb\Sql\Ddl\Index\Index; + +$alter = new AlterTable('products'); + +// Simple index +$alter->addConstraint(new Index('name', 'idx_product_name')); + +// Composite index +$alter->addConstraint(new Index(['category', 'price'], 'idx_category_price')); + +// Index with column length specifications +$alter->addConstraint(new Index( + ['title', 'description'], + 'idx_search', + [50, 100] // Index first 50 chars of title, 100 of description +)); +``` + +### Dropping Indexes + +Remove indexes from a table: + +```php +$alter = new AlterTable('products'); + +$alter->dropIndex('idx_old_search'); +$alter->dropIndex('idx_deprecated'); +``` + +### SQL Output for Dropping Indexes + +**Generated SQL:** + +```sql +ALTER TABLE "products" +DROP INDEX "idx_old_search", +DROP INDEX "idx_deprecated" +``` + +### Complex AlterTable Example + +Combine multiple operations in a single statement: + +```php +use PhpDb\Sql\Ddl\AlterTable; +use PhpDb\Sql\Ddl\Column; +use PhpDb\Sql\Ddl\Constraint; +use PhpDb\Sql\Ddl\Index\Index; + +$alter = new AlterTable('users'); + +// Add new columns +$alter->addColumn(new Column\Varchar('email', 255)); +$alter->addColumn(new Column\Varchar('phone', 20)); + +$updated = new Column\Timestamp('updated_at'); +$updated->setDefault('CURRENT_TIMESTAMP'); +$updated->setOption('on_update', true); +$alter->addColumn($updated); + +// Modify existing columns +$alter->changeColumn('name', new Column\Varchar('full_name', 200)); + +// Drop old columns +$alter->dropColumn('old_field'); +$alter->dropColumn('deprecated_field'); + +// Add constraints +$alter->addConstraint(new Constraint\UniqueKey('email', 'unique_email')); +$alter->addConstraint(new Constraint\ForeignKey( + 'fk_user_role', + 'role_id', + 'roles', + 'id', + 'CASCADE', + 'CASCADE' +)); + +// Drop old constraints +$alter->dropConstraint('old_constraint'); + +// Add index +$alter->addConstraint(new Index(['full_name', 'email'], 'idx_user_search')); + +// Drop old index +$alter->dropIndex('idx_old_search'); + +// Execute +$sql = new Sql($adapter); +$adapter->query( + $sql->buildSqlString($alter), + $adapter::QUERY_MODE_EXECUTE +); +``` + +## DropTable + +The `DropTable` class represents a `DROP TABLE` statement. + +```php title="Basic Drop Table" +use PhpDb\Sql\Ddl\DropTable; + +// Simple +$drop = new DropTable('old_table'); + +// Execute +$sql = new Sql($adapter); +$adapter->query( + $sql->buildSqlString($drop), + $adapter::QUERY_MODE_EXECUTE +); +``` + +### SQL Output for Basic Drop Table + +**Generated SQL:** + +```sql +DROP TABLE "old_table" +``` + +```php title="Schema-Qualified Drop" +use PhpDb\Sql\Ddl\DropTable; +use PhpDb\Sql\TableIdentifier; + +$drop = new DropTable(new TableIdentifier('users', 'archive')); +``` + +### SQL Output for Schema-Qualified Drop + +**Generated SQL:** + +```sql +DROP TABLE "archive"."users" +``` + +### Dropping Multiple Tables + +Execute multiple drop statements: + +```php +$tables = ['temp_table1', 'temp_table2', 'old_cache']; + +foreach ($tables as $tableName) { + $drop = new DropTable($tableName); + $adapter->query( + $sql->buildSqlString($drop), + $adapter::QUERY_MODE_EXECUTE + ); +} +``` + +## Platform-Specific Considerations + +### Current Status + +**Important:** Platform-specific DDL decorators have been **removed during +refactoring**. The decorator infrastructure exists in the codebase but specific +platform implementations (MySQL, SQL Server, Oracle, SQLite) have been +deprecated and removed. + +### What This Means + +1. **Platform specialization is handled at the Adapter Platform level**, + not the SQL DDL level +2. **DDL objects are platform-agnostic** - they define the structure, + and the platform renders it appropriately +3. **The decorator system can be used manually** if needed via + `setTypeDecorator()`, but this is advanced usage + +### Platform-Agnostic Approach + +The DDL abstraction is designed to work across platforms without modification: + +```php title="Example of Platform-Agnostic DDL Code" +// This code works on MySQL, PostgreSQL, SQL Server, SQLite, etc. +$table = new CreateTable('users'); +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('name', 255)); + +// The platform adapter handles rendering differences: +// - MySQL: CREATE TABLE `users` (`id` INT NOT NULL, +// `name` VARCHAR(255) NOT NULL) +// - PostgreSQL: CREATE TABLE "users" ("id" INTEGER NOT NULL, +// "name" VARCHAR(255) NOT NULL) +// - SQL Server: CREATE TABLE [users] ([id] INT NOT NULL, +// [name] VARCHAR(255) NOT NULL) +``` + +### Platform-Specific Options + +Use column options for platform-specific features: + +```php title="Using Platform-Specific Column Options" +// MySQL AUTO_INCREMENT +$id = new Column\Integer('id'); +$id->setOption('AUTO_INCREMENT', true); + +// PostgreSQL/SQL Server IDENTITY +$id = new Column\Integer('id'); +$id->setOption('identity', true); + +// MySQL UNSIGNED +$count = new Column\Integer('count'); +$count->setOption('unsigned', true); +``` + +**Note:** Not all options work on all platforms. Test your DDL against your +target database. + +### Platform Detection + +```php title="Detecting Database Platform at Runtime" +// Check platform before using platform-specific options +$platformName = $adapter->getPlatform()->getName(); + +if ($platformName === 'MySQL') { + $id->setOption('AUTO_INCREMENT', true); +} elseif (in_array($platformName, ['PostgreSQL', 'SqlServer'])) { + $id->setOption('identity', true); +} +``` + +## Inspecting DDL Objects + +Use `getRawState()` to inspect the internal configuration of DDL objects: + +```php title="Using getRawState() to Inspect DDL Configuration" +$table = new CreateTable('users'); +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('name', 255)); +$table->addConstraint(new Constraint\PrimaryKey('id')); + +// Get the internal state +$state = $table->getRawState(); +print_r($state); + +/* Output: +Array( + [table] => users + [temporary] => false + [columns] => Array(...) + [constraints] => Array(...) +) +*/ +``` + +This is useful for: + +- Debugging DDL object configuration +- Testing DDL generation +- Introspection and analysis tools + +## Working with Table Identifiers + +Use `TableIdentifier` for schema-qualified table references: + +```php title="Creating and Using Table Identifiers" +use PhpDb\Sql\TableIdentifier; + +// Table in default schema +$identifier = new TableIdentifier('users'); + +// Table in specific schema +$identifier = new TableIdentifier('users', 'public'); +$identifier = new TableIdentifier('audit_log', 'audit'); + +// Use in DDL objects +$table = new CreateTable(new TableIdentifier('users', 'auth')); +$alter = new AlterTable(new TableIdentifier('products', 'inventory')); +$drop = new DropTable(new TableIdentifier('temp', 'scratch')); + +// In foreign keys (schema.table syntax) +$fk = new ForeignKey( + 'fk_user_role', + 'role_id', + new TableIdentifier('roles', 'auth'), // Referenced table with schema + 'id' +); +``` + +## Nullable and Default Values + +### Setting Nullable + +```php title="Configuring Nullable Columns" +// NOT NULL (default for most types) +$column = new Column\Varchar('email', 255); +$column->setNullable(false); + +// Allow NULL +$column = new Column\Varchar('middle_name', 100); +$column->setNullable(true); + +// Check if nullable +if ($column->isNullable()) { + // ... +} +``` + +**Note:** Boolean columns cannot be made nullable: + +```php +$column = new Column\Boolean('is_active'); +$column->setNullable(true); // Has no effect - still NOT NULL +``` + +### Setting Default Values + +```php title="Configuring Default Column Values" +// String default +$column = new Column\Varchar('status', 20); +$column->setDefault('pending'); + +// Numeric default +$column = new Column\Integer('count'); +$column->setDefault(0); + +// SQL expression default +$column = new Column\Timestamp('created_at'); +$column->setDefault('CURRENT_TIMESTAMP'); + +// NULL default (requires nullable column) +$column = new Column\Varchar('notes', 255); +$column->setNullable(true); +$column->setDefault(null); + +// Get default value +$default = $column->getDefault(); +``` + +## Fluent Interface Patterns + +All DDL objects support method chaining for cleaner, more readable code. + +### Chaining Column Configuration + +```php title="Example of Fluent Column Configuration" +$column = (new Column\Varchar('email', 255)) + ->setNullable(false) + ->setDefault('user@example.com') + ->setOption('comment', 'User email address') + ->addConstraint(new Constraint\UniqueKey()); + +$table->addColumn($column); +``` + +### Chaining Table Construction + +```php title="Example of Fluent Table Construction" +$table = (new CreateTable('users')) + ->addColumn( + (new Column\Integer('id')) + ->setOption('AUTO_INCREMENT', true) + ->addConstraint(new Constraint\PrimaryKey()) + ) + ->addColumn( + (new Column\Varchar('username', 50)) + ->setNullable(false) + ) + ->addColumn( + (new Column\Varchar('email', 255)) + ->setNullable(false) + ) + ->addConstraint(new Constraint\UniqueKey('username', 'unique_username')) + ->addConstraint(new Constraint\UniqueKey('email', 'unique_email')); +``` diff --git a/docs/book/sql-ddl/columns.md b/docs/book/sql-ddl/columns.md new file mode 100644 index 000000000..69d89f5d1 --- /dev/null +++ b/docs/book/sql-ddl/columns.md @@ -0,0 +1,551 @@ +# Column Types Reference + +All column types are in the `PhpDb\Sql\Ddl\Column` namespace and implement `ColumnInterface`. + +## Numeric Types + +### Integer + +Standard integer column. + +```php title="Creating Integer Columns" +use PhpDb\Sql\Ddl\Column\Integer; + +$column = new Integer('user_id'); +$column = new Integer('count', false, 0); // NOT NULL with default 0 + +// With display length (platform-specific) +$column = new Integer('user_id'); +$column->setOption('length', 11); +``` + +**Constructor:** + +```php +__construct( + $name, + $nullable = false, + $default = null, + array $options = [] +) +``` + +**Methods:** + +- `setNullable(bool $nullable): self` +- `isNullable(): bool` +- `setDefault(string|int|null $default): self` +- `getDefault(): string|int|null` +- `setOption(string $name, mixed $value): self` +- `setOptions(array $options): self` + +### BigInteger + +For larger integer values (typically 64-bit). + +```php title="Creating BigInteger Columns" +use PhpDb\Sql\Ddl\Column\BigInteger; + +$column = new BigInteger('large_number'); +$column = new BigInteger('id', false, null, ['length' => 20]); +``` + +**Constructor:** + +```php +__construct( + $name, + $nullable = false, + $default = null, + array $options = [] +) +``` + +### Decimal + +Fixed-point decimal numbers with precision and scale. + +```php title="Creating Decimal Columns with Precision and Scale" +use PhpDb\Sql\Ddl\Column\Decimal; + +$column = new Decimal('price', 10, 2); // DECIMAL(10,2) +$column = new Decimal('tax_rate', 5, 4); // DECIMAL(5,4) + +// Can also be set after construction +$column = new Decimal('amount', 10); +$column->setDigits(12); // Change precision +$column->setDecimal(3); // Change scale +``` + +**Constructor:** `__construct($name, $precision, $scale = null)` + +**Methods:** + +- `setDigits(int $digits): self` - Set precision +- `getDigits(): int` - Get precision +- `setDecimal(int $decimal): self` - Set scale +- `getDecimal(): int` - Get scale + +### Floating + +Floating-point numbers. + +```php title="Creating Floating Point Columns" +use PhpDb\Sql\Ddl\Column\Floating; + +$column = new Floating('measurement', 10, 2); + +// Adjustable after construction +$column->setDigits(12); +$column->setDecimal(4); +``` + +**Constructor:** `__construct($name, $digits, $decimal)` + +> The class is named `Floating` rather than `Float` because `float` is a reserved +> keyword in PHP. + +## String Types + +### Varchar + +Variable-length character string. + +```php title="Creating Varchar Columns" +use PhpDb\Sql\Ddl\Column\Varchar; + +$column = new Varchar('name', 255); +$column = new Varchar('email', 320); // Max email length + +// Can be nullable +$column = new Varchar('middle_name', 100); +$column->setNullable(true); +``` + +**Constructor:** `__construct($name, $length)` + +**Methods:** + +- `setLength(int $length): self` +- `getLength(): int` + +### Char + +Fixed-length character string. + +```php title="Creating Fixed-Length Char Columns" +use PhpDb\Sql\Ddl\Column\Char; + +$column = new Char('country_code', 2); // ISO country codes +$column = new Char('status', 1); // Single character status +``` + +**Constructor:** `__construct($name, $length)` + +### Text + +Variable-length text for large strings. + +```php title="Creating Text Columns" +use PhpDb\Sql\Ddl\Column\Text; + +$column = new Text('description'); +$column = new Text('content', 65535); // With length limit + +// Can be nullable and have defaults +$column = new Text('notes', null, true, 'No notes'); +``` + +**Constructor:** + +```php +__construct( + $name, + $length = null, + $nullable = false, + $default = null, + array $options = [] +) +``` + +## Binary Types + +### Binary + +Fixed-length binary data. + +```php title="Creating Binary Columns" +use PhpDb\Sql\Ddl\Column\Binary; + +$column = new Binary('hash', 32); // 32-byte hash +``` + +**Constructor:** + +```php +__construct( + $name, + $length, + $nullable = false, + $default = null, + array $options = [] +) +``` + +### Varbinary + +Variable-length binary data. + +```php title="Creating Varbinary Columns" +use PhpDb\Sql\Ddl\Column\Varbinary; + +$column = new Varbinary('file_data', 65535); +``` + +**Constructor:** `__construct($name, $length)` + +### Blob + +Binary large object for very large binary data. + +```php title="Creating Blob Columns" +use PhpDb\Sql\Ddl\Column\Blob; + +$column = new Blob('image'); +$column = new Blob('document', 16777215); // MEDIUMBLOB size +``` + +**Constructor:** + +```php +__construct( + $name, + $length = null, + $nullable = false, + $default = null, + array $options = [] +) +``` + +## Date and Time Types + +### Date + +Date without time. + +```php title="Creating Date Columns" +use PhpDb\Sql\Ddl\Column\Date; + +$column = new Date('birth_date'); +$column = new Date('hire_date'); +``` + +**Constructor:** `__construct($name)` + +### Time + +Time without date. + +```php title="Creating Time Columns" +use PhpDb\Sql\Ddl\Column\Time; + +$column = new Time('start_time'); +$column = new Time('duration'); +``` + +**Constructor:** `__construct($name)` + +### Datetime + +Date and time combined. + +```php title="Creating Datetime Columns" +use PhpDb\Sql\Ddl\Column\Datetime; + +$column = new Datetime('last_login'); +$column = new Datetime('event_time'); +``` + +**Constructor:** `__construct($name)` + +### Timestamp + +Timestamp with special capabilities. + +```php title="Creating Timestamp Columns with Auto-Update" +use PhpDb\Sql\Ddl\Column\Timestamp; + +// Basic timestamp +$column = new Timestamp('created_at'); +$column->setDefault('CURRENT_TIMESTAMP'); + +// With automatic update on row modification +$column = new Timestamp('updated_at'); +$column->setDefault('CURRENT_TIMESTAMP'); +$column->setOption('on_update', true); +// Generates: TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +``` + +**Constructor:** `__construct($name)` + +**Special Options:** + +- `on_update` - When `true`, adds `ON UPDATE CURRENT_TIMESTAMP` + +## Boolean Type + +### Boolean + +Boolean/bit column. + +> **Note:** Boolean columns are always NOT NULL and cannot be made nullable. + +```php title="Creating Boolean Columns" +use PhpDb\Sql\Ddl\Column\Boolean; + +$column = new Boolean('is_active'); +$column = new Boolean('is_verified'); + +// Attempting to make nullable has no effect +$column->setNullable(true); // Does nothing - stays NOT NULL +``` + +**Constructor:** `__construct($name)` + +**Important:** The `setNullable()` method is overridden to always enforce NOT NULL. + +## Generic Column Type + +### Column + +Generic column type (defaults to INTEGER). Use specific types when possible. + +```php title="Creating Generic Columns" +use PhpDb\Sql\Ddl\Column\Column; + +$column = new Column('custom_field'); +``` + +**Constructor:** `__construct($name = null)` + +## Common Column Methods + +All column types share these methods: + +```php title="Working with Nullable, Defaults, Options, and Constraints" +// Nullable setting +$column->setNullable(true); // Allow NULL values +$column->setNullable(false); // NOT NULL (default for most types) +$isNullable = $column->isNullable(); + +// Default values +$column->setDefault('default_value'); +$column->setDefault(0); +$column->setDefault(null); +$default = $column->getDefault(); + +// Options (platform-specific features) +$column->setOption('AUTO_INCREMENT', true); +$column->setOption('comment', 'User identifier'); +$column->setOption('length', 11); +$column->setOptions(['AUTO_INCREMENT' => true, 'comment' => 'ID']); + +// Constraints (column-level) +$column->addConstraint(new Constraint\PrimaryKey()); + +// Name +$name = $column->getName(); +``` + +## Column Options Reference + +Column options provide a flexible way to specify platform-specific features and metadata. + +### Setting Options + +```php title="Setting Single and Multiple Column Options" +// Set single option +$column->setOption('option_name', 'option_value'); + +// Set multiple options +$column->setOptions([ + 'option1' => 'value1', + 'option2' => 'value2', +]); + +// Get all options +$options = $column->getOptions(); +``` + +### Documented Options + +| Option | Type | Platforms | Description | +| ---------------- | ------ | ----------------- | --------------------------- | +| `AUTO_INCREMENT` | bool | MySQL, MariaDB | Auto-increment integer | +| `identity` | bool | PostgreSQL, MSSQL | Identity/Serial column | +| `comment` | string | MySQL, PostgreSQL | Column comment | +| `on_update` | bool | MySQL (Timestamp) | ON UPDATE CURRENT_TIMESTAMP | +| `length` | int | MySQL (Integer) | Display width | + +### MySQL/MariaDB Specific Options + +```php title="Using MySQL-Specific Column Modifiers" +// UNSIGNED modifier +$column = new Column\Integer('count'); +$column->setOption('unsigned', true); +// Generates: `count` INT UNSIGNED NOT NULL + +// ZEROFILL modifier +$column = new Column\Integer('code'); +$column->setOption('zerofill', true); +// Generates: `code` INT ZEROFILL NOT NULL + +// Character set +$column = new Column\Varchar('name', 255); +$column->setOption('charset', 'utf8mb4'); +// Generates: `name` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL + +// Collation +$column = new Column\Varchar('name', 255); +$column->setOption('collation', 'utf8mb4_unicode_ci'); +// Generates: `name` VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL +``` + +### PostgreSQL Specific Options + +```php title="Creating Serial/Identity Columns in PostgreSQL" +// SERIAL type (via identity option) +$id = new Column\Integer('id'); +$id->setOption('identity', true); +// Generates: "id" SERIAL NOT NULL +``` + +### SQL Server Specific Options + +```php title="Creating Identity Columns in SQL Server" +// IDENTITY column +$id = new Column\Integer('id'); +$id->setOption('identity', true); +// Generates: [id] INT IDENTITY NOT NULL +``` + +### Common Option Patterns + +#### Auto-Incrementing Primary Key + +```php title="Creating Auto-Incrementing Primary Keys" +// MySQL +$id = new Column\Integer('id'); +$id->setOption('AUTO_INCREMENT', true); +$id->addConstraint(new Constraint\PrimaryKey()); +$table->addColumn($id); + +// PostgreSQL/SQL Server +$id = new Column\Integer('id'); +$id->setOption('identity', true); +$id->addConstraint(new Constraint\PrimaryKey()); +$table->addColumn($id); +``` + +#### Timestamp with Auto-Update + +```php title="Creating Self-Updating Timestamp Columns" +$updated = new Column\Timestamp('updated_at'); +$updated->setDefault('CURRENT_TIMESTAMP'); +$updated->setOption('on_update', true); +$table->addColumn($updated); +// MySQL: updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +``` + +#### Documented Column with Comment + +```php title="Adding Comments to Column Definitions" +$column = new Column\Varchar('email', 255); +$column->setOption('comment', 'User email address for authentication'); +$table->addColumn($column); +``` + +### Option Compatibility Notes + +**Important Considerations:** + +1. **Not all options work on all platforms** - Test your DDL against your + target database +2. **Some options are silently ignored** on unsupported platforms +3. **Platform rendering varies** - the same option may produce different SQL + on different platforms +4. **Options are not validated** by DDL objects - invalid options may cause + SQL errors during execution + +## Column Type Selection Best Practices + +### Numeric Type Selection + +#### Choosing the Right Numeric Type + +```php +// Use Integer for most numeric IDs and counters +$id = new Column\Integer('id'); // -2,147,483,648 to 2,147,483,647 +$count = new Column\Integer('view_count'); + +// Use BigInteger for very large numbers +$bigId = new Column\BigInteger('user_id'); // -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 + +// Use Decimal for money and precise calculations +$price = new Column\Decimal('price', 10, 2); // DECIMAL(10,2) - $99,999,999.99 +$tax = new Column\Decimal('tax_rate', 5, 4); // DECIMAL(5,4) - 0.9999 (99.99%) + +// Use Floating for scientific/approximate calculations (avoid for money!) +$latitude = new Column\Floating('lat', 10, 6); // GPS coordinates +$measurement = new Column\Floating('temp', 5, 2); // Temperature readings +``` + +### String Type Selection + +#### Choosing the Right String Type + +```php +// Use Varchar for bounded strings with known max length +$email = new Column\Varchar('email', 320); // Max email length (RFC 5321) +$username = new Column\Varchar('username', 50); +$countryCode = new Column\Varchar('country', 2); // ISO 3166-1 alpha-2 + +// Use Char for fixed-length strings +$statusCode = new Column\Char('status', 1); // Single character: 'A', 'P', 'C' +$currencyCode = new Column\Char('currency', 3); // ISO 4217: 'USD', 'EUR', 'GBP' + +// Use Text for unbounded or very large strings +$description = new Column\Text('description'); // Product descriptions +$content = new Column\Text('article_content'); // Article content +$notes = new Column\Text('notes'); // User notes +``` + +**Rule of Thumb:** + +- String <= 255 chars with known max → Varchar +- Fixed length → Char +- No length limit or very large → Text + +### Date/Time Types + +```php title="Choosing the Right Date and Time Type" +// Use Date for dates without time +$birthDate = new Column\Date('birth_date'); +$eventDate = new Column\Date('event_date'); + +// Use Time for times without date +$openTime = new Column\Time('opening_time'); +$duration = new Column\Time('duration'); + +// Use Datetime for specific moments in time (platform-agnostic) +$appointmentTime = new Column\Datetime('appointment_at'); +$publishedAt = new Column\Datetime('published_at'); + +// Use Timestamp for automatic tracking (created/updated) +$created = new Column\Timestamp('created_at'); +$created->setDefault('CURRENT_TIMESTAMP'); + +$updated = new Column\Timestamp('updated_at'); +$updated->setDefault('CURRENT_TIMESTAMP'); +$updated->setOption('on_update', true); +``` diff --git a/docs/book/sql-ddl/constraints.md b/docs/book/sql-ddl/constraints.md new file mode 100644 index 000000000..887b1daa6 --- /dev/null +++ b/docs/book/sql-ddl/constraints.md @@ -0,0 +1,488 @@ +# Constraints and Indexes + +Constraints enforce data integrity rules at the database level. +All constraints are in the `PhpDb\Sql\Ddl\Constraint` namespace. + +## Primary Key Constraints + +A primary key uniquely identifies each row in a table. + +```php title="Single-Column Primary Key" +use PhpDb\Sql\Ddl\Constraint\PrimaryKey; + +// Simple - name is optional +$pk = new Constraint\PrimaryKey('id'); + +// With explicit name +$pk = new Constraint\PrimaryKey('id', 'pk_users'); +``` + +### Composite Primary Key + +Multiple columns together form the primary key: + +```php +// Composite primary key +$pk = new Constraint\PrimaryKey(['user_id', 'role_id']); + +// With explicit name +$pk = new Constraint\PrimaryKey( + ['user_id', 'role_id'], + 'pk_user_roles' +); +``` + +### Column-Level Primary Key + +Attach primary key directly to a column: + +```php +use PhpDb\Sql\Ddl\Column\Integer; +use PhpDb\Sql\Ddl\Constraint\PrimaryKey; + +$id = new Integer('id'); +$id->setOption('AUTO_INCREMENT', true); +$id->addConstraint(new PrimaryKey()); + +$table->addColumn($id); +``` + +**Generated SQL:** + +```sql +"id" INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT +``` + +## Foreign Key Constraints + +Foreign keys enforce referential integrity between tables. + +```php title="Basic Foreign Key" +use PhpDb\Sql\Ddl\Constraint\ForeignKey; + +$fk = new ForeignKey( + 'fk_order_customer', // Constraint name (required) + 'customer_id', // Column in this table + 'customers', // Referenced table + 'id' // Referenced column +); + +$table->addConstraint($fk); +``` + +**Generated SQL:** + +```sql +CONSTRAINT "fk_order_customer" FOREIGN KEY ("customer_id") + REFERENCES "customers" ("id") +``` + +### Foreign Key with Referential Actions + +Control what happens when referenced rows are deleted or updated: + +```php +$fk = new ForeignKey( + 'fk_order_customer', + 'customer_id', + 'customers', + 'id', + 'CASCADE', // ON DELETE CASCADE - delete orders when customer is deleted + 'RESTRICT' // ON UPDATE RESTRICT - prevent customer ID changes if orders exist +); +``` + +**Available Actions:** + +- `CASCADE` - Propagate the change to dependent rows +- `SET NULL` - Set foreign key column to NULL +- `RESTRICT` - Prevent the change if dependent rows exist +- `NO ACTION` - Similar to RESTRICT (default) + +**Common Patterns:** + +```php title="Common Foreign Key Action Patterns" +// Delete child records when parent is deleted +$fk = new ForeignKey('fk_name', 'parent_id', 'parents', 'id', 'CASCADE'); + +// Set to NULL when parent is deleted (requires nullable column) +$fk = new ForeignKey('fk_name', 'parent_id', 'parents', 'id', 'SET NULL'); + +// Prevent deletion if child records exist +$fk = new ForeignKey('fk_name', 'parent_id', 'parents', 'id', 'RESTRICT'); +``` + +### Composite Foreign Key + +Multiple columns reference multiple columns in another table: + +```php +$fk = new ForeignKey( + 'fk_user_tenant', + ['user_id', 'tenant_id'], // Local columns (array) + 'user_tenants', // Referenced table + ['user_id', 'tenant_id'], // Referenced columns (array) + 'CASCADE', + 'CASCADE' +); +``` + +**Generated SQL:** + +```sql +CONSTRAINT "fk_user_tenant" FOREIGN KEY ("user_id", "tenant_id") + REFERENCES "user_tenants" ("user_id", "tenant_id") + ON DELETE CASCADE ON UPDATE CASCADE +``` + +## Unique Constraints + +Unique constraints ensure column values are unique across all rows. + +```php title="Single-Column Unique Constraint" +use PhpDb\Sql\Ddl\Constraint\UniqueKey; + +// Simple - name is optional +$unique = new UniqueKey('email'); + +// With explicit name +$unique = new UniqueKey('email', 'unique_user_email'); + +$table->addConstraint($unique); +``` + +**Generated SQL:** + +```sql +CONSTRAINT "unique_user_email" UNIQUE ("email") +``` + +### Composite Unique Constraint + +Multiple columns together must be unique: + +```php +// Username + tenant must be unique together +$unique = new UniqueKey( + ['username', 'tenant_id'], + 'unique_username_per_tenant' +); +``` + +**Generated SQL:** + +```sql +CONSTRAINT "unique_username_per_tenant" UNIQUE ("username", "tenant_id") +``` + +## Check Constraints + +Check constraints enforce custom validation rules. + +```php title="Simple Check Constraints" +use PhpDb\Sql\Ddl\Constraint\Check; + +// Age must be 18 or older +$check = new Check('age >= 18', 'check_adult_age'); +$table->addConstraint($check); + +// Price must be positive +$check = new Check('price > 0', 'check_positive_price'); +$table->addConstraint($check); + +// Email must contain @ +$check = new Check('email LIKE "%@%"', 'check_email_format'); +$table->addConstraint($check); +``` + +```php title="Complex Check Constraints" +// Discount percentage must be between 0 and 100 +$check = new Check( + 'discount_percent >= 0 AND discount_percent <= 100', + 'check_valid_discount' +); + +// End date must be after start date +$check = new Check( + 'end_date > start_date', + 'check_date_range' +); + +// Status must be one of specific values +$check = new Check( + "status IN ('pending', 'active', 'completed', 'cancelled')", + 'check_valid_status' +); +``` + +### Using Expressions in Check Constraints + +Check constraints can accept either string expressions or `Expression` objects. + +#### String Expressions (Simple) + +For simple constraints, use strings: + +```php +use PhpDb\Sql\Ddl\Constraint\Check; + +// Simple string expression +$check = new Check('age >= 18', 'check_adult'); +$check = new Check('price > 0', 'check_positive_price'); +$check = new Check("status IN ('active', 'pending', 'completed')", 'check_valid_status'); +``` + +#### Expression Objects (Advanced) + +For complex or parameterized constraints, use `Expression` objects: + +```php +use PhpDb\Sql\Expression; +use PhpDb\Sql\Ddl\Constraint\Check; + +// Expression with parameters +$expr = new Expression( + 'age >= ? AND age <= ?', + [18, 120] +); +$check = new Check($expr, 'check_valid_age_range'); + +// Complex expression +$expr = new Expression( + 'discount_percent BETWEEN ? AND ?', + [0, 100] +); +$check = new Check($expr, 'check_discount_range'); +``` + +## Indexes + +Indexes improve query performance by creating fast lookup structures. +The `Index` class is in the `PhpDb\Sql\Ddl\Index` namespace. + +```php title="Basic Index Creation" +use PhpDb\Sql\Ddl\Index\Index; + +// Single column index +$index = new Index('username', 'idx_username'); +$table->addConstraint($index); + +// With explicit name +$index = new Index('email', 'idx_user_email'); +$table->addConstraint($index); +``` + +**Generated SQL:** + +```sql +INDEX "idx_username" ("username") +``` + +### Composite Indexes + +Index multiple columns together: + +```php +// Index on category and price (useful for filtered sorts) +$index = new Index(['category', 'price'], 'idx_category_price'); +$table->addConstraint($index); + +// Index on last_name, first_name (useful for name searches) +$index = new Index(['last_name', 'first_name'], 'idx_name_search'); +$table->addConstraint($index); +``` + +**Generated SQL:** + +```sql +INDEX "idx_category_price" ("category", "price") +``` + +### Index with Column Length Specifications + +For large text columns, you can index only a prefix: + +```php +// Index first 50 characters of title +$index = new Index('title', 'idx_title', [50]); +$table->addConstraint($index); + +// Composite index with different lengths per column +$index = new Index( + ['title', 'description'], + 'idx_search', + [50, 100] // Index 50 chars of title, 100 of description +); +$table->addConstraint($index); +``` + +**Generated SQL (platform-specific):** + +```sql +INDEX "idx_search" ("title"(50), "description"(100)) +``` + +**Why use length specifications?** + +- Reduces index size for large text columns +- Improves index creation and maintenance performance +- Particularly useful for VARCHAR/TEXT columns that store long content + +### Adding Indexes to Existing Tables + +Use `AlterTable` to add indexes: + +```php +use PhpDb\Sql\Ddl\AlterTable; +use PhpDb\Sql\Ddl\Index\Index; + +$alter = new AlterTable('products'); + +// Add single-column index +$alter->addConstraint(new Index('sku', 'idx_product_sku')); + +// Add composite index +$alter->addConstraint(new Index( + ['category_id', 'created_at'], + 'idx_category_date' +)); + +// Add index with length limit +$alter->addConstraint(new Index('description', 'idx_description', [200])); +``` + +### Dropping Indexes + +Remove existing indexes from a table: + +```php +$alter = new AlterTable('products'); +$alter->dropIndex('idx_old_search'); +$alter->dropIndex('idx_deprecated_field'); +``` + +## Naming Conventions + +While some constraints allow optional names, it's a best practice to always +provide explicit names: + +```php title="Best Practice: Using Explicit Constraint Names" +// Good - explicit names for all constraints +$table->addConstraint(new Constraint\PrimaryKey('id', 'pk_users')); +$table->addConstraint(new Constraint\UniqueKey('email', 'unique_user_email')); +$table->addConstraint(new Constraint\ForeignKey( + 'fk_user_role', + 'role_id', + 'roles', + 'id' +)); + +// This makes it easier to drop or modify constraints later +$alter->dropConstraint('unique_user_email'); +$alter->dropConstraint('fk_user_role'); +``` + +**Recommended Naming Patterns:** + +- Primary keys: `pk_` +- Foreign keys: `fk__` or `fk_
_` +- Unique constraints: `unique_
_` or `unique_` +- Check constraints: `check_` +- Indexes: `idx_
_` or `idx_` + +## Index Strategy Best Practices + +### When to Add Indexes + +**DO index:** + +- Primary keys (automatic in most platforms) +- Foreign key columns +- Columns frequently used in WHERE clauses +- Columns used in JOIN conditions +- Columns used in ORDER BY clauses +- Columns used in GROUP BY clauses + +**DON'T index:** + +- Very small tables (< 1000 rows) +- Columns with low cardinality (few unique values) like boolean +- Columns rarely used in queries +- Columns that change frequently in write-heavy tables + +### Index Best Practices + +```php title="Implementing Indexing Best Practices" +// 1. Index foreign keys +$table->addColumn(new Column\Integer('user_id')); +$table->addConstraint(new Constraint\ForeignKey( + 'fk_order_user', + 'user_id', + 'users', + 'id' +)); +$table->addConstraint(new Index('user_id', 'idx_user')); + +// 2. Composite indexes for common query patterns +// If you often query: WHERE category_id = ? ORDER BY created_at DESC +$table->addConstraint(new Index(['category_id', 'created_at'], 'idx_category_date')); + +// 3. Covering indexes (columns used together in WHERE/ORDER) +// Query: WHERE status = 'active' AND priority = 'high' ORDER BY created_at +$table->addConstraint(new Index(['status', 'priority', 'created_at'], 'idx_active_priority')); + +// 4. Prefix indexes for large text columns +// Index first 100 chars +$table->addConstraint(new Index('title', 'idx_title', [100])); +``` + +### Index Order Matters + +```php title="Optimal vs Suboptimal Index Column Order" +// For query: WHERE category_id = ? ORDER BY created_at DESC +new Index(['category_id', 'created_at'], 'idx_category_date'); // Good + +// Less effective for the same query: +new Index(['created_at', 'category_id'], 'idx_date_category'); // Not optimal +``` + +**Rule:** Most selective (filters most rows) columns should come first. + +## Complete Constraint Example + +```php +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\Column; +use PhpDb\Sql\Ddl\Constraint; +use PhpDb\Sql\Ddl\Index\Index; + +$table = new CreateTable('articles'); + +// Columns +$table->addColumn((new Column\Integer('id'))->addConstraint(new Constraint\PrimaryKey())); +$table->addColumn(new Column\Varchar('title', 255)); +$table->addColumn(new Column\Text('content')); +$table->addColumn(new Column\Integer('category_id')); +$table->addColumn(new Column\Integer('author_id')); +$table->addColumn(new Column\Timestamp('published_at')); +$table->addColumn(new Column\Boolean('is_published')); + +// Indexes for performance +$table->addConstraint(new Index('category_id', 'idx_category')); +$table->addConstraint(new Index('author_id', 'idx_author')); +$table->addConstraint(new Index('published_at', 'idx_published_date')); + +// Composite indexes +$table->addConstraint(new Index( + ['is_published', 'published_at'], + 'idx_published_articles' +)); + +$table->addConstraint(new Index( + ['category_id', 'published_at'], + 'idx_category_date' +)); + +// Text search index with length limit +$table->addConstraint(new Index('title', 'idx_title_search', [100])); +``` diff --git a/docs/book/sql-ddl/examples.md b/docs/book/sql-ddl/examples.md new file mode 100644 index 000000000..e5830677b --- /dev/null +++ b/docs/book/sql-ddl/examples.md @@ -0,0 +1,511 @@ +# DDL Examples and Patterns + +## Example 1: E-Commerce Product Table + +```php title="Creating a Complete Product Table with Constraints and Indexes" +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\Column; +use PhpDb\Sql\Ddl\Constraint; +use PhpDb\Sql\Ddl\Index\Index; + +$table = new CreateTable('products'); + +// Primary key with auto-increment +$id = new Column\Integer('id'); +$id->setOption('AUTO_INCREMENT', true); +$id->addConstraint(new Constraint\PrimaryKey()); +$table->addColumn($id); + +// Basic product info +$table->addColumn(new Column\Varchar('sku', 50)); +$table->addColumn(new Column\Varchar('name', 255)); +$table->addColumn(new Column\Text('description')); + +// Pricing +$table->addColumn(new Column\Decimal('price', 10, 2)); +$table->addColumn(new Column\Decimal('cost', 10, 2)); + +// Inventory +$table->addColumn(new Column\Integer('stock_quantity')); + +// Foreign key to category +$table->addColumn(new Column\Integer('category_id')); +$table->addConstraint(new Constraint\ForeignKey( + 'fk_product_category', + 'category_id', + 'categories', + 'id', + 'RESTRICT', // Don't allow category deletion if products exist + 'CASCADE' // Update category_id if category.id changes +)); + +// Status and flags +$table->addColumn(new Column\Boolean('is_active')); +$table->addColumn(new Column\Boolean('is_featured')); + +// Timestamps +$created = new Column\Timestamp('created_at'); +$created->setDefault('CURRENT_TIMESTAMP'); +$table->addColumn($created); + +$updated = new Column\Timestamp('updated_at'); +$updated->setDefault('CURRENT_TIMESTAMP'); +$updated->setOption('on_update', true); +$table->addColumn($updated); + +// Constraints +$table->addConstraint(new Constraint\UniqueKey('sku', 'unique_product_sku')); +$table->addConstraint(new Constraint\Check('price >= cost', 'check_profitable_price')); +$table->addConstraint(new Constraint\Check('stock_quantity >= 0', 'check_non_negative_stock')); + +// Indexes for performance +$table->addConstraint(new Index('category_id', 'idx_category')); +$table->addConstraint(new Index('sku', 'idx_sku')); +$table->addConstraint(new Index(['is_active', 'is_featured'], 'idx_active_featured')); +$table->addConstraint(new Index('name', 'idx_name_search', [100])); + +// Execute +$sql = new Sql($adapter); +$adapter->query($sql->buildSqlString($table), $adapter::QUERY_MODE_EXECUTE); +``` + +## Example 2: User Authentication System + +```php title="Building a Multi-Table User Authentication Schema with Roles" +// Users table +$users = new CreateTable('users'); + +$id = new Column\Integer('id'); +$id->setOption('AUTO_INCREMENT', true); +$id->addConstraint(new Constraint\PrimaryKey()); +$users->addColumn($id); + +$users->addColumn(new Column\Varchar('username', 50)); +$users->addColumn(new Column\Varchar('email', 255)); +$users->addColumn(new Column\Varchar('password_hash', 255)); + +$lastLogin = new Column\Timestamp('last_login'); +$lastLogin->setNullable(true); +$users->addColumn($lastLogin); + +$users->addColumn(new Column\Boolean('is_active')); +$users->addColumn(new Column\Boolean('is_verified')); + +$users->addConstraint(new Constraint\UniqueKey('username', 'unique_username')); +$users->addConstraint(new Constraint\UniqueKey('email', 'unique_email')); +$users->addConstraint(new Index(['username', 'email'], 'idx_user_search')); + +// Execute +$adapter->query($sql->buildSqlString($users), $adapter::QUERY_MODE_EXECUTE); + +// Roles table +$roles = new CreateTable('roles'); + +$roleId = new Column\Integer('id'); +$roleId->setOption('AUTO_INCREMENT', true); +$roleId->addConstraint(new Constraint\PrimaryKey()); +$roles->addColumn($roleId); + +$roles->addColumn(new Column\Varchar('name', 50)); +$roles->addColumn(new Column\Text('description')); +$roles->addConstraint(new Constraint\UniqueKey('name', 'unique_role_name')); + +$adapter->query($sql->buildSqlString($roles), $adapter::QUERY_MODE_EXECUTE); + +// User-Role junction table +$userRoles = new CreateTable('user_roles'); + +$userRoles->addColumn(new Column\Integer('user_id')); +$userRoles->addColumn(new Column\Integer('role_id')); + +// Composite primary key +$userRoles->addConstraint(new Constraint\PrimaryKey(['user_id', 'role_id'])); + +// Foreign keys +$userRoles->addConstraint(new Constraint\ForeignKey( + 'fk_user_role_user', + 'user_id', + 'users', + 'id', + 'CASCADE', // Delete role assignments when user is deleted + 'CASCADE' +)); + +$userRoles->addConstraint(new Constraint\ForeignKey( + 'fk_user_role_role', + 'role_id', + 'roles', + 'id', + 'CASCADE', // Delete role assignments when role is deleted + 'CASCADE' +)); + +// Indexes +$userRoles->addConstraint(new Index('user_id', 'idx_user')); +$userRoles->addConstraint(new Index('role_id', 'idx_role')); + +$adapter->query($sql->buildSqlString($userRoles), $adapter::QUERY_MODE_EXECUTE); +``` + +## Example 3: Multi-Tenant Schema + +```php title="Implementing Cross-Schema Tables with Foreign Key References" +use PhpDb\Sql\TableIdentifier; + +// Tenants table (in public schema) +$tenants = new CreateTable(new TableIdentifier('tenants', 'public')); + +$tenantId = new Column\Integer('id'); +$tenantId->setOption('AUTO_INCREMENT', true); +$tenantId->addConstraint(new Constraint\PrimaryKey()); +$tenants->addColumn($tenantId); + +$tenants->addColumn(new Column\Varchar('name', 255)); +$tenants->addColumn(new Column\Varchar('subdomain', 100)); +$tenants->addColumn(new Column\Boolean('is_active')); + +$tenants->addConstraint(new Constraint\UniqueKey('subdomain', 'unique_subdomain')); + +$adapter->query($sql->buildSqlString($tenants), $adapter::QUERY_MODE_EXECUTE); + +// Tenant-specific users table (in tenant schema) +$tenantUsers = new CreateTable(new TableIdentifier('users', 'tenant_schema')); + +$userId = new Column\Integer('id'); +$userId->setOption('AUTO_INCREMENT', true); +$userId->addConstraint(new Constraint\PrimaryKey()); +$tenantUsers->addColumn($userId); + +$tenantUsers->addColumn(new Column\Integer('tenant_id')); +$tenantUsers->addColumn(new Column\Varchar('username', 50)); +$tenantUsers->addColumn(new Column\Varchar('email', 255)); + +// Composite unique constraint (username unique per tenant) +$tenantUsers->addConstraint(new Constraint\UniqueKey( + ['tenant_id', 'username'], + 'unique_tenant_username' +)); + +// Foreign key to public.tenants +$tenantUsers->addConstraint(new Constraint\ForeignKey( + 'fk_user_tenant', + 'tenant_id', + new TableIdentifier('tenants', 'public'), + 'id', + 'CASCADE', + 'CASCADE' +)); + +$adapter->query($sql->buildSqlString($tenantUsers), $adapter::QUERY_MODE_EXECUTE); +``` + +## Example 4: Database Migration Pattern + +```php title="Creating Reversible Migration Classes with Up and Down Methods" +use PhpDb\Sql\Sql; +use PhpDb\Sql\Ddl; + +class Migration_001_CreateUsersTable +{ + public function up($adapter) + { + $sql = new Sql($adapter); + + $table = new Ddl\CreateTable('users'); + + $id = new Ddl\Column\Integer('id'); + $id->setOption('AUTO_INCREMENT', true); + $id->addConstraint(new Ddl\Constraint\PrimaryKey()); + $table->addColumn($id); + + $table->addColumn(new Ddl\Column\Varchar('email', 255)); + $table->addColumn(new Ddl\Column\Varchar('password_hash', 255)); + $table->addColumn(new Ddl\Column\Boolean('is_active')); + + $table->addConstraint(new Ddl\Constraint\UniqueKey('email', 'unique_email')); + + $adapter->query( + $sql->buildSqlString($table), + $adapter::QUERY_MODE_EXECUTE + ); + } + + public function down($adapter) + { + $sql = new Sql($adapter); + $drop = new Ddl\DropTable('users'); + + $adapter->query( + $sql->buildSqlString($drop), + $adapter::QUERY_MODE_EXECUTE + ); + } +} + +class Migration_002_AddUserProfiles +{ + public function up($adapter) + { + $sql = new Sql($adapter); + + $alter = new Ddl\AlterTable('users'); + + $alter->addColumn(new Ddl\Column\Varchar('first_name', 100)); + $alter->addColumn(new Ddl\Column\Varchar('last_name', 100)); + + $bio = new Ddl\Column\Text('bio'); + $bio->setNullable(true); + $alter->addColumn($bio); + + $adapter->query( + $sql->buildSqlString($alter), + $adapter::QUERY_MODE_EXECUTE + ); + } + + public function down($adapter) + { + $sql = new Sql($adapter); + + $alter = new Ddl\AlterTable('users'); + $alter->dropColumn('first_name'); + $alter->dropColumn('last_name'); + $alter->dropColumn('bio'); + + $adapter->query( + $sql->buildSqlString($alter), + $adapter::QUERY_MODE_EXECUTE + ); + } +} +``` + +## Example 5: Audit Log Table + +```php title="Designing an Audit Trail Table for Tracking Data Changes" +$auditLog = new CreateTable('audit_log'); + +// Auto-increment ID +$id = new Column\BigInteger('id'); +$id->setOption('AUTO_INCREMENT', true); +$id->addConstraint(new Constraint\PrimaryKey()); +$auditLog->addColumn($id); + +// What was changed +$auditLog->addColumn(new Column\Varchar('table_name', 100)); +$auditLog->addColumn(new Column\Varchar('action', 20)); // INSERT, UPDATE, DELETE +$auditLog->addColumn(new Column\BigInteger('record_id')); + +// Who changed it +$userId = new Column\Integer('user_id'); +$userId->setNullable(true); // System actions might not have a user +$auditLog->addColumn($userId); + +// When it changed +$timestamp = new Column\Timestamp('created_at'); +$timestamp->setDefault('CURRENT_TIMESTAMP'); +$auditLog->addColumn($timestamp); + +// What changed (JSON or TEXT) +$auditLog->addColumn(new Column\Text('old_values')); +$auditLog->addColumn(new Column\Text('new_values')); + +// Additional context +$ipAddress = new Column\Varchar('ip_address', 45); // IPv6 compatible +$ipAddress->setNullable(true); +$auditLog->addColumn($ipAddress); + +// Constraints +$auditLog->addConstraint(new Constraint\Check( + "action IN ('INSERT', 'UPDATE', 'DELETE')", + 'check_valid_action' +)); + +// Indexes for querying +$auditLog->addConstraint(new Index('table_name', 'idx_table')); +$auditLog->addConstraint(new Index('record_id', 'idx_record')); +$auditLog->addConstraint(new Index('user_id', 'idx_user')); +$auditLog->addConstraint(new Index('created_at', 'idx_created')); +$auditLog->addConstraint(new Index(['table_name', 'record_id'], 'idx_table_record')); + +$adapter->query($sql->buildSqlString($auditLog), $adapter::QUERY_MODE_EXECUTE); +``` + +## Example 6: Session Storage Table + +```php title="Building a Database-Backed Session Storage System" +$sessions = new CreateTable('sessions'); + +// Session ID as primary key (not auto-increment) +$sessionId = new Column\Varchar('id', 128); +$sessionId->addConstraint(new Constraint\PrimaryKey()); +$sessions->addColumn($sessionId); + +// User association (optional - anonymous sessions allowed) +$userId = new Column\Integer('user_id'); +$userId->setNullable(true); +$sessions->addColumn($userId); + +// Session data +$sessions->addColumn(new Column\Text('data')); + +// Timestamps for expiration +$createdAt = new Column\Timestamp('created_at'); +$createdAt->setDefault('CURRENT_TIMESTAMP'); +$sessions->addColumn($createdAt); + +$expiresAt = new Column\Timestamp('expires_at'); +$sessions->addColumn($expiresAt); + +$lastActivity = new Column\Timestamp('last_activity'); +$lastActivity->setDefault('CURRENT_TIMESTAMP'); +$lastActivity->setOption('on_update', true); +$sessions->addColumn($lastActivity); + +// IP and user agent for security +$sessions->addColumn(new Column\Varchar('ip_address', 45)); +$sessions->addColumn(new Column\Varchar('user_agent', 255)); + +// Foreign key to users (SET NULL on delete - preserve session data) +$sessions->addConstraint(new Constraint\ForeignKey( + 'fk_session_user', + 'user_id', + 'users', + 'id', + 'SET NULL', + 'CASCADE' +)); + +// Indexes +$sessions->addConstraint(new Index('user_id', 'idx_user')); +$sessions->addConstraint(new Index('expires_at', 'idx_expires')); +$sessions->addConstraint(new Index('last_activity', 'idx_activity')); + +$adapter->query($sql->buildSqlString($sessions), $adapter::QUERY_MODE_EXECUTE); +``` + +## Example 7: File Storage Metadata Table + +```php title="Implementing File Metadata Storage with UUID Primary Keys" +$files = new CreateTable('files'); + +// UUID as primary key +$id = new Column\Char('id', 36); // UUID format +$id->addConstraint(new Constraint\PrimaryKey()); +$files->addColumn($id); + +// File information +$files->addColumn(new Column\Varchar('original_name', 255)); +$files->addColumn(new Column\Varchar('stored_name', 255)); +$files->addColumn(new Column\Varchar('mime_type', 100)); +$files->addColumn(new Column\BigInteger('file_size')); +$files->addColumn(new Column\Varchar('storage_path', 500)); + +// Hash for deduplication +$files->addColumn(new Column\Char('content_hash', 64)); // SHA-256 + +// Ownership +$files->addColumn(new Column\Integer('uploaded_by')); +$uploadedAt = new Column\Timestamp('uploaded_at'); +$uploadedAt->setDefault('CURRENT_TIMESTAMP'); +$files->addColumn($uploadedAt); + +// Soft delete +$deletedAt = new Column\Timestamp('deleted_at'); +$deletedAt->setNullable(true); +$files->addColumn($deletedAt); + +// Constraints +$files->addConstraint(new Constraint\UniqueKey('stored_name', 'unique_stored_name')); +$files->addConstraint(new Constraint\ForeignKey( + 'fk_file_user', + 'uploaded_by', + 'users', + 'id', + 'RESTRICT', // Don't allow user deletion if they have files + 'CASCADE' +)); + +// Indexes +$files->addConstraint(new Index('content_hash', 'idx_hash')); +$files->addConstraint(new Index('uploaded_by', 'idx_uploader')); +$files->addConstraint(new Index('mime_type', 'idx_mime')); +$files->addConstraint(new Index(['deleted_at', 'uploaded_at'], 'idx_active_files')); + +$adapter->query($sql->buildSqlString($files), $adapter::QUERY_MODE_EXECUTE); +``` + +## Troubleshooting Common Issues + +### Issue: Table Already Exists + +```php title="Safely Creating Tables with Existence Checks" +// Check before creating +function createTableIfNotExists($adapter, CreateTable $table) { + $sql = new Sql($adapter); + $tableName = $table->getRawState()['table']; + + try { + $adapter->query( + $sql->buildSqlString($table), + $adapter::QUERY_MODE_EXECUTE + ); + } catch (\Exception $e) { + if (strpos($e->getMessage(), 'already exists') !== false) { + // Table exists, that's fine + return false; + } + throw $e; + } + return true; +} +``` + +### Issue: Foreign Key Constraint Fails + +```php title="Ensuring Correct Table Creation Order for Foreign Keys" +// Ensure referenced table exists first +$sql = new Sql($adapter); + +// 1. Create parent table first +$roles = new CreateTable('roles'); +// ... add columns ... +$adapter->query($sql->buildSqlString($roles), $adapter::QUERY_MODE_EXECUTE); + +// 2. Then create child table with foreign key +$userRoles = new CreateTable('user_roles'); +// ... add columns and foreign key to roles ... +$adapter->query($sql->buildSqlString($userRoles), $adapter::QUERY_MODE_EXECUTE); +``` + +### Issue: Column Type Mismatch in Foreign Key + +```php title="Matching Column Types Between Parent and Child Tables" +// Ensure both columns have the same type +$parentTable = new CreateTable('categories'); +$parentId = new Column\Integer('id'); // INTEGER +$parentId->addConstraint(new Constraint\PrimaryKey()); +$parentTable->addColumn($parentId); + +$childTable = new CreateTable('products'); +$childTable->addColumn(new Column\Integer('category_id')); // Must also be INTEGER +$childTable->addConstraint(new Constraint\ForeignKey( + 'fk_product_category', + 'category_id', // INTEGER + 'categories', + 'id' // INTEGER - matches! +)); +``` + +### Issue: Index Too Long + +```php title="Using Prefix Indexes for Long Text Columns" +// Use prefix indexes for long text columns +$table->addConstraint(new Index( + 'long_description', + 'idx_description', + // MySQL InnoDB with utf8mb4 has 767 byte limit + // 191 chars * 4 bytes = 764 + [191] +)); +``` diff --git a/docs/book/sql-ddl/intro.md b/docs/book/sql-ddl/intro.md new file mode 100644 index 000000000..5ddfccbbe --- /dev/null +++ b/docs/book/sql-ddl/intro.md @@ -0,0 +1,254 @@ +# DDL Abstraction Overview + +`PhpDb\Sql\Ddl` provides object-oriented abstraction for DDL (Data Definition +Language) statements. Create, alter, and drop tables using PHP objects instead +of raw SQL, with automatic platform-specific SQL generation. + +## Basic Workflow + +The typical workflow for using DDL abstraction: + +1. **Create a DDL object** (CreateTable, AlterTable, or DropTable) +2. **Configure the object** (add columns, constraints, etc.) +3. **Generate SQL** using `Sql::buildSqlString()` +4. **Execute** using `Adapter::query()` with `QUERY_MODE_EXECUTE` + +```php title="Creating and Executing a Simple Table" +use PhpDb\Sql\Sql; +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\Column; + +// Assuming $adapter exists +$sql = new Sql($adapter); + +// Create a DDL object +$table = new CreateTable('users'); +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('name', 255)); + +// Execute +$adapter->query( + $sql->buildSqlString($table), + $adapter::QUERY_MODE_EXECUTE +); +``` + +## Creating Tables + +The `CreateTable` class represents a `CREATE TABLE` statement. You can build +complex table definitions using a fluent, object-oriented interface. + +```php title="Basic Table Creation" +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\Column; + +// Simple table +$table = new CreateTable('users'); +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('name', 255)); +``` + +### SQL Output for Basic Table + +**Generated SQL:** + +```sql +CREATE TABLE "users" ( + "id" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL +) +``` + +### Setting the Table Name + +You can set the table name during construction or after instantiation: + +```php +// During construction +$table = new CreateTable('products'); + +// After instantiation +$table = new CreateTable(); +$table->setTable('products'); +``` + +### Schema-Qualified Tables + +Use `TableIdentifier` to create tables in a specific schema: + +```php +use PhpDb\Sql\TableIdentifier; + +// Create table in the "public" schema +$table = new CreateTable(new TableIdentifier('users', 'public')); +``` + +### SQL Output for Schema-Qualified Table + +**Generated SQL:** + +```sql +CREATE TABLE "public"."users" (...) +``` + +### Temporary Tables + +Create temporary tables by passing `true` as the second parameter: + +```php +$table = new CreateTable('temp_data', true); + +// Or use the setter +$table = new CreateTable('temp_data'); +$table->setTemporary(true); +``` + +### SQL Output for Temporary Table + +**Generated SQL:** + +```sql +CREATE TEMPORARY TABLE "temp_data" (...) +``` + +### Adding Columns + +Columns are added using the `addColumn()` method with column type objects: + +```php +use PhpDb\Sql\Ddl\Column; + +$table = new CreateTable('products'); + +// Add various column types +$table->addColumn(new Column\Integer('id')); +$table->addColumn(new Column\Varchar('name', 255)); +$table->addColumn(new Column\Text('description')); +$table->addColumn(new Column\Decimal('price', 10, 2)); +$table->addColumn(new Column\Boolean('is_active')); +$table->addColumn(new Column\Timestamp('created_at')); +``` + +### Adding Constraints + +Table-level constraints are added using `addConstraint()`: + +```php +use PhpDb\Sql\Ddl\Constraint; + +// Primary key +$table->addConstraint(new Constraint\PrimaryKey('id')); + +// Unique constraint +$table->addConstraint(new Constraint\UniqueKey('email', 'unique_email')); + +// Foreign key +$table->addConstraint(new Constraint\ForeignKey( + 'fk_user_role', // Constraint name + 'role_id', // Column in this table + 'roles', // Referenced table + 'id', // Referenced column + 'CASCADE', // ON DELETE rule + 'CASCADE' // ON UPDATE rule +)); + +// Check constraint +$table->addConstraint(new Constraint\Check('price > 0', 'check_positive_price')); +``` + +### Column-Level Constraints + +Columns can have constraints attached directly: + +```php +use PhpDb\Sql\Ddl\Column; +use PhpDb\Sql\Ddl\Constraint; + +// Create a primary key column +$id = new Column\Integer('id'); +$id->addConstraint(new Constraint\PrimaryKey()); +$table->addColumn($id); +``` + +### SQL Output for Column-Level Constraint + +**Generated SQL:** + +```sql +"id" INTEGER NOT NULL PRIMARY KEY +``` + +### Fluent Interface Pattern + +All DDL objects support method chaining for cleaner code: + +```php +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\Column; +use PhpDb\Sql\Ddl\Constraint; + +$table = (new CreateTable('users')) + ->addColumn( + (new Column\Integer('id')) + ->setNullable(false) + ->addConstraint(new Constraint\PrimaryKey()) + ) + ->addColumn( + (new Column\Varchar('email', 255)) + ->setNullable(false) + ) + ->addConstraint(new Constraint\UniqueKey('email', 'unique_user_email')); +``` + +```php title="Complete Example: User Table" +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\Column; +use PhpDb\Sql\Ddl\Constraint; +use PhpDb\Sql\Ddl\Index\Index; + +$table = new CreateTable('users'); + +// Auto-increment primary key +$id = new Column\Integer('id'); +$id->setOption('AUTO_INCREMENT', true); +$id->addConstraint(new Constraint\PrimaryKey()); +$table->addColumn($id); + +// Basic columns +$table->addColumn(new Column\Varchar('username', 50)); +$table->addColumn(new Column\Varchar('email', 255)); +$table->addColumn(new Column\Varchar('password_hash', 255)); + +// Optional columns +$bio = new Column\Text('bio'); +$bio->setNullable(true); +$table->addColumn($bio); + +// Boolean (always NOT NULL) +$table->addColumn(new Column\Boolean('is_active')); + +// Timestamps +$created = new Column\Timestamp('created_at'); +$created->setDefault('CURRENT_TIMESTAMP'); +$table->addColumn($created); + +$updated = new Column\Timestamp('updated_at'); +$updated->setDefault('CURRENT_TIMESTAMP'); +$updated->setOption('on_update', true); +$table->addColumn($updated); + +// Constraints +$table->addConstraint(new Constraint\UniqueKey('username', 'unique_username')); +$table->addConstraint(new Constraint\UniqueKey('email', 'unique_email')); +$table->addConstraint(new Constraint\Check('email LIKE "%@%"', 'check_email_format')); + +// Index for searches +$table->addConstraint(new Index(['username', 'email'], 'idx_user_search')); + +// Execute +$sql = new Sql($adapter); +$adapter->query( + $sql->buildSqlString($table), + $adapter::QUERY_MODE_EXECUTE +); +``` diff --git a/docs/book/sql/advanced.md b/docs/book/sql/advanced.md new file mode 100644 index 000000000..4540209dd --- /dev/null +++ b/docs/book/sql/advanced.md @@ -0,0 +1,238 @@ +# Advanced SQL Features + +## Expression and Literal + +### Distinguishing between Expression and Literal + +Use `Literal` for static SQL fragments without parameters: + +```php title="Creating static SQL literals" +use PhpDb\Sql\Literal; + +$literal = new Literal('NOW()'); +$literal = new Literal('CURRENT_TIMESTAMP'); +$literal = new Literal('COUNT(*)'); +``` + +Use `Expression` when parameters are needed: + +```php title="Creating expressions with parameters" +use PhpDb\Sql\Expression; + +$expression = new Expression('DATE_ADD(NOW(), INTERVAL ? DAY)', [7]); +$expression = new Expression('CONCAT(?, ?)', ['Hello', 'World']); +``` + +```php title="Mixed parameter types in expressions" +use PhpDb\Sql\Argument; + +$expression = new Expression( + 'CASE WHEN ? > ? THEN ? ELSE ? END', + [ + Argument::identifier('age'), + Argument::value(18), + Argument::literal('ADULT'), + Argument::literal('MINOR'), + ] +); +``` + +Produces: + +```sql title="SQL output for mixed parameter types" +CASE WHEN age > 18 THEN ADULT ELSE MINOR END +``` + +```php title="Array values in expressions" +$expression = new Expression( + 'id IN (?)', + [Argument::value([1, 2, 3, 4, 5])] +); +``` + +Produces: + +```sql title="SQL output for array values" +id IN (?, ?, ?, ?, ?) +``` + +```php title="Nested expressions" +$innerExpression = new Expression('COUNT(*)'); +$outerExpression = new Expression( + 'CASE WHEN ? > ? THEN ? ELSE ? END', + [ + $innerExpression, + Argument::value(10), + Argument::literal('HIGH'), + Argument::literal('LOW'), + ] +); +``` + +Produces: + +```sql title="SQL output for nested expressions" +CASE WHEN COUNT(*) > 10 THEN HIGH ELSE LOW END +``` + +```php title="Using database-specific functions" +use PhpDb\Sql\Predicate; + +$select->where(new Predicate\Expression( + 'FIND_IN_SET(?, ?)', + [ + Argument::value('admin'), + Argument::identifier('roles'), + ] +)); +``` + +For detailed information on Arguments and Argument Types, see the [SQL Introduction](intro.md#arguments-and-argument-types). + +## Combine (UNION, INTERSECT, EXCEPT) + +The `Combine` class enables combining multiple SELECT statements using UNION, +INTERSECT, or EXCEPT operations. + +```php title="Basic Combine usage with UNION" +use PhpDb\Sql\Combine; + +$select1 = $sql->select('table1')->where(['status' => 'active']); +$select2 = $sql->select('table2')->where(['status' => 'pending']); + +$combine = new Combine($select1, Combine::COMBINE_UNION); +$combine->combine($select2); +``` + +```php title="Combine API" +class Combine extends AbstractPreparableSql +{ + final public const COMBINE_UNION = 'union'; + final public const COMBINE_EXCEPT = 'except'; + final public const COMBINE_INTERSECT = 'intersect'; + + public function __construct( + Select|array|null $select = null, + string $type = self::COMBINE_UNION, + string $modifier = '' + ); + public function combine( + Select|array $select, + string $type = self::COMBINE_UNION, + string $modifier = '' + ) : static; + public function union(Select|array $select, string $modifier = '') : static; + public function except(Select|array $select, string $modifier = '') : static; + public function intersect(Select|array $select, string $modifier = '') : static; + public function alignColumns() : static; + public function getRawState(?string $key = null) : mixed; +} +``` + +```php title="UNION" +$combine = new Combine(); +$combine->union($select1); +$combine->union($select2, 'ALL'); // UNION ALL keeps duplicates +``` + +Produces: + +```sql title="SQL output for UNION ALL" +(SELECT * FROM table1 WHERE status = 'active') +UNION ALL +(SELECT * FROM table2 WHERE status = 'pending') +``` + +### EXCEPT + +Returns rows from the first SELECT that don't appear in subsequent SELECTs: + +```php +$allUsers = $sql->select('users')->columns(['id', 'email']); +$premiumUsers = $sql->select('premium_users')->columns(['user_id', 'email']); + +$combine = new Combine(); +$combine->union($allUsers); +$combine->except($premiumUsers); +``` + +### INTERSECT + +Returns only rows that appear in all SELECT statements: + +```php +$combine = new Combine(); +$combine->union($select1); +$combine->intersect($select2); +``` + +### alignColumns() + +Ensures all SELECT statements have the same column structure: + +```php +$select1 = $sql->select('orders')->columns(['id', 'amount']); +$select2 = $sql->select('refunds')->columns(['id', 'amount', 'reason']); + +$combine = new Combine(); +$combine->union($select1); +$combine->union($select2); +$combine->alignColumns(); +``` + +Produces: + +```sql title="SQL output for aligned columns" +(SELECT id, amount, NULL AS reason FROM orders) +UNION +(SELECT id, amount, reason FROM refunds) +``` + +## Platform-Specific Considerations + +### Quote characters + +Different databases use different quote characters. Let the platform handle quoting: + +```php +// Correct - platform handles quoting +$select->from('users'); + +// Incorrect - manual quoting +$select->from('"users"'); +``` + +### Identifier case sensitivity + +Some databases are case-sensitive for identifiers. Be consistent: + +```php +// Consistent naming +$select->from('UserAccounts') + ->columns(['userId', 'userName']); +``` + +### NULL handling + +NULL requires special handling in SQL: + +```php +// Use IS NULL, not = NULL +$select->where->isNull('deleted_at'); + +// For NOT NULL +$select->where->isNotNull('email'); +``` + +### Type-safe comparisons + +When comparing identifiers to identifiers (not values): + +```php +use PhpDb\Sql\Argument; + +$where->equalTo( + Argument::identifier('table1.column'), + Argument::identifier('table2.column') +); +``` diff --git a/docs/book/sql/examples.md b/docs/book/sql/examples.md new file mode 100644 index 000000000..0e9d4600c --- /dev/null +++ b/docs/book/sql/examples.md @@ -0,0 +1,539 @@ +# Examples and Troubleshooting + +## Common Patterns and Best Practices + +### Handling Column Name Conflicts in JOINs + +When joining tables with columns that have the same name, +explicitly specify column aliases to avoid ambiguity: + +```php +$select->from(['u' => 'users']) + ->columns([ + 'userId' => 'id', + 'userName' => 'name', + 'userEmail' => 'email', + ]) + ->join( + ['o' => 'orders'], + 'u.id = o.userId', + [ + 'orderId' => 'id', + 'orderDate' => 'createdAt', + 'orderAmount' => 'amount', + ] + ); +``` + +This prevents confusion and ensures all columns are accessible in the +result set. + +### Working with NULL Values + +NULL requires special handling in SQL. Use the appropriate predicates: + +```php +$select->where(['deletedAt' => null]); + +$select->where->isNull('deletedAt') + ->or + ->lessThan('deletedAt', new Expression('NOW()')); +``` + +In UPDATE statements: + +```php title="Setting NULL Values in UPDATE" +$update->set(['optionalField' => null]); +``` + +In comparisons, remember that `column = NULL` does not work in SQL; +you must use `IS NULL`: + +```php title="Checking for NULL or Empty Values" +$select->where->nest() + ->isNull('field') + ->or + ->equalTo('field', '') +->unnest(); +``` + +### Dynamic Query Building + +Build queries dynamically based on conditions: + +```php +$select = $sql->select('products'); + +if ($categoryId) { + $select->where(['categoryId' => $categoryId]); +} + +if ($minPrice) { + $select->where->greaterThanOrEqualTo('price', $minPrice); +} + +if ($maxPrice) { + $select->where->lessThanOrEqualTo('price', $maxPrice); +} + +if ($searchTerm) { + $select->where->nest() + ->like('name', '%' . $searchTerm . '%') + ->or + ->like('description', '%' . $searchTerm . '%') + ->unnest(); +} + +if ($sortBy) { + $select->order($sortBy . ' ' . ($sortDirection ?? 'ASC')); +} + +if ($limit) { + $select->limit($limit); + if ($offset) { + $select->offset($offset); + } +} +``` + +### Reusing Query Components + +Create reusable query components for common patterns: + +```php +function applyActiveFilter(Select $select): Select +{ + return $select->where([ + 'status' => 'active', + 'deletedAt' => null, + ]); +} + +function applyPagination(Select $select, int $page, int $perPage): Select +{ + return $select + ->limit($perPage) + ->offset(($page - 1) * $perPage); +} + +$select = $sql->select('users'); +applyActiveFilter($select); +applyPagination($select, 2, 25); +``` + +## Troubleshooting and Common Issues + +### Empty WHERE Protection Errors + +If you encounter errors about empty WHERE clauses: + +```php title="UPDATE Without WHERE Clause (Wrong)" +$update = $sql->update('users'); +$update->set(['status' => 'inactive']); +// This will trigger empty WHERE protection! +``` + +Always include a WHERE clause for UPDATE and DELETE: + +```php title="Adding WHERE Clause to UPDATE" +$update->where(['id' => 123]); +``` + +To intentionally update all rows (use with extreme caution): + +```php title="Checking Empty WHERE Protection Status" +// Check the raw state to understand the protection status +$state = $update->getRawState(); +$protected = $state['emptyWhereProtection']; +``` + +### Parameter Count Mismatch + +When using Expression with placeholders: + +```php title="Incorrect Parameter Count" +// WRONG - 3 placeholders but only 2 values +$expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b']); +``` + +Ensure the number of `?` placeholders matches the number of parameters +provided, or you will receive a RuntimeException. + +```php title="Correct Parameter Count" +// CORRECT +$expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b', 'c']); +``` + +### Quote Character Issues + +Different databases use different quote characters. +Let the platform handle quoting: + +```php title="Proper Platform-Managed Quoting" +// CORRECT - let the platform handle quoting +$select->from('users'); +``` + +Avoid manually quoting identifiers: + +```php title="Avoid Manual Quoting" +// WRONG - don't manually quote +$select->from('"users"'); +``` + +### Type Confusion in Predicates + +When comparing two identifiers (column to column), +specify both types: + +```php title="Column Comparison Using Type Constants" +// Using type constants +$where->equalTo( + 'table1.columnA', + 'table2.columnB', + Predicate\Predicate::TYPE_IDENTIFIER, + Predicate\Predicate::TYPE_IDENTIFIER +); +``` + +Or use the Argument class for better readability: + +```php title="Column Comparison Using Argument Class" +// Using Argument class (recommended) +use PhpDb\Sql\Argument; + +$where->equalTo( + Argument::identifier('table1.columnA'), + Argument::identifier('table2.columnB') +); +``` + +### Debugging SQL Output + +To see the generated SQL for debugging: + +```php +// Get the SQL string (DO NOT use for execution with user input!) +$sqlString = $sql->buildSqlString($select); +echo $sqlString; + +// For debugging prepared statement parameters +$statement = $sql->prepareStatementForSqlObject($select); +// The statement object contains the SQL and parameter container +``` + +## Performance Considerations + +### Use Prepared Statements + +Always use `prepareStatementForSqlObject()` instead of +`buildSqlString()` for user input: + +```php +$select->where(['username' => $userInput]); +$statement = $sql->prepareStatementForSqlObject($select); +``` + +This provides: + +- Protection against SQL injection +- Better performance through query plan caching +- Proper type handling for parameters + +### Limit Result Sets + +Always use `limit()` for queries that may return large result sets: + +```php +$select->limit(100); +``` + +For pagination, combine with `offset()`: + +```php title="Pagination with Limit and Offset" +$select->limit(25)->offset(50); +``` + +### Select Only Required Columns + +Instead of selecting all columns: + +```php title="Selecting All Columns (Avoid)" +// Avoid - selects all columns +$select->from('users'); +``` + +Specify only the columns you need: + +```php title="Selecting Specific Columns" +// Better - only select what's needed +$select->from('users')->columns(['id', 'username', 'email']); +``` + +This reduces memory usage and network transfer. + +### Avoid N+1 Query Problems + +Use JOINs instead of multiple queries: + +```php title="Using JOINs to Avoid N+1 Queries" +// WRONG - N+1 queries +foreach ($orders as $order) { + // Additional query per order + $customer = getCustomer($order['customerId']); +} + +// CORRECT - single query with JOIN +$select->from('orders') + ->join( + 'customers', + 'orders.customerId = customers.id', + ['customerName' => 'name'] + ) + ->join( + 'products', + 'orders.productId = products.id', + ['productName' => 'name'] + ); +``` + +### Index-Friendly Queries + +Structure WHERE clauses to use database indexes: + +```php title="Index-Friendly WHERE Clause" +// Good - can use index on indexedColumn +$select->where->equalTo('indexedColumn', $value) + ->greaterThan('date', '2024-01-01'); +``` + +Avoid functions on indexed columns in WHERE: + +```php title="Functions on Indexed Columns (Prevents Index Usage)" +// BAD - prevents index usage +$select->where( + new Predicate\Expression('YEAR(createdAt) = ?', [2024]) +); +``` + +Instead, use ranges: + +```php title="Using Ranges for Index-Friendly Queries" +// GOOD - allows index usage +$select->where->between('createdAt', '2024-01-01', '2024-12-31'); +``` + +## Complete Examples + +```php title="Complex Reporting Query with Aggregation" +use PhpDb\Sql\Sql; +use PhpDb\Sql\Select; +use PhpDb\Sql\Expression; + +$sql = new Sql($adapter); + +$select = $sql->select('orders') + ->columns([ + 'customerId', + 'orderYear' => new Expression('YEAR(createdAt)'), + 'orderCount' => new Expression('COUNT(*)'), + 'totalRevenue' => new Expression('SUM(amount)'), + 'avgOrderValue' => new Expression('AVG(amount)'), + ]) + ->join( + 'customers', + 'orders.customerId = customers.id', + ['customerName' => 'name', 'customerTier' => 'tier'], + Select::JOIN_LEFT + ) + ->where(function ($where) { + $where->nest() + ->equalTo('orders.status', 'completed') + ->or + ->equalTo('orders.status', 'shipped') + ->unnest(); + $where->between( + 'orders.createdAt', + '2024-01-01', + '2024-12-31' + ); + }) + ->group(['customerId', new Expression('YEAR(createdAt)')]) + ->having(function ($having) { + $having->greaterThan(new Expression('SUM(amount)'), 10000); + }) + ->order(['totalRevenue DESC', 'orderYear DESC']) + ->limit(100); + +$statement = $sql->prepareStatementForSqlObject($select); +$results = $statement->execute(); +``` + +Produces: + +```sql title="Generated SQL for Reporting Query" +SELECT orders.customerId, + YEAR(createdAt) AS orderYear, + COUNT(*) AS orderCount, + SUM(amount) AS totalRevenue, + AVG(amount) AS avgOrderValue, + customers.name AS customerName, + customers.tier AS customerTier +FROM orders +LEFT JOIN customers ON orders.customerId = customers.id +WHERE (orders.status = 'completed' OR orders.status = 'shipped') + AND orders.createdAt BETWEEN '2024-01-01' AND '2024-12-31' +GROUP BY customerId, YEAR(createdAt) +HAVING SUM(amount) > 10000 +ORDER BY totalRevenue DESC, orderYear DESC +LIMIT 100 +``` + +```php title="Data Migration with INSERT SELECT" +$select = $sql->select('importedUsers') + ->columns(['username', 'email', 'firstName', 'lastName']) + ->where(['validated' => true]) + ->where->isNotNull('email'); + +$insert = $sql->insert('users'); +$insert->columns(['username', 'email', 'firstName', 'lastName']); +$insert->select($select); + +$statement = $sql->prepareStatementForSqlObject($insert); +$statement->execute(); +``` + +Produces: + +```sql title="Generated SQL for INSERT SELECT" +INSERT INTO users (username, email, firstName, lastName) +SELECT username, email, firstName, lastName +FROM importedUsers +WHERE validated = 1 AND email IS NOT NULL +``` + +```php title="Combining Multiple Result Sets" +use PhpDb\Sql\Combine; +use PhpDb\Sql\Literal; + +$activeUsers = $sql->select('users') + ->columns(['id', 'name', 'email', 'status' => new Literal('"active"')]) + ->where(['status' => 'active']); + +$pendingUsers = $sql->select('userRegistrations') + ->columns(['id', 'name', 'email', 'status' => new Literal('"pending"')]) + ->where(['verified' => false]); + +$suspendedUsers = $sql->select('users') + ->columns(['id', 'name', 'email', 'status' => new Literal('"suspended"')]) + ->where(['suspended' => true]); + +$combine = new Combine(); +$combine->union($activeUsers); +$combine->union($pendingUsers); +$combine->union($suspendedUsers); +$combine->alignColumns(); + +$statement = $sql->prepareStatementForSqlObject($combine); +$results = $statement->execute(); +``` + +Produces: + +```sql title="Generated SQL for UNION Query" +(SELECT id, name, email, "active" AS status + FROM users WHERE status = 'active') +UNION +(SELECT id, name, email, "pending" AS status + FROM userRegistrations WHERE verified = 0) +UNION +(SELECT id, name, email, "suspended" AS status + FROM users WHERE suspended = 1) +``` + +```php title="Search with Full-Text and Filters" +use PhpDb\Sql\Predicate; + +$select = $sql->select('products') + ->columns([ + 'id', + 'name', + 'description', + 'price', + 'relevance' => new Expression( + 'MATCH(name, description) AGAINST(?)', + [$searchTerm] + ), + ]) + ->where(function ($where) use ( + $searchTerm, + $categoryId, + $minPrice, + $maxPrice + ) { + // Full-text search + $where->expression( + 'MATCH(name, description) AGAINST(? IN BOOLEAN MODE)', + [$searchTerm] + ); + + // Category filter + if ($categoryId) { + $where->equalTo('categoryId', $categoryId); + } + + // Price range + if ($minPrice !== null && $maxPrice !== null) { + $where->between('price', $minPrice, $maxPrice); + } elseif ($minPrice !== null) { + $where->greaterThanOrEqualTo('price', $minPrice); + } elseif ($maxPrice !== null) { + $where->lessThanOrEqualTo('price', $maxPrice); + } + + // Only active products + $where->equalTo('status', 'active'); + }) + ->order('relevance DESC') + ->limit(50); +``` + +```php title="Batch Update with Transaction" +$connection = $adapter->getDriver()->getConnection(); +$connection->beginTransaction(); + +try { + // Deactivate old records + $update = $sql->update('subscriptions'); + $update->set(['status' => 'expired']); + $update->where->lessThan('expiresAt', new Expression('NOW()')); + $update->where->equalTo('status', 'active'); + $sql->prepareStatementForSqlObject($update)->execute(); + + // Archive processed orders + $select = $sql->select('orders') + ->where(['status' => 'completed']) + ->where->lessThan( + 'completedAt', + new Expression('DATE_SUB(NOW(), INTERVAL 1 YEAR)') + ); + + $insert = $sql->insert('orders_archive'); + $insert->select($select); + $sql->prepareStatementForSqlObject($insert)->execute(); + + // Delete archived orders from main table + $delete = $sql->delete('orders'); + $delete->where(['status' => 'completed']); + $delete->where->lessThan( + 'completedAt', + new Expression('DATE_SUB(NOW(), INTERVAL 1 YEAR)') + ); + $sql->prepareStatementForSqlObject($delete)->execute(); + + $connection->commit(); +} catch (\Exception $e) { + $connection->rollback(); + throw $e; +} +``` diff --git a/docs/book/sql/insert.md b/docs/book/sql/insert.md new file mode 100644 index 000000000..d07c058d0 --- /dev/null +++ b/docs/book/sql/insert.md @@ -0,0 +1,253 @@ +# Insert Queries + +The `Insert` class provides an API for building SQL INSERT statements. + +## Insert API + +```php title="Insert Class Definition" +class Insert extends AbstractPreparableSql + implements SqlInterface, PreparableSqlInterface +{ + final public const VALUES_MERGE = 'merge'; + final public const VALUES_SET = 'set'; + + public function __construct( + string|TableIdentifier|null $table = null + ); + public function into( + TableIdentifier|string|array $table + ) : static; + public function columns(array $columns) : static; + public function values( + array|Select $values, + string $flag = self::VALUES_SET + ) : static; + public function select(Select $select) : static; + public function getRawState( + ?string $key = null + ) : TableIdentifier|string|array; +} +``` + +As with `Select`, the table may be provided during instantiation or +via the `into()` method. + +## Basic Usage + +```php title="Creating a Basic Insert Statement" +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$insert = $sql->insert('users'); + +$insert->values([ + 'username' => 'john_doe', + 'email' => 'john@example.com', + 'created_at' => date('Y-m-d H:i:s'), +]); + +$statement = $sql->prepareStatementForSqlObject($insert); +$statement->execute(); +``` + +Produces: + +```sql title="Generated SQL Output" +INSERT INTO users (username, email, created_at) VALUES (?, ?, ?) +``` + +## columns() + +The `columns()` method explicitly sets which columns will receive +values: + +```php title="Setting Valid Columns" +$insert->columns(['foo', 'bar']); // set the valid columns +``` + +When using `columns()`, only the specified columns will be included +even if more values are provided: + +```php title="Restricting Columns with Validation" +$insert->columns(['username', 'email']); +$insert->values([ + 'username' => 'john', + 'email' => 'john@example.com', + 'extra_field' => 'ignored', // This will be ignored +]); +``` + +## values() + +The default behavior of values is to set the values. +Successive calls will not preserve values from previous calls. + +```php title="Setting Values for Insert" +$insert->values([ + 'col_1' => 'value1', + 'col_2' => 'value2', +]); +``` + +To merge values with previous calls, provide the appropriate flag: +`PhpDb\Sql\Insert::VALUES_MERGE` + +```php title="Merging Values from Multiple Calls" +$insert->values(['col_1' => 'value1'], $insert::VALUES_SET); +$insert->values(['col_2' => 'value2'], $insert::VALUES_MERGE); +``` + +This produces: + +```sql title="Merged Values SQL Output" +INSERT INTO table (col_1, col_2) VALUES (?, ?) +``` + +## select() + +The `select()` method enables INSERT INTO ... SELECT statements, +copying data from one table to another. + +```php title="INSERT INTO SELECT Statement" +$select = $sql->select('tempUsers') + ->columns(['username', 'email', 'createdAt']) + ->where(['imported' => false]); + +$insert = $sql->insert('users'); +$insert->columns(['username', 'email', 'createdAt']); +$insert->select($select); +``` + +Produces: + +```sql title="INSERT SELECT SQL Output" +INSERT INTO users (username, email, createdAt) +SELECT username, email, createdAt +FROM tempUsers WHERE imported = 0 +``` + +Alternatively, you can pass the Select object directly to `values()`: + +```php title="Passing Select to values() Method" +$insert->values($select); +``` + +Important: The column order must match between INSERT columns and +SELECT columns. + +## Property-style Column Access + +The Insert class supports property-style access to columns as an +alternative to using `values()`: + +```php title="Using Property-style Column Access" +$insert = $sql->insert('users'); +$insert->name = 'John'; +$insert->email = 'john@example.com'; + +if (isset($insert->name)) { + $value = $insert->name; +} + +unset($insert->email); +``` + +This is equivalent to: + +```php title="Equivalent values() Method Call" +$insert->values([ + 'name' => 'John', + 'email' => 'john@example.com', +]); +``` + +## InsertIgnore + +The `InsertIgnore` class provides MySQL-specific INSERT IGNORE syntax, +which silently ignores rows that would cause duplicate key errors. + +```php title="Using InsertIgnore for Duplicate Prevention" +use PhpDb\Sql\InsertIgnore; + +$insert = new InsertIgnore('users'); +$insert->values([ + 'username' => 'john', + 'email' => 'john@example.com', +]); +``` + +Produces: + +```sql title="INSERT IGNORE SQL Output" +INSERT IGNORE INTO users (username, email) VALUES (?, ?) +``` + +If a row with the same username or email already exists and there is a +unique constraint, the insert will be silently skipped rather than +producing an error. + +Note: INSERT IGNORE is MySQL-specific. Other databases may use +different syntax for this behavior +(e.g., INSERT ... ON CONFLICT DO NOTHING in PostgreSQL). + +## Examples + +```php title="Basic insert with prepared statement" +$insert = $sql->insert('products'); +$insert->values([ + 'name' => 'Widget', + 'price' => 29.99, + 'category_id' => 5, + 'created_at' => new Expression('NOW()'), +]); + +$statement = $sql->prepareStatementForSqlObject($insert); +$result = $statement->execute(); + +// Get the last insert ID +$lastId = $adapter->getDriver()->getLastGeneratedValue(); +``` + +```php title="Insert with expressions" +$insert = $sql->insert('logs'); +$insert->values([ + 'message' => 'User logged in', + 'created_at' => new Expression('NOW()'), + 'ip_hash' => new Expression('MD5(?)', ['192.168.1.1']), +]); +``` + +```php title="Bulk insert from select" +// Copy active users to an archive table +$select = $sql->select('users') + ->columns(['id', 'username', 'email', 'created_at']) + ->where(['status' => 'active']); + +$insert = $sql->insert('users_archive'); +$insert->columns([ + 'user_id', + 'username', + 'email', + 'original_created_at' +]); +$insert->select($select); + +$statement = $sql->prepareStatementForSqlObject($insert); +$statement->execute(); +``` + +```php title="Conditional insert with InsertIgnore" +// Import users, skipping duplicates +$users = [ + ['username' => 'alice', 'email' => 'alice@example.com'], + ['username' => 'bob', 'email' => 'bob@example.com'], +]; + +foreach ($users as $userData) { + $insert = new InsertIgnore('users'); + $insert->values($userData); + + $statement = $sql->prepareStatementForSqlObject($insert); + $statement->execute(); +} +``` diff --git a/docs/book/sql/intro.md b/docs/book/sql/intro.md new file mode 100644 index 000000000..2ceadc01e --- /dev/null +++ b/docs/book/sql/intro.md @@ -0,0 +1,289 @@ +# SQL Abstraction + +`PhpDb\Sql` provides an object-oriented API for building +platform-specific SQL queries. It produces either a prepared `Statement` +with `ParameterContainer`, or a raw SQL string for direct execution. +Requires an `Adapter` for platform-specific SQL generation. + +## Quick Start + +The `PhpDb\Sql\Sql` class creates the four primary DML statement types: +`Select`, `Insert`, `Update`, and `Delete`. + +```php title="Creating SQL Statement Objects" +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$select = $sql->select(); // PhpDb\Sql\Select instance +$insert = $sql->insert(); // PhpDb\Sql\Insert instance +$update = $sql->update(); // PhpDb\Sql\Update instance +$delete = $sql->delete(); // PhpDb\Sql\Delete instance +``` + +As a developer, you can now interact with these objects, as described in the +sections below, to customize each query. Once they have been populated with +values, they are ready to either be prepared or executed. + +### Preparing a Statement + +To prepare (using a Select object): + +```php +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$select = $sql->select(); +$select->from('foo'); +$select->where(['id' => 2]); + +$statement = $sql->prepareStatementForSqlObject($select); +$results = $statement->execute(); +``` + +### Executing a Query Directly + +To execute (using a Select object) + +```php +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$select = $sql->select(); +$select->from('foo'); +$select->where(['id' => 2]); + +$selectString = $sql->buildSqlString($select); +$results = $adapter->query($selectString, $adapter::QUERY_MODE_EXECUTE); +``` + +`PhpDb\\Sql\\Sql` objects can also be bound to a particular table so that in +obtaining a `Select`, `Insert`, `Update`, or `Delete` instance, the object will be +seeded with the table: + +```php title="Binding to a Default Table" +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter, 'foo'); +$select = $sql->select(); +// $select already has from('foo') applied +$select->where(['id' => 2]); +``` + +## Common Interfaces for SQL Implementations + +Each of these objects implements the following two interfaces: + +```php title="PreparableSqlInterface and SqlInterface" +interface PreparableSqlInterface +{ + public function prepareStatement( + Adapter $adapter, + StatementInterface $statement + ) : void; +} + +interface SqlInterface +{ + public function getSqlString( + PlatformInterface $adapterPlatform = null + ) : string; +} +``` + +Use these functions to produce either (a) a prepared statement, +or (b) a string to execute. + +## SQL Arguments and Argument Types + +`PhpDb\Sql` provides individual `Argument\` types as well as an +`Argument` factory class and an `ArgumentType` enum for type-safe +specification of SQL values. This provides a modern, object-oriented +alternative to using raw values or the legacy type constants. + +The `ArgumentType` enum defines six types, +each backed by its corresponding class: + +- `Identifier` - For column names, table names, and other identifiers that + should be quoted +- `Identifiers` - For arrays of identifiers + (e.g., multi-column IN predicates) +- `Value` - For values that should be parameterized or properly escaped + (default) +- `Values` - For arrays of values (e.g., IN clauses) +- `Literal` - For literal SQL fragments that should not be quoted + or escaped +- `Select` - For subqueries (Expression or SqlInterface objects) + +All argument classes are `readonly` and implement `ArgumentInterface`: + +```php title="Using Argument Factory and Classes" +use PhpDb\Sql\Argument; + +// Using the Argument factory class (recommended) +$valueArg = Argument::value(123); // Value type +$identifierArg = Argument::identifier('id'); // Identifier type +$literalArg = Argument::literal('NOW()'); // Literal SQL +$valuesArg = Argument::values([1, 2, 3]); // Multiple values +// Multiple identifiers +$identifiersArg = Argument::identifiers(['col1', 'col2']); + +// Direct instantiation is preferred +$arg = new Argument\Identifier('column_name'); +$arg = new Argument\Value(123); +$arg = new Argument\Literal('NOW()'); +$arg = new Argument\Values([1, 2, 3]); +``` + +The `Argument` classes are particularly useful when working with +expressions where you need to explicitly control how values are treated: + +```php title="Type-Safe Expression Arguments" +use PhpDb\Sql\Argument; +use PhpDb\Sql\Expression; + +// With Argument classes - explicit and type-safe +$expression = new Expression( + 'CONCAT(?, ?, ?)', + [ + new Argument\Identifier('column1'), + new Argument\Value('-'), + new Argument\Identifier('column2') + ] +); +``` + +Scalar values passed directly to `Expression` are automatically wrapped: + +- Scalars become `Argument\Value` +- Arrays become `Argument\Values` +- `ExpressionInterface` instances become `Argument\Select` + +> ### Literals +> +> `PhpDb\Sql` makes the distinction that literals will not have any +> parameters that need interpolating, while `Expression` objects *might* +> have parameters that need interpolating. In cases where there are +> parameters in an `Expression`, `PhpDb\Sql\AbstractSql` will do its best +> to identify placeholders when the `Expression` is processed during +> statement creation. In short, if you don't have parameters, +> use `Literal` objects`. + +## Working with the Sql Factory Class + +The `Sql` class serves as a factory for creating SQL statement objects +and provides methods for preparing and building SQL strings. + +```php title="Instantiating the Sql Factory" +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$sql = new Sql($adapter, 'defaultTable'); +``` + +```php title="Factory Methods" +$select = $sql->select(); +$select = $sql->select('users'); + +$insert = $sql->insert(); +$insert = $sql->insert('users'); + +$update = $sql->update(); +$update = $sql->update('users'); + +$delete = $sql->delete(); +$delete = $sql->delete('users'); +``` + +### Using a Default Table with Factory Methods + +When a default table is set on the Sql instance, +it will be used for all created statements unless overridden: + +```php +$sql = new Sql($adapter, 'users'); +$select = $sql->select(); +$insert = $sql->insert(); +``` + +### Preparing and Executing Queries + +The recommended approach for executing queries is to prepare them first: + +```php +$select = $sql->select('users')->where(['status' => 'active']); +$statement = $sql->prepareStatementForSqlObject($select); +$results = $statement->execute(); +``` + +This approach: + +- Uses parameter binding for security against SQL injection +- Allows the database to cache query plans +- Is the preferred method for production code + +### Building SQL String for Debugging + +For debugging or special cases, you can build the SQL string directly: + +```php +$select = $sql->select('users')->where(['id' => 5]); +$sqlString = $sql->buildSqlString($select); +``` + +Note: Direct string building bypasses parameter binding. +Use with caution and never with user input. + +```php title="Getting the SQL Platform" +$platform = $sql->getSqlPlatform(); +``` + +The platform object handles database-specific SQL generation and can be +used for custom query building. + +## TableIdentifier + +The `TableIdentifier` class provides a type-safe way to reference tables, +especially when working with schemas or databases. + +```php title="Creating and Using TableIdentifier" +use PhpDb\Sql\TableIdentifier; + +$table = new TableIdentifier('users', 'production'); + +$tableName = $table->getTable(); +$schemaName = $table->getSchema(); + +[$table, $schema] = $table->getTableAndSchema(); +``` + +### TableIdentifier in SELECT Queries + +Usage in SQL objects: + +```php +$select = new Select(new TableIdentifier('orders', 'ecommerce')); + +$select->join( + new TableIdentifier('customers', 'crm'), + 'orders.customerId = customers.id' +); +``` + +Produces: + +```sql +SELECT * FROM "ecommerce"."orders" +INNER JOIN "crm"."customers" ON orders.customerId = customers.id +``` + +### TableIdentifier with Table Aliases + +With aliases: + +```php +$select->from(['o' => new TableIdentifier('orders', 'sales')]) + ->join( + ['c' => new TableIdentifier('customers', 'crm')], + 'o.customerId = c.id' + ); +``` diff --git a/docs/book/sql/select.md b/docs/book/sql/select.md new file mode 100644 index 000000000..5f5a8387a --- /dev/null +++ b/docs/book/sql/select.md @@ -0,0 +1,462 @@ +# Select Queries + +`PhpDb\Sql\Select` presents a unified API for building +platform-specific SQL SELECT queries. Instances may be created and +consumed without `PhpDb\Sql\Sql`: + +## Creating a Select instance + +```php +use PhpDb\Sql\Select; + +$select = new Select(); +// or, to produce a $select bound to a specific table +$select = new Select('foo'); +``` + +If a table is provided to the `Select` object, then `from()` cannot be called +later to change the name of the table. + +## Select API + +Once you have a valid `Select` object, the following API can be used to +further specify various select statement parts: + +```php title="Select class definition and constants" +class Select extends AbstractPreparableSql + implements SqlInterface, PreparableSqlInterface +{ + final public const JOIN_INNER = 'inner'; + final public const JOIN_OUTER = 'outer'; + final public const JOIN_FULL_OUTER = 'full outer'; + final public const JOIN_LEFT = 'left'; + final public const JOIN_RIGHT = 'right'; + final public const JOIN_LEFT_OUTER = 'left outer'; + final public const JOIN_RIGHT_OUTER = 'right outer'; + final public const SQL_STAR = '*'; + final public const ORDER_ASCENDING = 'ASC'; + final public const ORDER_DESCENDING = 'DESC'; + final public const QUANTIFIER_DISTINCT = 'DISTINCT'; + final public const QUANTIFIER_ALL = 'ALL'; + final public const COMBINE_UNION = 'union'; + final public const COMBINE_EXCEPT = 'except'; + final public const COMBINE_INTERSECT = 'intersect'; + + public Where $where; + public Having $having; + public Join $joins; + + public function __construct( + array|string|TableIdentifier|null $table = null + ); + public function from( + array|string|TableIdentifier $table + ) : static; + public function quantifier( + ExpressionInterface|string $quantifier + ) : static; + public function columns( + array $columns, + bool $prefixColumnsWithTable = true + ) : static; + public function join( + array|string|TableIdentifier $name, + PredicateInterface|string $on, + array|string $columns = self::SQL_STAR, + string $type = self::JOIN_INNER + ) : static; + public function where( + PredicateInterface|array|string|Closure $predicate, + string $combination = Predicate\PredicateSet::OP_AND + ) : self; + public function group(mixed $group) : static; + public function having( + Having|PredicateInterface|array|Closure|string $predicate, + string $combination = Predicate\PredicateSet::OP_AND + ) : static; + public function order( + ExpressionInterface|array|string $order + ) : static; + public function limit(int|string $limit) : static; + public function offset(int|string $offset) : static; + public function combine( + Select $select, + string $type = self::COMBINE_UNION, + string $modifier = '' + ) : static; + public function reset(string $part) : static; + public function getRawState(?string $key = null) : mixed; + public function isTableReadOnly() : bool; +} +``` + +## from() + +```php title="Specifying the FROM table" +// As a string: +$select->from('foo'); + +// As an array to specify an alias +// (produces SELECT "t".* FROM "table" AS "t") +$select->from(['t' => 'table']); + +// Using a Sql\TableIdentifier: +// (same output as above) +$select->from(['t' => new TableIdentifier('table')]); +``` + +## columns() + +```php title="Selecting columns" +// As an array of names +$select->columns(['foo', 'bar']); + +// As an associative array with aliases as the keys +// (produces 'bar' AS 'foo', 'bax' AS 'baz') +$select->columns([ + 'foo' => 'bar', + 'baz' => 'bax' +]); + +// Sql function call on the column +// (produces CONCAT_WS('/', 'bar', 'bax') AS 'foo') +$select->columns([ + 'foo' => new \PhpDb\Sql\Expression("CONCAT_WS('/', 'bar', 'bax')") +]); +``` + +## join() + +```php title="Basic JOIN examples" +$select->join( + 'foo', // table name + 'id = bar.id', // expression to join on + ['bar', 'baz'], // (optional) list of columns + $select::JOIN_OUTER // (optional), one of inner, outer, etc. +); + +$select + ->from(['f' => 'foo']) // base table + ->join( + ['b' => 'bar'], // join table with alias + 'f.foo_id = b.foo_id' // join expression + ); +``` + +The `$on` parameter accepts either a string or a `PredicateInterface` +for complex join conditions: + +```php title="JOIN with predicate conditions" +use PhpDb\Sql\Predicate; + +$where = new Predicate\Predicate(); +$where->equalTo( + 'orders.customerId', + 'customers.id', + Predicate\Predicate::TYPE_IDENTIFIER, + Predicate\Predicate::TYPE_IDENTIFIER + ) + ->greaterThan('orders.amount', 100); + +$select->from('customers') + ->join('orders', $where, ['orderId', 'amount']); +``` + +Produces: + +```sql +SELECT customers.*, orders.orderId, orders.amount +FROM customers +INNER JOIN orders + ON orders.customerId = customers.id AND orders.amount > 100 +``` + +## order() + +```php title="Ordering results" +$select = new Select; +$select->order('id DESC'); // produces 'id' DESC + +$select = new Select; +$select + ->order('id DESC') + // produces 'id' DESC, 'name' ASC, 'age' DESC + ->order('name ASC, age DESC'); + +$select = new Select; +// produces 'name' ASC, 'age' DESC +$select->order(['name ASC', 'age DESC']); +``` + +## limit() and offset() + +```php title="Limiting and offsetting results" +$select = new Select; +$select->limit(5); +$select->offset(10); +``` + +## group() + +The `group()` method specifies columns for GROUP BY clauses, +typically used with aggregate functions to group rows that share +common values. + +```php title="Grouping by a single column" +$select->group('category'); +``` + +Multiple columns can be specified as an array, +or by calling `group()` multiple times: + +```php title="Grouping by multiple columns" +$select->group(['category', 'status']); + +$select->group('category') + ->group('status'); +``` + +As an example with aggregate functions: + +```php title="Grouping with aggregate functions" +$select->from('orders') + ->columns([ + 'customer_id', + 'totalOrders' => new Expression('COUNT(*)'), + 'totalAmount' => new Expression('SUM(amount)'), + ]) + ->group('customer_id'); +``` + +Produces: + +```sql +SELECT customer_id, COUNT(*) AS totalOrders, SUM(amount) AS totalAmount +FROM orders +GROUP BY customer_id +``` + +You can also use expressions in GROUP BY: + +```php title="Grouping with expressions" +$select->from('orders') + ->columns([ + 'orderYear' => new Expression('YEAR(created_at)'), + 'orderCount' => new Expression('COUNT(*)'), + ]) + ->group(new Expression('YEAR(created_at)')); +``` + +Produces: + +```sql +SELECT YEAR(created_at) AS orderYear, + COUNT(*) AS orderCount +FROM orders +GROUP BY YEAR(created_at) +``` + +## quantifier() + +The `quantifier()` method applies a quantifier to the SELECT statement, +such as DISTINCT or ALL. + +```php title="Using DISTINCT quantifier" +$select->from('orders') + ->columns(['customer_id']) + ->quantifier(Select::QUANTIFIER_DISTINCT); +``` + +Produces: + +```sql +SELECT DISTINCT customer_id FROM orders +``` + +The `QUANTIFIER_ALL` constant explicitly specifies ALL, +though this is typically the default behavior: + +```php title="Using ALL quantifier" +$select->quantifier(Select::QUANTIFIER_ALL); +``` + +## reset() + +The `reset()` method allows you to clear specific parts of a Select +statement, useful when building queries dynamically. + +```php title="Building a Select query before reset" +$select->from('users') + ->columns(['id', 'name']) + ->where(['status' => 'active']) + ->order('created_at DESC') + ->limit(10); +``` + +Before reset, produces: + +```sql +SELECT id, name FROM users +WHERE status = 'active' ORDER BY created_at DESC LIMIT 10 +``` + +After resetting WHERE, ORDER, and LIMIT: + +```php title="Resetting specific parts of a query" +$select->reset(Select::WHERE); +$select->reset(Select::ORDER); +$select->reset(Select::LIMIT); +``` + +Produces: + +```sql +SELECT id, name FROM users +``` + +Available parts that can be reset: + +- `Select::QUANTIFIER` +- `Select::COLUMNS` +- `Select::JOINS` +- `Select::WHERE` +- `Select::GROUP` +- `Select::HAVING` +- `Select::LIMIT` +- `Select::OFFSET` +- `Select::ORDER` +- `Select::COMBINE` + +Note that resetting `Select::TABLE` will throw an exception if the +table was provided in the constructor (read-only table). + +## getRawState() + +The `getRawState()` method returns the internal state of the Select +object, useful for debugging or introspection. + +```php title="Getting the full raw state" +$state = $select->getRawState(); +``` + +Returns an array containing: + +```php title="Raw state array structure" +[ + 'table' => 'users', + 'quantifier' => null, + 'columns' => ['id', 'name', 'email'], + 'joins' => Join object, + 'where' => Where object, + 'order' => ['created_at DESC'], + 'limit' => 10, + 'offset' => 0, + 'group' => null, + 'having' => null, + 'combine' => [], +] +``` + +You can also retrieve a specific state element: + +```php title="Getting specific state elements" +$table = $select->getRawState(Select::TABLE); +$columns = $select->getRawState(Select::COLUMNS); +$limit = $select->getRawState(Select::LIMIT); +``` + +## Combine + +For combining SELECT statements using UNION, INTERSECT, or EXCEPT, +see [Advanced SQL Features: Combine](advanced.md#combine-union-intersect-except). + +Quick example: + +```php +use PhpDb\Sql\Combine; + +$select1 = $sql->select('table1')->where(['status' => 'active']); +$select2 = $sql->select('table2')->where(['status' => 'pending']); + +$combine = new Combine(); +$combine->union($select1); +$combine->union($select2, 'ALL'); +``` + +## Advanced JOIN Usage + +### Multiple JOIN types in a single query + +```php title="Combining different JOIN types" +$select->from(['u' => 'users']) + ->join( + ['o' => 'orders'], + 'u.id = o.userId', + ['orderId', 'amount'], + Select::JOIN_LEFT + ) + ->join( + ['p' => 'products'], + 'o.productId = p.id', + ['productName', 'price'], + Select::JOIN_INNER + ) + ->join( + ['r' => 'reviews'], + 'p.id = r.productId', + ['rating'], + Select::JOIN_RIGHT + ); +``` + +### JOIN with no column selection + +When you need to join a table only for filtering purposes without +selecting its columns: + +```php title="Joining for filtering without selecting columns" +$select->from('orders') + ->join('customers', 'orders.customerId = customers.id', []) + ->where(['customers.status' => 'premium']); +``` + +Produces: + +```sql +SELECT orders.* FROM orders +INNER JOIN customers ON orders.customerId = customers.id +WHERE customers.status = 'premium' +``` + +### JOIN with expressions in columns + +```php title="Using expressions in JOIN column selection" +$select->from('users') + ->join( + 'orders', + 'users.id = orders.userId', + [ + 'orderCount' => new Expression('COUNT(*)'), + 'totalSpent' => new Expression('SUM(amount)'), + ] + ); +``` + +### Accessing the Join object + +The Join object can be accessed directly for programmatic manipulation: + +```php title="Programmatically accessing Join information" +foreach ($select->joins as $join) { + $tableName = $join['name']; + $onCondition = $join['on']; + $columns = $join['columns']; + $joinType = $join['type']; +} + +$joinCount = count($select->joins); + +$allJoins = $select->joins->getJoins(); + +$select->joins->reset(); +``` diff --git a/docs/book/sql/update-delete.md b/docs/book/sql/update-delete.md new file mode 100644 index 000000000..c880d6e64 --- /dev/null +++ b/docs/book/sql/update-delete.md @@ -0,0 +1,315 @@ +# Update and Delete Queries + +## Update + +The `Update` class provides an API for building SQL UPDATE statements. + +```php title="Update API" +class Update extends AbstractPreparableSql + implements SqlInterface, PreparableSqlInterface +{ + final public const VALUES_MERGE = 'merge'; + final public const VALUES_SET = 'set'; + + public Where $where; + + public function __construct( + string|TableIdentifier|null $table = null + ); + public function table( + TableIdentifier|string|array $table + ) : static; + public function set( + array $values, + string|int $flag = self::VALUES_SET + ) : static; + public function where( + PredicateInterface|array|Closure|string|Where $predicate, + string $combination = Predicate\PredicateSet::OP_AND + ) : static; + public function join( + array|string|TableIdentifier $name, + string $on, + string $type = Join::JOIN_INNER + ) : static; + public function getRawState(?string $key = null) : mixed; +} +``` + +```php title="Basic Usage" +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$update = $sql->update('users'); + +$update->set(['status' => 'inactive']); +$update->where(['id' => 123]); + +$statement = $sql->prepareStatementForSqlObject($update); +$statement->execute(); +``` + +Produces: + +```sql title="Generated SQL for basic update" +UPDATE users SET status = ? WHERE id = ? +``` + +### set() + +```php title="Setting multiple values" +$update->set(['foo' => 'bar', 'baz' => 'bax']); +``` + +The `set()` method accepts a flag parameter to control merging behavior: + +```php title="Controlling merge behavior with VALUES_SET and VALUES_MERGE" +$update->set(['status' => 'active'], Update::VALUES_SET); +$update->set( + ['updatedAt' => new Expression('NOW()')], + Update::VALUES_MERGE +); +``` + +When using `VALUES_MERGE`, you can optionally specify a numeric +priority to control the order of SET clauses: + +```php title="Using numeric priority to control SET clause ordering" +$update->set(['counter' => 1], 100); +$update->set(['status' => 'pending'], 50); +$update->set(['flag' => true], 75); +``` + +Produces SET clauses in priority order (50, 75, 100): + +```sql title="Generated SQL showing priority-based ordering" +UPDATE table SET status = ?, flag = ?, counter = ? +``` + +This is useful when the order of SET operations matters for certain +database operations or triggers. + +### where() + +The `where()` method works the same as in Select queries. +See the [Where and Having](where-having.md) documentation for full +details. + +```php title="Using various where clause methods" +$update->where(['id' => 5]); +$update->where->equalTo('status', 'active'); +$update->where(function ($where) { + $where->greaterThan('age', 18); +}); +``` + +### join() + +The Update class supports JOIN clauses for multi-table updates: + +```php title="Basic JOIN syntax" +$update->join('bar', 'foo.id = bar.foo_id', Update::JOIN_LEFT); +``` + +Example: + +```php title="Update with INNER JOIN on customers table" +$update = $sql->update('orders'); +$update->set(['status' => 'cancelled']); +$update->join( + 'customers', + 'orders.customerId = customers.id', + Join::JOIN_INNER +); +$update->where(['customers.status' => 'inactive']); +``` + +Produces: + +```sql title="Generated SQL for update with JOIN" +UPDATE orders +INNER JOIN customers ON orders.customerId = customers.id +SET status = ? +WHERE customers.status = ? +``` + +Note: JOIN support in UPDATE statements varies by database platform. +MySQL and PostgreSQL support this syntax, +while some other databases may not. + +## Delete + +The `Delete` class provides an API for building SQL DELETE statements. + +```php title="Delete API" +class Delete extends AbstractPreparableSql + implements SqlInterface, PreparableSqlInterface +{ + public Where $where; + + public function __construct( + string|TableIdentifier|null $table = null + ); + public function from( + TableIdentifier|string|array $table + ) : static; + public function where( + PredicateInterface|array|Closure|string|Where $predicate, + string $combination = Predicate\PredicateSet::OP_AND + ) : static; + public function getRawState(?string $key = null) : mixed; +} +``` + +```php title="Delete Basic Usage" +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$delete = $sql->delete('users'); + +$delete->where(['id' => 123]); + +$statement = $sql->prepareStatementForSqlObject($delete); +$statement->execute(); +``` + +Produces: + +```sql title="Generated SQL for basic delete" +DELETE FROM users WHERE id = ? +``` + +### Delete where() + +The `where()` method works the same as in Select queries. +See the [Where and Having](where-having.md) documentation for full +details. + +```php title="Using where conditions in delete statements" +$delete->where(['status' => 'deleted']); +$delete->where->lessThan('created_at', '2020-01-01'); +``` + +## Safety Features + +Both Update and Delete classes include empty WHERE protection by +default, which prevents accidental mass updates or deletes. + +```php title="Checking empty WHERE protection status" +$update = $sql->update('users'); +$update->set(['status' => 'deleted']); +// No where clause - this could update ALL rows! + +$state = $update->getRawState(); +$protected = $state['emptyWhereProtection']; +``` + +Most database drivers will prevent execution of UPDATE or DELETE +statements without a WHERE clause when this protection is enabled. +Always include a WHERE clause: + +```php title="Adding WHERE clause for safe operations" +$update->where(['id' => 123]); + +$delete = $sql->delete('logs'); +$delete->where->lessThan('createdAt', '2020-01-01'); +``` + +## Examples + +```php title="Update with expressions" +$update = $sql->update('products'); +$update->set([ + 'view_count' => new Expression('view_count + 1'), + 'last_viewed' => new Expression('NOW()'), +]); +$update->where(['id' => $productId]); +``` + +Produces: + +```sql title="Generated SQL for update with expressions" +UPDATE products +SET view_count = view_count + 1, last_viewed = NOW() +WHERE id = ? +``` + +```php title="Conditional update" +$update = $sql->update('orders'); +$update->set(['status' => 'shipped']); +$update->where(function ($where) { + $where->equalTo('status', 'processing') + ->and + ->lessThan( + 'created_at', + new Expression('NOW() - INTERVAL 7 DAY') + ); +}); +``` + +```php title="Update with JOIN" +$update = $sql->update('products'); +$update->set(['products.is_featured' => true]); +$update->join('categories', 'products.category_id = categories.id'); +$update->where(['categories.name' => 'Electronics']); +``` + +```php title="Delete old records" +$delete = $sql->delete('sessions'); +$delete->where->lessThan( + 'last_activity', + new Expression('NOW() - INTERVAL 24 HOUR') +); + +$statement = $sql->prepareStatementForSqlObject($delete); +$result = $statement->execute(); +$deletedCount = $result->getAffectedRows(); +``` + +```php title="Delete with complex conditions" +$delete = $sql->delete('users'); +$delete->where(function ($where) { + $where->nest() + ->equalTo('status', 'pending') + ->and + ->lessThan('created_at', '2023-01-01') + ->unnest() + ->or + ->nest() + ->equalTo('status', 'banned') + ->and + ->isNull('appeal_date') + ->unnest(); +}); +``` + +Produces: + +```sql title="Generated SQL for delete with complex conditions" +DELETE FROM users +WHERE (status = 'pending' AND created_at < '2023-01-01') + OR (status = 'banned' AND appeal_date IS NULL) +``` + +```php title="Bulk operations with transactions" +$connection = $adapter->getDriver()->getConnection(); +$connection->beginTransaction(); + +try { + // Update related records + $update = $sql->update('order_items'); + $update->set(['status' => 'cancelled']); + $update->where(['order_id' => $orderId]); + $sql->prepareStatementForSqlObject($update)->execute(); + + // Delete the order + $delete = $sql->delete('orders'); + $delete->where(['id' => $orderId]); + $sql->prepareStatementForSqlObject($delete)->execute(); + + $connection->commit(); +} catch (\Exception $e) { + $connection->rollback(); + throw $e; +} +``` diff --git a/docs/book/sql/where-having.md b/docs/book/sql/where-having.md new file mode 100644 index 000000000..ecb24bf96 --- /dev/null +++ b/docs/book/sql/where-having.md @@ -0,0 +1,845 @@ +# Where and Having + +In the following, we will talk about `Where`; note, however, +that `Having` utilizes the same API. + +Effectively, `Where` and `Having` extend from the same base object, a +`Predicate` (and `PredicateSet`). All of the parts that make up a WHERE or +HAVING clause that are AND'ed or OR'd together are called *predicates*. +The full set of predicates is called a `PredicateSet`. A `Predicate` +generally contains the values (and identifiers) separate from the +fragment they belong to until the last possible moment when the statement +is either prepared (parameteritized) or executed. In parameterization, +the parameters will be replaced with their proper placeholder +(a named or positional parameter), and the values stored inside an +`Adapter\ParameterContainer`. When executed, the values will be +interpolated into the fragments they belong to and properly quoted. + +## Using where() and having() + +`PhpDb\Sql\Select` provides bit of flexibility as it regards to what +kind of parameters are acceptable when calling `where()` or `having()`. +The method signature is listed as: + +```php title="Method signature for where() and having()" +/** + * Create where clause + * + * @param Where|callable|string|array $predicate + * @param string $combination One of the OP_* constants from + * Predicate\PredicateSet + * @return Select + */ +public function where( + $predicate, + $combination = Predicate\PredicateSet::OP_AND +); +``` + +If you provide a `PhpDb\Sql\Where` instance to `where()` or a +`PhpDb\Sql\Having` instance to `having()`, any previous internal +instances will be replaced completely. When either instance is processed, +this object will be iterated to produce the WHERE or HAVING section of +the SELECT statement. + +If you provide a PHP callable to `where()` or `having()`, +this function will be called with the `Select`'s `Where`/`Having` +instance as the only parameter. This enables code like the following: + +```php title="Using a callable with where()" +$select->where(function (Where $where) { + $where->like('username', 'ralph%'); +}); +``` + +If you provide a *string*, this string will be used to create a +`PhpDb\Sql\Predicate\Expression` instance, and its contents will be +applied as-is, with no quoting: + +```php title="Using a string expression with where()" +// SELECT "foo".* FROM "foo" WHERE x = 5 +$select->from('foo')->where('x = 5'); +``` + +If you provide an array with integer indices, the value can be one of: + +- a string; this will be used to build a `Predicate\Expression`. +- any object implementing `Predicate\PredicateInterface`. + +In either case, the instances are pushed onto the `Where` stack with +the `$combination` provided (defaulting to `AND`). + +As an example: + +```php title="Using an array of string expressions" +// SELECT "foo".* FROM "foo" WHERE x = 5 AND y = z +$select->from('foo')->where(['x = 5', 'y = z']); +``` + +If you provide an associative array with string keys, +any value with a string key will be cast as follows: + +| PHP value | Predicate type | +|-----------|------------------------------------------| +| `null` | `Predicate\IsNull` | +| `array` | `Predicate\In` | +| `string` | `Predicate\Operator`, key is identifier. | + +As an example: + +```php title="Using an associative array with mixed value types" +// SELECT "foo".* FROM "foo" WHERE "c1" IS NULL +// AND "c2" IN (?, ?, ?) AND "c3" IS NOT NULL +$select->from('foo')->where([ + 'c1' => null, + 'c2' => [1, 2, 3], + new \PhpDb\Sql\Predicate\IsNotNull('c3'), +]); +``` + +As another example of complex queries with nested conditions e.g. + +```sql title="SQL example with nested OR and AND conditions" +SELECT * WHERE (column1 is null or column1 = 2) AND (column2 = 3) +``` + +you need to use the `nest()` and `unnest()` methods, as follows: + +```php title="Using nest() and unnest() for complex conditions" +$select->where->nest() // bracket opened + ->isNull('column1') + ->or + ->equalTo('column1', '2') + ->unnest(); // bracket closed + ->equalTo('column2', '3'); +``` + +## Predicate API + +The `Where` and `Having` API is that of `Predicate` and `PredicateSet`: + +```php title="Predicate class API definition" +// Where & Having extend Predicate: +class Predicate extends PredicateSet +{ + // Magic properties for fluent chaining + public Predicate $and; + public Predicate $or; + public Predicate $nest; + public Predicate $unnest; + + public function nest() : Predicate; + public function setUnnest(?Predicate $predicate = null) : void; + public function unnest() : Predicate; + public function equalTo( + null|float|int|string|ArgumentInterface $left, + null|float|int|string|ArgumentInterface $right + ) : static; + public function notEqualTo( + null|float|int|string|ArgumentInterface $left, + null|float|int|string|ArgumentInterface $right + ) : static; + public function lessThan( + null|float|int|string|ArgumentInterface $left, + null|float|int|string|ArgumentInterface $right + ) : static; + public function greaterThan( + null|float|int|string|ArgumentInterface $left, + null|float|int|string|ArgumentInterface $right + ) : static; + public function lessThanOrEqualTo( + null|float|int|string|ArgumentInterface $left, + null|float|int|string|ArgumentInterface $right + ) : static; + public function greaterThanOrEqualTo( + null|float|int|string|ArgumentInterface $left, + null|float|int|string|ArgumentInterface $right + ) : static; + public function like( + null|float|int|string|ArgumentInterface $identifier, + null|float|int|string|ArgumentInterface $like + ) : static; + public function notLike( + null|float|int|string|ArgumentInterface $identifier, + null|float|int|string|ArgumentInterface $notLike + ) : static; + public function literal(string $literal) : static; + public function expression( + string $expression, + null|string|float|int|array|ArgumentInterface + |ExpressionInterface $parameters = [] + ) : static; + public function isNull( + float|int|string|ArgumentInterface $identifier + ) : static; + public function isNotNull( + float|int|string|ArgumentInterface $identifier + ) : static; + public function in( + float|int|string|ArgumentInterface $identifier, + array|ArgumentInterface $valueSet + ) : static; + public function notIn( + float|int|string|ArgumentInterface $identifier, + array|ArgumentInterface $valueSet + ) : static; + public function between( + null|float|int|string|array|ArgumentInterface $identifier, + null|float|int|string|array|ArgumentInterface $minValue, + null|float|int|string|array|ArgumentInterface $maxValue + ) : static; + public function notBetween( + null|float|int|string|array|ArgumentInterface $identifier, + null|float|int|string|array|ArgumentInterface $minValue, + null|float|int|string|array|ArgumentInterface $maxValue + ) : static; + public function predicate(PredicateInterface $predicate) : static; + + // Inherited From PredicateSet + + public function addPredicate( + PredicateInterface $predicate, + ?string $combination = null + ) : static; + public function addPredicates( + PredicateInterface|Closure|string|array $predicates, + string $combination = self::OP_AND + ) : static; + public function getPredicates() : array; + public function orPredicate( + PredicateInterface $predicate + ) : static; + public function andPredicate( + PredicateInterface $predicate + ) : static; + public function getExpressionData() : ExpressionData; + public function count() : int; +} +``` + +> **Note:** The `$leftType` and `$rightType` parameters have been removed +> from comparison methods. Type information is now encoded within +> `ArgumentInterface` implementations. Pass an `Argument\Identifier` for +> column names, `Argument\Value` for values, or `Argument\Literal` for raw +> SQL fragments directly to control how values are treated. + +Each method in the API will produce a corresponding `Predicate` object +of a similarly named type, as described below. + +## Comparison Predicates + +### Comparison Methods + +Methods: `equalTo()`, `lessThan()`, `greaterThan()`, +`lessThanOrEqualTo()`, `greaterThanOrEqualTo()` + +```php title="Using equalTo() to create an Operator predicate" +$where->equalTo('id', 5); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Operator('id', Operator::OPERATOR_EQUAL_TO, 5) +); +``` + +Operators use the following API: + +```php title="Operator class API definition" +class Operator implements PredicateInterface +{ + final public const OPERATOR_EQUAL_TO = '='; + final public const OP_EQ = '='; + final public const OPERATOR_NOT_EQUAL_TO = '!='; + final public const OP_NE = '!='; + final public const OPERATOR_LESS_THAN = '<'; + final public const OP_LT = '<'; + final public const OPERATOR_LESS_THAN_OR_EQUAL_TO = '<='; + final public const OP_LTE = '<='; + final public const OPERATOR_GREATER_THAN = '>'; + final public const OP_GT = '>'; + final public const OPERATOR_GREATER_THAN_OR_EQUAL_TO = '>='; + final public const OP_GTE = '>='; + + public function __construct( + null|string|ArgumentInterface + |ExpressionInterface|SqlInterface $left = null, + string $operator = self::OPERATOR_EQUAL_TO, + null|bool|string|int|float|ArgumentInterface + |ExpressionInterface|SqlInterface $right = null + ); + public function setLeft( + string|ArgumentInterface + |ExpressionInterface|SqlInterface $left + ) : static; + public function getLeft() : ?ArgumentInterface; + public function setOperator(string $operator) : static; + public function getOperator() : string; + public function setRight( + null|bool|string|int|float|ArgumentInterface + |ExpressionInterface|SqlInterface $right + ) : static; + public function getRight() : ?ArgumentInterface; + public function getExpressionData() : ExpressionData; +} +``` + +> **Note:** The `setLeftType()`, `getLeftType()`, `setRightType()`, and +> `getRightType()` methods have been removed. Type information is now +> encoded within the `ArgumentInterface` implementations. Pass +> `Argument\Identifier`, `Argument\Value`, or `Argument\Literal` directly +> to `setLeft()` and `setRight()` to control how values are treated. + +## Pattern Matching Predicates + +### like($identifier, $like), notLike($identifier, $notLike) + +```php title="Using like() to create a Like predicate" +$where->like($identifier, $like): + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Like($identifier, $like) +); +``` + +The following is the `Like` API: + +```php title="Like class API definition" +class Like implements PredicateInterface +{ + public function __construct( + null|string|ArgumentInterface $identifier = null, + null|bool|float|int|string|ArgumentInterface $like = null + ); + public function setIdentifier( + string|ArgumentInterface $identifier + ) : static; + public function getIdentifier() : ?ArgumentInterface; + public function setLike( + bool|float|int|null|string|ArgumentInterface $like + ) : static; + public function getLike() : ?ArgumentInterface; + public function setSpecification(string $specification) : static; + public function getSpecification() : string; + public function getExpressionData() : ExpressionData; +} +``` + +## Literal and Expression Predicates + +### literal($literal) + +```php title="Using literal() to create a Literal predicate" +$where->literal($literal); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Literal($literal) +); +``` + +The following is the `Literal` API: + +```php title="Literal class API definition" +class Literal implements ExpressionInterface, PredicateInterface +{ + public function __construct(string $literal = ''); + public function setLiteral(string $literal) : self; + public function getLiteral() : string; + public function getExpressionData() : ExpressionData; +} +``` + +### expression($expression, $parameter) + +```php title="Using expression() to create an Expression predicate" +$where->expression($expression, $parameter); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Expression($expression, $parameter) +); +``` + +The following is the `Expression` API: + +```php title="Expression class API definition" +class Expression implements + ExpressionInterface, PredicateInterface +{ + final public const PLACEHOLDER = '?'; + + public function __construct( + string $expression = '', + null|bool|string|float|int|array|ArgumentInterface + |ExpressionInterface $parameters = [] + ); + public function setExpression(string $expression) : self; + public function getExpression() : string; + public function setParameters( + null|bool|string|float|int|array|ExpressionInterface + |ArgumentInterface $parameters = [] + ) : self; + public function getParameters() : array; + public function getExpressionData() : ExpressionData; +} +``` + +Expression parameters can be supplied in multiple ways: + +```php title="Using Expression with various parameter types" +// Using Argument classes for explicit typing +$expression = new Expression( + 'CONCAT(?, ?, ?)', + [ + new Argument\Identifier('column1'), + new Argument\Value('-'), + new Argument\Identifier('column2') + ] +); + +// Scalar values are auto-wrapped as Argument\Value +$expression = new Expression('column > ?', 5); + +// Complex expression with mixed argument types +$select + ->from('foo') + ->columns([ + new Expression( + '(COUNT(?) + ?) AS ?', + [ + new Argument\Identifier('some_column'), + new Argument\Value(5), + new Argument\Identifier('bar'), + ], + ), + ]); + +// Produces SELECT (COUNT("some_column") + '5') AS "bar" FROM "foo" +``` + +## NULL Predicates + +### isNull($identifier) + +```php title="Using isNull() to create an IsNull predicate" +$where->isNull($identifier); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\IsNull($identifier) +); +``` + +The following is the `IsNull` API: + +```php title="IsNull class API definition" +class IsNull implements PredicateInterface +{ + public function __construct( + null|string|ArgumentInterface $identifier = null + ); + public function setIdentifier( + string|ArgumentInterface $identifier + ) : static; + public function getIdentifier() : ?ArgumentInterface; + public function setSpecification(string $specification) : static; + public function getSpecification() : string; + public function getExpressionData() : ExpressionData; +} +``` + +### isNotNull($identifier) + +```php title="Using isNotNull() to create an IsNotNull predicate" +$where->isNotNull($identifier); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\IsNotNull($identifier) +); +``` + +The following is the `IsNotNull` API: + +```php title="IsNotNull class API definition" +class IsNotNull implements PredicateInterface +{ + public function __construct( + null|string|ArgumentInterface $identifier = null + ); + public function setIdentifier( + string|ArgumentInterface $identifier + ) : static; + public function getIdentifier() : ?ArgumentInterface; + public function setSpecification(string $specification) : static; + public function getSpecification() : string; + public function getExpressionData() : ExpressionData; +} +``` + +## Set Predicates + +### in($identifier, $valueSet), notIn($identifier, $valueSet) + +```php title="Using in() to create an In predicate" +$where->in($identifier, $valueSet); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\In($identifier, $valueSet) +); +``` + +The following is the `In` API: + +```php title="In class API definition" +class In implements PredicateInterface +{ + public function __construct( + null|string|ArgumentInterface $identifier = null, + null|array|Select|ArgumentInterface $valueSet = null + ); + public function setIdentifier( + string|ArgumentInterface $identifier + ) : static; + public function getIdentifier() : ?ArgumentInterface; + public function setValueSet( + array|Select|ArgumentInterface $valueSet + ) : static; + public function getValueSet() : ?ArgumentInterface; + public function getExpressionData() : ExpressionData; +} +``` + +## Range Predicates + +### between() and notBetween() + +```php title="Using between() to create a Between predicate" +$where->between($identifier, $minValue, $maxValue); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Between($identifier, $minValue, $maxValue) +); +``` + +The following is the `Between` API: + +```php title="Between class API definition" +class Between implements PredicateInterface +{ + public function __construct( + null|string|ArgumentInterface $identifier = null, + null|int|float|string|ArgumentInterface $minValue = null, + null|int|float|string|ArgumentInterface $maxValue = null + ); + public function setIdentifier( + string|ArgumentInterface $identifier + ) : static; + public function getIdentifier() : ?ArgumentInterface; + public function setMinValue( + null|int|float|string|bool|ArgumentInterface $minValue + ) : static; + public function getMinValue() : ?ArgumentInterface; + public function setMaxValue( + null|int|float|string|bool|ArgumentInterface $maxValue + ) : static; + public function getMaxValue() : ?ArgumentInterface; + public function setSpecification(string $specification) : static; + public function getSpecification() : string; + public function getExpressionData() : ExpressionData; +} +``` + +As an example with different value types: + +```php title="Using between() with different value types" +$where->between('age', 18, 65); +$where->notBetween('price', 100, 500); +$where->between('createdAt', '2024-01-01', '2024-12-31'); +``` + +Produces: + +```sql title="SQL output for between() examples" +WHERE age BETWEEN 18 AND 65 + AND price NOT BETWEEN 100 AND 500 + AND createdAt BETWEEN '2024-01-01' AND '2024-12-31' +``` + +Expressions can also be used: + +```php title="Using between() with an Expression" +$where->between(new Expression('YEAR(createdAt)'), 2020, 2024); +``` + +Produces: + +```sql title="SQL output for between() with Expression" +WHERE YEAR(createdAt) BETWEEN 2020 AND 2024 +``` + +## Advanced Predicate Usage + +### Magic properties for fluent chaining + +The Predicate class provides magic properties that enable fluent method +chaining for combining predicates. These properties (`and`, `or`, `AND`, +`OR`, `nest`, `unnest`, `NEST`, `UNNEST`) facilitate readable query +construction. + +```php title="Using magic properties for fluent chaining" +$select->where + ->equalTo('status', 'active') + ->and + ->greaterThan('age', 18) + ->or + ->equalTo('role', 'admin'); +``` + +Produces: + +```sql title="SQL output for fluent chaining example" +WHERE status = 'active' AND age > 18 OR role = 'admin' +``` + +The properties are case-insensitive for convenience: + +```php title="Case-insensitive magic property usage" +$where->and->equalTo('a', 1); +$where->AND->equalTo('b', 2'); +``` + +### Deep nesting of predicates + +Complex nested conditions can be created using `nest()` and `unnest()`: + +```php title="Creating deeply nested predicate conditions" +$select->where->nest() + ->nest() + ->equalTo('a', 1) + ->or + ->equalTo('b', 2) + ->unnest() + ->and + ->nest() + ->equalTo('c', 3) + ->or + ->equalTo('d', 4) + ->unnest() + ->unnest(); +``` + +Produces: + +```sql title="SQL output for deeply nested predicates" +WHERE ((a = 1 OR b = 2) AND (c = 3 OR d = 4)) +``` + +### addPredicates() intelligent handling + +The `addPredicates()` method from `PredicateSet` provides intelligent +handling of various input types, automatically creating appropriate +predicate objects based on the input. + +```php title="Using addPredicates() with mixed input types" +$where->addPredicates([ + 'status = "active"', + 'age > ?', + 'category' => null, + 'id' => [1, 2, 3], + 'name' => 'John', + new \PhpDb\Sql\Predicate\IsNotNull('email'), +]); +``` + +The method detects and handles: + +| Input Type | Behavior | +| ---------- | -------- | +| String without `?` | Creates `Literal` predicate | +| String with `?` | Creates `Expression` (requires params) | +| Key => `null` | Creates `IsNull` predicate | +| Key => array | Creates `In` predicate | +| Key => scalar | Creates `Operator` (equality) | +| `PredicateInterface` | Uses predicate directly | + +Combination operators can be specified: + +```php title="Using addPredicates() with OR combination" +$where->addPredicates([ + 'role' => 'admin', + 'status' => 'active', +], PredicateSet::OP_OR); +``` + +Produces: + +```sql title="SQL output for OR combination" +WHERE role = 'admin' OR status = 'active' +``` + +### Using LIKE and NOT LIKE patterns + +The `like()` and `notLike()` methods support SQL wildcard patterns: + +```php title="Using like() and notLike() with wildcard patterns" +$where->like('name', 'John%'); +$where->like('email', '%@gmail.com'); +$where->like('description', '%keyword%'); +$where->notLike('email', '%@spam.com'); +``` + +Multiple LIKE conditions: + +```php title="Combining multiple LIKE conditions with OR" +$where->like('name', 'A%') + ->or + ->like('name', 'B%'); +``` + +Produces: + +```sql title="SQL output for multiple LIKE conditions" +WHERE name LIKE 'A%' OR name LIKE 'B%' +``` + +### Using HAVING with aggregate functions + +While `where()` filters rows before grouping, `having()` filters groups +after aggregation. The HAVING clause is used with GROUP BY and aggregate +functions. + +```php title="Using HAVING to filter aggregate results" +$select->from('orders') + ->columns([ + 'customerId', + 'orderCount' => new Expression('COUNT(*)'), + 'totalAmount' => new Expression('SUM(amount)'), + ]) + ->where->greaterThan('amount', 0) + ->group('customerId') + ->having->greaterThan(new Expression('COUNT(*)'), 10) + ->having->greaterThan(new Expression('SUM(amount)'), 1000); +``` + +Produces: + +```sql title="SQL output for HAVING with aggregate functions" +SELECT customerId, + COUNT(*) AS orderCount, + SUM(amount) AS totalAmount +FROM orders +WHERE amount > 0 +GROUP BY customerId +HAVING COUNT(*) > 10 AND SUM(amount) > 1000 +``` + +Using closures with HAVING: + +```php title="Using a closure with HAVING for complex conditions" +$select->having(function ($having) { + $having->greaterThan(new Expression('AVG(rating)'), 4.5) + ->or + ->greaterThan(new Expression('COUNT(reviews)'), 100); +}); +``` + +Produces: + +```sql title="SQL output for HAVING with closure" +HAVING AVG(rating) > 4.5 OR COUNT(reviews) > 100 +``` + +## Subqueries in WHERE Clauses + +Subqueries can be used in various contexts within SQL statements, +including WHERE clauses, FROM clauses, and SELECT columns. + +### Subqueries in WHERE IN clauses + +```php title="Using a subquery in a WHERE IN clause" +$subselect = $sql->select('orders') + ->columns(['customerId']) + ->where(['status' => 'completed']); + +$select = $sql->select('customers') + ->where->in('id', $subselect); +``` + +Produces: + +```sql title="SQL output for subquery in WHERE IN" +SELECT customers.* FROM customers +WHERE id IN ( + SELECT customerId FROM orders WHERE status = 'completed' +) +``` + +### Subqueries in FROM clauses + +```php title="Using a subquery in a FROM clause" +$subselect = $sql->select('orders') + ->columns([ + 'customerId', + 'total' => new Expression('SUM(amount)'), + ]) + ->group('customerId'); + +$select = $sql->select(['orderTotals' => $subselect]) + ->where->greaterThan('orderTotals.total', 1000); +``` + +Produces: + +```sql title="SQL output for subquery in FROM clause" +SELECT orderTotals.* FROM +(SELECT customerId, SUM(amount) AS total + FROM orders GROUP BY customerId) AS orderTotals +WHERE orderTotals.total > 1000 +``` + +### Scalar subqueries in SELECT columns + +```php title="Using a scalar subquery in SELECT columns" +$subselect = $sql->select('orders') + ->columns([new Expression('COUNT(*)')]) + ->where(new Predicate\Expression( + 'orders.customerId = customers.id' + )); + +$select = $sql->select('customers') + ->columns([ + 'id', + 'name', + 'orderCount' => $subselect, + ]); +``` + +Produces: + +```sql title="SQL output for scalar subquery in SELECT" +SELECT id, name, + (SELECT COUNT(*) FROM orders + WHERE orders.customerId = customers.id) AS orderCount +FROM customers +``` + +### Subqueries with comparison operators + +```php title="Using a subquery with a comparison operator" +$subselect = $sql->select('orders') + ->columns([new Expression('AVG(amount)')]); + +$select = $sql->select('orders') + ->where->greaterThan('amount', $subselect); +``` + +Produces: + +```sql title="SQL output for subquery with comparison operator" +SELECT orders.* FROM orders +WHERE amount > (SELECT AVG(amount) FROM orders) +``` diff --git a/docs/book/table-gateway.md b/docs/book/table-gateway.md index 9fdc1416e..155d1abe7 100644 --- a/docs/book/table-gateway.md +++ b/docs/book/table-gateway.md @@ -1,8 +1,10 @@ # Table Gateways -The Table Gateway subcomponent provides an object-oriented representation of a -database table; its methods mirror the most common table operations. In code, -the interface resembles: +The Table Gateway subcomponent provides an object-oriented representation of +a database table; its methods mirror the most common table operations. In +code, the interface resembles: + +## TableGatewayInterface Definition ```php namespace PhpDb\TableGateway; @@ -13,7 +15,9 @@ use PhpDb\Sql\Where; interface TableGatewayInterface { public function getTable() : string; - public function select(Where|callable|string|array $where = null) : ResultSetInterface; + public function select( + Where|callable|string|array $where = null + ) : ResultSetInterface; public function insert(array $set) : int; public function update( array $set, @@ -30,19 +34,19 @@ abstract basic implementation that provides functionality for `select()`, `insert()`, `update()`, `delete()`, as well as an additional API for doing these same kinds of tasks with explicit `PhpDb\Sql` objects: `selectWith()`, `insertWith()`, `updateWith()`, and `deleteWith()`. In addition, -AbstractTableGateway also implements a "Feature" API, that allows for expanding -the behaviors of the base `TableGateway` implementation without having to -extend the class with this new functionality. The `TableGateway` concrete -implementation simply adds a sensible constructor to the `AbstractTableGateway` -class so that out-of-the-box, `TableGateway` does not need to be extended in -order to be consumed and utilized to its fullest. +AbstractTableGateway also implements a "Feature" API, that allows for +expanding the behaviors of the base `TableGateway` implementation without +having to extend the class with this new functionality. The `TableGateway` +concrete implementation simply adds a sensible constructor to the +`AbstractTableGateway` class so that out-of-the-box, `TableGateway` does not +need to be extended in order to be consumed and utilized to its fullest. ## Quick start The following example uses `PhpDb\TableGateway\TableGateway`, which defines the following API: -```php +```php title="TableGateway Class API" namespace PhpDb\TableGateway; use PhpDb\Adapter\AdapterInterface; @@ -60,7 +64,8 @@ class TableGateway extends AbstractTableGateway public function __construct( string|TableIdentifier $table, AdapterInterface $adapter, - Feature\AbstractFeature|Feature\FeatureSet|Feature\AbstractFeature[] $features = null, + Feature\AbstractFeature|Feature\FeatureSet| + Feature\AbstractFeature[] $features = null, ResultSetInterface $resultSetPrototype = null, Sql\Sql $sql = null ); @@ -75,8 +80,12 @@ class TableGateway extends AbstractTableGateway public function getFeatureSet() Feature\FeatureSet; public function getResultSetPrototype() : ResultSetInterface; public function getSql() | Sql\Sql; - public function select(Sql\Where|callable|string|array $where = null) : ResultSetInterface; - public function selectWith(Sql\Select $select) : ResultSetInterface; + public function select( + Sql\Where|callable|string|array $where = null + ) : ResultSetInterface; + public function selectWith( + Sql\Select $select + ) : ResultSetInterface; public function insert(array $set) : int; public function insertWith(Sql\Insert $insert) | int; public function update( @@ -85,7 +94,9 @@ class TableGateway extends AbstractTableGateway array $joins = null ) : int; public function updateWith(Sql\Update $update) : int; - public function delete(Sql\Where|callable|string|array $where) : int; + public function delete( + Sql\Where|callable|string|array $where + ) : int; public function deleteWith(Sql\Delete $delete) : int; public function getLastInsertValue() : int; } @@ -95,12 +106,12 @@ The concrete `TableGateway` object uses constructor injection for getting dependencies and options into the instance. The table name and an instance of an `Adapter` are all that is required to create an instance. -Out of the box, this implementation makes no assumptions about table structure -or metadata, and when `select()` is executed, a simple `ResultSet` object with -the populated `Adapter`'s `Result` (the datasource) will be returned and ready -for iteration. +Out of the box, this implementation makes no assumptions about table +structure or metadata, and when `select()` is executed, a simple `ResultSet` +object with the populated `Adapter`'s `Result` (the datasource) will be +returned and ready for iteration. -```php +```php title="Basic Select Operations" use PhpDb\TableGateway\TableGateway; $projectTable = new TableGateway('project', $adapter); @@ -121,9 +132,10 @@ var_dump($artistRow); The `select()` method takes the same arguments as `PhpDb\Sql\Select::where()`; arguments will be passed to the `Select` -instance used to build the SELECT query. This means the following is possible: +instance used to build the SELECT query. This means the following is +possible: -```php +```php title="Advanced Select with Callback" use PhpDb\TableGateway\TableGateway; use PhpDb\Sql\Select; @@ -139,10 +151,10 @@ $rowset = $artistTable->select(function (Select $select) { ## TableGateway Features The Features API allows for extending the functionality of the base -`TableGateway` object without having to polymorphically extend the base class. -This allows for a wider array of possible mixing and matching of features to -achieve a particular behavior that needs to be attained to make the base -implementation of `TableGateway` useful for a particular problem. +`TableGateway` object without having to polymorphically extend the base +class. This allows for a wider array of possible mixing and matching of +features to achieve a particular behavior that needs to be attained to make +the base implementation of `TableGateway` useful for a particular problem. With the `TableGateway` object, features should be injected through the constructor. The constructor can take features in 3 different forms: @@ -153,76 +165,92 @@ constructor. The constructor can take features in 3 different forms: There are a number of features built-in and shipped with laminas-db: -- `GlobalAdapterFeature`: the ability to use a global/static adapter without - needing to inject it into a `TableGateway` instance. This is only useful when - you are extending the `AbstractTableGateway` implementation: +### GlobalAdapterFeature + +Use a global/static adapter without injecting it into a `TableGateway` +instance. This is only useful when extending the `AbstractTableGateway` +implementation: - ```php - use PhpDb\TableGateway\AbstractTableGateway; - use PhpDb\TableGateway\Feature; +```php +use PhpDb\TableGateway\AbstractTableGateway; +use PhpDb\TableGateway\Feature; - class MyTableGateway extends AbstractTableGateway +class MyTableGateway extends AbstractTableGateway +{ + public function __construct() { - public function __construct() - { - $this->table = 'my_table'; - $this->featureSet = new Feature\FeatureSet(); - $this->featureSet->addFeature(new Feature\GlobalAdapterFeature()); - $this->initialize(); - } + $this->table = 'my_table'; + $this->featureSet = new Feature\FeatureSet(); + $this->featureSet->addFeature(new Feature\GlobalAdapterFeature()); + $this->initialize(); } +} + +// elsewhere in code, in a bootstrap +PhpDb\TableGateway\Feature\GlobalAdapterFeature::setStaticAdapter( + $adapter +); + +// in a controller, or model somewhere +$table = new MyTableGateway(); // adapter is statically loaded +``` - // elsewhere in code, in a bootstrap - PhpDb\TableGateway\Feature\GlobalAdapterFeature::setStaticAdapter($adapter); +### MasterSlaveFeature - // in a controller, or model somewhere - $table = new MyTableGateway(); // adapter is statically loaded - ``` +Use a master adapter for `insert()`, `update()`, and `delete()`, but switch +to a slave adapter for all `select()` operations: -- `MasterSlaveFeature`: the ability to use a master adapter for `insert()`, - `update()`, and `delete()`, but switch to a slave adapter for all `select()` - operations. +```php +$table = new TableGateway( + 'artist', + $adapter, + new Feature\MasterSlaveFeature($slaveAdapter) +); +``` + +### MetadataFeature - ```php - $table = new TableGateway('artist', $adapter, new Feature\MasterSlaveFeature($slaveAdapter)); - ``` +Populate `TableGateway` with column information from a `Metadata` object. It +also stores primary key information for the `RowGatewayFeature`: + +```php +$table = new TableGateway('artist', $adapter, new Feature\MetadataFeature()); +``` -- `MetadataFeature`: the ability populate `TableGateway` with column - information from a `Metadata` object. It will also store the primary key - information in case the `RowGatewayFeature` needs to consume this information. +### EventFeature - ```php - $table = new TableGateway('artist', $adapter, new Feature\MetadataFeature()); - ``` +Compose a +[laminas-eventmanager](https://github.com/laminas/laminas-eventmanager) +`EventManager` instance and attach listeners to lifecycle events. See the +[section on lifecycle events below](#tablegateway-lifecycle-events) for +details: -- `EventFeature`: the ability to compose a - [laminas-eventmanager](https://github.com/laminas/laminas-eventmanager) - `EventManager` instance within your `TableGateway` instance, and attach - listeners to the various events of its lifecycle. See the [section on - lifecycle events below](#tablegateway-lifecycle-events) for more information - on available events and the parameters they compose. +```php +$table = new TableGateway( + 'artist', + $adapter, + new Feature\EventFeature($eventManagerInstance) +); +``` - ```php - $table = new TableGateway('artist', $adapter, new Feature\EventFeature($eventManagerInstance)); - ``` +### RowGatewayFeature -- `RowGatewayFeature`: the ability for `select()` to return a `ResultSet` object that upon iteration - will return a `RowGateway` instance for each row. +Return `RowGateway` instances when iterating `select()` results: - ```php - $table = new TableGateway('artist', $adapter, new Feature\RowGatewayFeature('id')); - $results = $table->select(['id' => 2]); +```php +$table = new TableGateway('artist', $adapter, new Feature\RowGatewayFeature('id')); +$results = $table->select(['id' => 2]); - $artistRow = $results->current(); - $artistRow->name = 'New Name'; - $artistRow->save(); - ``` +$artistRow = $results->current(); +$artistRow->name = 'New Name'; +$artistRow->save(); +``` ## TableGateway LifeCycle Events When the `EventFeature` is enabled on the `TableGateway` instance, you may -attach to any of the following events, which provide access to the parameters -listed. +attach to any of the following events, which provide access to the +parameters listed. - `preInitialize` (no parameters) - `postInitialize` (no parameters) @@ -248,17 +276,19 @@ listed. - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` -Listeners receive a `PhpDb\TableGateway\Feature\EventFeature\TableGatewayEvent` -instance as an argument. Within the listener, you can retrieve a parameter by -name from the event using the following syntax: +Listeners receive a +`PhpDb\TableGateway\Feature\EventFeature\TableGatewayEvent` instance as an +argument. Within the listener, you can retrieve a parameter by name from the +event using the following syntax: -```php +```php title="Retrieving Event Parameters" $parameter = $event->getParam($paramName); ``` -As an example, you might attach a listener on the `postInsert` event as follows: +As an example, you might attach a listener on the `postInsert` event as +follows: -```php +```php title="Attaching a Listener to postInsert Event" use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\TableGateway\Feature\EventFeature\TableGatewayEvent; use Laminas\EventManager\EventManager; diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..76af623de --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,42 @@ +docs_dir: docs/book +site_dir: docs/html +nav: + - Home: index.md + - Adapters: + - Introduction: adapter.md + - AdapterAwareTrait: adapters/adapter-aware-trait.md + - "Result Sets": + - Introduction: result-set/intro.md + - Examples: result-set/examples.md + - Advanced Usage: result-set/advanced.md + - "SQL Abstraction": + - Introduction: sql/intro.md + - Select: sql/select.md + - Insert: sql/insert.md + - Update and Delete: sql/update-delete.md + - Where and Having: sql/where-having.md + - Examples: sql/examples.md + - Advanced Usage: sql/advanced.md + - "DDL Abstraction": + - Introduction: sql-ddl/intro.md + - Columns: sql-ddl/columns.md + - Constraints: sql-ddl/constraints.md + - Alter and Drop: sql-ddl/alter-drop.md + - Examples: sql-ddl/examples.md + - Advanced Usage: sql-ddl/advanced.md + - "Table Gateways": table-gateway.md + - "Row Gateways": row-gateway.md + - "RDBMS Metadata": + - Introduction: metadata/intro.md + - Metadata Objects: metadata/objects.md + - Examples: metadata/examples.md + - Profiler: profiler.md + - "Application Integration": + - "Integrating in a Laminas MVC application": application-integration/usage-in-a-laminas-mvc-application.md + - "Integrating in a Mezzio application": application-integration/usage-in-a-mezzio-application.md + - "Docker Deployment": docker-deployment.md +site_name: phpdb +site_description: "Database abstraction layer, SQL abstraction, result set abstraction, and RowDataGateway and TableDataGateway implementations" +repo_url: 'https://github.com/php-db/phpdb' +extra: + project: Components