{"id":29117738,"url":"https://github.com/kakserpom/php-sqlx-rs","last_synced_at":"2026-04-09T04:02:48.661Z","repository":{"id":298011430,"uuid":"998557765","full_name":"kakserpom/php-sqlx-rs","owner":"kakserpom","description":"A modern feature-rich SQL driver for PHP.","archived":false,"fork":false,"pushed_at":"2026-03-20T11:30:22.000Z","size":1300,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-03-21T03:08:32.688Z","etag":null,"topics":["mssql","mysql","php","php-extension","postgresql","sql"],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/kakserpom.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-06-08T21:31:42.000Z","updated_at":"2026-03-20T11:30:25.000Z","dependencies_parsed_at":"2025-06-08T23:25:46.034Z","dependency_job_id":"be7ff00c-ac07-48ed-aebc-2e9ca3dcf761","html_url":"https://github.com/kakserpom/php-sqlx-rs","commit_stats":null,"previous_names":["kakserpom/php-sqlx-rs"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/kakserpom/php-sqlx-rs","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kakserpom%2Fphp-sqlx-rs","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kakserpom%2Fphp-sqlx-rs/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kakserpom%2Fphp-sqlx-rs/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kakserpom%2Fphp-sqlx-rs/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/kakserpom","download_url":"https://codeload.github.com/kakserpom/php-sqlx-rs/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kakserpom%2Fphp-sqlx-rs/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31584820,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-08T14:31:17.711Z","status":"online","status_checked_at":"2026-04-09T02:00:06.848Z","response_time":112,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["mssql","mysql","php","php-extension","postgresql","sql"],"created_at":"2025-06-29T12:05:01.540Z","updated_at":"2026-04-09T04:02:48.647Z","avatar_url":"https://github.com/kakserpom.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# SQLx PHP Extension\n\n[![CI](https://github.com/kakserpom/php-sqlx-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/kakserpom/php-sqlx-rs/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n[![PHP 8.1+](https://img.shields.io/badge/PHP-8.1%2B-8892BF.svg)](https://php.net)\n[![Rust](https://img.shields.io/badge/Rust-stable-orange.svg)](https://rust-lang.org)\n\n**[Documentation](https://kakserpom.github.io/php-sqlx-rs/)**\n\nThe extension is powered by Rust 🦀 and [SQLx](https://github.com/launchbadge/sqlx), built\nusing [ext-php-rs](https://github.com/extphprs/ext-php-rs). It enables safe, fast, and expressive\ndatabase access with additional SQL syntax. It comes with a powerful [query builder](QUERY-BUILDER.md).\n\n**Postgres**, **MySQL** and **Mssql** protocols are natively supported. Other protocols may require a custom wrapper.\n\nThe project's goals are centered around providing a **secure** and **ergonomic** way to interact with SQL-based DBMS\nwithout any compromise on performance.\n\n## Architecture\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                         PHP Application                         │\n├─────────────────────────────────────────────────────────────────┤\n│  DriverFactory  │  QueryBuilder  │  PreparedQuery  │  Clauses   │\n├─────────────────────────────────────────────────────────────────┤\n│                     Driver (PgDriver, MySqlDriver, etc.)        │\n│  ┌────────────┐  ┌─────────────┐  ┌───────────────────────────┐ │\n│  │ Connection │  │ Transaction │  │       Retry Policy        │ │\n│  │    Pool    │  │    Stack    │  │  (exponential backoff)    │ │\n│  └────────────┘  └─────────────┘  └───────────────────────────┘ │\n├─────────────────────────────────────────────────────────────────┤\n│                         AST Engine                              │\n│  ┌────────────┐  ┌─────────────┐  ┌───────────────────────────┐ │\n│  │   Parser   │  │   Renderer  │  │   LRU Cache (per shard)   │ │\n│  │            │  │  (per DBMS) │  │                           │ │\n│  └────────────┘  └─────────────┘  └───────────────────────────┘ │\n├─────────────────────────────────────────────────────────────────┤\n│                      SQLx (Rust)                                │\n│         Async runtime (Tokio) + Native protocol drivers         │\n├─────────────────────────────────────────────────────────────────┤\n│              PostgreSQL  │  MySQL  │  SQL Server                │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n**Data flow:** PHP calls → Driver → AST parse/render (cached) → SQLx async execution → Database\n\n## Getting Started\n\n### Installation\n\nInstall with [`cargo-php`](https://github.com/extphprs/ext-php-rs):\n\n```bash\ncargo install cargo-php --locked\ncd php-sqlx-cdylib\n```\n\nFor macOS:\n\n```bash\nexport MACOSX_DEPLOYMENT_TARGET=$(sw_vers -productVersion | tr -d '\\n')\nexport RUSTFLAGS=\"-C link-arg=-undefined -C link-arg=dynamic_lookup\"\n#export RUSTFLAGS=\"-Zmacro-backtrace -Zproc-macro-backtrace -Clink-arg=-undefined -Clink-arg=dynamic_lookup\"\ncargo install cargo-php --locked\ncd php-sqlx-cdylib\ncargo php install --release --yes\n```\n\n### Usage example\n\n```php\n$driver = Sqlx\\DriverFactory::make(\"postgres://user:pass@localhost/db\");\n\n$posts = $driver-\u003equeryAll('SELECT * FROM posts WHERE author_id = ?', [12345]);\n```\n\n## Features\n\n- AST-based SQL augmentation (e.g., conditional blocks)\n- Named parameters with `$param`, `:param`, or positional `:1` syntax\n- **Type-safe placeholders** with type suffixes (`?i`, `?s`, `?d`, `?u`, `?ud`)\n- **Nullable type support** with `n` prefix (`?ni`, `?ns`, `?nud`)\n- Automatic result conversion to PHP arrays or objects\n- **True streaming** via `query()` returning `QueryResult` iterator for memory-efficient processing\n- Painless `IN (?)` / `NOT IN (?)` clauses expansion and collapse\n- Safe and robust `ORDER BY` / `GROUP BY` clauses\n- Pagination with `PAGINATE`\n- Safe and robust `SELECT`\n- SQL transactions are supported in full\n- **Upsert support** with `upsert()` (PostgreSQL `ON CONFLICT`, MySQL `ON DUPLICATE KEY`)\n- **Batch inserts** with `insertMany()` for efficient multi-row inserts\n- Powerful Query Builder\n- Native JSON support (with lazy decoding and [SIMD](https://docs.rs/simd-json/latest/simd_json/) 🚀)\n- Optional persistent connections (with connection pooling)\n- **Automatic retry** with exponential backoff for transient failures\n- Custom `SqlxException` class with error codes for precise error handling\n- **Schema introspection** via `describeTable()` for table column metadata\n- **Query profiling** via `onQuery()` callback for logging and performance monitoring\n- **Connection tagging** via `setApplicationName()` and `setClientInfo()` for debugging\n- **Read replica support** with automatic query routing and round-robin load balancing\n- **IDE support** for VS Code and PHPStorm (syntax highlighting, live templates)\n\n---\n\n## Augmented SQL Syntax\n\nThis extension introduces a powerful SQL preprocessor that supports conditional blocks, optional fragments, and named\nparameters. Both positional (`?`, `$1`, `:1`) and named (`$param`, `:param`) placeholders are supported. All can be used\ninterchangeably.\n\n---\n\n### Conditional Blocks\n\nWrap parts of your query with double braces `{{ ... }}` to make them conditional:\n\n```\nSELECT *\nFROM users\nWHERE TRUE\n  {{ AND name = $name }}\n  {{ AND status = $status }}\n```\n\nIf a named parameter used inside the block is not provided, the entire block is omitted from the final query.\n\nNested conditional blocks are supported:\n\n```\nSELECT *\nFROM logs\nWHERE TRUE {{ AND date \u003e $since {{ AND level = $level }} }}\n```\n\nIn the above example the `level` condition will only be rendered when both `$level` and `$since` are set.\n\n```\nSELECT *\nFROM logs\nWHERE date \u003e $since {{ AND level = $level }}\n```\n\nThe above example will throw an exception if `$since` is not set.\n\n---\n\n### Type-Safe Placeholders\n\nYou can enforce type constraints on placeholders using type suffixes. When a value doesn't match the expected type, an\nexception is thrown during query rendering.\n\n#### Scalar Types\n\n| Suffix | Named Syntax             | Description                                      |\n|--------|--------------------------|--------------------------------------------------|\n| `?i`   | `:id!i`, `$id!i`         | Integer                                          |\n| `?u`   | `:age!u`, `$age!u`       | Unsigned integer (≥ 0)                           |\n| `?d`   | `:score!d`, `$score!d`   | Decimal (int, float, or numeric string)          |\n| `?ud`  | `:price!ud`, `$price!ud` | Unsigned decimal (≥ 0, int/float/numeric string) |\n| `?s`   | `:name!s`, `$name!s`     | String                                           |\n| `?j`   | `:data!j`, `$data!j`     | JSON (object, array, or Json wrapper)            |\n\n```php\n// Type validation examples\n$driver-\u003equeryAll('SELECT * FROM users WHERE id = ?i', [42]);        // OK\n$driver-\u003equeryAll('SELECT * FROM users WHERE id = ?i', [\"string\"]);  // Error: Type mismatch\n$driver-\u003equeryAll('SELECT * FROM users WHERE age = ?u', [-5]);       // Error: negative not allowed\n\n// Decimal accepts int, float, and numeric strings\n$driver-\u003equeryAll('SELECT * FROM products WHERE price = ?d', [19.99]);     // OK (float)\n$driver-\u003equeryAll('SELECT * FROM products WHERE price = ?d', [20]);        // OK (int)\n$driver-\u003equeryAll('SELECT * FROM products WHERE price = ?d', [\"19.99\"]);   // OK (numeric string)\n$driver-\u003equeryAll('SELECT * FROM products WHERE price = ?d', [\"abc\"]);     // Error: not numeric\n\n// JSON serializes arrays/objects as JSON strings\n$driver-\u003eexecute('INSERT INTO events (data) VALUES (?j)', [['event' =\u003e 'click', 'count' =\u003e 5]]);\n// Bound value: '{\"event\":\"click\",\"count\":5}'\n```\n\n#### Array Types (for IN clauses)\n\n| Suffix | Named Syntax  | Description                                     |\n|--------|---------------|-------------------------------------------------|\n| `?ia`  | `:ids!ia`     | Array of integers                               |\n| `?ua`  | `:ids!ua`     | Array of unsigned integers                      |\n| `?da`  | `:scores!da`  | Array of decimals                               |\n| `?uda` | `:prices!uda` | Array of unsigned decimals                      |\n| `?sa`  | `:names!sa`   | Array of strings                                |\n| `?ja`  | `:items!ja`   | Array of JSON (each element serialized as JSON) |\n\n```php\n$driver-\u003equeryAll('SELECT * FROM users WHERE id IN :ids!ia', [\n    'ids' =\u003e [1, 2, 3]\n]); // OK\n\n$driver-\u003equeryAll('SELECT * FROM users WHERE id IN :ids!ia', [\n    'ids' =\u003e [1, \"two\", 3]\n]); // Error: Type mismatch\n\n// JSON array - each element is serialized as JSON (useful for PostgreSQL JSONB[])\n$driver-\u003eexecute(\n    'INSERT INTO logs (items) VALUES (ARRAY[?ja]::jsonb[])',\n    [[['type' =\u003e 'click'], ['type' =\u003e 'view']]]\n);\n// Expands to: ARRAY[$1, $2]::jsonb[]\n// Bound values: '{\"type\":\"click\"}', '{\"type\":\"view\"}'\n```\n\n---\n\n### Nullable Types\n\nBy default, typed placeholders reject `null` values. Use the `n` prefix to allow nulls:\n\n| Suffix | Named Syntax | Description                              |\n|--------|--------------|------------------------------------------|\n| `?n`   | `:data!n`    | Nullable mixed (any type including null) |\n| `?ni`  | `:id!ni`     | Nullable integer                         |\n| `?nu`  | `:age!nu`    | Nullable unsigned integer                |\n| `?nd`  | `:score!nd`  | Nullable decimal                         |\n| `?nud` | `:price!nud` | Nullable unsigned decimal                |\n| `?ns`  | `:name!ns`   | Nullable string                          |\n| `?nj`  | `:data!nj`   | Nullable JSON                            |\n\nNullable array types: `?nia`, `?nua`, `?nda`, `?nuda`, `?nsa`, `?nja`\n\n```php\n// Non-nullable rejects null\n$driver-\u003equeryAll('SELECT * FROM users WHERE id = ?i', [null]);   // Error: Type mismatch\n\n// Nullable accepts null\n$driver-\u003equeryAll('SELECT * FROM users WHERE id = ?ni', [null]);  // OK - renders NULL\n$driver-\u003equeryAll('SELECT * FROM users WHERE id = ?ni', [42]);    // OK - renders 42\n```\n\n#### Nullable in Conditional Blocks\n\nThe nullable flag affects how conditional blocks behave:\n\n| Placeholder                | Value  | Block Behavior     |\n|----------------------------|--------|--------------------|\n| `:status!ni` (nullable)    | absent | Block **skipped**  |\n| `:status!ni` (nullable)    | `null` | Block **rendered** |\n| `:status!ni` (nullable)    | `5`    | Block **rendered** |\n| `:status!i` (non-nullable) | `null` | Block **skipped**  |\n| `:status` (untyped)        | `null` | Block **skipped**  |\n\n```php\n$sql = 'SELECT * FROM users WHERE 1=1 {{ AND status = :status!ni }}';\n\n// Absent - block skipped\n$driver-\u003equeryAll($sql, []);\n// Result: SELECT * FROM users WHERE 1=1\n\n// Null provided - block rendered (nullable allows null)\n$driver-\u003equeryAll($sql, ['status' =\u003e null]);\n// Result: SELECT * FROM users WHERE 1=1 AND status = NULL\n\n// Value provided - block rendered\n$driver-\u003equeryAll($sql, ['status' =\u003e 1]);\n// Result: SELECT * FROM users WHERE 1=1 AND status = $1\n```\n\nThis is useful when you want to explicitly query for `NULL` values vs. omitting the condition entirely.\n\n#### Quick Reference Cheat Sheet\n\n| Type             | Scalar | Nullable | Array  | Nullable Array |\n|------------------|--------|----------|--------|----------------|\n| **Any**          | `?`    | `?n`     | —      | —              |\n| **Integer**      | `?i`   | `?ni`    | `?ia`  | `?nia`         |\n| **Unsigned Int** | `?u`   | `?nu`    | `?ua`  | `?nua`         |\n| **Decimal**      | `?d`   | `?nd`    | `?da`  | `?nda`         |\n| **Unsigned Dec** | `?ud`  | `?nud`   | `?uda` | `?nuda`        |\n| **String**       | `?s`   | `?ns`    | `?sa`  | `?nsa`         |\n| **JSON**         | `?j`   | `?nj`    | `?ja`  | `?nja`         |\n\n**Named syntax**: Add `!` before suffix: `$id!i`, `:name!s`, `$prices!uda`, `$data!j`\n\n---\n\n### Painless `IN (?)` / `NOT IN (?)` clauses expansion and collapse\n\nPassing an array as a parameter to a single placeholder automatically expands it:\n\n```php\n// Expands to: SELECT * FROM people WHERE name IN (?, ?, ?)\n// with values ['Peter', 'John', 'Jane']\n$rows = $driver-\u003equeryAll(\n  'SELECT * FROM people WHERE name IN :names', [\n    'names' =\u003e ['Peter', 'John', 'Jane']\n  ]\n);\n```\n\nOmitting the parameter or passing an empty array will make `IN` collapse into boolean `FALSE`.\n\n```php\nvar_dump($driver-\u003edry(\n  'SELECT * FROM people WHERE name IN :names', [\n    'names' =\u003e []\n  ]\n));\n```\n\n```\narray(2) {\n  [0]=\u003e\n  string(53) \"SELECT * FROM people WHERE FALSE /* name IN :names */\"\n  [1]=\u003e\n  array(0) {\n  }\n}\n```\n\nSame goes for `NOT IN`, except it will collapse into boolean `TRUE`.\n\n```php\nvar_dump($driver-\u003edry(\n  'SELECT * FROM people WHERE name NOT IN :names', [\n    'names' =\u003e []\n  ]\n));\n```\n\n```\narray(2) {\n  [0]=\u003e\n  string(56) \"SELECT * FROM people WHERE TRUE /* name NOT IN :names */\"\n  [1]=\u003e\n  array(0) {\n  }\n}\n```\n\n\u003e Makes sense, right? Given that `x IN (1, 2, 3)` is sugar for `(x = 1 OR x = 2 OR x = 3)`\n\u003e and `x NOT IN (1, 2, 3)` is sugar for `(x != 1 AND x != 2 AND x != 3)`.\n\nKeep in mind that you can not only use it in `WHERE`, but also in `ON` clauses when joining.\n\n\u003e It is true that in simpler cases of `IN :empty` like the above example you could just\n\u003e immediately return an empty result set without sending it to DBMS, but there could be a `JOIN`\n\u003e or a `UNION`.\n---\n\n### Safe and robust `ORDER BY` / `GROUP BY` clauses\n\n`Sql\\ByClause` helper class for safe `ORDER BY` / `GROUP BY` clauses from user input.\n\n\u003e __SAFETY CONCERNS__\n\u003e - 🟢 You can safely pass any user input as sorting settings.\n\u003e - 🔴 Do NOT pass unsanitized user input into `ByClause` constructor to avoid SQL injection vulnerabilities. If you\n    absolutely have to, then apply `array_values()` to the argument to avoid SQL injections.\n\n**Examples**:\n\n```php\n// Let's define allowed columns\n$orderBy = new Sqlx\\ByClause([\n    'name',\n    'created_at',\n    'random' =\u003e 'RANDOM()'\n]);\n\n// Equivalent to: SELECT * FROM users ORDER BY `name` ASC, RANDOM()\n$driver-\u003equeryAll('SELECT * FROM users ORDER BY :order_by', [\n  'order_by' =\u003e $orderBy([\n    ['name', Sqlx\\ByClause::ASC],\n    'random'\n  ])\n]);\n\n```\n\nField names are case-sensitive, but they get trimmed.\n\n---\n\nPagination with `PAGINATE`\n---\n`Sql\\PaginateClause` helper class for safe pagination based on user input.\n\n```php\n// Let's define pagination rules\n$pagination = new Sqlx\\PaginateClause;\n$pagination-\u003eperPage(5);\n$pagination-\u003emaxPerPage(20);\n\n// Equivalent to: SELECT * FROM people ORDER by id LIMIT 5 OFFSET 500\n$rows = $driver-\u003equeryAll(\n  'SELECT * FROM people ORDER by id PAGINATE :pagination', [\n    'pagination' =\u003e $pagination(100)\n  ]\n);\n\n\n// Equivalent to: SELECT * FROM people ORDER by id LIMIT 10 OFFSET 1000\n$rows = $driver-\u003equeryAll(\n  'SELECT * FROM people ORDER by id PAGINATE :pagination', [\n    'pagination' =\u003e $pagination(100, 10)\n  ]\n);\n\n// Equivalent to: SELECT * FROM people ORDER by id LIMIT 5 OFFSET 0\n$rows = $driver-\u003equeryAll(\n  'SELECT * FROM people ORDER by id PAGINATE :pagination', [\n    'pagination' =\u003e $pagination()\n  ]\n);\n```\n\nYou can safely pass any unsanitized values as arguments, but keep in mind that `perPage()`/`maxPerPage()`/\n`defaultPerPage()`\nfunctions take a positive integer and throw an exception otherwise.\n\n---\n\n### Safe and robust `SELECT`\n\nA helper class for safe `SELECT` clauses from user input.\n\n\u003e __SAFETY CONCERNS__\n\u003e - 🟢 You can safely pass any user input as invocation argument.\n\u003e - 🔴 Do NOT pass unsanitized user input into `SelectClause` constructor to avoid SQL injection vulnerabilities. If you\n    absolutely have to, then apply `array_values()` to the argument to avoid SQL injections.\n\n**Examples**:\n\n```php\n$select = new Sqlx\\SelectClause([\n    'id',\n    'created_at',\n    'name',\n    'num_posts' =\u003e 'COUNT(posts.*)'\n]);\n\n// Equivalent to: SELECT `id`, `name`, COUNT(posts.*) AS `num_posts` FROM users\n$rows = $driver-\u003equeryAll('SELECT :select FROM users', [\n  'select' =\u003e $select(['id','name', 'num_posts'])\n]);\n```\n\nNote that column names are case-sensitive, but they get trimmed.\n\n--- \n\n## Transactions\n\n```php\n$driver-\u003ebegin(function($driver) {\n    // All queries inside this function will be wrapped in a transaction.\n    // You can use all driver functions here.\n    \n    $driver-\u003einsert('users', ['name' =\u003e 'John', 'age' =\u003e 25]);\n    $driver-\u003einsert('users', ['name' =\u003e 'Mary', 'age' =\u003e 20]);\n    \n    // return false; \n});\n```\n\nA `ROLLBACK` happens if the closure returns `false` or throws an exception.\nOtherwise, a `COMMIT` gets sent when functions finishes normally.\n\nAdditional supported methods to be called from inside a closure:\n\n- `savepoint(name: String)`\n- `rollbackToSavepoint(name: String)`\n- `releaseSavepoint(name: String)`\n\n---\n\n## Pinned Connections\n\nFor session-scoped operations that require multiple queries to run on the **same connection** without a database\ntransaction, use `withConnection`:\n\n```php\n$lastId = $driver-\u003ewithConnection(function($driver) {\n    $driver-\u003eexecute(\"INSERT INTO users (name) VALUES ('Alice')\");\n    return $driver-\u003equeryValue('SELECT LAST_INSERT_ID()');\n});\n```\n\n**Use cases:**\n\n- `LAST_INSERT_ID()` in MySQL (session-scoped)\n- Temporary tables (connection-scoped)\n- Session variables (`SET @var = ...`)\n- Advisory locks\n\n**Key differences from transactions:**\n\n- No `BEGIN`/`COMMIT` is issued\n- Each query is auto-committed immediately\n- No rollback on failure\n\nIf you need transactional semantics (atomicity, rollback), use `begin()` instead.\n\n---\n\n## Batch Insert\n\nThe `insertMany()` method inserts multiple rows in a single statement for better performance:\n\n```php\n$driver-\u003einsertMany('users', [\n    ['name' =\u003e 'Alice', 'email' =\u003e 'alice@example.com'],\n    ['name' =\u003e 'Bob', 'email' =\u003e 'bob@example.com'],\n    ['name' =\u003e 'Carol', 'email' =\u003e 'carol@example.com'],\n]);\n```\n\n**Generated SQL:**\n\n```sql\nINSERT INTO users (email, name)\nVALUES ($email_0, $name_0),\n       ($email_1, $name_1),\n       ($email_2, $name_2)\n```\n\n- Columns are determined from the first row\n- Missing columns in subsequent rows default to `NULL`\n- Returns the number of inserted rows\n\n---\n\n## Upsert (Insert or Update)\n\nThe `upsert()` method inserts a row or updates it if a conflict occurs on the specified columns:\n\n```php\n// Insert or update user by email (unique constraint)\n$driver-\u003eupsert('users', [\n    'email' =\u003e 'alice@example.com',\n    'name' =\u003e 'Alice',\n    'login_count' =\u003e 1\n], ['email'], ['name', 'login_count']);\n\n// Update all non-key columns on conflict (auto-detect)\n$driver-\u003eupsert('users', $userData, ['email']);\n```\n\n**Database-specific SQL generated:**\n\n| Database   | SQL Pattern                                                      |\n|------------|------------------------------------------------------------------|\n| PostgreSQL | `INSERT ... ON CONFLICT (cols) DO UPDATE SET col = EXCLUDED.col` |\n| MySQL      | `INSERT ... ON DUPLICATE KEY UPDATE col = VALUES(col)`           |\n| MSSQL      | Not supported (use `MERGE` statement directly)                   |\n\n**Parameters:**\n\n- `$table` – Table name\n- `$row` – Associative array of column → value\n- `$conflictColumns` – Columns that form the unique constraint\n- `$updateColumns` – (Optional) Columns to update on conflict. If omitted, updates all non-conflict columns.\n\n---\n\n## Retry Policy\n\nThe driver supports automatic retry with exponential backoff for transient failures like connection drops, pool\nexhaustion, and timeouts.\n\n```php\n$driver = Sqlx\\DriverFactory::make([\n    Sqlx\\DriverOptions::OPT_URL =\u003e 'postgres://user:pass@localhost/db',\n    Sqlx\\DriverOptions::OPT_RETRY_MAX_ATTEMPTS =\u003e 3,\n    Sqlx\\DriverOptions::OPT_RETRY_INITIAL_BACKOFF =\u003e '100ms',\n    Sqlx\\DriverOptions::OPT_RETRY_MAX_BACKOFF =\u003e '5s',\n    Sqlx\\DriverOptions::OPT_RETRY_MULTIPLIER =\u003e 2.0,\n]);\n```\n\n### Retry Behavior\n\n- **Disabled by default** – Set `OPT_RETRY_MAX_ATTEMPTS` \u003e 0 to enable\n- **Exponential backoff** – Each retry waits longer: 100ms → 200ms → 400ms → ...\n- **Capped backoff** – Backoff never exceeds `OPT_RETRY_MAX_BACKOFF`\n- **Transient errors only** – Only retries pool exhaustion, timeouts, and connection errors\n- **No retry in transactions** – Retries are skipped inside `begin()` to prevent partial commits\n\n### Transient vs Non-Transient Errors\n\n| Error Type        | Retried? | Examples                           |\n|-------------------|----------|------------------------------------|\n| Pool exhausted    | ✅ Yes    | All connections in use             |\n| Timeout           | ✅ Yes    | Connection or query timeout        |\n| Connection error  | ✅ Yes    | Connection dropped, network error  |\n| Query error       | ❌ No     | Syntax error, constraint violation |\n| Transaction error | ❌ No     | Deadlock, serialization failure    |\n| Parse error       | ❌ No     | Invalid SQL syntax                 |\n\n---\n\n## Error Handling\n\nAll errors thrown by the extension are instances of exception classes in the `Sqlx\\Exceptions` namespace.\nEach error type has its own exception class for precise `catch` handling:\n\n```php\nuse Sqlx\\Exceptions\\{SqlxException, QueryException, ConnectionException};\n\ntry {\n    $driver-\u003equeryRow('SELECT * FROM non_existent_table');\n} catch (QueryException $e) {\n    // Handle query-specific errors\n    echo \"Query failed: \" . $e-\u003egetMessage() . \"\\n\";\n} catch (ConnectionException $e) {\n    // Handle connection errors\n    echo \"Connection failed: \" . $e-\u003egetMessage() . \"\\n\";\n} catch (SqlxException $e) {\n    // Catch-all for any other sqlx errors\n    echo \"Error: \" . $e-\u003egetMessage() . \"\\n\";\n}\n```\n\n### Exception Classes\n\nAll exceptions extend `Sqlx\\Exceptions\\SqlxException`, which extends PHP's base `Exception`:\n\n| Exception Class                          | Error Code | Description                                       |\n|------------------------------------------|------------|---------------------------------------------------|\n| `Sqlx\\Exceptions\\SqlxException`          | 0          | Base class / General error                        |\n| `Sqlx\\Exceptions\\ConnectionException`    | 1          | Database connection failed                        |\n| `Sqlx\\Exceptions\\QueryException`         | 2          | Query execution failed                            |\n| `Sqlx\\Exceptions\\TransactionException`   | 3          | Transaction-related error                         |\n| `Sqlx\\Exceptions\\ParseException`         | 4          | SQL parsing/AST error                             |\n| `Sqlx\\Exceptions\\ParameterException`     | 5          | Missing or invalid parameter                      |\n| `Sqlx\\Exceptions\\ConfigurationException` | 6          | Configuration/options error                       |\n| `Sqlx\\Exceptions\\ValidationException`    | 7          | Invalid identifier or input validation error      |\n| `Sqlx\\Exceptions\\NotPermittedException`  | 8          | Operation not permitted (e.g., write on readonly) |\n| `Sqlx\\Exceptions\\TimeoutException`       | 9          | Operation timed out                               |\n| `Sqlx\\Exceptions\\PoolExhaustedException` | 10         | Connection pool exhausted                         |\n\nError codes are also available as constants on `SqlxException` for backwards compatibility:\n\n```php\nuse Sqlx\\Exceptions\\SqlxException;\n\nif ($e-\u003egetCode() === SqlxException::CONNECTION) {\n    // Handle connection error\n}\n```\n\n---\n\n## Parameter types\n\nSupported parameter types:\n\n```php\n\"text\"\n123\n3.14\ntrue\n[1, 2, 3]\n```\n\n\u003e ✅ PostgreSQL `BIGINT` values are safely mapped to PHP integers:\n\u003e ```php\n\u003e  var_dump($driver-\u003equeryValue('SELECT ((1::BIGINT \u003c\u003c 62) - 1) * 2 + 1');\n\u003e  // Output: int(9223372036854775807)\n\u003e```\n\nNested arrays are automatically flattened and bound in order.\n\nPostgreSQL/MySQL JSON types are automatically decoded into PHP arrays or objects.\n\n```php\nvar_dump($driver-\u003equeryValue(\n    'SELECT $1::json',\n    ['{\"foo\": [\"bar\", \"baz\"]}']\n));\n/* Output:\nobject(stdClass)#2 (1) {\n  [\"foo\"]=\u003e\n  array(2) {\n    [0]=\u003e\n    string(3) \"bar\"\n    [1]=\u003e\n    string(3) \"baz\"\n  }\n}*/\n\nvar_dump($driver-\u003equeryRow(\n    'SELECT $1::json AS col',\n    ['{\"foo\": [\"bar\", \"baz\"]}']\n)-\u003ecol-\u003efoo[0]);\n// Output: string(3) \"bar\"\n```\n\n## Query Builder overview\n\n\u003e See the full [Query Builder guide](QUERY-BUILDER.md).\n\nYou can fluently build SQL queries using `$driver-\u003ebuilder()`:\n\n```php\n$query = $driver-\u003ebuilder()\n    -\u003eselect(\"*\")\n    -\u003efrom(\"users\")\n    -\u003ewhere([\"active\" =\u003e true])\n    -\u003eorderBy(\"created_at DESC\")\n    -\u003elimit(10);\n```\n\nThe builder supports most SQL clauses:\n\n* `select()`, `from()`, `where()`, `groupBy()`, `orderBy()`, `having()`\n* `insertInto()`, `values()`, `valuesMany()`, `returning()`\n* `update()`, `set()`\n* `deleteFrom()`, `using()`\n* `with()`, `withRecursive()`\n* `join()`, `leftJoin()`, `rightJoin()`, `fullJoin()`, `naturalJoin()`, `crossJoin()`\n* `onConflict()`, `onDuplicateKeyUpdate()`\n* `limit()`, `offset()`, `paginate()`\n* `union()`, `unionAll()`\n* `forUpdate()`, `forShare()`\n* `truncateTable()`, `raw()`, `end()`\n\nEach method returns the builder itself, allowing fluent chaining.\n\n---\n\n### Insert: Multi-row Example\n\nUse `valuesMany()` to insert multiple rows in one statement:\n\n```php\n$driver-\u003ebuilder()-\u003einsert(\"users\")-\u003evaluesMany([\n    [\"Alice\", \"alice@example.com\"],\n    [\"Bob\", \"bob@example.com\"]\n]);\n\n// or with named keys:\n$driver-\u003ebuilder()-\u003einsert(\"users\")-\u003evaluesMany([\n    [\"name\" =\u003e \"Alice\", \"email\" =\u003e \"alice@example.com\"],\n    [\"name\" =\u003e \"Bob\",   \"email\" =\u003e \"bob@example.com\"]\n]);\n```\n\n---\n\n### Executing the Query\n\nAfter building the query, you can run it just like with prepared statements:\n\n```php\n$query-\u003eexecute();\n// OR\n$row = $query-\u003equeryRow();\n// OR\n$rows = $query-\u003equeryAll();\n// OR iterate lazily\nforeach ($query-\u003equery() as $row) {\n    echo $row-\u003ename . \"\\n\";\n}\n```\n\nYou can also preview the rendered SQL and parameters without executing:\n\n```php\nvar_dump((string) $query); // SQL with placeholders rendered\n```\n\n---\n\n## API\n\n### `Sql\\DriverFactory`\n\n```php\n$driver = Sqlx\\DriverFactory::make(\"postgres://user:pass@localhost/db\");\n```\n\nOr with options:\n\n```php\n$driver = Sqlx\\DriverFactory::make([\n    Sqlx\\DriverOptions::OPT_URL =\u003e 'postgres://user:pass@localhost/db',\n    Sqlx\\DriverOptions::OPT_ASSOC_ARRAYS =\u003e true,   // return arrays instead of objects\n    Sqlx\\DriverOptions::OPT_PERSISTENT_NAME =\u003e 'main_db'\n    Sqlx\\DriverOptions::OPT_MAX_CONNECTIONS =\u003e 5,\n    Sqlx\\DriverOptions::OPT_MIN_CONNECTIONS =\u003e 0,\n    //Sqlx\\DriverOptions::OPT_MAX_LIFETIME =\u003e \"30 min\",\n    Sqlx\\DriverOptions::OPT_IDLE_TIMEOUT =\u003e 120,\n    Sqlx\\DriverOptions::OPT_ACQUIRE_TIMEOUT =\u003e 10,\n]);\n\n// With automatic retry for transient failures\n$driver = Sqlx\\DriverFactory::make([\n    Sqlx\\DriverOptions::OPT_URL =\u003e 'postgres://user:pass@localhost/db',\n    Sqlx\\DriverOptions::OPT_RETRY_MAX_ATTEMPTS =\u003e 3,        // retry up to 3 times\n    Sqlx\\DriverOptions::OPT_RETRY_INITIAL_BACKOFF =\u003e '100ms',\n    Sqlx\\DriverOptions::OPT_RETRY_MAX_BACKOFF =\u003e '5s',\n    Sqlx\\DriverOptions::OPT_RETRY_MULTIPLIER =\u003e 2.0,        // exponential backoff\n]);\n```\n\n\u003cdetails\u003e\n\u003csummary\u003eDriverOptions reference\u003c/summary\u003e\n\n| Option                      | Type                    | Description                                                                         | Default      |\n|-----------------------------|-------------------------|-------------------------------------------------------------------------------------|--------------|\n| `OPT_URL`                   | `string`                | **Required.** Database connection URL. Example: `postgres://user:pass@localhost/db` | *(required)* |\n| `OPT_ASSOC_ARRAYS`          | `bool`                  | If true, rows are returned as associative arrays instead of objects                 | `false`      |\n| `OPT_PERSISTENT_NAME`       | `string \\| null`        | Enables persistent connection pool reuse under a given name                         | `null`       |\n| `OPT_MAX_CONNECTIONS`       | `int \u003e 0`               | Maximum number of connections in the pool                                           | `10`         |\n| `OPT_MIN_CONNECTIONS`       | `int ≥ 0`               | Minimum number of connections to keep alive                                         | `0`          |\n| `OPT_MAX_LIFETIME`          | `string \\| int \\| null` | Max lifetime of a pooled connection. Accepts `\"30s\"`, `\"5 min\"`, or seconds         | `null`       |\n| `OPT_IDLE_TIMEOUT`          | `string \\| int \\| null` | Idle timeout before closing pooled connections. Same format as above                | `null`       |\n| `OPT_ACQUIRE_TIMEOUT`       | `string \\| int \\| null` | Timeout to wait for a connection from the pool                                      | `null`       |\n| `OPT_TEST_BEFORE_ACQUIRE`   | `bool`                  | Whether to test connections before acquisition                                      | `false`      |\n| `OPT_COLLAPSIBLE_IN`        | `bool`                  | Enables automatic collapsing of `IN ()` / `NOT IN ()` into `FALSE` / `TRUE`         | `true`       |\n| `OPT_AST_CACHE_SHARD_COUNT` | `int \u003e 0`               | Number of internal SQL AST cache shards (advanced tuning)                           | `8`          |\n| `OPT_AST_CACHE_SHARD_SIZE`  | `int \u003e 0`               | Max number of entries per AST cache shard                                           | `256`        |\n| `OPT_RETRY_MAX_ATTEMPTS`    | `int ≥ 0`               | Max retry attempts for transient failures (0 = disabled)                            | `0`          |\n| `OPT_RETRY_INITIAL_BACKOFF` | `string \\| int`         | Initial backoff between retries. Accepts `\"100ms\"`, `\"1s\"`, or seconds              | `\"100ms\"`    |\n| `OPT_RETRY_MAX_BACKOFF`     | `string \\| int`         | Maximum backoff duration (caps exponential growth)                                  | `\"5s\"`       |\n| `OPT_RETRY_MULTIPLIER`      | `float`                 | Backoff multiplier for exponential backoff (e.g., 2.0 doubles each retry)           | `2.0`        |\n\n\u003c/details\u003e\n\n#### Basics\n\n- `assocArrays(): bool` – returns **true** if the driver is currently set to produce associative arrays instead of\n  objects.\n- `prepare(string $query): Sqlx\\PreparedQuery` – returns a reusable prepared query object bound to the same driver.\n\n#### Row helpers\n\n| Method                 | Returns                             | Notes                         |\n|------------------------|-------------------------------------|-------------------------------|\n| `queryRow()`           | first row (array \\| object)         | exception if no rows returned |\n| `queryRowAssoc()`      | first row (array)                   | ∟ enforces array mode         |\n| `queryRowObj()`        | first row (object)                  | ∟ enforces object mode        |\n| `queryMaybeRow()`      | first row (array \\| object \\| null) | null if no rows returned      |\n| `queryMaybeRowAssoc()` | first row (array \\| null)           | ∟ enforces array mode         |\n| `queryMaybeRowObj()`   | first row (object \\| null)          | ∟ enforces object mode        |\n\n#### Column helpers (single-row)\n\n| Method                   | Returns                        | Notes                                   |\n|--------------------------|--------------------------------|-----------------------------------------|\n| `queryValue()`           | first row column value         | exception if no rows returned           |\n| `queryValueAssoc()`      | ↑                              | ∟ enforces array mode for JSON objects  |\n| `queryValueObj()`        | ↑                              | ∟ enforces object mode for JSON objects |\n| `queryMaybeValue()`      | first row column value or null | null if no rows returned                |\n| `queryMaybeValueAssoc()` | ↑                              | ∟ enforces array mode for JSON objects  |\n| `queryMaybeValueObj()`   | ↑                              | ∟ enforces object mode for JSON objects |\n\n#### Column helpers (multi-row)\n\n| Method               | Returns                                | Notes                                   |\n|----------------------|----------------------------------------|-----------------------------------------|\n| `queryColumn()`      | array of column's values from each row | exception if no rows returned           |\n| `queryColumnAssoc()` | ↑                                      | ∟ enforces array mode for JSON objects  |\n| `queryColumnObj()`   | ↑                                      | ∟ enforces object mode for JSON objects |\n\n#### List helpers (all rows)\n\n| Method            | Returns               |\n|-------------------|-----------------------|\n| `queryAll()`      | array of rows         |\n| `queryAllAssoc()` | array of assoc arrays |\n| `queryAllObj()`   | array of objects      |\n\n#### Iterator helpers (lazy streaming)\n\n| Method         | Returns                       | Notes                          |\n|----------------|-------------------------------|--------------------------------|\n| `query()`      | `QueryResult` iterator        | Uses driver's default row mode |\n| `queryAssoc()` | `QueryResult` iterator        | Forces associative arrays      |\n| `queryObj()`   | `QueryResult` iterator        | Forces objects                 |\n\nThe `query()` method returns a `QueryResult` object that implements PHP's `Iterator` interface.\nRows are streamed from the database on demand, providing true lazy loading that's memory-efficient\nfor large result sets:\n\n```php\n// Iterate over results - rows are streamed as you iterate\n$result = $driver-\u003equery('SELECT * FROM large_table');\nforeach ($result as $index =\u003e $row) {\n    echo $row-\u003ename . \"\\n\";\n}\n\n// With parameters and custom buffer size\n$result = $driver-\u003equery(\n    'SELECT * FROM users WHERE status = ?s',\n    ['active'],\n    50  // buffer size (default: 100)\n);\n\n// Force associative arrays\n$result = $driver-\u003equeryAssoc('SELECT * FROM users');\nforeach ($result as $row) {\n    echo $row['name'] . \"\\n\";  // $row is an array\n}\n\n// Convert to array (fetches all remaining rows)\n$rows = $result-\u003etoArray();\n\n// Check iteration state\necho $result-\u003ecount();        // rows fetched so far\necho $result-\u003egetBatchSize(); // configured buffer size\n$result-\u003eisExhausted();       // true when all rows consumed\n```\n\nThe `QueryResult` class provides:\n- `current()` / `key()` / `next()` / `rewind()` / `valid()` – Iterator interface\n- `count()` – number of rows fetched so far\n- `toArray()` – convert remaining rows to array\n- `getBatchSize()` – get configured buffer size\n- `isExhausted()` – check if all rows have been consumed\n- `getLastError()` – get last error message if iteration stopped due to error\n\n#### Mutation helpers\n\n- `execute(string $query, array $parameters = null): int` – run **INSERT/UPDATE/DELETE** and return affected count.\n- `insert(string $table, array $row): int` – convenience wrapper around `INSERT`.\n- `insertMany(string $table, array $rows): int` – insert multiple rows in a single statement.\n- `upsert(string $table, array $row, array $conflictColumns, ?array $updateColumns = null): int` – insert or update on\n  conflict.\n\n#### Utilities\n\n- `dry(string $query, array $parameters = null): array` – render final SQL + bound parameters without executing. Handy\n  for debugging.\n\n#### Quoting helpers\n\n- `quote(mixed $value): string` – quote a value as a SQL literal (e.g., `'O''Reilly'`, `123`, `TRUE`).\n- `quoteLike(string $value): string` – quote with LIKE metacharacter escaping (`%` and `_` escaped).\n- `quoteIdentifier(string $name): string` – quote an identifier (table/column name) using database-specific style.\n\n```php\n// PostgreSQL\n$driver-\u003equote(\"O'Reilly\");        // 'O''Reilly'\n$driver-\u003equoteLike(\"100%_safe\");   // '100\\%\\_safe'\n$driver-\u003equoteIdentifier(\"user\");  // \"user\"\n\n// MySQL\n$driver-\u003equoteIdentifier(\"user\");  // `user`\n\n// MSSQL\n$driver-\u003equoteIdentifier(\"user\");  // [user]\n```\n\n#### Query Profiling\n\n- `onQuery(?callable $callback): void` – registers a callback for query profiling/logging.\n\n```php\n// Enable query logging\n$driver-\u003eonQuery(function(string $sql, string $sqlInline, float $durationMs) {\n    Logger::debug(\"Query took {$durationMs}ms: $sqlInline\");\n});\n\n// Run some queries - the callback is called after each one\n$users = $driver-\u003equeryAll('SELECT * FROM users WHERE status = $status', ['status' =\u003e 'active']);\n\n// Disable the hook\n$driver-\u003eonQuery(null);\n```\n\nThe callback receives:\n\n- `$sql` – The rendered SQL with placeholders (`SELECT * FROM users WHERE status = $1`)\n- `$sqlInline` – The SQL with inlined values for logging (`SELECT * FROM users WHERE status = 'active'`)\n- `$durationMs` – Execution time in milliseconds\n\n**Performance**: When no hook is registered, there is zero overhead. Timing only starts when a hook is active.\n\n#### Connection Tagging\n\nTag connections with metadata for easier debugging in database monitoring tools:\n\n- `setApplicationName(string $name): void` – sets the application name for this connection.\n- `setClientInfo(string $applicationName, array $info): void` – sets application name with additional metadata.\n\n```php\n// Simple application name\n$driver-\u003esetApplicationName('order-service');\n\n// With additional context (useful for debugging)\n$driver-\u003esetClientInfo('order-service', [\n    'request_id' =\u003e $requestId,\n    'user_id' =\u003e $userId,\n]);\n// Result: \"order-service {request_id='abc123',user_id=42}\"\n```\n\n**Database-specific visibility**:\n\n- PostgreSQL: Visible in `pg_stat_activity.application_name`\n- MySQL: Stored in session variable `@sqlx_application_name` (queryable via `SELECT @sqlx_application_name`)\n- MSSQL: Stored in session context (queryable via `SELECT SESSION_CONTEXT(N'application_name')`)\n\n#### Read Replicas\n\nConfigure read replicas for automatic read/write splitting:\n\n```php\n$driver = Sqlx\\DriverFactory::make([\n    Sqlx\\DriverOptions::OPT_URL =\u003e 'postgres://user:pass@primary/db',\n    Sqlx\\DriverOptions::OPT_READ_REPLICAS =\u003e [\n        'postgres://user:pass@replica1/db',\n        'postgres://user:pass@replica2/db',\n    ],\n]);\n\n// SELECT queries automatically route to replicas (round-robin)\n$users = $driver-\u003equeryAll('SELECT * FROM users');\n\n// Write operations always go to primary\n$driver-\u003eexecute('INSERT INTO users (name) VALUES (?s)', ['John']);\n```\n\n**Weighted load balancing:** Assign weights to control traffic distribution:\n\n```php\n$driver = Sqlx\\DriverFactory::make([\n    Sqlx\\DriverOptions::OPT_URL =\u003e 'postgres://user:pass@primary/db',\n    Sqlx\\DriverOptions::OPT_READ_REPLICAS =\u003e [\n        ['url' =\u003e 'postgres://user:pass@replica1/db', 'weight' =\u003e 3],  // 75% traffic\n        ['url' =\u003e 'postgres://user:pass@replica2/db', 'weight' =\u003e 1],  // 25% traffic\n    ],\n]);\n```\n\n**Routing rules:**\n\n- `queryAll`, `queryRow`, `queryMaybeRow`, `queryValue`, `queryColumn` → replicas\n- `execute` → primary\n- All queries inside transactions → primary (consistency guarantee)\n\n**Load balancing:** Weighted round-robin (or simple round-robin if all weights equal).\n\n```php\n// Check if replicas are configured\nif ($driver-\u003ehasReadReplicas()) {\n    echo \"Read queries are load balanced across replicas\";\n}\n```\n\n#### Schema Introspection\n\n- `describeTable(string $table, ?string $schema = null): array` – returns column metadata for the specified table.\n\n```php\n$columns = $driver-\u003edescribeTable('users');\n// Returns:\n// [\n//   ['name' =\u003e 'id', 'type' =\u003e 'integer', 'nullable' =\u003e false, 'default' =\u003e null, 'ordinal' =\u003e 1],\n//   ['name' =\u003e 'email', 'type' =\u003e 'varchar(255)', 'nullable' =\u003e false, 'default' =\u003e null, 'ordinal' =\u003e 2],\n//   ['name' =\u003e 'created_at', 'type' =\u003e 'timestamp', 'nullable' =\u003e true, 'default' =\u003e 'now()', 'ordinal' =\u003e 3],\n// ]\n\n// With explicit schema\n$columns = $driver-\u003edescribeTable('users', 'public');\n```\n\nEach column entry contains:\n\n- `name` – Column name (string)\n- `type` – Database-specific type (string), e.g., `varchar(255)`, `integer`, `timestamp`\n- `nullable` – Whether `NULL` is allowed (bool)\n- `default` – Default value expression (string|null)\n- `ordinal` – 1-based column position (int)\n\n---\n\n### Sqlx\\PreparedQuery\n\nPrepared queries expose exactly the same surface as the driver, but without the SQL argument:\n\n```php\n$query = $driver-\u003eprepare('SELECT * FROM logs WHERE level = $level');\n$rows  = $query-\u003equeryAll(['level' =\u003e 'warn']);\n\n// Or iterate lazily\nforeach ($query-\u003equery(['level' =\u003e 'warn']) as $row) {\n    echo $row-\u003emessage . \"\\n\";\n}\n```\n\nAll helpers listed above have their prepared-query counterparts:\n\n- `execute()`\n- `queryRow()` / `queryRowAssoc()` / `queryRowObj()`\n- `queryAll()` / `queryAllAssoc()` / `queryAllObj()`\n- `query()` / `queryAssoc()` / `queryObj()` – returns `QueryResult` iterator\n- `queryDictionary()` / `queryDictionaryAssoc()` / `queryDictionaryObj()`\n- `queryGroupedDictionary()` / `queryGroupedDictionaryAssoc()` / `queryGroupedDictionaryObj()`\n- `queryColumnDictionary()` / `queryColumnDictionaryAssoc()` / `queryColumnDictionaryObj()`\n\n---\n\n### Dictionary helpers (first column as key, row as value)\n\n| Method                   | Returns                                 | Notes                                  |\n|--------------------------|-----------------------------------------|----------------------------------------|\n| `queryDictionary()`      | `array\u003cstring \\| int, array \\| object\u003e` | key = first column, value = entire row |\n| `queryDictionaryAssoc()` | `array\u003cstring \\| int, array\u003e`           | ∟ forces associative arrays            |\n| `queryDictionaryObj()`   | `array\u003cstring \\| int, object\u003e`          | ∟ forces objects                       |\n\n\u003e - ⚠️ First column **must** be scalar, otherwise an exception will be thrown.\n\u003e - 🔀 The iteration order is preserved.\n\n```php\nvar_dump($driver-\u003equeryGroupedColumnDictionary(\n    'SELECT department, name FROM employees WHERE department IN (?)',\n    [['IT', 'HR']]\n));\n/* Output:\narray(2) {\n  [\"IT\"]=\u003e array(\"Alice\", \"Bob\")\n  [\"HR\"]=\u003e array(\"Eve\")\n}\n*/\n```\n\n---\n\n### Column Dictionary helpers (first column as key, second as value)\n\n| Method                         | Returns                       | Notes                                                   |\n|--------------------------------|-------------------------------|---------------------------------------------------------|\n| `queryColumnDictionary()`      | `array\u003cstring \\| int, mixed\u003e` | key = first column, value = second column               |\n| `queryColumnDictionaryAssoc()` | ↑                             | ∟ enforces array mode for second column if it's a JSON  |\n| `queryColumnDictionaryObj()`   | ↑                             | ∟ enforces object mode for second column if it's a JSON |\n\n```php\nvar_dump($driver-\u003equeryColumnDictionary(\n    'SELECT name, age FROM people WHERE name IN (?)',\n    [[\"Peter\", \"John\", \"Jane\"]]\n));\n/* Output:\narray(1) {\n  [\"John\"]=\u003e\n  int(22)\n}\n*/\n```\n\n---\n\n### Grouped Dictionary helpers (first column as key, many rows per key)\n\n| Method                          | Returns                                 | Notes                                             |\n|---------------------------------|-----------------------------------------|---------------------------------------------------|\n| `queryGroupedDictionary()`      | `array\u003cstring, array\u003carray \\| object\u003e\u003e` | key = first column, value = list of matching rows |\n| `queryGroupedDictionaryAssoc()` | `array\u003cstring, array\u003carray\u003e`            | ∟ forces associative arrays                       |\n| `queryGroupedDictionaryObj()`   | `array\u003cstring, array\u003cobject\u003e\u003e`          | ∟ forces objects                                  |\n\n```php\nvar_dump($driver-\u003equeryGroupedDictionary(\n    'SELECT department, name FROM employees WHERE department IN (?)',\n    [['IT', 'HR']]\n));\n/* Output:\narray(1) {\n  [\"IT\"]=\u003e\n  array(2) {\n    [0]=\u003e\n    object(stdClass)#2 (2) {\n      [\"department\"]=\u003e\n      string(2) \"IT\"\n      [\"name\"]=\u003e\n      string(5) \"Alice\"\n    }\n    [1]=\u003e\n    object(stdClass)#3 (2) {\n      [\"department\"]=\u003e\n      string(2) \"IT\"\n      [\"name\"]=\u003e\n      string(3) \"Bob\"\n    }\n  }\n}\n*/\n```\n\n---\n\n## Interfaces\n\nThe extension provides native PHP interfaces defined in Rust using the `#[php_interface]` macro from ext-php-rs.\nThese interfaces work seamlessly with PHP's `instanceof` checks, type hints, and IDE auto-completion.\n\n### Available Interfaces\n\n| Interface                         | Implementing Classes                                                      | Description                  |\n|-----------------------------------|---------------------------------------------------------------------------|------------------------------|\n| `Sqlx\\DriverInterface`            | `PgDriver`, `MySqlDriver`, `MssqlDriver`                                  | Database driver contract     |\n| `Sqlx\\PreparedQueryInterface`     | `PgPreparedQuery`, `MySqlPreparedQuery`, `MssqlPreparedQuery`             | Prepared statement contract  |\n| `Sqlx\\ReadQueryBuilderInterface`  | `PgReadQueryBuilder`, `MySqlReadQueryBuilder`, `MssqlReadQueryBuilder`    | Read query builder contract  |\n| `Sqlx\\WriteQueryBuilderInterface` | `PgWriteQueryBuilder`, `MySqlWriteQueryBuilder`, `MssqlWriteQueryBuilder` | Write query builder contract |\n\n```php\nuse Sqlx\\DriverInterface;\nuse Sqlx\\PreparedQueryInterface;\nuse Sqlx\\WriteQueryBuilderInterface;\nuse Sqlx\\ReadQueryBuilderInterface;\n\n// Type hints work as expected\nfunction runQuery(DriverInterface $driver): void {\n    $rows = $driver-\u003equeryAll('SELECT * FROM users');\n    // ...\n}\n\n// instanceof checks work\n$driver = Sqlx\\DriverFactory::make('postgres://...');\nassert($driver instanceof DriverInterface);\n\n$prepared = $driver-\u003eprepare('SELECT * FROM users WHERE id = ?');\nassert($prepared instanceof PreparedQueryInterface);\n\n$builder = $driver-\u003ebuilder();\nassert($builder instanceof WriteQueryBuilderInterface);\n\n$readBuilder = $driver-\u003ereadBuilder();\nassert($readBuilder instanceof ReadQueryBuilderInterface);\n```\n\n### Design Philosophy\n\nThese interfaces follow the [Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle),\nallowing you to depend on abstractions rather than concrete implementations:\n\n```php\n// Your code depends on the interface, not the concrete driver\nfunction fetchUsers(Sqlx\\DriverInterface $driver): array {\n    return $driver-\u003equeryAll('SELECT * FROM users');\n}\n\n// Works with any driver implementation\n$pgDriver = Sqlx\\DriverFactory::make('postgres://...');\n$mysqlDriver = Sqlx\\DriverFactory::make('mysql://...');\n\nfetchUsers($pgDriver);   // Works\nfetchUsers($mysqlDriver); // Also works\n```\n\nThis makes it easier to:\n- Write database-agnostic code\n- Mock drivers in unit tests\n- Switch between database backends\n\n---\n\n## Performance\n\nWell, it's fast. Nothing like similar projects written in userland PHP.\n\nThe AST cache eliminates repeated parsing overhead and speeds up query rendering.\n\nJSON decoding is lazy (on-demand) with optional [SIMD](https://docs.rs/simd-json/latest/simd_json/) support.\n\n### Rust benchmark suite\n\nIt is useful for measuring the performance of backend parts such as AST parsing/rendering.\n\nCommand:\n\n```shell\ncargo bench\n```\n\nM1 Max results for parsing and rendering **without** AST caching:\n\n```\nAst::parse_small        time:   [2.2591 µs 2.2661 µs 2.2744 µs]\nFound 5 outliers among 100 measurements (5.00%)\n  3 (3.00%) high mild\n  2 (2.00%) high severe\n\nAst::parse_big          time:   [6.6006 µs 6.6175 µs 6.6361 µs]\nFound 9 outliers among 100 measurements (9.00%)\n  5 (5.00%) high mild\n  4 (4.00%) high severe\n\nAst::render_big         time:   [607.02 ns 608.43 ns 610.11 ns]\nFound 8 outliers among 100 measurements (8.00%)\n  4 (4.00%) high mild\n  4 (4.00%) high severe\n```\n\n### PHP benchmarks\n\nRun:\n\n```shell\ncd benches\ncurl -s https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer | php -- --quiet\n./composer.phar require phpbench/phpbench --dev\n./vendor/bin/phpbench run benchmark.php\n```\n\nOr use Docker:\n\n```shell\ndocker build . -t php-sqlx-benches\ndocker run php-sqlx-benches\n```\n\nM1 Max results for parsing and rendering **with** AST caching:\n\n```\n    benchDrySmall...........................I0 - Mo1.246μs (±0.00%)\n    benchDryBig.............................I0 - Mo2.906μs (±0.00%)\n```\n\n## Running Tests\n\n```bash\ncargo test\n```\n\n---\n\n## Fuzzing\n\nThe AST parser can be fuzzed to find edge cases and potential security issues:\n\n```bash\n# Install cargo-fuzz (requires nightly)\ncargo install cargo-fuzz\n\n# Run the PostgreSQL parser fuzzer\ncargo +nightly fuzz run ast_postgres\n\n# Run for 60 seconds\ncargo +nightly fuzz run ast_postgres -- -max_total_time=60\n```\n\nAvailable fuzz targets:\n\n- `ast_postgres` - PostgreSQL parser\n- `ast_mysql` - MySQL parser\n- `ast_mssql` - MSSQL parser\n- `ast_render` - Parser + renderer with random parameters\n\nSee [fuzz/README.md](fuzz/README.md) for more details.\n\n---\n\n## IDE Support\n\nSyntax highlighting for conditional blocks (`{{ }}`), type-safe placeholders (`?i`, `?s`), and named parameters.\n\n### VS Code\n\nInstall the extension from `editors/vscode`:\n\n```bash\ncd editors/vscode\nnpx vsce package\n# Then install the .vsix file in VS Code\n```\n\nOr manually copy/symlink to `~/.vscode/extensions/php-sqlx`.\n\nFeatures:\n\n- Highlights `{{ }}` conditional blocks\n- Highlights typed placeholders (`?i`, `?ni`, `?ia`, `$name!s`)\n- Auto-injects into PHP strings starting with SQL keywords\n\nSee [editors/vscode/README.md](editors/vscode/README.md) for details.\n\n### PHPStorm / IntelliJ\n\nImport the language injection configuration:\n\n1. **Settings** \u003e **Editor** \u003e **Language Injections**\n2. Click **Import** and select `editors/phpstorm/IntelliLang.xml`\n\nAlso includes live templates for common patterns (`sqlxq`, `sqlxcond`, `sqlxtx`).\n\nSee [editors/phpstorm/README.md](editors/phpstorm/README.md) for details.\n\n---\n\n## Testing\n\n### Running Unit Tests (Rust)\n\n```bash\ncargo test --all-features\n```\n\n### Running Integration Tests (PHPUnit)\n\nStart the test databases with Docker Compose:\n\n```bash\ndocker-compose up -d\n```\n\nInstall PHPUnit and run tests:\n\n```bash\ncd tests\ncomposer install\nvendor/bin/phpunit                       # Run all tests\nvendor/bin/phpunit --testsuite PostgreSQL  # PostgreSQL only\nvendor/bin/phpunit --testsuite MySQL       # MySQL only\nvendor/bin/phpunit --testsuite MSSQL       # MSSQL only\n```\n\n#### Test Configuration\n\nDatabase URLs are configured in `tests/phpunit.xml`:\n\n```xml\n\n\u003cenv name=\"POSTGRES_URL\" value=\"postgres://postgres:postgres@localhost:5432/test_db\"/\u003e\n\u003cenv name=\"MYSQL_URL\" value=\"mysql://root:root@localhost:3306/test_db\"/\u003e\n\u003cenv name=\"MSSQL_URL\" value=\"mssql://sa:TestPassword123!@localhost:1433/test_db?TrustServerCertificate=true\"/\u003e\n```\n\n#### Test Coverage\n\nThe integration tests cover:\n\n- Connection handling\n- Basic queries (queryAll, queryRow, queryMaybeRow, queryValue, queryColumn)\n- Named and positional parameters\n- Type-safe placeholders\n- IN clause expansion\n- Conditional blocks\n- Transactions (commit, rollback, callback)\n- Schema introspection (describeTable)\n- Query hooks\n- Connection tagging\n- Database-specific features (RETURNING, OUTPUT, JSON types, etc.)\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkakserpom%2Fphp-sqlx-rs","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkakserpom%2Fphp-sqlx-rs","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkakserpom%2Fphp-sqlx-rs/lists"}