Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
dc2311b
wip: add MySQL/MariaDB design spec
hlubek Jun 30, 2026
523148f
wip: mysql foundation + expression layer
hlubek Jun 30, 2026
187a445
wip: mysql CASE expression
hlubek Jun 30, 2026
26261f9
wip: mysql SELECT builder (joins, grouping, ordering, limit, locking,…
hlubek Jun 30, 2026
4158f8d
wip: model MySQL functions via facade (not operator-style); dialect-n…
hlubek Jun 30, 2026
9b6b069
wip: spec - dialect-native design principle (natural look, self-stand…
hlubek Jun 30, 2026
d7e2a41
wip: AGENTS.md - dialect-native design convention (operators vs funct…
hlubek Jun 30, 2026
2f3aae9
wip: mysql window functions (OVER, named WINDOW, frame clauses, windo…
hlubek Jun 30, 2026
1424de6
wip: mysql INSERT builder (values, setMap, select, IGNORE, ON DUPLICA…
hlubek Jun 30, 2026
23c4e37
wip: mysql REPLACE builder (values, setMap, select, default values)
hlubek Jun 30, 2026
0870c20
wip: mysql UPDATE builder (single + multi-table joins, ORDER BY/LIMIT…
hlubek Jun 30, 2026
167cf35
wip: mysql DELETE builder (single + multi-table, ORDER BY/LIMIT, WITH…
hlubek Jun 30, 2026
642a513
wip: mysql special-shape functions (GROUP_CONCAT, EXTRACT, INTERVAL, …
hlubek Jun 30, 2026
154aae3
wip: mysql curated Q\Func set (string/regexp/numeric/datetime/json/mi…
hlubek Jun 30, 2026
2487cfe
wip: mysql-only Q\Func functions (regexpLike/grouping/anyValue/json s…
hlubek Jun 30, 2026
a7a1202
wip: extract AbstractSelectBuilder (shared SELECT logic + clause help…
hlubek Jul 1, 2026
55e3f5b
wip: extract shared SELECT subbuilder traits (aliases/join/rollup/ord…
hlubek Jul 1, 2026
b7b63fb
wip: MariaDB SELECT ladder (LOCK IN SHARE MODE, no lateral/of) + Mari…
hlubek Jul 1, 2026
38088dc
wip: DML abstract bases (INSERT/REPLACE/DELETE) + MariaDB DML with RE…
hlubek Jul 1, 2026
2522638
wip: MariaDB Q\Func via shared SharedFunctions trait (+ MariaDB-only …
hlubek Jul 1, 2026
27ae328
wip: comment sweep — self-standing docblocks (drop cross-dialect fram…
hlubek Jul 1, 2026
99b5c4f
wip: MySQL/MariaDB usage docs (docs/mysql-mariadb.md) + README dialec…
hlubek Jul 1, 2026
a8e8959
wip: mark MySQL/MariaDB port implemented in design-doc status header
hlubek Jul 1, 2026
982d812
wip: dialect-validation plumbing (Dialect, Target, SqlBuilder::requir…
hlubek Jul 1, 2026
01cd7e7
wip: MySQL vs MariaDB structural-differences reference (docs/mysql-ma…
hlubek Jul 1, 2026
d9e6631
wip: collapse MySQL+MariaDB into one dialect-validated builder (delet…
hlubek Jul 1, 2026
dabc707
wip: full fold — inline single-use subbuilder traits into their build…
hlubek Jul 1, 2026
0bfa650
wip: docs for the unified MySQL-family builder + target validation (m…
hlubek Jul 1, 2026
d20864f
wip: fold MySQL-only function coverage into DialectDifferencesTest §C…
hlubek Jul 1, 2026
168f492
wip: add Requirement::mysql()/mariaDb() named constructors; use them …
hlubek Jul 1, 2026
9bca3d1
wip: bitwise operators (& | ^ << >> on ExpBase, ~ via Q::bitNot) with…
hlubek Jul 1, 2026
f7f64e9
wip: MEMBER OF operator (ExpBase::memberOf → value MEMBER OF (json_ar…
hlubek Jul 1, 2026
821cd04
wip: PERCENTILE_CONT/DISC ordered-set aggregates (MariaDB-gated) — Or…
hlubek Jul 1, 2026
8677808
wip: JSON_TABLE FROM-clause table function (Q::jsonTable -> JsonTable…
hlubek Jul 1, 2026
22e98ea
wip: mark bitwise / MEMBER OF / PERCENTILE_CONT+DISC / JSON_TABLE as …
hlubek Jul 1, 2026
aeee488
wip: JSON_TABLE column API redesign — recursive JsonTableColumns via …
hlubek Jul 1, 2026
6580bff
wip: MySQL JSON-shape selection tests + AggBuilder DISTINCT gating; d…
hlubek Jul 1, 2026
1abf7d4
wip: broaden test coverage across MySQL + PostgreSQL builders (→ ~98.6%)
hlubek Jul 1, 2026
6b54c46
wip: cover remaining branch gaps (Keywords/Requirement/WindowDefiniti…
hlubek Jul 1, 2026
6759cd9
MySQL/MariaDB JSON parity + two-family README
hlubek Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
vendor
.phpunit.cache
tmp
98 changes: 96 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
# Query Object Builder

A fluent, immutable SQL query builder for PHP 8.4+. The public API is a
per-dialect facade (currently PostgreSQL); the query model and rendering live in
a `Builder` sub-namespace.
per-family facade — `PostgreSQL\Q` and `MySQL\Q` (the MySQL family covers both
MySQL and MariaDB in one builder); the query model and rendering live in each
family's `Builder` sub-namespace.

The MySQL family is a single builder for both engines: rendering is fully
determined by construction (never a dialect flag), engine-divergent constructs are
built their own way, and each such construct calls `$sb->requireDialect(...)` /
`requireAnyDialect(...)` while rendering so an opt-in
`Q::build($q)->withValidateTarget(Target::mysql()|mariaDb())` pass reports the
constructs the target cannot express. `Target` carries an optional version for
version-gated features. See `docs/mysql-mariadb.md` and the
`docs/mysql-mariadb-differences.md` catalogue.

The design adapts the Go package `github.com/networkteam/qrb`. We port its
patterns, but **never mention Go in the PHP code or comments** (see *Comments*).
Expand All @@ -19,6 +29,10 @@ patterns, but **never mention Go in the PHP code or comments** (see *Comments*).
- `tests/` — Pest tests, the `tests/Pest.php` bootstrap, and small `readonly`
option fixtures.

Each family has its own top-level namespace (`src/PostgreSQL/`, `src/MySQL/`)
mirroring this layout; the paths above show the PostgreSQL one. The two do not
share types — a query built with one is rendered by its own `QueryBuilder`.

## Conventions

### Immutability — the core invariant
Expand Down Expand Up @@ -56,6 +70,14 @@ immutability guarantee for build-time micro-optimization.
The user-facing surface is the facades (`Q`, `Q\Func`), the fluent builder
methods, the expression objects they return, and `QueryBuilder::toSql()`.

**`Q\Func` is the *expression* function facade: every method returns an `Exp`**
(directly, or a builder that is an `Exp`) — something usable anywhere an
expression is valid (SELECT list, `WHERE`, `ON`, an argument, …). A construct that
is *not* a general expression — a statement, a clause, or a FROM-only producer
like `JSON_TABLE` (its builder is a `FromExp`, not an `Exp`) — belongs on the `Q`
facade, next to `select`/`from`/`with` and the other constructs, **not** on
`Q\Func`. (This is why `Q::jsonTable()` and PG's `Q::rowsFrom()` live on `Q`.)

The **rendering contract is internal**: `SqlWriter` / `InnerSqlWriter`, every
`writeSql()` method, and `SqlBuilder` are plumbing — users never implement or
call them. Mark them, and the value-object state holders, `@internal`.
Expand All @@ -77,10 +99,64 @@ or at the very end. The cost on PHP hot paths is call overhead, not
concatenation, so fewer `writeString()` calls is the win.
`SelectBuilder::writeSelectParts()` is the reference.

### Validation errors

Errors found while rendering are collected on `SqlBuilder` (`$sb->addError(...)`)
and thrown together as one `QueryBuilderException` by `QueryBuilder::toSql()` — a
`writeSql()`/`innerWriteSql()` never throws directly. Which of the two `addError`
shapes to use is decided by a single question: **is there still a well-formed
statement to emit?**

- **Advisory value validation — gate on `$sb->isValidating()`, then keep
rendering.** The statement *shape* is well-formed but one *value or modifier* is
suspect: an invalid identifier (`IdentExp`), an unknown type (`TypeExp`), an
empty `CASE` (`CaseExp`), `DISTINCT` on an aggregate whose grammar rejects it
(`AggBuilder`). Add the error but **do not `return`** — emit the text anyway, so
the SQL is fully determined and `Q::build($q)->withoutValidation()` is the escape
hatch that lets a caller who knows better ship it (the server is the final
judge). These are the only checks `withoutValidation()` suppresses.
- **Mutually-exclusive builder state — always `addError` (never gated) and
`return`.** Two builder options cannot coexist in one statement, so there is no
shape to render: `LATERAL` + `ONLY` (`FromItem`), `values` + `query`
(`InsertBuilder` / `ReplaceBuilder`), an `ON CONFLICT` constraint name +
targets, `WITH ORDINALITY` + a column-definition list (`FuncBuilder`),
multi-table `DELETE`/`UPDATE` + a single-target `ORDER BY`/`LIMIT`. This is
builder-API misuse, not an invalid value, so `withoutValidation()` must not mask
it — it always throws.

Target/dialect gating is a third, separate mechanism: `$sb->requireDialect(...)` /
`requireAnyDialect(...)` report a construct the validated target cannot express.
It is opt-in via `Q::build($q)->withValidateTarget(...)` and keys off the target,
not `isValidating()`.

### Dialect-native design

Each dialect's facade and builders model *that dialect's own* SQL; a dialect is
never built as a diff against another.

- **Natural look.** The fluent API mirrors how the SQL reads. What the dialect
spells as an **operator** (comparisons, arithmetic, `LIKE`, `IS NULL`, plus
dialect-specific ones like PG `::`/`||` or MySQL `<=>`/`->`) is a chainable
method on the expression base; what reads as a **function** (`CONCAT`, `POW`,
`CAST`, `JSON_CONTAINS`, …) is constructed through the facade (`Q::func` /
`Q::cast` / `Q\Func`), not an operator-style chained method that merely emits a
function. Don't copy another dialect's expression surface — model the operator
set the dialect actually has.
- **No dialect is the baseline.** When carrying a pattern across dialects, keep
the structure (immutability, type-state, `derive()`, `writeSql`) but re-derive
the API and SQL from the target dialect's grammar — drop what it lacks, add what
it has.

### Comments

- No references to the Go port; no "what PHP can't do that Go can" explanations;
no TODO / "not yet supported" lists.
- No cross-*family* framing: never describe the code relative to the other family
("PostgreSQL has X", "no FULL JOIN here", "unlike PG") — same spirit as the no-Go
rule above. **Within** the MySQL family this does not apply to engine
divergences: a construct that only one engine accepts should say so plainly
(e.g. "the OF table list is a MySQL extension", "RETURNING is MariaDB-only"),
since that is exactly the `requireDialect(...)` validation contract.
- Facade and fluent-API methods: short, user-oriented docs — especially gotchas
("Multiple calls are joined with AND", "the JSON selection is always the first
select element").
Expand Down Expand Up @@ -128,3 +204,21 @@ vendor/bin/phpstan analyse

Both must pass for any change. (PHPStan config — `level: max`, paths, and the
Pest extension — lives in `phpstan.neon`.)

### Coverage

`XDEBUG_MODE=coverage vendor/bin/pest --coverage` (Xdebug is the driver; add
`--coverage-clover=<file>` for a per-line report). The suite covers ~98%; aim to
keep every builder class and public method exercised. Two testable patterns worth
knowing: advisory value checks (invalid `IdentExp`/`TypeExp`, empty `CaseExp`,
`DISTINCT` on an aggregate that rejects it) throw when built **and** still render
under `Q::build($q)->withoutValidation()` — assert both; mutually-exclusive builder
state (`values`+`query`, `ON CONFLICT` constraint+targets) always throws, even
without validation.

A handful of lines are **intentionally uncovered — don't chase them**: the private
constructors of the static facades (`Q`, `Q\Func`, `Literals`, `Precedence`,
`Keywords`), the `writeSql()` one-line delegators on the statement builders (they
are `InnerSqlWriter`s, so `QueryBuilder::toSql()` only ever calls `innerWriteSql()`),
and value-object guards with no fluent path to reach them (e.g. a `FromItem` that is
both `LATERAL` and `ONLY`).
Loading
Loading