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