From 44539ed46de362f36c1b932a74477d179f2119a7 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 17 Nov 2025 22:22:07 +1100 Subject: [PATCH 01/11] This commit enhances the documentation with professional code examples, additional SQL output demonstrations, and expanded Docker deployment options. --- .../usage-in-a-laminas-mvc-application.md | 164 +- .../usage-in-a-mezzio-application.md | 945 +++++++++++ docs/book/metadata.md | 817 +++++++++- docs/book/result-set.md | 818 +++++++++- docs/book/sql.md | 1411 ++++++++++++++++- 5 files changed, 4043 insertions(+), 112 deletions(-) create mode 100644 docs/book/application-integration/usage-in-a-mezzio-application.md 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..e39ade574 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 @@ -93,12 +93,72 @@ You can read more about the [adapter in the adapter chapter of the documentation When working with a MySQL database and when running the application with Docker, some files need to be added or adjusted. -### Adding the MySQL extension to the PHP container +This guide covers two web server options: **Nginx with PHP-FPM** (recommended for production) and **Apache** (simpler for development). -Change the `Dockerfile` to add the PDO MySQL extension to PHP. +### Option 1: Nginx with PHP-FPM (Recommended) + +Nginx with PHP-FPM provides better performance and resource efficiency for production environments. + +#### Creating the Dockerfile + +Create a `Dockerfile` in your project root: + +```Dockerfile +FROM php:8.2-fpm + +RUN apt-get update \ + && apt-get install -y git zlib1g-dev libzip-dev \ + && docker-php-ext-install zip pdo_mysql \ + && curl -sS https://getcomposer.org/installer \ + | php -- --install-dir=/usr/local/bin --filename=composer + +WORKDIR /var/www +``` + +#### Creating the Nginx Configuration + +Create a file at `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; + } +} +``` + +This configuration: +- Serves files from `/var/www/public` +- Routes all requests through `index.php` (required for laminas-mvc routing) +- Passes PHP requests to the `app` container on port 9000 +- Denies access to `.htaccess` files + +### Option 2: Apache + +Apache provides a simpler setup suitable for development environments. + +#### Creating the Dockerfile + +Create a `Dockerfile` in your project root: ```Dockerfile -FROM php:7.3-apache +FROM php:8.2-apache RUN apt-get update \ && apt-get install -y git zlib1g-dev libzip-dev \ @@ -137,9 +197,38 @@ Though it is not the topic to explain how to write a `docker-compose.yml` file, - 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. -### Link the containers +### Configuring the Application Container + +#### For Nginx (Option 1) -Now link the mysql container and the laminas container so that the application knows where to find the mysql server. +When using Nginx with PHP-FPM, you'll need both an `app` container running PHP-FPM and an `nginx` container: + +```yaml + app: + container_name: laminas-app + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/var/www + links: + - mysql:mysql + + nginx: + image: nginx:alpine + container_name: laminas-nginx + ports: + - 8080:80 + volumes: + - .:/var/www + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf + depends_on: + - app +``` + +#### For Apache (Option 2) + +When using Apache, you only need a single `laminas` container: ```yaml laminas: @@ -170,12 +259,65 @@ Optionnally, you can also add a container for phpMyAdmin. 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: +### Complete docker-compose.yml Examples + +#### Complete Example with Nginx (Recommended) ```yaml -version: "2.1" +version: "3.8" +services: + app: + container_name: laminas-app + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/var/www + links: + - mysql:mysql + + nginx: + image: nginx:alpine + container_name: laminas-nginx + ports: + - 8080:80 + volumes: + - .:/var/www + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf + depends_on: + - app + + mysql: + image: mysql:8 + container_name: laminas-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 + container_name: laminas-phpmyadmin + ports: + - 8081:80 + environment: + - PMA_HOST=${PMA_HOST} + depends_on: + - mysql +``` + +#### Complete Example with Apache + +```yaml +version: "3.8" services: laminas: + container_name: laminas-app build: context: . dockerfile: Dockerfile @@ -185,8 +327,10 @@ services: - .:/var/www links: - mysql:mysql + mysql: - image: mysql + image: mysql:8 + container_name: laminas-mysql ports: - 3306:3306 command: @@ -196,12 +340,16 @@ services: - ./.docker/mysql/:/docker-entrypoint-initdb.d/ environment: - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + phpmyadmin: image: phpmyadmin/phpmyadmin + container_name: laminas-phpmyadmin ports: - 8081:80 environment: - PMA_HOST=${PMA_HOST} + depends_on: + - mysql ``` ### Defining credentials 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..10efd3fc7 --- /dev/null +++ b/docs/book/application-integration/usage-in-a-mezzio-application.md @@ -0,0 +1,945 @@ +# Usage in a Mezzio Application + +The minimal installation for a Mezzio-based application doesn't include any database features. + +## When installing the Mezzio Skeleton Application + +While `Composer` is [installing the Mezzio Application](https://docs.mezzio.dev/mezzio/v3/getting-started/skeleton/), you can add the `phpdb` package after the skeleton is created. + +## Adding to an existing Mezzio Skeleton Application + +If the Mezzio application is already created, then use Composer to [add the phpdb](../index.md) package: + +```bash +composer require phpdb/phpdb +``` + +## Service Configuration + +Now that the phpdb package is installed, you need to configure the adapter through Mezzio's dependency injection container. + +### Configuring the adapter + +Mezzio uses PSR-11 containers and typically uses laminas-servicemanager or another DI container. The adapter configuration goes in your application's configuration files. + +Create a configuration file `config/autoload/database.global.php` to define database settings: + +### Working with a SQLite database + +SQLite is a lightweight option to have the application working with a database. + +Here is an example of the configuration array for a SQLite database. +Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: + +```php + [ + 'factories' => [ + Adapter::class => function ($container) { + return new Adapter([ + 'driver' => 'Pdo_Sqlite', + 'database' => 'data/sample.sqlite', + ]); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +The `data/` filepath for the SQLite file is relative to your application root directory. + +### Working with a MySQL database + +Unlike a SQLite database, the MySQL database adapter requires a MySQL server. + +Here is an example of a configuration array for a MySQL database. + +Create `config/autoload/database.local.php` for environment-specific credentials: + +```php + [ + 'factories' => [ + Adapter::class => function ($container) { + return new Adapter([ + 'driver' => 'Pdo_Mysql', + 'database' => 'your_database_name', + 'username' => 'your_mysql_username', + 'password' => 'your_mysql_password', + 'hostname' => 'localhost', + 'charset' => 'utf8mb4', + 'driver_options' => [ + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', + ], + ]); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +### Working with PostgreSQL database + +For PostgreSQL support: + +```php + [ + 'factories' => [ + Adapter::class => function ($container) { + return new Adapter([ + 'driver' => 'Pdo_Pgsql', + 'database' => 'your_database_name', + 'username' => 'your_pgsql_username', + 'password' => 'your_pgsql_password', + 'hostname' => 'localhost', + 'port' => 5432, + 'charset' => 'utf8', + ]); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +## 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 through dependency injection. + +### In Request Handlers + +Mezzio uses request handlers (also known as middleware) that receive dependencies through constructor injection: + +```php +adapter->query( + 'SELECT id, username, email FROM users WHERE status = ?', + ['active'] + ); + + $users = []; + foreach ($results as $row) { + $users[] = [ + 'id' => $row->id, + 'username' => $row->username, + 'email' => $row->email, + ]; + } + + return new JsonResponse(['users' => $users]); + } +} +``` + +### Creating a Handler Factory + +You need to create a factory for your handler that injects the adapter: + +```php +get(AdapterInterface::class) + ); + } +} +``` + +### Registering the Handler + +Register your handler factory in `config/autoload/dependencies.global.php`: + +```php + [ + 'invokables' => [ + // ... other invokables + ], + 'factories' => [ + UserListHandler::class => UserListHandlerFactory::class, + // ... other factories + ], + ], +]; +``` + +### Using with TableGateway + +For more structured database interactions, use TableGateway with dependency injection: + +```php +select(['status' => 'active']); + return iterator_to_array($resultSet); + } + + public function findUserById(int $id): ?array + { + $rowset = $this->select(['id' => $id]); + $row = $rowset->current(); + + return $row ? (array) $row : null; + } +} +``` + +Create a factory for the table: + +```php +get(AdapterInterface::class) + ); + } +} +``` + +Register the table factory: + +```php + [ + 'factories' => [ + UsersTable::class => UsersTableFactory::class, + ], + ], +]; +``` + +Use in your handler: + +```php +usersTable->findActiveUsers(); + + return new JsonResponse(['users' => $users]); + } +} +``` + +You can read more about the [adapter in the adapter chapter of the documentation](../adapter.md) and [TableGateway in the table gateway chapter](../table-gateway.md). + +## Environment-based Configuration + +For production deployments, use environment variables to configure database credentials: + +### Using dotenv + +Install `vlucas/phpdotenv`: + +```bash +composer require vlucas/phpdotenv +``` + +Create a `.env` file in your project root: + +```env +DB_DRIVER=Pdo_Mysql +DB_DATABASE=myapp_production +DB_USERNAME=dbuser +DB_PASSWORD=secure_password +DB_HOSTNAME=mysql-server +DB_PORT=3306 +DB_CHARSET=utf8mb4 +``` + +Load environment variables in `public/index.php`: + +```php +load(); +} + +$container = require 'config/container.php'; +``` + +Update your database configuration to use environment variables: + +```php + [ + 'factories' => [ + Adapter::class => function ($container) { + return new Adapter([ + 'driver' => $_ENV['DB_DRIVER'] ?? 'Pdo_Mysql', + 'database' => $_ENV['DB_DATABASE'] ?? 'myapp', + 'username' => $_ENV['DB_USERNAME'] ?? 'root', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'hostname' => $_ENV['DB_HOSTNAME'] ?? 'localhost', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', + ]); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +## Running with Docker + +When working with a MySQL database and when running the application with Docker, some files need to be added or adjusted. + +### Adding the MySQL extension to the PHP container + +#### Option 1: Nginx with PHP-FPM (Recommended) + +For an nginx-based setup with PHP-FPM, create a `Dockerfile`: + +```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 an nginx configuration file at `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; + } +} +``` + +Update your `docker-compose.yml` to include nginx: + +```yaml + nginx: + image: nginx:alpine + ports: + - "8080:80" + volumes: + - .:/var/www + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf + depends_on: + - app +``` + +#### Option 2: Apache + +For an Apache-based setup, create a `Dockerfile`: + +```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 +``` + +### Adding the MySQL container + +Change the `docker-compose.yml` file to add a new container for MySQL: + +```yaml +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:80" + volumes: + - .:/var/www + depends_on: + - mysql + environment: + - DB_DRIVER=Pdo_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} + +volumes: + mysql_data: +``` + +Though it is not the topic to explain how to write a `docker-compose.yml` file, a few details need to be highlighted: + +- The name of the container is `mysql`. +- MySQL database files will be persisted in a named volume `mysql_data`. +- SQL schemas will need to be added to the `./docker/mysql/init/` directory so that Docker will be able to build and populate the database(s). +- The MySQL docker image uses environment variables to set the database name, user, and passwords. +- The `depends_on` directive ensures MySQL starts before the application container. + +### Adding PostgreSQL Container + +For PostgreSQL instead of MySQL: + +```yaml +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:80" + volumes: + - .:/var/www + depends_on: + - postgres + environment: + - DB_DRIVER=Pdo_Pgsql + - DB_DATABASE=${DB_DATABASE} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - DB_HOSTNAME=postgres + - DB_PORT=5432 + + 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} + +volumes: + postgres_data: +``` + +Update the `Dockerfile` to install the PostgreSQL extension: + +```dockerfile +RUN docker-php-ext-install pdo_pgsql +``` + +### Adding phpMyAdmin + +Optionally, you can also add a container for phpMyAdmin: + +```yaml + phpmyadmin: + image: phpmyadmin/phpmyadmin + ports: + - "8081:80" + depends_on: + - mysql + environment: + - PMA_HOST=mysql + - PMA_PORT=3306 +``` + +### Complete Docker Compose Example + +#### With Nginx (Recommended) + +Putting everything together with nginx: + +```yaml +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/var/www + depends_on: + - mysql + environment: + - DB_DRIVER=Pdo_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: +``` + +#### With Apache + +For Apache-based deployment: + +```yaml +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:80" + volumes: + - .:/var/www + depends_on: + - mysql + environment: + - DB_DRIVER=Pdo_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: +``` + +### Defining credentials + +The `docker-compose.yml` file uses environment variables to define the credentials. + +Docker will read the environment variables from a `.env` file: + +```env +DB_DATABASE=mezzio_app +DB_USERNAME=appuser +DB_PASSWORD=apppassword +MYSQL_ROOT_PASSWORD=rootpassword +``` + +### Initiating the database schemas + +At build, if the volume is empty, Docker will create the MySQL database with any `.sql` files found in the `./docker/mysql/init/` directory. + +Create `docker/mysql/init/01-schema.sql`: + +```sql +USE mezzio_app; + +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); +``` + +Create `docker/mysql/init/02-seed.sql`: + +```sql +USE mezzio_app; + +INSERT INTO users (username, email, status) VALUES + ('alice', 'alice@example.com', 'active'), + ('bob', 'bob@example.com', 'active'), + ('charlie', 'charlie@example.com', 'inactive'); +``` + +If multiple `.sql` files are present, they are executed in alphanumeric order, which is why the files are prefixed with numbers. + +## Testing with Database + +For integration testing with a real database in Mezzio: + +### Create a Test Configuration + +Create `config/autoload/database.test.php`: + +```php + [ + 'factories' => [ + Adapter::class => function ($container) { + return new Adapter([ + 'driver' => 'Pdo_Sqlite', + 'database' => ':memory:', + ]); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +### Use in PHPUnit Tests + +```php +adapter = $container->get(AdapterInterface::class); + + $this->adapter->query( + 'CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT, + email TEXT, + status TEXT + )' + ); + + $this->adapter->query( + "INSERT INTO users (username, email, status) VALUES + ('alice', 'alice@example.com', 'active'), + ('bob', 'bob@example.com', 'active')" + ); + } + + public function testHandleReturnsUserList(): void + { + $handler = new UserListHandler($this->adapter); + $request = new ServerRequest(); + + $response = $handler->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertCount(2, $body['users']); + } +} +``` + +## Best Practices for Mezzio + +### Use Dependency Injection + +Always inject the adapter or table gateway through constructors, never instantiate directly in handlers. + +### Separate Database Logic + +Create repository or table gateway classes to separate database logic from HTTP handlers: + +```php +adapter); + $select = $sql->select('users'); + + $statement = $sql->prepareStatementForSqlObject($select); + $results = $statement->execute(); + + return iterator_to_array($results); + } + + public function findById(int $id): ?array + { + $sql = new Sql($this->adapter); + $select = $sql->select('users'); + $select->where(['id' => $id]); + + $statement = $sql->prepareStatementForSqlObject($select); + $results = $statement->execute(); + $row = $results->current(); + + return $row ? (array) $row : null; + } +} +``` + +### Use Configuration Factories + +Centralize adapter configuration in factory classes for better maintainability and testability. + +### Handle Exceptions + +Always wrap database operations in try-catch blocks: + +```php +use PhpDb\Adapter\Exception\RuntimeException; + +public function handle(ServerRequestInterface $request): ResponseInterface +{ + try { + $users = $this->usersTable->findActiveUsers(); + return new JsonResponse(['users' => $users]); + } catch (RuntimeException $e) { + return new JsonResponse( + ['error' => 'Database error occurred'], + 500 + ); + } +} +``` \ No newline at end of file diff --git a/docs/book/metadata.md b/docs/book/metadata.md index e49a6ad27..7f61fa73e 100644 --- a/docs/book/metadata.md +++ b/docs/book/metadata.md @@ -10,88 +10,142 @@ namespace PhpDb\Metadata; interface MetadataInterface { - public function getSchemas(); + 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; + 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; + 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 string $table, $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 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 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; + 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 -Usage of `PhpDb\Metadata` involves: +### Instantiating Metadata -- Constructing a `PhpDb\Metadata\Metadata` instance with an `Adapter`. -- Choosing a strategy for retrieving metadata, based on the database platform - used. In most cases, information will come from querying the - `INFORMATION_SCHEMA` tables for the currently accessible schema. +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: -The `Metadata::get*Names()` methods will return arrays of strings, while the -other methods will return value objects specific to the type queried. +```php +use PhpDb\Adapter\Adapter; +use PhpDb\Metadata\Source\Factory as MetadataSourceFactory; + +$adapter = new Adapter($config); +$metadata = MetadataSourceFactory::createSourceFromAdapter($adapter); +``` + +Alternatively, when using a dependency injection container: ```php -$metadata = new PhpDb\Metadata\Metadata($adapter); +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 -// get the table names +The `get*Names()` methods return arrays of strings: + +```php $tableNames = $metadata->getTableNames(); +$columnNames = $metadata->getColumnNames('users'); +$schemas = $metadata->getSchemas(); +``` -foreach ($tableNames as $tableName) { - echo 'In Table ' . $tableName . PHP_EOL; +The other methods return value objects specific to the type queried: - $table = $metadata->getTable($tableName); +```php +$table = $metadata->getTable('users'); // Returns TableObject or ViewObject +$column = $metadata->getColumn('id', 'users'); // Returns ColumnObject +$constraint = $metadata->getConstraint('PRIMARY', 'users'); // Returns ConstraintObject +``` - echo ' With columns: ' . PHP_EOL; - foreach ($table->getColumns() as $column) { - echo ' ' . $column->getName() - . ' -> ' . $column->getDataType() - . PHP_EOL; - } +Note that `getTable()` and `getView()` can return either `TableObject` or +`ViewObject` depending on whether the database object is a table or a view. - echo PHP_EOL; - echo ' With constraints: ' . PHP_EOL; +### Basic Example - foreach ($metadata->getConstraints($tableName) as $constraint) { - echo ' ' . $constraint->getName() - . ' -> ' . $constraint->getType() - . PHP_EOL; +```php +use PhpDb\Metadata\Source\Factory as MetadataSourceFactory; - if (! $constraint->hasColumns()) { - continue; - } +$adapter = new Adapter($config); +$metadata = MetadataSourceFactory::createSourceFromAdapter($adapter); - echo ' column: ' . implode(', ', $constraint->getColumns()); - if ($constraint->isForeignKey()) { - $fkCols = []; - foreach ($constraint->getReferencedColumns() as $refColumn) { - $fkCols[] = $constraint->getReferencedTableName() . '.' . $refColumn; - } - echo ' => ' . implode(', ', $fkCols); - } +$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: + +``` +id INT NOT NULL +username VARCHAR NOT NULL +email VARCHAR NOT NULL +created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +bio TEXT NULL +``` + +Inspecting constraints: - echo PHP_EOL; +```php +$constraints = $metadata->getConstraints('orders'); + +foreach ($constraints as $constraint) { + if ($constraint->isPrimaryKey()) { + printf("PRIMARY KEY (%s)\n", implode(', ', $constraint->getColumns())); } - echo '----' . PHP_EOL; + if ($constraint->isForeignKey()) { + printf( + "FOREIGN KEY %s (%s) REFERENCES %s (%s)\n", + $constraint->getName(), + implode(', ', $constraint->getColumns()), + $constraint->getReferencedTableName(), + implode(', ', $constraint->getReferencedColumns()) + ); + } } ``` +Example output: + +``` +PRIMARY KEY (id) +FOREIGN KEY fk_orders_customers (customer_id) REFERENCES customers (id) +FOREIGN KEY fk_orders_products (product_id) REFERENCES products (id) +``` + ## Metadata value objects Metadata returns value objects that provide an interface to help developers @@ -190,6 +244,119 @@ class PhpDb\Metadata\Object\ConstraintObject } ``` +### ViewObject + +The `ViewObject` extends `AbstractTableObject` and represents database views. It +includes all methods from `TableObject` plus view-specific properties: + +```php +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 +$view = $metadata->getView('active_users'); +echo $view->getViewDefinition(); +``` + +Outputs: + +```sql +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 +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 +$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: + +``` +customer_id -> customers.id + ON UPDATE: CASCADE + ON DELETE: RESTRICT +``` + ### TriggerObject ```php @@ -227,3 +394,547 @@ class PhpDb\Metadata\Object\TriggerObject public function setCreated($created); } ``` + +## Advanced Usage + +### Working with Schemas + +The `getSchemas()` method returns all available schema names in the database: + +```php +$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 +$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 +$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: + +```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()); +} +``` + +Include views when getting table names: + +```php +$allTables = $metadata->getTableNames(null, true); +``` + +### Working with Triggers + +Retrieve all triggers and their details: + +```php +$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 +$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: + +``` +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 +$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; +} +``` + +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 +$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; + } +} +``` + +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'); +``` + +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 +$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'); +``` + +## Error Handling and Exceptions + +All metadata methods throw the base PHP `\Exception` when the requested object is +not found. Note that while PhpDb has its own exception hierarchy +(`PhpDb\Exception\ExceptionInterface`), the Metadata component currently uses the +base Exception class. + +### Catching Metadata Exceptions + +```php +use Exception; + +try { + $table = $metadata->getTable('nonexistent_table'); +} catch (Exception $e) { + printf("Table not found: %s\n", $e->getMessage()); +} +``` + +### Common Exception Scenarios + +**Table not found:** + +```php +try { + $table = $metadata->getTable('invalid_table'); +} catch (Exception $e) { + // Message: Table "invalid_table" does not exist +} +``` + +**View not found:** + +```php +try { + $view = $metadata->getView('invalid_view'); +} catch (Exception $e) { + // Message: View "invalid_view" does not exist +} +``` + +**Column not found:** + +```php +try { + $column = $metadata->getColumn('invalid_column', 'users'); +} catch (Exception $e) { + // Message: A column by that name was not found. +} +``` + +**Constraint not found:** + +```php +try { + $constraint = $metadata->getConstraint('invalid_constraint', 'users'); +} catch (Exception $e) { + // Message: Cannot find a constraint by that name in this table +} +``` + +**Trigger not found:** + +```php +try { + $trigger = $metadata->getTrigger('invalid_trigger'); +} catch (Exception $e) { + // Message: Trigger "invalid_trigger" does not exist +} +``` + +**Unsupported table type:** + +```php +try { + $table = $metadata->getTable('user_view'); +} catch (Exception $e) { + if (str_contains($e->getMessage(), 'unsupported type')) { + // This object exists but is not a supported table type + } +} +``` + +### Best Practices for Exception Handling + +Check for existence before accessing metadata: + +```php +$tableNames = $metadata->getTableNames(); +if (! in_array('users', $tableNames, true)) { + throw new RuntimeException('Required table "users" does not exist'); +} + +$table = $metadata->getTable('users'); +``` + +Catch and log exceptions for better debugging: + +```php +try { + $column = $metadata->getColumn('email', 'users'); +} catch (Exception $e) { + $logger->error('Failed to retrieve column metadata', [ + 'column' => 'email', + 'table' => 'users', + 'error' => $e->getMessage(), + ]); + throw $e; +} +``` + +## Common Patterns and Best Practices + +### Finding All Tables with a Specific Column + +```php +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'); +``` + +### Discovering Foreign Key Relationships + +```php +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; +} +``` + +### Generating Schema Documentation + +```php +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()}** ({$constraint->getType()})\n"; + if ($constraint->hasColumns()) { + $doc .= " - Columns: " . implode(', ', $constraint->getColumns()) . "\n"; + } + if ($constraint->isForeignKey()) { + $doc .= " - References: {$constraint->getReferencedTableName()}"; + $doc .= "(" . implode(', ', $constraint->getReferencedColumns()) . ")\n"; + $doc .= " - ON UPDATE: {$constraint->getUpdateRule()}\n"; + $doc .= " - ON DELETE: {$constraint->getDeleteRule()}\n"; + } + } + + return $doc; +} +``` + +### Comparing Schemas Across Environments + +```php +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; +} +``` + +## Troubleshooting + +### Table Not Found Errors + +Always check if a table exists before trying to access it: + +```php +$tableNames = $metadata->getTableNames(); +if (in_array('users', $tableNames, true)) { + $table = $metadata->getTable('users'); +} else { + echo 'Table does not exist'; +} +``` + +### Performance with Large Schemas + +When working with databases that have hundreds of tables, use `get*Names()` +methods instead of retrieving full objects: + +```php +$tableNames = $metadata->getTableNames(); +foreach ($tableNames as $tableName) { + $columnNames = $metadata->getColumnNames($tableName); +} +``` + +This is more efficient than: + +```php +$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 +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 +$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/result-set.md b/docs/book/result-set.md index 91771976b..0ecee7e59 100644 --- a/docs/book/result-set.md +++ b/docs/book/result-set.md @@ -23,7 +23,7 @@ interface ResultSetInterface extends Traversable, Countable } ``` -## Quick start +## 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 @@ -31,6 +31,73 @@ 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. +### Example Data + +Throughout this documentation, we'll use this sample dataset: + +```php +$sampleData = [ + ['id' => 1, 'first_name' => 'Alice', 'last_name' => 'Johnson', 'email' => 'alice@example.com'], + ['id' => 2, 'first_name' => 'Bob', 'last_name' => 'Smith', 'email' => 'bob@example.com'], + ['id' => 3, 'first_name' => 'Charlie', 'last_name' => 'Brown', 'email' => 'charlie@example.com'], + ['id' => 4, 'first_name' => 'Diana', 'last_name' => 'Prince', 'email' => 'diana@example.com'], +]; +``` + +And this UserEntity class for hydration examples: + +```php +class UserEntity +{ + protected int $id; + protected string $first_name; + protected string $last_name; + protected string $email; + + public function getId(): int + { + return $this->id; + } + + public function getFirstName(): string + { + return $this->first_name; + } + + public function getLastName(): string + { + return $this->last_name; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setId(int $id): void + { + $this->id = $id; + } + + public function setFirstName(string $firstName): void + { + $this->first_name = $firstName; + } + + public function setLastName(string $lastName): void + { + $this->last_name = $lastName; + } + + public function setEmail(string $email): void + { + $this->email = $email; + } +} +``` + +### Basic Usage + The following is an example workflow similar to what one might find inside `PhpDb\Adapter\Adapter::query()`: @@ -43,16 +110,18 @@ $statement->prepare(); $result = $statement->execute($parameters); if ($result instanceof ResultInterface && $result->isQueryResult()) { - $resultSet = new ResultSet; + $resultSet = new ResultSet(); $resultSet->initialize($result); foreach ($resultSet as $row) { - echo $row->my_column . PHP_EOL; + printf("User: %s %s\n", $row->first_name, $row->last_name); } } ``` -## Laminas\\Db\\ResultSet\\ResultSet and Laminas\\Db\\ResultSet\\AbstractResultSet +## ResultSet Classes + +### AbstractResultSet For most purposes, either an instance of `PhpDb\ResultSet\ResultSet` or a derivative of `PhpDb\ResultSet\AbstractResultSet` will be used. The @@ -63,25 +132,27 @@ functionality: 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) : self; - public function getDataSource() : Iterator|IteratorAggregate|ResultInterface; - public function getFieldCount() : int; + 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; - /** Iterator */ - public function next() : mixed; - public function key() : string|int; - public function current() : mixed; - public function valid() : bool; - public function rewind() : void; + public function next(): void; + public function key(): int; + public function current(): mixed; + public function valid(): bool; + public function rewind(): void; - /** countable */ - public function count() : int; + public function count(): int; - /** get rows as array */ - public function toArray() : array; + public function toArray(): array; } ``` @@ -99,7 +170,7 @@ The `HydratingResultSet` depends on need to install: ```bash -$ composer require laminas/laminas-hydrator +composer require laminas/laminas-hydrator ``` In the example below, rows from the database will be iterated, and during @@ -112,46 +183,707 @@ use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\ResultSet\HydratingResultSet; use Laminas\Hydrator\Reflection as ReflectionHydrator; -class UserEntity -{ - protected $first_name; - protected $last_name; +$statement = $driver->createStatement('SELECT * FROM users'); +$statement->prepare(); +$result = $statement->execute(); - public function getFirstName() - { - return $this->first_name; +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()); } +} +``` - public function getLastName() - { - return $this->last_name; +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. + +## ResultSet API Reference + +### ResultSet Class + +The `ResultSet` class extends `AbstractResultSet` and provides row data as either +`ArrayObject` instances or plain arrays. + +```php +namespace PhpDb\ResultSet; + +use ArrayObject; + +class ResultSet extends AbstractResultSet +{ + public const TYPE_ARRAYOBJECT = 'arrayobject'; + public const TYPE_ARRAY = 'array'; + + public function __construct( + string $returnType = self::TYPE_ARRAYOBJECT, + ?ArrayObject $arrayObjectPrototype = null + ); + + public function setArrayObjectPrototype(ArrayObject $arrayObjectPrototype): static; + public function getArrayObjectPrototype(): ArrayObject; + public function getReturnType(): string; +} +``` + +#### Constructor Parameters + +**`$returnType`** - Controls how rows are returned: +- `ResultSet::TYPE_ARRAYOBJECT` (default) - Returns rows as ArrayObject instances +- `ResultSet::TYPE_ARRAY` - Returns rows as plain PHP arrays + +**`$arrayObjectPrototype`** - Custom ArrayObject prototype for row objects (only used with TYPE_ARRAYOBJECT) + +#### Return Type Modes + +**ArrayObject Mode** (default): + +```php +$resultSet = new ResultSet(ResultSet::TYPE_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 +$resultSet = new ResultSet(ResultSet::TYPE_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 +namespace PhpDb\ResultSet; + +use Laminas\Hydrator\HydratorInterface; + +class HydratingResultSet extends AbstractResultSet +{ + public function __construct( + ?HydratorInterface $hydrator = null, + ?object $objectPrototype = null + ); + + public function setHydrator(HydratorInterface $hydrator): static; + public function getHydrator(): HydratorInterface; + + public function setObjectPrototype(object $objectPrototype): static; + public function getObjectPrototype(): ?object; + + public function current(): ?object; + public function toArray(): array; +} +``` + +#### Constructor Defaults + +If no hydrator is provided, `ArraySerializableHydrator` is used by default: + +```php +$resultSet = new HydratingResultSet(); +``` + +If no object prototype is provided, `ArrayObject` is used: + +```php +$resultSet = new HydratingResultSet(new ReflectionHydrator()); +``` + +#### Runtime Hydrator Changes + +You can change the hydration strategy at runtime: + +```php +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 +$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 +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + break; +} + +$resultSet->buffer(); +``` + +Throws: + +``` +RuntimeException: Buffering must be enabled before iteration is started +``` + +### isBuffered() Method + +Checks if the result set is currently buffered: + +```php +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +var_dump($resultSet->isBuffered()); + +$resultSet->buffer(); + +var_dump($resultSet->isBuffered()); +``` + +Outputs: + +``` +bool(false) +bool(true) +``` + +### Automatic Buffering + +Arrays and certain data sources are automatically buffered: + +```php +$resultSet = new ResultSet(); +$resultSet->initialize([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], +]); + +var_dump($resultSet->isBuffered()); +``` + +Outputs: + +``` +bool(true) +``` + +## ArrayObject Access Patterns + +When using `TYPE_ARRAYOBJECT` mode (default), rows support both property and array access: + +```php +$resultSet = new ResultSet(ResultSet::TYPE_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); } - public function setFirstName($firstName) - { - $this->first_name = $firstName; + if (isset($row['phone'])) { + printf("Phone: %s\n", $row['phone']); } +} +``` - public function setLastName($lastName) +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 +class CustomRow extends ArrayObject +{ + public function getFullName(): string { - $this->last_name = $lastName; + return $this['first_name'] . ' ' . $this['last_name']; } } -$statement = $driver->createStatement($sql); -$statement->prepare($parameters); +$prototype = new CustomRow([], ArrayObject::ARRAY_AS_PROPS); +$resultSet = new ResultSet(ResultSet::TYPE_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 +$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 +use PhpDb\Adapter\Adapter; +use PhpDb\ResultSet\ResultSet; + +$customResultSet = new ResultSet(ResultSet::TYPE_ARRAY); + +$adapter = new Adapter([ + 'driver' => 'Pdo_Mysql', + 'database' => 'mydb', +], $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 +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()); +} +``` + +## Data Source Types + +The `initialize()` method accepts multiple data source types: + +### Arrays + +```php +$resultSet = new ResultSet(); +$resultSet->initialize([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], +]); +``` + +Arrays are automatically buffered and allow multiple iterations. + +### Iterator + +```php +$resultSet = new ResultSet(); +$resultSet->initialize(new ArrayIterator($data)); +``` + +### IteratorAggregate + +```php +$resultSet = new ResultSet(); +$resultSet->initialize($iteratorAggregate); +``` + +### ResultInterface (Driver Result) + +```php $result = $statement->execute(); +$resultSet = new ResultSet(); +$resultSet->initialize($result); +``` -if ($result instanceof ResultInterface && $result->isQueryResult()) { - $resultSet = new HydratingResultSet(new ReflectionHydrator, new UserEntity); - $resultSet->initialize($result); +This is the most common use case when working with database queries. - foreach ($resultSet as $user) { - echo $user->getFirstName() . ' ' . $user->getLastName() . PHP_EOL; +## 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 +$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 +$resultSet = $adapter->query('SELECT * FROM huge_table'); + +foreach ($resultSet as $row) { + processRow($row); +} +``` + +### Memory Efficiency Comparison + +```php +$arrayMode = new ResultSet(ResultSet::TYPE_ARRAY); +$arrayMode->initialize($result); + +$arrayObjectMode = new ResultSet(ResultSet::TYPE_ARRAYOBJECT); +$arrayObjectMode->initialize($result); +``` + +`TYPE_ARRAY` uses less memory per row than `TYPE_ARRAYOBJECT` because it avoids +object overhead. + +## 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()); +} +``` + +**ArrayObject without exchangeArray() method:** + +```php +try { + $invalidPrototype = new ArrayObject(); + unset($invalidPrototype->exchangeArray); + $resultSet->setArrayObjectPrototype($invalidPrototype); +} catch (InvalidArgumentException $e) { + printf("Error: %s\n", $e->getMessage()); +} +``` + +**Non-object passed to HydratingResultSet:** + +```php +try { + $resultSet->setObjectPrototype('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"); +} +``` + +## Advanced Usage + +### Multiple Hydrators + +Switch hydrators based on context: + +```php +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 +$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 +$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 +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +$firstRow = $resultSet->current(); +``` + +This returns the first row without advancing the iterator. + +## 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; } } ``` -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. +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); +} +``` + +### Checking for Empty Results + +```php +$resultSet = $adapter->query('SELECT * FROM users WHERE id = ?', [999]); + +if ($resultSet->count() === 0) { + printf("No users found\n"); +} +``` + +## Troubleshooting + +### Cannot Rewind After Iteration + +**Problem:** Trying to iterate twice fails + +**Solution:** Buffer the result set before first iteration + +```php +$resultSet->buffer(); + +foreach ($resultSet as $row) { + processRow($row); +} + +$resultSet->rewind(); + +foreach ($resultSet as $row) { + processRowAgain($row); +} +``` + +### Out of Memory Errors + +**Problem:** Large result sets cause memory exhaustion + +**Solution:** Use TYPE_ARRAY mode and avoid buffering + +```php +$resultSet = new ResultSet(ResultSet::TYPE_ARRAY); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + processRow($row); +} +``` + +### Property Access Not Working + +**Problem:** `$row->column_name` returns null + +**Solution:** Ensure using TYPE_ARRAYOBJECT mode (default) + +```php +$resultSet = new ResultSet(ResultSet::TYPE_ARRAYOBJECT); +``` + +Or use array access instead: + +```php +$value = $row['column_name']; +``` + +### Hydration Failures + +**Problem:** Object properties not populated + +**Solution:** Ensure hydrator matches object structure + +```php +use Laminas\Hydrator\ClassMethodsHydrator; +use Laminas\Hydrator\ReflectionHydrator; + +$resultSet = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); +``` + +Use `ReflectionHydrator` for protected/private properties, `ClassMethodsHydrator` +for public setters. + +### Invalid Data Source Exception + +**Problem:** `InvalidArgumentException` on initialize() + +**Solution:** Ensure data source is array, Iterator, IteratorAggregate, or ResultInterface + +```php +$resultSet->initialize($validDataSource); +``` diff --git a/docs/book/sql.md b/docs/book/sql.md index 1cda6899b..451e9af11 100644 --- a/docs/book/sql.md +++ b/docs/book/sql.md @@ -61,7 +61,7 @@ $selectString = $sql->buildSqlString($select); $results = $adapter->query($selectString, $adapter::QUERY_MODE_EXECUTE); ``` -`Laminas\\Db\\Sql\\Sql` objects can also be bound to a particular table so that in +`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: @@ -192,6 +192,27 @@ $select ); ``` +The `$on` parameter accepts either a string or a `PredicateInterface` for complex join conditions: + +```php +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 +``` + ### where(), having() `PhpDb\Sql\Select` provides bit of flexibility as it regards to what kind of @@ -304,10 +325,268 @@ $select->order(['name ASC', 'age DESC']); // produces 'name' ASC, 'age' DESC ```php $select = new Select; -$select->limit(5); // always takes an integer/numeric -$select->offset(10); // similarly takes an integer/numeric +$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 +$select->group('category'); +``` + +Multiple columns can be specified as an array, or by calling `group()` multiple times: + +```php +$select->group(['category', 'status']); + +$select->group('category') + ->group('status'); +``` + +As an example with aggregate functions: + +```php +$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 +$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 +$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 +$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 +$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 +$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 +$state = $select->getRawState(); +``` + +Returns an array containing: + +```php +[ + '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 +$table = $select->getRawState(Select::TABLE); +$columns = $select->getRawState(Select::COLUMNS); +$limit = $select->getRawState(Select::LIMIT); +``` + +## Combine + +The `Combine` class enables combining multiple SELECT statements using UNION, +INTERSECT, or EXCEPT operations. Each operation can optionally include modifiers +such as ALL or DISTINCT. + +```php +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); +``` + +### UNION + +The `union()` method combines results from multiple SELECT statements, removing +duplicates by default. + +```php +$combine = new Combine(); +$combine->union($select1); +$combine->union($select2, 'ALL'); +``` + +Produces: + +```sql +(SELECT * FROM table1 WHERE status = 'active') +UNION ALL +(SELECT * FROM table2 WHERE status = 'pending') +``` + +### EXCEPT + +The `except()` method returns rows from the first SELECT that do not appear in +subsequent SELECT statements. + +```php +$combine = new Combine(); +$combine->union($select1); +$combine->except($select2); +``` + +### INTERSECT + +The `intersect()` method returns only rows that appear in all SELECT statements. + +```php +$combine = new Combine(); +$combine->union($select1); +$combine->intersect($select2); +``` + +### alignColumns() + +The `alignColumns()` method ensures all SELECT statements have the same column +structure by adding NULL for missing columns. + +```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 +(SELECT id, amount, NULL AS reason FROM orders) +UNION +(SELECT id, amount, reason FROM refunds) +``` + +After alignment, both SELECTs will have id, amount, and reason columns, with +NULL used where columns are missing. + +### Using combine() on Select + +The Select class also provides a `combine()` method for simple combinations: + +```php +$select1->combine($select2, Select::COMBINE_UNION, 'ALL'); +``` + +Note that Select can only combine with one other Select. For multiple +combinations, use the Combine class directly. + ## Insert The Insert API: @@ -353,6 +632,89 @@ To merge values with previous calls, provide the appropriate flag: $insert->values(['col_2' => 'value2'], $insert::VALUES_MERGE); ``` +### select() + +The `select()` method enables INSERT INTO ... SELECT statements, copying data +from one table to another. + +```php +$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 +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 +$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 +$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 +$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 +use PhpDb\Sql\InsertIgnore; + +$insert = new InsertIgnore('users'); +$insert->values([ + 'username' => 'john', + 'email' => 'john@example.com', +]); +``` + +Produces: + +```sql +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). + ## Update ```php @@ -375,6 +737,29 @@ class Update $update->set(['foo' => 'bar', 'baz' => 'bax']); ``` +The `set()` method accepts a flag parameter to control merging behavior: + +```php +$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 +$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 +UPDATE table SET status = ?, flag = ?, counter = ? +``` + +This is useful when the order of SET operations matters for certain database operations or triggers. + ### where() See the [section on Where and Having](#where-and-having). @@ -419,15 +804,78 @@ There is also a special use case type for literal values (`TYPE_LITERAL`). All element types are expressed via the `PhpDb\Sql\ExpressionInterface` interface. +> **Note:** The `TYPE_*` constants are legacy constants maintained for backward +> compatibility. New code should use the `ArgumentType` enum and `Argument` +> class for type-safe argument handling (see the section below). + +### Arguments and Argument Types + +`PhpDb\Sql` provides the `Argument` class along with the `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 four types: + +- `ArgumentType::Identifier` - For column names, table names, and other identifiers that should be quoted +- `ArgumentType::Value` - For values that should be parameterized or properly escaped (default) +- `ArgumentType::Literal` - For literal SQL fragments that should not be quoted or escaped +- `ArgumentType::Select` - For subqueries (automatically detected when using Expression or SqlInterface objects) + +```php +use PhpDb\Sql\Argument; +use PhpDb\Sql\ArgumentType; + +// Using the constructor with explicit type +$arg = new Argument('column_name', ArgumentType::Identifier); + +// Using static factory methods (recommended) +$valueArg = Argument::value(123); // Value type +$identifierArg = Argument::identifier('id'); // Identifier type +$literalArg = Argument::literal('NOW()'); // Literal SQL + +// Using array notation for type specification +$arg = new Argument(['column_name' => ArgumentType::Identifier]); + +// Arrays of values are also supported +$arg = new Argument([1, 2, 3], ArgumentType::Value); +``` + +The `Argument` class is particularly useful when working with expressions +where you need to explicitly control how values are treated: + +```php +use PhpDb\Sql\Expression; +use PhpDb\Sql\Argument; + +// Without Argument - relies on positional type inference +$expression = new Expression( + 'CONCAT(?, ?, ?)', + [ + ['column1' => ExpressionInterface::TYPE_IDENTIFIER], + ['-' => ExpressionInterface::TYPE_VALUE], + ['column2' => ExpressionInterface::TYPE_IDENTIFIER] + ] +); + +// With Argument - more explicit and readable +$expression = new Expression( + 'CONCAT(?, ?, ?)', + [ + Argument::identifier('column1'), + Argument::value('-'), + Argument::identifier('column2') + ] +); +``` + > ### Literals > -> In Laminas 2.1, an actual `Literal` type was added. `PhpDb\Sql` now 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` 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. +> have parameters, use `Literal` objects or `Argument::literal()`. The `Where` and `Having` API is that of `Predicate` and `PredicateSet`: @@ -762,3 +1210,950 @@ class Between implements PredicateInterface public function setSpecification(string $specification); } ``` + +As an example with different value types: + +```php +$where->between('age', 18, 65); +$where->notBetween('price', 100, 500); +$where->between('createdAt', '2024-01-01', '2024-12-31'); +``` + +Produces: + +```sql +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 +$where->between(new Expression('YEAR(createdAt)'), 2020, 2024); +``` + +Produces: + +```sql +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 +$select->where + ->equalTo('status', 'active') + ->and + ->greaterThan('age', 18) + ->or + ->equalTo('role', 'admin'); +``` + +Produces: + +```sql +WHERE status = 'active' AND age > 18 OR role = 'admin' +``` + +The properties are case-insensitive for convenience: + +```php +$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 +$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 +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 +$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` predicate (requires parameters) | +| Key => `null` | Creates `IsNull` predicate | +| Key => array | Creates `In` predicate | +| Key => scalar | Creates `Operator` predicate (equality) | +| `PredicateInterface` | Uses predicate directly | + +Combination operators can be specified: + +```php +$where->addPredicates([ + 'role' => 'admin', + 'status' => 'active', +], PredicateSet::OP_OR); +``` + +Produces: + +```sql +WHERE role = 'admin' OR status = 'active' +``` + +### Using LIKE and NOT LIKE patterns + +The `like()` and `notLike()` methods support SQL wildcard patterns: + +```php +$where->like('name', 'John%'); +$where->like('email', '%@gmail.com'); +$where->like('description', '%keyword%'); +$where->notLike('email', '%@spam.com'); +``` + +Multiple LIKE conditions: + +```php +$where->like('name', 'A%') + ->or + ->like('name', 'B%'); +``` + +Produces: + +```sql +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 +$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 +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 +$select->having(function ($having) { + $having->greaterThan(new Expression('AVG(rating)'), 4.5) + ->or + ->greaterThan(new Expression('COUNT(reviews)'), 100); +}); +``` + +Produces: + +```sql +HAVING AVG(rating) > 4.5 OR COUNT(reviews) > 100 +``` + +## Subqueries + +Subqueries can be used in various contexts within SQL statements, including WHERE +clauses, FROM clauses, and SELECT columns. + +### Subqueries in WHERE IN clauses + +```php +$subselect = $sql->select('orders') + ->columns(['customerId']) + ->where(['status' => 'completed']); + +$select = $sql->select('customers') + ->where->in('id', $subselect); +``` + +Produces: + +```sql +SELECT customers.* FROM customers +WHERE id IN (SELECT customerId FROM orders WHERE status = 'completed') +``` + +### Subqueries in FROM clauses + +```php +$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 +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 +$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 +SELECT id, name, +(SELECT COUNT(*) FROM orders WHERE orders.customerId = customers.id) AS orderCount +FROM customers +``` + +### Subqueries with comparison operators + +```php +$subselect = $sql->select('orders') + ->columns([new Expression('AVG(amount)')]); + +$select = $sql->select('orders') + ->where->greaterThan('amount', $subselect); +``` + +Produces: + +```sql +SELECT orders.* FROM orders +WHERE amount > (SELECT AVG(amount) FROM orders) +``` + +## Advanced JOIN Usage + +### Multiple JOIN types in a single query + +```php +$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 +$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 +$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 +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(); +``` + +## Update and Delete Safety Features + +Both Update and Delete classes include empty WHERE protection by default, which +prevents accidental mass updates or deletes. + +```php +$update = $sql->update('users'); +$update->set(['status' => 'deleted']); + +$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 +$update->where(['id' => 123]); + +$delete = $sql->delete('logs'); +$delete->where->lessThan('createdAt', '2020-01-01'); +``` + +### Update with JOIN + +The Update class supports JOIN clauses for multi-table updates: + +```php +$update = $sql->update('orders'); +$update->set(['status' => 'cancelled']); +$update->join('customers', 'orders.customerId = customers.id', Update\Join::JOIN_INNER); +$update->where(['customers.status' => 'inactive']); +``` + +Produces: + +```sql +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. + +## Expression and Literal Advanced Usage + +### Distinguishing between Expression and Literal + +Use `Literal` for static SQL fragments without parameters: + +```php +$literal = new Literal('NOW()'); +$literal = new Literal('CURRENT_TIMESTAMP'); +$literal = new Literal('COUNT(*)'); +``` + +Use `Expression` when parameters are needed: + +```php +$expression = new Expression('DATE_ADD(NOW(), INTERVAL ? DAY)', [7]); +$expression = new Expression('CONCAT(?, ?)', ['Hello', 'World']); +``` + +### Mixed parameter types in expressions + +```php +$expression = new Expression( + 'CASE WHEN ? > ? THEN ? ELSE ? END', + [ + Argument::identifier('age'), + Argument::value(18), + Argument::literal('ADULT'), + Argument::literal('MINOR'), + ] +); +``` + +Produces: + +```sql +CASE WHEN age > 18 THEN ADULT ELSE MINOR END +``` + +### Array values in expressions + +```php +$expression = new Expression( + 'id IN (?)', + [Argument::value([1, 2, 3, 4, 5])] +); +``` + +Produces: + +```sql +id IN (?, ?, ?, ?, ?) +``` + +### Nested expressions + +```php +$innerExpression = new Expression('COUNT(*)'); +$outerExpression = new Expression( + 'CASE WHEN ? > ? THEN ? ELSE ? END', + [ + $innerExpression, + Argument::value(10), + Argument::literal('HIGH'), + Argument::literal('LOW'), + ] +); +``` + +Produces: + +```sql +CASE WHEN COUNT(*) > 10 THEN HIGH ELSE LOW END +``` + +### Using database-specific functions + +```php +$select->where(new Predicate\Expression( + 'FIND_IN_SET(?, ?)', + [ + Argument::value('admin'), + Argument::identifier('roles'), + ] +)); +``` + +## TableIdentifier + +The `TableIdentifier` class provides a type-safe way to reference tables, +especially when working with schemas or databases. + +```php +use PhpDb\Sql\TableIdentifier; + +$table = new TableIdentifier('users', 'production'); + +$tableName = $table->getTable(); +$schemaName = $table->getSchema(); + +[$table, $schema] = $table->getTableAndSchema(); +``` + +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 +``` + +With aliases: + +```php +$select->from(['o' => new TableIdentifier('orders', 'sales')]) + ->join( + ['c' => new TableIdentifier('customers', 'crm')], + 'o.customerId = c.id' + ); +``` + +## 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 +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$sql = new Sql($adapter, 'defaultTable'); +``` + +### Factory Methods + +```php +$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'); +``` + +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 Statements + +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 Strings + +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. + +### Accessing the Platform + +```php +$platform = $sql->getSqlPlatform(); +``` + +The platform object handles database-specific SQL generation and can be used for custom query building. + +## 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 +$update->set(['optionalField' => null]); +``` + +In comparisons, remember that `column = NULL` does not work in SQL; you must use `IS NULL`: + +```php +$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 +$update = $sql->update('users'); +$update->set(['status' => 'inactive']); +``` + +Always include a WHERE clause for UPDATE and DELETE: + +```php +$update->where(['id' => 123]); +``` + +To intentionally update all rows (use with extreme caution): + +```php +$state = $update->getRawState(); +``` + +### Parameter Count Mismatch + +When using Expression with placeholders: + +```php +$expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b']); +``` + +Ensure the number of `?` placeholders matches the number of parameters provided, or you will receive a RuntimeException. + +### Quote Character Issues + +Different databases use different quote characters. Let the platform handle quoting: + +```php +$select->from('users'); +``` + +Avoid manually quoting identifiers: + +```php +$select->from('"users"'); +``` + +### Type Confusion in Predicates + +When comparing two identifiers, specify both types: + +```php +$where->equalTo( + 'table1.columnA', + 'table2.columnB', + Predicate\Predicate::TYPE_IDENTIFIER, + Predicate\Predicate::TYPE_IDENTIFIER +); +``` + +Or use the Argument class: + +```php +$where->equalTo( + Argument::identifier('table1.columnA'), + Argument::identifier('table2.columnB') +); +``` + +## 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 +$select->limit(25)->offset(50); +``` + +### Select Only Required Columns + +Instead of selecting all columns: + +```php +$select->from('users'); +``` + +Specify only the columns you need: + +```php +$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 +$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 +$select->where->equalTo('indexedColumn', $value) + ->greaterThan('date', '2024-01-01'); +``` + +Avoid functions on indexed columns in WHERE: + +```php +$select->where(new Predicate\Expression('YEAR(createdAt) = ?', [2024])); +``` + +Instead, use ranges: + +```php +$select->where->between('createdAt', '2024-01-01', '2024-12-31'); +``` + +## Complete Examples + +### Complex reporting query with aggregation + +```php +$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(); +``` + +### Data migration with INSERT SELECT + +```php +$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(); +``` + +### Combining multiple result sets + +```php +$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(); +``` From d253bf1154685fc235cc6c73fb350da7e199822e Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 15:27:26 +1100 Subject: [PATCH 02/11] Re-added mkdocs yml configuration Split docs files and re-ordered 2-pass optimisation of examples and introductions Signed-off-by: Simon Mundy --- docs/book/adapter.md | 512 ++-- docs/book/adapters/adapter-aware-trait.md | 42 +- .../usage-in-a-laminas-mvc-application.md | 450 ++-- .../usage-in-a-mezzio-application.md | 495 +--- docs/book/docker-deployment.md | 292 +++ docs/book/index.html | 10 - docs/book/index.md | 138 +- docs/book/metadata.md | 940 ------- docs/book/metadata/examples.md | 272 +++ docs/book/metadata/intro.md | 399 +++ docs/book/metadata/objects.md | 317 +++ docs/book/profiler.md | 426 ++++ docs/book/result-set.md | 889 ------- docs/book/result-set/advanced.md | 502 ++++ docs/book/result-set/examples.md | 315 +++ docs/book/result-set/intro.md | 154 ++ docs/book/row-gateway.md | 19 +- docs/book/sql-ddl.md | 203 -- docs/book/sql-ddl/advanced.md | 472 ++++ docs/book/sql-ddl/alter-drop.md | 522 ++++ docs/book/sql-ddl/columns.md | 543 +++++ docs/book/sql-ddl/constraints.md | 507 ++++ docs/book/sql-ddl/examples.md | 531 ++++ docs/book/sql-ddl/intro.md | 253 ++ docs/book/sql.md | 2159 ----------------- docs/book/sql/advanced.md | 266 ++ docs/book/sql/examples.md | 551 +++++ docs/book/sql/insert.md | 273 +++ docs/book/sql/intro.md | 290 +++ docs/book/sql/select.md | 485 ++++ docs/book/sql/update-delete.md | 330 +++ docs/book/sql/where-having.md | 915 +++++++ docs/book/table-gateway.md | 48 +- mkdocs.yml | 42 + 34 files changed, 9352 insertions(+), 5210 deletions(-) create mode 100644 docs/book/docker-deployment.md delete mode 100644 docs/book/index.html mode change 120000 => 100644 docs/book/index.md delete mode 100644 docs/book/metadata.md create mode 100644 docs/book/metadata/examples.md create mode 100644 docs/book/metadata/intro.md create mode 100644 docs/book/metadata/objects.md create mode 100644 docs/book/profiler.md delete mode 100644 docs/book/result-set.md create mode 100644 docs/book/result-set/advanced.md create mode 100644 docs/book/result-set/examples.md create mode 100644 docs/book/result-set/intro.md delete mode 100644 docs/book/sql-ddl.md create mode 100644 docs/book/sql-ddl/advanced.md create mode 100644 docs/book/sql-ddl/alter-drop.md create mode 100644 docs/book/sql-ddl/columns.md create mode 100644 docs/book/sql-ddl/constraints.md create mode 100644 docs/book/sql-ddl/examples.md create mode 100644 docs/book/sql-ddl/intro.md delete mode 100644 docs/book/sql.md create mode 100644 docs/book/sql/advanced.md create mode 100644 docs/book/sql/examples.md create mode 100644 docs/book/sql/insert.md create mode 100644 docs/book/sql/intro.md create mode 100644 docs/book/sql/select.md create mode 100644 docs/book/sql/update-delete.md create mode 100644 docs/book/sql/where-having.md create mode 100644 mkdocs.yml diff --git a/docs/book/adapter.md b/docs/book/adapter.md index 48b6c14a8..075e40504 100644 --- a/docs/book/adapter.md +++ b/docs/book/adapter.md @@ -1,162 +1,114 @@ # 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: +| Package | Database | Status | +|---------|----------|--------| +| `php-db/mysql` | MySQL/MariaDB | Available | +| `php-db/sqlite` | SQLite | Available | +| `php-db/postgres` | PostgreSQL | Coming Soon | -```php -$adapter = new PhpDb\Adapter\Adapter([ - 'driver' => 'Mysqli', - 'database' => 'laminas_db_example', - 'username' => 'developer', - 'password' => 'developer-password', -]); -``` +## Quick Start -Another example, of a Sqlite connection via PDO: +### MySQL Connection ```php -$adapter = new PhpDb\Adapter\Adapter([ - 'driver' => 'Pdo_Sqlite', - 'database' => 'path/to/sqlite.db', +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: +### SQLite Connection ```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], -]); -``` +use PhpDb\Adapter\Adapter; +use PhpDb\Sqlite\Driver\Sqlite; +use PhpDb\Sqlite\Platform\Sqlite as 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. +$driver = new Sqlite([ + 'database' => '/path/to/database.sqlite', +]); -The list of officially supported drivers: +$adapter = new Adapter($driver, new SqlitePlatform()); +``` -- `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 +## The Adapter Class -## Creating an adapter using dependency injection +The `Adapter` class provides the primary interface for database operations: -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): +### Adapter Class Interface ```php -use PhpDb\Adapter\Platform\PlatformInterface; -use PhpDb\ResultSet\ResultSet; +namespace PhpDb\Adapter; -class PhpDb\Adapter\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: + +### Query with Prepared Statement ```php $adapter->query('SELECT * FROM `artist` WHERE `id` = ?', [5]); @@ -164,16 +116,16 @@ $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,9 +133,11 @@ 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: +### Executing DDL Statement Without Preparation + ```php $adapter->query( 'ALTER TABLE ADD INDEX(`foo_index`) ON (`foo_column`)', @@ -199,12 +153,11 @@ 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: + +### Creating and Executing a Statement ```php -// with optional parameters to bind up-front: $statement = $adapter->createStatement($sql, $optionalParameters); $result = $statement->execute(); ``` @@ -212,82 +165,77 @@ $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: +### DriverInterface -- 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: +### Driver Interface Definition ```php 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). +- Retrieve the overall last generated value (such as an auto-increment value) + +### StatementInterface -Now let's turn to the `Statement` API: +### Statement Interface Definition ```php 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: +### ResultInterface + +### Result Interface Definition ```php namespace PhpDb\Adapter\Driver; @@ -297,12 +245,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 +259,25 @@ 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: + +### Platform Interface Definition ```php 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,6 +285,8 @@ 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): +### Getting Platform from Adapter + ```php $platform = $adapter->getPlatform(); @@ -343,11 +294,11 @@ $platform = $adapter->getPlatform(); $platform = $adapter->platform; // magic property access ``` -The following are examples of `Platform` usage: +### Platform Usage Examples + +### Quoting Identifiers and Values ```php -// $adapter is a PhpDb\Adapter\Adapter instance; -// $platform is a PhpDb\Adapter\Platform\Sql92 instance. $platform = $adapter->getPlatform(); // "first_name" @@ -365,7 +316,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,8 +334,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: + +### ParameterContainer Class Interface ```php namespace PhpDb\Adapter; @@ -396,56 +348,61 @@ 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: + +### Setting Parameter Without Type ```php $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: + +### Setting Parameter with Type Binding ```php $container->offsetSet('limit', 5, $container::TYPE_INTEGER); @@ -453,15 +410,67 @@ $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 -## Examples +Drivers can provide optional features through the `DriverFeatureProviderInterface`: -Creating a `Driver`, a vendor-portable query, and preparing and iterating the -result: +### DriverFeatureProviderInterface Definition ```php -$adapter = new PhpDb\Adapter\Adapter($driverConfig); +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`: + +### Setting Up a Profiler + +```php +use PhpDb\Adapter\Profiler\Profiler; + +$profiler = new Profiler(); +$adapter = new Adapter($driver, $platform, profiler: $profiler); + +// 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: + +### Full Workflow Example with Adapter + +```php +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 +492,7 @@ $parameters = [ $statement->execute($parameters); -// DATA INSERTED, NOW CHECK - +// DATA UPDATED, NOW CHECK $statement = $adapter->query( 'SELECT * FROM ' . $qi('artist') @@ -495,4 +503,4 @@ $results = $statement->execute(['id' => 1]); $row = $results->current(); $name = $row['name']; -``` +``` \ No newline at end of file diff --git a/docs/book/adapters/adapter-aware-trait.md b/docs/book/adapters/adapter-aware-trait.md index 454bfd322..77d99ff0b 100644 --- a/docs/book/adapters/adapter-aware-trait.md +++ b/docs/book/adapters/adapter-aware-trait.md @@ -1,12 +1,6 @@ # 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 +8,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,21 +16,10 @@ 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 @@ -80,23 +61,26 @@ 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', + $driver = new Sqlite([ 'database' => 'path/to/sqlite.db', ]); + return new Adapter($driver, new SqlitePlatform()); } ], 'invokables' => [ @@ -124,8 +108,4 @@ $example = $serviceManager->get(Example::class); var_dump($example->getAdapter() instanceof PhpDb\Adapter\Adapter); // 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 e39ade574..dc67197b1 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,370 +1,222 @@ # 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 +## Service 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. +Now that the phpdb packages are installed, you need to configure the adapter through your application's service manager. -## Adding to an existing Laminas MVC Skeleton Application +### Configuring the Adapter -If the MVC application is already created, then use Composer to [add the laminas-db](../index.md) package. +Create a configuration file `config/autoload/database.global.php` (or `local.php` for credentials) to define database settings. -## The Abstract Factory +### Working with a SQLite database -Now that the laminas-db package is installed, the abstract factory `PhpDb\Adapter\AdapterAbstractServiceFactory` is available to be used with the service configuration. +SQLite is a lightweight option to have the application working with a database. -### Configuring the adapter +Here is an example of the configuration array for a SQLite database. +Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: -The abstract factory expects the configuration key `db` in order to create a `PhpDb\Adapter\Adapter` instance. +### SQLite adapter configuration -### Working with a Sqlite database +```php + [ - 'driver' => 'Pdo', - 'adapters' => [ - sqliteAdapter::class => [ - 'driver' => 'Pdo', - 'dsn' => 'sqlite:data/sample.sqlite', - ], + 'service_manager' => [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Sqlite([ + 'database' => 'data/sample.sqlite', + ]); + return new Adapter($driver, new SqlitePlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, ], ], ]; ``` -The `data/` filepath for the sqlite file is the default `data/` directory from the Laminas MVC application. +The `data/` filepath for the SQLite file is the default `data/` directory from the Laminas MVC application. ### Working with a MySQL database -Unlike a sqlite database, the MySQL database adapter requires a MySQL server. +Unlike a SQLite database, the MySQL database adapter requires a MySQL server. + +Here is an example of a configuration array for a MySQL database: -Here is an example of a configuration array for a MySQL database. +### MySQL adapter configuration ```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\'' - ], - ], + 'service_manager' => [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Mysql([ + 'database' => 'your_database_name', + 'username' => 'your_mysql_username', + 'password' => 'your_mysql_password', + 'hostname' => 'localhost', + 'charset' => 'utf8mb4', + ]); + return new Adapter($driver, new MysqlPlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, ], ], ]; ``` -## 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. - -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: - -```php -use sqliteAdapter ; +### Working with PostgreSQL database -$adapter = $container->get(sqliteAdapter::class) ; -``` +PostgreSQL support is coming soon. Once the `php-db/postgres` package is available: -For the MySQL Database configured earlier: +### PostgreSQL adapter configuration ```php -use mysqlAdapter ; +get(mysqlAdapter::class) ; -``` - -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. - -This guide covers two web server options: **Nginx with PHP-FPM** (recommended for production) and **Apache** (simpler for development). - -### Option 1: Nginx with PHP-FPM (Recommended) - -Nginx with PHP-FPM provides better performance and resource efficiency for production environments. +declare(strict_types=1); -#### Creating the Dockerfile +use PhpDb\Adapter\Adapter; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Postgres\Driver\Postgres; +use PhpDb\Postgres\Platform\Postgres as PostgresPlatform; +use Psr\Container\ContainerInterface; -Create a `Dockerfile` in your project root: - -```Dockerfile -FROM php:8.2-fpm - -RUN apt-get update \ - && apt-get install -y git zlib1g-dev libzip-dev \ - && docker-php-ext-install zip pdo_mysql \ - && curl -sS https://getcomposer.org/installer \ - | php -- --install-dir=/usr/local/bin --filename=composer - -WORKDIR /var/www +return [ + 'service_manager' => [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Postgres([ + 'database' => 'your_database_name', + 'username' => 'your_pgsql_username', + 'password' => 'your_pgsql_password', + 'hostname' => 'localhost', + 'port' => 5432, + ]); + return new Adapter($driver, new PostgresPlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; ``` -#### Creating the Nginx Configuration +## Working with the adapter -Create a file at `docker/nginx/default.conf`: +Once you have configured an adapter, as in the above examples, you now have a `PhpDb\Adapter\Adapter` available to your application. -```nginx -server { - listen 80; - server_name localhost; - root /var/www/public; - index index.php; +A factory for a class that consumes an adapter can pull the adapter from the container: - location / { - try_files $uri $uri/ /index.php?$query_string; - } +### Retrieving the adapter from the service container - location ~ \.php$ { - fastcgi_pass app:9000; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - include fastcgi_params; - } +```php +use PhpDb\Adapter\AdapterInterface; - location ~ /\.ht { - deny all; - } -} +$adapter = $container->get(AdapterInterface::class); ``` -This configuration: -- Serves files from `/var/www/public` -- Routes all requests through `index.php` (required for laminas-mvc routing) -- Passes PHP requests to the `app` container on port 9000 -- Denies access to `.htaccess` files +You can read more about the [adapter in the adapter chapter of the documentation](../adapter.md). -### Option 2: Apache +## Adapter-Aware Services with AdapterServiceDelegator -Apache provides a simpler setup suitable for development environments. +If you have services that implement `PhpDb\Adapter\AdapterAwareInterface`, you can use the `AdapterServiceDelegator` to automatically inject the database adapter. -#### Creating the Dockerfile +### Using the Delegator -Create a `Dockerfile` in your project root: +Register the delegator in your service configuration: -```Dockerfile -FROM php:8.2-apache +### Delegator configuration for adapter-aware services -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 +```php +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Container\AdapterServiceDelegator; -WORKDIR /var/www +return [ + 'service_manager' => [ + 'delegators' => [ + MyDatabaseService::class => [ + new AdapterServiceDelegator(AdapterInterface::class), + ], + ], + ], +]; ``` -### 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} -``` +### Multiple Adapters -Though it is not the topic to explain how to write a `docker-compose.yml` file, a few details need to be highlighted : - -- 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. - -### Configuring the Application Container - -#### For Nginx (Option 1) - -When using Nginx with PHP-FPM, you'll need both an `app` container running PHP-FPM and an `nginx` container: - -```yaml - app: - container_name: laminas-app - build: - context: . - dockerfile: Dockerfile - volumes: - - .:/var/www - links: - - mysql:mysql - - nginx: - image: nginx:alpine - container_name: laminas-nginx - ports: - - 8080:80 - volumes: - - .:/var/www - - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf - depends_on: - - app -``` +When using multiple adapters, you can specify which adapter to inject: -#### For Apache (Option 2) - -When using Apache, you only need a single `laminas` container: - -```yaml - laminas: - build: - context: . - dockerfile: Dockerfile - ports: - - 8080:80 - volumes: - - .:/var/www - links: - - mysql:mysql -``` +### Delegator configuration for multiple adapters -### Adding phpMyAdmin - -Optionnally, you can also add a container for phpMyAdmin. +```php +use PhpDb\Container\AdapterServiceDelegator; -```yaml - phpmyadmin: - image: phpmyadmin/phpmyadmin - ports: - - 8081:80 - environment: - - PMA_HOST=${PMA_HOST} +return [ + 'service_manager' => [ + 'delegators' => [ + ReadService::class => [ + new AdapterServiceDelegator('db.reader'), + ], + WriteService::class => [ + new AdapterServiceDelegator('db.writer'), + ], + ], + ], +]; ``` -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. - -### Complete docker-compose.yml Examples - -#### Complete Example with Nginx (Recommended) - -```yaml -version: "3.8" -services: - app: - container_name: laminas-app - build: - context: . - dockerfile: Dockerfile - volumes: - - .:/var/www - links: - - mysql:mysql - - nginx: - image: nginx:alpine - container_name: laminas-nginx - ports: - - 8080:80 - volumes: - - .:/var/www - - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf - depends_on: - - app - - mysql: - image: mysql:8 - container_name: laminas-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 - container_name: laminas-phpmyadmin - ports: - - 8081:80 - environment: - - PMA_HOST=${PMA_HOST} - depends_on: - - mysql -``` +### Implementing AdapterAwareInterface -#### Complete Example with Apache - -```yaml -version: "3.8" -services: - laminas: - container_name: laminas-app - build: - context: . - dockerfile: Dockerfile - ports: - - 8080:80 - volumes: - - .:/var/www - links: - - mysql:mysql - - mysql: - image: mysql:8 - container_name: laminas-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 - container_name: laminas-phpmyadmin - ports: - - 8081:80 - environment: - - PMA_HOST=${PMA_HOST} - depends_on: - - mysql -``` +Your service class must implement `AdapterAwareInterface`: -### Defining credentials +### Implementing AdapterAwareInterface in a service class + +```php +use PhpDb\Adapter\AdapterAwareInterface; +use PhpDb\Adapter\AdapterInterface; -The `docker-compose.yml` file uses ENV variables to define the credentials. +class MyDatabaseService implements AdapterAwareInterface +{ + private AdapterInterface $adapter; -Docker will read the ENV variables from a `.env` file. + public function setDbAdapter(AdapterInterface $adapter): void + { + $this->adapter = $adapter; + } -```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). \ No newline at end of file diff --git a/docs/book/application-integration/usage-in-a-mezzio-application.md b/docs/book/application-integration/usage-in-a-mezzio-application.md index 10efd3fc7..95e08f74d 100644 --- a/docs/book/application-integration/usage-in-a-mezzio-application.md +++ b/docs/book/application-integration/usage-in-a-mezzio-application.md @@ -1,28 +1,14 @@ # Usage in a Mezzio Application -The minimal installation for a Mezzio-based application doesn't include any database features. - -## When installing the Mezzio Skeleton Application - -While `Composer` is [installing the Mezzio Application](https://docs.mezzio.dev/mezzio/v3/getting-started/skeleton/), you can add the `phpdb` package after the skeleton is created. - -## Adding to an existing Mezzio Skeleton Application - -If the Mezzio application is already created, then use Composer to [add the phpdb](../index.md) package: - -```bash -composer require phpdb/phpdb -``` +For installation instructions, see [Installation](../index.md#installation). ## Service Configuration -Now that the phpdb package is installed, you need to configure the adapter through Mezzio's dependency injection container. - -### Configuring the adapter +Now that the phpdb packages are installed, you need to configure the adapter through Mezzio's dependency injection container. Mezzio uses PSR-11 containers and typically uses laminas-servicemanager or another DI container. The adapter configuration goes in your application's configuration files. -Create a configuration file `config/autoload/database.global.php` to define database settings: +Create a configuration file `config/autoload/database.global.php` to define database settings. ### Working with a SQLite database @@ -31,6 +17,8 @@ SQLite is a lightweight option to have the application working with a database. Here is an example of the configuration array for a SQLite database. Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: +### SQLite adapter configuration + ```php [ 'factories' => [ - Adapter::class => function ($container) { - return new Adapter([ - 'driver' => 'Pdo_Sqlite', + Adapter::class => function (ContainerInterface $container) { + $driver = new Sqlite([ 'database' => 'data/sample.sqlite', ]); + return new Adapter($driver, new SqlitePlatform()); }, ], 'aliases' => [ @@ -66,6 +57,8 @@ Here is an example of a configuration array for a MySQL database. Create `config/autoload/database.local.php` for environment-specific credentials: +### MySQL adapter configuration + ```php [ 'factories' => [ - Adapter::class => function ($container) { - return new Adapter([ - 'driver' => 'Pdo_Mysql', + Adapter::class => function (ContainerInterface $container) { + $driver = new Mysql([ 'database' => 'your_database_name', 'username' => 'your_mysql_username', 'password' => 'your_mysql_password', 'hostname' => 'localhost', 'charset' => 'utf8mb4', - 'driver_options' => [ - PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', - ], ]); + return new Adapter($driver, new MysqlPlatform()); }, ], 'aliases' => [ @@ -100,7 +93,9 @@ return [ ### Working with PostgreSQL database -For PostgreSQL support: +PostgreSQL support is coming soon. Once the `php-db/postgres` package is available: + +### PostgreSQL adapter configuration ```php [ 'factories' => [ - Adapter::class => function ($container) { - return new Adapter([ - 'driver' => 'Pdo_Pgsql', + Adapter::class => function (ContainerInterface $container) { + $driver = new Postgres([ 'database' => 'your_database_name', 'username' => 'your_pgsql_username', 'password' => 'your_pgsql_password', 'hostname' => 'localhost', 'port' => 5432, - 'charset' => 'utf8', ]); + return new Adapter($driver, new PostgresPlatform()); }, ], 'aliases' => [ @@ -140,6 +137,8 @@ Once you have configured an adapter, as in the above examples, you now have a `P Mezzio uses request handlers (also known as middleware) that receive dependencies through constructor injection: +### Request handler with database adapter injection + ```php [ 'factories' => [ - Adapter::class => function ($container) { - return new Adapter([ - 'driver' => $_ENV['DB_DRIVER'] ?? 'Pdo_Mysql', - 'database' => $_ENV['DB_DATABASE'] ?? 'myapp', - 'username' => $_ENV['DB_USERNAME'] ?? 'root', - 'password' => $_ENV['DB_PASSWORD'] ?? '', - 'hostname' => $_ENV['DB_HOSTNAME'] ?? 'localhost', - 'port' => $_ENV['DB_PORT'] ?? '3306', - 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', + Adapter::class => function (ContainerInterface $container) { + $dbType = $_ENV['DB_TYPE'] ?? 'sqlite'; + + if ($dbType === 'mysql') { + $driver = new Mysql([ + 'database' => $_ENV['DB_DATABASE'] ?? 'myapp', + 'username' => $_ENV['DB_USERNAME'] ?? 'root', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'hostname' => $_ENV['DB_HOSTNAME'] ?? 'localhost', + 'port' => (int) ($_ENV['DB_PORT'] ?? 3306), + 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', + ]); + return new Adapter($driver, new MysqlPlatform()); + } + + // Default to SQLite + $driver = new Sqlite([ + 'database' => $_ENV['DB_DATABASE'] ?? 'data/app.sqlite', ]); + return new Adapter($driver, new SqlitePlatform()); }, ], 'aliases' => [ @@ -419,365 +453,7 @@ return [ ## Running with Docker -When working with a MySQL database and when running the application with Docker, some files need to be added or adjusted. - -### Adding the MySQL extension to the PHP container - -#### Option 1: Nginx with PHP-FPM (Recommended) - -For an nginx-based setup with PHP-FPM, create a `Dockerfile`: - -```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 an nginx configuration file at `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; - } -} -``` - -Update your `docker-compose.yml` to include nginx: - -```yaml - nginx: - image: nginx:alpine - ports: - - "8080:80" - volumes: - - .:/var/www - - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf - depends_on: - - app -``` - -#### Option 2: Apache - -For an Apache-based setup, create a `Dockerfile`: - -```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 -``` - -### Adding the MySQL container - -Change the `docker-compose.yml` file to add a new container for MySQL: - -```yaml -version: "3.8" - -services: - app: - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:80" - volumes: - - .:/var/www - depends_on: - - mysql - environment: - - DB_DRIVER=Pdo_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} - -volumes: - mysql_data: -``` - -Though it is not the topic to explain how to write a `docker-compose.yml` file, a few details need to be highlighted: - -- The name of the container is `mysql`. -- MySQL database files will be persisted in a named volume `mysql_data`. -- SQL schemas will need to be added to the `./docker/mysql/init/` directory so that Docker will be able to build and populate the database(s). -- The MySQL docker image uses environment variables to set the database name, user, and passwords. -- The `depends_on` directive ensures MySQL starts before the application container. - -### Adding PostgreSQL Container - -For PostgreSQL instead of MySQL: - -```yaml -version: "3.8" - -services: - app: - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:80" - volumes: - - .:/var/www - depends_on: - - postgres - environment: - - DB_DRIVER=Pdo_Pgsql - - DB_DATABASE=${DB_DATABASE} - - DB_USERNAME=${DB_USERNAME} - - DB_PASSWORD=${DB_PASSWORD} - - DB_HOSTNAME=postgres - - DB_PORT=5432 - - 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} - -volumes: - postgres_data: -``` - -Update the `Dockerfile` to install the PostgreSQL extension: - -```dockerfile -RUN docker-php-ext-install pdo_pgsql -``` - -### Adding phpMyAdmin - -Optionally, you can also add a container for phpMyAdmin: - -```yaml - phpmyadmin: - image: phpmyadmin/phpmyadmin - ports: - - "8081:80" - depends_on: - - mysql - environment: - - PMA_HOST=mysql - - PMA_PORT=3306 -``` - -### Complete Docker Compose Example - -#### With Nginx (Recommended) - -Putting everything together with nginx: - -```yaml -version: "3.8" - -services: - app: - build: - context: . - dockerfile: Dockerfile - volumes: - - .:/var/www - depends_on: - - mysql - environment: - - DB_DRIVER=Pdo_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: -``` - -#### With Apache - -For Apache-based deployment: - -```yaml -version: "3.8" - -services: - app: - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:80" - volumes: - - .:/var/www - depends_on: - - mysql - environment: - - DB_DRIVER=Pdo_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: -``` - -### Defining credentials - -The `docker-compose.yml` file uses environment variables to define the credentials. - -Docker will read the environment variables from a `.env` file: - -```env -DB_DATABASE=mezzio_app -DB_USERNAME=appuser -DB_PASSWORD=apppassword -MYSQL_ROOT_PASSWORD=rootpassword -``` - -### Initiating the database schemas - -At build, if the volume is empty, Docker will create the MySQL database with any `.sql` files found in the `./docker/mysql/init/` directory. - -Create `docker/mysql/init/01-schema.sql`: - -```sql -USE mezzio_app; - -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); -``` - -Create `docker/mysql/init/02-seed.sql`: - -```sql -USE mezzio_app; - -INSERT INTO users (username, email, status) VALUES - ('alice', 'alice@example.com', 'active'), - ('bob', 'bob@example.com', 'active'), - ('charlie', 'charlie@example.com', 'inactive'); -``` - -If multiple `.sql` files are present, they are executed in alphanumeric order, which is why the files are prefixed with numbers. +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). ## Testing with Database @@ -787,6 +463,8 @@ For integration testing with a real database in Mezzio: Create `config/autoload/database.test.php`: +### Test database configuration with in-memory SQLite + ```php [ 'factories' => [ - Adapter::class => function ($container) { - return new Adapter([ - 'driver' => 'Pdo_Sqlite', + Adapter::class => function (ContainerInterface $container) { + $driver = new Sqlite([ 'database' => ':memory:', ]); + return new Adapter($driver, new SqlitePlatform()); }, ], 'aliases' => [ @@ -814,6 +495,8 @@ return [ ### Use in PHPUnit Tests +### PHPUnit test with database integration + ```php -
-

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..a41f92349 --- /dev/null +++ b/docs/book/index.md @@ -0,0 +1,137 @@ +# 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 core package via Composer: + +```bash +composer require php-db/phpdb +``` + +Additionally, install the driver package(s) for the database(s) you plan to use: + +```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 +``` + +### 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 \ No newline at end of file diff --git a/docs/book/metadata.md b/docs/book/metadata.md deleted file mode 100644 index 7f61fa73e..000000000 --- a/docs/book/metadata.md +++ /dev/null @@ -1,940 +0,0 @@ -# RDBMS Metadata - -`PhpDb\Metadata` is as 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: - -```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 -use PhpDb\Adapter\Adapter; -use PhpDb\Metadata\Source\Factory as MetadataSourceFactory; - -$adapter = new Adapter($config); -$metadata = MetadataSourceFactory::createSourceFromAdapter($adapter); -``` - -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 -$tableNames = $metadata->getTableNames(); -$columnNames = $metadata->getColumnNames('users'); -$schemas = $metadata->getSchemas(); -``` - -The other methods return value objects specific to the type queried: - -```php -$table = $metadata->getTable('users'); // Returns TableObject or ViewObject -$column = $metadata->getColumn('id', 'users'); // Returns ColumnObject -$constraint = $metadata->getConstraint('PRIMARY', 'users'); // Returns ConstraintObject -``` - -Note that `getTable()` and `getView()` can return either `TableObject` or -`ViewObject` depending on whether the database object is a table or a view. - -### Basic Example - -```php -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: - -``` -id INT NOT NULL -username VARCHAR NOT NULL -email VARCHAR NOT NULL -created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -bio TEXT NULL -``` - -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: - -``` -PRIMARY KEY (id) -FOREIGN KEY fk_orders_customers (customer_id) REFERENCES customers (id) -FOREIGN KEY fk_orders_products (product_id) REFERENCES products (id) -``` - -## 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 - -```php -class PhpDb\Metadata\Object\TableObject -{ - public function __construct($name); - public function setColumns(array $columns); - public function getColumns(); - public function setConstraints($constraints); - public function getConstraints(); - public function setName($name); - public function getName(); -} -``` - -### ColumnObject - -```php -class PhpDb\Metadata\Object\ColumnObject -{ - public function __construct($name, $tableName, $schemaName = null); - public function setName($name); - public function getName(); - public function getTableName(); - public function setTableName($tableName); - public function setSchemaName($schemaName); - public function getSchemaName(); - public function getOrdinalPosition(); - public function setOrdinalPosition($ordinalPosition); - public function getColumnDefault(); - public function setColumnDefault($columnDefault); - public function getIsNullable(); - public function setIsNullable($isNullable); - public function isNullable(); - public function getDataType(); - public function setDataType($dataType); - public function getCharacterMaximumLength(); - public function setCharacterMaximumLength($characterMaximumLength); - public function getCharacterOctetLength(); - public function setCharacterOctetLength($characterOctetLength); - public function getNumericPrecision(); - public function setNumericPrecision($numericPrecision); - public function getNumericScale(); - public function setNumericScale($numericScale); - public function getNumericUnsigned(); - public function setNumericUnsigned($numericUnsigned); - public function isNumericUnsigned(); - public function getErratas(); - public function setErratas(array $erratas); - public function getErrata($errataName); - public function setErrata($errataName, $errataValue); -} -``` - -### ConstraintObject - -```php -class PhpDb\Metadata\Object\ConstraintObject -{ - public function __construct($name, $tableName, $schemaName = null); - public function setName($name); - public function getName(); - public function setSchemaName($schemaName); - public function getSchemaName(); - public function getTableName(); - public function setTableName($tableName); - public function setType($type); - public function getType(); - public function hasColumns(); - public function getColumns(); - public function setColumns(array $columns); - public function getReferencedTableSchema(); - public function setReferencedTableSchema($referencedTableSchema); - public function getReferencedTableName(); - public function setReferencedTableName($referencedTableName); - public function getReferencedColumns(); - public function setReferencedColumns(array $referencedColumns); - public function getMatchOption(); - public function setMatchOption($matchOption); - public function getUpdateRule(); - public function setUpdateRule($updateRule); - public function getDeleteRule(); - public function setDeleteRule($deleteRule); - public function getCheckClause(); - public function setCheckClause($checkClause); - public function isPrimaryKey(); - public function isUnique(); - public function isForeignKey(); - public function isCheck(); - -} -``` - -### ViewObject - -The `ViewObject` extends `AbstractTableObject` and represents database views. It -includes all methods from `TableObject` plus view-specific properties: - -```php -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 -$view = $metadata->getView('active_users'); -echo $view->getViewDefinition(); -``` - -Outputs: - -```sql -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 -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 -$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: - -``` -customer_id -> customers.id - ON UPDATE: CASCADE - ON DELETE: RESTRICT -``` - -### TriggerObject - -```php -class PhpDb\Metadata\Object\TriggerObject -{ - public function getName(); - public function setName($name); - public function getEventManipulation(); - public function setEventManipulation($eventManipulation); - public function getEventObjectCatalog(); - public function setEventObjectCatalog($eventObjectCatalog); - public function getEventObjectSchema(); - public function setEventObjectSchema($eventObjectSchema); - public function getEventObjectTable(); - public function setEventObjectTable($eventObjectTable); - public function getActionOrder(); - public function setActionOrder($actionOrder); - public function getActionCondition(); - public function setActionCondition($actionCondition); - public function getActionStatement(); - public function setActionStatement($actionStatement); - public function getActionOrientation(); - public function setActionOrientation($actionOrientation); - public function getActionTiming(); - public function setActionTiming($actionTiming); - public function getActionReferenceOldTable(); - public function setActionReferenceOldTable($actionReferenceOldTable); - public function getActionReferenceNewTable(); - public function setActionReferenceNewTable($actionReferenceNewTable); - public function getActionReferenceOldRow(); - public function setActionReferenceOldRow($actionReferenceOldRow); - public function getActionReferenceNewRow(); - public function setActionReferenceNewRow($actionReferenceNewRow); - public function getCreated(); - public function setCreated($created); -} -``` - -## Advanced Usage - -### Working with Schemas - -The `getSchemas()` method returns all available schema names in the database: - -```php -$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 -$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 -$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: - -```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()); -} -``` - -Include views when getting table names: - -```php -$allTables = $metadata->getTableNames(null, true); -``` - -### Working with Triggers - -Retrieve all triggers and their details: - -```php -$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 -$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: - -``` -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 -$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; -} -``` - -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 -$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; - } -} -``` - -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'); -``` - -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 -$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'); -``` - -## Error Handling and Exceptions - -All metadata methods throw the base PHP `\Exception` when the requested object is -not found. Note that while PhpDb has its own exception hierarchy -(`PhpDb\Exception\ExceptionInterface`), the Metadata component currently uses the -base Exception class. - -### Catching Metadata Exceptions - -```php -use Exception; - -try { - $table = $metadata->getTable('nonexistent_table'); -} catch (Exception $e) { - printf("Table not found: %s\n", $e->getMessage()); -} -``` - -### Common Exception Scenarios - -**Table not found:** - -```php -try { - $table = $metadata->getTable('invalid_table'); -} catch (Exception $e) { - // Message: Table "invalid_table" does not exist -} -``` - -**View not found:** - -```php -try { - $view = $metadata->getView('invalid_view'); -} catch (Exception $e) { - // Message: View "invalid_view" does not exist -} -``` - -**Column not found:** - -```php -try { - $column = $metadata->getColumn('invalid_column', 'users'); -} catch (Exception $e) { - // Message: A column by that name was not found. -} -``` - -**Constraint not found:** - -```php -try { - $constraint = $metadata->getConstraint('invalid_constraint', 'users'); -} catch (Exception $e) { - // Message: Cannot find a constraint by that name in this table -} -``` - -**Trigger not found:** - -```php -try { - $trigger = $metadata->getTrigger('invalid_trigger'); -} catch (Exception $e) { - // Message: Trigger "invalid_trigger" does not exist -} -``` - -**Unsupported table type:** - -```php -try { - $table = $metadata->getTable('user_view'); -} catch (Exception $e) { - if (str_contains($e->getMessage(), 'unsupported type')) { - // This object exists but is not a supported table type - } -} -``` - -### Best Practices for Exception Handling - -Check for existence before accessing metadata: - -```php -$tableNames = $metadata->getTableNames(); -if (! in_array('users', $tableNames, true)) { - throw new RuntimeException('Required table "users" does not exist'); -} - -$table = $metadata->getTable('users'); -``` - -Catch and log exceptions for better debugging: - -```php -try { - $column = $metadata->getColumn('email', 'users'); -} catch (Exception $e) { - $logger->error('Failed to retrieve column metadata', [ - 'column' => 'email', - 'table' => 'users', - 'error' => $e->getMessage(), - ]); - throw $e; -} -``` - -## Common Patterns and Best Practices - -### Finding All Tables with a Specific Column - -```php -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'); -``` - -### Discovering Foreign Key Relationships - -```php -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; -} -``` - -### Generating Schema Documentation - -```php -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()}** ({$constraint->getType()})\n"; - if ($constraint->hasColumns()) { - $doc .= " - Columns: " . implode(', ', $constraint->getColumns()) . "\n"; - } - if ($constraint->isForeignKey()) { - $doc .= " - References: {$constraint->getReferencedTableName()}"; - $doc .= "(" . implode(', ', $constraint->getReferencedColumns()) . ")\n"; - $doc .= " - ON UPDATE: {$constraint->getUpdateRule()}\n"; - $doc .= " - ON DELETE: {$constraint->getDeleteRule()}\n"; - } - } - - return $doc; -} -``` - -### Comparing Schemas Across Environments - -```php -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; -} -``` - -## Troubleshooting - -### Table Not Found Errors - -Always check if a table exists before trying to access it: - -```php -$tableNames = $metadata->getTableNames(); -if (in_array('users', $tableNames, true)) { - $table = $metadata->getTable('users'); -} else { - echo 'Table does not exist'; -} -``` - -### Performance with Large Schemas - -When working with databases that have hundreds of tables, use `get*Names()` -methods instead of retrieving full objects: - -```php -$tableNames = $metadata->getTableNames(); -foreach ($tableNames as $tableName) { - $columnNames = $metadata->getColumnNames($tableName); -} -``` - -This is more efficient than: - -```php -$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 -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 -$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/examples.md b/docs/book/metadata/examples.md new file mode 100644 index 000000000..47efc7b34 --- /dev/null +++ b/docs/book/metadata/examples.md @@ -0,0 +1,272 @@ +# Metadata Examples and Troubleshooting + +## Common Patterns and Best Practices + +### Finding All Tables with a Specific Column + +```php +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'); +``` + +### Discovering Foreign Key Relationships + +```php +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; +} +``` + +### Generating Schema Documentation + +```php +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()}** ({$constraint->getType()})\n"; + if ($constraint->hasColumns()) { + $doc .= " - Columns: " . implode(', ', $constraint->getColumns()) . "\n"; + } + if ($constraint->isForeignKey()) { + $doc .= " - References: {$constraint->getReferencedTableName()}"; + $doc .= "(" . implode(', ', $constraint->getReferencedColumns()) . ")\n"; + $doc .= " - ON UPDATE: {$constraint->getUpdateRule()}\n"; + $doc .= " - ON DELETE: {$constraint->getDeleteRule()}\n"; + } + } + + return $doc; +} +``` + +### Comparing Schemas Across Environments + +```php +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; +} +``` + +### Generating Entity Classes from Metadata + +```php +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} \${$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 in this table | +| `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: + +### Efficient Metadata Access for Large Schemas + +```php +$tableNames = $metadata->getTableNames(); +foreach ($tableNames as $tableName) { + $columnNames = $metadata->getColumnNames($tableName); +} +``` + +This is more efficient than: + +### Inefficient Metadata Access Pattern + +```php +$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: + +### Verifying Schema Access Permissions + +```php +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: + +### Implementing Metadata Caching + +```php +$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..b50fd4dc6 --- /dev/null +++ b/docs/book/metadata/intro.md @@ -0,0 +1,399 @@ +# 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: + +### Creating Metadata from an Adapter + +```php +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: + +### Getting Names of Database Objects + +```php +$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 +$table = $metadata->getTable('users'); // Returns TableObject or ViewObject +$column = $metadata->getColumn('id', 'users'); // Returns ColumnObject +$constraint = $metadata->getConstraint('PRIMARY', 'users'); // Returns ConstraintObject +``` + +Note that `getTable()` and `getView()` can return either `TableObject` or +`ViewObject` depending on whether the database object is a table or a view. + +### Basic Example + +```php +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: + +``` +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: + +``` +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: + +### Listing All Schemas and Their Tables + +```php +$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: + +### Specifying a Schema Explicitly + +```php +$tables = $metadata->getTableNames('production'); +$columns = $metadata->getColumns('users', 'production'); +$constraints = $metadata->getConstraints('users', 'production'); +``` + +### Working with Views + +Retrieve all views in the current schema: + +### Retrieving View Information + +```php +$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: + +### Retrieving Trigger Information + +```php +$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()`: + +### Examining Foreign Key Details + +```php +$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: + +``` +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: + +### Examining Column Data Types + +```php +$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: + +### Using the Errata System + +```php +$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: + +### Using Method Chaining with Value Objects + +```php +$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..3ee61ee2f --- /dev/null +++ b/docs/book/metadata/objects.md @@ -0,0 +1,317 @@ +# 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: + +### TableObject Class Definition + +```php +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: + +### ColumnObject Class Definition + +```php +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; + public function isNumericUnsigned(): ?bool; // Alias for getNumericUnsigned() + + 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: + +### ConstraintObject Class Definition + +```php +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: + +### ViewObject Class Definition + +```php +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: + +### Retrieving View Definition + +```php +$view = $metadata->getView('active_users'); +echo $view->getViewDefinition(); +``` + +Outputs: + +### View Definition SQL Output + +```sql +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: + +### ConstraintKeyObject Class Definition + +```php +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()`: + +### Iterating Through Foreign Key Constraint Details + +```php +$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: + +### Foreign Key Constraint Output + +``` +customer_id -> customers.id + ON UPDATE: CASCADE + ON DELETE: RESTRICT +``` + +## TriggerObject + +All setter methods return `static` for fluent interface support: + +### TriggerObject Class Definition + +```php +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..5ef56793f --- /dev/null +++ b/docs/book/profiler.md @@ -0,0 +1,426 @@ +# 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: + +### Get the Last Profile + +```php +$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 +// ] +``` + +### Get All Profiles + +```php +// 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 SQL query that was executed | +| `parameters` | `ParameterContainer|null` | The bound parameters (if any) | +| `start` | `float` | Unix timestamp with microseconds (query start) | +| `end` | `float` | Unix timestamp with microseconds (query end) | +| `elapse` | `float` | Total 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), // Convert to ms + '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; + } +} +``` \ No newline at end of file diff --git a/docs/book/result-set.md b/docs/book/result-set.md deleted file mode 100644 index 0ecee7e59..000000000 --- a/docs/book/result-set.md +++ /dev/null @@ -1,889 +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. - -### Example Data - -Throughout this documentation, we'll use this sample dataset: - -```php -$sampleData = [ - ['id' => 1, 'first_name' => 'Alice', 'last_name' => 'Johnson', 'email' => 'alice@example.com'], - ['id' => 2, 'first_name' => 'Bob', 'last_name' => 'Smith', 'email' => 'bob@example.com'], - ['id' => 3, 'first_name' => 'Charlie', 'last_name' => 'Brown', 'email' => 'charlie@example.com'], - ['id' => 4, 'first_name' => 'Diana', 'last_name' => 'Prince', 'email' => 'diana@example.com'], -]; -``` - -And this UserEntity class for hydration examples: - -```php -class UserEntity -{ - protected int $id; - protected string $first_name; - protected string $last_name; - protected string $email; - - public function getId(): int - { - return $this->id; - } - - public function getFirstName(): string - { - return $this->first_name; - } - - public function getLastName(): string - { - return $this->last_name; - } - - public function getEmail(): string - { - return $this->email; - } - - public function setId(int $id): void - { - $this->id = $id; - } - - public function setFirstName(string $firstName): void - { - $this->first_name = $firstName; - } - - public function setLastName(string $lastName): void - { - $this->last_name = $lastName; - } - - public function setEmail(string $email): void - { - $this->email = $email; - } -} -``` - -### 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 -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; -} -``` - -## 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; - -$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. - -## ResultSet API Reference - -### ResultSet Class - -The `ResultSet` class extends `AbstractResultSet` and provides row data as either -`ArrayObject` instances or plain arrays. - -```php -namespace PhpDb\ResultSet; - -use ArrayObject; - -class ResultSet extends AbstractResultSet -{ - public const TYPE_ARRAYOBJECT = 'arrayobject'; - public const TYPE_ARRAY = 'array'; - - public function __construct( - string $returnType = self::TYPE_ARRAYOBJECT, - ?ArrayObject $arrayObjectPrototype = null - ); - - public function setArrayObjectPrototype(ArrayObject $arrayObjectPrototype): static; - public function getArrayObjectPrototype(): ArrayObject; - public function getReturnType(): string; -} -``` - -#### Constructor Parameters - -**`$returnType`** - Controls how rows are returned: -- `ResultSet::TYPE_ARRAYOBJECT` (default) - Returns rows as ArrayObject instances -- `ResultSet::TYPE_ARRAY` - Returns rows as plain PHP arrays - -**`$arrayObjectPrototype`** - Custom ArrayObject prototype for row objects (only used with TYPE_ARRAYOBJECT) - -#### Return Type Modes - -**ArrayObject Mode** (default): - -```php -$resultSet = new ResultSet(ResultSet::TYPE_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 -$resultSet = new ResultSet(ResultSet::TYPE_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 -namespace PhpDb\ResultSet; - -use Laminas\Hydrator\HydratorInterface; - -class HydratingResultSet extends AbstractResultSet -{ - public function __construct( - ?HydratorInterface $hydrator = null, - ?object $objectPrototype = null - ); - - public function setHydrator(HydratorInterface $hydrator): static; - public function getHydrator(): HydratorInterface; - - public function setObjectPrototype(object $objectPrototype): static; - public function getObjectPrototype(): ?object; - - public function current(): ?object; - public function toArray(): array; -} -``` - -#### Constructor Defaults - -If no hydrator is provided, `ArraySerializableHydrator` is used by default: - -```php -$resultSet = new HydratingResultSet(); -``` - -If no object prototype is provided, `ArrayObject` is used: - -```php -$resultSet = new HydratingResultSet(new ReflectionHydrator()); -``` - -#### Runtime Hydrator Changes - -You can change the hydration strategy at runtime: - -```php -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 -$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 -$resultSet = new ResultSet(); -$resultSet->initialize($result); - -foreach ($resultSet as $row) { - break; -} - -$resultSet->buffer(); -``` - -Throws: - -``` -RuntimeException: Buffering must be enabled before iteration is started -``` - -### isBuffered() Method - -Checks if the result set is currently buffered: - -```php -$resultSet = new ResultSet(); -$resultSet->initialize($result); - -var_dump($resultSet->isBuffered()); - -$resultSet->buffer(); - -var_dump($resultSet->isBuffered()); -``` - -Outputs: - -``` -bool(false) -bool(true) -``` - -### Automatic Buffering - -Arrays and certain data sources are automatically buffered: - -```php -$resultSet = new ResultSet(); -$resultSet->initialize([ - ['id' => 1, 'name' => 'Alice'], - ['id' => 2, 'name' => 'Bob'], -]); - -var_dump($resultSet->isBuffered()); -``` - -Outputs: - -``` -bool(true) -``` - -## ArrayObject Access Patterns - -When using `TYPE_ARRAYOBJECT` mode (default), rows support both property and array access: - -```php -$resultSet = new ResultSet(ResultSet::TYPE_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 -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(ResultSet::TYPE_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 -$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 -use PhpDb\Adapter\Adapter; -use PhpDb\ResultSet\ResultSet; - -$customResultSet = new ResultSet(ResultSet::TYPE_ARRAY); - -$adapter = new Adapter([ - 'driver' => 'Pdo_Mysql', - 'database' => 'mydb', -], $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 -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()); -} -``` - -## Data Source Types - -The `initialize()` method accepts multiple data source types: - -### Arrays - -```php -$resultSet = new ResultSet(); -$resultSet->initialize([ - ['id' => 1, 'name' => 'Alice'], - ['id' => 2, 'name' => 'Bob'], -]); -``` - -Arrays are automatically buffered and allow multiple iterations. - -### Iterator - -```php -$resultSet = new ResultSet(); -$resultSet->initialize(new ArrayIterator($data)); -``` - -### IteratorAggregate - -```php -$resultSet = new ResultSet(); -$resultSet->initialize($iteratorAggregate); -``` - -### ResultInterface (Driver Result) - -```php -$result = $statement->execute(); -$resultSet = new ResultSet(); -$resultSet->initialize($result); -``` - -This is the most common use case when working with database queries. - -## 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 -$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 -$resultSet = $adapter->query('SELECT * FROM huge_table'); - -foreach ($resultSet as $row) { - processRow($row); -} -``` - -### Memory Efficiency Comparison - -```php -$arrayMode = new ResultSet(ResultSet::TYPE_ARRAY); -$arrayMode->initialize($result); - -$arrayObjectMode = new ResultSet(ResultSet::TYPE_ARRAYOBJECT); -$arrayObjectMode->initialize($result); -``` - -`TYPE_ARRAY` uses less memory per row than `TYPE_ARRAYOBJECT` because it avoids -object overhead. - -## 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()); -} -``` - -**ArrayObject without exchangeArray() method:** - -```php -try { - $invalidPrototype = new ArrayObject(); - unset($invalidPrototype->exchangeArray); - $resultSet->setArrayObjectPrototype($invalidPrototype); -} catch (InvalidArgumentException $e) { - printf("Error: %s\n", $e->getMessage()); -} -``` - -**Non-object passed to HydratingResultSet:** - -```php -try { - $resultSet->setObjectPrototype('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"); -} -``` - -## Advanced Usage - -### Multiple Hydrators - -Switch hydrators based on context: - -```php -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 -$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 -$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 -$resultSet = new ResultSet(); -$resultSet->initialize($result); - -$firstRow = $resultSet->current(); -``` - -This returns the first row without advancing the iterator. - -## 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); -} -``` - -### Checking for Empty Results - -```php -$resultSet = $adapter->query('SELECT * FROM users WHERE id = ?', [999]); - -if ($resultSet->count() === 0) { - printf("No users found\n"); -} -``` - -## Troubleshooting - -### Cannot Rewind After Iteration - -**Problem:** Trying to iterate twice fails - -**Solution:** Buffer the result set before first iteration - -```php -$resultSet->buffer(); - -foreach ($resultSet as $row) { - processRow($row); -} - -$resultSet->rewind(); - -foreach ($resultSet as $row) { - processRowAgain($row); -} -``` - -### Out of Memory Errors - -**Problem:** Large result sets cause memory exhaustion - -**Solution:** Use TYPE_ARRAY mode and avoid buffering - -```php -$resultSet = new ResultSet(ResultSet::TYPE_ARRAY); -$resultSet->initialize($result); - -foreach ($resultSet as $row) { - processRow($row); -} -``` - -### Property Access Not Working - -**Problem:** `$row->column_name` returns null - -**Solution:** Ensure using TYPE_ARRAYOBJECT mode (default) - -```php -$resultSet = new ResultSet(ResultSet::TYPE_ARRAYOBJECT); -``` - -Or use array access instead: - -```php -$value = $row['column_name']; -``` - -### Hydration Failures - -**Problem:** Object properties not populated - -**Solution:** Ensure hydrator matches object structure - -```php -use Laminas\Hydrator\ClassMethodsHydrator; -use Laminas\Hydrator\ReflectionHydrator; - -$resultSet = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); -``` - -Use `ReflectionHydrator` for protected/private properties, `ClassMethodsHydrator` -for public setters. - -### Invalid Data Source Exception - -**Problem:** `InvalidArgumentException` on initialize() - -**Solution:** Ensure data source is array, Iterator, IteratorAggregate, or ResultInterface - -```php -$resultSet->initialize($validDataSource); -``` diff --git a/docs/book/result-set/advanced.md b/docs/book/result-set/advanced.md new file mode 100644 index 000000000..697b6116f --- /dev/null +++ b/docs/book/result-set/advanced.md @@ -0,0 +1,502 @@ +# 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. + +### ResultSet Class Definition + +```php +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: + +### ResultSetReturnType Definition + +```php +namespace PhpDb\ResultSet; + +enum ResultSetReturnType: string +{ + case ArrayObject = 'arrayobject'; + case Array = 'array'; +} +``` + +### Using ResultSetReturnType + +```php +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): + +### ArrayObject Mode Example + +```php +$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:** + +### Array Mode Example + +```php +$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`: + +### HydratingResultSet Class Definition + +```php +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: + +### Default Hydrator + +```php +$resultSet = new HydratingResultSet(); +``` + +If no object prototype is provided, `ArrayObject` is used: + +### Default Object Prototype + +```php +$resultSet = new HydratingResultSet(new ReflectionHydrator()); +``` + +#### Runtime Hydrator Changes + +You can change the hydration strategy at runtime: + +### Changing Hydrator at Runtime + +```php +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: + +### Buffering for Multiple Iterations + +```php +$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`: + +### Buffer After Iteration Error + +```php +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +foreach ($resultSet as $row) { + break; +} + +$resultSet->buffer(); +``` + +Throws: + +``` +RuntimeException: Buffering must be enabled before iteration is started +``` + +### isBuffered() Method + +Checks if the result set is currently buffered: + +### Checking Buffer Status + +```php +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +var_dump($resultSet->isBuffered()); + +$resultSet->buffer(); + +var_dump($resultSet->isBuffered()); +``` + +Outputs: + +``` +bool(false) +bool(true) +``` + +### Automatic Buffering + +Arrays and certain data sources are automatically buffered: + +### Array Data Source Auto-Buffering + +```php +$resultSet = new ResultSet(); +$resultSet->initialize([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], +]); + +var_dump($resultSet->isBuffered()); +``` + +Outputs: + +``` +bool(true) +``` + +## ArrayObject Access Patterns + +When using ArrayObject mode (default), rows support both property and array access: + +### Property and Array Access + +```php +$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: + +### Custom Row Class with Helper Methods + +```php +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: + +### Independent Query Results + +```php +$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: + +### Custom Adapter Prototype + +```php +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: + +### TableGateway with HydratingResultSet + +```php +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: + +### Buffering for Count and Multiple Passes + +```php +$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: + +### Streaming Large Result Sets + +```php +$resultSet = $adapter->query('SELECT * FROM huge_table'); + +foreach ($resultSet as $row) { + processRow($row); +} +``` + +### Memory Efficiency Comparison + +### Comparing Array vs ArrayObject Mode + +```php +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: + +### Conditional Hydrator Selection + +```php +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: + +### Using toArray() + +```php +$resultSet = new ResultSet(); +$resultSet->initialize($result); + +$allRows = $resultSet->toArray(); + +printf("Found %d rows\n", count($allRows)); +``` + +With HydratingResultSet, `toArray()` uses the hydrator's extractor: + +### toArray() with HydratingResultSet + +```php +$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: + +### Getting First Row with current() + +```php +$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..744f7a78c --- /dev/null +++ b/docs/book/result-set/examples.md @@ -0,0 +1,315 @@ +# 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); +} +``` + +### Checking for Empty Results + +```php +$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 +{ + protected string $first_name; // Matches column name + public function setFirstName($value) {} // For ClassMethodsHydrator +} +``` + +### 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); +} +``` + +### Use Generators for Transformation + +```php +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..94b4aab06 --- /dev/null +++ b/docs/book/result-set/intro.md @@ -0,0 +1,154 @@ +# 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: + +### AbstractResultSet API + +```php +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: + +### Installing laminas-hydrator + +```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: + +### Using HydratingResultSet with ReflectionHydrator + +```php +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()); +``` \ No newline at end of file diff --git a/docs/book/row-gateway.md b/docs/book/row-gateway.md index 5f11f0860..b0cd40c82 100644 --- a/docs/book/row-gateway.md +++ b/docs/book/row-gateway.md @@ -1,15 +1,10 @@ # 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,6 +24,8 @@ standalone, you need an `Adapter` instance and a set of data to work with. The following demonstrates a basic use case. +### Standalone RowGateway Usage + ```php use PhpDb\RowGateway\RowGateway; @@ -57,6 +54,8 @@ In that paradigm, `select()` operations will produce a `ResultSet` that iterates As an example: +### Using RowGateway with TableGateway + ```php use PhpDb\TableGateway\Feature\RowGatewayFeature; use PhpDb\TableGateway\TableGateway; @@ -77,6 +76,8 @@ essentially making them behave similarly to the pattern), pass a prototype object implementing the `RowGatewayInterface` to the `RowGatewayFeature` constructor instead of a primary key: +### Custom ActiveRecord-Style Implementation + ```php use PhpDb\TableGateway\Feature\RowGatewayFeature; use PhpDb\TableGateway\TableGateway; diff --git a/docs/book/sql-ddl.md b/docs/book/sql-ddl.md deleted file mode 100644 index da6710ef4..000000000 --- a/docs/book/sql-ddl.md +++ /dev/null @@ -1,203 +0,0 @@ -# DDL Abstraction - -`PhpDb\Sql\Ddl` is a sub-component of `PhpDb\Sql` allowing creation of DDL -(Data Definition Language) SQL statements. When combined with a platform -specific `PhpDb\Sql\Sql` object, DDL objects are capable of producing -platform-specific `CREATE TABLE` statements, with specialized data types, -constraints, and indexes for a database/schema. - -The following platforms have platform specializations for DDL: - -- MySQL -- All databases compatible with ANSI SQL92 - -## Creating Tables - -Like `PhpDb\Sql` objects, each statement type is represented by a class. For -example, `CREATE TABLE` is modeled by the `CreateTable` class; this is likewise -the same for `ALTER TABLE` (as `AlterTable`), and `DROP TABLE` (as -`DropTable`). You can create instances using a number of approaches: - -```php -use PhpDb\Sql\Ddl; -use PhpDb\Sql\TableIdentifier; - -$table = new Ddl\CreateTable(); - -// With a table name: -$table = new Ddl\CreateTable('bar'); - -// With a schema name "foo": -$table = new Ddl\CreateTable(new TableIdentifier('bar', 'foo')); - -// Optionally, as a temporary table: -$table = new Ddl\CreateTable('bar', true); -``` - -You can also set the table after instantiation: - -```php -$table->setTable('bar'); -``` - -Currently, columns are added by creating a column object (described in the -[data type table below](#currently-supported-data-types)): - -```php -use PhpDb\Sql\Ddl\Column; - -$table->addColumn(new Column\Integer('id')); -$table->addColumn(new Column\Varchar('name', 255)); -``` - -Beyond adding columns to a table, you may also add constraints: - -```php -use PhpDb\Sql\Ddl\Constraint; - -$table->addConstraint(new Constraint\PrimaryKey('id')); -$table->addConstraint( - new Constraint\UniqueKey(['name', 'foo'], 'my_unique_key') -); -``` - -You can also use the `AUTO_INCREMENT` attribute for MySQL: - -```php -use PhpDb\Sql\Ddl\Column; - -$column = new Column\Integer('id'); -$column->setOption('AUTO_INCREMENT', true); -``` - -## Altering Tables - -Similar to `CreateTable`, you may also use `AlterTable` instances: - -```php -use PhpDb\Sql\Ddl; -use PhpDb\Sql\TableIdentifier; - -$table = new Ddl\AlterTable(); - -// With a table name: -$table = new Ddl\AlterTable('bar'); - -// With a schema name "foo": -$table = new Ddl\AlterTable(new TableIdentifier('bar', 'foo')); - -// Optionally, as a temporary table: -$table = new Ddl\AlterTable('bar', true); -``` - -The primary difference between a `CreateTable` and `AlterTable` is that the -`AlterTable` takes into account that the table and its assets already exist. -Therefore, while you still have `addColumn()` and `addConstraint()`, you will -also have the ability to *alter* existing columns: - -```php -use PhpDb\Sql\Ddl\Column; - -$table->changeColumn('name', Column\Varchar('new_name', 50)); -``` - -You may also *drop* existing columns or constraints: - -```php -$table->dropColumn('foo'); -$table->dropConstraint('my_index'); -``` - -## Dropping Tables - -To drop a table, create a `DropTable` instance: - -```php -use PhpDb\Sql\Ddl; -use PhpDb\Sql\TableIdentifier; - -// With a table name: -$drop = new Ddl\DropTable('bar'); - -// With a schema name "foo": -$drop = new Ddl\DropTable(new TableIdentifier('bar', 'foo')); -``` - -## Executing DDL Statements - -After a DDL statement object has been created and configured, at some point you -will need to execute the statement. This requires an `Adapter` instance and a -properly seeded `Sql` instance. - -The workflow looks something like this, with `$ddl` being a `CreateTable`, -`AlterTable`, or `DropTable` instance: - -```php -use PhpDb\Sql\Sql; - -// Existence of $adapter is assumed. -$sql = new Sql($adapter); - -$adapter->query( - $sql->buildSqlString($ddl), - $adapter::QUERY_MODE_EXECUTE -); -``` - -By passing the `$ddl` object through the `$sql` instance's -`getSqlStringForSqlObject()` method, we ensure that any platform specific -specializations/modifications are utilized to create a platform specific SQL -statement. - -Next, using the constant `PhpDb\Adapter\Adapter::QUERY_MODE_EXECUTE` ensures -that the SQL statement is not prepared, as most DDL statements on most -platforms cannot be prepared, only executed. - -## Currently Supported Data Types - -These types exist in the `PhpDb\Sql\Ddl\Column` namespace. Data types must -implement `PhpDb\Sql\Ddl\Column\ColumnInterface`. - -In alphabetical order: - -Type | Arguments For Construction ------------------|--------------------------- -BigInteger | `$name`, `$nullable = false`, `$default = null`, `array $options = array()` -Binary | `$name`, `$length`, `nullable = false`, `$default = null`, `array $options = array()` -Blob | `$name`, `$length`, `nullable = false`, `$default = null`, `array $options = array()` -Boolean | `$name` -Char | `$name`, `length` -Column (generic) | `$name = null` -Date | `$name` -DateTime | `$name` -Decimal | `$name`, `$precision`, `$scale = null` -Float | `$name`, `$digits`, `$decimal` (Note: this class is deprecated as of 2.4.0; use Floating instead) -Floating | `$name`, `$digits`, `$decimal` -Integer | `$name`, `$nullable = false`, `default = null`, `array $options = array()` -Text | `$name`, `$length`, `nullable = false`, `$default = null`, `array $options = array()` -Time | `$name` -Timestamp | `$name` -Varbinary | `$name`, `$length` -Varchar | `$name`, `$length` - -Each of the above types can be utilized in any place that accepts a `Column\ColumnInterface` -instance. Currently, this is primarily in `CreateTable::addColumn()` and `AlterTable`'s -`addColumn()` and `changeColumn()` methods. - -## Currently Supported Constraint Types - -These types exist in the `PhpDb\Sql\Ddl\Constraint` namespace. Data types -must implement `PhpDb\Sql\Ddl\Constraint\ConstraintInterface`. - -In alphabetical order: - -Type | Arguments For Construction ------------|--------------------------- -Check | `$expression`, `$name` -ForeignKey | `$name`, `$column`, `$referenceTable`, `$referenceColumn`, `$onDeleteRule = null`, `$onUpdateRule = null` -PrimaryKey | `$columns` -UniqueKey | `$column`, `$name = null` - -Each of the above types can be utilized in any place that accepts a -`Column\ConstraintInterface` instance. Currently, this is primarily in -`CreateTable::addConstraint()` and `AlterTable::addConstraint()`. diff --git a/docs/book/sql-ddl/advanced.md b/docs/book/sql-ddl/advanced.md new file mode 100644 index 000000000..d62053610 --- /dev/null +++ b/docs/book/sql-ddl/advanced.md @@ -0,0 +1,472 @@ +# 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: + +### Disabling Foreign Key Checks for Bulk Operations + +```php +// 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; + } +} +``` \ No newline at end of file diff --git a/docs/book/sql-ddl/alter-drop.md b/docs/book/sql-ddl/alter-drop.md new file mode 100644 index 000000000..66ff0f24c --- /dev/null +++ b/docs/book/sql-ddl/alter-drop.md @@ -0,0 +1,522 @@ +# Modifying and Dropping Tables + +## AlterTable + +The `AlterTable` class represents an `ALTER TABLE` statement. It provides methods to modify existing table structures. + +### Basic AlterTable Creation + +```php +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. + +### Basic Drop Table + +```php +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" +``` + +### Schema-Qualified Drop + +```php +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: + +### Example of Platform-Agnostic DDL Code + +```php +// 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: + +### Using Platform-Specific Column Options + +```php +// 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 + +### Detecting Database Platform at Runtime + +```php +// 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: + +### Using getRawState() to Inspect DDL Configuration + +```php +$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: + +### Creating and Using Table Identifiers + +```php +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 + +### Configuring Nullable Columns + +```php +// 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 + +### Configuring Default Column Values + +```php +// 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 + +### Example of Fluent Column Configuration + +```php +$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 + +### Example of Fluent Table Construction + +```php +$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..551e3a37f --- /dev/null +++ b/docs/book/sql-ddl/columns.md @@ -0,0 +1,543 @@ +# Column Types Reference + +All column types are in the `PhpDb\Sql\Ddl\Column` namespace and implement `ColumnInterface`. + +## Numeric Types + +### Integer + +Standard integer column. + +### Creating Integer Columns + +```php +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:** `__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). + +### Creating BigInteger Columns + +```php +use PhpDb\Sql\Ddl\Column\BigInteger; + +$column = new BigInteger('large_number'); +$column = new BigInteger('id', false, null, ['length' => 20]); +``` + +**Constructor:** `__construct($name, $nullable = false, $default = null, array $options = [])` + +### Decimal + +Fixed-point decimal numbers with precision and scale. + +### Creating Decimal Columns with Precision and Scale + +```php +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. + +### Creating Floating Point Columns + +```php +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. + +### Creating Varchar Columns + +```php +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. + +### Creating Fixed-Length Char Columns + +```php +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. + +### Creating Text Columns + +```php +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:** `__construct($name, $length = null, $nullable = false, $default = null, array $options = [])` + +## Binary Types + +### Binary + +Fixed-length binary data. + +### Creating Binary Columns + +```php +use PhpDb\Sql\Ddl\Column\Binary; + +$column = new Binary('hash', 32); // 32-byte hash +``` + +**Constructor:** `__construct($name, $length, $nullable = false, $default = null, array $options = [])` + +### Varbinary + +Variable-length binary data. + +### Creating Varbinary Columns + +```php +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. + +### Creating Blob Columns + +```php +use PhpDb\Sql\Ddl\Column\Blob; + +$column = new Blob('image'); +$column = new Blob('document', 16777215); // MEDIUMBLOB size +``` + +**Constructor:** `__construct($name, $length = null, $nullable = false, $default = null, array $options = [])` + +## Date and Time Types + +### Date + +Date without time. + +### Creating Date Columns + +```php +use PhpDb\Sql\Ddl\Column\Date; + +$column = new Date('birth_date'); +$column = new Date('hire_date'); +``` + +**Constructor:** `__construct($name)` + +### Time + +Time without date. + +### Creating Time Columns + +```php +use PhpDb\Sql\Ddl\Column\Time; + +$column = new Time('start_time'); +$column = new Time('duration'); +``` + +**Constructor:** `__construct($name)` + +### Datetime + +Date and time combined. + +### Creating Datetime Columns + +```php +use PhpDb\Sql\Ddl\Column\Datetime; + +$column = new Datetime('last_login'); +$column = new Datetime('event_time'); +``` + +**Constructor:** `__construct($name)` + +### Timestamp + +Timestamp with special capabilities. + +### Creating Timestamp Columns with Auto-Update + +```php +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. + +### Creating Boolean Columns + +```php +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. + +### Creating Generic Columns + +```php +use PhpDb\Sql\Ddl\Column\Column; + +$column = new Column('custom_field'); +``` + +**Constructor:** `__construct($name = null)` + +## Common Column Methods + +All column types share these methods: + +### Working with Nullable, Defaults, Options, and Constraints + +```php +// 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 + +### Setting Single and Multiple Column Options + +```php +// 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 | Example | +|--------|------|-----------|-------------|---------| +| `AUTO_INCREMENT` | bool | MySQL, MariaDB | Auto-incrementing integer | `$col->setOption('AUTO_INCREMENT', true)` | +| `identity` | bool | PostgreSQL, SQL Server | Identity/Serial column | `$col->setOption('identity', true)` | +| `comment` | string | MySQL, PostgreSQL | Column comment/description | `$col->setOption('comment', 'User ID')` | +| `on_update` | bool | MySQL (Timestamp) | ON UPDATE CURRENT_TIMESTAMP | `$col->setOption('on_update', true)` | +| `length` | int | MySQL (Integer) | Display width | `$col->setOption('length', 11)` | + +### MySQL/MariaDB Specific Options + +### Using MySQL-Specific Column Modifiers + +```php +// 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 + +### Creating Serial/Identity Columns in PostgreSQL + +```php +// SERIAL type (via identity option) +$id = new Column\Integer('id'); +$id->setOption('identity', true); +// Generates: "id" SERIAL NOT NULL +``` + +### SQL Server Specific Options + +### Creating Identity Columns in SQL Server + +```php +// IDENTITY column +$id = new Column\Integer('id'); +$id->setOption('identity', true); +// Generates: [id] INT IDENTITY NOT NULL +``` + +### Common Option Patterns + +#### Auto-Incrementing Primary Key + +### Creating Auto-Incrementing Primary Keys + +```php +// 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 + +### Creating Self-Updating Timestamp Columns + +```php +$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 + +### Adding Comments to Column Definitions + +```php +$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 Types + +### 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 Types + +### 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 + +### Choosing the Right Date and Time Type + +```php +// 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..bbf96ce7b --- /dev/null +++ b/docs/book/sql-ddl/constraints.md @@ -0,0 +1,507 @@ +# 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. + +### Single-Column Primary Key + +```php +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 Output + +**Generated SQL:** +```sql +"id" INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT +``` + +## Foreign Key Constraints + +Foreign keys enforce referential integrity between tables. + +### Basic Foreign Key + +```php +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 Output + +**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:** + +### Common Foreign Key Action Patterns + +```php +// 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 Output + +**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. + +### Single-Column Unique Constraint + +```php +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 Output + +**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 Output + +**Generated SQL:** +```sql +CONSTRAINT "unique_username_per_tenant" UNIQUE ("username", "tenant_id") +``` + +## Check Constraints + +Check constraints enforce custom validation rules. + +### Simple Check Constraints + +```php +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); +``` + +### Complex Check Constraints + +```php +// 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. + +### Basic Index Creation + +```php +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 Output + +**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 Output + +**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 Output + +**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: + +### Best Practice: Using Explicit Constraint Names + +```php +// 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 + +### Implementing Indexing Best Practices + +```php +// 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 +$table->addConstraint(new Index('title', 'idx_title', [100])); // Index first 100 chars +``` + +### Index Order Matters + +### Optimal vs Suboptimal Index Column Order + +```php +// 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..5fc376793 --- /dev/null +++ b/docs/book/sql-ddl/examples.md @@ -0,0 +1,531 @@ +# DDL Examples and Patterns + +## Example 1: E-Commerce Product Table + +### Creating a Complete Product Table with Constraints and Indexes + +```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('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 + +### Building a Multi-Table User Authentication Schema with Roles + +```php +// 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 + +### Implementing Cross-Schema Tables with Foreign Key References + +```php +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 + +### Creating Reversible Migration Classes with Up and Down Methods + +```php +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 + +### Designing an Audit Trail Table for Tracking Data Changes + +```php +$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 + +### Building a Database-Backed Session Storage System + +```php +$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 + +### Implementing File Metadata Storage with UUID Primary Keys + +```php +$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 + +### Safely Creating Tables with Existence Checks + +```php +// 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 + +### Ensuring Correct Table Creation Order for Foreign Keys + +```php +// 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 + +### Matching Column Types Between Parent and Child Tables + +```php +// 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 + +### Using Prefix Indexes for Long Text Columns + +```php +// Use prefix indexes for long text columns +$table->addConstraint(new Index( + 'long_description', + 'idx_description', + [191] // MySQL InnoDB with utf8mb4 has 767 byte limit; 191 chars * 4 bytes = 764 +)); +``` diff --git a/docs/book/sql-ddl/intro.md b/docs/book/sql-ddl/intro.md new file mode 100644 index 000000000..6f0febd7f --- /dev/null +++ b/docs/book/sql-ddl/intro.md @@ -0,0 +1,253 @@ +# 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` + +### Creating and Executing a Simple Table + +```php +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. + +### Basic Table Creation + +```php +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')); +``` + +### Complete Example: User Table + +```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('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.md b/docs/book/sql.md deleted file mode 100644 index 451e9af11..000000000 --- a/docs/book/sql.md +++ /dev/null @@ -1,2159 +0,0 @@ -# SQL Abstraction - -`PhpDb\Sql` is a SQL abstraction layer for building platform-specific SQL -queries via an object-oriented API. The end result of a `PhpDb\Sql` object -will be to either produce a `Statement` and `ParameterContainer` that -represents the target query, or a full string that can be directly executed -against the database platform. To achieve this, `PhpDb\Sql` objects require a -`PhpDb\Adapter\Adapter` object in order to produce the desired results. - -## Quick start - -There are four primary tasks associated with interacting with a database -defined by Data Manipulation Language (DML): selecting, inserting, updating, -and deleting. As such, there are four primary classes that developers can -interact with in order to build queries in the `PhpDb\Sql` namespace: -`Select`, `Insert`, `Update`, and `Delete`. - -Since these four tasks are so closely related and generally used together -within the same application, the `PhpDb\Sql\Sql` class helps you create them -and produce the result you are attempting to achieve. - -```php -use PhpDb\Sql\Sql; - -$sql = new Sql($adapter); -$select = $sql->select(); // returns a PhpDb\Sql\Select instance -$insert = $sql->insert(); // returns a PhpDb\Sql\Insert instance -$update = $sql->update(); // returns a PhpDb\Sql\Update instance -$delete = $sql->delete(); // returns a 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. - -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(); -``` - -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 -use PhpDb\Sql\Sql; - -$sql = new Sql($adapter, 'foo'); -$select = $sql->select(); -$select->where(['id' => 2]); // $select already has from('foo') applied -``` - -## Common interfaces for SQL implementations - -Each of these objects implements the following two interfaces: - -```php -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. - -## Select - -`PhpDb\Sql\Select` presents a unified API for building platform-specific SQL -SELECT queries. Instances may be created and consumed without -`PhpDb\Sql\Sql`: - -```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. - -Once you have a valid `Select` object, the following API can be used to further -specify various select statement parts: - -```php -class Select extends AbstractSql implements SqlInterface, PreparableSqlInterface -{ - const JOIN_INNER = 'inner'; - const JOIN_OUTER = 'outer'; - const JOIN_FULL_OUTER = 'full outer'; - const JOIN_LEFT = 'left'; - const JOIN_RIGHT = 'right'; - const SQL_STAR = '*'; - const ORDER_ASCENDING = 'ASC'; - const ORDER_DESCENDING = 'DESC'; - - public $where; // @param Where $where - - public function __construct(string|array|TableIdentifier $table = null); - public function from(string|array|TableIdentifier $table) : self; - public function columns(array $columns, bool $prefixColumnsWithTable = true) : self; - public function join(string|array|TableIdentifier $name, string $on, string|array $columns = self::SQL_STAR, string $type = self::JOIN_INNER) : self; - public function where(Where|callable|string|array|PredicateInterface $predicate, string $combination = Predicate\PredicateSet::OP_AND) : self; - public function group(string|array $group); - public function having(Having|callable|string|array $predicate, string $combination = Predicate\PredicateSet::OP_AND) : self; - public function order(string|array $order) : self; - public function limit(int $limit) : self; - public function offset(int $offset) : self; -} -``` - -### from() - -```php -// 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 -// 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 -$select->join( - 'foo', // table name - 'id = bar.id', // expression to join on (will be quoted by platform object before insertion), - ['bar', 'baz'], // (optional) list of columns, same requirements as columns() above - $select::JOIN_OUTER // (optional), one of inner, outer, full outer, left, right also represented by constants in the API -); - -$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 -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 -``` - -### where(), 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 -/** - * 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 -$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 -// 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 -// 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`, where the key is the identifier. | - -As an example: - -```php -// 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 -SELECT * WHERE (column1 is null or column1 = 2) AND (column2 = 3) -``` - -you need to use the `nest()` and `unnest()` methods, as follows: - -```php -$select->where->nest() // bracket opened - ->isNull('column1') - ->or - ->equalTo('column1', '2') - ->unnest(); // bracket closed - ->equalTo('column2', '3'); -``` - -### order() - -```php -$select = new Select; -$select->order('id DESC'); // produces 'id' DESC - -$select = new Select; -$select - ->order('id DESC') - ->order('name ASC, age DESC'); // produces 'id' DESC, 'name' ASC, 'age' DESC - -$select = new Select; -$select->order(['name ASC', 'age DESC']); // produces 'name' ASC, 'age' DESC -``` - -### limit() and offset() - -```php -$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 -$select->group('category'); -``` - -Multiple columns can be specified as an array, or by calling `group()` multiple times: - -```php -$select->group(['category', 'status']); - -$select->group('category') - ->group('status'); -``` - -As an example with aggregate functions: - -```php -$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 -$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 -$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 -$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 -$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 -$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 -$state = $select->getRawState(); -``` - -Returns an array containing: - -```php -[ - '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 -$table = $select->getRawState(Select::TABLE); -$columns = $select->getRawState(Select::COLUMNS); -$limit = $select->getRawState(Select::LIMIT); -``` - -## Combine - -The `Combine` class enables combining multiple SELECT statements using UNION, -INTERSECT, or EXCEPT operations. Each operation can optionally include modifiers -such as ALL or DISTINCT. - -```php -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); -``` - -### UNION - -The `union()` method combines results from multiple SELECT statements, removing -duplicates by default. - -```php -$combine = new Combine(); -$combine->union($select1); -$combine->union($select2, 'ALL'); -``` - -Produces: - -```sql -(SELECT * FROM table1 WHERE status = 'active') -UNION ALL -(SELECT * FROM table2 WHERE status = 'pending') -``` - -### EXCEPT - -The `except()` method returns rows from the first SELECT that do not appear in -subsequent SELECT statements. - -```php -$combine = new Combine(); -$combine->union($select1); -$combine->except($select2); -``` - -### INTERSECT - -The `intersect()` method returns only rows that appear in all SELECT statements. - -```php -$combine = new Combine(); -$combine->union($select1); -$combine->intersect($select2); -``` - -### alignColumns() - -The `alignColumns()` method ensures all SELECT statements have the same column -structure by adding NULL for missing columns. - -```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 -(SELECT id, amount, NULL AS reason FROM orders) -UNION -(SELECT id, amount, reason FROM refunds) -``` - -After alignment, both SELECTs will have id, amount, and reason columns, with -NULL used where columns are missing. - -### Using combine() on Select - -The Select class also provides a `combine()` method for simple combinations: - -```php -$select1->combine($select2, Select::COMBINE_UNION, 'ALL'); -``` - -Note that Select can only combine with one other Select. For multiple -combinations, use the Combine class directly. - -## Insert - -The Insert API: - -```php -class Insert implements SqlInterface, PreparableSqlInterface -{ - const VALUES_MERGE = 'merge'; - const VALUES_SET = 'set'; - - public function __construct(string|TableIdentifier $table = null); - public function into(string|TableIdentifier $table) : self; - public function columns(array $columns) : self; - public function values(array $values, string $flag = self::VALUES_SET) : self; -} -``` - -As with `Select`, the table may be provided during instantiation or via the -`into()` method. - -### columns() - -```php -$insert->columns(['foo', 'bar']); // set the valid columns -``` - -### values() - -The default behavior of values is to set the values. Successive calls will not -preserve values from previous calls. - -```php -$insert->values([ - 'col_1' => 'value1', - 'col_2' => 'value2', -]); -``` - -To merge values with previous calls, provide the appropriate flag: -`PhpDb\Sql\Insert::VALUES_MERGE` - -```php -$insert->values(['col_2' => 'value2'], $insert::VALUES_MERGE); -``` - -### select() - -The `select()` method enables INSERT INTO ... SELECT statements, copying data -from one table to another. - -```php -$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 -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 -$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 -$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 -$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 -use PhpDb\Sql\InsertIgnore; - -$insert = new InsertIgnore('users'); -$insert->values([ - 'username' => 'john', - 'email' => 'john@example.com', -]); -``` - -Produces: - -```sql -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). - -## Update - -```php -class Update -{ - const VALUES_MERGE = 'merge'; - const VALUES_SET = 'set'; - - public $where; // @param Where $where - public function __construct(string|TableIdentifier $table = null); - public function table(string|TableIdentifier $table) : self; - public function set(array $values, string $flag = self::VALUES_SET) : self; - public function where(Where|callable|string|array|PredicateInterface $predicate, string $combination = Predicate\PredicateSet::OP_AND) : self; -} -``` - -### set() - -```php -$update->set(['foo' => 'bar', 'baz' => 'bax']); -``` - -The `set()` method accepts a flag parameter to control merging behavior: - -```php -$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 -$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 -UPDATE table SET status = ?, flag = ?, counter = ? -``` - -This is useful when the order of SET operations matters for certain database operations or triggers. - -### where() - -See the [section on Where and Having](#where-and-having). - -## Delete - -```php -class Delete -{ - public $where; // @param Where $where - - public function __construct(string|TableIdentifier $table = null); - public function from(string|TableIdentifier $table); - public function where(Where|callable|string|array|PredicateInterface $predicate, string $combination = Predicate\PredicateSet::OP_AND) : self; -} -``` - -### where() - -See the [section on Where and Having](#where-and-having). - -## 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. - -In the `Where`/`Having` API, a distinction is made between what elements are -considered identifiers (`TYPE_IDENTIFIER`) and which are values (`TYPE_VALUE`). -There is also a special use case type for literal values (`TYPE_LITERAL`). All -element types are expressed via the `PhpDb\Sql\ExpressionInterface` -interface. - -> **Note:** The `TYPE_*` constants are legacy constants maintained for backward -> compatibility. New code should use the `ArgumentType` enum and `Argument` -> class for type-safe argument handling (see the section below). - -### Arguments and Argument Types - -`PhpDb\Sql` provides the `Argument` class along with the `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 four types: - -- `ArgumentType::Identifier` - For column names, table names, and other identifiers that should be quoted -- `ArgumentType::Value` - For values that should be parameterized or properly escaped (default) -- `ArgumentType::Literal` - For literal SQL fragments that should not be quoted or escaped -- `ArgumentType::Select` - For subqueries (automatically detected when using Expression or SqlInterface objects) - -```php -use PhpDb\Sql\Argument; -use PhpDb\Sql\ArgumentType; - -// Using the constructor with explicit type -$arg = new Argument('column_name', ArgumentType::Identifier); - -// Using static factory methods (recommended) -$valueArg = Argument::value(123); // Value type -$identifierArg = Argument::identifier('id'); // Identifier type -$literalArg = Argument::literal('NOW()'); // Literal SQL - -// Using array notation for type specification -$arg = new Argument(['column_name' => ArgumentType::Identifier]); - -// Arrays of values are also supported -$arg = new Argument([1, 2, 3], ArgumentType::Value); -``` - -The `Argument` class is particularly useful when working with expressions -where you need to explicitly control how values are treated: - -```php -use PhpDb\Sql\Expression; -use PhpDb\Sql\Argument; - -// Without Argument - relies on positional type inference -$expression = new Expression( - 'CONCAT(?, ?, ?)', - [ - ['column1' => ExpressionInterface::TYPE_IDENTIFIER], - ['-' => ExpressionInterface::TYPE_VALUE], - ['column2' => ExpressionInterface::TYPE_IDENTIFIER] - ] -); - -// With Argument - more explicit and readable -$expression = new Expression( - 'CONCAT(?, ?, ?)', - [ - Argument::identifier('column1'), - Argument::value('-'), - Argument::identifier('column2') - ] -); -``` - -> ### 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 or `Argument::literal()`. - -The `Where` and `Having` API is that of `Predicate` and `PredicateSet`: - -```php -// Where & Having extend Predicate: -class Predicate extends PredicateSet -{ - public $and; - public $or; - public $AND; - public $OR; - public $NEST; - public $UNNEST; - - public function nest() : Predicate; - public function setUnnest(Predicate $predicate) : void; - public function unnest() : Predicate; - public function equalTo( - int|float|bool|string $left, - int|float|bool|string $right, - string $leftType = self::TYPE_IDENTIFIER, - string $rightType = self::TYPE_VALUE - ) : self; - public function notEqualTo( - int|float|bool|string $left, - int|float|bool|string $right, - string $leftType = self::TYPE_IDENTIFIER, - string $rightType = self::TYPE_VALUE - ) : self; - public function lessThan( - int|float|bool|string $left, - int|float|bool|string $right, - string $leftType = self::TYPE_IDENTIFIER, - string $rightType = self::TYPE_VALUE - ) : self; - public function greaterThan( - int|float|bool|string $left, - int|float|bool|string $right, - string $leftType = self::TYPE_IDENTIFIER, - string $rightType = self::TYPE_VALUE - ) : self; - public function lessThanOrEqualTo( - int|float|bool|string $left, - int|float|bool|string $right, - string $leftType = self::TYPE_IDENTIFIER, - string $rightType = self::TYPE_VALUE - ) : self; - public function greaterThanOrEqualTo( - int|float|bool|string $left, - int|float|bool|string $right, - string $leftType = self::TYPE_IDENTIFIER, - string $rightType = self::TYPE_VALUE - ) : self; - public function like(string $identifier, string $like) : self; - public function notLike(string $identifier, string $notLike) : self; - public function literal(string $literal) : self; - public function expression(string $expression, array $parameters = null) : self; - public function isNull(string $identifier) : self; - public function isNotNull(string $identifier) : self; - public function in(string $identifier, array $valueSet = []) : self; - public function notIn(string $identifier, array $valueSet = []) : self; - public function between( - string $identifier, - int|float|string $minValue, - int|float|string $maxValue - ) : self; - public function notBetween( - string $identifier, - int|float|string $minValue, - int|float|string $maxValue - ) : self; - public function predicate(PredicateInterface $predicate) : self; - - // Inherited From PredicateSet - - public function addPredicate(PredicateInterface $predicate, $combination = null) : self; - public function getPredicates() PredicateInterface[]; - public function orPredicate(PredicateInterface $predicate) : self; - public function andPredicate(PredicateInterface $predicate) : self; - public function getExpressionData() : array; - public function count() : int; -} -``` - -Each method in the API will produce a corresponding `Predicate` object of a similarly named -type, as described below. - -### equalTo(), lessThan(), greaterThan(), lessThanOrEqualTo(), greaterThanOrEqualTo() - -```php -$where->equalTo('id', 5); - -// The above is equivalent to: -$where->addPredicate( - new Predicate\Operator($left, Operator::OPERATOR_EQUAL_TO, $right, $leftType, $rightType) -); -``` - -Operators use the following API: - -```php -class Operator implements PredicateInterface -{ - const OPERATOR_EQUAL_TO = '='; - const OP_EQ = '='; - const OPERATOR_NOT_EQUAL_TO = '!='; - const OP_NE = '!='; - const OPERATOR_LESS_THAN = '<'; - const OP_LT = '<'; - const OPERATOR_LESS_THAN_OR_EQUAL_TO = '<='; - const OP_LTE = '<='; - const OPERATOR_GREATER_THAN = '>'; - const OP_GT = '>'; - const OPERATOR_GREATER_THAN_OR_EQUAL_TO = '>='; - const OP_GTE = '>='; - - public function __construct( - int|float|bool|string $left = null, - string $operator = self::OPERATOR_EQUAL_TO, - int|float|bool|string $right = null, - string $leftType = self::TYPE_IDENTIFIER, - string $rightType = self::TYPE_VALUE - ); - public function setLeft(int|float|bool|string $left); - public function getLeft() : int|float|bool|string; - public function setLeftType(string $type) : self; - public function getLeftType() : string; - public function setOperator(string $operator); - public function getOperator() : string; - public function setRight(int|float|bool|string $value) : self; - public function getRight() : int|float|bool|string; - public function setRightType(string $type) : self; - public function getRightType() : string; - public function getExpressionData() : array; -} -``` - -### like($identifier, $like), notLike($identifier, $notLike) - -```php -$where->like($identifier, $like): - -// The above is equivalent to: -$where->addPredicate( - new Predicate\Like($identifier, $like) -); -``` - -The following is the `Like` API: - -```php -class Like implements PredicateInterface -{ - public function __construct(string $identifier = null, string $like = null); - public function setIdentifier(string $identifier) : self; - public function getIdentifier() : string; - public function setLike(string $like) : self; - public function getLike() : string; -} -``` - -### literal($literal) - -```php -$where->literal($literal); - -// The above is equivalent to: -$where->addPredicate( - new Predicate\Literal($literal) -); -``` - -The following is the `Literal` API: - -```php -class Literal implements ExpressionInterface, PredicateInterface -{ - const PLACEHOLDER = '?'; - public function __construct(string $literal = ''); - public function setLiteral(string $literal) : self; - public function getLiteral() : string; -} -``` - -### expression($expression, $parameter) - -```php -$where->expression($expression, $parameter); - -// The above is equivalent to: -$where->addPredicate( - new Predicate\Expression($expression, $parameter) -); -``` - -The following is the `Expression` API: - -```php -class Expression implements ExpressionInterface, PredicateInterface -{ - const PLACEHOLDER = '?'; - - public function __construct( - string $expression = null, - int|float|bool|string|array $valueParameter = null - /* [, $valueParameter, ... ] */ - ); - public function setExpression(string $expression) : self; - public function getExpression() : string; - public function setParameters(int|float|bool|string|array $parameters) : self; - public function getParameters() : array; -} -``` - -Expression parameters can be supplied either as a single scalar, an array of values, or as an array of value/types for more granular escaping. - -```php -$select - ->from('foo') - ->columns([ - new Expression( - '(COUNT(?) + ?) AS ?', - [ - ['some_column' => ExpressionInterface::TYPE_IDENTIFIER], - [5 => ExpressionInterface::TYPE_VALUE], - ['bar' => ExpressionInterface::TYPE_IDENTIFIER], - ], - ), - ]); - -// Produces SELECT (COUNT("some_column") + '5') AS "bar" FROM "foo" -``` - -### isNull($identifier) - -```php -$where->isNull($identifier); - -// The above is equivalent to: -$where->addPredicate( - new Predicate\IsNull($identifier) -); -``` - -The following is the `IsNull` API: - -```php -class IsNull implements PredicateInterface -{ - public function __construct(string $identifier = null); - public function setIdentifier(string $identifier) : self; - public function getIdentifier() : string; -} -``` - -### isNotNull($identifier) - -```php -$where->isNotNull($identifier); - -// The above is equivalent to: -$where->addPredicate( - new Predicate\IsNotNull($identifier) -); -``` - -The following is the `IsNotNull` API: - -```php -class IsNotNull implements PredicateInterface -{ - public function __construct(string $identifier = null); - public function setIdentifier(string $identifier) : self; - public function getIdentifier() : string; -} -``` - -### in($identifier, $valueSet), notIn($identifier, $valueSet) - -```php -$where->in($identifier, $valueSet); - -// The above is equivalent to: -$where->addPredicate( - new Predicate\In($identifier, $valueSet) -); -``` - -The following is the `In` API: - -```php -class In implements PredicateInterface -{ - public function __construct( - string|array $identifier = null, - array|Select $valueSet = null - ); - public function setIdentifier(string|array $identifier) : self; - public function getIdentifier() : string|array; - public function setValueSet(array|Select $valueSet) : self; - public function getValueSet() : array|Select; -} -``` - -### between($identifier, $minValue, $maxValue), notBetween($identifier, $minValue, $maxValue) - -```php -$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 -class Between implements PredicateInterface -{ - public function __construct( - string $identifier = null, - int|float|string $minValue = null, - int|float|string $maxValue = null - ); - public function setIdentifier(string $identifier) : self; - public function getIdentifier() : string; - public function setMinValue(int|float|string $minValue) : self; - public function getMinValue() : int|float|string; - public function setMaxValue(int|float|string $maxValue) : self; - public function getMaxValue() : int|float|string; - public function setSpecification(string $specification); -} -``` - -As an example with different value types: - -```php -$where->between('age', 18, 65); -$where->notBetween('price', 100, 500); -$where->between('createdAt', '2024-01-01', '2024-12-31'); -``` - -Produces: - -```sql -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 -$where->between(new Expression('YEAR(createdAt)'), 2020, 2024); -``` - -Produces: - -```sql -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 -$select->where - ->equalTo('status', 'active') - ->and - ->greaterThan('age', 18) - ->or - ->equalTo('role', 'admin'); -``` - -Produces: - -```sql -WHERE status = 'active' AND age > 18 OR role = 'admin' -``` - -The properties are case-insensitive for convenience: - -```php -$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 -$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 -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 -$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` predicate (requires parameters) | -| Key => `null` | Creates `IsNull` predicate | -| Key => array | Creates `In` predicate | -| Key => scalar | Creates `Operator` predicate (equality) | -| `PredicateInterface` | Uses predicate directly | - -Combination operators can be specified: - -```php -$where->addPredicates([ - 'role' => 'admin', - 'status' => 'active', -], PredicateSet::OP_OR); -``` - -Produces: - -```sql -WHERE role = 'admin' OR status = 'active' -``` - -### Using LIKE and NOT LIKE patterns - -The `like()` and `notLike()` methods support SQL wildcard patterns: - -```php -$where->like('name', 'John%'); -$where->like('email', '%@gmail.com'); -$where->like('description', '%keyword%'); -$where->notLike('email', '%@spam.com'); -``` - -Multiple LIKE conditions: - -```php -$where->like('name', 'A%') - ->or - ->like('name', 'B%'); -``` - -Produces: - -```sql -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 -$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 -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 -$select->having(function ($having) { - $having->greaterThan(new Expression('AVG(rating)'), 4.5) - ->or - ->greaterThan(new Expression('COUNT(reviews)'), 100); -}); -``` - -Produces: - -```sql -HAVING AVG(rating) > 4.5 OR COUNT(reviews) > 100 -``` - -## Subqueries - -Subqueries can be used in various contexts within SQL statements, including WHERE -clauses, FROM clauses, and SELECT columns. - -### Subqueries in WHERE IN clauses - -```php -$subselect = $sql->select('orders') - ->columns(['customerId']) - ->where(['status' => 'completed']); - -$select = $sql->select('customers') - ->where->in('id', $subselect); -``` - -Produces: - -```sql -SELECT customers.* FROM customers -WHERE id IN (SELECT customerId FROM orders WHERE status = 'completed') -``` - -### Subqueries in FROM clauses - -```php -$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 -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 -$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 -SELECT id, name, -(SELECT COUNT(*) FROM orders WHERE orders.customerId = customers.id) AS orderCount -FROM customers -``` - -### Subqueries with comparison operators - -```php -$subselect = $sql->select('orders') - ->columns([new Expression('AVG(amount)')]); - -$select = $sql->select('orders') - ->where->greaterThan('amount', $subselect); -``` - -Produces: - -```sql -SELECT orders.* FROM orders -WHERE amount > (SELECT AVG(amount) FROM orders) -``` - -## Advanced JOIN Usage - -### Multiple JOIN types in a single query - -```php -$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 -$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 -$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 -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(); -``` - -## Update and Delete Safety Features - -Both Update and Delete classes include empty WHERE protection by default, which -prevents accidental mass updates or deletes. - -```php -$update = $sql->update('users'); -$update->set(['status' => 'deleted']); - -$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 -$update->where(['id' => 123]); - -$delete = $sql->delete('logs'); -$delete->where->lessThan('createdAt', '2020-01-01'); -``` - -### Update with JOIN - -The Update class supports JOIN clauses for multi-table updates: - -```php -$update = $sql->update('orders'); -$update->set(['status' => 'cancelled']); -$update->join('customers', 'orders.customerId = customers.id', Update\Join::JOIN_INNER); -$update->where(['customers.status' => 'inactive']); -``` - -Produces: - -```sql -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. - -## Expression and Literal Advanced Usage - -### Distinguishing between Expression and Literal - -Use `Literal` for static SQL fragments without parameters: - -```php -$literal = new Literal('NOW()'); -$literal = new Literal('CURRENT_TIMESTAMP'); -$literal = new Literal('COUNT(*)'); -``` - -Use `Expression` when parameters are needed: - -```php -$expression = new Expression('DATE_ADD(NOW(), INTERVAL ? DAY)', [7]); -$expression = new Expression('CONCAT(?, ?)', ['Hello', 'World']); -``` - -### Mixed parameter types in expressions - -```php -$expression = new Expression( - 'CASE WHEN ? > ? THEN ? ELSE ? END', - [ - Argument::identifier('age'), - Argument::value(18), - Argument::literal('ADULT'), - Argument::literal('MINOR'), - ] -); -``` - -Produces: - -```sql -CASE WHEN age > 18 THEN ADULT ELSE MINOR END -``` - -### Array values in expressions - -```php -$expression = new Expression( - 'id IN (?)', - [Argument::value([1, 2, 3, 4, 5])] -); -``` - -Produces: - -```sql -id IN (?, ?, ?, ?, ?) -``` - -### Nested expressions - -```php -$innerExpression = new Expression('COUNT(*)'); -$outerExpression = new Expression( - 'CASE WHEN ? > ? THEN ? ELSE ? END', - [ - $innerExpression, - Argument::value(10), - Argument::literal('HIGH'), - Argument::literal('LOW'), - ] -); -``` - -Produces: - -```sql -CASE WHEN COUNT(*) > 10 THEN HIGH ELSE LOW END -``` - -### Using database-specific functions - -```php -$select->where(new Predicate\Expression( - 'FIND_IN_SET(?, ?)', - [ - Argument::value('admin'), - Argument::identifier('roles'), - ] -)); -``` - -## TableIdentifier - -The `TableIdentifier` class provides a type-safe way to reference tables, -especially when working with schemas or databases. - -```php -use PhpDb\Sql\TableIdentifier; - -$table = new TableIdentifier('users', 'production'); - -$tableName = $table->getTable(); -$schemaName = $table->getSchema(); - -[$table, $schema] = $table->getTableAndSchema(); -``` - -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 -``` - -With aliases: - -```php -$select->from(['o' => new TableIdentifier('orders', 'sales')]) - ->join( - ['c' => new TableIdentifier('customers', 'crm')], - 'o.customerId = c.id' - ); -``` - -## 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 -use PhpDb\Sql\Sql; - -$sql = new Sql($adapter); -$sql = new Sql($adapter, 'defaultTable'); -``` - -### Factory Methods - -```php -$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'); -``` - -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 Statements - -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 Strings - -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. - -### Accessing the Platform - -```php -$platform = $sql->getSqlPlatform(); -``` - -The platform object handles database-specific SQL generation and can be used for custom query building. - -## 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 -$update->set(['optionalField' => null]); -``` - -In comparisons, remember that `column = NULL` does not work in SQL; you must use `IS NULL`: - -```php -$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 -$update = $sql->update('users'); -$update->set(['status' => 'inactive']); -``` - -Always include a WHERE clause for UPDATE and DELETE: - -```php -$update->where(['id' => 123]); -``` - -To intentionally update all rows (use with extreme caution): - -```php -$state = $update->getRawState(); -``` - -### Parameter Count Mismatch - -When using Expression with placeholders: - -```php -$expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b']); -``` - -Ensure the number of `?` placeholders matches the number of parameters provided, or you will receive a RuntimeException. - -### Quote Character Issues - -Different databases use different quote characters. Let the platform handle quoting: - -```php -$select->from('users'); -``` - -Avoid manually quoting identifiers: - -```php -$select->from('"users"'); -``` - -### Type Confusion in Predicates - -When comparing two identifiers, specify both types: - -```php -$where->equalTo( - 'table1.columnA', - 'table2.columnB', - Predicate\Predicate::TYPE_IDENTIFIER, - Predicate\Predicate::TYPE_IDENTIFIER -); -``` - -Or use the Argument class: - -```php -$where->equalTo( - Argument::identifier('table1.columnA'), - Argument::identifier('table2.columnB') -); -``` - -## 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 -$select->limit(25)->offset(50); -``` - -### Select Only Required Columns - -Instead of selecting all columns: - -```php -$select->from('users'); -``` - -Specify only the columns you need: - -```php -$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 -$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 -$select->where->equalTo('indexedColumn', $value) - ->greaterThan('date', '2024-01-01'); -``` - -Avoid functions on indexed columns in WHERE: - -```php -$select->where(new Predicate\Expression('YEAR(createdAt) = ?', [2024])); -``` - -Instead, use ranges: - -```php -$select->where->between('createdAt', '2024-01-01', '2024-12-31'); -``` - -## Complete Examples - -### Complex reporting query with aggregation - -```php -$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(); -``` - -### Data migration with INSERT SELECT - -```php -$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(); -``` - -### Combining multiple result sets - -```php -$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(); -``` diff --git a/docs/book/sql/advanced.md b/docs/book/sql/advanced.md new file mode 100644 index 000000000..942cc6a27 --- /dev/null +++ b/docs/book/sql/advanced.md @@ -0,0 +1,266 @@ +# Advanced SQL Features + +## Expression and Literal + +### Distinguishing between Expression and Literal + +Use `Literal` for static SQL fragments without parameters: + +### Creating static SQL literals + +```php +use PhpDb\Sql\Literal; + +$literal = new Literal('NOW()'); +$literal = new Literal('CURRENT_TIMESTAMP'); +$literal = new Literal('COUNT(*)'); +``` + +Use `Expression` when parameters are needed: + +### Creating expressions with parameters + +```php +use PhpDb\Sql\Expression; + +$expression = new Expression('DATE_ADD(NOW(), INTERVAL ? DAY)', [7]); +$expression = new Expression('CONCAT(?, ?)', ['Hello', 'World']); +``` + +### Mixed parameter types in expressions + +```php +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 output for mixed parameter types + +```sql +CASE WHEN age > 18 THEN ADULT ELSE MINOR END +``` + +### Array values in expressions + +```php +$expression = new Expression( + 'id IN (?)', + [Argument::value([1, 2, 3, 4, 5])] +); +``` + +Produces: + +### SQL output for array values + +```sql +id IN (?, ?, ?, ?, ?) +``` + +### Nested expressions + +```php +$innerExpression = new Expression('COUNT(*)'); +$outerExpression = new Expression( + 'CASE WHEN ? > ? THEN ? ELSE ? END', + [ + $innerExpression, + Argument::value(10), + Argument::literal('HIGH'), + Argument::literal('LOW'), + ] +); +``` + +Produces: + +### SQL output for nested expressions + +```sql +CASE WHEN COUNT(*) > 10 THEN HIGH ELSE LOW END +``` + +### Using database-specific functions + +```php +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. + +### Basic Combine usage with UNION + +```php +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); +``` + +### Combine API + +```php +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; +} +``` + +### UNION + +```php +$combine = new Combine(); +$combine->union($select1); +$combine->union($select2, 'ALL'); // UNION ALL keeps duplicates +``` + +Produces: + +### SQL output for UNION ALL + +```sql +(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 output for aligned columns + +```sql +(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') +); +``` \ No newline at end of file diff --git a/docs/book/sql/examples.md b/docs/book/sql/examples.md new file mode 100644 index 000000000..eb4112a0e --- /dev/null +++ b/docs/book/sql/examples.md @@ -0,0 +1,551 @@ +# 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: + +### Setting NULL Values in UPDATE + +```php +$update->set(['optionalField' => null]); +``` + +In comparisons, remember that `column = NULL` does not work in SQL; you must use `IS NULL`: + +### Checking for NULL or Empty Values + +```php +$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: + +### UPDATE Without WHERE Clause (Wrong) + +```php +$update = $sql->update('users'); +$update->set(['status' => 'inactive']); +// This will trigger empty WHERE protection! +``` + +Always include a WHERE clause for UPDATE and DELETE: + +### Adding WHERE Clause to UPDATE + +```php +$update->where(['id' => 123]); +``` + +To intentionally update all rows (use with extreme caution): + +### Checking Empty WHERE Protection Status + +```php +// Check the raw state to understand the protection status +$state = $update->getRawState(); +$protected = $state['emptyWhereProtection']; +``` + +### Parameter Count Mismatch + +When using Expression with placeholders: + +### Incorrect Parameter Count + +```php +// 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. + +### Correct Parameter Count + +```php +// CORRECT +$expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b', 'c']); +``` + +### Quote Character Issues + +Different databases use different quote characters. Let the platform handle quoting: + +### Proper Platform-Managed Quoting + +```php +// CORRECT - let the platform handle quoting +$select->from('users'); +``` + +Avoid manually quoting identifiers: + +### Avoid Manual Quoting + +```php +// WRONG - don't manually quote +$select->from('"users"'); +``` + +### Type Confusion in Predicates + +When comparing two identifiers (column to column), specify both types: + +### Column Comparison Using Type Constants + +```php +// 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: + +### Column Comparison Using Argument Class + +```php +// 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()`: + +### Pagination with Limit and Offset + +```php +$select->limit(25)->offset(50); +``` + +### Select Only Required Columns + +Instead of selecting all columns: + +### Selecting All Columns (Avoid) + +```php +// Avoid - selects all columns +$select->from('users'); +``` + +Specify only the columns you need: + +### Selecting Specific Columns + +```php +// 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: + +### Using JOINs to Avoid N+1 Queries + +```php +// WRONG - N+1 queries +foreach ($orders as $order) { + $customer = getCustomer($order['customerId']); // Additional query per order +} + +// 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: + +### Index-Friendly WHERE Clause + +```php +// Good - can use index on indexedColumn +$select->where->equalTo('indexedColumn', $value) + ->greaterThan('date', '2024-01-01'); +``` + +Avoid functions on indexed columns in WHERE: + +### Functions on Indexed Columns (Prevents Index Usage) + +```php +// BAD - prevents index usage +$select->where(new Predicate\Expression('YEAR(createdAt) = ?', [2024])); +``` + +Instead, use ranges: + +### Using Ranges for Index-Friendly Queries + +```php +// GOOD - allows index usage +$select->where->between('createdAt', '2024-01-01', '2024-12-31'); +``` + +## Complete Examples + +### Complex Reporting Query with Aggregation + +```php +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: + +### Generated SQL for Reporting Query + +```sql +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 +``` + +### Data Migration with INSERT SELECT + +```php +$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: + +### Generated SQL for INSERT SELECT + +```sql +INSERT INTO users (username, email, firstName, lastName) +SELECT username, email, firstName, lastName +FROM importedUsers +WHERE validated = 1 AND email IS NOT NULL +``` + +### Combining Multiple Result Sets + +```php +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: + +### Generated SQL for UNION Query + +```sql +(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) +``` + +### Search with Full-Text and Filters + +```php +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); +``` + +### Batch Update with Transaction + +```php +$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; +} +``` \ No newline at end of file diff --git a/docs/book/sql/insert.md b/docs/book/sql/insert.md new file mode 100644 index 000000000..aaa240b31 --- /dev/null +++ b/docs/book/sql/insert.md @@ -0,0 +1,273 @@ +# Insert Queries + +The `Insert` class provides an API for building SQL INSERT statements. + +## Insert API + +### Insert Class Definition + +```php +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 + +### Creating a Basic Insert Statement + +```php +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: + +### Generated SQL Output + +```sql +INSERT INTO users (username, email, created_at) VALUES (?, ?, ?) +``` + +## columns() + +The `columns()` method explicitly sets which columns will receive values: + +### Setting Valid Columns + +```php +$insert->columns(['foo', 'bar']); // set the valid columns +``` + +When using `columns()`, only the specified columns will be included even if more values are provided: + +### Restricting Columns with Validation + +```php +$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. + +### Setting Values for Insert + +```php +$insert->values([ + 'col_1' => 'value1', + 'col_2' => 'value2', +]); +``` + +To merge values with previous calls, provide the appropriate flag: +`PhpDb\Sql\Insert::VALUES_MERGE` + +### Merging Values from Multiple Calls + +```php +$insert->values(['col_1' => 'value1'], $insert::VALUES_SET); +$insert->values(['col_2' => 'value2'], $insert::VALUES_MERGE); +``` + +This produces: + +### Merged Values SQL Output + +```sql +INSERT INTO table (col_1, col_2) VALUES (?, ?) +``` + +## select() + +The `select()` method enables INSERT INTO ... SELECT statements, copying data +from one table to another. + +### INSERT INTO SELECT Statement + +```php +$select = $sql->select('tempUsers') + ->columns(['username', 'email', 'createdAt']) + ->where(['imported' => false]); + +$insert = $sql->insert('users'); +$insert->columns(['username', 'email', 'createdAt']); +$insert->select($select); +``` + +Produces: + +### INSERT SELECT SQL Output + +```sql +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()`: + +### Passing Select to values() Method + +```php +$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()`: + +### Using Property-style Column Access + +```php +$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: + +### Equivalent values() Method Call + +```php +$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. + +### Using InsertIgnore for Duplicate Prevention + +```php +use PhpDb\Sql\InsertIgnore; + +$insert = new InsertIgnore('users'); +$insert->values([ + 'username' => 'john', + 'email' => 'john@example.com', +]); +``` + +Produces: + +### INSERT IGNORE SQL Output + +```sql +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 + +### Basic insert with prepared statement + +```php +$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(); +``` + +### Insert with expressions + +```php +$insert = $sql->insert('logs'); +$insert->values([ + 'message' => 'User logged in', + 'created_at' => new Expression('NOW()'), + 'ip_hash' => new Expression('MD5(?)', ['192.168.1.1']), +]); +``` + +### Bulk insert from select + +```php +// 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(); +``` + +### Conditional insert with InsertIgnore + +```php +// 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(); +} +``` \ No newline at end of file diff --git a/docs/book/sql/intro.md b/docs/book/sql/intro.md new file mode 100644 index 000000000..43d6d3132 --- /dev/null +++ b/docs/book/sql/intro.md @@ -0,0 +1,290 @@ +# 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`. + +### Creating SQL Statement Objects + +```php +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$select = $sql->select(); // returns a PhpDb\Sql\Select instance +$insert = $sql->insert(); // returns a PhpDb\Sql\Insert instance +$update = $sql->update(); // returns a PhpDb\Sql\Update instance +$delete = $sql->delete(); // returns a 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: + +### Binding to a Default Table + +```php +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter, 'foo'); +$select = $sql->select(); +$select->where(['id' => 2]); // $select already has from('foo') applied +``` + +## Common Interfaces for SQL Implementations + +Each of these objects implements the following two interfaces: + +### PreparableSqlInterface and SqlInterface + +```php +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`: + +### Using Argument Factory and Classes + +```php +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 +$identifiersArg = Argument::identifiers(['col1', 'col2']); // Multiple identifiers + +// 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: + +### Type-Safe Expression Arguments + +```php +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. + +### Instantiating the Sql Factory + +```php +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$sql = new Sql($adapter, 'defaultTable'); +``` + +### Factory Methods + +```php +$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. + +### Getting the SQL Platform + +```php +$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. + +### Creating and Using TableIdentifier + +```php +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' + ); +``` \ No newline at end of file diff --git a/docs/book/sql/select.md b/docs/book/sql/select.md new file mode 100644 index 000000000..f00e85335 --- /dev/null +++ b/docs/book/sql/select.md @@ -0,0 +1,485 @@ +# 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: + +### Select class definition and constants + +```php +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() + +### Specifying the FROM table + +```php +// 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() + +### Selecting columns + +```php +// 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() + +### Basic JOIN examples + +```php +$select->join( + 'foo', // table name + 'id = bar.id', // expression to join on (will be quoted by platform), + ['bar', 'baz'], // (optional) list of columns, same as columns() above + $select::JOIN_OUTER // (optional), one of inner, outer, left, right, 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: + +### JOIN with predicate conditions + +```php +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() + +### Ordering results + +```php +$select = new Select; +$select->order('id DESC'); // produces 'id' DESC + +$select = new Select; +$select + ->order('id DESC') + ->order('name ASC, age DESC'); // produces 'id' DESC, 'name' ASC, 'age' DESC + +$select = new Select; +$select->order(['name ASC', 'age DESC']); // produces 'name' ASC, 'age' DESC +``` + +## limit() and offset() + +### Limiting and offsetting results + +```php +$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. + +### Grouping by a single column + +```php +$select->group('category'); +``` + +Multiple columns can be specified as an array, or by calling `group()` multiple times: + +### Grouping by multiple columns + +```php +$select->group(['category', 'status']); + +$select->group('category') + ->group('status'); +``` + +As an example with aggregate functions: + +### Grouping with aggregate functions + +```php +$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: + +### Grouping with expressions + +```php +$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. + +### Using DISTINCT quantifier + +```php +$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: + +### Using ALL quantifier + +```php +$select->quantifier(Select::QUANTIFIER_ALL); +``` + +## reset() + +The `reset()` method allows you to clear specific parts of a Select statement, +useful when building queries dynamically. + +### Building a Select query before reset + +```php +$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: + +### Resetting specific parts of a query + +```php +$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. + +### Getting the full raw state + +```php +$state = $select->getRawState(); +``` + +Returns an array containing: + +### Raw state array structure + +```php +[ + '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: + +### Getting specific state elements + +```php +$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 + +### Combining different JOIN types + +```php +$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: + +### Joining for filtering without selecting columns + +```php +$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 + +### Using expressions in JOIN column selection + +```php +$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: + +### Programmatically accessing Join information + +```php +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(); +``` \ No newline at end of file diff --git a/docs/book/sql/update-delete.md b/docs/book/sql/update-delete.md new file mode 100644 index 000000000..ede91f89c --- /dev/null +++ b/docs/book/sql/update-delete.md @@ -0,0 +1,330 @@ +# Update and Delete Queries + +## Update + +The `Update` class provides an API for building SQL UPDATE statements. + +### Update API + +```php +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; +} +``` + +### Basic Usage + +```php +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: + +### Generated SQL for basic update + +```sql +UPDATE users SET status = ? WHERE id = ? +``` + +### set() + +### Setting multiple values + +```php +$update->set(['foo' => 'bar', 'baz' => 'bax']); +``` + +The `set()` method accepts a flag parameter to control merging behavior: + +### Controlling merge behavior with VALUES_SET and VALUES_MERGE + +```php +$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: + +### Using numeric priority to control SET clause ordering + +```php +$update->set(['counter' => 1], 100); +$update->set(['status' => 'pending'], 50); +$update->set(['flag' => true], 75); +``` + +Produces SET clauses in priority order (50, 75, 100): + +### Generated SQL showing priority-based ordering + +```sql +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. + +### Using various where clause methods + +```php +$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: + +### Basic JOIN syntax + +```php +$update->join('bar', 'foo.id = bar.foo_id', Update::JOIN_LEFT); +``` + +Example: + +### Update with INNER JOIN on customers table + +```php +$update = $sql->update('orders'); +$update->set(['status' => 'cancelled']); +$update->join('customers', 'orders.customerId = customers.id', Join::JOIN_INNER); +$update->where(['customers.status' => 'inactive']); +``` + +Produces: + +### Generated SQL for update with JOIN + +```sql +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. + +### Delete API + +```php +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; +} +``` + +### Basic Usage + +```php +use PhpDb\Sql\Sql; + +$sql = new Sql($adapter); +$delete = $sql->delete('users'); + +$delete->where(['id' => 123]); + +$statement = $sql->prepareStatementForSqlObject($delete); +$statement->execute(); +``` + +Produces: + +### Generated SQL for basic delete + +```sql +DELETE FROM users WHERE id = ? +``` + +### where() + +The `where()` method works the same as in Select queries. See the [Where and Having](where-having.md) documentation for full details. + +### Using where conditions in delete statements + +```php +$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. + +### Checking empty WHERE protection status + +```php +$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: + +### Adding WHERE clause for safe operations + +```php +$update->where(['id' => 123]); + +$delete = $sql->delete('logs'); +$delete->where->lessThan('createdAt', '2020-01-01'); +``` + +## Examples + +### Update with expressions + +```php +$update = $sql->update('products'); +$update->set([ + 'view_count' => new Expression('view_count + 1'), + 'last_viewed' => new Expression('NOW()'), +]); +$update->where(['id' => $productId]); +``` + +Produces: + +### Generated SQL for update with expressions + +```sql +UPDATE products SET view_count = view_count + 1, last_viewed = NOW() WHERE id = ? +``` + +### Conditional update + +```php +$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')); +}); +``` + +### Update with JOIN + +```php +$update = $sql->update('products'); +$update->set(['products.is_featured' => true]); +$update->join('categories', 'products.category_id = categories.id'); +$update->where(['categories.name' => 'Electronics']); +``` + +### Delete old records + +```php +$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(); +``` + +### Delete with complex conditions + +```php +$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: + +### Generated SQL for delete with complex conditions + +```sql +DELETE FROM users +WHERE (status = 'pending' AND created_at < '2023-01-01') + OR (status = 'banned' AND appeal_date IS NULL) +``` + +### Bulk operations with transactions + +```php +$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..302057b59 --- /dev/null +++ b/docs/book/sql/where-having.md @@ -0,0 +1,915 @@ +# 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: + +### Method signature for where() and having() + +```php +/** + * 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: + +### Using a callable with where() + +```php +$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: + +### Using a string expression with where() + +```php +// 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: + +### Using an array of string expressions + +```php +// 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`, where the key is the identifier. | + +As an example: + +### Using an associative array with mixed value types + +```php +// 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 example with nested OR and AND conditions + +```sql +SELECT * WHERE (column1 is null or column1 = 2) AND (column2 = 3) +``` + +you need to use the `nest()` and `unnest()` methods, as follows: + +### Using nest() and unnest() for complex conditions + +```php +$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`: + +### Predicate class API definition + +```php +// 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 + +### equalTo(), lessThan(), greaterThan(), lessThanOrEqualTo(), greaterThanOrEqualTo() + +### Using equalTo() to create an Operator predicate + +```php +$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: + +### Operator class API definition + +```php +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) + +### Using like() to create a Like predicate + +```php +$where->like($identifier, $like): + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Like($identifier, $like) +); +``` + +The following is the `Like` API: + +### Like class API definition + +```php +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) + +### Using literal() to create a Literal predicate + +```php +$where->literal($literal); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Literal($literal) +); +``` + +The following is the `Literal` API: + +### Literal class API definition + +```php +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) + +### Using expression() to create an Expression predicate + +```php +$where->expression($expression, $parameter); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Expression($expression, $parameter) +); +``` + +The following is the `Expression` API: + +### Expression class API definition + +```php +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: + +### Using Expression with various parameter types + +```php +// 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) + +### Using isNull() to create an IsNull predicate + +```php +$where->isNull($identifier); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\IsNull($identifier) +); +``` + +The following is the `IsNull` API: + +### IsNull class API definition + +```php +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) + +### Using isNotNull() to create an IsNotNull predicate + +```php +$where->isNotNull($identifier); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\IsNotNull($identifier) +); +``` + +The following is the `IsNotNull` API: + +### IsNotNull class API definition + +```php +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) + +### Using in() to create an In predicate + +```php +$where->in($identifier, $valueSet); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\In($identifier, $valueSet) +); +``` + +The following is the `In` API: + +### In class API definition + +```php +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() + +### Using between() to create a Between predicate + +```php +$where->between($identifier, $minValue, $maxValue); + +// The above is equivalent to: +$where->addPredicate( + new Predicate\Between($identifier, $minValue, $maxValue) +); +``` + +The following is the `Between` API: + +### Between class API definition + +```php +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: + +### Using between() with different value types + +```php +$where->between('age', 18, 65); +$where->notBetween('price', 100, 500); +$where->between('createdAt', '2024-01-01', '2024-12-31'); +``` + +Produces: + +### SQL output for between() examples + +```sql +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: + +### Using between() with an Expression + +```php +$where->between(new Expression('YEAR(createdAt)'), 2020, 2024); +``` + +Produces: + +### SQL output for between() with Expression + +```sql +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. + +### Using magic properties for fluent chaining + +```php +$select->where + ->equalTo('status', 'active') + ->and + ->greaterThan('age', 18) + ->or + ->equalTo('role', 'admin'); +``` + +Produces: + +### SQL output for fluent chaining example + +```sql +WHERE status = 'active' AND age > 18 OR role = 'admin' +``` + +The properties are case-insensitive for convenience: + +### Case-insensitive magic property usage + +```php +$where->and->equalTo('a', 1); +$where->AND->equalTo('b', 2'); +``` + +### Deep nesting of predicates + +Complex nested conditions can be created using `nest()` and `unnest()`: + +### Creating deeply nested predicate conditions + +```php +$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 output for deeply nested predicates + +```sql +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. + +### Using addPredicates() with mixed input types + +```php +$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` predicate (requires parameters) | +| Key => `null` | Creates `IsNull` predicate | +| Key => array | Creates `In` predicate | +| Key => scalar | Creates `Operator` predicate (equality) | +| `PredicateInterface` | Uses predicate directly | + +Combination operators can be specified: + +### Using addPredicates() with OR combination + +```php +$where->addPredicates([ + 'role' => 'admin', + 'status' => 'active', +], PredicateSet::OP_OR); +``` + +Produces: + +### SQL output for OR combination + +```sql +WHERE role = 'admin' OR status = 'active' +``` + +### Using LIKE and NOT LIKE patterns + +The `like()` and `notLike()` methods support SQL wildcard patterns: + +### Using like() and notLike() with wildcard patterns + +```php +$where->like('name', 'John%'); +$where->like('email', '%@gmail.com'); +$where->like('description', '%keyword%'); +$where->notLike('email', '%@spam.com'); +``` + +Multiple LIKE conditions: + +### Combining multiple LIKE conditions with OR + +```php +$where->like('name', 'A%') + ->or + ->like('name', 'B%'); +``` + +Produces: + +### SQL output for multiple LIKE conditions + +```sql +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. + +### Using HAVING to filter aggregate results + +```php +$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 output for HAVING with aggregate functions + +```sql +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: + +### Using a closure with HAVING for complex conditions + +```php +$select->having(function ($having) { + $having->greaterThan(new Expression('AVG(rating)'), 4.5) + ->or + ->greaterThan(new Expression('COUNT(reviews)'), 100); +}); +``` + +Produces: + +### SQL output for HAVING with closure + +```sql +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 + +### Using a subquery in a WHERE IN clause + +```php +$subselect = $sql->select('orders') + ->columns(['customerId']) + ->where(['status' => 'completed']); + +$select = $sql->select('customers') + ->where->in('id', $subselect); +``` + +Produces: + +### SQL output for subquery in WHERE IN + +```sql +SELECT customers.* FROM customers +WHERE id IN (SELECT customerId FROM orders WHERE status = 'completed') +``` + +### Subqueries in FROM clauses + +### Using a subquery in a FROM clause + +```php +$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 output for subquery in FROM clause + +```sql +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 + +### Using a scalar subquery in SELECT columns + +```php +$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 output for scalar subquery in SELECT + +```sql +SELECT id, name, +(SELECT COUNT(*) FROM orders WHERE orders.customerId = customers.id) AS orderCount +FROM customers +``` + +### Subqueries with comparison operators + +### Using a subquery with a comparison operator + +```php +$subselect = $sql->select('orders') + ->columns([new Expression('AVG(amount)')]); + +$select = $sql->select('orders') + ->where->greaterThan('amount', $subselect); +``` + +Produces: + +### SQL output for subquery with comparison operator + +```sql +SELECT orders.* FROM orders +WHERE amount > (SELECT AVG(amount) FROM orders) +``` \ No newline at end of file diff --git a/docs/book/table-gateway.md b/docs/book/table-gateway.md index 5b8d2ebbf..d5365e503 100644 --- a/docs/book/table-gateway.md +++ b/docs/book/table-gateway.md @@ -4,6 +4,8 @@ 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; @@ -42,6 +44,8 @@ order to be consumed and utilized to its fullest. The following example uses `PhpDb\TableGateway\TableGateway`, which defines the following API: +### TableGateway Class API + ```php namespace PhpDb\TableGateway; @@ -100,6 +104,8 @@ 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. +### Basic Select Operations + ```php use PhpDb\TableGateway\TableGateway; @@ -123,6 +129,8 @@ 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: +### Advanced Select with Callback + ```php use PhpDb\TableGateway\TableGateway; use PhpDb\Sql\Select; @@ -157,6 +165,8 @@ There are a number of features built-in and shipped with laminas-db: needing to inject it into a `TableGateway` instance. This is only useful when you are extending the `AbstractTableGateway` implementation: + ### Extending AbstractTableGateway with GlobalAdapterFeature + ```php use PhpDb\TableGateway\AbstractTableGateway; use PhpDb\TableGateway\Feature; @@ -183,6 +193,8 @@ There are a number of features built-in and shipped with laminas-db: `update()`, and `delete()`, but switch to a slave adapter for all `select()` operations. + ### Using MasterSlaveFeature + ```php $table = new TableGateway('artist', $adapter, new Feature\MasterSlaveFeature($slaveAdapter)); ``` @@ -191,6 +203,8 @@ There are a number of features built-in and shipped with laminas-db: information from a `Metadata` object. It will also store the primary key information in case the `RowGatewayFeature` needs to consume this information. + ### Using MetadataFeature + ```php $table = new TableGateway('artist', $adapter, new Feature\MetadataFeature()); ``` @@ -202,6 +216,8 @@ There are a number of features built-in and shipped with laminas-db: lifecycle events below](#tablegateway-lifecycle-events) for more information on available events and the parameters they compose. + ### Using EventFeature + ```php $table = new TableGateway('artist', $adapter, new Feature\EventFeature($eventManagerInstance)); ``` @@ -209,6 +225,8 @@ There are a number of features built-in and shipped with laminas-db: - `RowGatewayFeature`: the ability for `select()` to return a `ResultSet` object that upon iteration will return a `RowGateway` instance for each row. + ### Using RowGatewayFeature + ```php $table = new TableGateway('artist', $adapter, new Feature\RowGatewayFeature('id')); $results = $table->select(['id' => 2]); @@ -227,37 +245,41 @@ listed. - `preInitialize` (no parameters) - `postInitialize` (no parameters) - `preSelect`, with the following parameters: - - `select`, with type `PhpDb\Sql\Select` + - `select`, with type `PhpDb\Sql\Select` - `postSelect`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` - `preInsert`, with the following parameters: - - `insert`, with type `PhpDb\Sql\Insert` + - `insert`, with type `PhpDb\Sql\Insert` - `postInsert`, with the following parameters: - - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` - - `result` with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` + - `result` with type `PhpDb\Adapter\Driver\ResultInterface` - `preUpdate`, with the following parameters: - - `update`, with type `PhpDb\Sql\Update` + - `update`, with type `PhpDb\Sql\Update` - `postUpdate`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - `preDelete`, with the following parameters: - - `delete`, with type `PhpDb\Sql\Delete` + - `delete`, with type `PhpDb\Sql\Delete` - `postDelete`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `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: +### Retrieving Event Parameters + ```php $parameter = $event->getParam($paramName); ``` As an example, you might attach a listener on the `postInsert` event as follows: +### Attaching a Listener to postInsert Event + ```php use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\TableGateway\Feature\EventFeature\TableGatewayEvent; diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..0eb28a10d --- /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 \ No newline at end of file From 708e9ec2e9dc0736f263ede81a73c58babae0d1a Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 15:51:30 +1100 Subject: [PATCH 03/11] Linted documentation Signed-off-by: Simon Mundy --- .gitignore copy | 9 -- .markdownlint.json | 5 + docs/book/adapter.md | 2 +- .../usage-in-a-laminas-mvc-application.md | 2 +- docs/book/docker-deployment.md | 2 +- docs/book/index.md | 2 +- docs/book/metadata/intro.md | 10 +- docs/book/metadata/objects.md | 2 +- docs/book/profiler.md | 4 +- docs/book/result-set/advanced.md | 9 +- docs/book/result-set/examples.md | 1 + docs/book/result-set/intro.md | 4 +- docs/book/row-gateway.md | 2 +- docs/book/sql-ddl/advanced.md | 3 +- docs/book/sql-ddl/alter-drop.md | 9 ++ docs/book/sql-ddl/columns.md | 13 +- docs/book/sql-ddl/constraints.md | 29 ++-- docs/book/sql-ddl/intro.md | 4 + docs/book/sql/advanced.md | 2 +- docs/book/sql/examples.md | 3 +- docs/book/sql/insert.md | 2 +- docs/book/sql/intro.md | 3 +- docs/book/sql/select.md | 4 +- docs/book/sql/update-delete.md | 4 +- docs/book/sql/where-having.md | 4 +- docs/book/table-gateway.md | 129 +++++++++--------- 26 files changed, 138 insertions(+), 125 deletions(-) delete mode 100644 .gitignore copy create mode 100644 .markdownlint.json diff --git a/.gitignore copy b/.gitignore copy deleted file mode 100644 index 0088c44ed..000000000 --- a/.gitignore copy +++ /dev/null @@ -1,9 +0,0 @@ -/.phpcs-cache -/.phpstan-cache -/phpstan.neon -/.phpunit.cache -/.phpunit.result.cache -/phpunit.xml -/vendor/ -/xdebug_filter.php -/clover.xml \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 000000000..39195897b --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "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 075e40504..776ae5599 100644 --- a/docs/book/adapter.md +++ b/docs/book/adapter.md @@ -503,4 +503,4 @@ $results = $statement->execute(['id' => 1]); $row = $results->current(); $name = $row['name']; -``` \ No newline at end of file +``` 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 dc67197b1..326fb00f5 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 @@ -219,4 +219,4 @@ class MyDatabaseService implements AdapterAwareInterface ## 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 +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/docker-deployment.md b/docs/book/docker-deployment.md index 951e5db3e..e7c145dbd 100644 --- a/docs/book/docker-deployment.md +++ b/docs/book/docker-deployment.md @@ -289,4 +289,4 @@ docker compose logs -f app docker compose down ``` -Access your application at `http://localhost:8080` and phpMyAdmin at `http://localhost:8081`. \ No newline at end of file +Access your application at `http://localhost:8080` and phpMyAdmin at `http://localhost:8081`. diff --git a/docs/book/index.md b/docs/book/index.md index a41f92349..57ed84e51 100644 --- a/docs/book/index.md +++ b/docs/book/index.md @@ -134,4 +134,4 @@ $usersTable->delete(['id' => 123]); - **[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 \ No newline at end of file +- **[Metadata](metadata/intro.md)** - Database introspection and schema information diff --git a/docs/book/metadata/intro.md b/docs/book/metadata/intro.md index b50fd4dc6..c889b76b9 100644 --- a/docs/book/metadata/intro.md +++ b/docs/book/metadata/intro.md @@ -5,7 +5,7 @@ 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 +## MetadataInterface Definition ```php namespace PhpDb\Metadata; @@ -118,7 +118,7 @@ foreach ($table->getColumns() as $column) { Example output: -``` +```text id INT NOT NULL username VARCHAR NOT NULL email VARCHAR NOT NULL @@ -152,7 +152,7 @@ foreach ($constraints as $constraint) { 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) @@ -248,11 +248,13 @@ foreach ($triggers as $trigger) { ``` 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 @@ -285,7 +287,7 @@ foreach ($foreignKeys as $constraint) { Outputs: -``` +```text Foreign Key: fk_orders_customers customer_id -> customers.id ON UPDATE: CASCADE diff --git a/docs/book/metadata/objects.md b/docs/book/metadata/objects.md index 3ee61ee2f..9fa75c672 100644 --- a/docs/book/metadata/objects.md +++ b/docs/book/metadata/objects.md @@ -254,7 +254,7 @@ Outputs: ### Foreign Key Constraint Output -``` +```text customer_id -> customers.id ON UPDATE: CASCADE ON DELETE: RESTRICT diff --git a/docs/book/profiler.md b/docs/book/profiler.md index 5ef56793f..57b9ee291 100644 --- a/docs/book/profiler.md +++ b/docs/book/profiler.md @@ -71,7 +71,7 @@ Each profile entry contains: | Key | Type | Description | |--------------|---------------------------|------------------------------------------------| | `sql` | `string` | The SQL query that was executed | -| `parameters` | `ParameterContainer|null` | The bound parameters (if any) | +| `parameters` | `ParameterContainer\|null` | The bound parameters (if any) | | `start` | `float` | Unix timestamp with microseconds (query start) | | `end` | `float` | Unix timestamp with microseconds (query end) | | `elapse` | `float` | Total execution time in seconds | @@ -423,4 +423,4 @@ class LoggingProfiler extends Profiler return $this; } } -``` \ No newline at end of file +``` diff --git a/docs/book/result-set/advanced.md b/docs/book/result-set/advanced.md index 697b6116f..b372fb48c 100644 --- a/docs/book/result-set/advanced.md +++ b/docs/book/result-set/advanced.md @@ -56,6 +56,7 @@ $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 @@ -203,7 +204,7 @@ $resultSet->buffer(); Throws: -``` +```text RuntimeException: Buffering must be enabled before iteration is started ``` @@ -226,7 +227,7 @@ var_dump($resultSet->isBuffered()); Outputs: -``` +```text bool(false) bool(true) ``` @@ -249,7 +250,7 @@ var_dump($resultSet->isBuffered()); Outputs: -``` +```text bool(true) ``` @@ -374,12 +375,14 @@ foreach ($users as $user) { ### 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 diff --git a/docs/book/result-set/examples.md b/docs/book/result-set/examples.md index 744f7a78c..a70ce7c0b 100644 --- a/docs/book/result-set/examples.md +++ b/docs/book/result-set/examples.md @@ -226,6 +226,7 @@ try { ### Hydration Failures Object properties not populated? Match hydrator to object structure: + - `ReflectionHydrator` for protected/private properties - `ClassMethodsHydrator` for public setters diff --git a/docs/book/result-set/intro.md b/docs/book/result-set/intro.md index 94b4aab06..739dc1ae2 100644 --- a/docs/book/result-set/intro.md +++ b/docs/book/result-set/intro.md @@ -4,7 +4,7 @@ `ResultSetInterface` is defined as follows: -### ResultSetInterface Definition +## ResultSetInterface Definition ```php use Countable; @@ -151,4 +151,4 @@ $resultSet->initialize(new ArrayIterator($data)); // ResultInterface (most common - from query execution) $resultSet->initialize($statement->execute()); -``` \ No newline at end of file +``` diff --git a/docs/book/row-gateway.md b/docs/book/row-gateway.md index b0cd40c82..11a5931d4 100644 --- a/docs/book/row-gateway.md +++ b/docs/book/row-gateway.md @@ -4,7 +4,7 @@ `RowGatewayInterface` defines these methods: -### RowGatewayInterface Definition +## RowGatewayInterface Definition ```php namespace PhpDb\RowGateway; diff --git a/docs/book/sql-ddl/advanced.md b/docs/book/sql-ddl/advanced.md index d62053610..a83d91cf0 100644 --- a/docs/book/sql-ddl/advanced.md +++ b/docs/book/sql-ddl/advanced.md @@ -7,6 +7,7 @@ **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 @@ -469,4 +470,4 @@ function executeSafeDdl($adapter, $ddl) { throw $e; } } -``` \ No newline at end of file +``` diff --git a/docs/book/sql-ddl/alter-drop.md b/docs/book/sql-ddl/alter-drop.md index 66ff0f24c..d7e6f0056 100644 --- a/docs/book/sql-ddl/alter-drop.md +++ b/docs/book/sql-ddl/alter-drop.md @@ -42,6 +42,7 @@ $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, @@ -67,6 +68,7 @@ $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 @@ -86,6 +88,7 @@ $alter->dropColumn('deprecated_column'); ### SQL Output for Dropping Columns **Generated SQL:** + ```sql ALTER TABLE "users" DROP COLUMN "old_field", @@ -135,6 +138,7 @@ $alter->dropConstraint('fk_old_relation'); ### SQL Output for Dropping Constraints **Generated SQL:** + ```sql ALTER TABLE "users" DROP CONSTRAINT "old_unique_key", @@ -178,6 +182,7 @@ $alter->dropIndex('idx_deprecated'); ### SQL Output for Dropping Indexes **Generated SQL:** + ```sql ALTER TABLE "products" DROP INDEX "idx_old_search", @@ -263,6 +268,7 @@ $adapter->query( ### SQL Output for Basic Drop Table **Generated SQL:** + ```sql DROP TABLE "old_table" ``` @@ -279,6 +285,7 @@ $drop = new DropTable(new TableIdentifier('users', 'archive')); ### SQL Output for Schema-Qualified Drop **Generated SQL:** + ```sql DROP TABLE "archive"."users" ``` @@ -393,6 +400,7 @@ Array( ``` This is useful for: + - Debugging DDL object configuration - Testing DDL generation - Introspection and analysis tools @@ -449,6 +457,7 @@ 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 diff --git a/docs/book/sql-ddl/columns.md b/docs/book/sql-ddl/columns.md index 551e3a37f..b2e20ddb9 100644 --- a/docs/book/sql-ddl/columns.md +++ b/docs/book/sql-ddl/columns.md @@ -24,6 +24,7 @@ $column->setOption('length', 11); **Constructor:** `__construct($name, $nullable = false, $default = null, array $options = [])` **Methods:** + - `setNullable(bool $nullable): self` - `isNullable(): bool` - `setDefault(string|int|null $default): self` @@ -67,6 +68,7 @@ $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 @@ -115,6 +117,7 @@ $column->setNullable(true); **Constructor:** `__construct($name, $length)` **Methods:** + - `setLength(int $length): self` - `getLength(): int` @@ -266,6 +269,7 @@ $column->setOption('on_update', true); **Constructor:** `__construct($name)` **Special Options:** + - `on_update` - When `true`, adds `ON UPDATE CURRENT_TIMESTAMP` ## Boolean Type @@ -470,9 +474,9 @@ $table->addColumn($column); ## Column Type Selection Best Practices -### Numeric Types +### Numeric Type Selection -### Choosing the Right Numeric Type +#### Choosing the Right Numeric Type ```php // Use Integer for most numeric IDs and counters @@ -491,9 +495,9 @@ $latitude = new Column\Floating('lat', 10, 6); // GPS coordinates $measurement = new Column\Floating('temp', 5, 2); // Temperature readings ``` -### String Types +### String Type Selection -### Choosing the Right String Type +#### Choosing the Right String Type ```php // Use Varchar for bounded strings with known max length @@ -512,6 +516,7 @@ $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 diff --git a/docs/book/sql-ddl/constraints.md b/docs/book/sql-ddl/constraints.md index bbf96ce7b..b7f6286f1 100644 --- a/docs/book/sql-ddl/constraints.md +++ b/docs/book/sql-ddl/constraints.md @@ -48,9 +48,8 @@ $id->addConstraint(new PrimaryKey()); $table->addColumn($id); ``` -### Generated SQL Output - **Generated SQL:** + ```sql "id" INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT ``` @@ -74,9 +73,8 @@ $fk = new ForeignKey( $table->addConstraint($fk); ``` -### Generated SQL Output - **Generated SQL:** + ```sql CONSTRAINT "fk_order_customer" FOREIGN KEY ("customer_id") REFERENCES "customers" ("id") @@ -98,6 +96,7 @@ $fk = new ForeignKey( ``` **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 @@ -133,9 +132,8 @@ $fk = new ForeignKey( ); ``` -### Generated SQL Output - **Generated SQL:** + ```sql CONSTRAINT "fk_user_tenant" FOREIGN KEY ("user_id", "tenant_id") REFERENCES "user_tenants" ("user_id", "tenant_id") @@ -160,9 +158,8 @@ $unique = new UniqueKey('email', 'unique_user_email'); $table->addConstraint($unique); ``` -### Generated SQL Output - **Generated SQL:** + ```sql CONSTRAINT "unique_user_email" UNIQUE ("email") ``` @@ -179,9 +176,8 @@ $unique = new UniqueKey( ); ``` -### Generated SQL Output - **Generated SQL:** + ```sql CONSTRAINT "unique_username_per_tenant" UNIQUE ("username", "tenant_id") ``` @@ -288,9 +284,8 @@ $index = new Index('email', 'idx_user_email'); $table->addConstraint($index); ``` -### Generated SQL Output - **Generated SQL:** + ```sql INDEX "idx_username" ("username") ``` @@ -309,9 +304,8 @@ $index = new Index(['last_name', 'first_name'], 'idx_name_search'); $table->addConstraint($index); ``` -### Generated SQL Output - **Generated SQL:** + ```sql INDEX "idx_category_price" ("category", "price") ``` @@ -334,14 +328,14 @@ $index = new Index( $table->addConstraint($index); ``` -### Generated SQL Output - **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 @@ -402,6 +396,7 @@ $alter->dropConstraint('fk_user_role'); ``` **Recommended Naming Patterns:** + - Primary keys: `pk_` - Foreign keys: `fk_
_` or `fk_
_` - Unique constraints: `unique_
_` or `unique_` @@ -413,6 +408,7 @@ $alter->dropConstraint('fk_user_role'); ### When to Add Indexes **DO index:** + - Primary keys (automatic in most platforms) - Foreign key columns - Columns frequently used in WHERE clauses @@ -421,6 +417,7 @@ $alter->dropConstraint('fk_user_role'); - 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 diff --git a/docs/book/sql-ddl/intro.md b/docs/book/sql-ddl/intro.md index 6f0febd7f..b9b712382 100644 --- a/docs/book/sql-ddl/intro.md +++ b/docs/book/sql-ddl/intro.md @@ -52,6 +52,7 @@ $table->addColumn(new Column\Varchar('name', 255)); ### SQL Output for Basic Table **Generated SQL:** + ```sql CREATE TABLE "users" ( "id" INTEGER NOT NULL, @@ -86,6 +87,7 @@ $table = new CreateTable(new TableIdentifier('users', 'public')); ### SQL Output for Schema-Qualified Table **Generated SQL:** + ```sql CREATE TABLE "public"."users" (...) ``` @@ -105,6 +107,7 @@ $table->setTemporary(true); ### SQL Output for Temporary Table **Generated SQL:** + ```sql CREATE TEMPORARY TABLE "temp_data" (...) ``` @@ -171,6 +174,7 @@ $table->addColumn($id); ### SQL Output for Column-Level Constraint **Generated SQL:** + ```sql "id" INTEGER NOT NULL PRIMARY KEY ``` diff --git a/docs/book/sql/advanced.md b/docs/book/sql/advanced.md index 942cc6a27..e079dbedd 100644 --- a/docs/book/sql/advanced.md +++ b/docs/book/sql/advanced.md @@ -263,4 +263,4 @@ $where->equalTo( Argument::identifier('table1.column'), Argument::identifier('table2.column') ); -``` \ No newline at end of file +``` diff --git a/docs/book/sql/examples.md b/docs/book/sql/examples.md index eb4112a0e..5937045c3 100644 --- a/docs/book/sql/examples.md +++ b/docs/book/sql/examples.md @@ -250,6 +250,7 @@ $statement = $sql->prepareStatementForSqlObject($select); ``` This provides: + - Protection against SQL injection - Better performance through query plan caching - Proper type handling for parameters @@ -548,4 +549,4 @@ try { $connection->rollback(); throw $e; } -``` \ No newline at end of file +``` diff --git a/docs/book/sql/insert.md b/docs/book/sql/insert.md index aaa240b31..cd8a90251 100644 --- a/docs/book/sql/insert.md +++ b/docs/book/sql/insert.md @@ -270,4 +270,4 @@ foreach ($users as $userData) { $statement = $sql->prepareStatementForSqlObject($insert); $statement->execute(); } -``` \ No newline at end of file +``` diff --git a/docs/book/sql/intro.md b/docs/book/sql/intro.md index 43d6d3132..14e42883b 100644 --- a/docs/book/sql/intro.md +++ b/docs/book/sql/intro.md @@ -216,6 +216,7 @@ $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 @@ -287,4 +288,4 @@ $select->from(['o' => new TableIdentifier('orders', 'sales')]) ['c' => new TableIdentifier('customers', 'crm')], 'o.customerId = c.id' ); -``` \ No newline at end of file +``` diff --git a/docs/book/sql/select.md b/docs/book/sql/select.md index f00e85335..fbfe4f9de 100644 --- a/docs/book/sql/select.md +++ b/docs/book/sql/select.md @@ -4,7 +4,7 @@ SELECT queries. Instances may be created and consumed without `PhpDb\Sql\Sql`: -### Creating a Select instance +## Creating a Select instance ```php use PhpDb\Sql\Select; @@ -482,4 +482,4 @@ $joinCount = count($select->joins); $allJoins = $select->joins->getJoins(); $select->joins->reset(); -``` \ No newline at end of file +``` diff --git a/docs/book/sql/update-delete.md b/docs/book/sql/update-delete.md index ede91f89c..e4908ee06 100644 --- a/docs/book/sql/update-delete.md +++ b/docs/book/sql/update-delete.md @@ -160,7 +160,7 @@ class Delete extends AbstractPreparableSql implements SqlInterface, PreparableSq } ``` -### Basic Usage +### Delete Basic Usage ```php use PhpDb\Sql\Sql; @@ -182,7 +182,7 @@ Produces: DELETE FROM users WHERE id = ? ``` -### where() +### Delete where() The `where()` method works the same as in Select queries. See the [Where and Having](where-having.md) documentation for full details. diff --git a/docs/book/sql/where-having.md b/docs/book/sql/where-having.md index 302057b59..5caeb5a6c 100644 --- a/docs/book/sql/where-having.md +++ b/docs/book/sql/where-having.md @@ -681,7 +681,7 @@ Produces: WHERE ((a = 1 OR b = 2) AND (c = 3 OR d = 4)) ``` -### addPredicates() intelligent handling +### addPredicates() intelligent handling The `addPredicates()` method from `PredicateSet` provides intelligent handling of various input types, automatically creating appropriate predicate objects based on @@ -912,4 +912,4 @@ Produces: ```sql SELECT orders.* FROM orders WHERE amount > (SELECT AVG(amount) FROM orders) -``` \ No newline at end of file +``` diff --git a/docs/book/table-gateway.md b/docs/book/table-gateway.md index d5365e503..92843a3e6 100644 --- a/docs/book/table-gateway.md +++ b/docs/book/table-gateway.md @@ -4,7 +4,7 @@ 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 +## TableGatewayInterface Definition ```php namespace PhpDb\TableGateway; @@ -161,80 +161,73 @@ 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 - ### Extending AbstractTableGateway with 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); +// 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 - ``` +// in a controller, or model somewhere +$table = new MyTableGateway(); // adapter is statically loaded +``` -- `MasterSlaveFeature`: the ability to use a master adapter for `insert()`, - `update()`, and `delete()`, but switch to a slave adapter for all `select()` - operations. +### MasterSlaveFeature - ### Using MasterSlaveFeature +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)); - ``` +```php +$table = new TableGateway('artist', $adapter, new Feature\MasterSlaveFeature($slaveAdapter)); +``` -- `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. +### MetadataFeature - ### Using MetadataFeature +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()); - ``` +```php +$table = new TableGateway('artist', $adapter, new Feature\MetadataFeature()); +``` -- `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. +### EventFeature - ### Using EventFeature +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: - ```php - $table = new TableGateway('artist', $adapter, new Feature\EventFeature($eventManagerInstance)); - ``` +```php +$table = new TableGateway('artist', $adapter, new Feature\EventFeature($eventManagerInstance)); +``` -- `RowGatewayFeature`: the ability for `select()` to return a `ResultSet` object that upon iteration - will return a `RowGateway` instance for each row. +### RowGatewayFeature - ### Using RowGatewayFeature +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 @@ -245,26 +238,26 @@ listed. - `preInitialize` (no parameters) - `postInitialize` (no parameters) - `preSelect`, with the following parameters: - - `select`, with type `PhpDb\Sql\Select` + - `select`, with type `PhpDb\Sql\Select` - `postSelect`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` - `preInsert`, with the following parameters: - - `insert`, with type `PhpDb\Sql\Insert` + - `insert`, with type `PhpDb\Sql\Insert` - `postInsert`, with the following parameters: - - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` - - `result` with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` + - `result` with type `PhpDb\Adapter\Driver\ResultInterface` - `preUpdate`, with the following parameters: - - `update`, with type `PhpDb\Sql\Update` + - `update`, with type `PhpDb\Sql\Update` - `postUpdate`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - `preDelete`, with the following parameters: - - `delete`, with type `PhpDb\Sql\Delete` + - `delete`, with type `PhpDb\Sql\Delete` - `postDelete`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `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 From d3b1c596fd888d876f063d89ee2334e0fe9d1d63 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 15:57:10 +1100 Subject: [PATCH 04/11] Use code-fenced titles for examples Signed-off-by: Simon Mundy --- docs/book/adapter.md | 80 ++----- .../usage-in-a-laminas-mvc-application.md | 28 +-- .../usage-in-a-mezzio-application.md | 68 ++---- docs/book/docker-deployment.md | 20 +- docs/book/metadata/examples.md | 36 +-- docs/book/metadata/intro.md | 44 +--- docs/book/metadata/objects.md | 40 +--- docs/book/profiler.md | 8 +- docs/book/result-set/advanced.md | 100 +++------ docs/book/result-set/examples.md | 8 +- docs/book/result-set/intro.md | 12 +- docs/book/row-gateway.md | 12 +- docs/book/sql-ddl/advanced.md | 4 +- docs/book/sql-ddl/alter-drop.md | 48 +--- docs/book/sql-ddl/columns.md | 100 +++------ docs/book/sql-ddl/constraints.md | 40 +--- docs/book/sql-ddl/examples.md | 44 +--- docs/book/sql-ddl/intro.md | 12 +- docs/book/sql/advanced.md | 56 ++--- docs/book/sql/examples.md | 104 +++------ docs/book/sql/insert.md | 76 ++----- docs/book/sql/intro.md | 36 +-- docs/book/sql/select.md | 88 ++------ docs/book/sql/update-delete.md | 100 +++------ docs/book/sql/where-having.md | 208 +++++------------- docs/book/table-gateway.md | 20 +- 26 files changed, 346 insertions(+), 1046 deletions(-) diff --git a/docs/book/adapter.md b/docs/book/adapter.md index 776ae5599..c932727aa 100644 --- a/docs/book/adapter.md +++ b/docs/book/adapter.md @@ -24,9 +24,7 @@ Database-specific drivers are provided as separate packages: ## Quick Start -### MySQL Connection - -```php +```php title="MySQL Connection" use PhpDb\Adapter\Adapter; use PhpDb\Mysql\Driver\Mysql; use PhpDb\Mysql\Platform\Mysql as MysqlPlatform; @@ -41,9 +39,7 @@ $driver = new Mysql([ $adapter = new Adapter($driver, new MysqlPlatform()); ``` -### SQLite Connection - -```php +```php title="SQLite Connection" use PhpDb\Adapter\Adapter; use PhpDb\Sqlite\Driver\Sqlite; use PhpDb\Sqlite\Platform\Sqlite as SqlitePlatform; @@ -59,9 +55,7 @@ $adapter = new Adapter($driver, new SqlitePlatform()); The `Adapter` class provides the primary interface for database operations: -### Adapter Class Interface - -```php +```php title="Adapter Class Interface" namespace PhpDb\Adapter; use PhpDb\ResultSet; @@ -108,9 +102,7 @@ By default, `PhpDb\Adapter\Adapter::query()` prefers that you use that you will supply a SQL statement containing placeholders for the values, and separately provide substitutions for those placeholders: -### Query with Prepared Statement - -```php +```php title="Query with Prepared Statement" $adapter->query('SELECT * FROM `artist` WHERE `id` = ?', [5]); ``` @@ -136,9 +128,7 @@ extensions and RDBMS systems are incapable of preparing such statements. To execute a query without the preparation step, pass a flag as the second argument indicating execution is required: -### Executing DDL Statement Without Preparation - -```php +```php title="Executing DDL Statement Without Preparation" $adapter->query( 'ALTER TABLE ADD INDEX(`foo_index`) ON (`foo_column`)', Adapter::QUERY_MODE_EXECUTE @@ -155,9 +145,7 @@ 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: -### Creating and Executing a Statement - -```php +```php title="Creating and Executing a Statement" $statement = $adapter->createStatement($sql, $optionalParameters); $result = $statement->execute(); ``` @@ -172,11 +160,7 @@ driver is composed of three objects: - A statement: `PhpDb\Adapter\Driver\StatementInterface` - A result: `PhpDb\Adapter\Driver\ResultInterface` -### DriverInterface - -### Driver Interface Definition - -```php +```php title="Driver Interface Definition" namespace PhpDb\Adapter\Driver; interface DriverInterface @@ -211,11 +195,7 @@ From this `DriverInterface`, you can: between the various ways parameters are named between extensions - Retrieve the overall last generated value (such as an auto-increment value) -### StatementInterface - -### Statement Interface Definition - -```php +```php title="Statement Interface Definition" namespace PhpDb\Adapter\Driver; interface StatementInterface extends StatementContainerInterface @@ -233,11 +213,7 @@ interface StatementInterface extends StatementContainerInterface } ``` -### ResultInterface - -### Result Interface Definition - -```php +```php title="Result Interface Definition" namespace PhpDb\Adapter\Driver; use Countable; @@ -261,9 +237,7 @@ 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: -### Platform Interface Definition - -```php +```php title="Platform Interface Definition" namespace PhpDb\Adapter\Platform; interface PlatformInterface @@ -285,20 +259,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): -### Getting Platform from Adapter - -```php +```php title="Getting Platform from Adapter" $platform = $adapter->getPlatform(); // or $platform = $adapter->platform; // magic property access ``` -### Platform Usage Examples - -### Quoting Identifiers and Values - -```php +```php title="Quoting Identifiers and Values" $platform = $adapter->getPlatform(); // "first_name" @@ -336,9 +304,7 @@ 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: -### ParameterContainer Class Interface - -```php +```php title="ParameterContainer Class Interface" namespace PhpDb\Adapter; use ArrayAccess; @@ -393,18 +359,14 @@ class ParameterContainer implements Iterator, ArrayAccess, Countable In addition to handling parameter names and values, the container will assist in tracking parameter types for PHP type to SQL type handling: -### Setting Parameter Without Type - -```php +```php title="Setting Parameter Without Type" $container->offsetSet('limit', 5); ``` To bind as an integer, pass the `ParameterContainer::TYPE_INTEGER` constant as the 3rd parameter: -### Setting Parameter with Type Binding - -```php +```php title="Setting Parameter with Type Binding" $container->offsetSet('limit', 5, $container::TYPE_INTEGER); ``` @@ -416,9 +378,7 @@ actual PHP database driver. Drivers can provide optional features through the `DriverFeatureProviderInterface`: -### DriverFeatureProviderInterface Definition - -```php +```php title="DriverFeatureProviderInterface Definition" namespace PhpDb\Adapter\Driver\Feature; interface DriverFeatureProviderInterface @@ -438,9 +398,7 @@ database platform. The adapter supports profiling through the `ProfilerInterface`: -### Setting Up a Profiler - -```php +```php title="Setting Up a Profiler" use PhpDb\Adapter\Profiler\Profiler; $profiler = new Profiler(); @@ -457,9 +415,7 @@ $profiles = $profiler->getProfiles(); Creating a driver, a vendor-portable query, and preparing and iterating the result: -### Full Workflow Example with Adapter - -```php +```php title="Full Workflow Example with Adapter" use PhpDb\Adapter\Adapter; use PhpDb\Mysql\Driver\Mysql; use PhpDb\Mysql\Platform\Mysql as MysqlPlatform; 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 326fb00f5..21e12b820 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 @@ -17,9 +17,7 @@ SQLite is a lightweight option to have the application working with a database. Here is an example of the configuration array for a SQLite database. Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: -### SQLite adapter configuration - -```php +```php title="SQLite adapter configuration" get(AdapterInterface::class); @@ -151,9 +143,7 @@ If you have services that implement `PhpDb\Adapter\AdapterAwareInterface`, you c Register the delegator in your service configuration: -### Delegator configuration for adapter-aware services - -```php +```php title="Delegator configuration for adapter-aware services" use PhpDb\Adapter\AdapterInterface; use PhpDb\Container\AdapterServiceDelegator; @@ -172,9 +162,7 @@ return [ When using multiple adapters, you can specify which adapter to inject: -### Delegator configuration for multiple adapters - -```php +```php title="Delegator configuration for multiple adapters" use PhpDb\Container\AdapterServiceDelegator; return [ @@ -195,9 +183,7 @@ return [ Your service class must implement `AdapterAwareInterface`: -### Implementing AdapterAwareInterface in a service class - -```php +```php title="Implementing AdapterAwareInterface in a service class" use PhpDb\Adapter\AdapterAwareInterface; use PhpDb\Adapter\AdapterInterface; diff --git a/docs/book/application-integration/usage-in-a-mezzio-application.md b/docs/book/application-integration/usage-in-a-mezzio-application.md index 95e08f74d..fa44e9b7f 100644 --- a/docs/book/application-integration/usage-in-a-mezzio-application.md +++ b/docs/book/application-integration/usage-in-a-mezzio-application.md @@ -17,9 +17,7 @@ SQLite is a lightweight option to have the application working with a database. Here is an example of the configuration array for a SQLite database. Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: -### SQLite adapter configuration - -```php +```php title="SQLite adapter configuration" getTable($tableName); @@ -97,9 +91,7 @@ function generateTableDocumentation(MetadataInterface $metadata, string $tableNa } ``` -### Comparing Schemas Across Environments - -```php +```php title="Comparing Schemas Across Environments" function compareTables( MetadataInterface $metadata1, MetadataInterface $metadata2, @@ -124,9 +116,7 @@ function compareTables( } ``` -### Generating Entity Classes from Metadata - -```php +```php title="Generating Entity Classes from Metadata" function generateEntityClass(MetadataInterface $metadata, string $tableName): string { $columns = $metadata->getColumns($tableName); @@ -188,9 +178,7 @@ if (in_array('users', $metadata->getTableNames(), true)) { When working with databases that have hundreds of tables, use `get*Names()` methods instead of retrieving full objects: -### Efficient Metadata Access for Large Schemas - -```php +```php title="Efficient Metadata Access for Large Schemas" $tableNames = $metadata->getTableNames(); foreach ($tableNames as $tableName) { $columnNames = $metadata->getColumnNames($tableName); @@ -199,9 +187,7 @@ foreach ($tableNames as $tableName) { This is more efficient than: -### Inefficient Metadata Access Pattern - -```php +```php title="Inefficient Metadata Access Pattern" $tables = $metadata->getTables(); foreach ($tables as $table) { $columns = $table->getColumns(); @@ -213,9 +199,7 @@ foreach ($tables as $table) { If you encounter errors accessing certain tables or schemas, verify database user permissions: -### Verifying Schema Access Permissions - -```php +```php title="Verifying Schema Access Permissions" try { $tables = $metadata->getTableNames('restricted_schema'); } catch (Exception $e) { @@ -228,9 +212,7 @@ try { The metadata component queries the database each time a method is called. For better performance in production, consider caching the results: -### Implementing Metadata Caching - -```php +```php title="Implementing Metadata Caching" $cache = $container->get('cache'); $cacheKey = 'metadata_tables'; diff --git a/docs/book/metadata/intro.md b/docs/book/metadata/intro.md index c889b76b9..7178bb20e 100644 --- a/docs/book/metadata/intro.md +++ b/docs/book/metadata/intro.md @@ -44,9 +44,7 @@ The `PhpDb\Metadata` component uses platform-specific implementations to retriev metadata from your database. The metadata instance is typically created through dependency injection or directly with an adapter: -### Creating Metadata from an Adapter - -```php +```php title="Creating Metadata from an Adapter" use PhpDb\Adapter\Adapter; use PhpDb\Metadata\Source\Factory as MetadataSourceFactory; @@ -71,9 +69,7 @@ tables for the currently accessible schema. The `get*Names()` methods return arrays of strings: -### Getting Names of Database Objects - -```php +```php title="Getting Names of Database Objects" $tableNames = $metadata->getTableNames(); $columnNames = $metadata->getColumnNames('users'); $schemas = $metadata->getSchemas(); @@ -92,9 +88,7 @@ $constraint = $metadata->getConstraint('PRIMARY', 'users'); // Returns Constrain Note that `getTable()` and `getView()` can return either `TableObject` or `ViewObject` depending on whether the database object is a table or a view. -### Basic Example - -```php +```php title="Basic Example" use PhpDb\Metadata\Source\Factory as MetadataSourceFactory; $adapter = new Adapter($config); @@ -164,9 +158,7 @@ FOREIGN KEY fk_orders_products (product_id) REFERENCES products (id) The `getSchemas()` method returns all available schema names in the database: -### Listing All Schemas and Their Tables - -```php +```php title="Listing All Schemas and Their Tables" $schemas = $metadata->getSchemas(); foreach ($schemas as $schema) { $tables = $metadata->getTableNames($schema); @@ -177,9 +169,7 @@ foreach ($schemas as $schema) { 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: -### Specifying a Schema Explicitly - -```php +```php title="Specifying a Schema Explicitly" $tables = $metadata->getTableNames('production'); $columns = $metadata->getColumns('users', 'production'); $constraints = $metadata->getConstraints('users', 'production'); @@ -189,9 +179,7 @@ $constraints = $metadata->getConstraints('users', 'production'); Retrieve all views in the current schema: -### Retrieving View Information - -```php +```php title="Retrieving View Information" $viewNames = $metadata->getViewNames(); foreach ($viewNames as $viewName) { $view = $metadata->getView($viewName); @@ -231,9 +219,7 @@ $allTables = $metadata->getTableNames(null, true); Retrieve all triggers and their details: -### Retrieving Trigger Information - -```php +```php title="Retrieving Trigger Information" $triggers = $metadata->getTriggers(); foreach ($triggers as $trigger) { printf( @@ -262,9 +248,7 @@ The `getActionTiming()` returns when the trigger fires: Get detailed foreign key information using `getConstraintKeys()`: -### Examining Foreign Key Details - -```php +```php title="Examining Foreign Key Details" $constraints = $metadata->getConstraints('orders'); $foreignKeys = array_filter($constraints, fn($c) => $c->isForeignKey()); @@ -302,9 +286,7 @@ Foreign Key: fk_orders_products Examine column types and their properties: -### Examining Column Data Types - -```php +```php title="Examining Column Data Types" $column = $metadata->getColumn('price', 'products'); if ($column->getDataType() === 'decimal') { @@ -341,9 +323,7 @@ echo 'Position: ' . $column->getOrdinalPosition() . PHP_EOL; The `ColumnObject` includes an errata system for storing database-specific metadata not covered by the standard properties: -### Using the Errata System - -```php +```php title="Using the Errata System" $columns = $metadata->getColumns('users'); foreach ($columns as $column) { if ($column->getErrata('auto_increment')) { @@ -382,9 +362,7 @@ foreach ($erratas as $key => $value) { All setter methods on value objects return `static`, enabling method chaining: -### Using Method Chaining with Value Objects - -```php +```php title="Using Method Chaining with Value Objects" $column = new ColumnObject('id', 'users'); $column->setDataType('int') ->setIsNullable(false) diff --git a/docs/book/metadata/objects.md b/docs/book/metadata/objects.md index 9fa75c672..8d6a260a4 100644 --- a/docs/book/metadata/objects.md +++ b/docs/book/metadata/objects.md @@ -7,9 +7,7 @@ better explore the metadata. Below is the API for the various value objects: `TableObject` extends `AbstractTableObject` and represents a database table: -### TableObject Class Definition - -```php +```php title="TableObject Class Definition" class PhpDb\Metadata\Object\TableObject extends AbstractTableObject { public function __construct(?string $name = null); @@ -26,9 +24,7 @@ class PhpDb\Metadata\Object\TableObject extends AbstractTableObject All setter methods return `static` for fluent interface support: -### ColumnObject Class Definition - -```php +```php title="ColumnObject Class Definition" class PhpDb\Metadata\Object\ColumnObject { public function __construct(string $name, string $tableName, ?string $schemaName = null); @@ -83,9 +79,7 @@ class PhpDb\Metadata\Object\ColumnObject All setter methods return `static` for fluent interface support: -### ConstraintObject Class Definition - -```php +```php title="ConstraintObject Class Definition" class PhpDb\Metadata\Object\ConstraintObject { public function __construct(string $name, string $tableName, ?string $schemaName = null); @@ -140,9 +134,7 @@ class PhpDb\Metadata\Object\ConstraintObject The `ViewObject` extends `AbstractTableObject` and represents database views. It includes all methods from `TableObject` plus view-specific properties: -### ViewObject Class Definition - -```php +```php title="ViewObject Class Definition" class PhpDb\Metadata\Object\ViewObject extends AbstractTableObject { public function __construct(?string $name = null); @@ -167,18 +159,14 @@ class PhpDb\Metadata\Object\ViewObject extends AbstractTableObject The `getViewDefinition()` method returns the SQL that creates the view: -### Retrieving View Definition - -```php +```php title="Retrieving View Definition" $view = $metadata->getView('active_users'); echo $view->getViewDefinition(); ``` Outputs: -### View Definition SQL Output - -```sql +```sql title="View Definition SQL Output" SELECT id, name, email FROM users WHERE status = 'active' ``` @@ -196,9 +184,7 @@ view supports INSERT, UPDATE, or DELETE operations. The `ConstraintKeyObject` provides detailed information about individual columns participating in constraints, particularly useful for foreign key relationships: -### ConstraintKeyObject Class Definition - -```php +```php title="ConstraintKeyObject Class Definition" class PhpDb\Metadata\Object\ConstraintKeyObject { public const FK_CASCADE = 'CASCADE'; @@ -237,9 +223,7 @@ class PhpDb\Metadata\Object\ConstraintKeyObject Constraint keys are retrieved using `getConstraintKeys()`: -### Iterating Through Foreign Key Constraint Details - -```php +```php title="Iterating Through Foreign Key Constraint Details" $keys = $metadata->getConstraintKeys('fk_orders_customers', 'orders'); foreach ($keys as $key) { echo $key->getColumnName() . ' -> ' @@ -252,9 +236,7 @@ foreach ($keys as $key) { Outputs: -### Foreign Key Constraint Output - -```text +```text title="Foreign Key Constraint Output" customer_id -> customers.id ON UPDATE: CASCADE ON DELETE: RESTRICT @@ -264,9 +246,7 @@ customer_id -> customers.id All setter methods return `static` for fluent interface support: -### TriggerObject Class Definition - -```php +```php title="TriggerObject Class Definition" class PhpDb\Metadata\Object\TriggerObject { public function getName(): ?string; diff --git a/docs/book/profiler.md b/docs/book/profiler.md index 57b9ee291..269238d24 100644 --- a/docs/book/profiler.md +++ b/docs/book/profiler.md @@ -26,9 +26,7 @@ Once attached, the profiler automatically tracks all queries executed through th After executing queries, you can retrieve profiling information: -### Get the Last Profile - -```php +```php title="Get the Last Profile" $adapter->query('SELECT * FROM users WHERE status = ?', ['active']); $lastProfile = $profiler->getLastProfile(); @@ -43,9 +41,7 @@ $lastProfile = $profiler->getLastProfile(); // ] ``` -### Get All Profiles - -```php +```php title="Get All Profiles" // Execute several queries $adapter->query('SELECT * FROM users'); $adapter->query('SELECT * FROM orders WHERE user_id = ?', [42]); diff --git a/docs/book/result-set/advanced.md b/docs/book/result-set/advanced.md index b372fb48c..d2806bfdd 100644 --- a/docs/book/result-set/advanced.md +++ b/docs/book/result-set/advanced.md @@ -7,9 +7,7 @@ The `ResultSet` class extends `AbstractResultSet` and provides row data as either `ArrayObject` instances or plain arrays. -### ResultSet Class Definition - -```php +```php title="ResultSet Class Definition" namespace PhpDb\ResultSet; use ArrayObject; @@ -31,9 +29,7 @@ class ResultSet extends AbstractResultSet The `ResultSetReturnType` enum provides type-safe return type configuration: -### ResultSetReturnType Definition - -```php +```php title="ResultSetReturnType Definition" namespace PhpDb\ResultSet; enum ResultSetReturnType: string @@ -43,9 +39,7 @@ enum ResultSetReturnType: string } ``` -### Using ResultSetReturnType - -```php +```php title="Using ResultSetReturnType" use PhpDb\ResultSet\ResultSet; use PhpDb\ResultSet\ResultSetReturnType; @@ -66,9 +60,7 @@ $resultSet = new ResultSet(ResultSetReturnType::Array); **ArrayObject Mode** (default): -### ArrayObject Mode Example - -```php +```php title="ArrayObject Mode Example" $resultSet = new ResultSet(ResultSetReturnType::ArrayObject); $resultSet->initialize($result); @@ -80,9 +72,7 @@ foreach ($resultSet as $row) { **Array Mode:** -### Array Mode Example - -```php +```php title="Array Mode Example" $resultSet = new ResultSet(ResultSetReturnType::Array); $resultSet->initialize($result); @@ -97,9 +87,7 @@ The array mode is more memory efficient for large result sets. Complete API for `HydratingResultSet`: -### HydratingResultSet Class Definition - -```php +```php title="HydratingResultSet Class Definition" namespace PhpDb\ResultSet; use Laminas\Hydrator\HydratorInterface; @@ -126,17 +114,13 @@ class HydratingResultSet extends AbstractResultSet If no hydrator is provided, `ArraySerializableHydrator` is used by default: -### Default Hydrator - -```php +```php title="Default Hydrator" $resultSet = new HydratingResultSet(); ``` If no object prototype is provided, `ArrayObject` is used: -### Default Object Prototype - -```php +```php title="Default Object Prototype" $resultSet = new HydratingResultSet(new ReflectionHydrator()); ``` @@ -144,9 +128,7 @@ $resultSet = new HydratingResultSet(new ReflectionHydrator()); You can change the hydration strategy at runtime: -### Changing Hydrator at Runtime - -```php +```php title="Changing Hydrator at Runtime" use Laminas\Hydrator\ClassMethodsHydrator; use Laminas\Hydrator\ReflectionHydrator; @@ -169,9 +151,7 @@ result sets are not buffered until explicitly requested. Forces the result set to buffer all rows into memory: -### Buffering for Multiple Iterations - -```php +```php title="Buffering for Multiple Iterations" $resultSet = new ResultSet(); $resultSet->initialize($result); $resultSet->buffer(); @@ -189,9 +169,7 @@ foreach ($resultSet as $row) { **Important:** Calling `buffer()` after iteration has started throws `RuntimeException`: -### Buffer After Iteration Error - -```php +```php title="Buffer After Iteration Error" $resultSet = new ResultSet(); $resultSet->initialize($result); @@ -212,9 +190,7 @@ RuntimeException: Buffering must be enabled before iteration is started Checks if the result set is currently buffered: -### Checking Buffer Status - -```php +```php title="Checking Buffer Status" $resultSet = new ResultSet(); $resultSet->initialize($result); @@ -236,9 +212,7 @@ bool(true) Arrays and certain data sources are automatically buffered: -### Array Data Source Auto-Buffering - -```php +```php title="Array Data Source Auto-Buffering" $resultSet = new ResultSet(); $resultSet->initialize([ ['id' => 1, 'name' => 'Alice'], @@ -258,9 +232,7 @@ bool(true) When using ArrayObject mode (default), rows support both property and array access: -### Property and Array Access - -```php +```php title="Property and Array Access" $resultSet = new ResultSet(ResultSetReturnType::ArrayObject); $resultSet->initialize($result); @@ -285,9 +257,7 @@ This flexibility comes from `ArrayObject` being constructed with the You can provide a custom ArrayObject subclass: -### Custom Row Class with Helper Methods - -```php +```php title="Custom Row Class with Helper Methods" class CustomRow extends ArrayObject { public function getFullName(): string @@ -319,9 +289,7 @@ When `Adapter::query()` or `TableGateway::select()` execute, they: This ensures each query gets an isolated ResultSet instance: -### Independent Query Results - -```php +```php title="Independent Query Results" $resultSet1 = $adapter->query('SELECT * FROM users'); $resultSet2 = $adapter->query('SELECT * FROM posts'); ``` @@ -332,9 +300,7 @@ Both `$resultSet1` and `$resultSet2` are independent clones with their own state You can provide a custom ResultSet prototype to the Adapter: -### Custom Adapter Prototype - -```php +```php title="Custom Adapter Prototype" use PhpDb\Adapter\Adapter; use PhpDb\ResultSet\ResultSet; use PhpDb\ResultSet\ResultSetReturnType; @@ -352,9 +318,7 @@ Now all queries return plain arrays instead of ArrayObject instances. TableGateway also uses a ResultSet prototype: -### TableGateway with HydratingResultSet - -```php +```php title="TableGateway with HydratingResultSet" use PhpDb\ResultSet\HydratingResultSet; use PhpDb\TableGateway\TableGateway; use Laminas\Hydrator\ReflectionHydrator; @@ -393,9 +357,7 @@ foreach ($users as $user) { Buffer when you need to: -### Buffering for Count and Multiple Passes - -```php +```php title="Buffering for Count and Multiple Passes" $resultSet->buffer(); $count = $resultSet->count(); @@ -413,9 +375,7 @@ foreach ($resultSet as $row) { Don't buffer for single-pass large result sets: -### Streaming Large Result Sets - -```php +```php title="Streaming Large Result Sets" $resultSet = $adapter->query('SELECT * FROM huge_table'); foreach ($resultSet as $row) { @@ -425,9 +385,7 @@ foreach ($resultSet as $row) { ### Memory Efficiency Comparison -### Comparing Array vs ArrayObject Mode - -```php +```php title="Comparing Array vs ArrayObject Mode" use PhpDb\ResultSet\ResultSetReturnType; $arrayMode = new ResultSet(ResultSetReturnType::Array); @@ -446,9 +404,7 @@ object overhead. Switch hydrators based on context: -### Conditional Hydrator Selection - -```php +```php title="Conditional Hydrator Selection" use Laminas\Hydrator\ClassMethodsHydrator; use Laminas\Hydrator\ReflectionHydrator; @@ -465,9 +421,7 @@ if ($includePrivateProps) { Extract all rows as arrays: -### Using toArray() - -```php +```php title="Using toArray()" $resultSet = new ResultSet(); $resultSet->initialize($result); @@ -478,9 +432,7 @@ printf("Found %d rows\n", count($allRows)); With HydratingResultSet, `toArray()` uses the hydrator's extractor: -### toArray() with HydratingResultSet - -```php +```php title="toArray() with HydratingResultSet" $resultSet = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); $resultSet->initialize($result); @@ -493,9 +445,7 @@ Each row is extracted back to an array using the hydrator's `extract()` method. Get the current row without iteration: -### Getting First Row with current() - -```php +```php title="Getting First Row with current()" $resultSet = new ResultSet(); $resultSet->initialize($result); diff --git a/docs/book/result-set/examples.md b/docs/book/result-set/examples.md index a70ce7c0b..bc2102e72 100644 --- a/docs/book/result-set/examples.md +++ b/docs/book/result-set/examples.md @@ -55,9 +55,7 @@ foreach ($resultSet as $user) { } ``` -### Checking for Empty Results - -```php +```php title="Checking for Empty Results" $resultSet = $adapter->query('SELECT * FROM users WHERE id = ?', [999]); if ($resultSet->count() === 0) { @@ -273,9 +271,7 @@ foreach ($resultSet as $row) { } ``` -### Use Generators for Transformation - -```php +```php title="Use Generators for Transformation" function transformUsers(ResultSetInterface $resultSet): Generator { foreach ($resultSet as $row) { diff --git a/docs/book/result-set/intro.md b/docs/book/result-set/intro.md index 739dc1ae2..0f48c7534 100644 --- a/docs/book/result-set/intro.md +++ b/docs/book/result-set/intro.md @@ -59,9 +59,7 @@ derivative of `PhpDb\ResultSet\AbstractResultSet` will be used. The implementation of the `AbstractResultSet` offers the following core functionality: -### AbstractResultSet API - -```php +```php title="AbstractResultSet API" namespace PhpDb\ResultSet; use Iterator; @@ -102,9 +100,7 @@ The `HydratingResultSet` depends on [laminas-hydrator](https://docs.laminas.dev/laminas-hydrator), which you will need to install: -### Installing laminas-hydrator - -```bash +```bash title="Installing laminas-hydrator" composer require laminas/laminas-hydrator ``` @@ -113,9 +109,7 @@ iteration, `HydratingResultSet` will use the `Reflection` based hydrator to inject the row data directly into the protected members of the cloned `UserEntity` object: -### Using HydratingResultSet with ReflectionHydrator - -```php +```php title="Using HydratingResultSet with ReflectionHydrator" use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\ResultSet\HydratingResultSet; use Laminas\Hydrator\Reflection as ReflectionHydrator; diff --git a/docs/book/row-gateway.md b/docs/book/row-gateway.md index 11a5931d4..8340020f5 100644 --- a/docs/book/row-gateway.md +++ b/docs/book/row-gateway.md @@ -24,9 +24,7 @@ standalone, you need an `Adapter` instance and a set of data to work with. The following demonstrates a basic use case. -### Standalone RowGateway Usage - -```php +```php title="Standalone RowGateway Usage" use PhpDb\RowGateway\RowGateway; // Query the database: @@ -54,9 +52,7 @@ In that paradigm, `select()` operations will produce a `ResultSet` that iterates As an example: -### Using RowGateway with TableGateway - -```php +```php title="Using RowGateway with TableGateway" use PhpDb\TableGateway\Feature\RowGatewayFeature; use PhpDb\TableGateway\TableGateway; @@ -76,9 +72,7 @@ essentially making them behave similarly to the pattern), pass a prototype object implementing the `RowGatewayInterface` to the `RowGatewayFeature` constructor instead of a primary key: -### Custom ActiveRecord-Style Implementation - -```php +```php title="Custom ActiveRecord-Style Implementation" use PhpDb\TableGateway\Feature\RowGatewayFeature; use PhpDb\TableGateway\TableGateway; use PhpDb\RowGateway\RowGatewayInterface; diff --git a/docs/book/sql-ddl/advanced.md b/docs/book/sql-ddl/advanced.md index a83d91cf0..eb5d53eb7 100644 --- a/docs/book/sql-ddl/advanced.md +++ b/docs/book/sql-ddl/advanced.md @@ -399,9 +399,7 @@ $adapter->query($sql->buildSqlString($alter), $adapter::QUERY_MODE_EXECUTE); Foreign keys add overhead to INSERT/UPDATE/DELETE operations: -### Disabling Foreign Key Checks for Bulk Operations - -```php +```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 diff --git a/docs/book/sql-ddl/alter-drop.md b/docs/book/sql-ddl/alter-drop.md index d7e6f0056..50c73b212 100644 --- a/docs/book/sql-ddl/alter-drop.md +++ b/docs/book/sql-ddl/alter-drop.md @@ -4,9 +4,7 @@ The `AlterTable` class represents an `ALTER TABLE` statement. It provides methods to modify existing table structures. -### Basic AlterTable Creation - -```php +```php title="Basic AlterTable Creation" use PhpDb\Sql\Ddl\AlterTable; use PhpDb\Sql\TableIdentifier; @@ -249,9 +247,7 @@ $adapter->query( The `DropTable` class represents a `DROP TABLE` statement. -### Basic Drop Table - -```php +```php title="Basic Drop Table" use PhpDb\Sql\Ddl\DropTable; // Simple @@ -273,9 +269,7 @@ $adapter->query( DROP TABLE "old_table" ``` -### Schema-Qualified Drop - -```php +```php title="Schema-Qualified Drop" use PhpDb\Sql\Ddl\DropTable; use PhpDb\Sql\TableIdentifier; @@ -322,9 +316,7 @@ foreach ($tables as $tableName) { The DDL abstraction is designed to work across platforms without modification: -### Example of Platform-Agnostic DDL Code - -```php +```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')); @@ -340,9 +332,7 @@ $table->addColumn(new Column\Varchar('name', 255)); Use column options for platform-specific features: -### Using Platform-Specific Column Options - -```php +```php title="Using Platform-Specific Column Options" // MySQL AUTO_INCREMENT $id = new Column\Integer('id'); $id->setOption('AUTO_INCREMENT', true); @@ -360,9 +350,7 @@ $count->setOption('unsigned', true); ### Platform Detection -### Detecting Database Platform at Runtime - -```php +```php title="Detecting Database Platform at Runtime" // Check platform before using platform-specific options $platformName = $adapter->getPlatform()->getName(); @@ -377,9 +365,7 @@ if ($platformName === 'MySQL') { Use `getRawState()` to inspect the internal configuration of DDL objects: -### Using getRawState() to Inspect DDL Configuration - -```php +```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)); @@ -409,9 +395,7 @@ This is useful for: Use `TableIdentifier` for schema-qualified table references: -### Creating and Using Table Identifiers - -```php +```php title="Creating and Using Table Identifiers" use PhpDb\Sql\TableIdentifier; // Table in default schema @@ -439,9 +423,7 @@ $fk = new ForeignKey( ### Setting Nullable -### Configuring Nullable Columns - -```php +```php title="Configuring Nullable Columns" // NOT NULL (default for most types) $column = new Column\Varchar('email', 255); $column->setNullable(false); @@ -465,9 +447,7 @@ $column->setNullable(true); // Has no effect - still NOT NULL ### Setting Default Values -### Configuring Default Column Values - -```php +```php title="Configuring Default Column Values" // String default $column = new Column\Varchar('status', 20); $column->setDefault('pending'); @@ -495,9 +475,7 @@ All DDL objects support method chaining for cleaner, more readable code. ### Chaining Column Configuration -### Example of Fluent Column Configuration - -```php +```php title="Example of Fluent Column Configuration" $column = (new Column\Varchar('email', 255)) ->setNullable(false) ->setDefault('user@example.com') @@ -509,9 +487,7 @@ $table->addColumn($column); ### Chaining Table Construction -### Example of Fluent Table Construction - -```php +```php title="Example of Fluent Table Construction" $table = (new CreateTable('users')) ->addColumn( (new Column\Integer('id')) diff --git a/docs/book/sql-ddl/columns.md b/docs/book/sql-ddl/columns.md index b2e20ddb9..8fe63a876 100644 --- a/docs/book/sql-ddl/columns.md +++ b/docs/book/sql-ddl/columns.md @@ -8,9 +8,7 @@ All column types are in the `PhpDb\Sql\Ddl\Column` namespace and implement `Colu Standard integer column. -### Creating Integer Columns - -```php +```php title="Creating Integer Columns" use PhpDb\Sql\Ddl\Column\Integer; $column = new Integer('user_id'); @@ -36,9 +34,7 @@ $column->setOption('length', 11); For larger integer values (typically 64-bit). -### Creating BigInteger Columns - -```php +```php title="Creating BigInteger Columns" use PhpDb\Sql\Ddl\Column\BigInteger; $column = new BigInteger('large_number'); @@ -51,9 +47,7 @@ $column = new BigInteger('id', false, null, ['length' => 20]); Fixed-point decimal numbers with precision and scale. -### Creating Decimal Columns with Precision and Scale - -```php +```php title="Creating Decimal Columns with Precision and Scale" use PhpDb\Sql\Ddl\Column\Decimal; $column = new Decimal('price', 10, 2); // DECIMAL(10,2) @@ -78,9 +72,7 @@ $column->setDecimal(3); // Change scale Floating-point numbers. -### Creating Floating Point Columns - -```php +```php title="Creating Floating Point Columns" use PhpDb\Sql\Ddl\Column\Floating; $column = new Floating('measurement', 10, 2); @@ -101,9 +93,7 @@ $column->setDecimal(4); Variable-length character string. -### Creating Varchar Columns - -```php +```php title="Creating Varchar Columns" use PhpDb\Sql\Ddl\Column\Varchar; $column = new Varchar('name', 255); @@ -125,9 +115,7 @@ $column->setNullable(true); Fixed-length character string. -### Creating Fixed-Length Char Columns - -```php +```php title="Creating Fixed-Length Char Columns" use PhpDb\Sql\Ddl\Column\Char; $column = new Char('country_code', 2); // ISO country codes @@ -140,9 +128,7 @@ $column = new Char('status', 1); // Single character status Variable-length text for large strings. -### Creating Text Columns - -```php +```php title="Creating Text Columns" use PhpDb\Sql\Ddl\Column\Text; $column = new Text('description'); @@ -160,9 +146,7 @@ $column = new Text('notes', null, true, 'No notes'); Fixed-length binary data. -### Creating Binary Columns - -```php +```php title="Creating Binary Columns" use PhpDb\Sql\Ddl\Column\Binary; $column = new Binary('hash', 32); // 32-byte hash @@ -174,9 +158,7 @@ $column = new Binary('hash', 32); // 32-byte hash Variable-length binary data. -### Creating Varbinary Columns - -```php +```php title="Creating Varbinary Columns" use PhpDb\Sql\Ddl\Column\Varbinary; $column = new Varbinary('file_data', 65535); @@ -188,9 +170,7 @@ $column = new Varbinary('file_data', 65535); Binary large object for very large binary data. -### Creating Blob Columns - -```php +```php title="Creating Blob Columns" use PhpDb\Sql\Ddl\Column\Blob; $column = new Blob('image'); @@ -205,9 +185,7 @@ $column = new Blob('document', 16777215); // MEDIUMBLOB size Date without time. -### Creating Date Columns - -```php +```php title="Creating Date Columns" use PhpDb\Sql\Ddl\Column\Date; $column = new Date('birth_date'); @@ -220,9 +198,7 @@ $column = new Date('hire_date'); Time without date. -### Creating Time Columns - -```php +```php title="Creating Time Columns" use PhpDb\Sql\Ddl\Column\Time; $column = new Time('start_time'); @@ -235,9 +211,7 @@ $column = new Time('duration'); Date and time combined. -### Creating Datetime Columns - -```php +```php title="Creating Datetime Columns" use PhpDb\Sql\Ddl\Column\Datetime; $column = new Datetime('last_login'); @@ -250,9 +224,7 @@ $column = new Datetime('event_time'); Timestamp with special capabilities. -### Creating Timestamp Columns with Auto-Update - -```php +```php title="Creating Timestamp Columns with Auto-Update" use PhpDb\Sql\Ddl\Column\Timestamp; // Basic timestamp @@ -278,9 +250,7 @@ $column->setOption('on_update', true); Boolean/bit column. **Note:** Boolean columns are always NOT NULL and cannot be made nullable. -### Creating Boolean Columns - -```php +```php title="Creating Boolean Columns" use PhpDb\Sql\Ddl\Column\Boolean; $column = new Boolean('is_active'); @@ -300,9 +270,7 @@ $column->setNullable(true); // Does nothing - stays NOT NULL Generic column type (defaults to INTEGER). Use specific types when possible. -### Creating Generic Columns - -```php +```php title="Creating Generic Columns" use PhpDb\Sql\Ddl\Column\Column; $column = new Column('custom_field'); @@ -314,9 +282,7 @@ $column = new Column('custom_field'); All column types share these methods: -### Working with Nullable, Defaults, Options, and Constraints - -```php +```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) @@ -347,9 +313,7 @@ Column options provide a flexible way to specify platform-specific features and ### Setting Options -### Setting Single and Multiple Column Options - -```php +```php title="Setting Single and Multiple Column Options" // Set single option $column->setOption('option_name', 'option_value'); @@ -375,9 +339,7 @@ $options = $column->getOptions(); ### MySQL/MariaDB Specific Options -### Using MySQL-Specific Column Modifiers - -```php +```php title="Using MySQL-Specific Column Modifiers" // UNSIGNED modifier $column = new Column\Integer('count'); $column->setOption('unsigned', true); @@ -401,9 +363,7 @@ $column->setOption('collation', 'utf8mb4_unicode_ci'); ### PostgreSQL Specific Options -### Creating Serial/Identity Columns in PostgreSQL - -```php +```php title="Creating Serial/Identity Columns in PostgreSQL" // SERIAL type (via identity option) $id = new Column\Integer('id'); $id->setOption('identity', true); @@ -412,9 +372,7 @@ $id->setOption('identity', true); ### SQL Server Specific Options -### Creating Identity Columns in SQL Server - -```php +```php title="Creating Identity Columns in SQL Server" // IDENTITY column $id = new Column\Integer('id'); $id->setOption('identity', true); @@ -425,9 +383,7 @@ $id->setOption('identity', true); #### Auto-Incrementing Primary Key -### Creating Auto-Incrementing Primary Keys - -```php +```php title="Creating Auto-Incrementing Primary Keys" // MySQL $id = new Column\Integer('id'); $id->setOption('AUTO_INCREMENT', true); @@ -443,9 +399,7 @@ $table->addColumn($id); #### Timestamp with Auto-Update -### Creating Self-Updating Timestamp Columns - -```php +```php title="Creating Self-Updating Timestamp Columns" $updated = new Column\Timestamp('updated_at'); $updated->setDefault('CURRENT_TIMESTAMP'); $updated->setOption('on_update', true); @@ -455,9 +409,7 @@ $table->addColumn($updated); #### Documented Column with Comment -### Adding Comments to Column Definitions - -```php +```php title="Adding Comments to Column Definitions" $column = new Column\Varchar('email', 255); $column->setOption('comment', 'User email address for authentication'); $table->addColumn($column); @@ -523,9 +475,7 @@ $notes = new Column\Text('notes'); // User notes ### Date/Time Types -### Choosing the Right Date and Time Type - -```php +```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'); diff --git a/docs/book/sql-ddl/constraints.md b/docs/book/sql-ddl/constraints.md index b7f6286f1..9bc0f3012 100644 --- a/docs/book/sql-ddl/constraints.md +++ b/docs/book/sql-ddl/constraints.md @@ -6,9 +6,7 @@ Constraints enforce data integrity rules at the database level. All constraints A primary key uniquely identifies each row in a table. -### Single-Column Primary Key - -```php +```php title="Single-Column Primary Key" use PhpDb\Sql\Ddl\Constraint\PrimaryKey; // Simple - name is optional @@ -58,9 +56,7 @@ $table->addColumn($id); Foreign keys enforce referential integrity between tables. -### Basic Foreign Key - -```php +```php title="Basic Foreign Key" use PhpDb\Sql\Ddl\Constraint\ForeignKey; $fk = new ForeignKey( @@ -104,9 +100,7 @@ $fk = new ForeignKey( **Common Patterns:** -### Common Foreign Key Action Patterns - -```php +```php title="Common Foreign Key Action Patterns" // Delete child records when parent is deleted $fk = new ForeignKey('fk_name', 'parent_id', 'parents', 'id', 'CASCADE'); @@ -144,9 +138,7 @@ CONSTRAINT "fk_user_tenant" FOREIGN KEY ("user_id", "tenant_id") Unique constraints ensure column values are unique across all rows. -### Single-Column Unique Constraint - -```php +```php title="Single-Column Unique Constraint" use PhpDb\Sql\Ddl\Constraint\UniqueKey; // Simple - name is optional @@ -186,9 +178,7 @@ CONSTRAINT "unique_username_per_tenant" UNIQUE ("username", "tenant_id") Check constraints enforce custom validation rules. -### Simple Check Constraints - -```php +```php title="Simple Check Constraints" use PhpDb\Sql\Ddl\Constraint\Check; // Age must be 18 or older @@ -204,9 +194,7 @@ $check = new Check('email LIKE "%@%"', 'check_email_format'); $table->addConstraint($check); ``` -### Complex Check Constraints - -```php +```php title="Complex Check Constraints" // Discount percentage must be between 0 and 100 $check = new Check( 'discount_percent >= 0 AND discount_percent <= 100', @@ -270,9 +258,7 @@ $check = new Check($expr, 'check_discount_range'); Indexes improve query performance by creating fast lookup structures. The `Index` class is in the `PhpDb\Sql\Ddl\Index` namespace. -### Basic Index Creation - -```php +```php title="Basic Index Creation" use PhpDb\Sql\Ddl\Index\Index; // Single column index @@ -377,9 +363,7 @@ $alter->dropIndex('idx_deprecated_field'); While some constraints allow optional names, it's a best practice to always provide explicit names: -### Best Practice: Using Explicit Constraint Names - -```php +```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')); @@ -425,9 +409,7 @@ $alter->dropConstraint('fk_user_role'); ### Index Best Practices -### Implementing Indexing Best Practices - -```php +```php title="Implementing Indexing Best Practices" // 1. Index foreign keys $table->addColumn(new Column\Integer('user_id')); $table->addConstraint(new Constraint\ForeignKey( @@ -452,9 +434,7 @@ $table->addConstraint(new Index('title', 'idx_title', [100])); // Index first 10 ### Index Order Matters -### Optimal vs Suboptimal Index Column Order - -```php +```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 diff --git a/docs/book/sql-ddl/examples.md b/docs/book/sql-ddl/examples.md index 5fc376793..757c565f4 100644 --- a/docs/book/sql-ddl/examples.md +++ b/docs/book/sql-ddl/examples.md @@ -2,9 +2,7 @@ ## Example 1: E-Commerce Product Table -### Creating a Complete Product Table with Constraints and Indexes - -```php +```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; @@ -73,9 +71,7 @@ $adapter->query($sql->buildSqlString($table), $adapter::QUERY_MODE_EXECUTE); ## Example 2: User Authentication System -### Building a Multi-Table User Authentication Schema with Roles - -```php +```php title="Building a Multi-Table User Authentication Schema with Roles" // Users table $users = new CreateTable('users'); @@ -153,9 +149,7 @@ $adapter->query($sql->buildSqlString($userRoles), $adapter::QUERY_MODE_EXECUTE); ## Example 3: Multi-Tenant Schema -### Implementing Cross-Schema Tables with Foreign Key References - -```php +```php title="Implementing Cross-Schema Tables with Foreign Key References" use PhpDb\Sql\TableIdentifier; // Tenants table (in public schema) @@ -207,9 +201,7 @@ $adapter->query($sql->buildSqlString($tenantUsers), $adapter::QUERY_MODE_EXECUTE ## Example 4: Database Migration Pattern -### Creating Reversible Migration Classes with Up and Down Methods - -```php +```php title="Creating Reversible Migration Classes with Up and Down Methods" use PhpDb\Sql\Sql; use PhpDb\Sql\Ddl; @@ -290,9 +282,7 @@ class Migration_002_AddUserProfiles ## Example 5: Audit Log Table -### Designing an Audit Trail Table for Tracking Data Changes - -```php +```php title="Designing an Audit Trail Table for Tracking Data Changes" $auditLog = new CreateTable('audit_log'); // Auto-increment ID @@ -343,9 +333,7 @@ $adapter->query($sql->buildSqlString($auditLog), $adapter::QUERY_MODE_EXECUTE); ## Example 6: Session Storage Table -### Building a Database-Backed Session Storage System - -```php +```php title="Building a Database-Backed Session Storage System" $sessions = new CreateTable('sessions'); // Session ID as primary key (not auto-increment) @@ -398,9 +386,7 @@ $adapter->query($sql->buildSqlString($sessions), $adapter::QUERY_MODE_EXECUTE); ## Example 7: File Storage Metadata Table -### Implementing File Metadata Storage with UUID Primary Keys - -```php +```php title="Implementing File Metadata Storage with UUID Primary Keys" $files = new CreateTable('files'); // UUID as primary key @@ -453,9 +439,7 @@ $adapter->query($sql->buildSqlString($files), $adapter::QUERY_MODE_EXECUTE); ### Issue: Table Already Exists -### Safely Creating Tables with Existence Checks - -```php +```php title="Safely Creating Tables with Existence Checks" // Check before creating function createTableIfNotExists($adapter, CreateTable $table) { $sql = new Sql($adapter); @@ -479,9 +463,7 @@ function createTableIfNotExists($adapter, CreateTable $table) { ### Issue: Foreign Key Constraint Fails -### Ensuring Correct Table Creation Order for Foreign Keys - -```php +```php title="Ensuring Correct Table Creation Order for Foreign Keys" // Ensure referenced table exists first $sql = new Sql($adapter); @@ -498,9 +480,7 @@ $adapter->query($sql->buildSqlString($userRoles), $adapter::QUERY_MODE_EXECUTE); ### Issue: Column Type Mismatch in Foreign Key -### Matching Column Types Between Parent and Child Tables - -```php +```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 @@ -519,9 +499,7 @@ $childTable->addConstraint(new Constraint\ForeignKey( ### Issue: Index Too Long -### Using Prefix Indexes for Long Text Columns - -```php +```php title="Using Prefix Indexes for Long Text Columns" // Use prefix indexes for long text columns $table->addConstraint(new Index( 'long_description', diff --git a/docs/book/sql-ddl/intro.md b/docs/book/sql-ddl/intro.md index b9b712382..49c31387f 100644 --- a/docs/book/sql-ddl/intro.md +++ b/docs/book/sql-ddl/intro.md @@ -11,9 +11,7 @@ The typical workflow for using DDL abstraction: 3. **Generate SQL** using `Sql::buildSqlString()` 4. **Execute** using `Adapter::query()` with `QUERY_MODE_EXECUTE` -### Creating and Executing a Simple Table - -```php +```php title="Creating and Executing a Simple Table" use PhpDb\Sql\Sql; use PhpDb\Sql\Ddl\CreateTable; use PhpDb\Sql\Ddl\Column; @@ -37,9 +35,7 @@ $adapter->query( The `CreateTable` class represents a `CREATE TABLE` statement. You can build complex table definitions using a fluent, object-oriented interface. -### Basic Table Creation - -```php +```php title="Basic Table Creation" use PhpDb\Sql\Ddl\CreateTable; use PhpDb\Sql\Ddl\Column; @@ -201,9 +197,7 @@ $table = (new CreateTable('users')) ->addConstraint(new Constraint\UniqueKey('email', 'unique_user_email')); ``` -### Complete Example: User Table - -```php +```php title="Complete Example: User Table" use PhpDb\Sql\Ddl\CreateTable; use PhpDb\Sql\Ddl\Column; use PhpDb\Sql\Ddl\Constraint; diff --git a/docs/book/sql/advanced.md b/docs/book/sql/advanced.md index e079dbedd..4540209dd 100644 --- a/docs/book/sql/advanced.md +++ b/docs/book/sql/advanced.md @@ -6,9 +6,7 @@ Use `Literal` for static SQL fragments without parameters: -### Creating static SQL literals - -```php +```php title="Creating static SQL literals" use PhpDb\Sql\Literal; $literal = new Literal('NOW()'); @@ -18,18 +16,14 @@ $literal = new Literal('COUNT(*)'); Use `Expression` when parameters are needed: -### Creating expressions with parameters - -```php +```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']); ``` -### Mixed parameter types in expressions - -```php +```php title="Mixed parameter types in expressions" use PhpDb\Sql\Argument; $expression = new Expression( @@ -45,15 +39,11 @@ $expression = new Expression( Produces: -### SQL output for mixed parameter types - -```sql +```sql title="SQL output for mixed parameter types" CASE WHEN age > 18 THEN ADULT ELSE MINOR END ``` -### Array values in expressions - -```php +```php title="Array values in expressions" $expression = new Expression( 'id IN (?)', [Argument::value([1, 2, 3, 4, 5])] @@ -62,15 +52,11 @@ $expression = new Expression( Produces: -### SQL output for array values - -```sql +```sql title="SQL output for array values" id IN (?, ?, ?, ?, ?) ``` -### Nested expressions - -```php +```php title="Nested expressions" $innerExpression = new Expression('COUNT(*)'); $outerExpression = new Expression( 'CASE WHEN ? > ? THEN ? ELSE ? END', @@ -85,15 +71,11 @@ $outerExpression = new Expression( Produces: -### SQL output for nested expressions - -```sql +```sql title="SQL output for nested expressions" CASE WHEN COUNT(*) > 10 THEN HIGH ELSE LOW END ``` -### Using database-specific functions - -```php +```php title="Using database-specific functions" use PhpDb\Sql\Predicate; $select->where(new Predicate\Expression( @@ -112,9 +94,7 @@ For detailed information on Arguments and Argument Types, see the [SQL Introduct The `Combine` class enables combining multiple SELECT statements using UNION, INTERSECT, or EXCEPT operations. -### Basic Combine usage with UNION - -```php +```php title="Basic Combine usage with UNION" use PhpDb\Sql\Combine; $select1 = $sql->select('table1')->where(['status' => 'active']); @@ -124,9 +104,7 @@ $combine = new Combine($select1, Combine::COMBINE_UNION); $combine->combine($select2); ``` -### Combine API - -```php +```php title="Combine API" class Combine extends AbstractPreparableSql { final public const COMBINE_UNION = 'union'; @@ -151,9 +129,7 @@ class Combine extends AbstractPreparableSql } ``` -### UNION - -```php +```php title="UNION" $combine = new Combine(); $combine->union($select1); $combine->union($select2, 'ALL'); // UNION ALL keeps duplicates @@ -161,9 +137,7 @@ $combine->union($select2, 'ALL'); // UNION ALL keeps duplicates Produces: -### SQL output for UNION ALL - -```sql +```sql title="SQL output for UNION ALL" (SELECT * FROM table1 WHERE status = 'active') UNION ALL (SELECT * FROM table2 WHERE status = 'pending') @@ -208,9 +182,7 @@ $combine->alignColumns(); Produces: -### SQL output for aligned columns - -```sql +```sql title="SQL output for aligned columns" (SELECT id, amount, NULL AS reason FROM orders) UNION (SELECT id, amount, reason FROM refunds) diff --git a/docs/book/sql/examples.md b/docs/book/sql/examples.md index 5937045c3..701a8e3d9 100644 --- a/docs/book/sql/examples.md +++ b/docs/book/sql/examples.md @@ -40,17 +40,13 @@ $select->where->isNull('deletedAt') In UPDATE statements: -### Setting NULL Values in UPDATE - -```php +```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`: -### Checking for NULL or Empty Values - -```php +```php title="Checking for NULL or Empty Values" $select->where->nest() ->isNull('field') ->or @@ -128,9 +124,7 @@ applyPagination($select, 2, 25); If you encounter errors about empty WHERE clauses: -### UPDATE Without WHERE Clause (Wrong) - -```php +```php title="UPDATE Without WHERE Clause (Wrong)" $update = $sql->update('users'); $update->set(['status' => 'inactive']); // This will trigger empty WHERE protection! @@ -138,17 +132,13 @@ $update->set(['status' => 'inactive']); Always include a WHERE clause for UPDATE and DELETE: -### Adding WHERE Clause to UPDATE - -```php +```php title="Adding WHERE Clause to UPDATE" $update->where(['id' => 123]); ``` To intentionally update all rows (use with extreme caution): -### Checking Empty WHERE Protection Status - -```php +```php title="Checking Empty WHERE Protection Status" // Check the raw state to understand the protection status $state = $update->getRawState(); $protected = $state['emptyWhereProtection']; @@ -158,18 +148,14 @@ $protected = $state['emptyWhereProtection']; When using Expression with placeholders: -### Incorrect Parameter Count - -```php +```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. -### Correct Parameter Count - -```php +```php title="Correct Parameter Count" // CORRECT $expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b', 'c']); ``` @@ -178,18 +164,14 @@ $expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b', 'c']); Different databases use different quote characters. Let the platform handle quoting: -### Proper Platform-Managed Quoting - -```php +```php title="Proper Platform-Managed Quoting" // CORRECT - let the platform handle quoting $select->from('users'); ``` Avoid manually quoting identifiers: -### Avoid Manual Quoting - -```php +```php title="Avoid Manual Quoting" // WRONG - don't manually quote $select->from('"users"'); ``` @@ -198,9 +180,7 @@ $select->from('"users"'); When comparing two identifiers (column to column), specify both types: -### Column Comparison Using Type Constants - -```php +```php title="Column Comparison Using Type Constants" // Using type constants $where->equalTo( 'table1.columnA', @@ -212,9 +192,7 @@ $where->equalTo( Or use the Argument class for better readability: -### Column Comparison Using Argument Class - -```php +```php title="Column Comparison Using Argument Class" // Using Argument class (recommended) use PhpDb\Sql\Argument; @@ -265,9 +243,7 @@ $select->limit(100); For pagination, combine with `offset()`: -### Pagination with Limit and Offset - -```php +```php title="Pagination with Limit and Offset" $select->limit(25)->offset(50); ``` @@ -275,18 +251,14 @@ $select->limit(25)->offset(50); Instead of selecting all columns: -### Selecting All Columns (Avoid) - -```php +```php title="Selecting All Columns (Avoid)" // Avoid - selects all columns $select->from('users'); ``` Specify only the columns you need: -### Selecting Specific Columns - -```php +```php title="Selecting Specific Columns" // Better - only select what's needed $select->from('users')->columns(['id', 'username', 'email']); ``` @@ -297,9 +269,7 @@ This reduces memory usage and network transfer. Use JOINs instead of multiple queries: -### Using JOINs to Avoid N+1 Queries - -```php +```php title="Using JOINs to Avoid N+1 Queries" // WRONG - N+1 queries foreach ($orders as $order) { $customer = getCustomer($order['customerId']); // Additional query per order @@ -315,9 +285,7 @@ $select->from('orders') Structure WHERE clauses to use database indexes: -### Index-Friendly WHERE Clause - -```php +```php title="Index-Friendly WHERE Clause" // Good - can use index on indexedColumn $select->where->equalTo('indexedColumn', $value) ->greaterThan('date', '2024-01-01'); @@ -325,27 +293,21 @@ $select->where->equalTo('indexedColumn', $value) Avoid functions on indexed columns in WHERE: -### Functions on Indexed Columns (Prevents Index Usage) - -```php +```php title="Functions on Indexed Columns (Prevents Index Usage)" // BAD - prevents index usage $select->where(new Predicate\Expression('YEAR(createdAt) = ?', [2024])); ``` Instead, use ranges: -### Using Ranges for Index-Friendly Queries - -```php +```php title="Using Ranges for Index-Friendly Queries" // GOOD - allows index usage $select->where->between('createdAt', '2024-01-01', '2024-12-31'); ``` ## Complete Examples -### Complex Reporting Query with Aggregation - -```php +```php title="Complex Reporting Query with Aggregation" use PhpDb\Sql\Sql; use PhpDb\Sql\Select; use PhpDb\Sql\Expression; @@ -387,9 +349,7 @@ $results = $statement->execute(); Produces: -### Generated SQL for Reporting Query - -```sql +```sql title="Generated SQL for Reporting Query" SELECT orders.customerId, YEAR(createdAt) AS orderYear, COUNT(*) AS orderCount, @@ -407,9 +367,7 @@ ORDER BY totalRevenue DESC, orderYear DESC LIMIT 100 ``` -### Data Migration with INSERT SELECT - -```php +```php title="Data Migration with INSERT SELECT" $select = $sql->select('importedUsers') ->columns(['username', 'email', 'firstName', 'lastName']) ->where(['validated' => true]) @@ -425,18 +383,14 @@ $statement->execute(); Produces: -### Generated SQL for INSERT SELECT - -```sql +```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 ``` -### Combining Multiple Result Sets - -```php +```php title="Combining Multiple Result Sets" use PhpDb\Sql\Combine; use PhpDb\Sql\Literal; @@ -464,9 +418,7 @@ $results = $statement->execute(); Produces: -### Generated SQL for UNION Query - -```sql +```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) @@ -474,9 +426,7 @@ UNION (SELECT id, name, email, "suspended" AS status FROM users WHERE suspended = 1) ``` -### Search with Full-Text and Filters - -```php +```php title="Search with Full-Text and Filters" use PhpDb\Sql\Predicate; $select = $sql->select('products') @@ -515,9 +465,7 @@ $select = $sql->select('products') ->limit(50); ``` -### Batch Update with Transaction - -```php +```php title="Batch Update with Transaction" $connection = $adapter->getDriver()->getConnection(); $connection->beginTransaction(); diff --git a/docs/book/sql/insert.md b/docs/book/sql/insert.md index cd8a90251..44337d47b 100644 --- a/docs/book/sql/insert.md +++ b/docs/book/sql/insert.md @@ -4,9 +4,7 @@ The `Insert` class provides an API for building SQL INSERT statements. ## Insert API -### Insert Class Definition - -```php +```php title="Insert Class Definition" class Insert extends AbstractPreparableSql implements SqlInterface, PreparableSqlInterface { final public const VALUES_MERGE = 'merge'; @@ -29,9 +27,7 @@ As with `Select`, the table may be provided during instantiation or via the ## Basic Usage -### Creating a Basic Insert Statement - -```php +```php title="Creating a Basic Insert Statement" use PhpDb\Sql\Sql; $sql = new Sql($adapter); @@ -49,9 +45,7 @@ $statement->execute(); Produces: -### Generated SQL Output - -```sql +```sql title="Generated SQL Output" INSERT INTO users (username, email, created_at) VALUES (?, ?, ?) ``` @@ -59,17 +53,13 @@ INSERT INTO users (username, email, created_at) VALUES (?, ?, ?) The `columns()` method explicitly sets which columns will receive values: -### Setting Valid Columns - -```php +```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: -### Restricting Columns with Validation - -```php +```php title="Restricting Columns with Validation" $insert->columns(['username', 'email']); $insert->values([ 'username' => 'john', @@ -83,9 +73,7 @@ $insert->values([ The default behavior of values is to set the values. Successive calls will not preserve values from previous calls. -### Setting Values for Insert - -```php +```php title="Setting Values for Insert" $insert->values([ 'col_1' => 'value1', 'col_2' => 'value2', @@ -95,18 +83,14 @@ $insert->values([ To merge values with previous calls, provide the appropriate flag: `PhpDb\Sql\Insert::VALUES_MERGE` -### Merging Values from Multiple Calls - -```php +```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: -### Merged Values SQL Output - -```sql +```sql title="Merged Values SQL Output" INSERT INTO table (col_1, col_2) VALUES (?, ?) ``` @@ -115,9 +99,7 @@ INSERT INTO table (col_1, col_2) VALUES (?, ?) The `select()` method enables INSERT INTO ... SELECT statements, copying data from one table to another. -### INSERT INTO SELECT Statement - -```php +```php title="INSERT INTO SELECT Statement" $select = $sql->select('tempUsers') ->columns(['username', 'email', 'createdAt']) ->where(['imported' => false]); @@ -129,18 +111,14 @@ $insert->select($select); Produces: -### INSERT SELECT SQL Output - -```sql +```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()`: -### Passing Select to values() Method - -```php +```php title="Passing Select to values() Method" $insert->values($select); ``` @@ -151,9 +129,7 @@ Important: The column order must match between INSERT columns and SELECT columns The Insert class supports property-style access to columns as an alternative to using `values()`: -### Using Property-style Column Access - -```php +```php title="Using Property-style Column Access" $insert = $sql->insert('users'); $insert->name = 'John'; $insert->email = 'john@example.com'; @@ -167,9 +143,7 @@ unset($insert->email); This is equivalent to: -### Equivalent values() Method Call - -```php +```php title="Equivalent values() Method Call" $insert->values([ 'name' => 'John', 'email' => 'john@example.com', @@ -181,9 +155,7 @@ $insert->values([ The `InsertIgnore` class provides MySQL-specific INSERT IGNORE syntax, which silently ignores rows that would cause duplicate key errors. -### Using InsertIgnore for Duplicate Prevention - -```php +```php title="Using InsertIgnore for Duplicate Prevention" use PhpDb\Sql\InsertIgnore; $insert = new InsertIgnore('users'); @@ -195,9 +167,7 @@ $insert->values([ Produces: -### INSERT IGNORE SQL Output - -```sql +```sql title="INSERT IGNORE SQL Output" INSERT IGNORE INTO users (username, email) VALUES (?, ?) ``` @@ -209,9 +179,7 @@ for this behavior (e.g., INSERT ... ON CONFLICT DO NOTHING in PostgreSQL). ## Examples -### Basic insert with prepared statement - -```php +```php title="Basic insert with prepared statement" $insert = $sql->insert('products'); $insert->values([ 'name' => 'Widget', @@ -227,9 +195,7 @@ $result = $statement->execute(); $lastId = $adapter->getDriver()->getLastGeneratedValue(); ``` -### Insert with expressions - -```php +```php title="Insert with expressions" $insert = $sql->insert('logs'); $insert->values([ 'message' => 'User logged in', @@ -238,9 +204,7 @@ $insert->values([ ]); ``` -### Bulk insert from select - -```php +```php title="Bulk insert from select" // Copy active users to an archive table $select = $sql->select('users') ->columns(['id', 'username', 'email', 'created_at']) @@ -254,9 +218,7 @@ $statement = $sql->prepareStatementForSqlObject($insert); $statement->execute(); ``` -### Conditional insert with InsertIgnore - -```php +```php title="Conditional insert with InsertIgnore" // Import users, skipping duplicates $users = [ ['username' => 'alice', 'email' => 'alice@example.com'], diff --git a/docs/book/sql/intro.md b/docs/book/sql/intro.md index 14e42883b..62825d1f7 100644 --- a/docs/book/sql/intro.md +++ b/docs/book/sql/intro.md @@ -6,9 +6,7 @@ The `PhpDb\Sql\Sql` class creates the four primary DML statement types: `Select`, `Insert`, `Update`, and `Delete`. -### Creating SQL Statement Objects - -```php +```php title="Creating SQL Statement Objects" use PhpDb\Sql\Sql; $sql = new Sql($adapter); @@ -58,9 +56,7 @@ $results = $adapter->query($selectString, $adapter::QUERY_MODE_EXECUTE); obtaining a `Select`, `Insert`, `Update`, or `Delete` instance, the object will be seeded with the table: -### Binding to a Default Table - -```php +```php title="Binding to a Default Table" use PhpDb\Sql\Sql; $sql = new Sql($adapter, 'foo'); @@ -72,9 +68,7 @@ $select->where(['id' => 2]); // $select already has from('foo') applied Each of these objects implements the following two interfaces: -### PreparableSqlInterface and SqlInterface - -```php +```php title="PreparableSqlInterface and SqlInterface" interface PreparableSqlInterface { public function prepareStatement( @@ -112,9 +106,7 @@ The `ArgumentType` enum defines six types, each backed by its corresponding clas All argument classes are `readonly` and implement `ArgumentInterface`: -### Using Argument Factory and Classes - -```php +```php title="Using Argument Factory and Classes" use PhpDb\Sql\Argument; // Using the Argument factory class (recommended) @@ -134,9 +126,7 @@ $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: -### Type-Safe Expression Arguments - -```php +```php title="Type-Safe Expression Arguments" use PhpDb\Sql\Argument; use PhpDb\Sql\Expression; @@ -170,18 +160,14 @@ Scalar values passed directly to `Expression` are automatically wrapped: The `Sql` class serves as a factory for creating SQL statement objects and provides methods for preparing and building SQL strings. -### Instantiating the Sql Factory - -```php +```php title="Instantiating the Sql Factory" use PhpDb\Sql\Sql; $sql = new Sql($adapter); $sql = new Sql($adapter, 'defaultTable'); ``` -### Factory Methods - -```php +```php title="Factory Methods" $select = $sql->select(); $select = $sql->select('users'); @@ -232,9 +218,7 @@ $sqlString = $sql->buildSqlString($select); Note: Direct string building bypasses parameter binding. Use with caution and never with user input. -### Getting the SQL Platform - -```php +```php title="Getting the SQL Platform" $platform = $sql->getSqlPlatform(); ``` @@ -245,9 +229,7 @@ The platform object handles database-specific SQL generation and can be used for The `TableIdentifier` class provides a type-safe way to reference tables, especially when working with schemas or databases. -### Creating and Using TableIdentifier - -```php +```php title="Creating and Using TableIdentifier" use PhpDb\Sql\TableIdentifier; $table = new TableIdentifier('users', 'production'); diff --git a/docs/book/sql/select.md b/docs/book/sql/select.md index fbfe4f9de..54101c7cf 100644 --- a/docs/book/sql/select.md +++ b/docs/book/sql/select.md @@ -22,9 +22,7 @@ later to change the name of the table. Once you have a valid `Select` object, the following API can be used to further specify various select statement parts: -### Select class definition and constants - -```php +```php title="Select class definition and constants" class Select extends AbstractPreparableSql implements SqlInterface, PreparableSqlInterface { final public const JOIN_INNER = 'inner'; @@ -87,9 +85,7 @@ class Select extends AbstractPreparableSql implements SqlInterface, PreparableSq ## from() -### Specifying the FROM table - -```php +```php title="Specifying the FROM table" // As a string: $select->from('foo'); @@ -104,9 +100,7 @@ $select->from(['t' => new TableIdentifier('table')]); ## columns() -### Selecting columns - -```php +```php title="Selecting columns" // As an array of names $select->columns(['foo', 'bar']); @@ -126,9 +120,7 @@ $select->columns([ ## join() -### Basic JOIN examples - -```php +```php title="Basic JOIN examples" $select->join( 'foo', // table name 'id = bar.id', // expression to join on (will be quoted by platform), @@ -146,9 +138,7 @@ $select The `$on` parameter accepts either a string or a `PredicateInterface` for complex join conditions: -### JOIN with predicate conditions - -```php +```php title="JOIN with predicate conditions" use PhpDb\Sql\Predicate; $where = new Predicate\Predicate(); @@ -169,9 +159,7 @@ INNER JOIN orders ON orders.customerId = customers.id AND orders.amount > 100 ## order() -### Ordering results - -```php +```php title="Ordering results" $select = new Select; $select->order('id DESC'); // produces 'id' DESC @@ -186,9 +174,7 @@ $select->order(['name ASC', 'age DESC']); // produces 'name' ASC, 'age' DESC ## limit() and offset() -### Limiting and offsetting results - -```php +```php title="Limiting and offsetting results" $select = new Select; $select->limit(5); $select->offset(10); @@ -199,17 +185,13 @@ $select->offset(10); The `group()` method specifies columns for GROUP BY clauses, typically used with aggregate functions to group rows that share common values. -### Grouping by a single column - -```php +```php title="Grouping by a single column" $select->group('category'); ``` Multiple columns can be specified as an array, or by calling `group()` multiple times: -### Grouping by multiple columns - -```php +```php title="Grouping by multiple columns" $select->group(['category', 'status']); $select->group('category') @@ -218,9 +200,7 @@ $select->group('category') As an example with aggregate functions: -### Grouping with aggregate functions - -```php +```php title="Grouping with aggregate functions" $select->from('orders') ->columns([ 'customer_id', @@ -240,9 +220,7 @@ GROUP BY customer_id You can also use expressions in GROUP BY: -### Grouping with expressions - -```php +```php title="Grouping with expressions" $select->from('orders') ->columns([ 'orderYear' => new Expression('YEAR(created_at)'), @@ -264,9 +242,7 @@ GROUP BY YEAR(created_at) The `quantifier()` method applies a quantifier to the SELECT statement, such as DISTINCT or ALL. -### Using DISTINCT quantifier - -```php +```php title="Using DISTINCT quantifier" $select->from('orders') ->columns(['customer_id']) ->quantifier(Select::QUANTIFIER_DISTINCT); @@ -281,9 +257,7 @@ SELECT DISTINCT customer_id FROM orders The `QUANTIFIER_ALL` constant explicitly specifies ALL, though this is typically the default behavior: -### Using ALL quantifier - -```php +```php title="Using ALL quantifier" $select->quantifier(Select::QUANTIFIER_ALL); ``` @@ -292,9 +266,7 @@ $select->quantifier(Select::QUANTIFIER_ALL); The `reset()` method allows you to clear specific parts of a Select statement, useful when building queries dynamically. -### Building a Select query before reset - -```php +```php title="Building a Select query before reset" $select->from('users') ->columns(['id', 'name']) ->where(['status' => 'active']) @@ -310,9 +282,7 @@ SELECT id, name FROM users WHERE status = 'active' ORDER BY created_at DESC LIMI After resetting WHERE, ORDER, and LIMIT: -### Resetting specific parts of a query - -```php +```php title="Resetting specific parts of a query" $select->reset(Select::WHERE); $select->reset(Select::ORDER); $select->reset(Select::LIMIT); @@ -345,17 +315,13 @@ provided in the constructor (read-only table). The `getRawState()` method returns the internal state of the Select object, useful for debugging or introspection. -### Getting the full raw state - -```php +```php title="Getting the full raw state" $state = $select->getRawState(); ``` Returns an array containing: -### Raw state array structure - -```php +```php title="Raw state array structure" [ 'table' => 'users', 'quantifier' => null, @@ -373,9 +339,7 @@ Returns an array containing: You can also retrieve a specific state element: -### Getting specific state elements - -```php +```php title="Getting specific state elements" $table = $select->getRawState(Select::TABLE); $columns = $select->getRawState(Select::COLUMNS); $limit = $select->getRawState(Select::LIMIT); @@ -402,9 +366,7 @@ $combine->union($select2, 'ALL'); ### Multiple JOIN types in a single query -### Combining different JOIN types - -```php +```php title="Combining different JOIN types" $select->from(['u' => 'users']) ->join( ['o' => 'orders'], @@ -431,9 +393,7 @@ $select->from(['u' => 'users']) When you need to join a table only for filtering purposes without selecting its columns: -### Joining for filtering without selecting columns - -```php +```php title="Joining for filtering without selecting columns" $select->from('orders') ->join('customers', 'orders.customerId = customers.id', []) ->where(['customers.status' => 'premium']); @@ -449,9 +409,7 @@ WHERE customers.status = 'premium' ### JOIN with expressions in columns -### Using expressions in JOIN column selection - -```php +```php title="Using expressions in JOIN column selection" $select->from('users') ->join( 'orders', @@ -467,9 +425,7 @@ $select->from('users') The Join object can be accessed directly for programmatic manipulation: -### Programmatically accessing Join information - -```php +```php title="Programmatically accessing Join information" foreach ($select->joins as $join) { $tableName = $join['name']; $onCondition = $join['on']; diff --git a/docs/book/sql/update-delete.md b/docs/book/sql/update-delete.md index e4908ee06..0c340df70 100644 --- a/docs/book/sql/update-delete.md +++ b/docs/book/sql/update-delete.md @@ -4,9 +4,7 @@ The `Update` class provides an API for building SQL UPDATE statements. -### Update API - -```php +```php title="Update API" class Update extends AbstractPreparableSql implements SqlInterface, PreparableSqlInterface { final public const VALUES_MERGE = 'merge'; @@ -30,9 +28,7 @@ class Update extends AbstractPreparableSql implements SqlInterface, PreparableSq } ``` -### Basic Usage - -```php +```php title="Basic Usage" use PhpDb\Sql\Sql; $sql = new Sql($adapter); @@ -47,34 +43,26 @@ $statement->execute(); Produces: -### Generated SQL for basic update - -```sql +```sql title="Generated SQL for basic update" UPDATE users SET status = ? WHERE id = ? ``` ### set() -### Setting multiple values - -```php +```php title="Setting multiple values" $update->set(['foo' => 'bar', 'baz' => 'bax']); ``` The `set()` method accepts a flag parameter to control merging behavior: -### Controlling merge behavior with VALUES_SET and VALUES_MERGE - -```php +```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: -### Using numeric priority to control SET clause ordering - -```php +```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); @@ -82,9 +70,7 @@ $update->set(['flag' => true], 75); Produces SET clauses in priority order (50, 75, 100): -### Generated SQL showing priority-based ordering - -```sql +```sql title="Generated SQL showing priority-based ordering" UPDATE table SET status = ?, flag = ?, counter = ? ``` @@ -94,9 +80,7 @@ This is useful when the order of SET operations matters for certain database ope The `where()` method works the same as in Select queries. See the [Where and Having](where-having.md) documentation for full details. -### Using various where clause methods - -```php +```php title="Using various where clause methods" $update->where(['id' => 5]); $update->where->equalTo('status', 'active'); $update->where(function ($where) { @@ -108,17 +92,13 @@ $update->where(function ($where) { The Update class supports JOIN clauses for multi-table updates: -### Basic JOIN syntax - -```php +```php title="Basic JOIN syntax" $update->join('bar', 'foo.id = bar.foo_id', Update::JOIN_LEFT); ``` Example: -### Update with INNER JOIN on customers table - -```php +```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); @@ -127,9 +107,7 @@ $update->where(['customers.status' => 'inactive']); Produces: -### Generated SQL for update with JOIN - -```sql +```sql title="Generated SQL for update with JOIN" UPDATE orders INNER JOIN customers ON orders.customerId = customers.id SET status = ? @@ -143,9 +121,7 @@ PostgreSQL support this syntax, while some other databases may not. The `Delete` class provides an API for building SQL DELETE statements. -### Delete API - -```php +```php title="Delete API" class Delete extends AbstractPreparableSql implements SqlInterface, PreparableSqlInterface { public Where $where; @@ -160,9 +136,7 @@ class Delete extends AbstractPreparableSql implements SqlInterface, PreparableSq } ``` -### Delete Basic Usage - -```php +```php title="Delete Basic Usage" use PhpDb\Sql\Sql; $sql = new Sql($adapter); @@ -176,9 +150,7 @@ $statement->execute(); Produces: -### Generated SQL for basic delete - -```sql +```sql title="Generated SQL for basic delete" DELETE FROM users WHERE id = ? ``` @@ -186,9 +158,7 @@ DELETE FROM users WHERE id = ? The `where()` method works the same as in Select queries. See the [Where and Having](where-having.md) documentation for full details. -### Using where conditions in delete statements - -```php +```php title="Using where conditions in delete statements" $delete->where(['status' => 'deleted']); $delete->where->lessThan('created_at', '2020-01-01'); ``` @@ -198,9 +168,7 @@ $delete->where->lessThan('created_at', '2020-01-01'); Both Update and Delete classes include empty WHERE protection by default, which prevents accidental mass updates or deletes. -### Checking empty WHERE protection status - -```php +```php title="Checking empty WHERE protection status" $update = $sql->update('users'); $update->set(['status' => 'deleted']); // No where clause - this could update ALL rows! @@ -213,9 +181,7 @@ 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: -### Adding WHERE clause for safe operations - -```php +```php title="Adding WHERE clause for safe operations" $update->where(['id' => 123]); $delete = $sql->delete('logs'); @@ -224,9 +190,7 @@ $delete->where->lessThan('createdAt', '2020-01-01'); ## Examples -### Update with expressions - -```php +```php title="Update with expressions" $update = $sql->update('products'); $update->set([ 'view_count' => new Expression('view_count + 1'), @@ -237,15 +201,11 @@ $update->where(['id' => $productId]); Produces: -### Generated SQL for update with expressions - -```sql +```sql title="Generated SQL for update with expressions" UPDATE products SET view_count = view_count + 1, last_viewed = NOW() WHERE id = ? ``` -### Conditional update - -```php +```php title="Conditional update" $update = $sql->update('orders'); $update->set(['status' => 'shipped']); $update->where(function ($where) { @@ -255,18 +215,14 @@ $update->where(function ($where) { }); ``` -### Update with JOIN - -```php +```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']); ``` -### Delete old records - -```php +```php title="Delete old records" $delete = $sql->delete('sessions'); $delete->where->lessThan('last_activity', new Expression('NOW() - INTERVAL 24 HOUR')); @@ -275,9 +231,7 @@ $result = $statement->execute(); $deletedCount = $result->getAffectedRows(); ``` -### Delete with complex conditions - -```php +```php title="Delete with complex conditions" $delete = $sql->delete('users'); $delete->where(function ($where) { $where->nest() @@ -296,17 +250,13 @@ $delete->where(function ($where) { Produces: -### Generated SQL for delete with complex conditions - -```sql +```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) ``` -### Bulk operations with transactions - -```php +```php title="Bulk operations with transactions" $connection = $adapter->getDriver()->getConnection(); $connection->beginTransaction(); diff --git a/docs/book/sql/where-having.md b/docs/book/sql/where-having.md index 5caeb5a6c..ea3369628 100644 --- a/docs/book/sql/where-having.md +++ b/docs/book/sql/where-having.md @@ -21,9 +21,7 @@ quoted. parameters are acceptable when calling `where()` or `having()`. The method signature is listed as: -### Method signature for where() and having() - -```php +```php title="Method signature for where() and having()" /** * Create where clause * @@ -43,9 +41,7 @@ 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: -### Using a callable with where() - -```php +```php title="Using a callable with where()" $select->where(function (Where $where) { $where->like('username', 'ralph%'); }); @@ -55,9 +51,7 @@ 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: -### Using a string expression with where() - -```php +```php title="Using a string expression with where()" // SELECT "foo".* FROM "foo" WHERE x = 5 $select->from('foo')->where('x = 5'); ``` @@ -72,9 +66,7 @@ In either case, the instances are pushed onto the `Where` stack with the As an example: -### Using an array of string expressions - -```php +```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']); ``` @@ -90,9 +82,7 @@ key will be cast as follows: As an example: -### Using an associative array with mixed value types - -```php +```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([ @@ -104,17 +94,13 @@ $select->from('foo')->where([ As another example of complex queries with nested conditions e.g. -### SQL example with nested OR and AND conditions - -```sql +```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: -### Using nest() and unnest() for complex conditions - -```php +```php title="Using nest() and unnest() for complex conditions" $select->where->nest() // bracket opened ->isNull('column1') ->or @@ -127,9 +113,7 @@ $select->where->nest() // bracket opened The `Where` and `Having` API is that of `Predicate` and `PredicateSet`: -### Predicate class API definition - -```php +```php title="Predicate class API definition" // Where & Having extend Predicate: class Predicate extends PredicateSet { @@ -241,9 +225,7 @@ similarly named type, as described below. ### equalTo(), lessThan(), greaterThan(), lessThanOrEqualTo(), greaterThanOrEqualTo() -### Using equalTo() to create an Operator predicate - -```php +```php title="Using equalTo() to create an Operator predicate" $where->equalTo('id', 5); // The above is equivalent to: @@ -254,9 +236,7 @@ $where->addPredicate( Operators use the following API: -### Operator class API definition - -```php +```php title="Operator class API definition" class Operator implements PredicateInterface { final public const OPERATOR_EQUAL_TO = '='; @@ -304,9 +284,7 @@ class Operator implements PredicateInterface ### like($identifier, $like), notLike($identifier, $notLike) -### Using like() to create a Like predicate - -```php +```php title="Using like() to create a Like predicate" $where->like($identifier, $like): // The above is equivalent to: @@ -317,9 +295,7 @@ $where->addPredicate( The following is the `Like` API: -### Like class API definition - -```php +```php title="Like class API definition" class Like implements PredicateInterface { public function __construct( @@ -342,9 +318,7 @@ class Like implements PredicateInterface ### literal($literal) -### Using literal() to create a Literal predicate - -```php +```php title="Using literal() to create a Literal predicate" $where->literal($literal); // The above is equivalent to: @@ -355,9 +329,7 @@ $where->addPredicate( The following is the `Literal` API: -### Literal class API definition - -```php +```php title="Literal class API definition" class Literal implements ExpressionInterface, PredicateInterface { public function __construct(string $literal = ''); @@ -369,9 +341,7 @@ class Literal implements ExpressionInterface, PredicateInterface ### expression($expression, $parameter) -### Using expression() to create an Expression predicate - -```php +```php title="Using expression() to create an Expression predicate" $where->expression($expression, $parameter); // The above is equivalent to: @@ -382,9 +352,7 @@ $where->addPredicate( The following is the `Expression` API: -### Expression class API definition - -```php +```php title="Expression class API definition" class Expression implements ExpressionInterface, PredicateInterface { final public const PLACEHOLDER = '?'; @@ -407,9 +375,7 @@ class Expression implements ExpressionInterface, PredicateInterface Expression parameters can be supplied in multiple ways: -### Using Expression with various parameter types - -```php +```php title="Using Expression with various parameter types" // Using Argument classes for explicit typing $expression = new Expression( 'CONCAT(?, ?, ?)', @@ -444,9 +410,7 @@ $select ### isNull($identifier) -### Using isNull() to create an IsNull predicate - -```php +```php title="Using isNull() to create an IsNull predicate" $where->isNull($identifier); // The above is equivalent to: @@ -457,9 +421,7 @@ $where->addPredicate( The following is the `IsNull` API: -### IsNull class API definition - -```php +```php title="IsNull class API definition" class IsNull implements PredicateInterface { public function __construct(null|string|ArgumentInterface $identifier = null); @@ -473,9 +435,7 @@ class IsNull implements PredicateInterface ### isNotNull($identifier) -### Using isNotNull() to create an IsNotNull predicate - -```php +```php title="Using isNotNull() to create an IsNotNull predicate" $where->isNotNull($identifier); // The above is equivalent to: @@ -486,9 +446,7 @@ $where->addPredicate( The following is the `IsNotNull` API: -### IsNotNull class API definition - -```php +```php title="IsNotNull class API definition" class IsNotNull implements PredicateInterface { public function __construct(null|string|ArgumentInterface $identifier = null); @@ -504,9 +462,7 @@ class IsNotNull implements PredicateInterface ### in($identifier, $valueSet), notIn($identifier, $valueSet) -### Using in() to create an In predicate - -```php +```php title="Using in() to create an In predicate" $where->in($identifier, $valueSet); // The above is equivalent to: @@ -517,9 +473,7 @@ $where->addPredicate( The following is the `In` API: -### In class API definition - -```php +```php title="In class API definition" class In implements PredicateInterface { public function __construct( @@ -540,9 +494,7 @@ class In implements PredicateInterface ### between() and notBetween() -### Using between() to create a Between predicate - -```php +```php title="Using between() to create a Between predicate" $where->between($identifier, $minValue, $maxValue); // The above is equivalent to: @@ -553,9 +505,7 @@ $where->addPredicate( The following is the `Between` API: -### Between class API definition - -```php +```php title="Between class API definition" class Between implements PredicateInterface { public function __construct( @@ -583,9 +533,7 @@ class Between implements PredicateInterface As an example with different value types: -### Using between() with different value types - -```php +```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'); @@ -593,25 +541,19 @@ $where->between('createdAt', '2024-01-01', '2024-12-31'); Produces: -### SQL output for between() examples - -```sql +```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: -### Using between() with an Expression - -```php +```php title="Using between() with an Expression" $where->between(new Expression('YEAR(createdAt)'), 2020, 2024); ``` Produces: -### SQL output for between() with Expression - -```sql +```sql title="SQL output for between() with Expression" WHERE YEAR(createdAt) BETWEEN 2020 AND 2024 ``` @@ -623,9 +565,7 @@ 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. -### Using magic properties for fluent chaining - -```php +```php title="Using magic properties for fluent chaining" $select->where ->equalTo('status', 'active') ->and @@ -636,17 +576,13 @@ $select->where Produces: -### SQL output for fluent chaining example - -```sql +```sql title="SQL output for fluent chaining example" WHERE status = 'active' AND age > 18 OR role = 'admin' ``` The properties are case-insensitive for convenience: -### Case-insensitive magic property usage - -```php +```php title="Case-insensitive magic property usage" $where->and->equalTo('a', 1); $where->AND->equalTo('b', 2'); ``` @@ -655,9 +591,7 @@ $where->AND->equalTo('b', 2'); Complex nested conditions can be created using `nest()` and `unnest()`: -### Creating deeply nested predicate conditions - -```php +```php title="Creating deeply nested predicate conditions" $select->where->nest() ->nest() ->equalTo('a', 1) @@ -675,9 +609,7 @@ $select->where->nest() Produces: -### SQL output for deeply nested predicates - -```sql +```sql title="SQL output for deeply nested predicates" WHERE ((a = 1 OR b = 2) AND (c = 3 OR d = 4)) ``` @@ -687,9 +619,7 @@ The `addPredicates()` method from `PredicateSet` provides intelligent handling o various input types, automatically creating appropriate predicate objects based on the input. -### Using addPredicates() with mixed input types - -```php +```php title="Using addPredicates() with mixed input types" $where->addPredicates([ 'status = "active"', 'age > ?', @@ -713,9 +643,7 @@ The method detects and handles: Combination operators can be specified: -### Using addPredicates() with OR combination - -```php +```php title="Using addPredicates() with OR combination" $where->addPredicates([ 'role' => 'admin', 'status' => 'active', @@ -724,9 +652,7 @@ $where->addPredicates([ Produces: -### SQL output for OR combination - -```sql +```sql title="SQL output for OR combination" WHERE role = 'admin' OR status = 'active' ``` @@ -734,9 +660,7 @@ WHERE role = 'admin' OR status = 'active' The `like()` and `notLike()` methods support SQL wildcard patterns: -### Using like() and notLike() with wildcard patterns - -```php +```php title="Using like() and notLike() with wildcard patterns" $where->like('name', 'John%'); $where->like('email', '%@gmail.com'); $where->like('description', '%keyword%'); @@ -745,9 +669,7 @@ $where->notLike('email', '%@spam.com'); Multiple LIKE conditions: -### Combining multiple LIKE conditions with OR - -```php +```php title="Combining multiple LIKE conditions with OR" $where->like('name', 'A%') ->or ->like('name', 'B%'); @@ -755,9 +677,7 @@ $where->like('name', 'A%') Produces: -### SQL output for multiple LIKE conditions - -```sql +```sql title="SQL output for multiple LIKE conditions" WHERE name LIKE 'A%' OR name LIKE 'B%' ``` @@ -766,9 +686,7 @@ WHERE name LIKE 'A%' OR name LIKE 'B%' While `where()` filters rows before grouping, `having()` filters groups after aggregation. The HAVING clause is used with GROUP BY and aggregate functions. -### Using HAVING to filter aggregate results - -```php +```php title="Using HAVING to filter aggregate results" $select->from('orders') ->columns([ 'customerId', @@ -783,9 +701,7 @@ $select->from('orders') Produces: -### SQL output for HAVING with aggregate functions - -```sql +```sql title="SQL output for HAVING with aggregate functions" SELECT customerId, COUNT(*) AS orderCount, SUM(amount) AS totalAmount FROM orders WHERE amount > 0 @@ -795,9 +711,7 @@ HAVING COUNT(*) > 10 AND SUM(amount) > 1000 Using closures with HAVING: -### Using a closure with HAVING for complex conditions - -```php +```php title="Using a closure with HAVING for complex conditions" $select->having(function ($having) { $having->greaterThan(new Expression('AVG(rating)'), 4.5) ->or @@ -807,9 +721,7 @@ $select->having(function ($having) { Produces: -### SQL output for HAVING with closure - -```sql +```sql title="SQL output for HAVING with closure" HAVING AVG(rating) > 4.5 OR COUNT(reviews) > 100 ``` @@ -820,9 +732,7 @@ clauses, FROM clauses, and SELECT columns. ### Subqueries in WHERE IN clauses -### Using a subquery in a WHERE IN clause - -```php +```php title="Using a subquery in a WHERE IN clause" $subselect = $sql->select('orders') ->columns(['customerId']) ->where(['status' => 'completed']); @@ -833,18 +743,14 @@ $select = $sql->select('customers') Produces: -### SQL output for subquery in WHERE IN - -```sql +```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 -### Using a subquery in a FROM clause - -```php +```php title="Using a subquery in a FROM clause" $subselect = $sql->select('orders') ->columns([ 'customerId', @@ -858,9 +764,7 @@ $select = $sql->select(['orderTotals' => $subselect]) Produces: -### SQL output for subquery in FROM clause - -```sql +```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 @@ -868,9 +772,7 @@ WHERE orderTotals.total > 1000 ### Scalar subqueries in SELECT columns -### Using a scalar subquery in SELECT columns - -```php +```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')); @@ -885,9 +787,7 @@ $select = $sql->select('customers') Produces: -### SQL output for scalar subquery in SELECT - -```sql +```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 @@ -895,9 +795,7 @@ FROM customers ### Subqueries with comparison operators -### Using a subquery with a comparison operator - -```php +```php title="Using a subquery with a comparison operator" $subselect = $sql->select('orders') ->columns([new Expression('AVG(amount)')]); @@ -907,9 +805,7 @@ $select = $sql->select('orders') Produces: -### SQL output for subquery with comparison operator - -```sql +```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 92843a3e6..61350e2c3 100644 --- a/docs/book/table-gateway.md +++ b/docs/book/table-gateway.md @@ -44,9 +44,7 @@ order to be consumed and utilized to its fullest. The following example uses `PhpDb\TableGateway\TableGateway`, which defines the following API: -### TableGateway Class API - -```php +```php title="TableGateway Class API" namespace PhpDb\TableGateway; use PhpDb\Adapter\AdapterInterface; @@ -104,9 +102,7 @@ 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. -### Basic Select Operations - -```php +```php title="Basic Select Operations" use PhpDb\TableGateway\TableGateway; $projectTable = new TableGateway('project', $adapter); @@ -129,9 +125,7 @@ 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: -### Advanced Select with Callback - -```php +```php title="Advanced Select with Callback" use PhpDb\TableGateway\TableGateway; use PhpDb\Sql\Select; @@ -263,17 +257,13 @@ 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: -### Retrieving Event Parameters - -```php +```php title="Retrieving Event Parameters" $parameter = $event->getParam($paramName); ``` As an example, you might attach a listener on the `postInsert` event as follows: -### Attaching a Listener to postInsert Event - -```php +```php title="Attaching a Listener to postInsert Event" use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\TableGateway\Feature\EventFeature\TableGatewayEvent; use Laminas\EventManager\EventManager; From 0e1455a67f995cce9b1032950c5ab58c76511e4e Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 16:30:14 +1100 Subject: [PATCH 05/11] Refactored documentation for 0.4.x Split chapters/examples Linted documentation Signed-off-by: Simon Mundy --- docs/book/adapter.md | 513 ++++++----- docs/book/adapters/adapter-aware-trait.md | 42 +- .../usage-in-a-laminas-mvc-application.md | 306 ++++--- .../usage-in-a-mezzio-application.md | 598 +++++++++++++ docs/book/docker-deployment.md | 282 ++++++ docs/book/index.html | 10 - docs/book/index.md | 138 ++- docs/book/metadata/examples.md | 254 ++++++ docs/book/metadata/intro.md | 379 ++++++++ docs/book/metadata/objects.md | 297 +++++++ docs/book/profiler.md | 422 +++++++++ docs/book/result-set/advanced.md | 455 ++++++++++ docs/book/result-set/examples.md | 312 +++++++ .../{result-set.md => result-set/intro.md} | 127 ++- docs/book/row-gateway.md | 19 +- docs/book/sql-ddl/advanced.md | 471 ++++++++++ docs/book/sql-ddl/alter-drop.md | 507 +++++++++++ docs/book/sql-ddl/columns.md | 498 +++++++++++ docs/book/sql-ddl/constraints.md | 484 +++++++++++ docs/book/sql-ddl/examples.md | 509 +++++++++++ docs/book/sql-ddl/intro.md | 251 ++++++ docs/book/sql/advanced.md | 238 +++++ docs/book/sql/examples.md | 500 +++++++++++ docs/book/sql/insert.md | 235 +++++ docs/book/sql/intro.md | 273 ++++++ docs/book/sql/select.md | 441 ++++++++++ docs/book/sql/update-delete.md | 280 ++++++ docs/book/sql/where-having.md | 811 ++++++++++++++++++ docs/book/table-gateway.md | 113 +-- mkdocs.yml | 42 + 30 files changed, 9205 insertions(+), 602 deletions(-) create mode 100644 docs/book/application-integration/usage-in-a-mezzio-application.md create mode 100644 docs/book/docker-deployment.md delete mode 100644 docs/book/index.html mode change 120000 => 100644 docs/book/index.md create mode 100644 docs/book/metadata/examples.md create mode 100644 docs/book/metadata/intro.md create mode 100644 docs/book/metadata/objects.md create mode 100644 docs/book/profiler.md create mode 100644 docs/book/result-set/advanced.md create mode 100644 docs/book/result-set/examples.md rename docs/book/{result-set.md => result-set/intro.md} (54%) create mode 100644 docs/book/sql-ddl/advanced.md create mode 100644 docs/book/sql-ddl/alter-drop.md create mode 100644 docs/book/sql-ddl/columns.md create mode 100644 docs/book/sql-ddl/constraints.md create mode 100644 docs/book/sql-ddl/examples.md create mode 100644 docs/book/sql-ddl/intro.md create mode 100644 docs/book/sql/advanced.md create mode 100644 docs/book/sql/examples.md create mode 100644 docs/book/sql/insert.md create mode 100644 docs/book/sql/intro.md create mode 100644 docs/book/sql/select.md create mode 100644 docs/book/sql/update-delete.md create mode 100644 docs/book/sql/where-having.md create mode 100644 mkdocs.yml diff --git a/docs/book/adapter.md b/docs/book/adapter.md index d68dbc18e..34fc3a787 100644 --- a/docs/book/adapter.md +++ b/docs/book/adapter.md @@ -1,169 +1,125 @@ # 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: +<<<<<<< HEAD +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` +======= - create a new `Statement` object. - prepare the array `[5]` into a `ParameterContainer` if necessary. - inject the `ParameterContainer` into the `Statement` object. @@ -174,6 +130,7 @@ The above example will go through the following steps: inject the `Result` as its datasource, and return the new `ResultSet` instance. - otherwise, return the `Result`. +>>>>>>> origin/0.4.x ## Query Execution @@ -181,10 +138,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 +156,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 +166,67 @@ $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). +- Retrieve the overall last generated value (such as an auto-increment value) -Now let's turn to the `Statement` API: - -```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 +234,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 +248,23 @@ 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 +272,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 +297,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 +315,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 +327,119 @@ 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 -## Examples +The adapter supports profiling through the `ProfilerInterface`: -Creating a `Driver`, a vendor-portable query, and preparing and iterating the -result: +```php title="Setting Up a Profiler" +use PhpDb\Adapter\Profiler\Profiler; -```php -$adapter = new PhpDb\Adapter\Adapter($driverConfig); +$profiler = new Profiler(); +$adapter = new Adapter($driver, $platform, profiler: $profiler); + +// 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 +461,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..77d99ff0b 100644 --- a/docs/book/adapters/adapter-aware-trait.md +++ b/docs/book/adapters/adapter-aware-trait.md @@ -1,12 +1,6 @@ # 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 +8,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,21 +16,10 @@ 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 @@ -80,23 +61,26 @@ 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', + $driver = new Sqlite([ 'database' => 'path/to/sqlite.db', ]); + return new Adapter($driver, new SqlitePlatform()); } ], 'invokables' => [ @@ -124,8 +108,4 @@ $example = $serviceManager->get(Example::class); var_dump($example->getAdapter() instanceof PhpDb\Adapter\Adapter); // 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..21e12b820 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,222 +1,208 @@ # 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 +## Service 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. +Now that the phpdb packages are installed, you need to configure the adapter through your application's service manager. -## Adding to an existing Laminas MVC Skeleton Application +### Configuring the Adapter -If the MVC application is already created, then use Composer to [add the laminas-db](../index.md) package. +Create a configuration file `config/autoload/database.global.php` (or `local.php` for credentials) to define database settings. -## The Abstract Factory +### Working with a SQLite database -Now that the laminas-db package is installed, the abstract factory `PhpDb\Adapter\AdapterAbstractServiceFactory` is available to be used with the service configuration. +SQLite is a lightweight option to have the application working with a database. -### Configuring the adapter +Here is an example of the configuration array for a SQLite database. +Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: -The abstract factory expects the configuration key `db` in order to create a `PhpDb\Adapter\Adapter` instance. +```php title="SQLite adapter configuration" + [ - 'driver' => 'Pdo', - 'adapters' => [ - sqliteAdapter::class => [ - 'driver' => 'Pdo', - 'dsn' => 'sqlite:data/sample.sqlite', - ], + 'service_manager' => [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Sqlite([ + 'database' => 'data/sample.sqlite', + ]); + return new Adapter($driver, new SqlitePlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, ], ], ]; ``` -The `data/` filepath for the sqlite file is the default `data/` directory from the Laminas MVC application. +The `data/` filepath for the SQLite file is the default `data/` directory from the Laminas MVC application. ### Working with a MySQL database -Unlike a sqlite database, the MySQL database adapter requires a MySQL server. +Unlike a SQLite database, the MySQL database adapter requires a MySQL server. + +Here is an example of a configuration array for a MySQL database: + +```php title="MySQL adapter configuration" + [ - '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\'' - ], - ], + 'service_manager' => [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Mysql([ + 'database' => 'your_database_name', + 'username' => 'your_mysql_username', + 'password' => 'your_mysql_password', + 'hostname' => 'localhost', + 'charset' => 'utf8mb4', + ]); + return new Adapter($driver, new MysqlPlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, ], ], ]; ``` -## Working with the adapter +### Working with PostgreSQL database -Once you have configured an adapter, as in the above examples, you now have a `PhpDb\Adapter\Adapter` available to your application. +PostgreSQL support is coming soon. Once the `php-db/postgres` package is available: + +```php title="PostgreSQL adapter configuration" +get(sqliteAdapter::class) ; +return [ + 'service_manager' => [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Postgres([ + 'database' => 'your_database_name', + 'username' => 'your_pgsql_username', + 'password' => 'your_pgsql_password', + 'hostname' => 'localhost', + 'port' => 5432, + ]); + return new Adapter($driver, new PostgresPlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; ``` -For the MySQL Database configured earlier: +## Working with the adapter -```php -use mysqlAdapter ; +Once you have configured an adapter, as in the above examples, you now have a `PhpDb\Adapter\Adapter` available to your application. -$adapter = $container->get(mysqlAdapter::class) ; -``` +A factory for a class that consumes an adapter can pull the adapter from the container: -You can read more about the [adapter in the adapter chapter of the documentation](../adapter.md). +```php title="Retrieving the adapter from the service container" +use PhpDb\Adapter\AdapterInterface; -## Running with Docker +$adapter = $container->get(AdapterInterface::class); +``` -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..fa44e9b7f --- /dev/null +++ b/docs/book/application-integration/usage-in-a-mezzio-application.md @@ -0,0 +1,598 @@ +# Usage in a Mezzio Application + +For installation instructions, see [Installation](../index.md#installation). + +## Service Configuration + +Now that the phpdb packages are installed, you need to configure the adapter through Mezzio's dependency injection container. + +Mezzio uses PSR-11 containers and typically uses laminas-servicemanager or another DI container. The adapter configuration goes in your application's configuration files. + +Create a configuration file `config/autoload/database.global.php` to define database settings. + +### Working with a SQLite database + +SQLite is a lightweight option to have the application working with a database. + +Here is an example of the configuration array for a SQLite database. +Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: + +```php title="SQLite adapter configuration" + [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Sqlite([ + 'database' => 'data/sample.sqlite', + ]); + return new Adapter($driver, new SqlitePlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +The `data/` filepath for the SQLite file is relative to your application root directory. + +### Working with a MySQL database + +Unlike a SQLite database, the MySQL database adapter requires a MySQL server. + +Here is an example of a configuration array for a MySQL database. + +Create `config/autoload/database.local.php` for environment-specific credentials: + +```php title="MySQL adapter configuration" + [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Mysql([ + 'database' => 'your_database_name', + 'username' => 'your_mysql_username', + 'password' => 'your_mysql_password', + 'hostname' => 'localhost', + 'charset' => 'utf8mb4', + ]); + return new Adapter($driver, new MysqlPlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +### Working with PostgreSQL database + +PostgreSQL support is coming soon. Once the `php-db/postgres` package is available: + +```php title="PostgreSQL adapter configuration" + [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Postgres([ + 'database' => 'your_database_name', + 'username' => 'your_pgsql_username', + 'password' => 'your_pgsql_password', + 'hostname' => 'localhost', + 'port' => 5432, + ]); + return new Adapter($driver, new PostgresPlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +## 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 through dependency injection. + +### In Request Handlers + +Mezzio uses request handlers (also known as middleware) that receive dependencies through constructor injection: + +```php title="Request handler with database adapter injection" +adapter->query( + 'SELECT id, username, email FROM users WHERE status = ?', + ['active'] + ); + + $users = []; + foreach ($results as $row) { + $users[] = [ + 'id' => $row->id, + 'username' => $row->username, + 'email' => $row->email, + ]; + } + + return new JsonResponse(['users' => $users]); + } +} +``` + +### Creating a Handler Factory + +You need to create a factory for your handler that injects the adapter: + +```php title="Handler factory implementation" +get(AdapterInterface::class) + ); + } +} +``` + +### Registering the Handler + +Register your handler factory in `config/autoload/dependencies.global.php`: + +```php title="Registering the handler in dependencies configuration" + [ + 'invokables' => [ + // ... other invokables + ], + 'factories' => [ + UserListHandler::class => UserListHandlerFactory::class, + // ... other factories + ], + ], +]; +``` + +### Using with TableGateway + +For more structured database interactions, use TableGateway with dependency injection: + +```php title="Extending TableGateway for custom database operations" +select(['status' => 'active']); + return iterator_to_array($resultSet); + } + + public function findUserById(int $id): ?array + { + $rowset = $this->select(['id' => $id]); + $row = $rowset->current(); + + return $row ? (array) $row : null; + } +} +``` + +Create a factory for the table: + +```php title="Factory for the TableGateway class" +get(AdapterInterface::class) + ); + } +} +``` + +Register the table factory: + +```php title="Registering the table factory in dependencies" + [ + 'factories' => [ + UsersTable::class => UsersTableFactory::class, + ], + ], +]; +``` + +Use in your handler: + +```php title="Using TableGateway in a request handler" +usersTable->findActiveUsers(); + + return new JsonResponse(['users' => $users]); + } +} +``` + +You can read more about the [adapter in the adapter chapter of the documentation](../adapter.md) and [TableGateway in the table gateway chapter](../table-gateway.md). + +## Environment-based Configuration + +For production deployments, use environment variables to configure database credentials: + +### Using dotenv + +Install `vlucas/phpdotenv`: + +```bash title="Installing the phpdotenv package" +composer require vlucas/phpdotenv +``` + +Create a `.env` file in your project root: + +### Environment variables configuration file + +```env +DB_TYPE=mysql +DB_DATABASE=myapp_production +DB_USERNAME=dbuser +DB_PASSWORD=secure_password +DB_HOSTNAME=mysql-server +DB_PORT=3306 +DB_CHARSET=utf8mb4 +``` + +Load environment variables in `public/index.php`: + +```php title="Loading environment variables in the application bootstrap" +load(); +} + +$container = require 'config/container.php'; +``` + +Update your database configuration to use environment variables: + +```php title="Dynamic adapter configuration using environment variables" + [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $dbType = $_ENV['DB_TYPE'] ?? 'sqlite'; + + if ($dbType === 'mysql') { + $driver = new Mysql([ + 'database' => $_ENV['DB_DATABASE'] ?? 'myapp', + 'username' => $_ENV['DB_USERNAME'] ?? 'root', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'hostname' => $_ENV['DB_HOSTNAME'] ?? 'localhost', + 'port' => (int) ($_ENV['DB_PORT'] ?? 3306), + 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', + ]); + return new Adapter($driver, new MysqlPlatform()); + } + + // Default to SQLite + $driver = new Sqlite([ + 'database' => $_ENV['DB_DATABASE'] ?? 'data/app.sqlite', + ]); + return new Adapter($driver, new SqlitePlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +## 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). + +## Testing with Database + +For integration testing with a real database in Mezzio: + +### Create a Test Configuration + +Create `config/autoload/database.test.php`: + +```php title="Test database configuration with in-memory SQLite" + [ + 'factories' => [ + Adapter::class => function (ContainerInterface $container) { + $driver = new Sqlite([ + 'database' => ':memory:', + ]); + return new Adapter($driver, new SqlitePlatform()); + }, + ], + 'aliases' => [ + AdapterInterface::class => Adapter::class, + ], + ], +]; +``` + +### Use in PHPUnit Tests + +```php title="PHPUnit test with database integration" +adapter = $container->get(AdapterInterface::class); + + $this->adapter->query( + 'CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT, + email TEXT, + status TEXT + )' + ); + + $this->adapter->query( + "INSERT INTO users (username, email, status) VALUES + ('alice', 'alice@example.com', 'active'), + ('bob', 'bob@example.com', 'active')" + ); + } + + public function testHandleReturnsUserList(): void + { + $handler = new UserListHandler($this->adapter); + $request = new ServerRequest(); + + $response = $handler->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertCount(2, $body['users']); + } +} +``` + +## Best Practices for Mezzio + +### Use Dependency Injection + +Always inject the adapter or table gateway through constructors, never instantiate directly in handlers. + +### Separate Database Logic + +Create repository or table gateway classes to separate database logic from HTTP handlers: + +```php title="Repository pattern implementation for database operations" +adapter); + $select = $sql->select('users'); + + $statement = $sql->prepareStatementForSqlObject($select); + $results = $statement->execute(); + + return iterator_to_array($results); + } + + public function findById(int $id): ?array + { + $sql = new Sql($this->adapter); + $select = $sql->select('users'); + $select->where(['id' => $id]); + + $statement = $sql->prepareStatementForSqlObject($select); + $results = $statement->execute(); + $row = $results->current(); + + return $row ? (array) $row : null; + } +} +``` + +### Use Configuration Factories + +Centralize adapter configuration in factory classes for better maintainability and testability. + +### Handle Exceptions + +Always wrap database operations in try-catch blocks: + +```php title="Exception handling for database operations" +use PhpDb\Adapter\Exception\RuntimeException; + +public function handle(ServerRequestInterface $request): ResponseInterface +{ + try { + $users = $this->usersTable->findActiveUsers(); + return new JsonResponse(['users' => $users]); + } catch (RuntimeException $e) { + return new JsonResponse( + ['error' => 'Database error occurred'], + 500 + ); + } +} +``` diff --git a/docs/book/docker-deployment.md b/docs/book/docker-deployment.md new file mode 100644 index 000000000..846961be5 --- /dev/null +++ b/docs/book/docker-deployment.md @@ -0,0 +1,282 @@ +# 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..57ed84e51 --- /dev/null +++ b/docs/book/index.md @@ -0,0 +1,137 @@ +# 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 core package via Composer: + +```bash +composer require php-db/phpdb +``` + +Additionally, install the driver package(s) for the database(s) you plan to use: + +```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 +``` + +### 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..790fb1a5f --- /dev/null +++ b/docs/book/metadata/examples.md @@ -0,0 +1,254 @@ +# 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()}** ({$constraint->getType()})\n"; + if ($constraint->hasColumns()) { + $doc .= " - Columns: " . implode(', ', $constraint->getColumns()) . "\n"; + } + if ($constraint->isForeignKey()) { + $doc .= " - References: {$constraint->getReferencedTableName()}"; + $doc .= "(" . implode(', ', $constraint->getReferencedColumns()) . ")\n"; + $doc .= " - ON UPDATE: {$constraint->getUpdateRule()}\n"; + $doc .= " - ON DELETE: {$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} \${$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 in this table | +| `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..7178bb20e --- /dev/null +++ b/docs/book/metadata/intro.md @@ -0,0 +1,379 @@ +# 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 +$table = $metadata->getTable('users'); // Returns TableObject or ViewObject +$column = $metadata->getColumn('id', 'users'); // Returns ColumnObject +$constraint = $metadata->getConstraint('PRIMARY', 'users'); // Returns ConstraintObject +``` + +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..8d6a260a4 --- /dev/null +++ b/docs/book/metadata/objects.md @@ -0,0 +1,297 @@ +# 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; + public function isNumericUnsigned(): ?bool; // Alias for getNumericUnsigned() + + 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..269238d24 --- /dev/null +++ b/docs/book/profiler.md @@ -0,0 +1,422 @@ +# 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 SQL query that was executed | +| `parameters` | `ParameterContainer\|null` | The bound parameters (if any) | +| `start` | `float` | Unix timestamp with microseconds (query start) | +| `end` | `float` | Unix timestamp with microseconds (query end) | +| `elapse` | `float` | Total 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), // Convert to ms + '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/advanced.md b/docs/book/result-set/advanced.md new file mode 100644 index 000000000..d2806bfdd --- /dev/null +++ b/docs/book/result-set/advanced.md @@ -0,0 +1,455 @@ +# 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..bc2102e72 --- /dev/null +++ b/docs/book/result-set/examples.md @@ -0,0 +1,312 @@ +# 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 +{ + protected string $first_name; // Matches column name + public function setFirstName($value) {} // For ClassMethodsHydrator +} +``` + +### 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.md b/docs/book/result-set/intro.md similarity index 54% rename from docs/book/result-set.md rename to docs/book/result-set/intro.md index 42f30de0e..f3b55e28b 100644 --- a/docs/book/result-set.md +++ b/docs/book/result-set/intro.md @@ -1,16 +1,10 @@ # 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: +`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; @@ -18,12 +12,14 @@ use Traversable; interface ResultSetInterface extends Traversable, Countable { - public function initialize(mixed $dataSource) : void; - public function getFieldCount() : int; + public function initialize(iterable $dataSource): ResultSetInterface; + public function getFieldCount(): mixed; + public function setRowPrototype(ArrayObject $rowPrototype): ResultSetInterface; + public function getRowPrototype(): ?object; } ``` -## Quick start +## 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 @@ -31,6 +27,8 @@ 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()`: @@ -43,49 +41,53 @@ $statement->prepare(); $result = $statement->execute($parameters); if ($result instanceof ResultInterface && $result->isQueryResult()) { - $resultSet = new ResultSet; + $resultSet = new ResultSet(); $resultSet->initialize($result); foreach ($resultSet as $row) { - echo $row->my_column . PHP_EOL; + printf("User: %s %s\n", $row->first_name, $row->last_name); } } ``` -## Laminas\\Db\\ResultSet\\ResultSet and Laminas\\Db\\ResultSet\\AbstractResultSet +## 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 +```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) : 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; + 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; } ``` -## Laminas\\Db\\ResultSet\\HydratingResultSet +## HydratingResultSet `PhpDb\ResultSet\HydratingResultSet` is a more flexible `ResultSet` object that allows the developer to choose an appropriate "hydration strategy" for @@ -98,7 +100,11 @@ The `HydratingResultSet` depends on [laminas-hydrator](https://docs.laminas.dev/laminas-hydrator), which you will need to install: +<<<<<<< HEAD:docs/book/result-set/intro.md +```bash title="Installing laminas-hydrator" +======= ```bash +>>>>>>> origin/0.4.x:docs/book/result-set.md composer require laminas/laminas-hydrator ``` @@ -107,47 +113,21 @@ iteration, `HydratingResultSet` will use the `Reflection` based hydrator to inject the row data directly into the protected members of the cloned `UserEntity` object: -```php +```php title="Using HydratingResultSet with ReflectionHydrator" 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); +$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 = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); $resultSet->initialize($result); foreach ($resultSet as $user) { - echo $user->getFirstName() . ' ' . $user->getLastName() . PHP_EOL; + printf("%s %s\n", $user->getFirstName(), $user->getLastName()); } } ``` @@ -155,3 +135,18 @@ if ($result instanceof ResultInterface && $result->isQueryResult()) { 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..8340020f5 100644 --- a/docs/book/row-gateway.md +++ b/docs/book/row-gateway.md @@ -1,15 +1,10 @@ # 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,7 +24,7 @@ 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: @@ -57,7 +52,7 @@ In that paradigm, `select()` operations will produce a `ResultSet` that iterates As an example: -```php +```php title="Using RowGateway with TableGateway" use PhpDb\TableGateway\Feature\RowGatewayFeature; use PhpDb\TableGateway\TableGateway; @@ -77,7 +72,7 @@ essentially making them behave similarly to the 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; diff --git a/docs/book/sql-ddl/advanced.md b/docs/book/sql-ddl/advanced.md new file mode 100644 index 000000000..eb5d53eb7 --- /dev/null +++ b/docs/book/sql-ddl/advanced.md @@ -0,0 +1,471 @@ +# 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..50c73b212 --- /dev/null +++ b/docs/book/sql-ddl/alter-drop.md @@ -0,0 +1,507 @@ +# 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..8fe63a876 --- /dev/null +++ b/docs/book/sql-ddl/columns.md @@ -0,0 +1,498 @@ +# 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:** `__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:** `__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:** `__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:** `__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:** `__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 | Example | +|--------|------|-----------|-------------|---------| +| `AUTO_INCREMENT` | bool | MySQL, MariaDB | Auto-incrementing integer | `$col->setOption('AUTO_INCREMENT', true)` | +| `identity` | bool | PostgreSQL, SQL Server | Identity/Serial column | `$col->setOption('identity', true)` | +| `comment` | string | MySQL, PostgreSQL | Column comment/description | `$col->setOption('comment', 'User ID')` | +| `on_update` | bool | MySQL (Timestamp) | ON UPDATE CURRENT_TIMESTAMP | `$col->setOption('on_update', true)` | +| `length` | int | MySQL (Integer) | Display width | `$col->setOption('length', 11)` | + +### 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..9bc0f3012 --- /dev/null +++ b/docs/book/sql-ddl/constraints.md @@ -0,0 +1,484 @@ +# 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 +$table->addConstraint(new Index('title', 'idx_title', [100])); // Index first 100 chars +``` + +### 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..757c565f4 --- /dev/null +++ b/docs/book/sql-ddl/examples.md @@ -0,0 +1,509 @@ +# 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', + [191] // MySQL InnoDB with utf8mb4 has 767 byte limit; 191 chars * 4 bytes = 764 +)); +``` diff --git a/docs/book/sql-ddl/intro.md b/docs/book/sql-ddl/intro.md new file mode 100644 index 000000000..49c31387f --- /dev/null +++ b/docs/book/sql-ddl/intro.md @@ -0,0 +1,251 @@ +# 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..701a8e3d9 --- /dev/null +++ b/docs/book/sql/examples.md @@ -0,0 +1,500 @@ +# 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) { + $customer = getCustomer($order['customerId']); // Additional query per order +} + +// 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..44337d47b --- /dev/null +++ b/docs/book/sql/insert.md @@ -0,0 +1,235 @@ +# 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..62825d1f7 --- /dev/null +++ b/docs/book/sql/intro.md @@ -0,0 +1,273 @@ +# 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(); // returns a PhpDb\Sql\Select instance +$insert = $sql->insert(); // returns a PhpDb\Sql\Insert instance +$update = $sql->update(); // returns a PhpDb\Sql\Update instance +$delete = $sql->delete(); // returns a 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->where(['id' => 2]); // $select already has from('foo') applied +``` + +## 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 +$identifiersArg = Argument::identifiers(['col1', 'col2']); // Multiple identifiers + +// 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..54101c7cf --- /dev/null +++ b/docs/book/sql/select.md @@ -0,0 +1,441 @@ +# 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 (will be quoted by platform), + ['bar', 'baz'], // (optional) list of columns, same as columns() above + $select::JOIN_OUTER // (optional), one of inner, outer, left, right, 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') + ->order('name ASC, age DESC'); // produces 'id' DESC, 'name' ASC, 'age' DESC + +$select = new Select; +$select->order(['name ASC', 'age DESC']); // produces '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..0c340df70 --- /dev/null +++ b/docs/book/sql/update-delete.md @@ -0,0 +1,280 @@ +# 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..ea3369628 --- /dev/null +++ b/docs/book/sql/where-having.md @@ -0,0 +1,811 @@ +# 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`, where the key is the 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 + +### 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` predicate (requires parameters) | +| Key => `null` | Creates `IsNull` predicate | +| Key => array | Creates `In` predicate | +| Key => scalar | Creates `Operator` predicate (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..cd4502de6 100644 --- a/docs/book/table-gateway.md +++ b/docs/book/table-gateway.md @@ -4,6 +4,8 @@ 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; @@ -42,7 +44,7 @@ order to be consumed and utilized to its fullest. 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; @@ -100,7 +102,7 @@ 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); @@ -123,7 +125,7 @@ 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: -```php +```php title="Advanced Select with Callback" use PhpDb\TableGateway\TableGateway; use PhpDb\Sql\Select; @@ -153,70 +155,73 @@ 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); +// 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 - ``` +// in a controller, or model somewhere +$table = new MyTableGateway(); // adapter is statically loaded +``` -- `MasterSlaveFeature`: the ability to use a master adapter for `insert()`, - `update()`, and `delete()`, but switch to a slave adapter for all `select()` - operations. +### MasterSlaveFeature - ```php - $table = new TableGateway('artist', $adapter, new Feature\MasterSlaveFeature($slaveAdapter)); - ``` +Use a master adapter for `insert()`, `update()`, and `delete()`, but switch to +a slave adapter for all `select()` operations: -- `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. +```php +$table = new TableGateway('artist', $adapter, new Feature\MasterSlaveFeature($slaveAdapter)); +``` - ```php - $table = new TableGateway('artist', $adapter, new Feature\MetadataFeature()); - ``` +### MetadataFeature -- `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. +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\EventFeature($eventManagerInstance)); - ``` +```php +$table = new TableGateway('artist', $adapter, new Feature\MetadataFeature()); +``` + +### EventFeature + +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: -- `RowGatewayFeature`: the ability for `select()` to return a `ResultSet` object that upon iteration - will return a `RowGateway` instance for each row. +```php +$table = new TableGateway('artist', $adapter, new Feature\EventFeature($eventManagerInstance)); +``` - ```php - $table = new TableGateway('artist', $adapter, new Feature\RowGatewayFeature('id')); - $results = $table->select(['id' => 2]); +### RowGatewayFeature - $artistRow = $results->current(); - $artistRow->name = 'New Name'; - $artistRow->save(); - ``` +Return `RowGateway` instances when iterating `select()` results: + +```php +$table = new TableGateway('artist', $adapter, new Feature\RowGatewayFeature('id')); +$results = $table->select(['id' => 2]); + +$artistRow = $results->current(); +$artistRow->name = 'New Name'; +$artistRow->save(); +``` ## TableGateway LifeCycle Events @@ -252,13 +257,13 @@ 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: -```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..0eb28a10d --- /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 \ No newline at end of file From 20b9e6f99e2a371c1902df00e813396f00c8dd20 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 17:07:57 +1100 Subject: [PATCH 06/11] Refactored documentation for 0.4.x Split chapters/examples Linted documentation Signed-off-by: Simon Mundy --- docs/book/adapter.md | 60 +++--- docs/book/adapters/adapter-aware-trait.md | 30 ++- .../usage-in-a-laminas-mvc-application.md | 33 +++- .../usage-in-a-mezzio-application.md | 101 ++++++---- docs/book/docker-deployment.md | 33 ++-- docs/book/index.md | 53 +++-- docs/book/metadata/examples.md | 80 +++++--- docs/book/metadata/intro.md | 124 ++++++++---- docs/book/metadata/objects.md | 144 ++++++++++---- docs/book/profiler.md | 27 +-- docs/book/result-set/advanced.md | 65 +++++-- docs/book/result-set/examples.md | 17 +- docs/book/result-set/intro.md | 38 ++-- docs/book/row-gateway.md | 27 ++- docs/book/sql-ddl/advanced.md | 4 +- docs/book/sql-ddl/alter-drop.md | 29 ++- docs/book/sql-ddl/columns.md | 85 ++++++-- docs/book/sql-ddl/constraints.md | 12 +- docs/book/sql-ddl/examples.md | 4 +- docs/book/sql-ddl/intro.md | 7 +- docs/book/sql/examples.md | 77 ++++++-- docs/book/sql/insert.md | 64 +++--- docs/book/sql/intro.md | 68 ++++--- docs/book/sql/select.md | 91 +++++---- docs/book/sql/update-delete.md | 81 +++++--- docs/book/sql/where-having.md | 184 +++++++++++------- docs/book/table-gateway.md | 127 +++++++----- 27 files changed, 1117 insertions(+), 548 deletions(-) diff --git a/docs/book/adapter.md b/docs/book/adapter.md index 34fc3a787..28b7acaad 100644 --- a/docs/book/adapter.md +++ b/docs/book/adapter.md @@ -1,6 +1,9 @@ # Adapters -`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. +`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. ## Package Architecture @@ -17,7 +20,7 @@ Starting with version 0.4.x, PhpDb uses a modular package architecture. The core Database-specific drivers are provided as separate packages: | Package | Database | Status | -|---------|----------|--------| +| ------- | -------- | ------ | | `php-db/mysql` | MySQL/MariaDB | Available | | `php-db/sqlite` | SQLite | Available | | `php-db/postgres` | PostgreSQL | Coming Soon | @@ -90,10 +93,14 @@ class Adapter implements AdapterInterface, Profiler\ProfilerAwareInterface, Sche ### Constructor Parameters -- **`$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 +- **`$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 @@ -108,29 +115,14 @@ $adapter->query('SELECT * FROM `artist` WHERE `id` = ?', [5]); The above example will go through the following steps: -<<<<<<< HEAD 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` -======= -- 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`. ->>>>>>> origin/0.4.x + 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 @@ -183,7 +175,9 @@ interface DriverInterface public const NAME_FORMAT_CAMELCASE = 'camelCase'; public const NAME_FORMAT_NATURAL = 'natural'; - public function getDatabasePlatformName(string $nameFormat = self::NAME_FORMAT_CAMELCASE): string; + public function getDatabasePlatformName( + string $nameFormat = self::NAME_FORMAT_CAMELCASE + ): string; public function checkEnvironment(): bool; public function getConnection(): ConnectionInterface; public function createStatement($sqlOrResource = null): StatementInterface; @@ -221,7 +215,9 @@ interface StatementInterface extends StatementContainerInterface /** Inherited from StatementContainerInterface */ public function setSql(string $sql): void; public function getSql(): string; - public function setParameterContainer(ParameterContainer $parameterContainer): void; + public function setParameterContainer( + ParameterContainer $parameterContainer + ): void; public function getParameterContainer(): ParameterContainer; } ``` @@ -264,7 +260,10 @@ interface PlatformInterface 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; + public function quoteIdentifierInFragment( + string $identifier, + array $additionalSafeWords = [] + ): string; } ``` @@ -333,7 +332,12 @@ class ParameterContainer implements Iterator, ArrayAccess, Countable 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 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) */ diff --git a/docs/book/adapters/adapter-aware-trait.md b/docs/book/adapters/adapter-aware-trait.md index 77d99ff0b..29d845b97 100644 --- a/docs/book/adapters/adapter-aware-trait.md +++ b/docs/book/adapters/adapter-aware-trait.md @@ -1,6 +1,7 @@ # AdapterAwareTrait -`PhpDb\Adapter\AdapterAwareTrait` provides a standard implementation of `AdapterAwareInterface` for injecting database adapters into your classes. +`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; @@ -25,8 +26,8 @@ $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 @@ -35,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 @@ -61,7 +63,8 @@ class Example implements AdapterAwareInterface ### Create and Configure Service Manager -Create and [configure 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 Psr\Container\ContainerInterface; @@ -76,7 +79,9 @@ use PhpDb\Sqlite\Platform\Sqlite as SqlitePlatform; $serviceManager = new Laminas\ServiceManager\ServiceManager([ 'factories' => [ // Database adapter - AdapterInterface::class => static function(ContainerInterface $container) { + AdapterInterface::class => static function( + ContainerInterface $container + ) { $driver = new Sqlite([ 'database' => 'path/to/sqlite.db', ]); @@ -98,14 +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\Adapter +); // true ``` -The [laminas-validator](https://docs.laminas.dev/laminas-validator/validators/db/) `Db\RecordExists` and `Db\NoRecordExists` validators use this pattern. +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 21e12b820..5baae94e1 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 @@ -4,18 +4,21 @@ For installation instructions, see [Installation](../index.md#installation). ## Service Configuration -Now that the phpdb packages are installed, you need to configure the adapter through your application's service manager. +Now that the phpdb packages are installed, you need to configure the +adapter through your application's service manager. ### Configuring the Adapter -Create a configuration file `config/autoload/database.global.php` (or `local.php` for credentials) to define database settings. +Create a configuration file `config/autoload/database.global.php` +(or `local.php` for credentials) to define database settings. ### Working with a SQLite database SQLite is a lightweight option to have the application working with a database. Here is an example of the configuration array for a SQLite database. -Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: +Assuming the SQLite file path is `data/sample.sqlite`, the following +configuration will produce the adapter: ```php title="SQLite adapter configuration" get(AdapterInterface::class); ``` -You can read more about the [adapter in the adapter chapter of the documentation](../adapter.md). +You can read more about the +[adapter in the adapter chapter of the documentation](../adapter.md). ## Adapter-Aware Services with AdapterServiceDelegator -If you have services that implement `PhpDb\Adapter\AdapterAwareInterface`, you can use the `AdapterServiceDelegator` to automatically inject the database adapter. +If you have services that implement `PhpDb\Adapter\AdapterAwareInterface`, +you can use the `AdapterServiceDelegator` to automatically inject the +database adapter. ### Using the Delegator @@ -205,4 +215,7 @@ class MyDatabaseService implements AdapterAwareInterface ## 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). +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 index fa44e9b7f..ca1134cb3 100644 --- a/docs/book/application-integration/usage-in-a-mezzio-application.md +++ b/docs/book/application-integration/usage-in-a-mezzio-application.md @@ -4,18 +4,23 @@ For installation instructions, see [Installation](../index.md#installation). ## Service Configuration -Now that the phpdb packages are installed, you need to configure the adapter through Mezzio's dependency injection container. +Now that the phpdb packages are installed, you need to configure the +adapter through Mezzio's dependency injection container. -Mezzio uses PSR-11 containers and typically uses laminas-servicemanager or another DI container. The adapter configuration goes in your application's configuration files. +Mezzio uses PSR-11 containers and typically uses laminas-servicemanager +or another DI container. The adapter configuration goes in your +application's configuration files. -Create a configuration file `config/autoload/database.global.php` to define database settings. +Create a configuration file `config/autoload/database.global.php` to +define database settings. ### Working with a SQLite database SQLite is a lightweight option to have the application working with a database. Here is an example of the configuration array for a SQLite database. -Assuming the SQLite file path is `data/sample.sqlite`, the following configuration will produce the adapter: +Assuming the SQLite file path is `data/sample.sqlite`, the following +configuration will produce the adapter: ```php title="SQLite adapter configuration" adapter->query( 'SELECT id, username, email FROM users WHERE status = ?', ['active'] @@ -188,8 +200,9 @@ use Psr\Container\ContainerInterface; class UserListHandlerFactory { - public function __invoke(ContainerInterface $container): UserListHandler - { + public function __invoke( + ContainerInterface $container + ): UserListHandler { return new UserListHandler( $container->get(AdapterInterface::class) ); @@ -199,7 +212,8 @@ class UserListHandlerFactory ### Registering the Handler -Register your handler factory in `config/autoload/dependencies.global.php`: +Register your handler factory in +`config/autoload/dependencies.global.php`: ```php title="Registering the handler in dependencies configuration" get(AdapterInterface::class) ); @@ -323,8 +339,9 @@ class UserListHandler implements RequestHandlerInterface ) { } - public function handle(ServerRequestInterface $request): ResponseInterface - { + public function handle( + ServerRequestInterface $request + ): ResponseInterface { $users = $this->usersTable->findActiveUsers(); return new JsonResponse(['users' => $users]); @@ -332,11 +349,14 @@ class UserListHandler implements RequestHandlerInterface } ``` -You can read more about the [adapter in the adapter chapter of the documentation](../adapter.md) and [TableGateway in the table gateway chapter](../table-gateway.md). +You can read more about the +[adapter in the adapter chapter of the documentation](../adapter.md) and +[TableGateway in the table gateway chapter](../table-gateway.md). ## Environment-based Configuration -For production deployments, use environment variables to configure database credentials: +For production deployments, use environment variables to configure +database credentials: ### Using dotenv @@ -396,7 +416,9 @@ use Psr\Container\ContainerInterface; return [ 'dependencies' => [ 'factories' => [ - Adapter::class => function (ContainerInterface $container) { + Adapter::class => function ( + ContainerInterface $container + ) { $dbType = $_ENV['DB_TYPE'] ?? 'sqlite'; if ($dbType === 'mysql') { @@ -413,7 +435,8 @@ return [ // Default to SQLite $driver = new Sqlite([ - 'database' => $_ENV['DB_DATABASE'] ?? 'data/app.sqlite', + 'database' => + $_ENV['DB_DATABASE'] ?? 'data/app.sqlite', ]); return new Adapter($driver, new SqlitePlatform()); }, @@ -427,7 +450,10 @@ return [ ## 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). +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). ## Testing with Database @@ -451,7 +477,9 @@ use Psr\Container\ContainerInterface; return [ 'dependencies' => [ 'factories' => [ - Adapter::class => function (ContainerInterface $container) { + Adapter::class => function ( + ContainerInterface $container + ) { $driver = new Sqlite([ 'database' => ':memory:', ]); @@ -499,7 +527,8 @@ class UserListHandlerTest extends TestCase ); $this->adapter->query( - "INSERT INTO users (username, email, status) VALUES + "INSERT INTO users (username, email, status) + VALUES ('alice', 'alice@example.com', 'active'), ('bob', 'bob@example.com', 'active')" ); @@ -523,11 +552,13 @@ class UserListHandlerTest extends TestCase ### Use Dependency Injection -Always inject the adapter or table gateway through constructors, never instantiate directly in handlers. +Always inject the adapter or table gateway through constructors, +never instantiate directly in handlers. ### Separate Database Logic -Create repository or table gateway classes to separate database logic from HTTP handlers: +Create repository or table gateway classes to separate database logic +from HTTP handlers: ```php title="Repository pattern implementation for database operations" adapter); $select = $sql->select('users'); - $statement = $sql->prepareStatementForSqlObject($select); + $statement = + $sql->prepareStatementForSqlObject($select); $results = $statement->execute(); return iterator_to_array($results); @@ -563,7 +595,8 @@ class UserRepository $select = $sql->select('users'); $select->where(['id' => $id]); - $statement = $sql->prepareStatementForSqlObject($select); + $statement = + $sql->prepareStatementForSqlObject($select); $results = $statement->execute(); $row = $results->current(); @@ -574,7 +607,8 @@ class UserRepository ### Use Configuration Factories -Centralize adapter configuration in factory classes for better maintainability and testability. +Centralize adapter configuration in factory classes for better +maintainability and testability. ### Handle Exceptions @@ -583,8 +617,9 @@ Always wrap database operations in try-catch blocks: ```php title="Exception handling for database operations" use PhpDb\Adapter\Exception\RuntimeException; -public function handle(ServerRequestInterface $request): ResponseInterface -{ +public function handle( + ServerRequestInterface $request +): ResponseInterface { try { $users = $this->usersTable->findActiveUsers(); return new JsonResponse(['users' => $users]); diff --git a/docs/book/docker-deployment.md b/docs/book/docker-deployment.md index 846961be5..721d2749e 100644 --- a/docs/book/docker-deployment.md +++ b/docs/book/docker-deployment.md @@ -1,10 +1,12 @@ # Docker Deployment -This guide covers Docker deployment for phpdb applications, applicable to both Laminas MVC and Mezzio frameworks. +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). +Two web server options are supported: **Nginx with PHP-FPM** +(recommended for production) and **Apache** (simpler for development). ### Nginx with PHP-FPM @@ -37,7 +39,8 @@ server { location ~ \.php$ { fastcgi_pass app:9000; fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param SCRIPT_FILENAME \ + $document_root$fastcgi_script_name; include fastcgi_params; } @@ -58,7 +61,8 @@ 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 + && sed -i 's!/var/www/html!/var/www/public!g' \ + /etc/apache2/sites-available/000-default.conf WORKDIR /var/www @@ -72,7 +76,8 @@ mysql: image: mysql:8.0 ports: - "3306:3306" - command: --default-authentication-plugin=mysql_native_password + command: \ + --default-authentication-plugin=mysql_native_password volumes: - mysql_data:/var/lib/mysql - ./docker/mysql/init:/docker-entrypoint-initdb.d @@ -143,7 +148,8 @@ services: - "8080:80" volumes: - .:/var/www - - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf + - ./docker/nginx/default.conf:\ + /etc/nginx/conf.d/default.conf depends_on: - app @@ -151,7 +157,8 @@ services: image: mysql:8.0 ports: - "3306:3306" - command: --default-authentication-plugin=mysql_native_password + command: \ + --default-authentication-plugin=mysql_native_password volumes: - mysql_data:/var/lib/mysql - ./docker/mysql/init:/docker-entrypoint-initdb.d @@ -201,7 +208,8 @@ services: image: mysql:8.0 ports: - "3306:3306" - command: --default-authentication-plugin=mysql_native_password + command: \ + --default-authentication-plugin=mysql_native_password volumes: - mysql_data:/var/lib/mysql - ./docker/mysql/init:/docker-entrypoint-initdb.d @@ -238,7 +246,8 @@ 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. +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`: @@ -249,7 +258,8 @@ CREATE TABLE users ( 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; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; CREATE INDEX idx_status ON users(status); ``` @@ -279,4 +289,5 @@ docker compose logs -f app docker compose down ``` -Access your application at `http://localhost:8080` and phpMyAdmin at `http://localhost:8081`. +Access your application at `http://localhost:8080` and phpMyAdmin at +`http://localhost:8081`. diff --git a/docs/book/index.md b/docs/book/index.md index 57ed84e51..57177f0ae 100644 --- a/docs/book/index.md +++ b/docs/book/index.md @@ -2,11 +2,14 @@ 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 +- **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 +- **TableGateway and RowGateway** implementations for the Table Data + Gateway and Row Data Gateway patterns ## Installation @@ -16,7 +19,8 @@ Install the core package via Composer: composer require php-db/phpdb ``` -Additionally, install the driver package(s) for the database(s) you plan to use: +Additionally, install the driver package(s) for the database(s) you plan to +use: ```bash # For MySQL/MariaDB support @@ -31,9 +35,11 @@ composer require php-db/postgres ### Mezzio -phpdb provides a `ConfigProvider` that is automatically registered when using [laminas-component-installer](https://docs.laminas.dev/laminas-component-installer/). +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`: +If you are not using the component installer, add the following to your +`config/config.php`: ```php $aggregator = new ConfigAggregator([ @@ -42,13 +48,17 @@ $aggregator = new ConfigAggregator([ ]); ``` -For detailed Mezzio configuration including adapter setup and dependency injection, see the [Mezzio integration guide](application-integration/usage-in-a-mezzio-application.md). +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/). +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`: +If you are not using the component installer, add the module to your +`config/modules.config.php`: ```php return [ @@ -57,14 +67,18 @@ return [ ]; ``` -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). +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 +- **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: @@ -129,9 +143,14 @@ $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 +- **[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 +- **[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 index 790fb1a5f..e3572bc13 100644 --- a/docs/book/metadata/examples.md +++ b/docs/book/metadata/examples.md @@ -3,8 +3,10 @@ ## Common Patterns and Best Practices ```php title="Finding All Tables with a Specific Column" -function findTablesWithColumn(MetadataInterface $metadata, string $columnName): array -{ +function findTablesWithColumn( + MetadataInterface $metadata, + string $columnName +): array { $tables = []; foreach ($metadata->getTableNames() as $tableName) { $columnNames = $metadata->getColumnNames($tableName); @@ -19,8 +21,10 @@ $tablesWithUserId = findTablesWithColumn($metadata, 'user_id'); ``` ```php title="Discovering Foreign Key Relationships" -function getForeignKeyRelationships(MetadataInterface $metadata, string $tableName): array -{ +function getForeignKeyRelationships( + MetadataInterface $metadata, + string $tableName +): array { $relationships = []; $constraints = $metadata->getConstraints($tableName); @@ -44,8 +48,10 @@ function getForeignKeyRelationships(MetadataInterface $metadata, string $tableNa ``` ```php title="Generating Schema Documentation" -function generateTableDocumentation(MetadataInterface $metadata, string $tableName): string -{ +function generateTableDocumentation( + MetadataInterface $metadata, + string $tableName +): string { $table = $metadata->getTable($tableName); $doc = "# Table: $tableName\n\n"; @@ -75,15 +81,22 @@ function generateTableDocumentation(MetadataInterface $metadata, string $tableNa $constraints = $metadata->getConstraints($tableName); foreach ($constraints as $constraint) { - $doc .= "- **{$constraint->getName()}** ({$constraint->getType()})\n"; + $doc .= "- **{$constraint->getName()}** "; + $doc .= "({$constraint->getType()})\n"; if ($constraint->hasColumns()) { - $doc .= " - Columns: " . implode(', ', $constraint->getColumns()) . "\n"; + $doc .= " - Columns: " . + implode(', ', $constraint->getColumns()) . "\n"; } if ($constraint->isForeignKey()) { - $doc .= " - References: {$constraint->getReferencedTableName()}"; - $doc .= "(" . implode(', ', $constraint->getReferencedColumns()) . ")\n"; - $doc .= " - ON UPDATE: {$constraint->getUpdateRule()}\n"; - $doc .= " - ON DELETE: {$constraint->getDeleteRule()}\n"; + $doc .= " - References: "; + $doc .= "{$constraint->getReferencedTableName()}"; + $doc .= "(" . + implode(', ', $constraint->getReferencedColumns()) . + ")\n"; + $doc .= " - ON UPDATE: "; + $doc .= "{$constraint->getUpdateRule()}\n"; + $doc .= " - ON DELETE: "; + $doc .= "{$constraint->getDeleteRule()}\n"; } } @@ -117,25 +130,39 @@ function compareTables( ``` ```php title="Generating Entity Classes from Metadata" -function generateEntityClass(MetadataInterface $metadata, string $tableName): string -{ +function generateEntityClass( + MetadataInterface $metadata, + string $tableName +): string { $columns = $metadata->getColumns($tableName); - $className = str_replace(' ', '', ucwords(str_replace('_', ' ', $tableName))); + $className = str_replace( + ' ', + '', + ucwords(str_replace('_', ' ', $tableName)) + ); $code = "getDataType()) { - 'int', 'integer', 'bigint', 'smallint', 'tinyint' => 'int', + '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} \${$property};\n"; + $property = lcfirst( + str_replace( + ' ', + '', + ucwords(str_replace('_', ' ', $column->getName())) + ) + ); + + $code .= " private {$nullable}{$type} "; + $code .= "\${$property};\n"; } $code .= "}\n"; @@ -158,14 +185,15 @@ try { **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 in this table | +| `getConstraint()` | Cannot find a constraint by that name | | `getTrigger()` | Trigger "name" does not exist | -**Best practice:** Check existence first using `getTableNames()`, `getColumnNames()`, etc: +**Best practice:** Check existence first using `getTableNames()`, +`getColumnNames()`, etc: ```php if (in_array('users', $metadata->getTableNames(), true)) { @@ -175,8 +203,8 @@ if (in_array('users', $metadata->getTableNames(), true)) { ### Performance with Large Schemas -When working with databases that have hundreds of tables, use `get*Names()` -methods instead of retrieving full objects: +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(); @@ -209,8 +237,8 @@ try { ### Caching Metadata -The metadata component queries the database each time a method is called. For -better performance in production, consider caching the results: +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'); diff --git a/docs/book/metadata/intro.md b/docs/book/metadata/intro.md index 7178bb20e..e1ee8d78e 100644 --- a/docs/book/metadata/intro.md +++ b/docs/book/metadata/intro.md @@ -1,9 +1,9 @@ # 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: +`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 @@ -14,25 +14,61 @@ 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 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 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; + public function getTrigger( + string $triggerName, + ?string $schema = null + ) : Object\TriggerObject; } ``` @@ -40,9 +76,9 @@ interface MetadataInterface ### 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: +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; @@ -80,9 +116,12 @@ $schemas = $metadata->getSchemas(); The other methods return value objects specific to the type queried: ```php -$table = $metadata->getTable('users'); // Returns TableObject or ViewObject -$column = $metadata->getColumn('id', 'users'); // Returns ColumnObject -$constraint = $metadata->getConstraint('PRIMARY', 'users'); // Returns ConstraintObject +// 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 @@ -148,7 +187,8 @@ Example output: ```text PRIMARY KEY (id) -FOREIGN KEY fk_orders_customers (customer_id) REFERENCES customers (id) +FOREIGN KEY fk_orders_customers (customer_id) REFERENCES + customers (id) FOREIGN KEY fk_orders_products (product_id) REFERENCES products (id) ``` @@ -156,18 +196,24 @@ FOREIGN KEY fk_orders_products (product_id) REFERENCES products (id) ### Working with Schemas -The `getSchemas()` method returns all available schema names in the database: +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)); + 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: +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'); @@ -201,7 +247,11 @@ Distinguishing between tables and views: $table = $metadata->getTable('users'); if ($table instanceof \PhpDb\Metadata\Object\ViewObject) { - printf("View: %s\nDefinition: %s\n", $table->getName(), $table->getViewDefinition()); + printf( + "View: %s\nDefinition: %s\n", + $table->getName(), + $table->getViewDefinition() + ); } else { printf("Table: %s\n", $table->getName()); } @@ -250,12 +300,18 @@ 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()); +$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'); + $keys = $metadata->getConstraintKeys( + $constraint->getName(), + 'orders' + ); foreach ($keys as $key) { printf( " %s -> %s.%s\n ON UPDATE: %s\n ON DELETE: %s\n", @@ -313,8 +369,10 @@ 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 'Nullable: ' . + ($column->isNullable() ? 'YES' : 'NO') . PHP_EOL; +echo 'Default: ' . + ($column->getColumnDefault() ?? 'NULL') . PHP_EOL; echo 'Position: ' . $column->getOrdinalPosition() . PHP_EOL; ``` diff --git a/docs/book/metadata/objects.md b/docs/book/metadata/objects.md index 8d6a260a4..2216745de 100644 --- a/docs/book/metadata/objects.md +++ b/docs/book/metadata/objects.md @@ -1,11 +1,13 @@ # 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: +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: +`TableObject` extends `AbstractTableObject` and represents a database +table: ```php title="TableObject Class Definition" class PhpDb\Metadata\Object\TableObject extends AbstractTableObject @@ -27,7 +29,11 @@ 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 __construct( + string $name, + string $tableName, + ?string $schemaName = null + ); public function setName(string $name): void; public function getName(): string; @@ -42,7 +48,9 @@ class PhpDb\Metadata\Object\ColumnObject public function setOrdinalPosition(?int $ordinalPosition): static; public function getColumnDefault(): ?string; - public function setColumnDefault(null|string|int|bool $columnDefault): static; + public function setColumnDefault( + null|string|int|bool $columnDefault + ): static; public function getIsNullable(): ?bool; public function setIsNullable(?bool $isNullable): static; @@ -52,10 +60,14 @@ class PhpDb\Metadata\Object\ColumnObject public function setDataType(string $dataType): static; public function getCharacterMaximumLength(): ?int; - public function setCharacterMaximumLength(?int $characterMaximumLength): static; + public function setCharacterMaximumLength( + ?int $characterMaximumLength + ): static; public function getCharacterOctetLength(): ?int; - public function setCharacterOctetLength(?int $characterOctetLength): static; + public function setCharacterOctetLength( + ?int $characterOctetLength + ): static; public function getNumericPrecision(): ?int; public function setNumericPrecision(?int $numericPrecision): static; @@ -64,8 +76,11 @@ class PhpDb\Metadata\Object\ColumnObject public function setNumericScale(?int $numericScale): static; public function getNumericUnsigned(): ?bool; - public function setNumericUnsigned(?bool $numericUnsigned): static; - public function isNumericUnsigned(): ?bool; // Alias for getNumericUnsigned() + public function setNumericUnsigned( + ?bool $numericUnsigned + ): static; + // Alias for getNumericUnsigned() + public function isNumericUnsigned(): ?bool; public function getErratas(): array; public function setErratas(array $erratas): static; @@ -82,7 +97,11 @@ 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 __construct( + string $name, + string $tableName, + ?string $schemaName = null + ); public function setName(string $name): void; public function getName(): string; @@ -101,13 +120,19 @@ class PhpDb\Metadata\Object\ConstraintObject public function setColumns(array $columns): static; public function getReferencedTableSchema(): ?string; - public function setReferencedTableSchema(string $referencedTableSchema): static; + public function setReferencedTableSchema( + string $referencedTableSchema + ): static; public function getReferencedTableName(): ?string; - public function setReferencedTableName(string $referencedTableName): static; + public function setReferencedTableName( + string $referencedTableName + ): static; public function getReferencedColumns(): ?array; - public function setReferencedColumns(array $referencedColumns): static; + public function setReferencedColumns( + array $referencedColumns + ): static; public function getMatchOption(): ?string; public function setMatchOption(string $matchOption): static; @@ -131,8 +156,9 @@ class PhpDb\Metadata\Object\ConstraintObject ## ViewObject -The `ViewObject` extends `AbstractTableObject` and represents database views. It -includes all methods from `TableObject` plus view-specific properties: +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 @@ -176,13 +202,14 @@ The `getCheckOption()` returns the view's check option: - `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. +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: +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 @@ -202,35 +229,52 @@ class PhpDb\Metadata\Object\ConstraintKeyObject public function setOrdinalPosition(int $ordinalPosition): static; public function getPositionInUniqueConstraint(): ?bool; - public function setPositionInUniqueConstraint(bool $positionInUniqueConstraint): static; + public function setPositionInUniqueConstraint( + bool $positionInUniqueConstraint + ): static; public function getReferencedTableSchema(): ?string; - public function setReferencedTableSchema(string $referencedTableSchema): static; + public function setReferencedTableSchema( + string $referencedTableSchema + ): static; public function getReferencedTableName(): ?string; - public function setReferencedTableName(string $referencedTableName): static; + public function setReferencedTableName( + string $referencedTableName + ): static; public function getReferencedColumnName(): ?string; - public function setReferencedColumnName(string $referencedColumnName): static; + public function setReferencedColumnName( + string $referencedColumnName + ): static; public function getForeignKeyUpdateRule(): ?string; - public function setForeignKeyUpdateRule(string $foreignKeyUpdateRule): void; + public function setForeignKeyUpdateRule( + string $foreignKeyUpdateRule + ): void; public function getForeignKeyDeleteRule(): ?string; - public function setForeignKeyDeleteRule(string $foreignKeyDeleteRule): void; + 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'); +$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; + echo ' ON UPDATE: ' . + $key->getForeignKeyUpdateRule() . PHP_EOL; + echo ' ON DELETE: ' . + $key->getForeignKeyDeleteRule() . PHP_EOL; } ``` @@ -253,43 +297,65 @@ class PhpDb\Metadata\Object\TriggerObject public function setName(string $name): static; public function getEventManipulation(): ?string; - public function setEventManipulation(string $eventManipulation): static; + public function setEventManipulation( + string $eventManipulation + ): static; public function getEventObjectCatalog(): ?string; - public function setEventObjectCatalog(string $eventObjectCatalog): static; + public function setEventObjectCatalog( + string $eventObjectCatalog + ): static; public function getEventObjectSchema(): ?string; - public function setEventObjectSchema(string $eventObjectSchema): static; + public function setEventObjectSchema( + string $eventObjectSchema + ): static; public function getEventObjectTable(): ?string; - public function setEventObjectTable(string $eventObjectTable): static; + 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 setActionCondition( + ?string $actionCondition + ): static; public function getActionStatement(): ?string; - public function setActionStatement(string $actionStatement): static; + public function setActionStatement( + string $actionStatement + ): static; public function getActionOrientation(): ?string; - public function setActionOrientation(string $actionOrientation): static; + 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 setActionReferenceOldTable( + ?string $actionReferenceOldTable + ): static; public function getActionReferenceNewTable(): ?string; - public function setActionReferenceNewTable(?string $actionReferenceNewTable): static; + public function setActionReferenceNewTable( + ?string $actionReferenceNewTable + ): static; public function getActionReferenceOldRow(): ?string; - public function setActionReferenceOldRow(string $actionReferenceOldRow): static; + public function setActionReferenceOldRow( + string $actionReferenceOldRow + ): static; public function getActionReferenceNewRow(): ?string; - public function setActionReferenceNewRow(string $actionReferenceNewRow): static; + 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 index 269238d24..92cf67d16 100644 --- a/docs/book/profiler.md +++ b/docs/book/profiler.md @@ -1,6 +1,9 @@ # 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. +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 @@ -20,7 +23,8 @@ $adapter->setProfiler($profiler); $adapter = new Adapter($driver, $platform, $resultSetPrototype, $profiler); ``` -Once attached, the profiler automatically tracks all queries executed through the adapter. +Once attached, the profiler automatically tracks all queries executed through +the adapter. ## Retrieving Profile Data @@ -64,13 +68,13 @@ foreach ($allProfiles as $index => $profile) { Each profile entry contains: -| Key | Type | Description | -|--------------|---------------------------|------------------------------------------------| -| `sql` | `string` | The SQL query that was executed | -| `parameters` | `ParameterContainer\|null` | The bound parameters (if any) | -| `start` | `float` | Unix timestamp with microseconds (query start) | -| `end` | `float` | Unix timestamp with microseconds (query end) | -| `elapse` | `float` | Total execution time in seconds | +| 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 @@ -137,7 +141,7 @@ class DebugBarCollector $queries[] = [ 'sql' => $profile['sql'], 'params' => $profile['parameters']?->getNamedArray() ?? [], - 'duration' => round($profile['elapse'] * 1000, 2), // Convert to ms + 'duration' => round($profile['elapse'] * 1000, 2), 'duration_str' => sprintf('%.2f ms', $profile['elapse'] * 1000), ]; } @@ -357,7 +361,8 @@ $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: +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 diff --git a/docs/book/result-set/advanced.md b/docs/book/result-set/advanced.md index d2806bfdd..d0398275f 100644 --- a/docs/book/result-set/advanced.md +++ b/docs/book/result-set/advanced.md @@ -4,8 +4,8 @@ ### ResultSet Class -The `ResultSet` class extends `AbstractResultSet` and provides row data as either -`ArrayObject` instances or plain arrays. +The `ResultSet` class extends `AbstractResultSet` and provides row data as +either `ArrayObject` instances or plain arrays. ```php title="ResultSet Class Definition" namespace PhpDb\ResultSet; @@ -19,7 +19,9 @@ class ResultSet extends AbstractResultSet ?ArrayObject $rowPrototype = null ); - public function setRowPrototype(ArrayObject $rowPrototype): ResultSetInterface; + public function setRowPrototype( + ArrayObject $rowPrototype + ): ResultSetInterface; public function getRowPrototype(): ArrayObject; public function getReturnType(): ResultSetReturnType; } @@ -27,7 +29,8 @@ class ResultSet extends AbstractResultSet ### ResultSetReturnType Enum -The `ResultSetReturnType` enum provides type-safe return type configuration: +The `ResultSetReturnType` enum provides type-safe return type +configuration: ```php title="ResultSetReturnType Definition" namespace PhpDb\ResultSet; @@ -51,10 +54,12 @@ $resultSet = new ResultSet(ResultSetReturnType::Array); **`$returnType`** - Controls how rows are returned: -- `ResultSetReturnType::ArrayObject` (default) - Returns rows as ArrayObject instances +- `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) +**`$rowPrototype`** - Custom ArrayObject prototype for row objects +(only used with ArrayObject mode) #### Return Type Modes @@ -99,10 +104,14 @@ class HydratingResultSet extends AbstractResultSet ?object $rowPrototype = null ); - public function setHydrator(HydratorInterface $hydrator): ResultSetInterface; + public function setHydrator( + HydratorInterface $hydrator + ): ResultSetInterface; public function getHydrator(): HydratorInterface; - public function setRowPrototype(object $rowPrototype): ResultSetInterface; + public function setRowPrototype( + object $rowPrototype + ): ResultSetInterface; public function getRowPrototype(): object; public function current(): ?object; @@ -132,7 +141,10 @@ You can change the hydration strategy at runtime: use Laminas\Hydrator\ClassMethodsHydrator; use Laminas\Hydrator\ReflectionHydrator; -$resultSet = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); +$resultSet = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() +); $resultSet->initialize($result); foreach ($resultSet as $user) { @@ -167,7 +179,8 @@ foreach ($resultSet as $row) { } ``` -**Important:** Calling `buffer()` after iteration has started throws `RuntimeException`: +**Important:** Calling `buffer()` after iteration has started throws +`RuntimeException`: ```php title="Buffer After Iteration Error" $resultSet = new ResultSet(); @@ -183,7 +196,8 @@ $resultSet->buffer(); Throws: ```text -RuntimeException: Buffering must be enabled before iteration is started +RuntimeException: Buffering must be enabled before iteration is +started ``` ### isBuffered() Method @@ -230,7 +244,8 @@ bool(true) ## ArrayObject Access Patterns -When using ArrayObject mode (default), rows support both property and array access: +When using ArrayObject mode (default), rows support both property and array +access: ```php title="Property and Array Access" $resultSet = new ResultSet(ResultSetReturnType::ArrayObject); @@ -267,7 +282,10 @@ class CustomRow extends ArrayObject } $prototype = new CustomRow([], ArrayObject::ARRAY_AS_PROPS); -$resultSet = new ResultSet(ResultSetReturnType::ArrayObject, $prototype); +$resultSet = new ResultSet( + ResultSetReturnType::ArrayObject, + $prototype +); $resultSet->initialize($result); foreach ($resultSet as $row) { @@ -294,7 +312,8 @@ $resultSet1 = $adapter->query('SELECT * FROM users'); $resultSet2 = $adapter->query('SELECT * FROM posts'); ``` -Both `$resultSet1` and `$resultSet2` are independent clones with their own state. +Both `$resultSet1` and `$resultSet2` are independent clones with their own +state. ### Customizing the Prototype @@ -323,7 +342,10 @@ use PhpDb\ResultSet\HydratingResultSet; use PhpDb\TableGateway\TableGateway; use Laminas\Hydrator\ReflectionHydrator; -$prototype = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); +$prototype = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() +); $userTable = new TableGateway('users', $adapter, null, $prototype); @@ -408,7 +430,10 @@ Switch hydrators based on context: use Laminas\Hydrator\ClassMethodsHydrator; use Laminas\Hydrator\ReflectionHydrator; -$resultSet = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); +$resultSet = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() +); if ($includePrivateProps) { $resultSet->setHydrator(new ReflectionHydrator()); @@ -433,13 +458,17 @@ 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 = 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. +Each row is extracted back to an array using the hydrator's `extract()` +method. ### Accessing Current Row diff --git a/docs/book/result-set/examples.md b/docs/book/result-set/examples.md index bc2102e72..cf4f14843 100644 --- a/docs/book/result-set/examples.md +++ b/docs/book/result-set/examples.md @@ -219,7 +219,8 @@ try { ### Property Access Not Working -`$row->column_name` returns null? Ensure using ArrayObject mode (default), or use array access: `$row['column_name']`. +`$row->column_name` returns null? Ensure using ArrayObject mode (default), +or use array access: `$row['column_name']`. ### Hydration Failures @@ -236,14 +237,18 @@ Column names must match property names or setter methods: // Database columns: first_name, last_name class UserEntity { - protected string $first_name; // Matches column name - public function setFirstName($value) {} // For ClassMethodsHydrator + // 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`). +Ensure the result set is buffered first: `$resultSet->buffer()`. For +`HydratingResultSet`, the hydrator must have an `extract()` method +(e.g., `ReflectionHydrator`). ## Performance Tips @@ -293,7 +298,9 @@ foreach ($users as $user) { Reduce data at the database level: ```php -$resultSet = $adapter->query('SELECT id, name FROM users WHERE active = 1 LIMIT 100'); +$resultSet = $adapter->query( + 'SELECT id, name FROM users WHERE active = 1 LIMIT 100' +); ``` ### Profile Memory Usage diff --git a/docs/book/result-set/intro.md b/docs/book/result-set/intro.md index f3b55e28b..9b1fb1d26 100644 --- a/docs/book/result-set/intro.md +++ b/docs/book/result-set/intro.md @@ -1,6 +1,10 @@ # 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. +`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: @@ -14,7 +18,9 @@ interface ResultSetInterface extends Traversable, Countable { public function initialize(iterable $dataSource): ResultSetInterface; public function getFieldCount(): mixed; - public function setRowPrototype(ArrayObject $rowPrototype): ResultSetInterface; + public function setRowPrototype( + ArrayObject $rowPrototype + ): ResultSetInterface; public function getRowPrototype(): ?object; } ``` @@ -68,8 +74,11 @@ 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 initialize( + array|Iterator|IteratorAggregate|ResultInterface $dataSource + ): ResultSetInterface; + public function getDataSource(): + array|Iterator|IteratorAggregate|ResultInterface; public function getFieldCount(): int; public function buffer(): ResultSetInterface; @@ -92,19 +101,15 @@ abstract class AbstractResultSet implements Iterator, ResultSetInterface `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. +`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: -<<<<<<< HEAD:docs/book/result-set/intro.md ```bash title="Installing laminas-hydrator" -======= -```bash ->>>>>>> origin/0.4.x:docs/book/result-set.md composer require laminas/laminas-hydrator ``` @@ -123,7 +128,10 @@ $statement->prepare(); $result = $statement->execute(); if ($result instanceof ResultInterface && $result->isQueryResult()) { - $resultSet = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); + $resultSet = new HydratingResultSet( + new ReflectionHydrator(), + new UserEntity() + ); $resultSet->initialize($result); foreach ($resultSet as $user) { @@ -132,13 +140,15 @@ if ($result instanceof ResultInterface && $result->isQueryResult()) { } ``` -For more information, see the [laminas-hydrator](https://docs.laminas.dev/laminas-hydrator/) +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`: +The `initialize()` method accepts arrays, `Iterator`, `IteratorAggregate`, +or `ResultInterface`: ```php // Arrays (auto-buffered, allows multiple iterations) diff --git a/docs/book/row-gateway.md b/docs/book/row-gateway.md index 8340020f5..cfeb1c467 100644 --- a/docs/book/row-gateway.md +++ b/docs/book/row-gateway.md @@ -1,6 +1,9 @@ # Row Gateways -`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. +`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 these methods: @@ -28,7 +31,10 @@ The following demonstrates a basic use case. 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(); @@ -45,9 +51,10 @@ $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: @@ -69,8 +76,8 @@ $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 title="Custom ActiveRecord-Style Implementation" use PhpDb\TableGateway\Feature\RowGatewayFeature; @@ -89,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 index eb5d53eb7..3110b1ee7 100644 --- a/docs/book/sql-ddl/advanced.md +++ b/docs/book/sql-ddl/advanced.md @@ -4,7 +4,9 @@ ### DDL Error Behavior -**Important:** DDL objects themselves do **not throw exceptions** during construction or configuration. They are designed to build up state without validation. +**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: diff --git a/docs/book/sql-ddl/alter-drop.md b/docs/book/sql-ddl/alter-drop.md index 50c73b212..c01053505 100644 --- a/docs/book/sql-ddl/alter-drop.md +++ b/docs/book/sql-ddl/alter-drop.md @@ -2,7 +2,8 @@ ## AlterTable -The `AlterTable` class represents an `ALTER TABLE` statement. It provides methods to modify existing table structures. +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; @@ -304,13 +305,19 @@ foreach ($tables as $tableName) { ### 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. +**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 +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 @@ -323,9 +330,12 @@ $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) +// - 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 @@ -346,7 +356,8 @@ $count = new Column\Integer('count'); $count->setOption('unsigned', true); ``` -**Note:** Not all options work on all platforms. Test your DDL against your target database. +**Note:** Not all options work on all platforms. Test your DDL against your +target database. ### Platform Detection diff --git a/docs/book/sql-ddl/columns.md b/docs/book/sql-ddl/columns.md index 8fe63a876..69d89f5d1 100644 --- a/docs/book/sql-ddl/columns.md +++ b/docs/book/sql-ddl/columns.md @@ -19,7 +19,16 @@ $column = new Integer('user_id'); $column->setOption('length', 11); ``` -**Constructor:** `__construct($name, $nullable = false, $default = null, array $options = [])` +**Constructor:** + +```php +__construct( + $name, + $nullable = false, + $default = null, + array $options = [] +) +``` **Methods:** @@ -41,7 +50,16 @@ $column = new BigInteger('large_number'); $column = new BigInteger('id', false, null, ['length' => 20]); ``` -**Constructor:** `__construct($name, $nullable = false, $default = null, array $options = [])` +**Constructor:** + +```php +__construct( + $name, + $nullable = false, + $default = null, + array $options = [] +) +``` ### Decimal @@ -138,7 +156,17 @@ $column = new Text('content', 65535); // With length limit $column = new Text('notes', null, true, 'No notes'); ``` -**Constructor:** `__construct($name, $length = null, $nullable = false, $default = null, array $options = [])` +**Constructor:** + +```php +__construct( + $name, + $length = null, + $nullable = false, + $default = null, + array $options = [] +) +``` ## Binary Types @@ -152,7 +180,17 @@ use PhpDb\Sql\Ddl\Column\Binary; $column = new Binary('hash', 32); // 32-byte hash ``` -**Constructor:** `__construct($name, $length, $nullable = false, $default = null, array $options = [])` +**Constructor:** + +```php +__construct( + $name, + $length, + $nullable = false, + $default = null, + array $options = [] +) +``` ### Varbinary @@ -177,7 +215,17 @@ $column = new Blob('image'); $column = new Blob('document', 16777215); // MEDIUMBLOB size ``` -**Constructor:** `__construct($name, $length = null, $nullable = false, $default = null, array $options = [])` +**Constructor:** + +```php +__construct( + $name, + $length = null, + $nullable = false, + $default = null, + array $options = [] +) +``` ## Date and Time Types @@ -248,7 +296,9 @@ $column->setOption('on_update', true); ### Boolean -Boolean/bit column. **Note:** Boolean columns are always NOT NULL and cannot be made nullable. +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; @@ -329,13 +379,13 @@ $options = $column->getOptions(); ### Documented Options -| Option | Type | Platforms | Description | Example | -|--------|------|-----------|-------------|---------| -| `AUTO_INCREMENT` | bool | MySQL, MariaDB | Auto-incrementing integer | `$col->setOption('AUTO_INCREMENT', true)` | -| `identity` | bool | PostgreSQL, SQL Server | Identity/Serial column | `$col->setOption('identity', true)` | -| `comment` | string | MySQL, PostgreSQL | Column comment/description | `$col->setOption('comment', 'User ID')` | -| `on_update` | bool | MySQL (Timestamp) | ON UPDATE CURRENT_TIMESTAMP | `$col->setOption('on_update', true)` | -| `length` | int | MySQL (Integer) | Display width | `$col->setOption('length', 11)` | +| 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 @@ -419,10 +469,13 @@ $table->addColumn($column); **Important Considerations:** -1. **Not all options work on all platforms** - Test your DDL against your target database +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 +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 diff --git a/docs/book/sql-ddl/constraints.md b/docs/book/sql-ddl/constraints.md index 9bc0f3012..887b1daa6 100644 --- a/docs/book/sql-ddl/constraints.md +++ b/docs/book/sql-ddl/constraints.md @@ -1,6 +1,7 @@ # Constraints and Indexes -Constraints enforce data integrity rules at the database level. All constraints are in the `PhpDb\Sql\Ddl\Constraint` namespace. +Constraints enforce data integrity rules at the database level. +All constraints are in the `PhpDb\Sql\Ddl\Constraint` namespace. ## Primary Key Constraints @@ -256,7 +257,8 @@ $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. +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; @@ -361,7 +363,8 @@ $alter->dropIndex('idx_deprecated_field'); ## Naming Conventions -While some constraints allow optional names, it's a best practice to always provide explicit names: +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 @@ -429,7 +432,8 @@ $table->addConstraint(new Index(['category_id', 'created_at'], 'idx_category_dat $table->addConstraint(new Index(['status', 'priority', 'created_at'], 'idx_active_priority')); // 4. Prefix indexes for large text columns -$table->addConstraint(new Index('title', 'idx_title', [100])); // Index first 100 chars +// Index first 100 chars +$table->addConstraint(new Index('title', 'idx_title', [100])); ``` ### Index Order Matters diff --git a/docs/book/sql-ddl/examples.md b/docs/book/sql-ddl/examples.md index 757c565f4..e5830677b 100644 --- a/docs/book/sql-ddl/examples.md +++ b/docs/book/sql-ddl/examples.md @@ -504,6 +504,8 @@ $childTable->addConstraint(new Constraint\ForeignKey( $table->addConstraint(new Index( 'long_description', 'idx_description', - [191] // MySQL InnoDB with utf8mb4 has 767 byte limit; 191 chars * 4 bytes = 764 + // 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 index 49c31387f..5ddfccbbe 100644 --- a/docs/book/sql-ddl/intro.md +++ b/docs/book/sql-ddl/intro.md @@ -1,6 +1,8 @@ # 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. +`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 @@ -33,7 +35,8 @@ $adapter->query( ## Creating Tables -The `CreateTable` class represents a `CREATE TABLE` statement. You can build complex table definitions using a fluent, object-oriented interface. +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; diff --git a/docs/book/sql/examples.md b/docs/book/sql/examples.md index 701a8e3d9..0e9d4600c 100644 --- a/docs/book/sql/examples.md +++ b/docs/book/sql/examples.md @@ -4,7 +4,8 @@ ### Handling Column Name Conflicts in JOINs -When joining tables with columns that have the same name, explicitly specify column aliases to avoid ambiguity: +When joining tables with columns that have the same name, +explicitly specify column aliases to avoid ambiguity: ```php $select->from(['u' => 'users']) @@ -24,7 +25,8 @@ $select->from(['u' => 'users']) ); ``` -This prevents confusion and ensures all columns are accessible in the result set. +This prevents confusion and ensures all columns are accessible in the +result set. ### Working with NULL Values @@ -44,7 +46,8 @@ In UPDATE statements: $update->set(['optionalField' => null]); ``` -In comparisons, remember that `column = NULL` does not work in SQL; you must use `IS 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() @@ -153,7 +156,8 @@ When using Expression with placeholders: $expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b']); ``` -Ensure the number of `?` placeholders matches the number of parameters provided, or you will receive a RuntimeException. +Ensure the number of `?` placeholders matches the number of parameters +provided, or you will receive a RuntimeException. ```php title="Correct Parameter Count" // CORRECT @@ -162,7 +166,8 @@ $expression = new Expression('CONCAT(?, ?, ?)', ['a', 'b', 'c']); ### Quote Character Issues -Different databases use different quote characters. Let the platform handle quoting: +Different databases use different quote characters. +Let the platform handle quoting: ```php title="Proper Platform-Managed Quoting" // CORRECT - let the platform handle quoting @@ -178,7 +183,8 @@ $select->from('"users"'); ### Type Confusion in Predicates -When comparing two identifiers (column to column), specify both types: +When comparing two identifiers (column to column), +specify both types: ```php title="Column Comparison Using Type Constants" // Using type constants @@ -220,7 +226,8 @@ $statement = $sql->prepareStatementForSqlObject($select); ### Use Prepared Statements -Always use `prepareStatementForSqlObject()` instead of `buildSqlString()` for user input: +Always use `prepareStatementForSqlObject()` instead of +`buildSqlString()` for user input: ```php $select->where(['username' => $userInput]); @@ -272,13 +279,22 @@ Use JOINs instead of multiple queries: ```php title="Using JOINs to Avoid N+1 Queries" // WRONG - N+1 queries foreach ($orders as $order) { - $customer = getCustomer($order['customerId']); // Additional query per 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']); + ->join( + 'customers', + 'orders.customerId = customers.id', + ['customerName' => 'name'] + ) + ->join( + 'products', + 'orders.productId = products.id', + ['productName' => 'name'] + ); ``` ### Index-Friendly Queries @@ -295,7 +311,9 @@ 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])); +$select->where( + new Predicate\Expression('YEAR(createdAt) = ?', [2024]) +); ``` Instead, use ranges: @@ -334,7 +352,11 @@ $select = $sql->select('orders') ->or ->equalTo('orders.status', 'shipped') ->unnest(); - $where->between('orders.createdAt', '2024-01-01', '2024-12-31'); + $where->between( + 'orders.createdAt', + '2024-01-01', + '2024-12-31' + ); }) ->group(['customerId', new Expression('YEAR(createdAt)')]) ->having(function ($having) { @@ -419,11 +441,14 @@ $results = $statement->execute(); Produces: ```sql title="Generated SQL for UNION Query" -(SELECT id, name, email, "active" AS status FROM users WHERE status = 'active') +(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) +(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) +(SELECT id, name, email, "suspended" AS status + FROM users WHERE suspended = 1) ``` ```php title="Search with Full-Text and Filters" @@ -435,9 +460,17 @@ $select = $sql->select('products') 'name', 'description', 'price', - 'relevance' => new Expression('MATCH(name, description) AGAINST(?)', [$searchTerm]), + 'relevance' => new Expression( + 'MATCH(name, description) AGAINST(?)', + [$searchTerm] + ), ]) - ->where(function ($where) use ($searchTerm, $categoryId, $minPrice, $maxPrice) { + ->where(function ($where) use ( + $searchTerm, + $categoryId, + $minPrice, + $maxPrice + ) { // Full-text search $where->expression( 'MATCH(name, description) AGAINST(? IN BOOLEAN MODE)', @@ -480,7 +513,10 @@ try { // Archive processed orders $select = $sql->select('orders') ->where(['status' => 'completed']) - ->where->lessThan('completedAt', new Expression('DATE_SUB(NOW(), INTERVAL 1 YEAR)')); + ->where->lessThan( + 'completedAt', + new Expression('DATE_SUB(NOW(), INTERVAL 1 YEAR)') + ); $insert = $sql->insert('orders_archive'); $insert->select($select); @@ -489,7 +525,10 @@ try { // 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)')); + $delete->where->lessThan( + 'completedAt', + new Expression('DATE_SUB(NOW(), INTERVAL 1 YEAR)') + ); $sql->prepareStatementForSqlObject($delete)->execute(); $connection->commit(); diff --git a/docs/book/sql/insert.md b/docs/book/sql/insert.md index 44337d47b..d07c058d0 100644 --- a/docs/book/sql/insert.md +++ b/docs/book/sql/insert.md @@ -5,25 +5,32 @@ 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 +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 __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; + public function getRawState( + ?string $key = null + ) : TableIdentifier|string|array; } ``` -As with `Select`, the table may be provided during instantiation or via the -`into()` method. +As with `Select`, the table may be provided during instantiation or +via the `into()` method. ## Basic Usage @@ -51,13 +58,15 @@ INSERT INTO users (username, email, created_at) VALUES (?, ?, ?) ## columns() -The `columns()` method explicitly sets which columns will receive values: +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: +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']); @@ -70,8 +79,8 @@ $insert->values([ ## values() -The default behavior of values is to set the values. Successive calls will not -preserve values from previous calls. +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([ @@ -96,8 +105,8 @@ INSERT INTO table (col_1, col_2) VALUES (?, ?) ## select() -The `select()` method enables INSERT INTO ... SELECT statements, copying data -from one table to another. +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') @@ -113,7 +122,8 @@ Produces: ```sql title="INSERT SELECT SQL Output" INSERT INTO users (username, email, createdAt) -SELECT username, email, createdAt FROM tempUsers WHERE imported = 0 +SELECT username, email, createdAt +FROM tempUsers WHERE imported = 0 ``` Alternatively, you can pass the Select object directly to `values()`: @@ -122,12 +132,13 @@ Alternatively, you can pass the Select object directly to `values()`: $insert->values($select); ``` -Important: The column order must match between INSERT columns and SELECT columns. +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()`: +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'); @@ -152,8 +163,8 @@ $insert->values([ ## InsertIgnore -The `InsertIgnore` class provides MySQL-specific INSERT IGNORE syntax, which -silently ignores rows that would cause duplicate key errors. +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; @@ -171,11 +182,13 @@ Produces: 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. +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). +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 @@ -211,7 +224,12 @@ $select = $sql->select('users') ->where(['status' => 'active']); $insert = $sql->insert('users_archive'); -$insert->columns(['user_id', 'username', 'email', 'original_created_at']); +$insert->columns([ + 'user_id', + 'username', + 'email', + 'original_created_at' +]); $insert->select($select); $statement = $sql->prepareStatementForSqlObject($insert); diff --git a/docs/book/sql/intro.md b/docs/book/sql/intro.md index 62825d1f7..2ceadc01e 100644 --- a/docs/book/sql/intro.md +++ b/docs/book/sql/intro.md @@ -1,19 +1,23 @@ # 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. +`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`. +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(); // returns a PhpDb\Sql\Select instance -$insert = $sql->insert(); // returns a PhpDb\Sql\Insert instance -$update = $sql->update(); // returns a PhpDb\Sql\Update instance -$delete = $sql->delete(); // returns a PhpDb\Sql\Delete instance +$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 @@ -61,7 +65,8 @@ use PhpDb\Sql\Sql; $sql = new Sql($adapter, 'foo'); $select = $sql->select(); -$select->where(['id' => 2]); // $select already has from('foo') applied +// $select already has from('foo') applied +$select->where(['id' => 2]); ``` ## Common Interfaces for SQL Implementations @@ -79,12 +84,14 @@ interface PreparableSqlInterface interface SqlInterface { - public function getSqlString(PlatformInterface $adapterPlatform = null) : string; + public function getSqlString( + PlatformInterface $adapterPlatform = null + ) : string; } ``` -Use these functions to produce either (a) a prepared statement, or (b) a string -to execute. +Use these functions to produce either (a) a prepared statement, +or (b) a string to execute. ## SQL Arguments and Argument Types @@ -93,15 +100,18 @@ to execute. 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: +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) +- `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 +- `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`: @@ -114,7 +124,8 @@ $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 -$identifiersArg = Argument::identifiers(['col1', 'col2']); // Multiple identifiers +// Multiple identifiers +$identifiersArg = Argument::identifiers(['col1', 'col2']); // Direct instantiation is preferred $arg = new Argument\Identifier('column_name'); @@ -123,8 +134,8 @@ $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: +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; @@ -149,16 +160,18 @@ Scalar values passed directly to `Expression` are automatically wrapped: > ### 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`. +> `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. +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; @@ -183,7 +196,8 @@ $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: +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'); @@ -216,13 +230,15 @@ $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. +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. +The platform object handles database-specific SQL generation and can be +used for custom query building. ## TableIdentifier diff --git a/docs/book/sql/select.md b/docs/book/sql/select.md index 54101c7cf..5f5a8387a 100644 --- a/docs/book/sql/select.md +++ b/docs/book/sql/select.md @@ -1,8 +1,8 @@ # 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`: +`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 @@ -19,11 +19,12 @@ 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: +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 +class Select extends AbstractPreparableSql + implements SqlInterface, PreparableSqlInterface { final public const JOIN_INNER = 'inner'; final public const JOIN_OUTER = 'outer'; @@ -48,8 +49,12 @@ class Select extends AbstractPreparableSql implements SqlInterface, PreparableSq 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 from( + array|string|TableIdentifier $table + ) : static; + public function quantifier( + ExpressionInterface|string $quantifier + ) : static; public function columns( array $columns, bool $prefixColumnsWithTable = true @@ -69,7 +74,9 @@ class Select extends AbstractPreparableSql implements SqlInterface, PreparableSq Having|PredicateInterface|array|Closure|string $predicate, string $combination = Predicate\PredicateSet::OP_AND ) : static; - public function order(ExpressionInterface|array|string $order) : 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( @@ -123,9 +130,9 @@ $select->columns([ ```php title="Basic JOIN examples" $select->join( 'foo', // table name - 'id = bar.id', // expression to join on (will be quoted by platform), - ['bar', 'baz'], // (optional) list of columns, same as columns() above - $select::JOIN_OUTER // (optional), one of inner, outer, left, right, etc. + 'id = bar.id', // expression to join on + ['bar', 'baz'], // (optional) list of columns + $select::JOIN_OUTER // (optional), one of inner, outer, etc. ); $select @@ -136,13 +143,19 @@ $select ); ``` -The `$on` parameter accepts either a string or a `PredicateInterface` for complex join conditions: +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) +$where->equalTo( + 'orders.customerId', + 'customers.id', + Predicate\Predicate::TYPE_IDENTIFIER, + Predicate\Predicate::TYPE_IDENTIFIER + ) ->greaterThan('orders.amount', 100); $select->from('customers') @@ -154,7 +167,8 @@ Produces: ```sql SELECT customers.*, orders.orderId, orders.amount FROM customers -INNER JOIN orders ON orders.customerId = customers.id AND orders.amount > 100 +INNER JOIN orders + ON orders.customerId = customers.id AND orders.amount > 100 ``` ## order() @@ -166,10 +180,12 @@ $select->order('id DESC'); // produces 'id' DESC $select = new Select; $select ->order('id DESC') - ->order('name ASC, age DESC'); // produces 'id' DESC, 'name' ASC, 'age' DESC + // produces 'id' DESC, 'name' ASC, 'age' DESC + ->order('name ASC, age DESC'); $select = new Select; -$select->order(['name ASC', 'age DESC']); // produces 'name' ASC, 'age' DESC +// produces 'name' ASC, 'age' DESC +$select->order(['name ASC', 'age DESC']); ``` ## limit() and offset() @@ -182,14 +198,16 @@ $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. +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: +Multiple columns can be specified as an array, +or by calling `group()` multiple times: ```php title="Grouping by multiple columns" $select->group(['category', 'status']); @@ -232,15 +250,16 @@ $select->from('orders') Produces: ```sql -SELECT YEAR(created_at) AS orderYear, COUNT(*) AS orderCount +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. +The `quantifier()` method applies a quantifier to the SELECT statement, +such as DISTINCT or ALL. ```php title="Using DISTINCT quantifier" $select->from('orders') @@ -254,8 +273,8 @@ Produces: SELECT DISTINCT customer_id FROM orders ``` -The `QUANTIFIER_ALL` constant explicitly specifies ALL, though this is typically -the default behavior: +The `QUANTIFIER_ALL` constant explicitly specifies ALL, +though this is typically the default behavior: ```php title="Using ALL quantifier" $select->quantifier(Select::QUANTIFIER_ALL); @@ -263,8 +282,8 @@ $select->quantifier(Select::QUANTIFIER_ALL); ## reset() -The `reset()` method allows you to clear specific parts of a Select statement, -useful when building queries dynamically. +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') @@ -277,7 +296,8 @@ $select->from('users') Before reset, produces: ```sql -SELECT id, name FROM users WHERE status = 'active' ORDER BY created_at DESC LIMIT 10 +SELECT id, name FROM users +WHERE status = 'active' ORDER BY created_at DESC LIMIT 10 ``` After resetting WHERE, ORDER, and LIMIT: @@ -307,13 +327,13 @@ Available parts that can be reset: - `Select::ORDER` - `Select::COMBINE` -Note that resetting `Select::TABLE` will throw an exception if the table was -provided in the constructor (read-only table). +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. +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(); @@ -347,7 +367,8 @@ $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). +For combining SELECT statements using UNION, INTERSECT, or EXCEPT, +see [Advanced SQL Features: Combine](advanced.md#combine-union-intersect-except). Quick example: @@ -390,8 +411,8 @@ $select->from(['u' => 'users']) ### JOIN with no column selection -When you need to join a table only for filtering purposes without selecting its -columns: +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') diff --git a/docs/book/sql/update-delete.md b/docs/book/sql/update-delete.md index 0c340df70..c880d6e64 100644 --- a/docs/book/sql/update-delete.md +++ b/docs/book/sql/update-delete.md @@ -5,16 +5,24 @@ The `Update` class provides an API for building SQL UPDATE statements. ```php title="Update API" -class Update extends AbstractPreparableSql implements SqlInterface, PreparableSqlInterface +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 __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 @@ -57,10 +65,14 @@ 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); +$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: +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); @@ -74,11 +86,14 @@ Produces SET clauses in priority order (50, 75, 100): UPDATE table SET status = ?, flag = ?, counter = ? ``` -This is useful when the order of SET operations matters for certain database operations or triggers. +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. +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]); @@ -101,7 +116,11 @@ 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->join( + 'customers', + 'orders.customerId = customers.id', + Join::JOIN_INNER +); $update->where(['customers.status' => 'inactive']); ``` @@ -114,20 +133,26 @@ 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. +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 +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 __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 @@ -156,7 +181,9 @@ 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. +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']); @@ -165,8 +192,8 @@ $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. +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'); @@ -177,9 +204,9 @@ $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: +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]); @@ -202,7 +229,9 @@ $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 = ? +UPDATE products +SET view_count = view_count + 1, last_viewed = NOW() +WHERE id = ? ``` ```php title="Conditional update" @@ -211,7 +240,10 @@ $update->set(['status' => 'shipped']); $update->where(function ($where) { $where->equalTo('status', 'processing') ->and - ->lessThan('created_at', new Expression('NOW() - INTERVAL 7 DAY')); + ->lessThan( + 'created_at', + new Expression('NOW() - INTERVAL 7 DAY') + ); }); ``` @@ -224,7 +256,10 @@ $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')); +$delete->where->lessThan( + 'last_activity', + new Expression('NOW() - INTERVAL 24 HOUR') +); $statement = $sql->prepareStatementForSqlObject($delete); $result = $statement->execute(); diff --git a/docs/book/sql/where-having.md b/docs/book/sql/where-having.md index ea3369628..ecb24bf96 100644 --- a/docs/book/sql/where-having.md +++ b/docs/book/sql/where-having.md @@ -1,45 +1,50 @@ # Where and Having -In the following, we will talk about `Where`; note, however, that `Having` -utilizes the same API. +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. +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: +`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 + * @param string $combination One of the OP_* constants from + * Predicate\PredicateSet * @return Select */ -public function where($predicate, $combination = Predicate\PredicateSet::OP_AND); +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. +`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: +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) { @@ -48,8 +53,8 @@ $select->where(function (Where $where) { ``` 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: +`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 @@ -61,8 +66,8 @@ 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`). +In either case, the instances are pushed onto the `Where` stack with +the `$combination` provided (defaulting to `AND`). As an example: @@ -71,14 +76,14 @@ As an example: $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: +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`, where the key is the identifier. | +| PHP value | Predicate type | +|-----------|------------------------------------------| +| `null` | `Predicate\IsNull` | +| `array` | `Predicate\In` | +| `string` | `Predicate\Operator`, key is identifier. | As an example: @@ -218,12 +223,15 @@ class Predicate extends PredicateSet > 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. +Each method in the API will produce a corresponding `Predicate` object +of a similarly named type, as described below. ## Comparison Predicates -### equalTo(), lessThan(), greaterThan(), lessThanOrEqualTo(), greaterThanOrEqualTo() +### Comparison Methods + +Methods: `equalTo()`, `lessThan()`, `greaterThan()`, +`lessThanOrEqualTo()`, `greaterThanOrEqualTo()` ```php title="Using equalTo() to create an Operator predicate" $where->equalTo('id', 5); @@ -239,18 +247,18 @@ 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 = '>='; + 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 @@ -260,7 +268,8 @@ class Operator implements PredicateInterface |ExpressionInterface|SqlInterface $right = null ); public function setLeft( - string|ArgumentInterface|ExpressionInterface|SqlInterface $left + string|ArgumentInterface + |ExpressionInterface|SqlInterface $left ) : static; public function getLeft() : ?ArgumentInterface; public function setOperator(string $operator) : static; @@ -302,7 +311,9 @@ class Like implements PredicateInterface null|string|ArgumentInterface $identifier = null, null|bool|float|int|string|ArgumentInterface $like = null ); - public function setIdentifier(string|ArgumentInterface $identifier) : static; + public function setIdentifier( + string|ArgumentInterface $identifier + ) : static; public function getIdentifier() : ?ArgumentInterface; public function setLike( bool|float|int|null|string|ArgumentInterface $like @@ -353,7 +364,8 @@ $where->addPredicate( The following is the `Expression` API: ```php title="Expression class API definition" -class Expression implements ExpressionInterface, PredicateInterface +class Expression implements + ExpressionInterface, PredicateInterface { final public const PLACEHOLDER = '?'; @@ -424,8 +436,12 @@ 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 __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; @@ -449,8 +465,12 @@ 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 __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; @@ -480,7 +500,9 @@ class In implements PredicateInterface null|string|ArgumentInterface $identifier = null, null|array|Select|ArgumentInterface $valueSet = null ); - public function setIdentifier(string|ArgumentInterface $identifier) : static; + public function setIdentifier( + string|ArgumentInterface $identifier + ) : static; public function getIdentifier() : ?ArgumentInterface; public function setValueSet( array|Select|ArgumentInterface $valueSet @@ -542,7 +564,9 @@ $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' +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: @@ -561,9 +585,10 @@ WHERE YEAR(createdAt) BETWEEN 2020 AND 2024 ### 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. +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 @@ -615,9 +640,9 @@ 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. +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([ @@ -633,12 +658,12 @@ $where->addPredicates([ The method detects and handles: | Input Type | Behavior | -|------------|----------| +| ---------- | -------- | | String without `?` | Creates `Literal` predicate | -| String with `?` | Creates `Expression` predicate (requires parameters) | +| String with `?` | Creates `Expression` (requires params) | | Key => `null` | Creates `IsNull` predicate | | Key => array | Creates `In` predicate | -| Key => scalar | Creates `Operator` predicate (equality) | +| Key => scalar | Creates `Operator` (equality) | | `PredicateInterface` | Uses predicate directly | Combination operators can be specified: @@ -683,8 +708,9 @@ 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. +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') @@ -702,7 +728,9 @@ $select->from('orders') Produces: ```sql title="SQL output for HAVING with aggregate functions" -SELECT customerId, COUNT(*) AS orderCount, SUM(amount) AS totalAmount +SELECT customerId, + COUNT(*) AS orderCount, + SUM(amount) AS totalAmount FROM orders WHERE amount > 0 GROUP BY customerId @@ -727,8 +755,8 @@ 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 can be used in various contexts within SQL statements, +including WHERE clauses, FROM clauses, and SELECT columns. ### Subqueries in WHERE IN clauses @@ -745,7 +773,9 @@ Produces: ```sql title="SQL output for subquery in WHERE IN" SELECT customers.* FROM customers -WHERE id IN (SELECT customerId FROM orders WHERE status = 'completed') +WHERE id IN ( + SELECT customerId FROM orders WHERE status = 'completed' +) ``` ### Subqueries in FROM clauses @@ -766,7 +796,8 @@ 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 +(SELECT customerId, SUM(amount) AS total + FROM orders GROUP BY customerId) AS orderTotals WHERE orderTotals.total > 1000 ``` @@ -775,7 +806,9 @@ WHERE orderTotals.total > 1000 ```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')); + ->where(new Predicate\Expression( + 'orders.customerId = customers.id' + )); $select = $sql->select('customers') ->columns([ @@ -789,7 +822,8 @@ Produces: ```sql title="SQL output for scalar subquery in SELECT" SELECT id, name, -(SELECT COUNT(*) FROM orders WHERE orders.customerId = customers.id) AS orderCount + (SELECT COUNT(*) FROM orders + WHERE orders.customerId = customers.id) AS orderCount FROM customers ``` diff --git a/docs/book/table-gateway.md b/docs/book/table-gateway.md index cd4502de6..c52276b3f 100644 --- a/docs/book/table-gateway.md +++ b/docs/book/table-gateway.md @@ -1,8 +1,8 @@ # 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 @@ -15,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, @@ -32,12 +34,12 @@ 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 @@ -62,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 ); @@ -77,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( @@ -87,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; } @@ -97,10 +106,10 @@ 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 title="Basic Select Operations" use PhpDb\TableGateway\TableGateway; @@ -123,7 +132,8 @@ 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 title="Advanced Select with Callback" use PhpDb\TableGateway\TableGateway; @@ -141,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: @@ -157,8 +167,9 @@ There are a number of features built-in and shipped with laminas-db: ### GlobalAdapterFeature -Use a global/static adapter without injecting it into a `TableGateway` instance. -This is only useful when extending the `AbstractTableGateway` implementation: +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; @@ -176,7 +187,9 @@ class MyTableGateway extends AbstractTableGateway } // elsewhere in code, in a bootstrap -PhpDb\TableGateway\Feature\GlobalAdapterFeature::setStaticAdapter($adapter); +PhpDb\TableGateway\Feature\GlobalAdapterFeature::setStaticAdapter( + $adapter +); // in a controller, or model somewhere $table = new MyTableGateway(); // adapter is statically loaded @@ -184,11 +197,15 @@ $table = new MyTableGateway(); // adapter is statically loaded ### MasterSlaveFeature -Use a master adapter for `insert()`, `update()`, and `delete()`, but switch to -a slave adapter for all `select()` operations: +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)); +$table = new TableGateway( + 'artist', + $adapter, + new Feature\MasterSlaveFeature($slaveAdapter) +); ``` ### MetadataFeature @@ -202,12 +219,18 @@ $table = new TableGateway('artist', $adapter, new Feature\MetadataFeature()); ### EventFeature -Compose a [laminas-eventmanager](https://github.com/laminas/laminas-eventmanager) +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: +[section on lifecycle events below](#tablegateway-lifecycle-events) for +details: ```php -$table = new TableGateway('artist', $adapter, new Feature\EventFeature($eventManagerInstance)); +$table = new TableGateway( + 'artist', + $adapter, + new Feature\EventFeature($eventManagerInstance) +); ``` ### RowGatewayFeature @@ -226,42 +249,44 @@ $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) - `preSelect`, with the following parameters: - - `select`, with type `PhpDb\Sql\Select` + - `select`, with type `PhpDb\Sql\Select` - `postSelect`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` - `preInsert`, with the following parameters: - - `insert`, with type `PhpDb\Sql\Insert` + - `insert`, with type `PhpDb\Sql\Insert` - `postInsert`, with the following parameters: - - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` - - `result` with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` + - `result` with type `PhpDb\Adapter\Driver\ResultInterface` - `preUpdate`, with the following parameters: - - `update`, with type `PhpDb\Sql\Update` + - `update`, with type `PhpDb\Sql\Update` - `postUpdate`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - `preDelete`, with the following parameters: - - `delete`, with type `PhpDb\Sql\Delete` + - `delete`, with type `PhpDb\Sql\Delete` - `postDelete`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `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 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 title="Attaching a Listener to postInsert Event" use PhpDb\Adapter\Driver\ResultInterface; From f6e347465c2cef2ffdbbfdeae11a5ad34942a242 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 17:13:06 +1100 Subject: [PATCH 07/11] Linting failed without new line Signed-off-by: Simon Mundy --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 0eb28a10d..76af623de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,4 +39,4 @@ 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 \ No newline at end of file + project: Components From 1b30d17310f8ee6131ac0634979a95ffeed6b1d4 Mon Sep 17 00:00:00 2001 From: Simon Mundy <46739456+simon-mundy@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:14:01 +1100 Subject: [PATCH 08/11] Delete .markdownlint.json Signed-off-by: Simon Mundy <46739456+simon-mundy@users.noreply.github.com> --- .markdownlint.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 39195897b..000000000 --- a/.markdownlint.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "MD013": false, - "MD033": false, - "MD060": false -} \ No newline at end of file From 573dcf4d8cfd9366ce55d096b8232a7fb2e7962d Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 17:19:57 +1100 Subject: [PATCH 09/11] Linting failed without new line Signed-off-by: Simon Mundy --- docs/book/adapters/adapter-aware-trait.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/book/adapters/adapter-aware-trait.md b/docs/book/adapters/adapter-aware-trait.md index 29d845b97..afb823578 100644 --- a/docs/book/adapters/adapter-aware-trait.md +++ b/docs/book/adapters/adapter-aware-trait.md @@ -112,7 +112,7 @@ of the `Example` class with a database adapter: $example = $serviceManager->get(Example::class); var_dump( - $example->getAdapter() instanceof PhpDb\Adapter\Adapter + $example->getAdapter() instanceof PhpDb\Adapter\AdapterInterface ); // true ``` From 66a63ae3877e9cef16092c3a750fb9e807bfec4e Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Mon, 8 Dec 2025 17:20:34 +1100 Subject: [PATCH 10/11] Fixed minor linting issues Signed-off-by: Simon Mundy --- .markdownlint.json | 1 + docs/book/table-gateway.md | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.markdownlint.json b/.markdownlint.json index 39195897b..54ccb7e9b 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,4 +1,5 @@ { + "MD007": { "indent": 4 }, "MD013": false, "MD033": false, "MD060": false diff --git a/docs/book/table-gateway.md b/docs/book/table-gateway.md index c52276b3f..155d1abe7 100644 --- a/docs/book/table-gateway.md +++ b/docs/book/table-gateway.md @@ -255,26 +255,26 @@ parameters listed. - `preInitialize` (no parameters) - `postInitialize` (no parameters) - `preSelect`, with the following parameters: - - `select`, with type `PhpDb\Sql\Select` + - `select`, with type `PhpDb\Sql\Select` - `postSelect`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `resultSet`, with type `PhpDb\ResultSet\ResultSetInterface` - `preInsert`, with the following parameters: - - `insert`, with type `PhpDb\Sql\Insert` + - `insert`, with type `PhpDb\Sql\Insert` - `postInsert`, with the following parameters: - - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` - - `result` with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement` with type `PhpDb\Adapter\Driver\StatementInterface` + - `result` with type `PhpDb\Adapter\Driver\ResultInterface` - `preUpdate`, with the following parameters: - - `update`, with type `PhpDb\Sql\Update` + - `update`, with type `PhpDb\Sql\Update` - `postUpdate`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` + - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` - `preDelete`, with the following parameters: - - `delete`, with type `PhpDb\Sql\Delete` + - `delete`, with type `PhpDb\Sql\Delete` - `postDelete`, with the following parameters: - - `statement`, with type `PhpDb\Adapter\Driver\StatementInterface` - - `result`, with type `PhpDb\Adapter\Driver\ResultInterface` + - `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 From 6bb44d212c15e1314d150e7a8d00d605f2339449 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Tue, 9 Dec 2025 16:15:24 +1100 Subject: [PATCH 11/11] Improved usage examples of Adapters --- .../usage-in-a-laminas-mvc-application.md | 170 ++--- .../usage-in-a-mezzio-application.md | 654 ++---------------- docs/book/index.md | 13 +- 3 files changed, 150 insertions(+), 687 deletions(-) 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 5baae94e1..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 @@ -2,142 +2,116 @@ For installation instructions, see [Installation](../index.md#installation). -## Service Configuration +## Configuration -Now that the phpdb packages are installed, you need to configure the -adapter through your application's service manager. +The adapter factory is already wired into the service manager. You only +need to provide the `db` configuration in `config/autoload/db.global.php`: -### Configuring the Adapter - -Create a configuration file `config/autoload/database.global.php` -(or `local.php` for credentials) to define database settings. - -### Working with a SQLite database - -SQLite is a lightweight option to have the application working with a database. - -Here is an example of the configuration array for a SQLite database. -Assuming the SQLite file path is `data/sample.sqlite`, the following -configuration will produce the adapter: - -```php title="SQLite adapter configuration" +```php title="config/autoload/db.global.php" [ - 'factories' => [ - Adapter::class => function (ContainerInterface $container) { - $driver = new Sqlite([ - 'database' => 'data/sample.sqlite', - ]); - return new Adapter($driver, new SqlitePlatform()); - }, + 'db' => [ + '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' => [], ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, + 'options' => [ + 'buffer_results' => false, ], ], ]; ``` -The `data/` filepath for the SQLite file is the default `data/` directory -from the Laminas MVC application. - -### Working with a MySQL database +### Named Adapters -Unlike a SQLite database, the MySQL database adapter requires a MySQL server. +For applications requiring multiple database connections (e.g., read/write +separation), use named adapters: -Here is an example of a configuration array for a MySQL database: - -```php title="MySQL adapter configuration" +```php title="config/autoload/db.global.php" [ - 'factories' => [ - Adapter::class => function (ContainerInterface $container) { - $driver = new Mysql([ - 'database' => 'your_database_name', - 'username' => 'your_mysql_username', - 'password' => 'your_mysql_password', - 'hostname' => 'localhost', - 'charset' => 'utf8mb4', - ]); - return new Adapter($driver, new MysqlPlatform()); - }, - ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, + 'db' => [ + '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 PostgreSQL database +## Working with the Adapter -PostgreSQL support is coming soon. Once the `php-db/postgres` package is -available: +### Container-Managed Instantiation -```php title="PostgreSQL adapter configuration" - [ - 'factories' => [ - Adapter::class => function (ContainerInterface $container) { - $driver = new Postgres([ - 'database' => 'your_database_name', - 'username' => 'your_pgsql_username', - 'password' => 'your_pgsql_password', - 'hostname' => 'localhost', - 'port' => 5432, - ]); - return new Adapter($driver, new PostgresPlatform()); - }, - ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, - ], - ], -]; +$adapter = $container->get(AdapterInterface::class); ``` -## Working with the adapter +### Manual Instantiation -Once you have configured an adapter, as in the above examples, -you now have a `PhpDb\Adapter\Adapter` available to your application. +If you need to create an adapter without the container: -A factory for a class that consumes an adapter can pull the adapter from -the container: +```php +use PhpDb\Adapter\Adapter; +use PhpDb\Mysql\Driver\Mysql; +use PhpDb\Mysql\Platform\Mysql as MysqlPlatform; -```php title="Retrieving the adapter from the service container" -use PhpDb\Adapter\AdapterInterface; +$driver = new Mysql([ + 'hostname' => 'localhost', + 'database' => 'my_database', + 'username' => 'my_username', + 'password' => 'my_password', +]); -$adapter = $container->get(AdapterInterface::class); +$adapter = new Adapter($driver, new MysqlPlatform()); ``` You can read more about the diff --git a/docs/book/application-integration/usage-in-a-mezzio-application.md b/docs/book/application-integration/usage-in-a-mezzio-application.md index ca1134cb3..870d4240b 100644 --- a/docs/book/application-integration/usage-in-a-mezzio-application.md +++ b/docs/book/application-integration/usage-in-a-mezzio-application.md @@ -2,632 +2,124 @@ For installation instructions, see [Installation](../index.md#installation). -## Service Configuration +## Configuration -Now that the phpdb packages are installed, you need to configure the -adapter through Mezzio's dependency injection container. +The adapter factory is already wired into the container. You only +need to provide the `db` configuration in `config/autoload/db.global.php`: -Mezzio uses PSR-11 containers and typically uses laminas-servicemanager -or another DI container. The adapter configuration goes in your -application's configuration files. - -Create a configuration file `config/autoload/database.global.php` to -define database settings. - -### Working with a SQLite database - -SQLite is a lightweight option to have the application working with a database. - -Here is an example of the configuration array for a SQLite database. -Assuming the SQLite file path is `data/sample.sqlite`, the following -configuration will produce the adapter: - -```php title="SQLite adapter configuration" - [ - 'factories' => [ - Adapter::class => function (ContainerInterface $container) { - $driver = new Sqlite([ - 'database' => 'data/sample.sqlite', - ]); - return new Adapter($driver, new SqlitePlatform()); - }, - ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, - ], - ], -]; -``` - -The `data/` filepath for the SQLite file is relative to your application -root directory. - -### Working with a MySQL database - -Unlike a SQLite database, the MySQL database adapter requires a MySQL server. - -Here is an example of a configuration array for a MySQL database. - -Create `config/autoload/database.local.php` for environment-specific -credentials: - -```php title="MySQL adapter configuration" +```php title="config/autoload/db.global.php" [ - 'factories' => [ - Adapter::class => function (ContainerInterface $container) { - $driver = new Mysql([ - 'database' => 'your_database_name', - 'username' => 'your_mysql_username', - 'password' => 'your_mysql_password', - 'hostname' => 'localhost', - 'charset' => 'utf8mb4', - ]); - return new Adapter($driver, new MysqlPlatform()); - }, + 'db' => [ + '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' => [], ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, + 'options' => [ + 'buffer_results' => false, ], ], ]; ``` -### Working with PostgreSQL database +### Named Adapters -PostgreSQL support is coming soon. Once the `php-db/postgres` package is -available: +For applications requiring multiple database connections (e.g., read/write +separation), use named adapters: -```php title="PostgreSQL adapter configuration" +```php title="config/autoload/db.global.php" [ - 'factories' => [ - Adapter::class => function (ContainerInterface $container) { - $driver = new Postgres([ - 'database' => 'your_database_name', - 'username' => 'your_pgsql_username', - 'password' => 'your_pgsql_password', - 'hostname' => 'localhost', - 'port' => 5432, - ]); - return new Adapter($driver, new PostgresPlatform()); - }, - ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, + 'db' => [ + '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 - -Once you have configured an adapter, as in the above examples, -you now have a `PhpDb\Adapter\Adapter` available to your application -through dependency injection. - -### In Request Handlers - -Mezzio uses request handlers (also known as middleware) that receive -dependencies through constructor injection: - -```php title="Request handler with database adapter injection" -adapter->query( - 'SELECT id, username, email FROM users WHERE status = ?', - ['active'] - ); - - $users = []; - foreach ($results as $row) { - $users[] = [ - 'id' => $row->id, - 'username' => $row->username, - 'email' => $row->email, - ]; - } - - return new JsonResponse(['users' => $users]); - } -} -``` - -### Creating a Handler Factory - -You need to create a factory for your handler that injects the adapter: - -```php title="Handler factory implementation" -get(AdapterInterface::class) - ); - } -} -``` - -### Registering the Handler - -Register your handler factory in -`config/autoload/dependencies.global.php`: - -```php title="Registering the handler in dependencies configuration" - [ - 'invokables' => [ - // ... other invokables - ], - 'factories' => [ - UserListHandler::class => UserListHandlerFactory::class, - // ... other factories - ], - ], -]; -``` - -### Using with TableGateway - -For more structured database interactions, use TableGateway with -dependency injection: - -```php title="Extending TableGateway for custom database operations" -select(['status' => 'active']); - return iterator_to_array($resultSet); - } - - public function findUserById(int $id): ?array - { - $rowset = $this->select(['id' => $id]); - $row = $rowset->current(); - - return $row ? (array) $row : null; - } -} -``` - -Create a factory for the table: - -```php title="Factory for the TableGateway class" -get(AdapterInterface::class) - ); - } -} -``` - -Register the table factory: - -```php title="Registering the table factory in dependencies" - [ - 'factories' => [ - UsersTable::class => UsersTableFactory::class, - ], - ], -]; -``` -Use in your handler: - -```php title="Using TableGateway in a request handler" -usersTable->findActiveUsers(); - - return new JsonResponse(['users' => $users]); - } -} -``` - -You can read more about the -[adapter in the adapter chapter of the documentation](../adapter.md) and -[TableGateway in the table gateway chapter](../table-gateway.md). - -## Environment-based Configuration - -For production deployments, use environment variables to configure -database credentials: - -### Using dotenv - -Install `vlucas/phpdotenv`: - -```bash title="Installing the phpdotenv package" -composer require vlucas/phpdotenv -``` - -Create a `.env` file in your project root: - -### Environment variables configuration file - -```env -DB_TYPE=mysql -DB_DATABASE=myapp_production -DB_USERNAME=dbuser -DB_PASSWORD=secure_password -DB_HOSTNAME=mysql-server -DB_PORT=3306 -DB_CHARSET=utf8mb4 -``` - -Load environment variables in `public/index.php`: - -```php title="Loading environment variables in the application bootstrap" -load(); -} - -$container = require 'config/container.php'; +$adapter = $container->get(AdapterInterface::class); ``` -Update your database configuration to use environment variables: +### Manual Instantiation -```php title="Dynamic adapter configuration using environment variables" - [ - 'factories' => [ - Adapter::class => function ( - ContainerInterface $container - ) { - $dbType = $_ENV['DB_TYPE'] ?? 'sqlite'; - if ($dbType === 'mysql') { - $driver = new Mysql([ - 'database' => $_ENV['DB_DATABASE'] ?? 'myapp', - 'username' => $_ENV['DB_USERNAME'] ?? 'root', - 'password' => $_ENV['DB_PASSWORD'] ?? '', - 'hostname' => $_ENV['DB_HOSTNAME'] ?? 'localhost', - 'port' => (int) ($_ENV['DB_PORT'] ?? 3306), - 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', - ]); - return new Adapter($driver, new MysqlPlatform()); - } +$driver = new Mysql([ + 'hostname' => 'localhost', + 'database' => 'my_database', + 'username' => 'my_username', + 'password' => 'my_password', +]); - // Default to SQLite - $driver = new Sqlite([ - 'database' => - $_ENV['DB_DATABASE'] ?? 'data/app.sqlite', - ]); - return new Adapter($driver, new SqlitePlatform()); - }, - ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, - ], - ], -]; +$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). - -## Testing with Database - -For integration testing with a real database in Mezzio: - -### Create a Test Configuration - -Create `config/autoload/database.test.php`: - -```php title="Test database configuration with in-memory SQLite" - [ - 'factories' => [ - Adapter::class => function ( - ContainerInterface $container - ) { - $driver = new Sqlite([ - 'database' => ':memory:', - ]); - return new Adapter($driver, new SqlitePlatform()); - }, - ], - 'aliases' => [ - AdapterInterface::class => Adapter::class, - ], - ], -]; -``` - -### Use in PHPUnit Tests - -```php title="PHPUnit test with database integration" -adapter = $container->get(AdapterInterface::class); - - $this->adapter->query( - 'CREATE TABLE users ( - id INTEGER PRIMARY KEY, - username TEXT, - email TEXT, - status TEXT - )' - ); - - $this->adapter->query( - "INSERT INTO users (username, email, status) - VALUES - ('alice', 'alice@example.com', 'active'), - ('bob', 'bob@example.com', 'active')" - ); - } - - public function testHandleReturnsUserList(): void - { - $handler = new UserListHandler($this->adapter); - $request = new ServerRequest(); - - $response = $handler->handle($request); - - $this->assertEquals(200, $response->getStatusCode()); - $body = json_decode((string) $response->getBody(), true); - $this->assertCount(2, $body['users']); - } -} -``` - -## Best Practices for Mezzio - -### Use Dependency Injection - -Always inject the adapter or table gateway through constructors, -never instantiate directly in handlers. - -### Separate Database Logic - -Create repository or table gateway classes to separate database logic -from HTTP handlers: - -```php title="Repository pattern implementation for database operations" -adapter); - $select = $sql->select('users'); - - $statement = - $sql->prepareStatementForSqlObject($select); - $results = $statement->execute(); - - return iterator_to_array($results); - } - - public function findById(int $id): ?array - { - $sql = new Sql($this->adapter); - $select = $sql->select('users'); - $select->where(['id' => $id]); - - $statement = - $sql->prepareStatementForSqlObject($select); - $results = $statement->execute(); - $row = $results->current(); - - return $row ? (array) $row : null; - } -} -``` - -### Use Configuration Factories - -Centralize adapter configuration in factory classes for better -maintainability and testability. - -### Handle Exceptions - -Always wrap database operations in try-catch blocks: - -```php title="Exception handling for database operations" -use PhpDb\Adapter\Exception\RuntimeException; - -public function handle( - ServerRequestInterface $request -): ResponseInterface { - try { - $users = $this->usersTable->findActiveUsers(); - return new JsonResponse(['users' => $users]); - } catch (RuntimeException $e) { - return new JsonResponse( - ['error' => 'Database error occurred'], - 500 - ); - } -} -``` +[Docker Deployment Guide](../docker-deployment.md). \ No newline at end of file diff --git a/docs/book/index.md b/docs/book/index.md index 57177f0ae..01835a595 100644 --- a/docs/book/index.md +++ b/docs/book/index.md @@ -13,14 +13,8 @@ phpdb is a database abstraction layer providing: ## Installation -Install the core package via Composer: - -```bash -composer require php-db/phpdb -``` - -Additionally, install the driver package(s) for the database(s) you plan to -use: +Install the driver package(s) for the database(s) you plan to +use via Composer: ```bash # For MySQL/MariaDB support @@ -33,6 +27,9 @@ composer require php-db/sqlite 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