An open API service indexing awesome lists of open source software.

https://github.com/kakserpom/php-sqlx-rs

A modern feature-rich SQL driver for PHP.
https://github.com/kakserpom/php-sqlx-rs

mssql mysql php php-extension postgresql sql

Last synced: 2 months ago
JSON representation

A modern feature-rich SQL driver for PHP.

Awesome Lists containing this project

README

          

# SQLx PHP Extension

[![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)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![PHP 8.1+](https://img.shields.io/badge/PHP-8.1%2B-8892BF.svg)](https://php.net)
[![Rust](https://img.shields.io/badge/Rust-stable-orange.svg)](https://rust-lang.org)

**[Documentation](https://kakserpom.github.io/php-sqlx-rs/)**

The extension is powered by Rust ๐Ÿฆ€ and [SQLx](https://github.com/launchbadge/sqlx), built
using [ext-php-rs](https://github.com/extphprs/ext-php-rs). It enables safe, fast, and expressive
database access with additional SQL syntax. It comes with a powerful [query builder](QUERY-BUILDER.md).

**Postgres**, **MySQL** and **Mssql** protocols are natively supported. Other protocols may require a custom wrapper.

The project's goals are centered around providing a **secure** and **ergonomic** way to interact with SQL-based DBMS
without any compromise on performance.

## Architecture

```
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ PHP Application โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ DriverFactory โ”‚ QueryBuilder โ”‚ PreparedQuery โ”‚ Clauses โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Driver (PgDriver, MySqlDriver, etc.) โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ Connection โ”‚ โ”‚ Transaction โ”‚ โ”‚ Retry Policy โ”‚ โ”‚
โ”‚ โ”‚ Pool โ”‚ โ”‚ Stack โ”‚ โ”‚ (exponential backoff) โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ AST Engine โ”‚
โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚ โ”‚ Parser โ”‚ โ”‚ Renderer โ”‚ โ”‚ LRU Cache (per shard) โ”‚ โ”‚
โ”‚ โ”‚ โ”‚ โ”‚ (per DBMS) โ”‚ โ”‚ โ”‚ โ”‚
โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ SQLx (Rust) โ”‚
โ”‚ Async runtime (Tokio) + Native protocol drivers โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ PostgreSQL โ”‚ MySQL โ”‚ SQL Server โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
```

**Data flow:** PHP calls โ†’ Driver โ†’ AST parse/render (cached) โ†’ SQLx async execution โ†’ Database

## Getting Started

### Installation

Install with [`cargo-php`](https://github.com/extphprs/ext-php-rs):

```bash
cargo install cargo-php --locked
cd php-sqlx-cdylib
```

For macOS:

```bash
export MACOSX_DEPLOYMENT_TARGET=$(sw_vers -productVersion | tr -d '\n')
export RUSTFLAGS="-C link-arg=-undefined -C link-arg=dynamic_lookup"
#export RUSTFLAGS="-Zmacro-backtrace -Zproc-macro-backtrace -Clink-arg=-undefined -Clink-arg=dynamic_lookup"
cargo install cargo-php --locked
cd php-sqlx-cdylib
cargo php install --release --yes
```

### Usage example

```php
$driver = Sqlx\DriverFactory::make("postgres://user:pass@localhost/db");

$posts = $driver->queryAll('SELECT * FROM posts WHERE author_id = ?', [12345]);
```

## Features

- AST-based SQL augmentation (e.g., conditional blocks)
- Named parameters with `$param`, `:param`, or positional `:1` syntax
- **Type-safe placeholders** with type suffixes (`?i`, `?s`, `?d`, `?u`, `?ud`)
- **Nullable type support** with `n` prefix (`?ni`, `?ns`, `?nud`)
- Automatic result conversion to PHP arrays or objects
- **True streaming** via `query()` returning `QueryResult` iterator for memory-efficient processing
- Painless `IN (?)` / `NOT IN (?)` clauses expansion and collapse
- Safe and robust `ORDER BY` / `GROUP BY` clauses
- Pagination with `PAGINATE`
- Safe and robust `SELECT`
- SQL transactions are supported in full
- **Upsert support** with `upsert()` (PostgreSQL `ON CONFLICT`, MySQL `ON DUPLICATE KEY`)
- **Batch inserts** with `insertMany()` for efficient multi-row inserts
- Powerful Query Builder
- Native JSON support (with lazy decoding and [SIMD](https://docs.rs/simd-json/latest/simd_json/) ๐Ÿš€)
- Optional persistent connections (with connection pooling)
- **Automatic retry** with exponential backoff for transient failures
- Custom `SqlxException` class with error codes for precise error handling
- **Schema introspection** via `describeTable()` for table column metadata
- **Query profiling** via `onQuery()` callback for logging and performance monitoring
- **Connection tagging** via `setApplicationName()` and `setClientInfo()` for debugging
- **Read replica support** with automatic query routing and round-robin load balancing
- **IDE support** for VS Code and PHPStorm (syntax highlighting, live templates)

---

## Augmented SQL Syntax

This extension introduces a powerful SQL preprocessor that supports conditional blocks, optional fragments, and named
parameters. Both positional (`?`, `$1`, `:1`) and named (`$param`, `:param`) placeholders are supported. All can be used
interchangeably.

---

### Conditional Blocks

Wrap parts of your query with double braces `{{ ... }}` to make them conditional:

```
SELECT *
FROM users
WHERE TRUE
{{ AND name = $name }}
{{ AND status = $status }}
```

If a named parameter used inside the block is not provided, the entire block is omitted from the final query.

Nested conditional blocks are supported:

```
SELECT *
FROM logs
WHERE TRUE {{ AND date > $since {{ AND level = $level }} }}
```

In the above example the `level` condition will only be rendered when both `$level` and `$since` are set.

```
SELECT *
FROM logs
WHERE date > $since {{ AND level = $level }}
```

The above example will throw an exception if `$since` is not set.

---

### Type-Safe Placeholders

You can enforce type constraints on placeholders using type suffixes. When a value doesn't match the expected type, an
exception is thrown during query rendering.

#### Scalar Types

| Suffix | Named Syntax | Description |
|--------|--------------------------|--------------------------------------------------|
| `?i` | `:id!i`, `$id!i` | Integer |
| `?u` | `:age!u`, `$age!u` | Unsigned integer (โ‰ฅ 0) |
| `?d` | `:score!d`, `$score!d` | Decimal (int, float, or numeric string) |
| `?ud` | `:price!ud`, `$price!ud` | Unsigned decimal (โ‰ฅ 0, int/float/numeric string) |
| `?s` | `:name!s`, `$name!s` | String |
| `?j` | `:data!j`, `$data!j` | JSON (object, array, or Json wrapper) |

```php
// Type validation examples
$driver->queryAll('SELECT * FROM users WHERE id = ?i', [42]); // OK
$driver->queryAll('SELECT * FROM users WHERE id = ?i', ["string"]); // Error: Type mismatch
$driver->queryAll('SELECT * FROM users WHERE age = ?u', [-5]); // Error: negative not allowed

// Decimal accepts int, float, and numeric strings
$driver->queryAll('SELECT * FROM products WHERE price = ?d', [19.99]); // OK (float)
$driver->queryAll('SELECT * FROM products WHERE price = ?d', [20]); // OK (int)
$driver->queryAll('SELECT * FROM products WHERE price = ?d', ["19.99"]); // OK (numeric string)
$driver->queryAll('SELECT * FROM products WHERE price = ?d', ["abc"]); // Error: not numeric

// JSON serializes arrays/objects as JSON strings
$driver->execute('INSERT INTO events (data) VALUES (?j)', [['event' => 'click', 'count' => 5]]);
// Bound value: '{"event":"click","count":5}'
```

#### Array Types (for IN clauses)

| Suffix | Named Syntax | Description |
|--------|---------------|-------------------------------------------------|
| `?ia` | `:ids!ia` | Array of integers |
| `?ua` | `:ids!ua` | Array of unsigned integers |
| `?da` | `:scores!da` | Array of decimals |
| `?uda` | `:prices!uda` | Array of unsigned decimals |
| `?sa` | `:names!sa` | Array of strings |
| `?ja` | `:items!ja` | Array of JSON (each element serialized as JSON) |

```php
$driver->queryAll('SELECT * FROM users WHERE id IN :ids!ia', [
'ids' => [1, 2, 3]
]); // OK

$driver->queryAll('SELECT * FROM users WHERE id IN :ids!ia', [
'ids' => [1, "two", 3]
]); // Error: Type mismatch

// JSON array - each element is serialized as JSON (useful for PostgreSQL JSONB[])
$driver->execute(
'INSERT INTO logs (items) VALUES (ARRAY[?ja]::jsonb[])',
[[['type' => 'click'], ['type' => 'view']]]
);
// Expands to: ARRAY[$1, $2]::jsonb[]
// Bound values: '{"type":"click"}', '{"type":"view"}'
```

---

### Nullable Types

By default, typed placeholders reject `null` values. Use the `n` prefix to allow nulls:

| Suffix | Named Syntax | Description |
|--------|--------------|------------------------------------------|
| `?n` | `:data!n` | Nullable mixed (any type including null) |
| `?ni` | `:id!ni` | Nullable integer |
| `?nu` | `:age!nu` | Nullable unsigned integer |
| `?nd` | `:score!nd` | Nullable decimal |
| `?nud` | `:price!nud` | Nullable unsigned decimal |
| `?ns` | `:name!ns` | Nullable string |
| `?nj` | `:data!nj` | Nullable JSON |

Nullable array types: `?nia`, `?nua`, `?nda`, `?nuda`, `?nsa`, `?nja`

```php
// Non-nullable rejects null
$driver->queryAll('SELECT * FROM users WHERE id = ?i', [null]); // Error: Type mismatch

// Nullable accepts null
$driver->queryAll('SELECT * FROM users WHERE id = ?ni', [null]); // OK - renders NULL
$driver->queryAll('SELECT * FROM users WHERE id = ?ni', [42]); // OK - renders 42
```

#### Nullable in Conditional Blocks

The nullable flag affects how conditional blocks behave:

| Placeholder | Value | Block Behavior |
|----------------------------|--------|--------------------|
| `:status!ni` (nullable) | absent | Block **skipped** |
| `:status!ni` (nullable) | `null` | Block **rendered** |
| `:status!ni` (nullable) | `5` | Block **rendered** |
| `:status!i` (non-nullable) | `null` | Block **skipped** |
| `:status` (untyped) | `null` | Block **skipped** |

```php
$sql = 'SELECT * FROM users WHERE 1=1 {{ AND status = :status!ni }}';

// Absent - block skipped
$driver->queryAll($sql, []);
// Result: SELECT * FROM users WHERE 1=1

// Null provided - block rendered (nullable allows null)
$driver->queryAll($sql, ['status' => null]);
// Result: SELECT * FROM users WHERE 1=1 AND status = NULL

// Value provided - block rendered
$driver->queryAll($sql, ['status' => 1]);
// Result: SELECT * FROM users WHERE 1=1 AND status = $1
```

This is useful when you want to explicitly query for `NULL` values vs. omitting the condition entirely.

#### Quick Reference Cheat Sheet

| Type | Scalar | Nullable | Array | Nullable Array |
|------------------|--------|----------|--------|----------------|
| **Any** | `?` | `?n` | โ€” | โ€” |
| **Integer** | `?i` | `?ni` | `?ia` | `?nia` |
| **Unsigned Int** | `?u` | `?nu` | `?ua` | `?nua` |
| **Decimal** | `?d` | `?nd` | `?da` | `?nda` |
| **Unsigned Dec** | `?ud` | `?nud` | `?uda` | `?nuda` |
| **String** | `?s` | `?ns` | `?sa` | `?nsa` |
| **JSON** | `?j` | `?nj` | `?ja` | `?nja` |

**Named syntax**: Add `!` before suffix: `$id!i`, `:name!s`, `$prices!uda`, `$data!j`

---

### Painless `IN (?)` / `NOT IN (?)` clauses expansion and collapse

Passing an array as a parameter to a single placeholder automatically expands it:

```php
// Expands to: SELECT * FROM people WHERE name IN (?, ?, ?)
// with values ['Peter', 'John', 'Jane']
$rows = $driver->queryAll(
'SELECT * FROM people WHERE name IN :names', [
'names' => ['Peter', 'John', 'Jane']
]
);
```

Omitting the parameter or passing an empty array will make `IN` collapse into boolean `FALSE`.

```php
var_dump($driver->dry(
'SELECT * FROM people WHERE name IN :names', [
'names' => []
]
));
```

```
array(2) {
[0]=>
string(53) "SELECT * FROM people WHERE FALSE /* name IN :names */"
[1]=>
array(0) {
}
}
```

Same goes for `NOT IN`, except it will collapse into boolean `TRUE`.

```php
var_dump($driver->dry(
'SELECT * FROM people WHERE name NOT IN :names', [
'names' => []
]
));
```

```
array(2) {
[0]=>
string(56) "SELECT * FROM people WHERE TRUE /* name NOT IN :names */"
[1]=>
array(0) {
}
}
```

> Makes sense, right? Given that `x IN (1, 2, 3)` is sugar for `(x = 1 OR x = 2 OR x = 3)`
> and `x NOT IN (1, 2, 3)` is sugar for `(x != 1 AND x != 2 AND x != 3)`.

Keep in mind that you can not only use it in `WHERE`, but also in `ON` clauses when joining.

> It is true that in simpler cases of `IN :empty` like the above example you could just
> immediately return an empty result set without sending it to DBMS, but there could be a `JOIN`
> or a `UNION`.
---

### Safe and robust `ORDER BY` / `GROUP BY` clauses

`Sql\ByClause` helper class for safe `ORDER BY` / `GROUP BY` clauses from user input.

> __SAFETY CONCERNS__
> - ๐ŸŸข You can safely pass any user input as sorting settings.
> - ๐Ÿ”ด Do NOT pass unsanitized user input into `ByClause` constructor to avoid SQL injection vulnerabilities. If you
absolutely have to, then apply `array_values()` to the argument to avoid SQL injections.

**Examples**:

```php
// Let's define allowed columns
$orderBy = new Sqlx\ByClause([
'name',
'created_at',
'random' => 'RANDOM()'
]);

// Equivalent to: SELECT * FROM users ORDER BY `name` ASC, RANDOM()
$driver->queryAll('SELECT * FROM users ORDER BY :order_by', [
'order_by' => $orderBy([
['name', Sqlx\ByClause::ASC],
'random'
])
]);

```

Field names are case-sensitive, but they get trimmed.

---

Pagination with `PAGINATE`
---
`Sql\PaginateClause` helper class for safe pagination based on user input.

```php
// Let's define pagination rules
$pagination = new Sqlx\PaginateClause;
$pagination->perPage(5);
$pagination->maxPerPage(20);

// Equivalent to: SELECT * FROM people ORDER by id LIMIT 5 OFFSET 500
$rows = $driver->queryAll(
'SELECT * FROM people ORDER by id PAGINATE :pagination', [
'pagination' => $pagination(100)
]
);

// Equivalent to: SELECT * FROM people ORDER by id LIMIT 10 OFFSET 1000
$rows = $driver->queryAll(
'SELECT * FROM people ORDER by id PAGINATE :pagination', [
'pagination' => $pagination(100, 10)
]
);

// Equivalent to: SELECT * FROM people ORDER by id LIMIT 5 OFFSET 0
$rows = $driver->queryAll(
'SELECT * FROM people ORDER by id PAGINATE :pagination', [
'pagination' => $pagination()
]
);
```

You can safely pass any unsanitized values as arguments, but keep in mind that `perPage()`/`maxPerPage()`/
`defaultPerPage()`
functions take a positive integer and throw an exception otherwise.

---

### Safe and robust `SELECT`

A helper class for safe `SELECT` clauses from user input.

> __SAFETY CONCERNS__
> - ๐ŸŸข You can safely pass any user input as invocation argument.
> - ๐Ÿ”ด Do NOT pass unsanitized user input into `SelectClause` constructor to avoid SQL injection vulnerabilities. If you
absolutely have to, then apply `array_values()` to the argument to avoid SQL injections.

**Examples**:

```php
$select = new Sqlx\SelectClause([
'id',
'created_at',
'name',
'num_posts' => 'COUNT(posts.*)'
]);

// Equivalent to: SELECT `id`, `name`, COUNT(posts.*) AS `num_posts` FROM users
$rows = $driver->queryAll('SELECT :select FROM users', [
'select' => $select(['id','name', 'num_posts'])
]);
```

Note that column names are case-sensitive, but they get trimmed.

---

## Transactions

```php
$driver->begin(function($driver) {
// All queries inside this function will be wrapped in a transaction.
// You can use all driver functions here.

$driver->insert('users', ['name' => 'John', 'age' => 25]);
$driver->insert('users', ['name' => 'Mary', 'age' => 20]);

// return false;
});
```

A `ROLLBACK` happens if the closure returns `false` or throws an exception.
Otherwise, a `COMMIT` gets sent when functions finishes normally.

Additional supported methods to be called from inside a closure:

- `savepoint(name: String)`
- `rollbackToSavepoint(name: String)`
- `releaseSavepoint(name: String)`

---

## Pinned Connections

For session-scoped operations that require multiple queries to run on the **same connection** without a database
transaction, use `withConnection`:

```php
$lastId = $driver->withConnection(function($driver) {
$driver->execute("INSERT INTO users (name) VALUES ('Alice')");
return $driver->queryValue('SELECT LAST_INSERT_ID()');
});
```

**Use cases:**

- `LAST_INSERT_ID()` in MySQL (session-scoped)
- Temporary tables (connection-scoped)
- Session variables (`SET @var = ...`)
- Advisory locks

**Key differences from transactions:**

- No `BEGIN`/`COMMIT` is issued
- Each query is auto-committed immediately
- No rollback on failure

If you need transactional semantics (atomicity, rollback), use `begin()` instead.

---

## Batch Insert

The `insertMany()` method inserts multiple rows in a single statement for better performance:

```php
$driver->insertMany('users', [
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Bob', 'email' => 'bob@example.com'],
['name' => 'Carol', 'email' => 'carol@example.com'],
]);
```

**Generated SQL:**

```sql
INSERT INTO users (email, name)
VALUES ($email_0, $name_0),
($email_1, $name_1),
($email_2, $name_2)
```

- Columns are determined from the first row
- Missing columns in subsequent rows default to `NULL`
- Returns the number of inserted rows

---

## Upsert (Insert or Update)

The `upsert()` method inserts a row or updates it if a conflict occurs on the specified columns:

```php
// Insert or update user by email (unique constraint)
$driver->upsert('users', [
'email' => 'alice@example.com',
'name' => 'Alice',
'login_count' => 1
], ['email'], ['name', 'login_count']);

// Update all non-key columns on conflict (auto-detect)
$driver->upsert('users', $userData, ['email']);
```

**Database-specific SQL generated:**

| Database | SQL Pattern |
|------------|------------------------------------------------------------------|
| PostgreSQL | `INSERT ... ON CONFLICT (cols) DO UPDATE SET col = EXCLUDED.col` |
| MySQL | `INSERT ... ON DUPLICATE KEY UPDATE col = VALUES(col)` |
| MSSQL | Not supported (use `MERGE` statement directly) |

**Parameters:**

- `$table` โ€“ Table name
- `$row` โ€“ Associative array of column โ†’ value
- `$conflictColumns` โ€“ Columns that form the unique constraint
- `$updateColumns` โ€“ (Optional) Columns to update on conflict. If omitted, updates all non-conflict columns.

---

## Retry Policy

The driver supports automatic retry with exponential backoff for transient failures like connection drops, pool
exhaustion, and timeouts.

```php
$driver = Sqlx\DriverFactory::make([
Sqlx\DriverOptions::OPT_URL => 'postgres://user:pass@localhost/db',
Sqlx\DriverOptions::OPT_RETRY_MAX_ATTEMPTS => 3,
Sqlx\DriverOptions::OPT_RETRY_INITIAL_BACKOFF => '100ms',
Sqlx\DriverOptions::OPT_RETRY_MAX_BACKOFF => '5s',
Sqlx\DriverOptions::OPT_RETRY_MULTIPLIER => 2.0,
]);
```

### Retry Behavior

- **Disabled by default** โ€“ Set `OPT_RETRY_MAX_ATTEMPTS` > 0 to enable
- **Exponential backoff** โ€“ Each retry waits longer: 100ms โ†’ 200ms โ†’ 400ms โ†’ ...
- **Capped backoff** โ€“ Backoff never exceeds `OPT_RETRY_MAX_BACKOFF`
- **Transient errors only** โ€“ Only retries pool exhaustion, timeouts, and connection errors
- **No retry in transactions** โ€“ Retries are skipped inside `begin()` to prevent partial commits

### Transient vs Non-Transient Errors

| Error Type | Retried? | Examples |
|-------------------|----------|------------------------------------|
| Pool exhausted | โœ… Yes | All connections in use |
| Timeout | โœ… Yes | Connection or query timeout |
| Connection error | โœ… Yes | Connection dropped, network error |
| Query error | โŒ No | Syntax error, constraint violation |
| Transaction error | โŒ No | Deadlock, serialization failure |
| Parse error | โŒ No | Invalid SQL syntax |

---

## Error Handling

All errors thrown by the extension are instances of exception classes in the `Sqlx\Exceptions` namespace.
Each error type has its own exception class for precise `catch` handling:

```php
use Sqlx\Exceptions\{SqlxException, QueryException, ConnectionException};

try {
$driver->queryRow('SELECT * FROM non_existent_table');
} catch (QueryException $e) {
// Handle query-specific errors
echo "Query failed: " . $e->getMessage() . "\n";
} catch (ConnectionException $e) {
// Handle connection errors
echo "Connection failed: " . $e->getMessage() . "\n";
} catch (SqlxException $e) {
// Catch-all for any other sqlx errors
echo "Error: " . $e->getMessage() . "\n";
}
```

### Exception Classes

All exceptions extend `Sqlx\Exceptions\SqlxException`, which extends PHP's base `Exception`:

| Exception Class | Error Code | Description |
|------------------------------------------|------------|---------------------------------------------------|
| `Sqlx\Exceptions\SqlxException` | 0 | Base class / General error |
| `Sqlx\Exceptions\ConnectionException` | 1 | Database connection failed |
| `Sqlx\Exceptions\QueryException` | 2 | Query execution failed |
| `Sqlx\Exceptions\TransactionException` | 3 | Transaction-related error |
| `Sqlx\Exceptions\ParseException` | 4 | SQL parsing/AST error |
| `Sqlx\Exceptions\ParameterException` | 5 | Missing or invalid parameter |
| `Sqlx\Exceptions\ConfigurationException` | 6 | Configuration/options error |
| `Sqlx\Exceptions\ValidationException` | 7 | Invalid identifier or input validation error |
| `Sqlx\Exceptions\NotPermittedException` | 8 | Operation not permitted (e.g., write on readonly) |
| `Sqlx\Exceptions\TimeoutException` | 9 | Operation timed out |
| `Sqlx\Exceptions\PoolExhaustedException` | 10 | Connection pool exhausted |

Error codes are also available as constants on `SqlxException` for backwards compatibility:

```php
use Sqlx\Exceptions\SqlxException;

if ($e->getCode() === SqlxException::CONNECTION) {
// Handle connection error
}
```

---

## Parameter types

Supported parameter types:

```php
"text"
123
3.14
true
[1, 2, 3]
```

> โœ… PostgreSQL `BIGINT` values are safely mapped to PHP integers:
> ```php
> var_dump($driver->queryValue('SELECT ((1::BIGINT << 62) - 1) * 2 + 1');
> // Output: int(9223372036854775807)
>```

Nested arrays are automatically flattened and bound in order.

PostgreSQL/MySQL JSON types are automatically decoded into PHP arrays or objects.

```php
var_dump($driver->queryValue(
'SELECT $1::json',
['{"foo": ["bar", "baz"]}']
));
/* Output:
object(stdClass)#2 (1) {
["foo"]=>
array(2) {
[0]=>
string(3) "bar"
[1]=>
string(3) "baz"
}
}*/

var_dump($driver->queryRow(
'SELECT $1::json AS col',
['{"foo": ["bar", "baz"]}']
)->col->foo[0]);
// Output: string(3) "bar"
```

## Query Builder overview

> See the full [Query Builder guide](QUERY-BUILDER.md).

You can fluently build SQL queries using `$driver->builder()`:

```php
$query = $driver->builder()
->select("*")
->from("users")
->where(["active" => true])
->orderBy("created_at DESC")
->limit(10);
```

The builder supports most SQL clauses:

* `select()`, `from()`, `where()`, `groupBy()`, `orderBy()`, `having()`
* `insertInto()`, `values()`, `valuesMany()`, `returning()`
* `update()`, `set()`
* `deleteFrom()`, `using()`
* `with()`, `withRecursive()`
* `join()`, `leftJoin()`, `rightJoin()`, `fullJoin()`, `naturalJoin()`, `crossJoin()`
* `onConflict()`, `onDuplicateKeyUpdate()`
* `limit()`, `offset()`, `paginate()`
* `union()`, `unionAll()`
* `forUpdate()`, `forShare()`
* `truncateTable()`, `raw()`, `end()`

Each method returns the builder itself, allowing fluent chaining.

---

### Insert: Multi-row Example

Use `valuesMany()` to insert multiple rows in one statement:

```php
$driver->builder()->insert("users")->valuesMany([
["Alice", "alice@example.com"],
["Bob", "bob@example.com"]
]);

// or with named keys:
$driver->builder()->insert("users")->valuesMany([
["name" => "Alice", "email" => "alice@example.com"],
["name" => "Bob", "email" => "bob@example.com"]
]);
```

---

### Executing the Query

After building the query, you can run it just like with prepared statements:

```php
$query->execute();
// OR
$row = $query->queryRow();
// OR
$rows = $query->queryAll();
// OR iterate lazily
foreach ($query->query() as $row) {
echo $row->name . "\n";
}
```

You can also preview the rendered SQL and parameters without executing:

```php
var_dump((string) $query); // SQL with placeholders rendered
```

---

## API

### `Sql\DriverFactory`

```php
$driver = Sqlx\DriverFactory::make("postgres://user:pass@localhost/db");
```

Or with options:

```php
$driver = Sqlx\DriverFactory::make([
Sqlx\DriverOptions::OPT_URL => 'postgres://user:pass@localhost/db',
Sqlx\DriverOptions::OPT_ASSOC_ARRAYS => true, // return arrays instead of objects
Sqlx\DriverOptions::OPT_PERSISTENT_NAME => 'main_db'
Sqlx\DriverOptions::OPT_MAX_CONNECTIONS => 5,
Sqlx\DriverOptions::OPT_MIN_CONNECTIONS => 0,
//Sqlx\DriverOptions::OPT_MAX_LIFETIME => "30 min",
Sqlx\DriverOptions::OPT_IDLE_TIMEOUT => 120,
Sqlx\DriverOptions::OPT_ACQUIRE_TIMEOUT => 10,
]);

// With automatic retry for transient failures
$driver = Sqlx\DriverFactory::make([
Sqlx\DriverOptions::OPT_URL => 'postgres://user:pass@localhost/db',
Sqlx\DriverOptions::OPT_RETRY_MAX_ATTEMPTS => 3, // retry up to 3 times
Sqlx\DriverOptions::OPT_RETRY_INITIAL_BACKOFF => '100ms',
Sqlx\DriverOptions::OPT_RETRY_MAX_BACKOFF => '5s',
Sqlx\DriverOptions::OPT_RETRY_MULTIPLIER => 2.0, // exponential backoff
]);
```

DriverOptions reference

| Option | Type | Description | Default |
|-----------------------------|-------------------------|-------------------------------------------------------------------------------------|--------------|
| `OPT_URL` | `string` | **Required.** Database connection URL. Example: `postgres://user:pass@localhost/db` | *(required)* |
| `OPT_ASSOC_ARRAYS` | `bool` | If true, rows are returned as associative arrays instead of objects | `false` |
| `OPT_PERSISTENT_NAME` | `string \| null` | Enables persistent connection pool reuse under a given name | `null` |
| `OPT_MAX_CONNECTIONS` | `int > 0` | Maximum number of connections in the pool | `10` |
| `OPT_MIN_CONNECTIONS` | `int โ‰ฅ 0` | Minimum number of connections to keep alive | `0` |
| `OPT_MAX_LIFETIME` | `string \| int \| null` | Max lifetime of a pooled connection. Accepts `"30s"`, `"5 min"`, or seconds | `null` |
| `OPT_IDLE_TIMEOUT` | `string \| int \| null` | Idle timeout before closing pooled connections. Same format as above | `null` |
| `OPT_ACQUIRE_TIMEOUT` | `string \| int \| null` | Timeout to wait for a connection from the pool | `null` |
| `OPT_TEST_BEFORE_ACQUIRE` | `bool` | Whether to test connections before acquisition | `false` |
| `OPT_COLLAPSIBLE_IN` | `bool` | Enables automatic collapsing of `IN ()` / `NOT IN ()` into `FALSE` / `TRUE` | `true` |
| `OPT_AST_CACHE_SHARD_COUNT` | `int > 0` | Number of internal SQL AST cache shards (advanced tuning) | `8` |
| `OPT_AST_CACHE_SHARD_SIZE` | `int > 0` | Max number of entries per AST cache shard | `256` |
| `OPT_RETRY_MAX_ATTEMPTS` | `int โ‰ฅ 0` | Max retry attempts for transient failures (0 = disabled) | `0` |
| `OPT_RETRY_INITIAL_BACKOFF` | `string \| int` | Initial backoff between retries. Accepts `"100ms"`, `"1s"`, or seconds | `"100ms"` |
| `OPT_RETRY_MAX_BACKOFF` | `string \| int` | Maximum backoff duration (caps exponential growth) | `"5s"` |
| `OPT_RETRY_MULTIPLIER` | `float` | Backoff multiplier for exponential backoff (e.g., 2.0 doubles each retry) | `2.0` |

#### Basics

- `assocArrays(): bool` โ€“ returns **true** if the driver is currently set to produce associative arrays instead of
objects.
- `prepare(string $query): Sqlx\PreparedQuery` โ€“ returns a reusable prepared query object bound to the same driver.

#### Row helpers

| Method | Returns | Notes |
|------------------------|-------------------------------------|-------------------------------|
| `queryRow()` | first row (array \| object) | exception if no rows returned |
| `queryRowAssoc()` | first row (array) | โˆŸ enforces array mode |
| `queryRowObj()` | first row (object) | โˆŸ enforces object mode |
| `queryMaybeRow()` | first row (array \| object \| null) | null if no rows returned |
| `queryMaybeRowAssoc()` | first row (array \| null) | โˆŸ enforces array mode |
| `queryMaybeRowObj()` | first row (object \| null) | โˆŸ enforces object mode |

#### Column helpers (single-row)

| Method | Returns | Notes |
|--------------------------|--------------------------------|-----------------------------------------|
| `queryValue()` | first row column value | exception if no rows returned |
| `queryValueAssoc()` | โ†‘ | โˆŸ enforces array mode for JSON objects |
| `queryValueObj()` | โ†‘ | โˆŸ enforces object mode for JSON objects |
| `queryMaybeValue()` | first row column value or null | null if no rows returned |
| `queryMaybeValueAssoc()` | โ†‘ | โˆŸ enforces array mode for JSON objects |
| `queryMaybeValueObj()` | โ†‘ | โˆŸ enforces object mode for JSON objects |

#### Column helpers (multi-row)

| Method | Returns | Notes |
|----------------------|----------------------------------------|-----------------------------------------|
| `queryColumn()` | array of column's values from each row | exception if no rows returned |
| `queryColumnAssoc()` | โ†‘ | โˆŸ enforces array mode for JSON objects |
| `queryColumnObj()` | โ†‘ | โˆŸ enforces object mode for JSON objects |

#### List helpers (all rows)

| Method | Returns |
|-------------------|-----------------------|
| `queryAll()` | array of rows |
| `queryAllAssoc()` | array of assoc arrays |
| `queryAllObj()` | array of objects |

#### Iterator helpers (lazy streaming)

| Method | Returns | Notes |
|----------------|-------------------------------|--------------------------------|
| `query()` | `QueryResult` iterator | Uses driver's default row mode |
| `queryAssoc()` | `QueryResult` iterator | Forces associative arrays |
| `queryObj()` | `QueryResult` iterator | Forces objects |

The `query()` method returns a `QueryResult` object that implements PHP's `Iterator` interface.
Rows are streamed from the database on demand, providing true lazy loading that's memory-efficient
for large result sets:

```php
// Iterate over results - rows are streamed as you iterate
$result = $driver->query('SELECT * FROM large_table');
foreach ($result as $index => $row) {
echo $row->name . "\n";
}

// With parameters and custom buffer size
$result = $driver->query(
'SELECT * FROM users WHERE status = ?s',
['active'],
50 // buffer size (default: 100)
);

// Force associative arrays
$result = $driver->queryAssoc('SELECT * FROM users');
foreach ($result as $row) {
echo $row['name'] . "\n"; // $row is an array
}

// Convert to array (fetches all remaining rows)
$rows = $result->toArray();

// Check iteration state
echo $result->count(); // rows fetched so far
echo $result->getBatchSize(); // configured buffer size
$result->isExhausted(); // true when all rows consumed
```

The `QueryResult` class provides:
- `current()` / `key()` / `next()` / `rewind()` / `valid()` โ€“ Iterator interface
- `count()` โ€“ number of rows fetched so far
- `toArray()` โ€“ convert remaining rows to array
- `getBatchSize()` โ€“ get configured buffer size
- `isExhausted()` โ€“ check if all rows have been consumed
- `getLastError()` โ€“ get last error message if iteration stopped due to error

#### Mutation helpers

- `execute(string $query, array $parameters = null): int` โ€“ run **INSERT/UPDATE/DELETE** and return affected count.
- `insert(string $table, array $row): int` โ€“ convenience wrapper around `INSERT`.
- `insertMany(string $table, array $rows): int` โ€“ insert multiple rows in a single statement.
- `upsert(string $table, array $row, array $conflictColumns, ?array $updateColumns = null): int` โ€“ insert or update on
conflict.

#### Utilities

- `dry(string $query, array $parameters = null): array` โ€“ render final SQL + bound parameters without executing. Handy
for debugging.

#### Quoting helpers

- `quote(mixed $value): string` โ€“ quote a value as a SQL literal (e.g., `'O''Reilly'`, `123`, `TRUE`).
- `quoteLike(string $value): string` โ€“ quote with LIKE metacharacter escaping (`%` and `_` escaped).
- `quoteIdentifier(string $name): string` โ€“ quote an identifier (table/column name) using database-specific style.

```php
// PostgreSQL
$driver->quote("O'Reilly"); // 'O''Reilly'
$driver->quoteLike("100%_safe"); // '100\%\_safe'
$driver->quoteIdentifier("user"); // "user"

// MySQL
$driver->quoteIdentifier("user"); // `user`

// MSSQL
$driver->quoteIdentifier("user"); // [user]
```

#### Query Profiling

- `onQuery(?callable $callback): void` โ€“ registers a callback for query profiling/logging.

```php
// Enable query logging
$driver->onQuery(function(string $sql, string $sqlInline, float $durationMs) {
Logger::debug("Query took {$durationMs}ms: $sqlInline");
});

// Run some queries - the callback is called after each one
$users = $driver->queryAll('SELECT * FROM users WHERE status = $status', ['status' => 'active']);

// Disable the hook
$driver->onQuery(null);
```

The callback receives:

- `$sql` โ€“ The rendered SQL with placeholders (`SELECT * FROM users WHERE status = $1`)
- `$sqlInline` โ€“ The SQL with inlined values for logging (`SELECT * FROM users WHERE status = 'active'`)
- `$durationMs` โ€“ Execution time in milliseconds

**Performance**: When no hook is registered, there is zero overhead. Timing only starts when a hook is active.

#### Connection Tagging

Tag connections with metadata for easier debugging in database monitoring tools:

- `setApplicationName(string $name): void` โ€“ sets the application name for this connection.
- `setClientInfo(string $applicationName, array $info): void` โ€“ sets application name with additional metadata.

```php
// Simple application name
$driver->setApplicationName('order-service');

// With additional context (useful for debugging)
$driver->setClientInfo('order-service', [
'request_id' => $requestId,
'user_id' => $userId,
]);
// Result: "order-service {request_id='abc123',user_id=42}"
```

**Database-specific visibility**:

- PostgreSQL: Visible in `pg_stat_activity.application_name`
- MySQL: Stored in session variable `@sqlx_application_name` (queryable via `SELECT @sqlx_application_name`)
- MSSQL: Stored in session context (queryable via `SELECT SESSION_CONTEXT(N'application_name')`)

#### Read Replicas

Configure read replicas for automatic read/write splitting:

```php
$driver = Sqlx\DriverFactory::make([
Sqlx\DriverOptions::OPT_URL => 'postgres://user:pass@primary/db',
Sqlx\DriverOptions::OPT_READ_REPLICAS => [
'postgres://user:pass@replica1/db',
'postgres://user:pass@replica2/db',
],
]);

// SELECT queries automatically route to replicas (round-robin)
$users = $driver->queryAll('SELECT * FROM users');

// Write operations always go to primary
$driver->execute('INSERT INTO users (name) VALUES (?s)', ['John']);
```

**Weighted load balancing:** Assign weights to control traffic distribution:

```php
$driver = Sqlx\DriverFactory::make([
Sqlx\DriverOptions::OPT_URL => 'postgres://user:pass@primary/db',
Sqlx\DriverOptions::OPT_READ_REPLICAS => [
['url' => 'postgres://user:pass@replica1/db', 'weight' => 3], // 75% traffic
['url' => 'postgres://user:pass@replica2/db', 'weight' => 1], // 25% traffic
],
]);
```

**Routing rules:**

- `queryAll`, `queryRow`, `queryMaybeRow`, `queryValue`, `queryColumn` โ†’ replicas
- `execute` โ†’ primary
- All queries inside transactions โ†’ primary (consistency guarantee)

**Load balancing:** Weighted round-robin (or simple round-robin if all weights equal).

```php
// Check if replicas are configured
if ($driver->hasReadReplicas()) {
echo "Read queries are load balanced across replicas";
}
```

#### Schema Introspection

- `describeTable(string $table, ?string $schema = null): array` โ€“ returns column metadata for the specified table.

```php
$columns = $driver->describeTable('users');
// Returns:
// [
// ['name' => 'id', 'type' => 'integer', 'nullable' => false, 'default' => null, 'ordinal' => 1],
// ['name' => 'email', 'type' => 'varchar(255)', 'nullable' => false, 'default' => null, 'ordinal' => 2],
// ['name' => 'created_at', 'type' => 'timestamp', 'nullable' => true, 'default' => 'now()', 'ordinal' => 3],
// ]

// With explicit schema
$columns = $driver->describeTable('users', 'public');
```

Each column entry contains:

- `name` โ€“ Column name (string)
- `type` โ€“ Database-specific type (string), e.g., `varchar(255)`, `integer`, `timestamp`
- `nullable` โ€“ Whether `NULL` is allowed (bool)
- `default` โ€“ Default value expression (string|null)
- `ordinal` โ€“ 1-based column position (int)

---

### Sqlx\PreparedQuery

Prepared queries expose exactly the same surface as the driver, but without the SQL argument:

```php
$query = $driver->prepare('SELECT * FROM logs WHERE level = $level');
$rows = $query->queryAll(['level' => 'warn']);

// Or iterate lazily
foreach ($query->query(['level' => 'warn']) as $row) {
echo $row->message . "\n";
}
```

All helpers listed above have their prepared-query counterparts:

- `execute()`
- `queryRow()` / `queryRowAssoc()` / `queryRowObj()`
- `queryAll()` / `queryAllAssoc()` / `queryAllObj()`
- `query()` / `queryAssoc()` / `queryObj()` โ€“ returns `QueryResult` iterator
- `queryDictionary()` / `queryDictionaryAssoc()` / `queryDictionaryObj()`
- `queryGroupedDictionary()` / `queryGroupedDictionaryAssoc()` / `queryGroupedDictionaryObj()`
- `queryColumnDictionary()` / `queryColumnDictionaryAssoc()` / `queryColumnDictionaryObj()`

---

### Dictionary helpers (first column as key, row as value)

| Method | Returns | Notes |
|--------------------------|-----------------------------------------|----------------------------------------|
| `queryDictionary()` | `array` | key = first column, value = entire row |
| `queryDictionaryAssoc()` | `array` | โˆŸ forces associative arrays |
| `queryDictionaryObj()` | `array` | โˆŸ forces objects |

> - โš ๏ธ First column **must** be scalar, otherwise an exception will be thrown.
> - ๐Ÿ”€ The iteration order is preserved.

```php
var_dump($driver->queryGroupedColumnDictionary(
'SELECT department, name FROM employees WHERE department IN (?)',
[['IT', 'HR']]
));
/* Output:
array(2) {
["IT"]=> array("Alice", "Bob")
["HR"]=> array("Eve")
}
*/
```

---

### Column Dictionary helpers (first column as key, second as value)

| Method | Returns | Notes |
|--------------------------------|-------------------------------|---------------------------------------------------------|
| `queryColumnDictionary()` | `array` | key = first column, value = second column |
| `queryColumnDictionaryAssoc()` | โ†‘ | โˆŸ enforces array mode for second column if it's a JSON |
| `queryColumnDictionaryObj()` | โ†‘ | โˆŸ enforces object mode for second column if it's a JSON |

```php
var_dump($driver->queryColumnDictionary(
'SELECT name, age FROM people WHERE name IN (?)',
[["Peter", "John", "Jane"]]
));
/* Output:
array(1) {
["John"]=>
int(22)
}
*/
```

---

### Grouped Dictionary helpers (first column as key, many rows per key)

| Method | Returns | Notes |
|---------------------------------|-----------------------------------------|---------------------------------------------------|
| `queryGroupedDictionary()` | `array>` | key = first column, value = list of matching rows |
| `queryGroupedDictionaryAssoc()` | `array` | โˆŸ forces associative arrays |
| `queryGroupedDictionaryObj()` | `array>` | โˆŸ forces objects |

```php
var_dump($driver->queryGroupedDictionary(
'SELECT department, name FROM employees WHERE department IN (?)',
[['IT', 'HR']]
));
/* Output:
array(1) {
["IT"]=>
array(2) {
[0]=>
object(stdClass)#2 (2) {
["department"]=>
string(2) "IT"
["name"]=>
string(5) "Alice"
}
[1]=>
object(stdClass)#3 (2) {
["department"]=>
string(2) "IT"
["name"]=>
string(3) "Bob"
}
}
}
*/
```

---

## Interfaces

The extension provides native PHP interfaces defined in Rust using the `#[php_interface]` macro from ext-php-rs.
These interfaces work seamlessly with PHP's `instanceof` checks, type hints, and IDE auto-completion.

### Available Interfaces

| Interface | Implementing Classes | Description |
|-----------------------------------|---------------------------------------------------------------------------|------------------------------|
| `Sqlx\DriverInterface` | `PgDriver`, `MySqlDriver`, `MssqlDriver` | Database driver contract |
| `Sqlx\PreparedQueryInterface` | `PgPreparedQuery`, `MySqlPreparedQuery`, `MssqlPreparedQuery` | Prepared statement contract |
| `Sqlx\ReadQueryBuilderInterface` | `PgReadQueryBuilder`, `MySqlReadQueryBuilder`, `MssqlReadQueryBuilder` | Read query builder contract |
| `Sqlx\WriteQueryBuilderInterface` | `PgWriteQueryBuilder`, `MySqlWriteQueryBuilder`, `MssqlWriteQueryBuilder` | Write query builder contract |

```php
use Sqlx\DriverInterface;
use Sqlx\PreparedQueryInterface;
use Sqlx\WriteQueryBuilderInterface;
use Sqlx\ReadQueryBuilderInterface;

// Type hints work as expected
function runQuery(DriverInterface $driver): void {
$rows = $driver->queryAll('SELECT * FROM users');
// ...
}

// instanceof checks work
$driver = Sqlx\DriverFactory::make('postgres://...');
assert($driver instanceof DriverInterface);

$prepared = $driver->prepare('SELECT * FROM users WHERE id = ?');
assert($prepared instanceof PreparedQueryInterface);

$builder = $driver->builder();
assert($builder instanceof WriteQueryBuilderInterface);

$readBuilder = $driver->readBuilder();
assert($readBuilder instanceof ReadQueryBuilderInterface);
```

### Design Philosophy

These interfaces follow the [Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle),
allowing you to depend on abstractions rather than concrete implementations:

```php
// Your code depends on the interface, not the concrete driver
function fetchUsers(Sqlx\DriverInterface $driver): array {
return $driver->queryAll('SELECT * FROM users');
}

// Works with any driver implementation
$pgDriver = Sqlx\DriverFactory::make('postgres://...');
$mysqlDriver = Sqlx\DriverFactory::make('mysql://...');

fetchUsers($pgDriver); // Works
fetchUsers($mysqlDriver); // Also works
```

This makes it easier to:
- Write database-agnostic code
- Mock drivers in unit tests
- Switch between database backends

---

## Performance

Well, it's fast. Nothing like similar projects written in userland PHP.

The AST cache eliminates repeated parsing overhead and speeds up query rendering.

JSON decoding is lazy (on-demand) with optional [SIMD](https://docs.rs/simd-json/latest/simd_json/) support.

### Rust benchmark suite

It is useful for measuring the performance of backend parts such as AST parsing/rendering.

Command:

```shell
cargo bench
```

M1 Max results for parsing and rendering **without** AST caching:

```
Ast::parse_small time: [2.2591 ยตs 2.2661 ยตs 2.2744 ยตs]
Found 5 outliers among 100 measurements (5.00%)
3 (3.00%) high mild
2 (2.00%) high severe

Ast::parse_big time: [6.6006 ยตs 6.6175 ยตs 6.6361 ยตs]
Found 9 outliers among 100 measurements (9.00%)
5 (5.00%) high mild
4 (4.00%) high severe

Ast::render_big time: [607.02 ns 608.43 ns 610.11 ns]
Found 8 outliers among 100 measurements (8.00%)
4 (4.00%) high mild
4 (4.00%) high severe
```

### PHP benchmarks

Run:

```shell
cd benches
curl -s https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer | php -- --quiet
./composer.phar require phpbench/phpbench --dev
./vendor/bin/phpbench run benchmark.php
```

Or use Docker:

```shell
docker build . -t php-sqlx-benches
docker run php-sqlx-benches
```

M1 Max results for parsing and rendering **with** AST caching:

```
benchDrySmall...........................I0 - Mo1.246ฮผs (ยฑ0.00%)
benchDryBig.............................I0 - Mo2.906ฮผs (ยฑ0.00%)
```

## Running Tests

```bash
cargo test
```

---

## Fuzzing

The AST parser can be fuzzed to find edge cases and potential security issues:

```bash
# Install cargo-fuzz (requires nightly)
cargo install cargo-fuzz

# Run the PostgreSQL parser fuzzer
cargo +nightly fuzz run ast_postgres

# Run for 60 seconds
cargo +nightly fuzz run ast_postgres -- -max_total_time=60
```

Available fuzz targets:

- `ast_postgres` - PostgreSQL parser
- `ast_mysql` - MySQL parser
- `ast_mssql` - MSSQL parser
- `ast_render` - Parser + renderer with random parameters

See [fuzz/README.md](fuzz/README.md) for more details.

---

## IDE Support

Syntax highlighting for conditional blocks (`{{ }}`), type-safe placeholders (`?i`, `?s`), and named parameters.

### VS Code

Install the extension from `editors/vscode`:

```bash
cd editors/vscode
npx vsce package
# Then install the .vsix file in VS Code
```

Or manually copy/symlink to `~/.vscode/extensions/php-sqlx`.

Features:

- Highlights `{{ }}` conditional blocks
- Highlights typed placeholders (`?i`, `?ni`, `?ia`, `$name!s`)
- Auto-injects into PHP strings starting with SQL keywords

See [editors/vscode/README.md](editors/vscode/README.md) for details.

### PHPStorm / IntelliJ

Import the language injection configuration:

1. **Settings** > **Editor** > **Language Injections**
2. Click **Import** and select `editors/phpstorm/IntelliLang.xml`

Also includes live templates for common patterns (`sqlxq`, `sqlxcond`, `sqlxtx`).

See [editors/phpstorm/README.md](editors/phpstorm/README.md) for details.

---

## Testing

### Running Unit Tests (Rust)

```bash
cargo test --all-features
```

### Running Integration Tests (PHPUnit)

Start the test databases with Docker Compose:

```bash
docker-compose up -d
```

Install PHPUnit and run tests:

```bash
cd tests
composer install
vendor/bin/phpunit # Run all tests
vendor/bin/phpunit --testsuite PostgreSQL # PostgreSQL only
vendor/bin/phpunit --testsuite MySQL # MySQL only
vendor/bin/phpunit --testsuite MSSQL # MSSQL only
```

#### Test Configuration

Database URLs are configured in `tests/phpunit.xml`:

```xml

```

#### Test Coverage

The integration tests cover:

- Connection handling
- Basic queries (queryAll, queryRow, queryMaybeRow, queryValue, queryColumn)
- Named and positional parameters
- Type-safe placeholders
- IN clause expansion
- Conditional blocks
- Transactions (commit, rollback, callback)
- Schema introspection (describeTable)
- Query hooks
- Connection tagging
- Database-specific features (RETURNING, OUTPUT, JSON types, etc.)

---

## License

MIT