From dc2311b3e6f6159f4d06df5dfdbb6bef70bf6106 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:10:09 +0200 Subject: [PATCH 01/40] wip: add MySQL/MariaDB design spec Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mysql-mariadb-design.md | 468 +++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/mysql-mariadb-design.md diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md new file mode 100644 index 0000000..545061d --- /dev/null +++ b/docs/mysql-mariadb-design.md @@ -0,0 +1,468 @@ +# MySQL / MariaDB dialect — design spec + +Blueprint for adding MySQL + MariaDB query building alongside the existing +PostgreSQL builder. Synthesised from the official-doc research sweep; the +exhaustive per-area findings live next to this file's source material (the +`scratchpad/research/*.md` extracts: `select`, `insert`, `replace`, `update`, +`delete`, `with-cte`, `expressions`, `functions-mysql`, `functions-mariadb`). + +**Version anchors:** MySQL **8.4 (LTS)**, MariaDB **11.x GA** (11.8 LTS). +Every feature gated below the anchor is treated as *not available*. + +> Status: the **Architecture** section is a recommendation pending confirmation. +> Everything else (the keep/drop/replace/add tables, function set, version gates) +> is dialect-fact and independent of that decision. + +--- + +## 1. Key findings that shape the design + +1. **MySQL and MariaDB share rendering primitives.** Both quote identifiers with + backticks `` `x` ``, bind with positional `?`, and escape strings with + backslash *and* doubled quotes. So at the `SqlBuilder` / leaf-expression level + there is **one** MySQL-family dialect, not two. (Contrast PostgreSQL: `"x"`, + `$1`, `E'...'`.) +2. **No containment between the two engines.** MySQL-only: `LATERAL`, `FOR SHARE` + + `OF tbl`, `REGEXP_LIKE()`, `GROUPING()`, `ANY_VALUE()`, `JSON_SCHEMA_*`, + `JSON_PRETTY`, the `AS new` upsert row-alias, `MEMBER OF`. MariaDB-only: + `RETURNING` (INSERT/REPLACE/DELETE), `CYCLE`, `OFFSET..FETCH`, `ROWS EXAMINED`, + `WAIT n`, `LOCK IN SHARE MODE` as the only shared lock, `JSON_QUERY`, + `JSON_DETAILED`, `MEDIAN`, `PERCENTILE_CONT/DISC`, Oracle-compat funcs. So the + split must be modelled per-feature, not as inheritance. +3. **The split surface is small and of two kinds:** + - **Render-time keyword choice** — e.g. `FOR SHARE` (MySQL) vs + `LOCK IN SHARE MODE` (MariaDB). Same builder method, dialect-branched + `writeSql`. + - **Feature availability** — e.g. `LATERAL` (MySQL-only), `RETURNING` + (MariaDB-only). The capability simply does not exist on the other engine. +4. **The expression/operator layer is a rewrite, not a port.** Most PG operators + become **function calls** in MySQL/MariaDB: `::`→`CAST(x AS t)`, `||`→`CONCAT`, + `^`→`POW`, `@>`→`JSON_CONTAINS`, `#>`/`#>>`→`JSON_EXTRACT`/`JSON_UNQUOTE`. + A handful are dropped (`ILIKE`, `SIMILAR TO`, POSIX `~`-family as distinct + case variants) and a few added (`<=>`, `REGEXP`, `MEMBER OF`). Operator + precedence differs and needs its own table. + +--- + +## 2. Architecture (proposed) + +A **new namespace family** (`MySQL` + `MariaDB`), built by **duplicating the +PostgreSQL builder and adapting it** — *not* by extracting a shared `Common/` +core yet. (Rule of three: PG is the first dialect, MySQL the second; extract the +shared plumbing only once two working dialects exist to factor against.) + +``` +src/MySQL/ + Q.php # MySQL facade (static, like PostgreSQL\Q) + Q/Func.php # MySQL function facade + Builder/ # shared primitives + machinery + abstract base builders + # + the MySQL concrete builder ladder +src/MariaDB/ + Q.php # MariaDB facade + Q/Func.php # MariaDB function facade (+ JSON_QUERY/MEDIAN/PERCENTILE_*) + Builder/ # the MariaDB concrete builder ladder (RETURNING, …), + # reusing the primitives + abstract bases from src/MySQL/Builder +``` + +### Variant modelling — decided: per-variant subclasses (compile-time safe) + +Each engine gets its own concrete builder ladder, so a method that is invalid on +an engine **does not exist** on that engine's builder — misuse is a type error +(IDE + PHPStan), not a runtime surprise. Consequences: + +- **No `MySqlVariant` flag, no build-time gating.** Class identity carries the + dialect: render-time splits become per-class `writeSql` (`FOR SHARE` vs + `LOCK IN SHARE MODE`; `new.col` vs `VALUES(col)`); availability splits are just + the presence/absence of a method. This *removes* a runtime flag and a set of + validation branches the unified approach would have needed — so the net + complexity gap is smaller than the class count suggests. +- **Only the builder ladder forks; the plumbing is shared.** The rendering + primitives (`SqlBuilder` with backtick/`?`/escaping, `Keywords`, `Literals`, + `IdentExp`) and the machinery (`SqlWriter`/`InnerSqlWriter`/`QueryBuilder`/ + `Precedence`/structural value objects) are mechanically identical for both + engines and live **once** in `src/MySQL/Builder/`. `src/MariaDB/Builder/` adds + only the subclasses that genuinely differ. +- **Shared logic stays single-sourced.** Generic clause logic + `derive()` + + rendering live on an abstract base builder (+ traits); per-variant classes are + mostly covariant return-type shims plus the 2-3 methods that actually diverge + (MySQL: `fromLateral`/`joinLateral`/…/`of()`; MariaDB: `returning()` on + INSERT/REPLACE/DELETE). What's duplicated is *signatures*, not behaviour. +- **Cost:** ~20-25 extra thin classes (the SELECT + DML ladders fork because the + type-state subbuilders `extends` the base). Accepted deliberately: it lets the + engines diverge freely as they drift (MariaDB 12.3 WITH-before-UPDATE, MariaDB + 13 UPDATE RETURNING, MySQL-only optimizer features) without threading version + flags through shared code. + +The exact subbuilder hierarchy (how far the MySQL-only `lateral` methods thread +down the transition chain vs. living only on the entry builder) is settled in the +SELECT implementation stage. + +### Rendering primitives (the MySQL-family `SqlBuilder` / leaves) + +| Concern | MySQL family | +|---|---| +| Identifier quote | backtick `` `x` ``, internal `` ` `` doubled; keyword list = MySQL/MariaDB reserved words (new list, not PG's) | +| Placeholder | positional `?` — **note: not reusable**, so `Q::bind()` name-reuse must emit `?` per occurrence (or bind the value once per `?`) rather than reuse a numbered slot | +| String literal | `'...'`, double `''` and escape `\` → `\\` (default sql_mode) | +| Identifier validity regex | MySQL identifier rules (differs from PG — e.g. leading digits allowed, `$` allowed) | + +`SqlWriter` / `InnerSqlWriter` / `QueryBuilder` / `Precedence` infrastructure is +copied from PG unchanged in shape (Precedence gets a new operator map, §7). + +--- + +## 3. The three-dimensional clause model + +Every clause is judged on three axes (recap of the agreed rubric): + +1. **Compat** — supported in MySQL? MariaDB? (keep / drop / replace). +2. **Variant** — MySQL-only / MariaDB-only / both. +3. **Scope** — *should we expose it*: **include** (shapes the logical query) / + **defer** (niche, cheap to add later) / **exclude** (hint, side-effect, or + admin concern; reachable via `Q::func`/raw if ever needed). + +Scope verdicts applied below: +- **Exclude:** `INTO OUTFILE/DUMPFILE/@var`, priority modifiers + (`LOW_PRIORITY`/`HIGH_PRIORITY`/`DELAYED`/`QUICK`), `SQL_*` result modifiers, + `SQL_CALC_FOUND_ROWS`, `PROCEDURE`, `ROWS EXAMINED`, `WAIT n`, `FOR PORTION OF`. +- **Defer:** `PARTITION (...)` selection, index hints, `STRAIGHT_JOIN`, + `NATURAL JOIN`, MariaDB `OFFSET..FETCH`/`CYCLE`, `INSERT ... SET` form, + MySQL `VALUES ROW()`/`TABLE` source forms. +- **Include:** everything that shapes the query (see per-statement tables). + +--- + +## 4. Per-statement plans + +Condensed; full clause-by-clause tables and verbatim grammar in the research +extracts. "drop" = PG-only, removed; "replace" = same intent, different SQL; +"add" = new for MySQL/MariaDB. + +### SELECT + +- **Keep:** `select`/`distinct`, `from`+`as`+derived-table `columnAliases`, + `join`/`leftJoin`/`rightJoin`/`crossJoin` + `on`/`using`/`as`, `where`, + `groupBy` (plain), `having`, named `window`, `orderBy` + `asc`/`desc`, + `limit`/`offset`, `union`/`intersect`/`except` + `all()`/`query()`, + `forUpdate` + `nowait`/`skipLocked`, `with`/`withRecursive`, the + `derive()`/`isEmpty()`/`writeSelectParts()` plumbing. +- **Drop (PG-only):** `DISTINCT ON`, `fromOnly` (ONLY), `fullJoin` (no FULL + OUTER), `groupBy` `cube`/`groupingSets`/`empty`/`distinct`, `orderBy` + `nullsFirst`/`nullsLast`, `forNoKeyUpdate`/`forKeyShare`, `RowsFrom` / + `WITH ORDINALITY`, CTE `[NOT] MATERIALIZED`, `SEARCH`. +- **Replace:** `forShare()` → `FOR SHARE` (MySQL) / `LOCK IN SHARE MODE` + (MariaDB); `groupBy().rollup()` → trailing `WITH ROLLUP` (not `ROLLUP(...)`); + `offset` always renders within `LIMIT n OFFSET m`. +- **Add (include):** `lateral` from/join family (**MySQL-only**, build-validated); + `of(tbl)` on the lock (**MySQL-only**). `intersect`/`except` `all()` gated + (MySQL 8.0.31 / MariaDB 10.5). +- **Defer/exclude:** leading modifiers, `PARTITION`, `STRAIGHT_JOIN`, + `NATURAL JOIN`, MariaDB `OFFSET..FETCH`/`ROWS EXAMINED`/`WAIT`/`CYCLE`, `INTO`. + +### INSERT + +- **Keep:** `insertInto`, `columnNames`, `values` (repeatable), `setMap`, + `query` (`INSERT ... SELECT`). +- **Drop:** table `as()` alias (no such thing in MySQL/MariaDB INSERT); the whole + PG `ON CONFLICT` chain (`onConstraint`, conflict-target `where`, `doUpdate`, + DO-UPDATE `where`); leading `WITH` on INSERT (CTE only inside the feeding + SELECT). +- **Replace:** `defaultValues()` → render `() VALUES ()`; `onConflict(...)` / + `OnConflict*` (two states) → a **single** `onDuplicateKeyUpdate()` → + `OnDuplicateKeyUpdateInsertBuilder` with `set(col, val)` (no target, no WHERE). + Reuse `UpdateSetItem`. +- **Add:** `ignore()` (`INSERT IGNORE`, the practical `DO NOTHING` analogue — + document the semantic difference); proposed-row value reference for the upsert: + `new.col` (MySQL, via emitted `AS new` row alias) vs `VALUES(col)` (MariaDB) — + a variant-branched expression helper. `returning()` + `ReturningInsertBuilder` + → **MariaDB-only** (build-validated), reuse `ReturningItem`. +- **Defer:** `INSERT ... SET`, `PARTITION`. + +### REPLACE (new statement; no PG analog — mirror `InsertBuilder`) + +- A small `replaceInto()` → `ReplaceBuilder`: `columnNames`, `values`, `set`/ + `setMap`, `query` (`REPLACE ... SELECT`), `Q::default()` for DEFAULT. No + `ON DUPLICATE KEY UPDATE` (REPLACE *is* the conflict resolution), no table + alias, no leading WITH. +- `returning()` → **MariaDB-only** (build-validated). +- Defer: `PARTITION`, MySQL `ROW()`/`TABLE` source forms. + +### UPDATE + +- **Keep:** `update`, target `as`, `set`/`setMap`, `where`, `applyIf`. +- **Drop:** `returning()` + `ReturningUpdateBuilder` — **no UPDATE RETURNING** in + MySQL 8.4 or MariaDB 11.x (MariaDB only at 13.0, with `OLD_VALUE()` — out of + anchor). +- **Replace:** PG `from()` → `FromUpdateBuilder` becomes **multi-table UPDATE** + (`UPDATE t1 JOIN t2 ... SET ...` / comma list); qualified `SET t.col = ...`. +- **Add (include):** `orderBy`/`limit` — **single-table only** (illegal on + multi-table; enforce). Leading `WITH` on UPDATE — MySQL yes; MariaDB only + 12.3+ → build-validated off for 11.x. +- **Defer/exclude:** `LOW_PRIORITY`/`IGNORE` modifiers, `PARTITION`, + `FOR PORTION OF`. + +### DELETE + +- **Keep:** `deleteFrom`, target `as` (MariaDB ≥11.6 — build-gate), `where`. +- **Replace:** PG auxiliary `using()` → `FromDeleteBuilder` does **not** map. + MySQL/MariaDB have two *multi-table* forms; `DELETE t.* FROM ` is the + portable one. Expose multi-table delete explicitly; PG `DELETE FROM t USING x` + is **not valid** MySQL/MariaDB grammar. +- **Drop:** `LATERAL`/`ONLY` on the USING item. +- **Add (include):** `orderBy`/`limit` (single-table; MariaDB multi-table only + 11.8.1+ → keep single-table). `returning()` → **MariaDB single-table only** + (build-validated; no aggregates). Leading `WITH` → MySQL yes; MariaDB 12.3+ + (off for 11.x). +- **Defer/exclude:** modifiers, `PARTITION`, `FOR PORTION OF`, index hints. + +### WITH / CTE + +- The MySQL-family `WithQueryItem` is a **strict subset** of PG's: + `(recursive, name, columnNames, query)`. +- **Keep:** `with`/`withRecursive`, `as`, `columnNames`, multiple CTEs, `select`. +- **Drop:** `asMaterialized`/`asNotMaterialized`, the entire `SEARCH` + sub-chain (`WithSearchBuilder`/`WithSearchByBuilder`/`WithQuerySearch`). +- **Gate:** `WITH` before UPDATE/DELETE — MySQL yes; MariaDB **12.3+** (so on the + 11.x anchor expose only `WITH ... SELECT`, plus INSERT/REPLACE via feeding + SELECT). +- **Defer (MariaDB-only):** `CYCLE col RESTRICT` (the relaxed, non-standard form; + PG builder doesn't expose CYCLE either). + +--- + +## 5. Set operations / parenthesized query expressions + +Standard query-expression grammar — already modelled correctly by the existing +`combinations` walk + `WritesParenthesizedSql`. `INTERSECT` binds tighter than +`UNION`/`EXCEPT` in both engines (matches the flat-chain rendering); per-branch +`ORDER BY`/`LIMIT` and precedence overrides come free via the nested-`query()` +parenthesised form. Port unchanged; only the version gates (`INTERSECT`/`EXCEPT` +@ MySQL 8.0.31 / MariaDB 10.3; `... ALL` @ 8.0.31 / MariaDB 10.5) and the `INTO` +tail (excluded) apply. + +--- + +## 6. Expression / operator layer + +The chokepoint. New `ExpBase` for the MySQL family. + +| PG operator | Verdict | Render (both engines unless noted) | +|---|---|---| +| `=` `<>` `<` `<=` `>` `>=` | keep | unchanged | +| `IS [NOT] NULL` | keep | unchanged | +| `IS [NOT] DISTINCT FROM` | replace | `a <=> b` / `NOT (a <=> b)` | +| `::` cast | replace | `CAST(a AS type)` — **new MySQL type validator**, not PG's | +| `\|\|` concat | replace | `CONCAT(a, b, ...)` (|| is logical OR by default) | +| `^` (pow) | replace | `POW(a, b)` (`^` is bitwise XOR here) | +| `LIKE`/`NOT LIKE` | keep | unchanged (ci by default — collation, document) | +| `ILIKE`/`SIMILAR TO` | drop | use `LIKE`/`REGEXP` + explicit `COLLATE` | +| `~`/`~*`/`!~`/`!~*` | replace | `a REGEXP b` / `a NOT REGEXP b` (case = collation; case-sensitive variant is engine-divergent → expose only the default) | +| `->` `->>` | replace | MySQL: `a -> '$.p'` / `a ->> '$.p'`. MariaDB: `JSON_EXTRACT(a,'$.p')` / `JSON_UNQUOTE(JSON_EXTRACT(...))` — **variant split** | +| `#>` `#>>` | replace/merge | fold into the `->`/`->>` renderers (single JSON-path-string model) | +| `@>` `<@` | replace | `JSON_CONTAINS(a,b)` / `JSON_CONTAINS(b,a)` | + +**Add:** `nullSafeEq` (`<=>`), `regexp`/`notRegexp`, `memberOf` (`a MEMBER OF(arr)`, +MySQL ≥8.0.17 / MariaDB), `jsonValue` (`JSON_VALUE`). Optional bitwise family. + +**Contract change:** JSON accessors take a **JSON path string** (`'$.k'`), not a +PG-style int/text key. + +**Precedence:** a new map is required (do not reuse PG's). Deltas: `^` is bitwise +(tightest arithmetic); `||` is OR-level; `IS` sits at the comparison level; +`BETWEEN` below comparisons; an explicit `XOR` tier between `AND` and `OR`. +Upside: cast/concat/pow/json/regex move to **function form** (atomic, parens- +wrapped), shrinking the set of precedence-sensitive infix operators to +comparisons, `IS`, arithmetic, `LIKE`, `REGEXP`, `IN`, `MEMBER OF`, `<=>`, and +the boolean tiers. + +--- + +## 7. Function facade (`Q\Func`) — curated default set + +Goal: a broadly-useful default; anything omitted stays reachable via +`Q::func(name, ...)`. Mirror PG camelCase where a name already exists. Mirror the +three builder kinds: `FuncExp` (scalar), `AggBuilder` (DISTINCT/ORDER BY/...), +`WindowFuncBuilder` (`OVER`). + +**Shared default (safe on both):** aggregates `count/sum/avg/min/max/groupConcat/ +jsonArrayAgg/jsonObjectAgg/bitAnd/bitOr/bitXor/stddevPop/stddevSamp/varPop/ +varSamp`; string `concat/concatWs/lower/upper/length/charLength/substring/left/ +right/trim/ltrim/rtrim/lpad/rpad/replace/repeat/reverse/locate/instr/ +substringIndex/field/findInSet/format/hex/unhex`; regexp `regexpReplace/ +regexpInstr/regexpSubstr` (match via the `REGEXP` operator); numeric `abs/ceil/ +floor/round/truncate/mod/power/sqrt/exp/ln/log/log2/log10/sign/rand/pi` + trig; +datetime `now/curdate/curtime/currentTimestamp/utcTimestamp/date/time/year/month/ +day/hour/minute/second/quarter/week/dayOfWeek/dayOfYear/dayName/monthName/lastDay/ +extract/dateAdd/dateSub/dateDiff/timestampDiff/timestampAdd/dateFormat/strToDate/ +unixTimestamp/fromUnixtime/convertTz`; JSON `jsonObject/jsonArray/jsonQuote/ +jsonUnquote/jsonExtract/jsonContains/jsonContainsPath/jsonKeys/jsonSearch/ +jsonValue(2-arg)/jsonSet/jsonInsert/jsonReplace/jsonRemove/jsonArrayAppend/ +jsonArrayInsert/jsonMergePatch/jsonMergePreserve/jsonType/jsonDepth/jsonLength/ +jsonValid`; window `rowNumber/rank/denseRank/percentRank/cumeDist/ntile/lag/lead/ +firstValue/lastValue/nthValue`; misc `uuid/uuidToBin/binToUuid/isUuid`. + +On `Q` (not `Q\Func`), mirroring PG: `coalesce`, `nullif`, `greatest`, `least`, +`cast`/`convert`. `if`/`ifnull` on `Q\Func` (no PG analog). + +**MySQL-only (gate):** `regexpLike`, `grouping`, `anyValue`, `jsonSchemaValid*`, +`jsonStorage*`, `jsonPretty`, `randomBytes`. +**MariaDB-only (gate):** `jsonQuery`, `jsonDetailed` (pretty-print), `jsonExists`, +`median`, `percentileCont`, `percentileDisc`, Oracle-compat (`toChar`, +`addMonths`, `monthsBetween`, `chr`, `oct`). + +Builders that need special shapes: `groupConcat` (DISTINCT/ORDER BY/SEPARATOR), +`extract` (mirror PG `ExtractExp`), the `INTERVAL expr unit` keyword arg for +`dateAdd`/`dateSub`, `cast` (typed), `trim` (BOTH/LEADING/TRAILING ... FROM). + +--- + +## 8. MySQL-vs-MariaDB split matrix (the build-validated / branched set) + +| Feature | MySQL 8.4 | MariaDB 11.x | Handling | +|---|---|---|---| +| Identifier quote / placeholder / escaping | backtick / `?` / `\` | identical | shared — no split | +| `LATERAL` | yes (8.0.14) | **no** | availability (MySQL-only) | +| Shared lock | `FOR SHARE` (+`OF tbl`) | `LOCK IN SHARE MODE` | render-branch; `of()` MySQL-only | +| `NOWAIT`/`SKIP LOCKED` | 8.0 | 10.6 | keep (both in anchor) | +| `INTERSECT`/`EXCEPT` | 8.0.31 | 10.3 | keep; `ALL` @ 8.0.31 / 10.5 | +| `RETURNING` INSERT/REPLACE/DELETE | **no** | yes (10.5 / 10.5 / 10.0.5) | availability (MariaDB-only) | +| `RETURNING` UPDATE | no | no (13.0) | drop for both | +| `WITH` before UPDATE/DELETE | yes | **12.3** | availability (MySQL-only in anchor) | +| Upsert value-ref | `new.col` (`AS new`) | `VALUES(col)` | render-branch | +| `ROLLUP` form | `ROLLUP(...)` or `WITH ROLLUP` | `WITH ROLLUP` only | render trailing `WITH ROLLUP` for both | +| JSON `->`/`->>` operator | yes | **no** (function form) | render-branch | +| `JSON_PRETTY` / `JSON_DETAILED` | `JSON_PRETTY` | `JSON_DETAILED` | render-branch (if exposed) | +| `MEDIAN`/`PERCENTILE_*` window | no | yes | availability (MariaDB-only) | +| `GROUPING`/`ANY_VALUE`/`REGEXP_LIKE` | yes | no | availability (MySQL-only) | +| `CYCLE` on recursive CTE | no | 10.5.2 (relaxed) | availability (MariaDB-only, deferred) | + +> Handling per §2: an *availability* split = the method exists only on that +> engine's builder (type-level, no runtime check); a *render-branch* = one method +> name, distinct per-class `writeSql`. No runtime variant flag. + +--- + +## 9. Implementation staging + +Goal: **PG-comparable scope** (same breadth, dialect-appropriate — PG-only +features dropped, MySQL/MariaDB-only added), incrementally, each stage green on +`vendor/bin/pest` + `vendor/bin/phpstan analyse` before the next. + +**Test corpus per stage** (since there's no upstream suite to port like qrb was): +the matching PostgreSQL test cases adapted to MySQL/MariaDB SQL, **plus** verbatim +example queries from the official docs — both asserted via `toRenderSql`. Doc +examples are part of every stage, not deferred. + +**Definition of done (per statement stage):** every production in that +statement's official-doc grammar carries an explicit verdict — Supported / +Deferred / Excluded — worked top-to-bottom from the doc. Deferred/Excluded +entries are recorded in §12 with a reason. Nothing is left unsupported silently. + +| # | Stage | Contents | +|---|---|---| +| 0 | **Testing infra** | dialect-aware `toRenderSql` (build through the MySQL/MariaDB `QueryBuilder`, not the PG one); `tests/MySQL/` + `tests/MariaDB/` Pest bootstraps | +| 1 | **Foundation** | MySQL `SqlBuilder` (backtick / `?` / `\`+`''` escaping), `Keywords` (MySQL reserved list + backtick quoting), `Literals`, `IdentExp` (MySQL validity regex); copied machinery (`SqlWriter`/`InnerSqlWriter`/`QueryBuilder`/`Precedence`/`WritesParenthesizedSql`/structural value objects); `MySQL\Q` facade skeleton | +| 2 | **Expression layer** | function-form `ExpBase` + new `Precedence` map; `OpExp`/`FuncExp`; literals; args/binds (non-reusable `?`); `CAST` + a new MySQL type validator (replaces `TypeExp`); `CASE`; junctions (AND/OR/NOT); `IN`/`EXISTS`/`ANY`/`ALL` + subqueries; drop `ARRAY`, reshape `INTERVAL` | +| 3 | **SELECT** | clause ladder (abstract base + MySQL concretes) minus dropped clauses; includes CTEs (minus `MATERIALIZED`/`SEARCH`), set operations, locking (`FOR SHARE`); MySQL `lateral`/`of()` | +| 4 | **Window functions** | `OVER`, named `WINDOW` clause, frame clauses (`ROWS`/`RANGE BETWEEN`), window-only funcs (`row_number`/`rank`/`lag`/`lead`/`ntile`/`first_value`/…) | +| 5 | **DML** | INSERT (+ `onDuplicateKeyUpdate`, `ignore`), REPLACE, UPDATE (multi-table + order/limit), DELETE (multi-table + order/limit) — MySQL ladder | +| 6 | **Function facade** | curated `Q\Func` default set + special-shape builders (`GROUP_CONCAT`, `EXTRACT`, `INTERVAL` arg, `TRIM`) | +| 7 | **MariaDB variant** | the MariaDB concrete ladder over the shared abstract bases: `RETURNING` subclasses (INSERT/REPLACE/DELETE), `LOCK IN SHARE MODE`, no `lateral`/`of()`, MariaDB-only funcs (`JSON_QUERY`/`JSON_DETAILED`/`MEDIAN`/`PERCENTILE_*`), `MariaDB\Q` facade, `WITH`-before-UPDATE/DELETE gated off | +| 8 | **Doc-example & parity audit** | consolidated verbatim doc-example tests; a PG-parity map (each PG test → ported / adapted / dropped-with-reason); README / usage docs | + +The abstract base builders are designed bi-dialect-aware from stage 1 (per the +§8 split matrix), so the MariaDB stage adds only divergent subclasses rather than +forcing base rework. + +--- + +## 10. Resolved decisions + +- **Variant modelling:** per-variant subclasses (compile-time safe) — see §2. + No runtime variant flag; class identity carries the dialect. +- **Naming:** `src/MySQL/` holds the shared primitives + machinery + abstract + base builders + the MySQL ladder; `src/MariaDB/` holds the MariaDB facade and + its divergent builder subclasses, reusing `src/MySQL/Builder/`. Facades + `MySQL\Q` / `MySQL\Q\Func` and `MariaDB\Q` / `MariaDB\Q\Func`, peers of + `PostgreSQL\Q`. + +Open sub-decision deferred to the SELECT stage: how far the MySQL-only `lateral` +methods thread down the transition chain. + +## 11. Sources + +Official MySQL 8.4 reference manual and MariaDB KB/docs, per-area URLs in the +research extracts. Anchors confirmed by re-fetch where divergence mattered +(verify pass); the REPLACE verify stage failed on output formatting only — its +research file is complete and was cross-checked against the INSERT findings. + +--- + +## 12. Coverage ledger — deliberate non-support + +Principle: implement each area **against the full official-doc grammar** and give +**every production** an explicit verdict. "Not supported" is always a recorded +decision, never a silent omission. Statuses: + +- **Supported** — built + tested (the default; see §4/§6/§7 + the test corpus). +- **Deferred** — in scope eventually, not this pass; cheap to add later behind an + explicit method. (The "(yet)" cases.) +- **Excluded** — out of scope: not query-shape (result routing, optimizer/priority + hints, server/session knobs), deprecated, or stored-program-only. Reachable via + raw SQL / `Q::func` if ever needed. +- **N/A (PG-only)** — the PG builder has it; MySQL/MariaDB don't (see §4/§6). + +Deferred + Excluded surface in the dialect README "Limitations" section (stage 8). +`SELECT ... INTO` is the canonical Excluded case, in both senses: +`INTO OUTFILE/DUMPFILE/@var` (result routing) and stored-program `INTO var_list` +(variable assignment). + +### SELECT +| Clause | Status | Reason | +|---|---|---| +| `INTO OUTFILE/DUMPFILE/@var`, stored-program `INTO var_list` | Excluded | result routing / variable assignment — not query shape | +| `HIGH_PRIORITY`, `SQL_SMALL/BIG/BUFFER_RESULT`, `SQL_CACHE`/`SQL_NO_CACHE`, `SQL_CALC_FOUND_ROWS` | Excluded | optimizer/result hints; `SQL_CALC_FOUND_ROWS` deprecated | +| `PROCEDURE name(...)` | Excluded | deprecated, outside the query model | +| `ROWS EXAMINED n`, `WAIT n` lock option (MariaDB) | Excluded | resource / lock-timeout knobs | +| `PARTITION (...)` selection | Deferred | partition pruning; from-item add later | +| index hints (`USE`/`FORCE`/`IGNORE INDEX`) | Deferred | optimizer hint | +| `STRAIGHT_JOIN`, `NATURAL [LEFT\|RIGHT] JOIN` | Deferred | niche join forms | +| `LIMIT offset, count` short form | Deferred | `LIMIT n OFFSET m` covers it | +| MariaDB `OFFSET..FETCH FIRST/NEXT ... ONLY/WITH TIES` | Deferred | LIMIT/OFFSET covers the common case | +| MariaDB recursive-CTE `CYCLE col RESTRICT` | Deferred | PG builder exposes no CYCLE either | + +### INSERT / REPLACE +| Clause | Status | Reason | +|---|---|---| +| `LOW_PRIORITY`/`HIGH_PRIORITY`/`DELAYED` | Excluded | priority hints; `DELAYED` ignored in 8.4 | +| `INSERT ... SET` assignment form | Deferred | `(cols) VALUES (...)` covers it | +| `PARTITION (...)` | Deferred | partition selection | +| MySQL `VALUES ROW(...)` / `TABLE tbl` source | Deferred | MySQL-only source forms | + +### UPDATE / DELETE +| Clause | Status | Reason | +|---|---|---| +| `LOW_PRIORITY`/`QUICK` | Excluded | priority hints | +| `IGNORE` | Deferred | error-demotion toggle; add as a modifier later | +| `PARTITION (...)` | Deferred | partition selection | +| MariaDB `FOR PORTION OF period FROM..TO` | Excluded | application-time temporal tables, separate feature | +| MariaDB DELETE index hints (11.8.1+) | Deferred | optimizer hint | + +### Expressions +| Item | Status | Reason | +|---|---|---| +| bitwise `&` `\|` `^`(XOR) `<<` `>>` `~` | Deferred | not in the PG surface; add if wanted | +| case-sensitive regex (`~`/`!~` equivalents) | Deferred | engine-divergent (`REGEXP_LIKE(...,'c')` vs `REGEXP BINARY`); only the ci-default `REGEXP` ships now | +| `ILIKE`, `SIMILAR TO`, `::`, `\|\|`, `^`(pow), `@>`/`<@`, `#>`/`#>>`, `ARRAY` | N/A (PG-only) | dropped or mapped to functions — see §6 | + +### Functions +| Category / function | Status | Reason | +|---|---|---| +| Spatial / GIS (`ST_*`, `MBR*`) | Excluded | large specialized surface; via `Q::func` | +| Encryption / compression (`AES_*`, `MD5`/`SHA*`, `COMPRESS`) | Excluded from default | security/config-sensitive; via `Q::func` | +| Locking / information / replication / internal funcs | Excluded | connection/server state, not query shape | +| XML (`ExtractValue`, `UpdateXML`) | Excluded | niche | +| `JSON_TABLE` | Deferred | FROM-clause table function (FROM machinery, not `Q\Func`) | +| `MEMBER OF` operator | Deferred | JSON membership; add to the expression layer if wanted | +| MySQL-only `REGEXP_LIKE`/`GROUPING`/`ANY_VALUE`/`JSON_SCHEMA*`/`JSON_STORAGE*`/`JSON_PRETTY`/`RANDOM_BYTES` | Supported (MySQL facade only) | gated; absent on MariaDB | +| MariaDB-only `JSON_QUERY`/`JSON_DETAILED`/`MEDIAN`/`PERCENTILE_*`/Oracle-compat | Supported (MariaDB facade only) | gated; absent on MySQL | From 523148fa94f8f1297e1ef5600126c82f9cfe04de Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:20:18 +0200 Subject: [PATCH 02/40] wip: mysql foundation + expression layer Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/Arg.php | 23 +++ src/MySQL/Builder/BindExp.php | 24 +++ src/MySQL/Builder/BoolLiteral.php | 22 +++ src/MySQL/Builder/CastExp.php | 27 +++ src/MySQL/Builder/DefaultLiteral.php | 16 ++ src/MySQL/Builder/Exp.php | 13 ++ src/MySQL/Builder/ExpBase.php | 203 ++++++++++++++++++++ src/MySQL/Builder/Expressions.php | 33 ++++ src/MySQL/Builder/FloatLiteral.php | 23 +++ src/MySQL/Builder/FromExp.php | 16 ++ src/MySQL/Builder/FromLateralExp.php | 15 ++ src/MySQL/Builder/FuncExp.php | 32 +++ src/MySQL/Builder/IdentExp.php | 59 ++++++ src/MySQL/Builder/InExp.php | 26 +++ src/MySQL/Builder/InnerSqlWriter.php | 17 ++ src/MySQL/Builder/IntLiteral.php | 21 ++ src/MySQL/Builder/Junction.php | 61 ++++++ src/MySQL/Builder/Keywords.php | 165 ++++++++++++++++ src/MySQL/Builder/Literals.php | 28 +++ src/MySQL/Builder/NullLiteral.php | 16 ++ src/MySQL/Builder/OpExp.php | 61 ++++++ src/MySQL/Builder/Precedence.php | 47 +++++ src/MySQL/Builder/Precedencer.php | 16 ++ src/MySQL/Builder/QueryBuilder.php | 83 ++++++++ src/MySQL/Builder/QueryBuilderException.php | 13 ++ src/MySQL/Builder/SelectOrExpressions.php | 16 ++ src/MySQL/Builder/SqlBuilder.php | 117 +++++++++++ src/MySQL/Builder/SqlWriter.php | 20 ++ src/MySQL/Builder/StringLiteral.php | 21 ++ src/MySQL/Builder/TypeExp.php | 47 +++++ src/MySQL/Builder/UnaryExp.php | 48 +++++ src/MySQL/Q.php | 173 +++++++++++++++++ tests/MySQL/Q/ExpressionsTest.php | 66 +++++++ tests/Pest.php | 33 +++- 34 files changed, 1593 insertions(+), 8 deletions(-) create mode 100644 src/MySQL/Builder/Arg.php create mode 100644 src/MySQL/Builder/BindExp.php create mode 100644 src/MySQL/Builder/BoolLiteral.php create mode 100644 src/MySQL/Builder/CastExp.php create mode 100644 src/MySQL/Builder/DefaultLiteral.php create mode 100644 src/MySQL/Builder/Exp.php create mode 100644 src/MySQL/Builder/ExpBase.php create mode 100644 src/MySQL/Builder/Expressions.php create mode 100644 src/MySQL/Builder/FloatLiteral.php create mode 100644 src/MySQL/Builder/FromExp.php create mode 100644 src/MySQL/Builder/FromLateralExp.php create mode 100644 src/MySQL/Builder/FuncExp.php create mode 100644 src/MySQL/Builder/IdentExp.php create mode 100644 src/MySQL/Builder/InExp.php create mode 100644 src/MySQL/Builder/InnerSqlWriter.php create mode 100644 src/MySQL/Builder/IntLiteral.php create mode 100644 src/MySQL/Builder/Junction.php create mode 100644 src/MySQL/Builder/Keywords.php create mode 100644 src/MySQL/Builder/Literals.php create mode 100644 src/MySQL/Builder/NullLiteral.php create mode 100644 src/MySQL/Builder/OpExp.php create mode 100644 src/MySQL/Builder/Precedence.php create mode 100644 src/MySQL/Builder/Precedencer.php create mode 100644 src/MySQL/Builder/QueryBuilder.php create mode 100644 src/MySQL/Builder/QueryBuilderException.php create mode 100644 src/MySQL/Builder/SelectOrExpressions.php create mode 100644 src/MySQL/Builder/SqlBuilder.php create mode 100644 src/MySQL/Builder/SqlWriter.php create mode 100644 src/MySQL/Builder/StringLiteral.php create mode 100644 src/MySQL/Builder/TypeExp.php create mode 100644 src/MySQL/Builder/UnaryExp.php create mode 100644 src/MySQL/Q.php create mode 100644 tests/MySQL/Q/ExpressionsTest.php diff --git a/src/MySQL/Builder/Arg.php b/src/MySQL/Builder/Arg.php new file mode 100644 index 0000000..17fc68c --- /dev/null +++ b/src/MySQL/Builder/Arg.php @@ -0,0 +1,23 @@ +writeString($sb->createPlaceholder($this->argument)); + } +} diff --git a/src/MySQL/Builder/BindExp.php b/src/MySQL/Builder/BindExp.php new file mode 100644 index 0000000..f03aabc --- /dev/null +++ b/src/MySQL/Builder/BindExp.php @@ -0,0 +1,24 @@ +writeString($sb->bindPlaceholder($this->name)); + } +} diff --git a/src/MySQL/Builder/BoolLiteral.php b/src/MySQL/Builder/BoolLiteral.php new file mode 100644 index 0000000..1a26f00 --- /dev/null +++ b/src/MySQL/Builder/BoolLiteral.php @@ -0,0 +1,22 @@ +writeString($this->value ? 'TRUE' : 'FALSE'); + } +} diff --git a/src/MySQL/Builder/CastExp.php b/src/MySQL/Builder/CastExp.php new file mode 100644 index 0000000..2b7e87a --- /dev/null +++ b/src/MySQL/Builder/CastExp.php @@ -0,0 +1,27 @@ +writeString('CAST('); + $this->exp->writeSql($sb); + $sb->writeString(' AS '); + $this->type->writeSql($sb); + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Builder/DefaultLiteral.php b/src/MySQL/Builder/DefaultLiteral.php new file mode 100644 index 0000000..89cca25 --- /dev/null +++ b/src/MySQL/Builder/DefaultLiteral.php @@ -0,0 +1,16 @@ +writeString('DEFAULT'); + } +} diff --git a/src/MySQL/Builder/Exp.php b/src/MySQL/Builder/Exp.php new file mode 100644 index 0000000..f747d21 --- /dev/null +++ b/src/MySQL/Builder/Exp.php @@ -0,0 +1,13 @@ +op('=', $rgt); + } + + public function neq(Exp $rgt): Exp + { + return $this->op('<>', $rgt); + } + + public function lt(Exp $rgt): Exp + { + return $this->op('<', $rgt); + } + + public function lte(Exp $rgt): Exp + { + return $this->op('<=', $rgt); + } + + public function gt(Exp $rgt): Exp + { + return $this->op('>', $rgt); + } + + public function gte(Exp $rgt): Exp + { + return $this->op('>=', $rgt); + } + + /** + * Null-safe equality (`a <=> b`); the MySQL/MariaDB equivalent of the SQL + * standard `IS NOT DISTINCT FROM`. + */ + public function nullSafeEq(Exp $rgt): OpExp + { + return $this->op('<=>', $rgt); + } + + public function isNotDistinctFrom(Exp $rgt): Exp + { + return $this->op('<=>', $rgt); + } + + public function isDistinctFrom(Exp $rgt): Exp + { + return new UnaryExp($this->op('<=>', $rgt), Precedence::of('NOT'), prefix: 'NOT'); + } + + /** + * Cast to the given type (`CAST(expr AS type)`). There is no `::` operator. + */ + public function cast(string $type): CastExp + { + return new CastExp($this, new TypeExp($type)); + } + + // Math operators + + public function plus(Exp $rgt): OpExp + { + return $this->op('+', $rgt); + } + + public function minus(Exp $rgt): OpExp + { + return $this->op('-', $rgt); + } + + public function mult(Exp $rgt): OpExp + { + return $this->op('*', $rgt); + } + + public function divide(Exp $rgt): OpExp + { + return $this->op('/', $rgt); + } + + public function mod(Exp $rgt): OpExp + { + return $this->op('%', $rgt); + } + + /** + * Power. Rendered as `POW(a, b)` — `^` is bitwise XOR in MySQL/MariaDB. + */ + public function pow(Exp $rgt): FuncExp + { + return new FuncExp('POW', [$this, $rgt]); + } + + /** + * String concatenation. Rendered as `CONCAT(a, b)` — `||` is logical OR + * under the default sql_mode. Note `CONCAT` returns NULL if any argument is NULL. + */ + public function concat(Exp $rgt): FuncExp + { + return new FuncExp('CONCAT', [$this, $rgt]); + } + + // JSON + + /** + * Extract a JSON value at the given path (`a -> '$.path'`). The right-hand + * side is a JSON path string literal, not a key/index expression. + */ + public function jsonExtract(Exp $rgt): OpExp + { + return $this->op('->', $rgt); + } + + /** + * Extract and unquote a JSON value at the given path (`a ->> '$.path'`). + */ + public function jsonExtractText(Exp $rgt): OpExp + { + return $this->op('->>', $rgt); + } + + /** + * JSON containment (`JSON_CONTAINS(a, candidate)`). + */ + public function jsonContains(Exp $candidate): FuncExp + { + return new FuncExp('JSON_CONTAINS', [$this, $candidate]); + } + + // Pattern matching + + public function like(Exp $rgt): Exp + { + return $this->op('LIKE', $rgt); + } + + public function notLike(Exp $rgt): Exp + { + return $this->op('NOT LIKE', $rgt); + } + + /** + * Regular-expression match (`a REGEXP b`). Case sensitivity follows the + * operand collation (case-insensitive by default). + */ + public function regexp(Exp $rgt): Exp + { + return $this->op('REGEXP', $rgt); + } + + public function notRegexp(Exp $rgt): Exp + { + return $this->op('NOT REGEXP', $rgt); + } + + /** + * Build an `IN (...)` expression. The right-hand side is a subquery or a + * list of expressions (see {@see Expressions}). + */ + public function in(SelectOrExpressions $rgt): Exp + { + return new InExp($this, 'IN', $rgt); + } + + public function notIn(SelectOrExpressions $rgt): Exp + { + return new InExp($this, 'NOT IN', $rgt); + } + + public function isNull(): Exp + { + return new UnaryExp($this, Precedence::of('IS'), suffix: 'IS NULL'); + } + + public function isNotNull(): Exp + { + return new UnaryExp($this, Precedence::of('IS'), suffix: 'IS NOT NULL'); + } +} diff --git a/src/MySQL/Builder/Expressions.php b/src/MySQL/Builder/Expressions.php new file mode 100644 index 0000000..7a63f64 --- /dev/null +++ b/src/MySQL/Builder/Expressions.php @@ -0,0 +1,33 @@ + $exps + */ + public function __construct( + public readonly array $exps, + ) { + } + + public function writeSql(SqlBuilder $sb): void + { + $sb->writeString('('); + foreach ($this->exps as $i => $exp) { + if ($i > 0) { + $sb->writeString(','); + } + $exp->writeSql($sb); + } + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Builder/FloatLiteral.php b/src/MySQL/Builder/FloatLiteral.php new file mode 100644 index 0000000..1cc55f3 --- /dev/null +++ b/src/MySQL/Builder/FloatLiteral.php @@ -0,0 +1,23 @@ +value), '0'), '.'); + $sb->writeString($s === '' || $s === '-' ? '0' : $s); + } +} diff --git a/src/MySQL/Builder/FromExp.php b/src/MySQL/Builder/FromExp.php new file mode 100644 index 0000000..fde4b8a --- /dev/null +++ b/src/MySQL/Builder/FromExp.php @@ -0,0 +1,16 @@ + $args + */ + public function __construct( + private readonly string $name, + private readonly array $args, + ) { + } + + public function writeSql(SqlBuilder $sb): void + { + $sb->writeString($this->name . '('); + foreach ($this->args as $i => $arg) { + if ($i > 0) { + $sb->writeString(','); + } + $arg->writeSql($sb); + } + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Builder/IdentExp.php b/src/MySQL/Builder/IdentExp.php new file mode 100644 index 0000000..9d71e13 --- /dev/null +++ b/src/MySQL/Builder/IdentExp.php @@ -0,0 +1,59 @@ +ident; + } + + public function writeSql(SqlBuilder $sb): void + { + if ($sb->isValidating() && !self::isValidIdentifier($this->ident)) { + $sb->addError(new QueryBuilderException(sprintf('identifier: invalid: %s', $this->ident))); + + return; + } + + $sb->writeString($this->quotedIdent); + } + + private static function isValidIdentifier(string $s): bool + { + return preg_match(self::VALID_IDENTIFIER_REGEX, $s) === 1; + } +} diff --git a/src/MySQL/Builder/InExp.php b/src/MySQL/Builder/InExp.php new file mode 100644 index 0000000..1ce3e03 --- /dev/null +++ b/src/MySQL/Builder/InExp.php @@ -0,0 +1,26 @@ +lft->writeSql($sb); + $sb->writeString(' ' . $this->op . ' '); + $this->rgt->writeSql($sb); + } +} diff --git a/src/MySQL/Builder/InnerSqlWriter.php b/src/MySQL/Builder/InnerSqlWriter.php new file mode 100644 index 0000000..e9430b0 --- /dev/null +++ b/src/MySQL/Builder/InnerSqlWriter.php @@ -0,0 +1,17 @@ +writeString((string) $this->value); + } +} diff --git a/src/MySQL/Builder/Junction.php b/src/MySQL/Builder/Junction.php new file mode 100644 index 0000000..bb416ea --- /dev/null +++ b/src/MySQL/Builder/Junction.php @@ -0,0 +1,61 @@ + $exps + */ + private function __construct( + private readonly array $exps, + private readonly string $op, + ) { + } + + public static function and(Exp ...$exps): self + { + return new self(array_values($exps), 'AND'); + } + + public static function or(Exp ...$exps): self + { + return new self(array_values($exps), 'OR'); + } + + public function precedence(): int + { + return Precedence::of($this->op); + } + + public function writeSql(SqlBuilder $sb): void + { + if (count($this->exps) === 1) { + $this->exps[0]->writeSql($sb); + + return; + } + + foreach ($this->exps as $i => $exp) { + if ($i > 0) { + $sb->writeString(' ' . $this->op . ' '); + } + // Wrap nested junctions in parentheses. + if ($exp instanceof self) { + $sb->writeString('('); + $exp->writeSql($sb); + $sb->writeString(')'); + } else { + $exp->writeSql($sb); + } + } + } +} diff --git a/src/MySQL/Builder/Keywords.php b/src/MySQL/Builder/Keywords.php new file mode 100644 index 0000000..b9a28e1 --- /dev/null +++ b/src/MySQL/Builder/Keywords.php @@ -0,0 +1,165 @@ + + */ + private const RESERVED = [ + 'ACCESSIBLE' => true, 'ADD' => true, 'ALL' => true, 'ALTER' => true, 'ANALYZE' => true, + 'AND' => true, 'AS' => true, 'ASC' => true, 'ASENSITIVE' => true, 'BEFORE' => true, + 'BETWEEN' => true, 'BIGINT' => true, 'BINARY' => true, 'BLOB' => true, 'BOTH' => true, + 'BY' => true, 'CALL' => true, 'CASCADE' => true, 'CASE' => true, 'CHANGE' => true, + 'CHAR' => true, 'CHARACTER' => true, 'CHECK' => true, 'COLLATE' => true, 'COLUMN' => true, + 'CONDITION' => true, 'CONSTRAINT' => true, 'CONTINUE' => true, 'CONVERT' => true, + 'CREATE' => true, 'CROSS' => true, 'CUBE' => true, 'CUME_DIST' => true, + 'CURRENT_DATE' => true, 'CURRENT_TIME' => true, 'CURRENT_TIMESTAMP' => true, + 'CURRENT_USER' => true, 'CURSOR' => true, 'DATABASE' => true, 'DATABASES' => true, + 'DAY_HOUR' => true, 'DAY_MICROSECOND' => true, 'DAY_MINUTE' => true, 'DAY_SECOND' => true, + 'DEC' => true, 'DECIMAL' => true, 'DECLARE' => true, 'DEFAULT' => true, 'DELAYED' => true, + 'DELETE' => true, 'DENSE_RANK' => true, 'DESC' => true, 'DESCRIBE' => true, + 'DETERMINISTIC' => true, 'DISTINCT' => true, 'DISTINCTROW' => true, 'DIV' => true, + 'DOUBLE' => true, 'DROP' => true, 'DUAL' => true, 'EACH' => true, 'ELSE' => true, + 'ELSEIF' => true, 'EMPTY' => true, 'ENCLOSED' => true, 'ESCAPED' => true, 'EXCEPT' => true, + 'EXISTS' => true, 'EXIT' => true, 'EXPLAIN' => true, 'FALSE' => true, 'FETCH' => true, + 'FIRST_VALUE' => true, 'FLOAT' => true, 'FLOAT4' => true, 'FLOAT8' => true, 'FOR' => true, + 'FORCE' => true, 'FOREIGN' => true, 'FROM' => true, 'FULLTEXT' => true, 'FUNCTION' => true, + 'GENERATED' => true, 'GET' => true, 'GRANT' => true, 'GROUP' => true, 'GROUPING' => true, + 'GROUPS' => true, 'HAVING' => true, 'HIGH_PRIORITY' => true, 'HOUR_MICROSECOND' => true, + 'HOUR_MINUTE' => true, 'HOUR_SECOND' => true, 'IF' => true, 'IGNORE' => true, 'IN' => true, + 'INDEX' => true, 'INFILE' => true, 'INNER' => true, 'INOUT' => true, 'INSENSITIVE' => true, + 'INSERT' => true, 'INT' => true, 'INT1' => true, 'INT2' => true, 'INT3' => true, + 'INT4' => true, 'INT8' => true, 'INTEGER' => true, 'INTERVAL' => true, 'INTO' => true, + 'IO_AFTER_GTIDS' => true, 'IO_BEFORE_GTIDS' => true, 'IS' => true, 'ITERATE' => true, + 'JOIN' => true, 'JSON_TABLE' => true, 'KEY' => true, 'KEYS' => true, 'KILL' => true, + 'LAG' => true, 'LAST_VALUE' => true, 'LATERAL' => true, 'LEAD' => true, 'LEADING' => true, + 'LEAVE' => true, 'LEFT' => true, 'LIKE' => true, 'LIMIT' => true, 'LINEAR' => true, + 'LINES' => true, 'LOAD' => true, 'LOCALTIME' => true, 'LOCALTIMESTAMP' => true, + 'LOCK' => true, 'LONG' => true, 'LONGBLOB' => true, 'LONGTEXT' => true, 'LOOP' => true, + 'LOW_PRIORITY' => true, 'MASTER_BIND' => true, + 'MASTER_SSL_VERIFY_SERVER_CERT' => true, 'MATCH' => true, 'MAXVALUE' => true, + 'MEDIUMBLOB' => true, 'MEDIUMINT' => true, 'MEDIUMTEXT' => true, 'MIDDLEINT' => true, + 'MINUTE_MICROSECOND' => true, 'MINUTE_SECOND' => true, 'MOD' => true, 'MODIFIES' => true, + 'NATURAL' => true, 'NOT' => true, 'NO_WRITE_TO_BINLOG' => true, 'NTH_VALUE' => true, + 'NTILE' => true, 'NULL' => true, 'NUMERIC' => true, 'OF' => true, 'ON' => true, + 'OPTIMIZE' => true, 'OPTIMIZER_COSTS' => true, 'OPTION' => true, 'OPTIONALLY' => true, + 'OR' => true, 'ORDER' => true, 'OUT' => true, 'OUTER' => true, 'OUTFILE' => true, + 'OVER' => true, 'PARTITION' => true, 'PERCENT_RANK' => true, 'PRECISION' => true, + 'PRIMARY' => true, 'PROCEDURE' => true, 'PURGE' => true, 'RANGE' => true, 'RANK' => true, + 'READ' => true, 'READS' => true, 'READ_WRITE' => true, 'REAL' => true, 'RECURSIVE' => true, + 'REFERENCES' => true, 'REGEXP' => true, 'RELEASE' => true, 'RENAME' => true, + 'REPEAT' => true, 'REPLACE' => true, 'REQUIRE' => true, 'RESIGNAL' => true, + 'RESTRICT' => true, 'RETURN' => true, 'REVOKE' => true, 'RIGHT' => true, 'RLIKE' => true, + 'ROW' => true, 'ROWS' => true, 'ROW_NUMBER' => true, 'SCHEMA' => true, 'SCHEMAS' => true, + 'SECOND_MICROSECOND' => true, 'SELECT' => true, 'SENSITIVE' => true, 'SEPARATOR' => true, + 'SET' => true, 'SHOW' => true, 'SIGNAL' => true, 'SMALLINT' => true, 'SPATIAL' => true, + 'SPECIFIC' => true, 'SQL' => true, 'SQLEXCEPTION' => true, 'SQLSTATE' => true, + 'SQLWARNING' => true, 'SQL_BIG_RESULT' => true, 'SQL_CALC_FOUND_ROWS' => true, + 'SQL_SMALL_RESULT' => true, 'SSL' => true, 'STARTING' => true, 'STORED' => true, + 'STRAIGHT_JOIN' => true, 'SYSTEM' => true, 'TABLE' => true, 'TERMINATED' => true, + 'THEN' => true, 'TINYBLOB' => true, 'TINYINT' => true, 'TINYTEXT' => true, 'TO' => true, + 'TRAILING' => true, 'TRIGGER' => true, 'TRUE' => true, 'UNDO' => true, 'UNION' => true, + 'UNIQUE' => true, 'UNLOCK' => true, 'UNSIGNED' => true, 'UPDATE' => true, 'USAGE' => true, + 'USE' => true, 'USING' => true, 'UTC_DATE' => true, 'UTC_TIME' => true, + 'UTC_TIMESTAMP' => true, 'VALUES' => true, 'VARBINARY' => true, 'VARCHAR' => true, + 'VARCHARACTER' => true, 'VARYING' => true, 'VIRTUAL' => true, 'WHEN' => true, + 'WHERE' => true, 'WHILE' => true, 'WINDOW' => true, 'WITH' => true, 'WRITE' => true, + 'XOR' => true, 'YEAR_MONTH' => true, 'ZEROFILL' => true, + ]; + + private function __construct() + { + } + + /** + * Process an identifier and quote the parts that are reserved keywords. + * + * Handles dotted paths by processing each part individually. Parts that are + * already quoted are left unchanged. + */ + public static function quoteIdentifierIfKeyword(string $ident): string + { + if ($ident === '' || $ident === '*') { + return $ident; + } + + $parts = self::splitIdentifier($ident); + + foreach ($parts as $i => $part) { + if (self::isAlreadyQuoted($part) || $part === '*') { + continue; + } + if (self::isReservedKeyword($part)) { + $parts[$i] = self::quoteIdentifier($part); + } + } + + return implode('.', $parts); + } + + public static function isReservedKeyword(string $s): bool + { + return isset(self::RESERVED[strtoupper($s)]); + } + + /** + * Wrap an identifier in backticks, escaping any internal backtick. + */ + private static function quoteIdentifier(string $s): string + { + return '`' . str_replace('`', '``', $s) . '`'; + } + + private static function isAlreadyQuoted(string $s): bool + { + return strlen($s) >= 2 && $s[0] === '`' && $s[strlen($s) - 1] === '`'; + } + + /** + * Split an identifier by dots, but respect backtick-quoted parts. + * e.g. `schema.`my.table`.column` -> ['schema', '`my.table`', 'column'] + * + * @return list + */ + private static function splitIdentifier(string $ident): array + { + $parts = []; + $current = ''; + $inQuote = false; + + $length = strlen($ident); + for ($i = 0; $i < $length; $i++) { + $ch = $ident[$i]; + if ($ch === '`') { + $inQuote = !$inQuote; + $current .= $ch; + } elseif ($ch === '.' && !$inQuote) { + $parts[] = $current; + $current = ''; + } else { + $current .= $ch; + } + } + + if ($current !== '') { + $parts[] = $current; + } + + return $parts; + } +} diff --git a/src/MySQL/Builder/Literals.php b/src/MySQL/Builder/Literals.php new file mode 100644 index 0000000..e378329 --- /dev/null +++ b/src/MySQL/Builder/Literals.php @@ -0,0 +1,28 @@ +writeString('NULL'); + } +} diff --git a/src/MySQL/Builder/OpExp.php b/src/MySQL/Builder/OpExp.php new file mode 100644 index 0000000..39cf969 --- /dev/null +++ b/src/MySQL/Builder/OpExp.php @@ -0,0 +1,61 @@ +op); + } + + public function writeSql(SqlBuilder $sb): void + { + $lftNeedsParens = $this->lft instanceof Precedencer && $this->lft->precedence() < $this->precedence(); + + if ($lftNeedsParens) { + $sb->writeString('('); + } + $this->lft->writeSql($sb); + if ($lftNeedsParens) { + $sb->writeString(')'); + } + + $sb->writeString($this->unspaced ? $this->op : ' ' . $this->op . ' '); + + $rgtNeedsParens = false; + if ($this->rgt instanceof Precedencer) { + $rgtNeedsParens = $this->rgt->precedence() < $this->precedence(); + // Special case: if the right expression is an op expression with a + // different operator but the same precedence (e.g. + / -), we need + // parentheses. + if ($this->rgt instanceof OpExp && $this->rgt->op !== $this->op && $this->rgt->precedence() === $this->precedence()) { + $rgtNeedsParens = true; + } + } + + if ($rgtNeedsParens) { + $sb->writeString('('); + } + $this->rgt->writeSql($sb); + if ($rgtNeedsParens) { + $sb->writeString(')'); + } + } +} diff --git a/src/MySQL/Builder/Precedence.php b/src/MySQL/Builder/Precedence.php new file mode 100644 index 0000000..2a65632 --- /dev/null +++ b/src/MySQL/Builder/Precedence.php @@ -0,0 +1,47 @@ + */ + private const MAP = [ + // JSON path operators bind tightest among the operators we render. + '->' => 6, + '->>' => 6, + '*' => 4, + '/' => 4, + '%' => 4, + 'DIV' => 4, + 'MOD' => 4, + '+' => 3, + '-' => 3, + // comparison row (default 0): = <=> < > <= >= <> IS LIKE REGEXP IN ... + 'BETWEEN' => -1, + 'NOT' => -2, + 'AND' => -3, + 'XOR' => -4, + 'OR' => -5, + ]; + + private function __construct() + { + } + + public static function of(string $op): int + { + return self::MAP[$op] ?? 0; + } +} diff --git a/src/MySQL/Builder/Precedencer.php b/src/MySQL/Builder/Precedencer.php new file mode 100644 index 0000000..06f6b2d --- /dev/null +++ b/src/MySQL/Builder/Precedencer.php @@ -0,0 +1,16 @@ + $namedArgs + */ + public function __construct( + private readonly SqlWriter $writer, + private readonly array $namedArgs = [], + private readonly bool $validating = true, + ) { + } + + public static function build(SqlWriter $writer): self + { + return new self($writer); + } + + /** + * Generate the SQL and the list of positional arguments. + * + * @return array{0: string, 1: array} + * @throws QueryBuilderException if building the query failed + */ + public function toSql(): array + { + $sb = new SqlBuilder($this->validating); + + // A top-level query is written without the parentheses it would get as a subquery. + if ($this->writer instanceof InnerSqlWriter) { + $this->writer->innerWriteSql($sb); + } else { + $this->writer->writeSql($sb); + } + + $sql = $sb->getSql(); + $args = $sb->getArgs(); + + foreach ($sb->getNamedArgs() as $name => $argIndices) { + if (!array_key_exists($name, $this->namedArgs)) { + throw new QueryBuilderException(sprintf('missing named argument "%s"', $name)); + } + // A name can occupy several placeholders (MySQL '?' is not reusable). + foreach ($argIndices as $argIdx) { + $args[$argIdx - 1] = $this->namedArgs[$name]; + } + } + + $errors = $sb->getErrors(); + if ($errors !== []) { + $message = implode("\n", array_map(static fn (\Throwable $e): string => $e->getMessage(), $errors)); + throw new QueryBuilderException($message, 0, $errors[0]); + } + + return [$sql, $args]; + } + + /** + * Bind values for named placeholders (created via {@see SqlBuilder::bindPlaceholder()}). + * + * @param array $args + */ + public function withNamedArgs(array $args): self + { + return new self($this->writer, $args, $this->validating); + } + + /** + * Disable validation (e.g. of identifiers) while building. + */ + public function withoutValidation(): self + { + return new self($this->writer, $this->namedArgs, false); + } +} diff --git a/src/MySQL/Builder/QueryBuilderException.php b/src/MySQL/Builder/QueryBuilderException.php new file mode 100644 index 0000000..e237e1c --- /dev/null +++ b/src/MySQL/Builder/QueryBuilderException.php @@ -0,0 +1,13 @@ + + */ + private array $args = []; + + /** Current positional placeholder index (1-based count of placeholders). */ + private int $argIdx = 0; + + /** + * Map of named argument name to the (1-based) positional placeholder indices + * it occupies. MySQL `?` placeholders are not reusable, so a name may map to + * several indices — one per occurrence. + * + * @var array> + */ + private array $namedArgs = []; + + /** @var list<\Throwable> */ + private array $errors = []; + + public function __construct( + private readonly bool $validating = true, + ) { + } + + public function writeString(string $s): void + { + $this->sql .= $s; + } + + /** + * Create a new positional placeholder bound to the given argument value. + */ + public function createPlaceholder(mixed $argument): string + { + $this->args[] = $argument; + $this->argIdx++; + + return '?'; + } + + /** + * Create a placeholder for a named argument. Unlike PostgreSQL's numbered + * placeholders, MySQL `?` is positional and cannot be reused, so every + * occurrence of the same name gets its own placeholder and arg slot; all of a + * name's slots are filled with the same value by {@see QueryBuilder::withNamedArgs()}. + */ + public function bindPlaceholder(string $name): string + { + $this->args[] = null; + $this->argIdx++; + $this->namedArgs[$name][] = $this->argIdx; + + return '?'; + } + + public function isValidating(): bool + { + return $this->validating; + } + + public function addError(\Throwable $error): void + { + $this->errors[] = $error; + } + + public function getSql(): string + { + return $this->sql; + } + + /** + * @return array + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * @return array> + */ + public function getNamedArgs(): array + { + return $this->namedArgs; + } + + /** + * @return list<\Throwable> + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/MySQL/Builder/SqlWriter.php b/src/MySQL/Builder/SqlWriter.php new file mode 100644 index 0000000..327e9bd --- /dev/null +++ b/src/MySQL/Builder/SqlWriter.php @@ -0,0 +1,20 @@ +writeString(Literals::quoteLiteral($this->value)); + } +} diff --git a/src/MySQL/Builder/TypeExp.php b/src/MySQL/Builder/TypeExp.php new file mode 100644 index 0000000..62cc1b3 --- /dev/null +++ b/src/MySQL/Builder/TypeExp.php @@ -0,0 +1,47 @@ +isValidating() && !self::isValidType($this->type)) { + $sb->addError(new QueryBuilderException(sprintf('type: invalid: %s', $this->type))); + + return; + } + + $sb->writeString($this->type); + } + + private static function isValidType(string $s): bool + { + return preg_match(self::VALID_TYPE_REGEX, trim($s)) === 1; + } +} diff --git a/src/MySQL/Builder/UnaryExp.php b/src/MySQL/Builder/UnaryExp.php new file mode 100644 index 0000000..f7c36f8 --- /dev/null +++ b/src/MySQL/Builder/UnaryExp.php @@ -0,0 +1,48 @@ +precedence; + } + + public function writeSql(SqlBuilder $sb): void + { + $needsParens = $this->exp instanceof Precedencer && $this->exp->precedence() < $this->precedence; + + $s = $this->prefix !== '' ? $this->prefix . ' ' : ''; + if ($needsParens) { + $s .= '('; + } + $sb->writeString($s); + + $this->exp->writeSql($sb); + + $s = $needsParens ? ')' : ''; + if ($this->suffix !== '') { + $s .= ' ' . $this->suffix; + } + if ($s !== '') { + $sb->writeString($s); + } + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php new file mode 100644 index 0000000..97eb0e0 --- /dev/null +++ b/src/MySQL/Q.php @@ -0,0 +1,173 @@ + new Arg($a), array_values($arguments))); + } + + /** + * Combine the given expressions with AND. + */ + public static function and(Exp ...$exps): Junction + { + return Junction::and(...$exps); + } + + /** + * Combine the given expressions with OR. + */ + public static function or(Exp ...$exps): Junction + { + return Junction::or(...$exps); + } + + /** + * Negate an expression (`NOT ...`). + */ + public static function not(Exp $exp): UnaryExp + { + return new UnaryExp($exp, Precedence::of('NOT'), prefix: 'NOT'); + } + + /** + * Arithmetic negation (`- ...`) of a numeric expression. + */ + public static function neg(Exp $exp): UnaryExp + { + return new UnaryExp($exp, 5, prefix: '-'); // unary minus binds tightly + } + + /** + * Build a `COALESCE(...)` expression. + */ + public static function coalesce(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('COALESCE', array_values([$exp, ...$rest])); + } + + /** + * Start a new query builder for the given writer. + */ + public static function build(SqlWriter $writer): QueryBuilder + { + return QueryBuilder::build($writer); + } +} diff --git a/tests/MySQL/Q/ExpressionsTest.php b/tests/MySQL/Q/ExpressionsTest.php new file mode 100644 index 0000000..2adda88 --- /dev/null +++ b/tests/MySQL/Q/ExpressionsTest.php @@ -0,0 +1,66 @@ +toRenderSql('users'); + expect(Q::n('order'))->toRenderSql('`order`'); + expect(Q::n('u.name'))->toRenderSql('u.name'); + }); + + it('quotes string literals with doubled quotes and escaped backslashes', function () { + expect(Q::string("a'b"))->toRenderSql("'a''b'"); + expect(Q::string('a\\b'))->toRenderSql("'a\\\\b'"); + }); + + it('renders numeric, bool and null literals', function () { + expect(Q::int(10))->toRenderSql('10'); + expect(Q::float(0.5))->toRenderSql('0.5'); + expect(Q::bool(true))->toRenderSql('TRUE'); + expect(Q::null())->toRenderSql('NULL'); + }); + + it('binds positional arguments as ?', function () { + expect(Q::arg(5))->toRenderSql('?', [5]); + expect(Q::n('a')->eq(Q::arg(1)))->toRenderSql('a = ?', [1]); + }); + + it('renders operators that become functions in MySQL', function () { + expect(Q::n('a')->concat(Q::string('x')))->toRenderSql("CONCAT(a, 'x')"); + expect(Q::n('a')->pow(Q::int(2)))->toRenderSql('POW(a, 2)'); + expect(Q::n('a')->cast('UNSIGNED'))->toRenderSql('CAST(a AS UNSIGNED)'); + expect(Q::n('a')->cast('DECIMAL(10,2)'))->toRenderSql('CAST(a AS DECIMAL(10,2))'); + expect(Q::n('a')->jsonContains(Q::arg('1')))->toRenderSql('JSON_CONTAINS(a, ?)', ['1']); + }); + + it('renders null-safe equality and IS [NOT] DISTINCT FROM', function () { + expect(Q::n('a')->nullSafeEq(Q::arg(1)))->toRenderSql('a <=> ?', [1]); + expect(Q::n('a')->isNotDistinctFrom(Q::arg(1)))->toRenderSql('a <=> ?', [1]); + expect(Q::n('a')->isDistinctFrom(Q::arg(1)))->toRenderSql('NOT a <=> ?', [1]); + }); + + it('renders JSON path extraction operators', function () { + expect(Q::n('doc')->jsonExtract(Q::string('$.name')))->toRenderSql("doc -> '$.name'"); + expect(Q::n('doc')->jsonExtractText(Q::string('$.name')))->toRenderSql("doc ->> '$.name'"); + }); + + it('renders IN with an argument list', function () { + expect(Q::n('a')->in(Q::args(1, 2, 3)))->toRenderSql('a IN (?,?,?)', [1, 2, 3]); + expect(Q::n('a')->notIn(Q::exps(Q::int(1), Q::int(2))))->toRenderSql('a NOT IN (1,2)'); + }); + + it('renders REGEXP and LIKE', function () { + expect(Q::n('a')->like(Q::string('%x%')))->toRenderSql("a LIKE '%x%'"); + expect(Q::n('a')->regexp(Q::string('^x')))->toRenderSql("a REGEXP '^x'"); + }); + + it('combines conditions with AND / OR / NOT', function () { + expect(Q::and(Q::n('a')->eq(Q::int(1)), Q::n('b')->eq(Q::int(2)))) + ->toRenderSql('a = 1 AND b = 2'); + expect(Q::not(Q::n('a')))->toRenderSql('NOT a'); + expect(Q::coalesce(Q::n('a'), Q::int(0)))->toRenderSql('COALESCE(a, 0)'); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index bf4d1b1..f295890 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,26 +2,43 @@ declare(strict_types=1); -use Flowpack\QueryObjectBuilder\PostgreSQL\Builder\QueryBuilder; -use Flowpack\QueryObjectBuilder\PostgreSQL\Builder\SqlWriter; +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder as MySQLQueryBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter as MySQLSqlWriter; +use Flowpack\QueryObjectBuilder\PostgreSQL\Builder\QueryBuilder as PostgreSQLQueryBuilder; +use Flowpack\QueryObjectBuilder\PostgreSQL\Builder\SqlWriter as PostgreSQLSqlWriter; use PHPUnit\Framework\Assert; // Expect the query under test to render to the given SQL — ignoring insignificant -// whitespace — and to bind exactly the given positional arguments. +// whitespace — and to bind exactly the given positional arguments. Works for any +// dialect: the matching QueryBuilder is chosen from the writer's type. expect()->extend('toRenderSql', function (string $expectedSql, ?array $args = null): void { - [$sql, $boundArgs] = QueryBuilder::build(asSqlWriter($this->value))->toSql(); + [$sql, $boundArgs] = renderQuery($this->value); Assert::assertSame(normalizeSql($expectedSql), normalizeSql($sql)); Assert::assertSame($args ?? [], $boundArgs); }); /** - * Narrow the (statically untyped) expectation value to a SqlWriter. + * Build the query under test through the QueryBuilder matching its dialect. + * + * @return array{0: string, 1: array} */ -function asSqlWriter(mixed $value): SqlWriter +function renderQuery(mixed $value): array { - if (!$value instanceof SqlWriter) { - throw new InvalidArgumentException('toRenderSql() expects a ' . SqlWriter::class . ' value.'); + if ($value instanceof MySQLSqlWriter) { + return MySQLQueryBuilder::build($value)->toSql(); + } + + return PostgreSQLQueryBuilder::build(asPostgreSQLSqlWriter($value))->toSql(); +} + +/** + * Narrow the (statically untyped) expectation value to a PostgreSQL SqlWriter. + */ +function asPostgreSQLSqlWriter(mixed $value): PostgreSQLSqlWriter +{ + if (!$value instanceof PostgreSQLSqlWriter) { + throw new InvalidArgumentException('toRenderSql() expects a ' . PostgreSQLSqlWriter::class . ' or ' . MySQLSqlWriter::class . ' value.'); } return $value; From 187a445059e3f0daacd831e96f813fc95d094ec2 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:21:39 +0200 Subject: [PATCH 03/40] wip: mysql CASE expression Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/CaseBuilder.php | 51 +++++++++++++++++++++++++++ src/MySQL/Builder/CaseCondition.php | 19 ++++++++++ src/MySQL/Builder/CaseExp.php | 50 ++++++++++++++++++++++++++ src/MySQL/Builder/CaseWhenBuilder.php | 35 ++++++++++++++++++ src/MySQL/Q.php | 10 ++++++ tests/MySQL/Q/CaseTest.php | 26 ++++++++++++++ 6 files changed, 191 insertions(+) create mode 100644 src/MySQL/Builder/CaseBuilder.php create mode 100644 src/MySQL/Builder/CaseCondition.php create mode 100644 src/MySQL/Builder/CaseExp.php create mode 100644 src/MySQL/Builder/CaseWhenBuilder.php create mode 100644 tests/MySQL/Q/CaseTest.php diff --git a/src/MySQL/Builder/CaseBuilder.php b/src/MySQL/Builder/CaseBuilder.php new file mode 100644 index 0000000..9af27dd --- /dev/null +++ b/src/MySQL/Builder/CaseBuilder.php @@ -0,0 +1,51 @@ + $conditions + */ + public function __construct( + private readonly ?Exp $expression = null, + private readonly array $conditions = [], + private readonly ?Exp $elseResult = null, + ) { + } + + /** + * Start a `WHEN condition` branch; supply its result via + * {@see CaseWhenBuilder::then()}. + */ + public function when(Exp $condition): CaseWhenBuilder + { + return new CaseWhenBuilder( + $this->expression, + [...$this->conditions, new CaseCondition($condition)], + $this->elseResult, + ); + } + + /** + * Set the `ELSE` result. + */ + public function else(Exp $result): self + { + return new self($this->expression, $this->conditions, $result); + } + + /** + * Finish the CASE expression. + */ + public function end(): CaseExp + { + return new CaseExp($this->expression, $this->conditions, $this->elseResult); + } +} diff --git a/src/MySQL/Builder/CaseCondition.php b/src/MySQL/Builder/CaseCondition.php new file mode 100644 index 0000000..eeafedf --- /dev/null +++ b/src/MySQL/Builder/CaseCondition.php @@ -0,0 +1,19 @@ + $conditions + */ + public function __construct( + private readonly ?Exp $expression, + private readonly array $conditions, + private readonly ?Exp $elseResult, + ) { + } + + public function writeSql(SqlBuilder $sb): void + { + $sb->writeString('CASE'); + if ($this->expression !== null) { + $sb->writeString(' '); + $this->expression->writeSql($sb); + } + + if ($sb->isValidating() && $this->conditions === []) { + $sb->addError(new QueryBuilderException('case: no conditions given')); + } + + foreach ($this->conditions as $condition) { + $sb->writeString(' WHEN '); + $condition->condition->writeSql($sb); + $sb->writeString(' THEN '); + $condition->result?->writeSql($sb); + } + + if ($this->elseResult !== null) { + $sb->writeString(' ELSE '); + $this->elseResult->writeSql($sb); + } + + $sb->writeString(' END'); + } +} diff --git a/src/MySQL/Builder/CaseWhenBuilder.php b/src/MySQL/Builder/CaseWhenBuilder.php new file mode 100644 index 0000000..bc0e0c5 --- /dev/null +++ b/src/MySQL/Builder/CaseWhenBuilder.php @@ -0,0 +1,35 @@ + $conditions the last entry has no result yet + */ + public function __construct( + private readonly ?Exp $expression, + private readonly array $conditions, + private readonly ?Exp $elseResult, + ) { + } + + /** + * Set the result for the preceding WHEN condition. + */ + public function then(Exp $result): CaseBuilder + { + $conditions = $this->conditions; + $lastIdx = array_key_last($conditions); + assert($lastIdx !== null); + $conditions[$lastIdx] = new CaseCondition($conditions[$lastIdx]->condition, $result); + + return new CaseBuilder($this->expression, $conditions, $this->elseResult); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 97eb0e0..5cded57 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -7,6 +7,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\Arg; use Flowpack\QueryObjectBuilder\MySQL\Builder\BindExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\BoolLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\CaseBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\DefaultLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; use Flowpack\QueryObjectBuilder\MySQL\Builder\Expressions; @@ -163,6 +164,15 @@ public static function coalesce(Exp $exp, Exp ...$rest): FuncExp return new FuncExp('COALESCE', array_values([$exp, ...$rest])); } + /** + * Start a CASE expression (optionally with a leading expression to compare + * each WHEN against). + */ + public static function case(Exp ...$exp): CaseBuilder + { + return new CaseBuilder($exp[0] ?? null); + } + /** * Start a new query builder for the given writer. */ diff --git a/tests/MySQL/Q/CaseTest.php b/tests/MySQL/Q/CaseTest.php new file mode 100644 index 0000000..daa351b --- /dev/null +++ b/tests/MySQL/Q/CaseTest.php @@ -0,0 +1,26 @@ +when(Q::n('a')->eq(Q::int(1)))->then(Q::string('one')) + ->when(Q::n('a')->eq(Q::int(2)))->then(Q::string('two')) + ->else(Q::string('other')) + ->end(); + + expect($case)->toRenderSql("CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END"); + }); + + it('renders a simple CASE expression with a leading operand', function () { + $case = Q::case(Q::n('status')) + ->when(Q::int(1))->then(Q::string('active')) + ->else(Q::string('inactive')) + ->end(); + + expect($case)->toRenderSql("CASE status WHEN 1 THEN 'active' ELSE 'inactive' END"); + }); +}); From 26261f901f713e2154e00b2d0722c4ffc5b28977 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:31:27 +0200 Subject: [PATCH 04/40] wip: mysql SELECT builder (joins, grouping, ordering, limit, locking, set ops, CTE, lateral) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/Combination.php | 23 ++ src/MySQL/Builder/CombinationBuilder.php | 42 ++ src/MySQL/Builder/CombinationType.php | 12 + src/MySQL/Builder/ExistsExp.php | 23 ++ src/MySQL/Builder/ForSelectBuilder.php | 46 +++ src/MySQL/Builder/FromItem.php | 48 +++ src/MySQL/Builder/FromSelectBuilder.php | 41 ++ src/MySQL/Builder/GroupBySelectBuilder.php | 20 + src/MySQL/Builder/Join.php | 48 +++ src/MySQL/Builder/JoinSelectBuilder.php | 64 +++ src/MySQL/Builder/JoinType.php | 16 + src/MySQL/Builder/LockingClause.php | 36 ++ src/MySQL/Builder/OrderByClause.php | 29 ++ src/MySQL/Builder/OrderBySelectBuilder.php | 40 ++ src/MySQL/Builder/OutputExpr.php | 19 + src/MySQL/Builder/RendersWithQueries.php | 37 ++ src/MySQL/Builder/SelectBuilder.php | 385 +++++++++++++++++++ src/MySQL/Builder/SelectQueryParts.php | 47 +++ src/MySQL/Builder/SelectSelectBuilder.php | 34 ++ src/MySQL/Builder/SortOrder.php | 11 + src/MySQL/Builder/SubqueryExp.php | 32 ++ src/MySQL/Builder/WithBuilder.php | 60 +++ src/MySQL/Builder/WithQuery.php | 12 + src/MySQL/Builder/WithQueryItem.php | 39 ++ src/MySQL/Builder/WithWithBuilder.php | 51 +++ src/MySQL/Builder/WritesParenthesizedSql.php | 25 ++ src/MySQL/Q.php | 54 +++ tests/MySQL/Q/SelectBuilderTest.php | 105 +++++ 28 files changed, 1399 insertions(+) create mode 100644 src/MySQL/Builder/Combination.php create mode 100644 src/MySQL/Builder/CombinationBuilder.php create mode 100644 src/MySQL/Builder/CombinationType.php create mode 100644 src/MySQL/Builder/ExistsExp.php create mode 100644 src/MySQL/Builder/ForSelectBuilder.php create mode 100644 src/MySQL/Builder/FromItem.php create mode 100644 src/MySQL/Builder/FromSelectBuilder.php create mode 100644 src/MySQL/Builder/GroupBySelectBuilder.php create mode 100644 src/MySQL/Builder/Join.php create mode 100644 src/MySQL/Builder/JoinSelectBuilder.php create mode 100644 src/MySQL/Builder/JoinType.php create mode 100644 src/MySQL/Builder/LockingClause.php create mode 100644 src/MySQL/Builder/OrderByClause.php create mode 100644 src/MySQL/Builder/OrderBySelectBuilder.php create mode 100644 src/MySQL/Builder/OutputExpr.php create mode 100644 src/MySQL/Builder/RendersWithQueries.php create mode 100644 src/MySQL/Builder/SelectBuilder.php create mode 100644 src/MySQL/Builder/SelectQueryParts.php create mode 100644 src/MySQL/Builder/SelectSelectBuilder.php create mode 100644 src/MySQL/Builder/SortOrder.php create mode 100644 src/MySQL/Builder/SubqueryExp.php create mode 100644 src/MySQL/Builder/WithBuilder.php create mode 100644 src/MySQL/Builder/WithQuery.php create mode 100644 src/MySQL/Builder/WithQueryItem.php create mode 100644 src/MySQL/Builder/WithWithBuilder.php create mode 100644 src/MySQL/Builder/WritesParenthesizedSql.php create mode 100644 tests/MySQL/Q/SelectBuilderTest.php diff --git a/src/MySQL/Builder/Combination.php b/src/MySQL/Builder/Combination.php new file mode 100644 index 0000000..cbbd290 --- /dev/null +++ b/src/MySQL/Builder/Combination.php @@ -0,0 +1,23 @@ +derive(self::class, combinations: $this->rebuildLastCombination(all: true)); + } + + public function query(SelectBuilder $query): self + { + return $this->derive(self::class, combinations: $this->rebuildLastCombination(query: $query)); + } + + /** + * Return the combinations with the last one replaced by a copy carrying the + * given overrides. + * + * @return list + */ + private function rebuildLastCombination(?bool $all = null, ?SelectBuilder $query = null): array + { + $combinations = $this->combinations; + $lastIdx = array_key_last($combinations); + assert($lastIdx !== null); + + $c = $combinations[$lastIdx]; + $combinations[$lastIdx] = new Combination($c->parts, $c->type, $all ?? $c->all, $query ?? $c->query); + + return $combinations; + } +} diff --git a/src/MySQL/Builder/CombinationType.php b/src/MySQL/Builder/CombinationType.php new file mode 100644 index 0000000..2d5dba5 --- /dev/null +++ b/src/MySQL/Builder/CombinationType.php @@ -0,0 +1,12 @@ +writeString('EXISTS '); + $this->subquery->writeSql($sb); + } +} diff --git a/src/MySQL/Builder/ForSelectBuilder.php b/src/MySQL/Builder/ForSelectBuilder.php new file mode 100644 index 0000000..37965f2 --- /dev/null +++ b/src/MySQL/Builder/ForSelectBuilder.php @@ -0,0 +1,46 @@ +deriveLocking(ofTables: array_values($tables)); + } + + public function nowait(): self + { + return $this->deriveLocking(waitPolicy: 'NOWAIT'); + } + + public function skipLocked(): self + { + return $this->deriveLocking(waitPolicy: 'SKIP LOCKED'); + } + + /** + * @param list|null $ofTables + */ + private function deriveLocking(?array $ofTables = null, ?string $waitPolicy = null): self + { + $lc = $this->parts->lockingClause; + assert($lc !== null); + + return $this->derive(self::class, lockingClause: new LockingClause( + $lc->lockStrength, + $ofTables ?? $lc->ofTables, + $waitPolicy ?? $lc->waitPolicy, + )); + } +} diff --git a/src/MySQL/Builder/FromItem.php b/src/MySQL/Builder/FromItem.php new file mode 100644 index 0000000..2ec6e1d --- /dev/null +++ b/src/MySQL/Builder/FromItem.php @@ -0,0 +1,48 @@ + $columnAliases + */ + public function __construct( + public readonly FromExp $from, + public readonly string $alias = '', + public readonly bool $lateral = false, + public readonly array $columnAliases = [], + ) { + } + + public function writeSql(SqlBuilder $sb): void + { + if ($this->lateral) { + $sb->writeString('LATERAL '); + } + + $this->from->writeSql($sb); + + $s = ''; + if ($this->alias !== '') { + $s .= ' AS ' . $this->alias; + } + if ($this->columnAliases !== []) { + if ($this->alias === '') { + $s .= ' AS'; + } + $s .= ' (' . implode(',', $this->columnAliases) . ')'; + } + if ($s !== '') { + $sb->writeString($s); + } + } +} diff --git a/src/MySQL/Builder/FromSelectBuilder.php b/src/MySQL/Builder/FromSelectBuilder.php new file mode 100644 index 0000000..5d05f56 --- /dev/null +++ b/src/MySQL/Builder/FromSelectBuilder.php @@ -0,0 +1,41 @@ +parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + $item = $from[$lastIdx]; + $from[$lastIdx] = new FromItem($item->from, $alias, $item->lateral, $item->columnAliases); + + return $this->derive(self::class, from: $from); + } + + /** + * Set the column aliases for the last added from item. + */ + public function columnAliases(string ...$aliases): self + { + $from = $this->parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + $item = $from[$lastIdx]; + $from[$lastIdx] = new FromItem($item->from, $item->alias, $item->lateral, array_values($aliases)); + + return $this->derive(self::class, from: $from); + } +} diff --git a/src/MySQL/Builder/GroupBySelectBuilder.php b/src/MySQL/Builder/GroupBySelectBuilder.php new file mode 100644 index 0000000..f2aace9 --- /dev/null +++ b/src/MySQL/Builder/GroupBySelectBuilder.php @@ -0,0 +1,20 @@ +derive(self::class, groupByWithRollup: true); + } +} diff --git a/src/MySQL/Builder/Join.php b/src/MySQL/Builder/Join.php new file mode 100644 index 0000000..24b16f1 --- /dev/null +++ b/src/MySQL/Builder/Join.php @@ -0,0 +1,48 @@ + $using + */ + public function __construct( + public readonly JoinType $joinType, + public readonly bool $lateral, + public readonly FromExp $from, + public readonly string $alias = '', + public readonly ?Exp $on = null, + public readonly array $using = [], + ) { + } + + public function writeSql(SqlBuilder $sb): void + { + $s = $this->joinType->value; + if ($this->lateral) { + $s .= ' LATERAL'; + } + $s .= ' '; + $sb->writeString($s); + $this->from->writeSql($sb); + + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + + if ($this->on !== null) { + $sb->writeString(' ON '); + $this->on->writeSql($sb); + } elseif ($this->using !== []) { + $sb->writeString(' USING (' . implode(', ', $this->using) . ')'); + } + } +} diff --git a/src/MySQL/Builder/JoinSelectBuilder.php b/src/MySQL/Builder/JoinSelectBuilder.php new file mode 100644 index 0000000..c36ed46 --- /dev/null +++ b/src/MySQL/Builder/JoinSelectBuilder.php @@ -0,0 +1,64 @@ +derive(self::class, from: $this->rebuildLastJoin(alias: $alias)); + } + + /** + * Set the ON condition for the last added join. + */ + public function on(Exp $cond): SelectBuilder + { + return $this->derive(SelectBuilder::class, from: $this->rebuildLastJoin(on: $cond)); + } + + /** + * Set the USING columns for the last added join. + */ + public function using(string ...$columns): SelectBuilder + { + return $this->derive(SelectBuilder::class, from: $this->rebuildLastJoin(using: array_values($columns))); + } + + /** + * Return the from list with the last join replaced by a copy carrying the + * given overrides. + * + * @param list|null $using + * @return list + */ + private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array + { + $from = $this->parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + + $join = $from[$lastIdx]->from; + assert($join instanceof Join); + + $from[$lastIdx] = new FromItem(new Join( + $join->joinType, + $join->lateral, + $join->from, + $alias ?? $join->alias, + $on ?? $join->on, + $using ?? $join->using, + )); + + return $from; + } +} diff --git a/src/MySQL/Builder/JoinType.php b/src/MySQL/Builder/JoinType.php new file mode 100644 index 0000000..4ba54ce --- /dev/null +++ b/src/MySQL/Builder/JoinType.php @@ -0,0 +1,16 @@ + $ofTables + */ + public function __construct( + public readonly string $lockStrength, + public readonly array $ofTables = [], + public readonly string $waitPolicy = '', + ) { + } + + public function writeSql(SqlBuilder $sb): void + { + $s = 'FOR ' . $this->lockStrength; + if ($this->ofTables !== []) { + $s .= ' OF ' . implode(',', $this->ofTables); + } + if ($this->waitPolicy !== '') { + $s .= ' ' . $this->waitPolicy; + } + $sb->writeString($s); + } +} diff --git a/src/MySQL/Builder/OrderByClause.php b/src/MySQL/Builder/OrderByClause.php new file mode 100644 index 0000000..275db79 --- /dev/null +++ b/src/MySQL/Builder/OrderByClause.php @@ -0,0 +1,29 @@ +exp->writeSql($sb); + + if ($this->order !== null) { + $sb->writeString(' ' . $this->order->value); + } + } +} diff --git a/src/MySQL/Builder/OrderBySelectBuilder.php b/src/MySQL/Builder/OrderBySelectBuilder.php new file mode 100644 index 0000000..6f3cbe8 --- /dev/null +++ b/src/MySQL/Builder/OrderBySelectBuilder.php @@ -0,0 +1,40 @@ +derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); + } + + public function desc(): self + { + return $this->derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); + } + + /** + * Return the order by list with the last term replaced by a copy carrying the + * given sort direction. + * + * @return list + */ + private function rebuildLastOrderBy(SortOrder $order): array + { + $orderBys = $this->parts->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $orderBys; + } +} diff --git a/src/MySQL/Builder/OutputExpr.php b/src/MySQL/Builder/OutputExpr.php new file mode 100644 index 0000000..e9b2e0c --- /dev/null +++ b/src/MySQL/Builder/OutputExpr.php @@ -0,0 +1,19 @@ + $withQueries + */ + protected function writeWithQueries(SqlBuilder $sb, array $withQueries): void + { + $hasRecursive = false; + foreach ($withQueries as $w) { + if ($w->recursive) { + $hasRecursive = true; + break; + } + } + + // RECURSIVE is written once, right after WITH, and applies to all queries. + $sb->writeString($hasRecursive ? 'WITH RECURSIVE ' : 'WITH '); + foreach ($withQueries as $i => $w) { + if ($i > 0) { + $sb->writeString(','); + } + $w->writeSql($sb); + } + $sb->writeString(' '); + } +} diff --git a/src/MySQL/Builder/SelectBuilder.php b/src/MySQL/Builder/SelectBuilder.php new file mode 100644 index 0000000..e05a816 --- /dev/null +++ b/src/MySQL/Builder/SelectBuilder.php @@ -0,0 +1,385 @@ + $withQueries the leading WITH clause, if any + * @param list $combinations previous selects combined via UNION / INTERSECT / EXCEPT + */ + public function __construct( + protected readonly SelectQueryParts $parts = new SelectQueryParts(), + protected readonly array $withQueries = [], + protected readonly array $combinations = [], + ) { + } + + /** + * Add the given expressions to the select list. + */ + public function select(Exp ...$exps): SelectSelectBuilder + { + $selectList = $this->parts->selectList; + foreach ($exps as $exp) { + $selectList[] = new OutputExpr($exp); + } + + return $this->derive(SelectSelectBuilder::class, selectList: $selectList); + } + + /** + * Add a table / subquery to the FROM clause. + */ + public function from(FromExp $from): FromSelectBuilder + { + return $this->derive(FromSelectBuilder::class, from: [...$this->parts->from, new FromItem($from)]); + } + + /** + * Add a `LATERAL` subquery to the FROM clause (MySQL only). + */ + public function fromLateral(FromLateralExp $from): FromSelectBuilder + { + return $this->derive(FromSelectBuilder::class, from: [...$this->parts->from, new FromItem($from, lateral: true)]); + } + + public function join(FromExp $from): JoinSelectBuilder + { + return $this->addJoin(JoinType::Inner, $from, false); + } + + public function joinLateral(FromExp $from): JoinSelectBuilder + { + return $this->addJoin(JoinType::Inner, $from, true); + } + + public function leftJoin(FromExp $from): JoinSelectBuilder + { + return $this->addJoin(JoinType::Left, $from, false); + } + + public function leftJoinLateral(FromExp $from): JoinSelectBuilder + { + return $this->addJoin(JoinType::Left, $from, true); + } + + public function rightJoin(FromExp $from): JoinSelectBuilder + { + return $this->addJoin(JoinType::Right, $from, false); + } + + public function crossJoin(FromExp $from): JoinSelectBuilder + { + return $this->addJoin(JoinType::Cross, $from, false); + } + + public function crossJoinLateral(FromExp $from): JoinSelectBuilder + { + return $this->addJoin(JoinType::Cross, $from, true); + } + + private function addJoin(JoinType $joinType, FromExp $from, bool $lateral): JoinSelectBuilder + { + return $this->derive( + JoinSelectBuilder::class, + from: [...$this->parts->from, new FromItem(new Join($joinType, $lateral, $from))], + ); + } + + /** + * Add a WHERE condition. Multiple calls are joined with AND. + */ + public function where(Exp $cond): SelectBuilder + { + return $this->derive(SelectBuilder::class, whereConjunction: [...$this->parts->whereConjunction, $cond]); + } + + /** + * Add expressions to the GROUP BY clause. Refine with + * {@see GroupBySelectBuilder::withRollup()}. + */ + public function groupBy(Exp ...$exps): GroupBySelectBuilder + { + return $this->derive(GroupBySelectBuilder::class, groupBys: [...$this->parts->groupBys, ...array_values($exps)]); + } + + /** + * Add a HAVING condition. Multiple calls are joined with AND. + */ + public function having(Exp $cond): SelectBuilder + { + return $this->derive(SelectBuilder::class, havingConjunction: [...$this->parts->havingConjunction, $cond]); + } + + /** + * Add an ORDER BY expression (refine it via {@see OrderBySelectBuilder}). + */ + public function orderBy(Exp $exp): OrderBySelectBuilder + { + return $this->derive(OrderBySelectBuilder::class, orderBys: [...$this->parts->orderBys, new OrderByClause($exp)]); + } + + public function limit(Exp $exp): SelectBuilder + { + return $this->derive(SelectBuilder::class, limit: $exp); + } + + public function offset(Exp $exp): SelectBuilder + { + return $this->derive(SelectBuilder::class, offset: $exp); + } + + /** + * Combine this select with the following one using UNION. Refine with + * {@see CombinationBuilder::all()} or supply the query via + * {@see CombinationBuilder::query()}. + */ + public function union(): CombinationBuilder + { + return $this->addCombination(CombinationType::Union); + } + + public function intersect(): CombinationBuilder + { + return $this->addCombination(CombinationType::Intersect); + } + + public function except(): CombinationBuilder + { + return $this->addCombination(CombinationType::Except); + } + + private function addCombination(CombinationType $type): CombinationBuilder + { + // Archive the current parts as a combination and start a fresh select. + return $this->derive( + CombinationBuilder::class, + parts: new SelectQueryParts(), + combinations: [...$this->combinations, new Combination($this->parts, $type)], + ); + } + + public function forUpdate(): ForSelectBuilder + { + return $this->derive(ForSelectBuilder::class, lockingClause: new LockingClause('UPDATE')); + } + + public function forShare(): ForSelectBuilder + { + return $this->derive(ForSelectBuilder::class, lockingClause: new LockingClause('SHARE')); + } + + /** + * Append the given WITH queries to this select's WITH clause. + */ + public function appendWith(WithBuilder $with): SelectBuilder + { + return $this->derive(SelectBuilder::class, withQueries: [...$this->withQueries, ...$with->withQueryItems()]); + } + + /** + * Whether this builder carries no content yet: no WITH queries, no + * combinations and empty select parts. Useful for conditional query building. + */ + public function isEmpty(): bool + { + return $this->withQueries === [] && $this->combinations === [] && $this->parts->isEmpty(); + } + + /** + * Assemble a new builder of the given type from the current state with the + * given fields replaced; a null argument keeps the current value. This is the + * single place where a derived {@see SelectQueryParts} and the type-state + * transition are produced. + * + * @template T of SelectBuilder + * @param class-string $class + * @param list|null $selectList + * @param list|null $from + * @param list|null $whereConjunction + * @param list|null $groupBys + * @param list|null $havingConjunction + * @param list|null $orderBys + * @param list|null $withQueries + * @param list|null $combinations + * @return T + */ + protected function derive( + string $class, + ?SelectQueryParts $parts = null, + ?bool $distinct = null, + ?array $selectList = null, + ?array $from = null, + ?array $whereConjunction = null, + ?array $groupBys = null, + ?bool $groupByWithRollup = null, + ?array $havingConjunction = null, + ?array $orderBys = null, + ?Exp $limit = null, + ?Exp $offset = null, + ?LockingClause $lockingClause = null, + ?array $withQueries = null, + ?array $combinations = null, + ): SelectBuilder { + $parts ??= new SelectQueryParts( + distinct: $distinct ?? $this->parts->distinct, + selectList: $selectList ?? $this->parts->selectList, + from: $from ?? $this->parts->from, + whereConjunction: $whereConjunction ?? $this->parts->whereConjunction, + groupBys: $groupBys ?? $this->parts->groupBys, + groupByWithRollup: $groupByWithRollup ?? $this->parts->groupByWithRollup, + havingConjunction: $havingConjunction ?? $this->parts->havingConjunction, + orderBys: $orderBys ?? $this->parts->orderBys, + limit: $limit ?? $this->parts->limit, + offset: $offset ?? $this->parts->offset, + lockingClause: $lockingClause ?? $this->parts->lockingClause, + ); + + return new $class($parts, $withQueries ?? $this->withQueries, $combinations ?? $this->combinations); + } + + /** + * Write the select without the surrounding parentheses (the top-level query). + * + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + if ($this->withQueries !== []) { + $this->writeWithQueries($sb, $this->withQueries); + } + + // Previous selects combined via UNION / INTERSECT / EXCEPT come first. + foreach ($this->combinations as $c) { + $this->writeSelectParts($sb, $c->parts); + $s = ' ' . $c->type->value; + if ($c->all) { + $s .= ' ALL'; + } + $sb->writeString($s . ' '); + $c->query?->writeSql($sb); + } + + if (!$this->parts->isEmpty()) { + $this->writeSelectParts($sb, $this->parts); + } + + if ($this->parts->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->parts->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->parts->limit !== null) { + $sb->writeString(' LIMIT '); + $this->parts->limit->writeSql($sb); + } + + if ($this->parts->offset !== null) { + $sb->writeString(' OFFSET '); + $this->parts->offset->writeSql($sb); + } + + if ($this->parts->lockingClause !== null) { + $sb->writeString(' '); + $this->parts->lockingClause->writeSql($sb); + } + } + + private function writeSelectParts(SqlBuilder $sb, SelectQueryParts $parts): void + { + // Accumulate literal SQL into $s and only flush before a nested writer + // needs to write (and once at the very end). + $sb->writeString('SELECT '); + $s = $parts->distinct ? 'DISTINCT ' : ''; + $needComma = false; + + foreach ($parts->selectList as $out) { + if ($needComma) { + $s .= ','; + } + $sb->writeString($s); + $s = ''; + + $out->exp->writeSql($sb); + + if ($out->alias !== '') { + $s = ' AS ' . $out->alias; + } + $needComma = true; + } + + if ($parts->from !== []) { + $s .= ' FROM '; + foreach ($parts->from as $i => $fromItem) { + if ($i > 0) { + // A join attaches to the previous item; a plain item is comma-separated. + $s .= $fromItem->from instanceof Join ? ' ' : ','; + } + $sb->writeString($s); + $s = ''; + + $fromItem->writeSql($sb); + } + } + + if ($parts->whereConjunction !== []) { + $sb->writeString($s . ' WHERE '); + $s = ''; + + Junction::and(...$parts->whereConjunction)->writeSql($sb); + } + + if ($parts->groupBys !== []) { + $sb->writeString($s . ' GROUP BY '); + $s = ''; + + foreach ($parts->groupBys as $i => $groupBy) { + if ($i > 0) { + $sb->writeString(','); + } + $groupBy->writeSql($sb); + } + if ($parts->groupByWithRollup) { + $s = ' WITH ROLLUP'; + } + } + + if ($parts->havingConjunction !== []) { + $sb->writeString($s . ' HAVING '); + $s = ''; + + Junction::and(...$parts->havingConjunction)->writeSql($sb); + } + + if ($s !== '') { + $sb->writeString($s); + } + } +} diff --git a/src/MySQL/Builder/SelectQueryParts.php b/src/MySQL/Builder/SelectQueryParts.php new file mode 100644 index 0000000..6f8f503 --- /dev/null +++ b/src/MySQL/Builder/SelectQueryParts.php @@ -0,0 +1,47 @@ + $selectList + * @param list $from + * @param list $whereConjunction conditions joined with AND + * @param list $groupBys the GROUP BY expressions + * @param list $havingConjunction conditions joined with AND + * @param list $orderBys + */ + public function __construct( + public readonly bool $distinct = false, + public readonly array $selectList = [], + public readonly array $from = [], + public readonly array $whereConjunction = [], + public readonly array $groupBys = [], + public readonly bool $groupByWithRollup = false, + public readonly array $havingConjunction = [], + public readonly array $orderBys = [], + public readonly ?Exp $limit = null, + public readonly ?Exp $offset = null, + public readonly ?LockingClause $lockingClause = null, + ) { + } + + public function isEmpty(): bool + { + return !$this->distinct && $this->selectList === [] && $this->from === [] + && $this->whereConjunction === [] && $this->groupBys === [] && $this->havingConjunction === [] + && $this->orderBys === [] && $this->limit === null && $this->offset === null + && $this->lockingClause === null; + } +} diff --git a/src/MySQL/Builder/SelectSelectBuilder.php b/src/MySQL/Builder/SelectSelectBuilder.php new file mode 100644 index 0000000..044927f --- /dev/null +++ b/src/MySQL/Builder/SelectSelectBuilder.php @@ -0,0 +1,34 @@ +parts->selectList; + $lastIdx = array_key_last($selectList); + assert($lastIdx !== null); + $selectList[$lastIdx] = new OutputExpr($selectList[$lastIdx]->exp, $alias); + + return $this->derive(self::class, selectList: $selectList); + } + + /** + * Make the select `DISTINCT`. + */ + public function distinct(): SelectBuilder + { + return $this->derive(SelectBuilder::class, distinct: true); + } +} diff --git a/src/MySQL/Builder/SortOrder.php b/src/MySQL/Builder/SortOrder.php new file mode 100644 index 0000000..fc2daf1 --- /dev/null +++ b/src/MySQL/Builder/SortOrder.php @@ -0,0 +1,11 @@ +writeString($this->op . ' '); + + $isSelect = $this->exp instanceof SelectBuilder; + if (!$isSelect) { + $sb->writeString('('); + } + $this->exp->writeSql($sb); + if (!$isSelect) { + $sb->writeString(')'); + } + } +} diff --git a/src/MySQL/Builder/WithBuilder.php b/src/MySQL/Builder/WithBuilder.php new file mode 100644 index 0000000..222ab93 --- /dev/null +++ b/src/MySQL/Builder/WithBuilder.php @@ -0,0 +1,60 @@ + $withQueries + */ + public function __construct( + private readonly array $withQueries, + ) { + } + + /** + * The WITH query items, for appending to another select via + * {@see SelectBuilder::appendWith()}. + * + * @return list + */ + public function withQueryItems(): array + { + return $this->withQueries; + } + + /** + * Add another WITH query (its body is supplied via {@see WithWithBuilder::as()}). + */ + public function with(string $queryName): WithWithBuilder + { + return $this->startWithQuery($queryName, false); + } + + /** + * Add another WITH RECURSIVE query. + */ + public function withRecursive(string $queryName): WithWithBuilder + { + return $this->startWithQuery($queryName, true); + } + + private function startWithQuery(string $queryName, bool $recursive): WithWithBuilder + { + return new WithWithBuilder([...$this->withQueries, new WithQueryItem($recursive, $queryName)]); + } + + /** + * Start the SELECT statement following the WITH clause. + */ + public function select(Exp ...$exps): SelectSelectBuilder + { + return (new SelectBuilder(withQueries: $this->withQueries))->select(...$exps); + } +} diff --git a/src/MySQL/Builder/WithQuery.php b/src/MySQL/Builder/WithQuery.php new file mode 100644 index 0000000..802805b --- /dev/null +++ b/src/MySQL/Builder/WithQuery.php @@ -0,0 +1,12 @@ + $columnNames + */ + public function __construct( + public readonly bool $recursive, + public readonly string $queryName, + public readonly array $columnNames = [], + public readonly ?WithQuery $query = null, + ) { + } + + public function writeSql(SqlBuilder $sb): void + { + $s = $this->queryName; + if ($this->columnNames !== []) { + $s .= '(' . implode(',', $this->columnNames) . ')'; + } + $s .= ' AS '; + $sb->writeString($s); + + // The body renders its own surrounding parentheses. + $this->query?->writeSql($sb); + } +} diff --git a/src/MySQL/Builder/WithWithBuilder.php b/src/MySQL/Builder/WithWithBuilder.php new file mode 100644 index 0000000..07d4675 --- /dev/null +++ b/src/MySQL/Builder/WithWithBuilder.php @@ -0,0 +1,51 @@ + $withQueries + */ + public function __construct( + private readonly array $withQueries, + ) { + } + + /** + * Set the column names for the currently started WITH query. + */ + public function columnNames(string ...$names): self + { + $withQueries = $this->withQueries; + $lastIdx = array_key_last($withQueries); + assert($lastIdx !== null); + + $item = $withQueries[$lastIdx]; + $withQueries[$lastIdx] = new WithQueryItem($item->recursive, $item->queryName, array_values($names), $item->query); + + return new self($withQueries); + } + + /** + * Supply the body for the currently started WITH query. + */ + public function as(WithQuery $query): WithBuilder + { + $withQueries = $this->withQueries; + $lastIdx = array_key_last($withQueries); + assert($lastIdx !== null); + + $item = $withQueries[$lastIdx]; + $withQueries[$lastIdx] = new WithQueryItem($item->recursive, $item->queryName, $item->columnNames, $query); + + return new WithBuilder($withQueries); + } +} diff --git a/src/MySQL/Builder/WritesParenthesizedSql.php b/src/MySQL/Builder/WritesParenthesizedSql.php new file mode 100644 index 0000000..4342d0a --- /dev/null +++ b/src/MySQL/Builder/WritesParenthesizedSql.php @@ -0,0 +1,25 @@ +writeString('('); + $this->innerWriteSql($sb); + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 5cded57..342d048 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -10,6 +10,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\CaseBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\DefaultLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\ExistsExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\Expressions; use Flowpack\QueryObjectBuilder\MySQL\Builder\FloatLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; @@ -19,9 +20,14 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Precedence; use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectSelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter; use Flowpack\QueryObjectBuilder\MySQL\Builder\StringLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SubqueryExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\UnaryExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\WithQueryItem; +use Flowpack\QueryObjectBuilder\MySQL\Builder\WithWithBuilder; /** * Entry point (facade) for building MySQL queries. @@ -35,6 +41,54 @@ private function __construct() { } + /** + * Select the given output expressions and start a new select builder. + */ + public static function select(Exp ...$exps): SelectSelectBuilder + { + return (new SelectBuilder())->select(...$exps); + } + + /** + * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. + */ + public static function with(string $queryName): WithWithBuilder + { + return new WithWithBuilder([new WithQueryItem(false, $queryName)]); + } + + /** + * Start a WITH RECURSIVE clause. + */ + public static function withRecursive(string $queryName): WithWithBuilder + { + return new WithWithBuilder([new WithQueryItem(true, $queryName)]); + } + + /** + * An `EXISTS (subquery)` expression. + */ + public static function exists(SelectBuilder $subquery): ExistsExp + { + return new ExistsExp($subquery); + } + + /** + * An `ANY (...)` row/subquery comparison operand. + */ + public static function any(Exp $exp): SubqueryExp + { + return new SubqueryExp('ANY', $exp); + } + + /** + * An `ALL (...)` row/subquery comparison operand. + */ + public static function all(Exp $exp): SubqueryExp + { + return new SubqueryExp('ALL', $exp); + } + /** * Write the given name / identifier (validated when the query is built). */ diff --git a/tests/MySQL/Q/SelectBuilderTest.php b/tests/MySQL/Q/SelectBuilderTest.php new file mode 100644 index 0000000..482768d --- /dev/null +++ b/tests/MySQL/Q/SelectBuilderTest.php @@ -0,0 +1,105 @@ +from(Q::n('orders')) + ->where(Q::n('id')->eq(Q::arg(1))) + )->toRenderSql('SELECT id,email FROM orders WHERE id = ?', [1]); + + // A reserved keyword used as an identifier is backtick-quoted. + expect(Q::select(Q::n('id'))->from(Q::n('order'))) + ->toRenderSql('SELECT id FROM `order`'); + }); + + it('aliases select expressions and from items', function () { + expect( + Q::select(Q::n('u.id'))->as('user_id') + ->from(Q::n('users'))->as('u') + )->toRenderSql('SELECT u.id AS user_id FROM users AS u'); + }); + + it('renders DISTINCT', function () { + expect( + Q::select(Q::n('country'))->distinct()->from(Q::n('users')) + )->toRenderSql('SELECT DISTINCT country FROM users'); + }); + + it('renders joins with ON and USING', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->leftJoin(Q::n('orders'))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) + )->toRenderSql('SELECT * FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id'); + + expect( + Q::select(Q::n('*'))->from(Q::n('a'))->join(Q::n('b'))->using('id') + )->toRenderSql('SELECT * FROM a JOIN b USING (id)'); + }); + + it('renders GROUP BY with rollup, HAVING and ORDER BY', function () { + expect( + Q::select(Q::n('country')) + ->from(Q::n('users')) + ->groupBy(Q::n('country'))->withRollup() + ->having(Q::n('country')->isNotNull()) + ->orderBy(Q::n('country'))->desc() + )->toRenderSql('SELECT country FROM users GROUP BY country WITH ROLLUP HAVING country IS NOT NULL ORDER BY country DESC'); + }); + + it('renders LIMIT and OFFSET', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->limit(Q::int(10))->offset(Q::int(20)) + )->toRenderSql('SELECT id FROM users LIMIT 10 OFFSET 20'); + }); + + it('renders locking clauses', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->forUpdate()->skipLocked() + )->toRenderSql('SELECT id FROM users FOR UPDATE SKIP LOCKED'); + + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->forShare()->of('users')->nowait() + )->toRenderSql('SELECT id FROM users FOR SHARE OF users NOWAIT'); + }); + + it('renders UNION and INTERSECT combinations', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('a')) + ->union()->all() + ->query(Q::select(Q::n('id'))->from(Q::n('b'))) + )->toRenderSql('SELECT id FROM a UNION ALL (SELECT id FROM b)'); + }); + + it('renders a CTE', function () { + expect( + Q::with('recent')->as(Q::select(Q::n('id'))->from(Q::n('orders'))) + ->select(Q::n('*'))->from(Q::n('recent')) + )->toRenderSql('WITH recent AS (SELECT id FROM orders) SELECT * FROM recent'); + }); + + it('renders EXISTS and IN with a subquery', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users')) + ->where(Q::exists(Q::select(Q::int(1))->from(Q::n('orders')))) + )->toRenderSql('SELECT id FROM users WHERE EXISTS (SELECT 1 FROM orders)'); + + expect( + Q::select(Q::n('id'))->from(Q::n('users')) + ->where(Q::n('id')->in(Q::select(Q::n('user_id'))->from(Q::n('orders')))) + )->toRenderSql('SELECT id FROM users WHERE id IN (SELECT user_id FROM orders)'); + }); + + it('renders a LATERAL derived table', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->joinLateral(Q::select(Q::n('*'))->from(Q::n('orders')))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) + )->toRenderSql('SELECT * FROM users AS u JOIN LATERAL (SELECT * FROM orders) AS o ON o.user_id = u.id'); + }); +}); From 4158f8d90c632a7e3ca37e7f374368b9e54dd450 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:34:32 +0200 Subject: [PATCH 05/40] wip: model MySQL functions via facade (not operator-style); dialect-native comments Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/CastExp.php | 4 +- src/MySQL/Builder/ExpBase.php | 65 +++++----------------------- src/MySQL/Builder/FromItem.php | 2 +- src/MySQL/Builder/FromLateralExp.php | 4 +- src/MySQL/Builder/JoinType.php | 2 +- src/MySQL/Builder/LockingClause.php | 1 - src/MySQL/Builder/OrderByClause.php | 1 - src/MySQL/Builder/SelectBuilder.php | 2 +- src/MySQL/Builder/SqlBuilder.php | 8 ++-- src/MySQL/Builder/WithQueryItem.php | 2 - src/MySQL/Q.php | 19 ++++++++ tests/MySQL/Q/ExpressionsTest.php | 16 +++---- 12 files changed, 47 insertions(+), 79 deletions(-) diff --git a/src/MySQL/Builder/CastExp.php b/src/MySQL/Builder/CastExp.php index 2b7e87a..f4b6e5e 100644 --- a/src/MySQL/Builder/CastExp.php +++ b/src/MySQL/Builder/CastExp.php @@ -5,8 +5,8 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * A `CAST(expr AS type)` expression. MySQL/MariaDB have no `::` cast operator; - * the type vocabulary is the restricted CAST target set (see {@see TypeExp}). + * A `CAST(expr AS type)` expression; the type vocabulary is the restricted CAST + * target set (see {@see TypeExp}). */ final class CastExp extends ExpBase { diff --git a/src/MySQL/Builder/ExpBase.php b/src/MySQL/Builder/ExpBase.php index f091ead..3dfba80 100644 --- a/src/MySQL/Builder/ExpBase.php +++ b/src/MySQL/Builder/ExpBase.php @@ -6,12 +6,13 @@ /** * Base class for expressions, providing the chainable operator methods with - * `$this` as their left-hand side. + * `$this` as their left-hand side — the infix and postfix operators of the SQL + * dialect (comparison, arithmetic, `LIKE`, `REGEXP`, `IN`, `IS NULL`, the JSON + * path operators, ...). * - * Several operations that PostgreSQL spells as operators are functions in - * MySQL/MariaDB: concatenation (`CONCAT`), power (`POW`), cast (`CAST`), - * JSON containment (`JSON_CONTAINS`). `||` would mean logical OR and `^` - * bitwise XOR here, so they are not used. + * Functions such as `CONCAT`, `POW`, `CAST` or `JSON_CONTAINS` read as function + * calls in SQL, so they are constructed through the facade ({@see Q}) rather than + * chained here. */ abstract class ExpBase implements Exp { @@ -54,33 +55,14 @@ public function gte(Exp $rgt): Exp } /** - * Null-safe equality (`a <=> b`); the MySQL/MariaDB equivalent of the SQL - * standard `IS NOT DISTINCT FROM`. + * Null-safe equality (`a <=> b`): like `=`, but `NULL <=> NULL` is true. */ public function nullSafeEq(Exp $rgt): OpExp { return $this->op('<=>', $rgt); } - public function isNotDistinctFrom(Exp $rgt): Exp - { - return $this->op('<=>', $rgt); - } - - public function isDistinctFrom(Exp $rgt): Exp - { - return new UnaryExp($this->op('<=>', $rgt), Precedence::of('NOT'), prefix: 'NOT'); - } - - /** - * Cast to the given type (`CAST(expr AS type)`). There is no `::` operator. - */ - public function cast(string $type): CastExp - { - return new CastExp($this, new TypeExp($type)); - } - - // Math operators + // Arithmetic operators public function plus(Exp $rgt): OpExp { @@ -107,28 +89,11 @@ public function mod(Exp $rgt): OpExp return $this->op('%', $rgt); } - /** - * Power. Rendered as `POW(a, b)` — `^` is bitwise XOR in MySQL/MariaDB. - */ - public function pow(Exp $rgt): FuncExp - { - return new FuncExp('POW', [$this, $rgt]); - } - - /** - * String concatenation. Rendered as `CONCAT(a, b)` — `||` is logical OR - * under the default sql_mode. Note `CONCAT` returns NULL if any argument is NULL. - */ - public function concat(Exp $rgt): FuncExp - { - return new FuncExp('CONCAT', [$this, $rgt]); - } - - // JSON + // JSON path operators /** * Extract a JSON value at the given path (`a -> '$.path'`). The right-hand - * side is a JSON path string literal, not a key/index expression. + * side is a JSON path string literal (e.g. `'$.name'`, `'$[0]'`). */ public function jsonExtract(Exp $rgt): OpExp { @@ -143,14 +108,6 @@ public function jsonExtractText(Exp $rgt): OpExp return $this->op('->>', $rgt); } - /** - * JSON containment (`JSON_CONTAINS(a, candidate)`). - */ - public function jsonContains(Exp $candidate): FuncExp - { - return new FuncExp('JSON_CONTAINS', [$this, $candidate]); - } - // Pattern matching public function like(Exp $rgt): Exp @@ -165,7 +122,7 @@ public function notLike(Exp $rgt): Exp /** * Regular-expression match (`a REGEXP b`). Case sensitivity follows the - * operand collation (case-insensitive by default). + * operand collation. */ public function regexp(Exp $rgt): Exp { diff --git a/src/MySQL/Builder/FromItem.php b/src/MySQL/Builder/FromItem.php index 2ec6e1d..36a8078 100644 --- a/src/MySQL/Builder/FromItem.php +++ b/src/MySQL/Builder/FromItem.php @@ -6,7 +6,7 @@ /** * A FROM-clause item: a relation (table, subquery or join) with an optional - * alias and column aliases, optionally LATERAL (MySQL only). + * alias and column aliases, optionally LATERAL. * * @internal */ diff --git a/src/MySQL/Builder/FromLateralExp.php b/src/MySQL/Builder/FromLateralExp.php index 920a3e6..cf4995a 100644 --- a/src/MySQL/Builder/FromLateralExp.php +++ b/src/MySQL/Builder/FromLateralExp.php @@ -6,9 +6,7 @@ /** * Marker interface for a FROM item that may be prefixed with `LATERAL` - * (a function call or a subquery — not a plain table name). - * - * LATERAL is supported by MySQL (8.0.14+) but not MariaDB. + * (a subquery — not a plain table name). */ interface FromLateralExp extends FromExp { diff --git a/src/MySQL/Builder/JoinType.php b/src/MySQL/Builder/JoinType.php index 4ba54ce..3a87fbf 100644 --- a/src/MySQL/Builder/JoinType.php +++ b/src/MySQL/Builder/JoinType.php @@ -5,7 +5,7 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * MySQL/MariaDB join types. There is no FULL OUTER JOIN. + * The supported join types. */ enum JoinType: string { diff --git a/src/MySQL/Builder/LockingClause.php b/src/MySQL/Builder/LockingClause.php index 9b09860..504cc4e 100644 --- a/src/MySQL/Builder/LockingClause.php +++ b/src/MySQL/Builder/LockingClause.php @@ -6,7 +6,6 @@ /** * A row-locking clause, e.g. `FOR UPDATE`, `FOR SHARE OF a, b SKIP LOCKED`. - * (MariaDB renders its shared lock differently; that is handled by its own builder.) * * @internal */ diff --git a/src/MySQL/Builder/OrderByClause.php b/src/MySQL/Builder/OrderByClause.php index 275db79..3e5f66b 100644 --- a/src/MySQL/Builder/OrderByClause.php +++ b/src/MySQL/Builder/OrderByClause.php @@ -6,7 +6,6 @@ /** * A single ORDER BY term: an expression with an optional sort direction. - * MySQL/MariaDB have no `NULLS FIRST/LAST`. * * @internal */ diff --git a/src/MySQL/Builder/SelectBuilder.php b/src/MySQL/Builder/SelectBuilder.php index e05a816..ea8aff0 100644 --- a/src/MySQL/Builder/SelectBuilder.php +++ b/src/MySQL/Builder/SelectBuilder.php @@ -58,7 +58,7 @@ public function from(FromExp $from): FromSelectBuilder } /** - * Add a `LATERAL` subquery to the FROM clause (MySQL only). + * Add a `LATERAL` subquery to the FROM clause. */ public function fromLateral(FromLateralExp $from): FromSelectBuilder { diff --git a/src/MySQL/Builder/SqlBuilder.php b/src/MySQL/Builder/SqlBuilder.php index 2876bf9..52c6516 100644 --- a/src/MySQL/Builder/SqlBuilder.php +++ b/src/MySQL/Builder/SqlBuilder.php @@ -62,10 +62,10 @@ public function createPlaceholder(mixed $argument): string } /** - * Create a placeholder for a named argument. Unlike PostgreSQL's numbered - * placeholders, MySQL `?` is positional and cannot be reused, so every - * occurrence of the same name gets its own placeholder and arg slot; all of a - * name's slots are filled with the same value by {@see QueryBuilder::withNamedArgs()}. + * Create a placeholder for a named argument. Positional `?` placeholders are + * not reusable, so every occurrence of the same name gets its own placeholder + * and arg slot; all of a name's slots are filled with the same value by + * {@see QueryBuilder::withNamedArgs()}. */ public function bindPlaceholder(string $name): string { diff --git a/src/MySQL/Builder/WithQueryItem.php b/src/MySQL/Builder/WithQueryItem.php index 55a8698..55fcd3a 100644 --- a/src/MySQL/Builder/WithQueryItem.php +++ b/src/MySQL/Builder/WithQueryItem.php @@ -7,8 +7,6 @@ /** * A single entry of a WITH clause: `name [(columns)] AS (query)`. * - * MySQL/MariaDB have no `[NOT] MATERIALIZED` or `SEARCH` clause. - * * @internal */ final class WithQueryItem diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 342d048..8ad4729 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -8,6 +8,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\BindExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\BoolLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\CaseBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\CastExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\DefaultLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; use Flowpack\QueryObjectBuilder\MySQL\Builder\ExistsExp; @@ -25,6 +26,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter; use Flowpack\QueryObjectBuilder\MySQL\Builder\StringLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\SubqueryExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\TypeExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\UnaryExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithQueryItem; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithWithBuilder; @@ -210,6 +212,23 @@ public static function neg(Exp $exp): UnaryExp return new UnaryExp($exp, 5, prefix: '-'); // unary minus binds tightly } + /** + * A function-call expression, e.g. `Q::func('CONCAT', $a, $b)` for + * `CONCAT(a, b)`. Common functions also have dedicated helpers on `Q\Func`. + */ + public static function func(string $name, Exp ...$args): FuncExp + { + return new FuncExp($name, array_values($args)); + } + + /** + * A `CAST(expr AS type)` expression (e.g. `Q::cast($x, 'UNSIGNED')`). + */ + public static function cast(Exp $exp, string $type): CastExp + { + return new CastExp($exp, new TypeExp($type)); + } + /** * Build a `COALESCE(...)` expression. */ diff --git a/tests/MySQL/Q/ExpressionsTest.php b/tests/MySQL/Q/ExpressionsTest.php index 2adda88..9493056 100644 --- a/tests/MySQL/Q/ExpressionsTest.php +++ b/tests/MySQL/Q/ExpressionsTest.php @@ -28,18 +28,16 @@ expect(Q::n('a')->eq(Q::arg(1)))->toRenderSql('a = ?', [1]); }); - it('renders operators that become functions in MySQL', function () { - expect(Q::n('a')->concat(Q::string('x')))->toRenderSql("CONCAT(a, 'x')"); - expect(Q::n('a')->pow(Q::int(2)))->toRenderSql('POW(a, 2)'); - expect(Q::n('a')->cast('UNSIGNED'))->toRenderSql('CAST(a AS UNSIGNED)'); - expect(Q::n('a')->cast('DECIMAL(10,2)'))->toRenderSql('CAST(a AS DECIMAL(10,2))'); - expect(Q::n('a')->jsonContains(Q::arg('1')))->toRenderSql('JSON_CONTAINS(a, ?)', ['1']); + it('builds functions and CAST through the facade', function () { + expect(Q::func('CONCAT', Q::n('a'), Q::string('x')))->toRenderSql("CONCAT(a, 'x')"); + expect(Q::func('POW', Q::n('a'), Q::int(2)))->toRenderSql('POW(a, 2)'); + expect(Q::cast(Q::n('a'), 'UNSIGNED'))->toRenderSql('CAST(a AS UNSIGNED)'); + expect(Q::cast(Q::n('a'), 'DECIMAL(10,2)'))->toRenderSql('CAST(a AS DECIMAL(10,2))'); + expect(Q::func('JSON_CONTAINS', Q::n('a'), Q::arg('1')))->toRenderSql('JSON_CONTAINS(a, ?)', ['1']); }); - it('renders null-safe equality and IS [NOT] DISTINCT FROM', function () { + it('renders null-safe equality', function () { expect(Q::n('a')->nullSafeEq(Q::arg(1)))->toRenderSql('a <=> ?', [1]); - expect(Q::n('a')->isNotDistinctFrom(Q::arg(1)))->toRenderSql('a <=> ?', [1]); - expect(Q::n('a')->isDistinctFrom(Q::arg(1)))->toRenderSql('NOT a <=> ?', [1]); }); it('renders JSON path extraction operators', function () { From 9b6b069f578b305fccb3baa79e22fc44ad253a0d Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:35:34 +0200 Subject: [PATCH 06/40] wip: spec - dialect-native design principle (natural look, self-standing comments) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mysql-mariadb-design.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index 545061d..82b3fbf 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -9,9 +9,10 @@ exhaustive per-area findings live next to this file's source material (the **Version anchors:** MySQL **8.4 (LTS)**, MariaDB **11.x GA** (11.8 LTS). Every feature gated below the anchor is treated as *not available*. -> Status: the **Architecture** section is a recommendation pending confirmation. -> Everything else (the keep/drop/replace/add tables, function set, version gates) -> is dialect-fact and independent of that decision. +> Status: architecture **decided** (per-variant subclasses; `src/MySQL` + +> `src/MariaDB`). Implementation in progress — foundation, expression layer and +> SELECT have landed for MySQL. The keep/drop/replace/add tables, function set and +> version gates are dialect-fact. --- @@ -44,7 +45,7 @@ Every feature gated below the anchor is treated as *not available*. --- -## 2. Architecture (proposed) +## 2. Architecture A **new namespace family** (`MySQL` + `MariaDB`), built by **duplicating the PostgreSQL builder and adapting it** — *not* by extracting a shared `Common/` @@ -64,6 +65,23 @@ src/MariaDB/ # reusing the primitives + abstract bases from src/MySQL/Builder ``` +### Dialect-native design (principle) + +Each dialect builder is first-class and models *its own* SQL — it is **not** a +diff against another dialect. Two consequences enforced throughout: + +- **Natural look:** the builder mirrors how the SQL reads. Genuine operators + (`=`, `<=>`, `+`, `LIKE`, `REGEXP`, `->`, `IS NULL`, ...) are chainable methods + on the expression base; things that read as **function calls** (`CONCAT`, + `POW`, `CAST`, `JSON_CONTAINS`, ...) are constructed via the facade + (`Q::func` / `Q::cast` / `Q\Func`), never as operator-style chained methods that + merely emit a function. The MySQL `ExpBase` therefore carries only the dialect's + actual operators — it is not a copy of another dialect's expression surface. +- **Self-standing comments:** comments describe what the code does in this + dialect; they never frame it relative to another dialect ("PostgreSQL has X", + "no FULL JOIN here", "unlike PG"). A standing rule, analogous to the no-Go rule + in AGENTS.md — verified continuously, with a final sweep in stage 8. + ### Variant modelling — decided: per-variant subclasses (compile-time safe) Each engine gets its own concrete builder ladder, so a method that is invalid on @@ -244,7 +262,10 @@ tail (excluded) apply. ## 6. Expression / operator layer -The chokepoint. New `ExpBase` for the MySQL family. +The chokepoint. New `ExpBase` for the MySQL family. Per the dialect-native +principle (§2), only genuine **operators** are chainable `ExpBase` methods; the +"replace → function" rows below are built through the facade (`Q::func`, +`Q::cast`, `Q\Func`), not as expression methods. The table maps SQL semantics: | PG operator | Verdict | Render (both engines unless noted) | |---|---|---| From d7e2a41a27b72a5e8bce0dac2d0fe3c1a7cb3459 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:38:13 +0200 Subject: [PATCH 07/40] wip: AGENTS.md - dialect-native design convention (operators vs functions, no cross-dialect comment framing) Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 28 ++++++++++++++++++++++++++-- docs/mysql-mariadb-design.md | 24 ++++++++---------------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88a2bad..aefa43b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # 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-dialect facade (PostgreSQL and MySQL; MariaDB planned); the query model and +rendering live in a per-dialect `Builder` sub-namespace. 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*). @@ -19,6 +19,9 @@ 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 supported dialect has its own top-level namespace (`src/PostgreSQL/`, +`src/MySQL/`, …) mirroring this layout; the paths above show the PostgreSQL one. + ## Conventions ### Immutability — the core invariant @@ -77,10 +80,31 @@ 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. +### 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-dialect framing: describe what the code does in *this* dialect, never + relative to another ("PostgreSQL has X", "no FULL JOIN here", "unlike PG") — + same spirit as the no-Go rule above. - 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"). diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index 82b3fbf..8cfb366 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -65,22 +65,14 @@ src/MariaDB/ # reusing the primitives + abstract bases from src/MySQL/Builder ``` -### Dialect-native design (principle) - -Each dialect builder is first-class and models *its own* SQL — it is **not** a -diff against another dialect. Two consequences enforced throughout: - -- **Natural look:** the builder mirrors how the SQL reads. Genuine operators - (`=`, `<=>`, `+`, `LIKE`, `REGEXP`, `->`, `IS NULL`, ...) are chainable methods - on the expression base; things that read as **function calls** (`CONCAT`, - `POW`, `CAST`, `JSON_CONTAINS`, ...) are constructed via the facade - (`Q::func` / `Q::cast` / `Q\Func`), never as operator-style chained methods that - merely emit a function. The MySQL `ExpBase` therefore carries only the dialect's - actual operators — it is not a copy of another dialect's expression surface. -- **Self-standing comments:** comments describe what the code does in this - dialect; they never frame it relative to another dialect ("PostgreSQL has X", - "no FULL JOIN here", "unlike PG"). A standing rule, analogous to the no-Go rule - in AGENTS.md — verified continuously, with a final sweep in stage 8. +### Dialect-native design + +The canonical rule lives in **AGENTS.md** ("Dialect-native design" + the +no-cross-dialect-framing comment rule): each dialect models its own SQL — +operators are chainable expression methods, functions are built via the facade +(`Q::func` / `Q::cast` / `Q\Func`), and comments never frame one dialect against +another. Consequence here: the MySQL `ExpBase` carries only MySQL's actual +operators, and a final comment sweep is part of stage 8. ### Variant modelling — decided: per-variant subclasses (compile-time safe) From 2f3aae968aae28022c5780fcea93e0c2e615d510 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:52:42 +0200 Subject: [PATCH 08/40] wip: mysql window functions (OVER, named WINDOW, frame clauses, window-only funcs) --- docs/mysql-mariadb-design.md | 16 ++ src/MySQL/Builder/AggBuilder.php | 50 ++++ src/MySQL/Builder/FrameBound.php | 55 ++++ src/MySQL/Builder/FrameUnits.php | 11 + src/MySQL/Builder/NamedWindow.php | 25 ++ src/MySQL/Builder/OrderBySelectBuilder.php | 2 +- .../Builder/OrderByWindowFuncCallBuilder.php | 39 +++ .../Builder/OrderByWindowSelectBuilder.php | 39 +++ src/MySQL/Builder/SelectBuilder.php | 26 ++ src/MySQL/Builder/SelectQueryParts.php | 4 +- src/MySQL/Builder/WindowDefining.php | 91 ++++++ src/MySQL/Builder/WindowDefinition.php | 75 +++++ src/MySQL/Builder/WindowFrame.php | 36 +++ src/MySQL/Builder/WindowFuncBuilder.php | 27 ++ src/MySQL/Builder/WindowFuncCallBuilder.php | 82 ++++++ src/MySQL/Builder/WindowSelectBuilder.php | 38 +++ src/MySQL/Q.php | 43 +++ src/MySQL/Q/Func.php | 184 +++++++++++++ tests/MySQL/Q/WindowFuncBuilderTest.php | 259 ++++++++++++++++++ 19 files changed, 1100 insertions(+), 2 deletions(-) create mode 100644 src/MySQL/Builder/AggBuilder.php create mode 100644 src/MySQL/Builder/FrameBound.php create mode 100644 src/MySQL/Builder/FrameUnits.php create mode 100644 src/MySQL/Builder/NamedWindow.php create mode 100644 src/MySQL/Builder/OrderByWindowFuncCallBuilder.php create mode 100644 src/MySQL/Builder/OrderByWindowSelectBuilder.php create mode 100644 src/MySQL/Builder/WindowDefining.php create mode 100644 src/MySQL/Builder/WindowDefinition.php create mode 100644 src/MySQL/Builder/WindowFrame.php create mode 100644 src/MySQL/Builder/WindowFuncBuilder.php create mode 100644 src/MySQL/Builder/WindowFuncCallBuilder.php create mode 100644 src/MySQL/Builder/WindowSelectBuilder.php create mode 100644 src/MySQL/Q/Func.php create mode 100644 tests/MySQL/Q/WindowFuncBuilderTest.php diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index 8cfb366..7d94af9 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -444,6 +444,22 @@ Deferred + Excluded surface in the dialect README "Limitations" section (stage 8 | MariaDB `OFFSET..FETCH FIRST/NEXT ... ONLY/WITH TIES` | Deferred | LIMIT/OFFSET covers the common case | | MariaDB recursive-CTE `CYCLE col RESTRICT` | Deferred | PG builder exposes no CYCLE either | +### Window functions + +The `over_clause` (`OVER (window_spec)` and `OVER window_name`), the named `WINDOW` +clause, `PARTITION BY`, the window `ORDER BY`, and the `frame_clause` +(`ROWS`/`RANGE` with `CURRENT ROW` / `UNBOUNDED PRECEDING|FOLLOWING` / +`expr PRECEDING|FOLLOWING` bounds, single-bound and `BETWEEN … AND …` forms) are +all **Supported**. The window-only functions `ROW_NUMBER`, `RANK`, `DENSE_RANK`, +`PERCENT_RANK`, `CUME_DIST`, `NTILE`, `LAG`, `LEAD`, `FIRST_VALUE`, `LAST_VALUE`, +`NTH_VALUE` are Supported via `Q\Func`. + +| Production | Status | Reason | +|---|---|---| +| `GROUPS` frame units | N/A (not in grammar) | only `ROWS`/`RANGE` exist in MySQL 8.4 / MariaDB 11.x | +| frame `EXCLUDE CURRENT ROW`/`GROUP`/`TIES`/`NO OTHERS` | N/A (not in grammar) | frame exclusion is unsupported by both engines | +| aggregate-as-window beyond `count`/`sum`/`avg`/`min`/`max` | Deferred | the full curated aggregate set (and its `over()`) lands with the function facade (§7, stage 6) | + ### INSERT / REPLACE | Clause | Status | Reason | |---|---|---| diff --git a/src/MySQL/Builder/AggBuilder.php b/src/MySQL/Builder/AggBuilder.php new file mode 100644 index 0000000..55da42d --- /dev/null +++ b/src/MySQL/Builder/AggBuilder.php @@ -0,0 +1,50 @@ + $exps + */ + public function __construct( + protected readonly string $name, + protected readonly array $exps, + protected readonly bool $distinct = false, + ) { + } + + public function distinct(): self + { + return new self($this->name, $this->exps, true); + } + + /** + * Use this aggregate as a window function. Pass an existing window name to + * reference a window from the query's `WINDOW` clause, or omit it and refine + * the window inline via {@see WindowFuncCallBuilder::partitionBy()} / + * {@see WindowFuncCallBuilder::orderBy()}. + */ + public function over(string $existingWindowName = ''): WindowFuncCallBuilder + { + return new WindowFuncCallBuilder($this, new WindowDefinition($existingWindowName)); + } + + public function writeSql(SqlBuilder $sb): void + { + $sb->writeString($this->name . '(' . ($this->distinct ? 'DISTINCT ' : '')); + foreach ($this->exps as $i => $exp) { + if ($i > 0) { + $sb->writeString(','); + } + $exp->writeSql($sb); + } + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Builder/FrameBound.php b/src/MySQL/Builder/FrameBound.php new file mode 100644 index 0000000..ac34d40 --- /dev/null +++ b/src/MySQL/Builder/FrameBound.php @@ -0,0 +1,55 @@ +offset !== null) { + $this->offset->writeSql($sb); + $sb->writeString(' '); + } + $sb->writeString($this->keyword); + } +} diff --git a/src/MySQL/Builder/FrameUnits.php b/src/MySQL/Builder/FrameUnits.php new file mode 100644 index 0000000..1aec39d --- /dev/null +++ b/src/MySQL/Builder/FrameUnits.php @@ -0,0 +1,11 @@ +writeString($this->name . ' AS '); + $this->definition->writeSql($sb); + } +} diff --git a/src/MySQL/Builder/OrderBySelectBuilder.php b/src/MySQL/Builder/OrderBySelectBuilder.php index 6f3cbe8..c0964e1 100644 --- a/src/MySQL/Builder/OrderBySelectBuilder.php +++ b/src/MySQL/Builder/OrderBySelectBuilder.php @@ -8,7 +8,7 @@ * The builder state right after adding an ORDER BY expression, where * {@see asc()} / {@see desc()} set the sort direction of that last term. */ -final class OrderBySelectBuilder extends SelectBuilder +class OrderBySelectBuilder extends SelectBuilder { public function asc(): self { diff --git a/src/MySQL/Builder/OrderByWindowFuncCallBuilder.php b/src/MySQL/Builder/OrderByWindowFuncCallBuilder.php new file mode 100644 index 0000000..47c4e1a --- /dev/null +++ b/src/MySQL/Builder/OrderByWindowFuncCallBuilder.php @@ -0,0 +1,39 @@ +rebuildLastOrderBy(SortOrder::Asc); + } + + public function desc(): self + { + return $this->rebuildLastOrderBy(SortOrder::Desc); + } + + private function rebuildLastOrderBy(SortOrder $order): self + { + $orderBys = $this->definition->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return new self($this->funcCall, new WindowDefinition( + $this->definition->existingWindowName, + $this->definition->partitionBy, + $orderBys, + $this->definition->frame, + )); + } +} diff --git a/src/MySQL/Builder/OrderByWindowSelectBuilder.php b/src/MySQL/Builder/OrderByWindowSelectBuilder.php new file mode 100644 index 0000000..645b716 --- /dev/null +++ b/src/MySQL/Builder/OrderByWindowSelectBuilder.php @@ -0,0 +1,39 @@ +rebuildLastWindowOrderBy(SortOrder::Asc); + } + + public function desc(): self + { + return $this->rebuildLastWindowOrderBy(SortOrder::Desc); + } + + private function rebuildLastWindowOrderBy(SortOrder $order): self + { + $def = $this->lastWindowDefinition(); + + $orderBys = $def->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $this->deriveWindow(self::class, new WindowDefinition($def->existingWindowName, $def->partitionBy, $orderBys, $def->frame)); + } +} diff --git a/src/MySQL/Builder/SelectBuilder.php b/src/MySQL/Builder/SelectBuilder.php index ea8aff0..bf96644 100644 --- a/src/MySQL/Builder/SelectBuilder.php +++ b/src/MySQL/Builder/SelectBuilder.php @@ -133,6 +133,17 @@ public function having(Exp $cond): SelectBuilder return $this->derive(SelectBuilder::class, havingConjunction: [...$this->parts->havingConjunction, $cond]); } + /** + * Add a named window to the WINDOW clause. Define it via + * {@see WindowSelectBuilder::as()} / {@see WindowSelectBuilder::partitionBy()} / + * {@see WindowDefining::orderBy()}, then reference it from a window function + * via {@see WindowFuncBuilder::over()}. + */ + public function window(string $name): WindowSelectBuilder + { + return $this->derive(WindowSelectBuilder::class, windows: [...$this->parts->windows, new NamedWindow($name)]); + } + /** * Add an ORDER BY expression (refine it via {@see OrderBySelectBuilder}). */ @@ -221,6 +232,7 @@ public function isEmpty(): bool * @param list|null $whereConjunction * @param list|null $groupBys * @param list|null $havingConjunction + * @param list|null $windows * @param list|null $orderBys * @param list|null $withQueries * @param list|null $combinations @@ -236,6 +248,7 @@ protected function derive( ?array $groupBys = null, ?bool $groupByWithRollup = null, ?array $havingConjunction = null, + ?array $windows = null, ?array $orderBys = null, ?Exp $limit = null, ?Exp $offset = null, @@ -251,6 +264,7 @@ protected function derive( groupBys: $groupBys ?? $this->parts->groupBys, groupByWithRollup: $groupByWithRollup ?? $this->parts->groupByWithRollup, havingConjunction: $havingConjunction ?? $this->parts->havingConjunction, + windows: $windows ?? $this->parts->windows, orderBys: $orderBys ?? $this->parts->orderBys, limit: $limit ?? $this->parts->limit, offset: $offset ?? $this->parts->offset, @@ -378,6 +392,18 @@ private function writeSelectParts(SqlBuilder $sb, SelectQueryParts $parts): void Junction::and(...$parts->havingConjunction)->writeSql($sb); } + if ($parts->windows !== []) { + $sb->writeString($s . ' WINDOW '); + $s = ''; + + foreach ($parts->windows as $i => $window) { + if ($i > 0) { + $sb->writeString(','); + } + $window->writeSql($sb); + } + } + if ($s !== '') { $sb->writeString($s); } diff --git a/src/MySQL/Builder/SelectQueryParts.php b/src/MySQL/Builder/SelectQueryParts.php index 6f8f503..361bb3b 100644 --- a/src/MySQL/Builder/SelectQueryParts.php +++ b/src/MySQL/Builder/SelectQueryParts.php @@ -20,6 +20,7 @@ final class SelectQueryParts * @param list $whereConjunction conditions joined with AND * @param list $groupBys the GROUP BY expressions * @param list $havingConjunction conditions joined with AND + * @param list $windows the WINDOW clause definitions * @param list $orderBys */ public function __construct( @@ -30,6 +31,7 @@ public function __construct( public readonly array $groupBys = [], public readonly bool $groupByWithRollup = false, public readonly array $havingConjunction = [], + public readonly array $windows = [], public readonly array $orderBys = [], public readonly ?Exp $limit = null, public readonly ?Exp $offset = null, @@ -41,7 +43,7 @@ public function isEmpty(): bool { return !$this->distinct && $this->selectList === [] && $this->from === [] && $this->whereConjunction === [] && $this->groupBys === [] && $this->havingConjunction === [] - && $this->orderBys === [] && $this->limit === null && $this->offset === null + && $this->windows === [] && $this->orderBys === [] && $this->limit === null && $this->offset === null && $this->lockingClause === null; } } diff --git a/src/MySQL/Builder/WindowDefining.php b/src/MySQL/Builder/WindowDefining.php new file mode 100644 index 0000000..051223c --- /dev/null +++ b/src/MySQL/Builder/WindowDefining.php @@ -0,0 +1,91 @@ +lastWindowDefinition(); + + return $this->deriveWindow(OrderByWindowSelectBuilder::class, new WindowDefinition( + $def->existingWindowName, + $def->partitionBy, + [...$def->orderBys, new OrderByClause($exp)], + $def->frame, + )); + } + + /** + * Bound the current window's frame in `ROWS` units. Pass one bound for the + * `ROWS start` form or two for `ROWS BETWEEN start AND end`. + */ + public function rows(FrameBound $start, ?FrameBound $end = null): WindowSelectBuilder + { + return $this->withWindowFrame(new WindowFrame(FrameUnits::Rows, $start, $end)); + } + + /** + * Bound the current window's frame in `RANGE` units. Pass one bound for the + * `RANGE start` form or two for `RANGE BETWEEN start AND end`. + */ + public function range(FrameBound $start, ?FrameBound $end = null): WindowSelectBuilder + { + return $this->withWindowFrame(new WindowFrame(FrameUnits::Range, $start, $end)); + } + + private function withWindowFrame(WindowFrame $frame): WindowSelectBuilder + { + $def = $this->lastWindowDefinition(); + + return $this->deriveWindow(WindowSelectBuilder::class, new WindowDefinition( + $def->existingWindowName, + $def->partitionBy, + $def->orderBys, + $frame, + )); + } + + protected function lastWindowDefinition(): WindowDefinition + { + $windows = $this->parts->windows; + $lastIdx = array_key_last($windows); + assert($lastIdx !== null); + + return $windows[$lastIdx]->definition; + } + + /** + * Return a builder of the given type with the last named window's definition + * replaced. + * + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function deriveWindow(string $class, WindowDefinition $definition): SelectBuilder + { + $windows = $this->parts->windows; + $lastIdx = array_key_last($windows); + assert($lastIdx !== null); + + $windows[$lastIdx] = new NamedWindow($windows[$lastIdx]->name, $definition); + + return $this->derive($class, windows: $windows); + } +} diff --git a/src/MySQL/Builder/WindowDefinition.php b/src/MySQL/Builder/WindowDefinition.php new file mode 100644 index 0000000..85bdd96 --- /dev/null +++ b/src/MySQL/Builder/WindowDefinition.php @@ -0,0 +1,75 @@ + $partitionBy + * @param list $orderBys + */ + public function __construct( + public readonly string $existingWindowName = '', + public readonly array $partitionBy = [], + public readonly array $orderBys = [], + public readonly ?WindowFrame $frame = null, + ) { + } + + /** + * Whether this definition is nothing but a reference to an existing window, so + * a window function can write the bare `OVER name` form instead of `OVER (...)`. + */ + public function isExistingNameOnly(): bool + { + return $this->existingWindowName !== '' && $this->partitionBy === [] && $this->orderBys === [] && $this->frame === null; + } + + public function writeSql(SqlBuilder $sb): void + { + $s = '('; + $hasContent = false; + if ($this->existingWindowName !== '') { + $s .= $this->existingWindowName; + $hasContent = true; + } + if ($this->partitionBy !== []) { + $sb->writeString($s . ($hasContent ? ' ' : '') . 'PARTITION BY '); + $s = ''; + foreach ($this->partitionBy as $i => $exp) { + if ($i > 0) { + $sb->writeString(','); + } + $exp->writeSql($sb); + } + $hasContent = true; + } + if ($this->orderBys !== []) { + $sb->writeString($s . ($hasContent ? ' ' : '') . 'ORDER BY '); + $s = ''; + foreach ($this->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + $hasContent = true; + } + if ($this->frame !== null) { + $sb->writeString($s . ($hasContent ? ' ' : '')); + $s = ''; + $this->frame->writeSql($sb); + } + $sb->writeString($s . ')'); + } +} diff --git a/src/MySQL/Builder/WindowFrame.php b/src/MySQL/Builder/WindowFrame.php new file mode 100644 index 0000000..ecb82f9 --- /dev/null +++ b/src/MySQL/Builder/WindowFrame.php @@ -0,0 +1,36 @@ +writeString($this->units->value . ' '); + + // A second bound turns the frame into a BETWEEN ... AND ... extent. + if ($this->end !== null) { + $sb->writeString('BETWEEN '); + $this->start->writeSql($sb); + $sb->writeString(' AND '); + $this->end->writeSql($sb); + } else { + $this->start->writeSql($sb); + } + } +} diff --git a/src/MySQL/Builder/WindowFuncBuilder.php b/src/MySQL/Builder/WindowFuncBuilder.php new file mode 100644 index 0000000..2374a7c --- /dev/null +++ b/src/MySQL/Builder/WindowFuncBuilder.php @@ -0,0 +1,27 @@ +funcCall, new WindowDefinition($existingWindowName)); + } +} diff --git a/src/MySQL/Builder/WindowFuncCallBuilder.php b/src/MySQL/Builder/WindowFuncCallBuilder.php new file mode 100644 index 0000000..d9645ba --- /dev/null +++ b/src/MySQL/Builder/WindowFuncCallBuilder.php @@ -0,0 +1,82 @@ +funcCall, new WindowDefinition( + $this->definition->existingWindowName, + [...$this->definition->partitionBy, $exp, ...array_values($exps)], + $this->definition->orderBys, + $this->definition->frame, + )); + } + + /** + * Add an ORDER BY expression to the window (refine via {@see OrderByWindowFuncCallBuilder}). + */ + public function orderBy(Exp $exp): OrderByWindowFuncCallBuilder + { + return new OrderByWindowFuncCallBuilder($this->funcCall, new WindowDefinition( + $this->definition->existingWindowName, + $this->definition->partitionBy, + [...$this->definition->orderBys, new OrderByClause($exp)], + $this->definition->frame, + )); + } + + /** + * Bound the frame in `ROWS` units. Pass one bound for the `ROWS start` form or + * two for `ROWS BETWEEN start AND end`. + */ + public function rows(FrameBound $start, ?FrameBound $end = null): self + { + return $this->withFrame(new WindowFrame(FrameUnits::Rows, $start, $end)); + } + + /** + * Bound the frame in `RANGE` units. Pass one bound for the `RANGE start` form + * or two for `RANGE BETWEEN start AND end`. + */ + public function range(FrameBound $start, ?FrameBound $end = null): self + { + return $this->withFrame(new WindowFrame(FrameUnits::Range, $start, $end)); + } + + private function withFrame(WindowFrame $frame): self + { + return new self($this->funcCall, new WindowDefinition( + $this->definition->existingWindowName, + $this->definition->partitionBy, + $this->definition->orderBys, + $frame, + )); + } + + public function writeSql(SqlBuilder $sb): void + { + $this->funcCall->writeSql($sb); + $sb->writeString(' OVER '); + // A bare existing window name is written without parentheses. + if ($this->definition->isExistingNameOnly()) { + $sb->writeString($this->definition->existingWindowName); + } else { + $this->definition->writeSql($sb); + } + } +} diff --git a/src/MySQL/Builder/WindowSelectBuilder.php b/src/MySQL/Builder/WindowSelectBuilder.php new file mode 100644 index 0000000..635bf5f --- /dev/null +++ b/src/MySQL/Builder/WindowSelectBuilder.php @@ -0,0 +1,38 @@ +lastWindowDefinition(); + + return $this->deriveWindow(self::class, new WindowDefinition($existingWindowName, $def->partitionBy, $def->orderBys, $def->frame)); + } + + public function partitionBy(Exp $exp, Exp ...$exps): self + { + $def = $this->lastWindowDefinition(); + + return $this->deriveWindow(self::class, new WindowDefinition( + $def->existingWindowName, + [...$def->partitionBy, $exp, ...array_values($exps)], + $def->orderBys, + $def->frame, + )); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 8ad4729..afce9b2 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -14,6 +14,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\ExistsExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\Expressions; use Flowpack\QueryObjectBuilder\MySQL\Builder\FloatLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\FrameBound; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IdentExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IntLiteral; @@ -246,6 +247,48 @@ public static function case(Exp ...$exp): CaseBuilder return new CaseBuilder($exp[0] ?? null); } + // Window frame bounds (for `ROWS` / `RANGE` frame clauses) + + /** + * The `CURRENT ROW` frame bound. + */ + public static function currentRow(): FrameBound + { + return FrameBound::currentRow(); + } + + /** + * The `UNBOUNDED PRECEDING` frame bound (the start of the partition). + */ + public static function unboundedPreceding(): FrameBound + { + return FrameBound::unboundedPreceding(); + } + + /** + * The `UNBOUNDED FOLLOWING` frame bound (the end of the partition). + */ + public static function unboundedFollowing(): FrameBound + { + return FrameBound::unboundedFollowing(); + } + + /** + * An `expr PRECEDING` frame bound (the given offset before the current row). + */ + public static function preceding(Exp $offset): FrameBound + { + return FrameBound::preceding($offset); + } + + /** + * An `expr FOLLOWING` frame bound (the given offset after the current row). + */ + public static function following(Exp $offset): FrameBound + { + return FrameBound::following($offset); + } + /** * Start a new query builder for the given writer. */ diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php new file mode 100644 index 0000000..51e4e42 --- /dev/null +++ b/src/MySQL/Q/Func.php @@ -0,0 +1,184 @@ + + */ + private static function leadLagArgs(Exp $expr, ?Exp $offset, ?Exp $default): array + { + $args = [$expr]; + if ($offset !== null) { + $args[] = $offset; + if ($default !== null) { + $args[] = $default; + } + } + + return $args; + } +} diff --git a/tests/MySQL/Q/WindowFuncBuilderTest.php b/tests/MySQL/Q/WindowFuncBuilderTest.php new file mode 100644 index 0000000..fab6069 --- /dev/null +++ b/tests/MySQL/Q/WindowFuncBuilderTest.php @@ -0,0 +1,259 @@ +over(), + )->from(Q::n('empsalary')), + )->toRenderSql('SELECT salary, SUM(salary) OVER () FROM empsalary'); + }); + + it('renders an aggregate over a partition', function () { + expect( + Q::select( + Q::n('depname'), + Q::n('empno'), + Q::n('salary'), + Q\Func::avg(Q::n('salary'))->over()->partitionBy(Q::n('depname')), + )->from(Q::n('empsalary')), + )->toRenderSql( + 'SELECT depname, empno, salary, AVG(salary) OVER (PARTITION BY depname) FROM empsalary', + ); + }); + + it('renders an aggregate over an order by', function () { + expect( + Q::select( + Q::n('salary'), + Q\Func::sum(Q::n('salary'))->over()->orderBy(Q::n('salary')), + )->from(Q::n('empsalary')), + )->toRenderSql('SELECT salary, SUM(salary) OVER (ORDER BY salary) FROM empsalary'); + }); + + it('renders rank over partition order by desc', function () { + expect( + Q::select( + Q::n('depname'), + Q::n('empno'), + Q::n('salary'), + Q\Func::rank()->over()->partitionBy(Q::n('depname'))->orderBy(Q::n('salary'))->desc(), + )->from(Q::n('empsalary')), + )->toRenderSql( + <<<'SQL' + SELECT depname, empno, salary, + RANK() OVER (PARTITION BY depname ORDER BY salary DESC) + FROM empsalary + SQL, + ); + }); + + it('renders multiple partition and order by terms', function () { + expect( + Q::select( + Q::n('depname'), + Q::n('empno'), + Q::n('salary'), + Q::n('enroll_date'), + ) + ->from( + Q::select( + Q::n('depname'), + Q::n('empno'), + Q::n('salary'), + Q::n('enroll_date'), + ) + ->select( + Q\Func::rank()->over()->partitionBy(Q::n('depname'))->orderBy(Q::n('salary'))->desc()->orderBy(Q::n('empno')), + )->as('pos') + ->from(Q::n('empsalary')), + )->as('salaries') + ->where(Q::n('pos')->lt(Q::int(3))), + )->toRenderSql( + <<<'SQL' + SELECT depname, empno, salary, enroll_date + FROM + (SELECT depname, empno, salary, enroll_date, + RANK() OVER (PARTITION BY depname ORDER BY salary DESC, empno) AS pos + FROM empsalary + ) AS salaries + WHERE pos < 3 + SQL, + ); + }); + }); + + describe('named WINDOW clause', function () { + it('renders functions referencing a named window', function () { + expect( + Q::select( + Q\Func::sum(Q::n('salary'))->over('w'), + Q\Func::avg(Q::n('salary'))->over('w'), + Q\Func::rowNumber()->over('w'), + ) + ->from(Q::n('empsalary')) + ->window('w')->as()->partitionBy(Q::n('depname'))->orderBy(Q::n('salary'))->desc(), + )->toRenderSql( + <<<'SQL' + SELECT SUM(salary) OVER w, AVG(salary) OVER w, ROW_NUMBER() OVER w + FROM empsalary + WINDOW w AS (PARTITION BY depname ORDER BY salary DESC) + SQL, + ); + }); + + it('renders several named windows', function () { + expect( + Q::select( + Q\Func::rowNumber()->over('w1'), + Q\Func::sum(Q::n('val'))->over('w2'), + ) + ->from(Q::n('t')) + ->window('w1')->as()->orderBy(Q::n('a')) + ->window('w2')->as()->partitionBy(Q::n('b')), + )->toRenderSql( + 'SELECT ROW_NUMBER() OVER w1, SUM(val) OVER w2 FROM t WINDOW w1 AS (ORDER BY a), w2 AS (PARTITION BY b)', + ); + }); + + it('refines a referenced window with extra clauses', function () { + expect( + Q::select( + Q\Func::sum(Q::n('x'))->over('w')->orderBy(Q::n('y')), + ) + ->from(Q::n('t')) + ->window('w')->as()->partitionBy(Q::n('g')), + )->toRenderSql( + 'SELECT SUM(x) OVER (w ORDER BY y) FROM t WINDOW w AS (PARTITION BY g)', + ); + }); + }); + + describe('frame clauses', function () { + it('renders ROWS UNBOUNDED PRECEDING (running total)', function () { + expect( + Q::select( + Q\Func::sum(Q::n('val'))->over() + ->partitionBy(Q::n('subject')) + ->orderBy(Q::n('time')) + ->rows(Q::unboundedPreceding()), + )->from(Q::n('observations')), + )->toRenderSql( + 'SELECT SUM(val) OVER (PARTITION BY subject ORDER BY time ROWS UNBOUNDED PRECEDING) FROM observations', + ); + }); + + it('renders ROWS BETWEEN n PRECEDING AND n FOLLOWING (moving average)', function () { + expect( + Q::select( + Q\Func::avg(Q::n('val'))->over() + ->partitionBy(Q::n('subject')) + ->orderBy(Q::n('time')) + ->rows(Q::preceding(Q::int(1)), Q::following(Q::int(1))), + )->from(Q::n('observations')), + )->toRenderSql( + 'SELECT AVG(val) OVER (PARTITION BY subject ORDER BY time ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) FROM observations', + ); + }); + + it('renders RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', function () { + expect( + Q::select( + Q\Func::sum(Q::n('x'))->over() + ->orderBy(Q::n('x')) + ->range(Q::unboundedPreceding(), Q::currentRow()), + )->from(Q::n('t')), + )->toRenderSql( + 'SELECT SUM(x) OVER (ORDER BY x RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM t', + ); + }); + + it('renders a frame inside a named window', function () { + expect( + Q::select( + Q\Func::sum(Q::n('val'))->over('w'), + ) + ->from(Q::n('observations')) + ->window('w')->as()->orderBy(Q::n('time'))->rows(Q::unboundedPreceding()), + )->toRenderSql( + 'SELECT SUM(val) OVER w FROM observations WINDOW w AS (ORDER BY time ROWS UNBOUNDED PRECEDING)', + ); + }); + }); + + describe('window-only functions', function () { + it('renders the ranking functions over a named window', function () { + expect( + Q::select( + Q::n('val'), + Q\Func::rowNumber()->over('w'), + Q\Func::rank()->over('w'), + Q\Func::denseRank()->over('w'), + Q\Func::percentRank()->over('w'), + Q\Func::cumeDist()->over('w'), + ) + ->from(Q::n('numbers')) + ->window('w')->as()->orderBy(Q::n('val')), + )->toRenderSql( + <<<'SQL' + SELECT val, + ROW_NUMBER() OVER w, RANK() OVER w, DENSE_RANK() OVER w, + PERCENT_RANK() OVER w, CUME_DIST() OVER w + FROM numbers + WINDOW w AS (ORDER BY val) + SQL, + ); + }); + + it('renders NTILE with a bucket count', function () { + expect( + Q::select( + Q\Func::ntile(Q::int(4))->over()->orderBy(Q::n('val')), + )->from(Q::n('numbers')), + )->toRenderSql('SELECT NTILE(4) OVER (ORDER BY val) FROM numbers'); + }); + + it('renders LAG and LEAD with offset and default', function () { + expect( + Q::select( + Q\Func::lag(Q::n('val'))->over()->orderBy(Q::n('t')), + Q\Func::lead(Q::n('val'), Q::int(1), Q::int(0))->over()->orderBy(Q::n('t')), + )->from(Q::n('t')), + )->toRenderSql( + 'SELECT LAG(val) OVER (ORDER BY t), LEAD(val, 1, 0) OVER (ORDER BY t) FROM t', + ); + }); + + it('renders FIRST_VALUE, LAST_VALUE and NTH_VALUE', function () { + expect( + Q::select( + Q\Func::firstValue(Q::n('val'))->over('w'), + Q\Func::lastValue(Q::n('val'))->over('w'), + Q\Func::nthValue(Q::n('val'), Q::int(2))->over('w'), + ) + ->from(Q::n('t')) + ->window('w')->as()->orderBy(Q::n('t')), + )->toRenderSql( + <<<'SQL' + SELECT FIRST_VALUE(val) OVER w, LAST_VALUE(val) OVER w, NTH_VALUE(val, 2) OVER w + FROM t + WINDOW w AS (ORDER BY t) + SQL, + ); + }); + + it('renders COUNT(*) as a window function', function () { + expect( + Q::select( + Q\Func::count(Q::n('*'))->over()->partitionBy(Q::n('country')), + )->from(Q::n('sales')), + )->toRenderSql('SELECT COUNT(*) OVER (PARTITION BY country) FROM sales'); + }); + }); +}); From 1424de6f9e60366d7f68098c14c1fad09db365f9 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 30 Jun 2026 23:59:28 +0200 Subject: [PATCH 09/40] wip: mysql INSERT builder (values, setMap, select, IGNORE, ON DUPLICATE KEY UPDATE) --- src/MySQL/Builder/InsertBuilder.php | 222 ++++++++++++++++++ .../OnDuplicateKeyUpdateInsertBuilder.php | 24 ++ src/MySQL/Builder/UpdateSetItem.php | 27 +++ src/MySQL/Q.php | 18 ++ tests/MySQL/Q/InsertBuilderTest.php | 74 ++++++ 5 files changed, 365 insertions(+) create mode 100644 src/MySQL/Builder/InsertBuilder.php create mode 100644 src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php create mode 100644 src/MySQL/Builder/UpdateSetItem.php create mode 100644 tests/MySQL/Q/InsertBuilderTest.php diff --git a/src/MySQL/Builder/InsertBuilder.php b/src/MySQL/Builder/InsertBuilder.php new file mode 100644 index 0000000..1682646 --- /dev/null +++ b/src/MySQL/Builder/InsertBuilder.php @@ -0,0 +1,222 @@ + $columnNames + * @param list> $valueLists + * @param list $onDuplicateKeyUpdateSetItems + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly bool $ignore = false, + protected readonly array $columnNames = [], + protected readonly bool $defaultValues = false, + protected readonly array $valueLists = [], + protected readonly ?SelectBuilder $query = null, + protected readonly array $onDuplicateKeyUpdateSetItems = [], + ) { + } + + /** + * Demote insert errors (e.g. duplicate-key, foreign-key) to warnings so the + * offending rows are skipped instead of aborting the statement. + */ + public function ignore(): self + { + return $this->derive(self::class, ignore: true); + } + + /** + * Set the column names to insert into. + */ + public function columnNames(string $columnName, string ...$rest): self + { + return $this->derive(self::class, columnNames: array_values([$columnName, ...$rest])); + } + + /** + * Insert a row consisting entirely of default values, rendered as `() VALUES ()`. + * Calling {@see values()} afterwards overrules this. + */ + public function defaultValues(): self + { + return $this->derive(self::class, defaultValues: true); + } + + /** + * Append a row of values to insert. Call multiple times to insert several rows. + */ + public function values(Exp ...$values): self + { + return $this->derive(self::class, valueLists: [...$this->valueLists, array_values($values)]); + } + + /** + * Set the column names and values from the given map (column name => value). + * Values are bound as arguments and the column order is stable (sorted by name). + * Overwrites any previous column names and values. + * + * @param array $map + */ + public function setMap(array $map): self + { + ksort($map, SORT_STRING); + + $values = []; + foreach ($map as $value) { + $values[] = new Arg($value); + } + + return $this->derive(self::class, columnNames: array_keys($map), valueLists: [$values]); + } + + /** + * Insert the result of the given select query. + */ + public function query(SelectBuilder $query): self + { + return $this->derive(self::class, query: $query); + } + + /** + * Add an `ON DUPLICATE KEY UPDATE` clause. Reference the row that would have + * been inserted via `Q::inserted('col')`. + */ + public function onDuplicateKeyUpdate(): OnDuplicateKeyUpdateInsertBuilder + { + return $this->derive(OnDuplicateKeyUpdateInsertBuilder::class); + } + + /** + * Assemble a new builder of the given type with the given fields replaced; a + * null argument keeps the current value. + * + * @template T of InsertBuilder + * @param class-string $class + * @param list|null $columnNames + * @param list>|null $valueLists + * @param list|null $onDuplicateKeyUpdateSetItems + * @return T + */ + protected function derive( + string $class, + ?bool $ignore = null, + ?array $columnNames = null, + ?bool $defaultValues = null, + ?array $valueLists = null, + ?SelectBuilder $query = null, + ?array $onDuplicateKeyUpdateSetItems = null, + ): InsertBuilder { + return new $class( + $this->tableName, + $ignore ?? $this->ignore, + $columnNames ?? $this->columnNames, + $defaultValues ?? $this->defaultValues, + $valueLists ?? $this->valueLists, + $query ?? $this->query, + $onDuplicateKeyUpdateSetItems ?? $this->onDuplicateKeyUpdateSetItems, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + $sb->writeString($this->ignore ? 'INSERT IGNORE INTO ' : 'INSERT INTO '); + $this->tableName->writeSql($sb); + + if ($this->valueLists !== [] && $this->query !== null) { + $sb->addError(new QueryBuilderException('insert: cannot set both values and query')); + + return; + } + + // A row of all defaults is the empty column list with an empty value row. + if ($this->defaultValues) { + $sb->writeString(' () VALUES ()'); + } else { + $this->writeColumnNames($sb); + $this->writeSource($sb); + } + + if ($this->onDuplicateKeyUpdateSetItems !== []) { + $this->writeOnDuplicateKeyUpdate($sb); + } + } + + private function writeColumnNames(SqlBuilder $sb): void + { + if ($this->columnNames === []) { + return; + } + + $s = ' ('; + foreach ($this->columnNames as $i => $columnName) { + $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); + } + $sb->writeString($s . ')'); + } + + private function writeSource(SqlBuilder $sb): void + { + if ($this->query !== null) { + $sb->writeString(' '); + $this->query->innerWriteSql($sb); + + return; + } + + if ($this->valueLists === []) { + return; + } + + $sb->writeString(' VALUES '); + foreach ($this->valueLists as $i => $valueList) { + $sb->writeString(($i > 0 ? ',' : '') . '('); + foreach ($valueList as $j => $value) { + if ($j > 0) { + $sb->writeString(','); + } + $value->writeSql($sb); + } + $sb->writeString(')'); + } + } + + private function writeOnDuplicateKeyUpdate(SqlBuilder $sb): void + { + // The `AS new` row alias makes the proposed row reachable as `new.col` in + // the update assignments (see `Q::inserted()`); it follows the value rows. + if ($this->query === null) { + $sb->writeString(' AS new'); + } + + $sb->writeString(' ON DUPLICATE KEY UPDATE '); + foreach ($this->onDuplicateKeyUpdateSetItems as $i => $item) { + if ($i > 0) { + $sb->writeString(','); + } + $item->writeSql($sb); + } + } +} diff --git a/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php b/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php new file mode 100644 index 0000000..152cdca --- /dev/null +++ b/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php @@ -0,0 +1,24 @@ +derive(self::class, onDuplicateKeyUpdateSetItems: [ + ...$this->onDuplicateKeyUpdateSetItems, + new UpdateSetItem($columnName, $value), + ]); + } +} diff --git a/src/MySQL/Builder/UpdateSetItem.php b/src/MySQL/Builder/UpdateSetItem.php new file mode 100644 index 0000000..3b5068d --- /dev/null +++ b/src/MySQL/Builder/UpdateSetItem.php @@ -0,0 +1,27 @@ +writeString(Keywords::quoteIdentifierIfKeyword($this->columnName) . ' = '); + $this->value->writeSql($sb); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index afce9b2..d179b0e 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -17,6 +17,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\FrameBound; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IdentExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\InsertBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\IntLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Junction; use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; @@ -52,6 +53,23 @@ public static function select(Exp ...$exps): SelectSelectBuilder return (new SelectBuilder())->select(...$exps); } + /** + * Start an INSERT statement into the given table. + */ + public static function insertInto(IdentExp $tableName): InsertBuilder + { + return new InsertBuilder($tableName); + } + + /** + * Reference the value of `column` from the row that would have been inserted, + * for use inside `ON DUPLICATE KEY UPDATE` (rendered as `new.column`). + */ + public static function inserted(string $column): IdentExp + { + return IdentExp::n('new.' . $column); + } + /** * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. */ diff --git a/tests/MySQL/Q/InsertBuilderTest.php b/tests/MySQL/Q/InsertBuilderTest.php new file mode 100644 index 0000000..76885d7 --- /dev/null +++ b/tests/MySQL/Q/InsertBuilderTest.php @@ -0,0 +1,74 @@ +columnNames('id', 'email') + ->values(Q::arg(1), Q::arg('a@b.c')), + )->toRenderSql('INSERT INTO users (id,email) VALUES (?,?)', [1, 'a@b.c']); + }); + + it('inserts multiple rows', function () { + expect( + Q::insertInto(Q::n('t')) + ->columnNames('a', 'b') + ->values(Q::int(1), Q::int(2)) + ->values(Q::int(3), Q::int(4)), + )->toRenderSql('INSERT INTO t (a,b) VALUES (1,2),(3,4)'); + }); + + it('backtick-quotes reserved keyword columns', function () { + expect( + Q::insertInto(Q::n('t')) + ->columnNames('id', 'order') + ->values(Q::int(1), Q::int(2)), + )->toRenderSql('INSERT INTO t (id,`order`) VALUES (1,2)'); + }); + + it('inserts from a map with stable column order', function () { + expect( + Q::insertInto(Q::n('t'))->setMap(['b' => 2, 'a' => 1]), + )->toRenderSql('INSERT INTO t (a,b) VALUES (?,?)', [1, 2]); + }); + + it('inserts the result of a select query', function () { + expect( + Q::insertInto(Q::n('archive')) + ->columnNames('id', 'name') + ->query( + Q::select(Q::n('id'), Q::n('name'))->from(Q::n('users'))->where(Q::n('active')->eq(Q::int(0))), + ), + )->toRenderSql('INSERT INTO archive (id,name) SELECT id,name FROM users WHERE active = 0'); + }); + + it('renders INSERT IGNORE', function () { + expect( + Q::insertInto(Q::n('t'))->ignore()->columnNames('a')->values(Q::int(1)), + )->toRenderSql('INSERT IGNORE INTO t (a) VALUES (1)'); + }); + + it('renders a default-values row', function () { + expect( + Q::insertInto(Q::n('t'))->defaultValues(), + )->toRenderSql('INSERT INTO t () VALUES ()'); + }); + + it('renders ON DUPLICATE KEY UPDATE referencing the inserted row', function () { + expect( + Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits') + ->values(Q::arg(1), Q::arg(10)) + ->onDuplicateKeyUpdate() + ->set('hits', Q::inserted('hits')) + ->set('seen', Q::int(1)), + )->toRenderSql( + 'INSERT INTO t (id,hits) VALUES (?,?) AS new ON DUPLICATE KEY UPDATE hits = new.hits,seen = 1', + [1, 10], + ); + }); +}); From 23c4e373615d87e8709bb9fa6e00f8d52a364c0c Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 00:00:41 +0200 Subject: [PATCH 10/40] wip: mysql REPLACE builder (values, setMap, select, default values) --- src/MySQL/Builder/ReplaceBuilder.php | 167 +++++++++++++++++++++++++++ src/MySQL/Q.php | 9 ++ tests/MySQL/Q/ReplaceBuilderTest.php | 52 +++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/MySQL/Builder/ReplaceBuilder.php create mode 100644 tests/MySQL/Q/ReplaceBuilderTest.php diff --git a/src/MySQL/Builder/ReplaceBuilder.php b/src/MySQL/Builder/ReplaceBuilder.php new file mode 100644 index 0000000..4b7c0bb --- /dev/null +++ b/src/MySQL/Builder/ReplaceBuilder.php @@ -0,0 +1,167 @@ + $columnNames + * @param list> $valueLists + */ + public function __construct( + private readonly IdentExp $tableName, + private readonly array $columnNames = [], + private readonly bool $defaultValues = false, + private readonly array $valueLists = [], + private readonly ?SelectBuilder $query = null, + ) { + } + + /** + * Set the column names to replace into. + */ + public function columnNames(string $columnName, string ...$rest): self + { + return $this->derive(columnNames: array_values([$columnName, ...$rest])); + } + + /** + * Replace with a row consisting entirely of default values, rendered as + * `() VALUES ()`. Calling {@see values()} afterwards overrules this. + */ + public function defaultValues(): self + { + return $this->derive(defaultValues: true); + } + + /** + * Append a row of values. Call multiple times to replace several rows. + */ + public function values(Exp ...$values): self + { + return $this->derive(valueLists: [...$this->valueLists, array_values($values)]); + } + + /** + * Set the column names and values from the given map (column name => value). + * Values are bound as arguments and the column order is stable (sorted by name). + * Overwrites any previous column names and values. + * + * @param array $map + */ + public function setMap(array $map): self + { + ksort($map, SORT_STRING); + + $values = []; + foreach ($map as $value) { + $values[] = new Arg($value); + } + + return $this->derive(columnNames: array_keys($map), valueLists: [$values]); + } + + /** + * Replace with the result of the given select query. + */ + public function query(SelectBuilder $query): self + { + return $this->derive(query: $query); + } + + /** + * @param list|null $columnNames + * @param list>|null $valueLists + */ + private function derive( + ?array $columnNames = null, + ?bool $defaultValues = null, + ?array $valueLists = null, + ?SelectBuilder $query = null, + ): self { + return new self( + $this->tableName, + $columnNames ?? $this->columnNames, + $defaultValues ?? $this->defaultValues, + $valueLists ?? $this->valueLists, + $query ?? $this->query, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + $sb->writeString('REPLACE INTO '); + $this->tableName->writeSql($sb); + + if ($this->valueLists !== [] && $this->query !== null) { + $sb->addError(new QueryBuilderException('replace: cannot set both values and query')); + + return; + } + + // A row of all defaults is the empty column list with an empty value row. + if ($this->defaultValues) { + $sb->writeString(' () VALUES ()'); + + return; + } + + $this->writeColumnNames($sb); + + if ($this->query !== null) { + $sb->writeString(' '); + $this->query->innerWriteSql($sb); + + return; + } + + if ($this->valueLists === []) { + return; + } + + $sb->writeString(' VALUES '); + foreach ($this->valueLists as $i => $valueList) { + $sb->writeString(($i > 0 ? ',' : '') . '('); + foreach ($valueList as $j => $value) { + if ($j > 0) { + $sb->writeString(','); + } + $value->writeSql($sb); + } + $sb->writeString(')'); + } + } + + private function writeColumnNames(SqlBuilder $sb): void + { + if ($this->columnNames === []) { + return; + } + + $s = ' ('; + foreach ($this->columnNames as $i => $columnName) { + $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); + } + $sb->writeString($s . ')'); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index d179b0e..394ac28 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -23,6 +23,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Precedence; use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\ReplaceBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectSelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter; @@ -70,6 +71,14 @@ public static function inserted(string $column): IdentExp return IdentExp::n('new.' . $column); } + /** + * Start a REPLACE statement into the given table. + */ + public static function replaceInto(IdentExp $tableName): ReplaceBuilder + { + return new ReplaceBuilder($tableName); + } + /** * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. */ diff --git a/tests/MySQL/Q/ReplaceBuilderTest.php b/tests/MySQL/Q/ReplaceBuilderTest.php new file mode 100644 index 0000000..8ba1abc --- /dev/null +++ b/tests/MySQL/Q/ReplaceBuilderTest.php @@ -0,0 +1,52 @@ +columnNames('id', 'email') + ->values(Q::arg(1), Q::arg('a@b.c')), + )->toRenderSql('REPLACE INTO users (id,email) VALUES (?,?)', [1, 'a@b.c']); + }); + + it('replaces multiple rows', function () { + expect( + Q::replaceInto(Q::n('t')) + ->columnNames('a', 'b') + ->values(Q::int(1), Q::int(2)) + ->values(Q::int(3), Q::int(4)), + )->toRenderSql('REPLACE INTO t (a,b) VALUES (1,2),(3,4)'); + }); + + it('replaces from a map with stable column order', function () { + expect( + Q::replaceInto(Q::n('t'))->setMap(['b' => 2, 'a' => 1]), + )->toRenderSql('REPLACE INTO t (a,b) VALUES (?,?)', [1, 2]); + }); + + it('uses the DEFAULT keyword as a value', function () { + expect( + Q::replaceInto(Q::n('t')) + ->columnNames('id', 'created') + ->values(Q::arg(1), Q::default()), + )->toRenderSql('REPLACE INTO t (id,created) VALUES (?,DEFAULT)', [1]); + }); + + it('replaces with the result of a select query', function () { + expect( + Q::replaceInto(Q::n('archive')) + ->columnNames('id', 'name') + ->query(Q::select(Q::n('id'), Q::n('name'))->from(Q::n('users'))), + )->toRenderSql('REPLACE INTO archive (id,name) SELECT id,name FROM users'); + }); + + it('renders a default-values row', function () { + expect( + Q::replaceInto(Q::n('t'))->defaultValues(), + )->toRenderSql('REPLACE INTO t () VALUES ()'); + }); +}); From 0870c2004821c001716c8e870cfae41d8e1d1385 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 00:02:49 +0200 Subject: [PATCH 11/40] wip: mysql UPDATE builder (single + multi-table joins, ORDER BY/LIMIT, WITH, applyIf) --- src/MySQL/Builder/JoinUpdateBuilder.php | 64 ++++++ src/MySQL/Builder/OrderByUpdateBuilder.php | 37 ++++ src/MySQL/Builder/UpdateBuilder.php | 235 +++++++++++++++++++++ src/MySQL/Builder/WithBuilder.php | 8 + src/MySQL/Q.php | 9 + tests/MySQL/Q/UpdateBuilderTest.php | 91 ++++++++ 6 files changed, 444 insertions(+) create mode 100644 src/MySQL/Builder/JoinUpdateBuilder.php create mode 100644 src/MySQL/Builder/OrderByUpdateBuilder.php create mode 100644 src/MySQL/Builder/UpdateBuilder.php create mode 100644 tests/MySQL/Q/UpdateBuilderTest.php diff --git a/src/MySQL/Builder/JoinUpdateBuilder.php b/src/MySQL/Builder/JoinUpdateBuilder.php new file mode 100644 index 0000000..0812732 --- /dev/null +++ b/src/MySQL/Builder/JoinUpdateBuilder.php @@ -0,0 +1,64 @@ +derive(self::class, joins: $this->rebuildLastJoin(alias: $alias)); + } + + /** + * Set the ON condition for the last added join. + */ + public function on(Exp $cond): UpdateBuilder + { + return $this->derive(UpdateBuilder::class, joins: $this->rebuildLastJoin(on: $cond)); + } + + /** + * Set the USING columns for the last added join. + */ + public function using(string ...$columns): UpdateBuilder + { + return $this->derive(UpdateBuilder::class, joins: $this->rebuildLastJoin(using: array_values($columns))); + } + + /** + * Return the join list with the last join replaced by a copy carrying the given + * overrides. + * + * @param list|null $using + * @return list + */ + private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array + { + $joins = $this->joins; + $lastIdx = array_key_last($joins); + assert($lastIdx !== null); + + $join = $joins[$lastIdx]->from; + assert($join instanceof Join); + + $joins[$lastIdx] = new FromItem(new Join( + $join->joinType, + $join->lateral, + $join->from, + $alias ?? $join->alias, + $on ?? $join->on, + $using ?? $join->using, + )); + + return $joins; + } +} diff --git a/src/MySQL/Builder/OrderByUpdateBuilder.php b/src/MySQL/Builder/OrderByUpdateBuilder.php new file mode 100644 index 0000000..6a3ddda --- /dev/null +++ b/src/MySQL/Builder/OrderByUpdateBuilder.php @@ -0,0 +1,37 @@ +derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); + } + + public function desc(): self + { + return $this->derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); + } + + /** + * @return list + */ + private function rebuildLastOrderBy(SortOrder $order): array + { + $orderBys = $this->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $orderBys; + } +} diff --git a/src/MySQL/Builder/UpdateBuilder.php b/src/MySQL/Builder/UpdateBuilder.php new file mode 100644 index 0000000..fa533b9 --- /dev/null +++ b/src/MySQL/Builder/UpdateBuilder.php @@ -0,0 +1,235 @@ + $withQueries the leading WITH clause, if any + * @param list $joins additional joined tables (multi-table update) + * @param list $setItems + * @param list $whereConjunction conditions joined with AND + * @param list $orderBys + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly array $withQueries = [], + protected readonly string $alias = '', + protected readonly array $joins = [], + protected readonly array $setItems = [], + protected readonly array $whereConjunction = [], + protected readonly array $orderBys = [], + protected readonly ?Exp $limit = null, + ) { + } + + /** + * Set an alias for the target table. + */ + public function as(string $alias): self + { + return $this->derive(self::class, alias: $alias); + } + + /** + * Join another table, turning this into a multi-table update. Refine it via + * {@see JoinUpdateBuilder::on()} / {@see JoinUpdateBuilder::using()} / + * {@see JoinUpdateBuilder::as()}. + */ + public function join(FromExp $from): JoinUpdateBuilder + { + return $this->addJoin(JoinType::Inner, $from); + } + + public function leftJoin(FromExp $from): JoinUpdateBuilder + { + return $this->addJoin(JoinType::Left, $from); + } + + public function rightJoin(FromExp $from): JoinUpdateBuilder + { + return $this->addJoin(JoinType::Right, $from); + } + + public function crossJoin(FromExp $from): JoinUpdateBuilder + { + return $this->addJoin(JoinType::Cross, $from); + } + + private function addJoin(JoinType $joinType, FromExp $from): JoinUpdateBuilder + { + return $this->derive(JoinUpdateBuilder::class, joins: [...$this->joins, new FromItem(new Join($joinType, false, $from))]); + } + + /** + * Add a `SET column = value` assignment. Qualify the column (e.g. `t1.col`) in + * a multi-table update. + */ + public function set(string $columnName, Exp $value): self + { + return $this->derive(self::class, setItems: [...$this->setItems, new UpdateSetItem($columnName, $value)]); + } + + /** + * Set the SET-clause assignments from the given map (column name => value). + * Values are bound as arguments and the column order is stable (sorted by name). + * Overwrites any previous assignments. + * + * @param array $map + */ + public function setMap(array $map): self + { + ksort($map, SORT_STRING); + + $setItems = []; + foreach ($map as $columnName => $value) { + $setItems[] = new UpdateSetItem($columnName, new Arg($value)); + } + + return $this->derive(self::class, setItems: $setItems); + } + + /** + * Add a WHERE condition. Multiple calls are joined with AND. + */ + public function where(Exp $cond): self + { + return $this->derive(self::class, whereConjunction: [...$this->whereConjunction, $cond]); + } + + /** + * Add an ORDER BY expression (single-table update only). Refine it via + * {@see OrderByUpdateBuilder}. + */ + public function orderBy(Exp $exp): OrderByUpdateBuilder + { + return $this->derive(OrderByUpdateBuilder::class, orderBys: [...$this->orderBys, new OrderByClause($exp)]); + } + + /** + * Limit the number of rows updated (single-table update only). + */ + public function limit(Exp $exp): self + { + return $this->derive(self::class, limit: $exp); + } + + /** + * Apply the given function to this builder if the condition is true; otherwise + * return the builder unchanged. Helpful for conditional query building. + * + * @param callable(UpdateBuilder): UpdateBuilder $apply + */ + public function applyIf(bool $cond, callable $apply): UpdateBuilder + { + return $cond ? $apply($this) : $this; + } + + /** + * @template T of UpdateBuilder + * @param class-string $class + * @param list|null $joins + * @param list|null $setItems + * @param list|null $whereConjunction + * @param list|null $orderBys + * @return T + */ + protected function derive( + string $class, + ?string $alias = null, + ?array $joins = null, + ?array $setItems = null, + ?array $whereConjunction = null, + ?array $orderBys = null, + ?Exp $limit = null, + ): UpdateBuilder { + return new $class( + $this->tableName, + $this->withQueries, + $alias ?? $this->alias, + $joins ?? $this->joins, + $setItems ?? $this->setItems, + $whereConjunction ?? $this->whereConjunction, + $orderBys ?? $this->orderBys, + $limit ?? $this->limit, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + // ORDER BY / LIMIT bound which rows a single-table update touches; they are + // not part of the multi-table grammar. + if ($this->joins !== [] && ($this->orderBys !== [] || $this->limit !== null)) { + $sb->addError(new QueryBuilderException('update: ORDER BY / LIMIT not allowed in a multi-table update')); + + return; + } + + if ($this->withQueries !== []) { + $this->writeWithQueries($sb, $this->withQueries); + } + + $sb->writeString('UPDATE '); + $this->tableName->writeSql($sb); + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + foreach ($this->joins as $join) { + $sb->writeString(' '); + $join->writeSql($sb); + } + + $sb->writeString(' SET '); + foreach ($this->setItems as $i => $setItem) { + if ($i > 0) { + $sb->writeString(','); + } + $setItem->writeSql($sb); + } + + if ($this->whereConjunction !== []) { + $sb->writeString(' WHERE '); + Junction::and(...$this->whereConjunction)->writeSql($sb); + } + + if ($this->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->limit !== null) { + $sb->writeString(' LIMIT '); + $this->limit->writeSql($sb); + } + } +} diff --git a/src/MySQL/Builder/WithBuilder.php b/src/MySQL/Builder/WithBuilder.php index 222ab93..5bfdef2 100644 --- a/src/MySQL/Builder/WithBuilder.php +++ b/src/MySQL/Builder/WithBuilder.php @@ -57,4 +57,12 @@ public function select(Exp ...$exps): SelectSelectBuilder { return (new SelectBuilder(withQueries: $this->withQueries))->select(...$exps); } + + /** + * Start an UPDATE statement using the WITH clause's common table expressions. + */ + public function update(IdentExp $tableName): UpdateBuilder + { + return new UpdateBuilder($tableName, $this->withQueries); + } } diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 394ac28..e23044f 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -30,6 +30,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\StringLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\SubqueryExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\TypeExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\UpdateBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\UnaryExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithQueryItem; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithWithBuilder; @@ -79,6 +80,14 @@ public static function replaceInto(IdentExp $tableName): ReplaceBuilder return new ReplaceBuilder($tableName); } + /** + * Start an UPDATE statement on the given table. + */ + public static function update(IdentExp $tableName): UpdateBuilder + { + return new UpdateBuilder($tableName); + } + /** * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. */ diff --git a/tests/MySQL/Q/UpdateBuilderTest.php b/tests/MySQL/Q/UpdateBuilderTest.php new file mode 100644 index 0000000..fd0ee90 --- /dev/null +++ b/tests/MySQL/Q/UpdateBuilderTest.php @@ -0,0 +1,91 @@ +set('name', Q::arg('Jane')) + ->where(Q::n('id')->eq(Q::arg(1))), + )->toRenderSql('UPDATE users SET name = ? WHERE id = ?', ['Jane', 1]); + }); + + it('updates from a map with stable column order', function () { + expect( + Q::update(Q::n('t'))->setMap(['b' => 2, 'a' => 1]), + )->toRenderSql('UPDATE t SET a = ?,b = ?', [1, 2]); + }); + + it('quotes reserved keyword columns in SET', function () { + expect( + Q::update(Q::n('t'))->set('order', Q::int(1)), + )->toRenderSql('UPDATE t SET `order` = 1'); + }); + + it('aliases the target table', function () { + expect( + Q::update(Q::n('users'))->as('u')->set('u.name', Q::arg('x')), + )->toRenderSql('UPDATE users AS u SET u.name = ?', ['x']); + }); + + it('renders ORDER BY and LIMIT on a single-table update', function () { + expect( + Q::update(Q::n('t')) + ->set('id', Q::n('id')->plus(Q::int(1))) + ->orderBy(Q::n('id'))->desc() + ->limit(Q::int(10)), + )->toRenderSql('UPDATE t SET id = id + 1 ORDER BY id DESC LIMIT 10'); + }); + + it('renders a multi-table update with a LEFT JOIN', function () { + expect( + Q::update(Q::n('t1')) + ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->set('t1.col1', Q::n('t2.col1')) + ->where(Q::n('t2.col2')->isNull()), + )->toRenderSql( + 'UPDATE t1 LEFT JOIN t2 ON t1.id = t2.id SET t1.col1 = t2.col1 WHERE t2.col2 IS NULL', + ); + }); + + it('renders a multi-table update joining on equal ids', function () { + expect( + Q::update(Q::n('items')) + ->join(Q::n('month'))->on(Q::n('items.id')->eq(Q::n('month.id'))) + ->set('items.price', Q::n('month.price')), + )->toRenderSql('UPDATE items JOIN month ON items.id = month.id SET items.price = month.price'); + }); + + it('rejects ORDER BY / LIMIT on a multi-table update', function () { + $q = Q::update(Q::n('t1')) + ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->set('t1.a', Q::int(1)) + ->limit(Q::int(1)); + + expect(static fn () => Q::build($q)->toSql())->toThrow(QueryBuilderException::class); + }); + + it('renders a leading WITH clause', function () { + expect( + Q::with('ids')->as(Q::select(Q::n('id'))->from(Q::n('flagged'))) + ->update(Q::n('users')) + ->set('active', Q::int(0)) + ->where(Q::n('id')->in(Q::select(Q::n('id'))->from(Q::n('ids')))), + )->toRenderSql( + 'WITH ids AS (SELECT id FROM flagged) UPDATE users SET active = 0 WHERE id IN (SELECT id FROM ids)', + ); + }); + + it('applies a conditional modification with applyIf', function () { + $build = static fn (bool $withLimit) => Q::update(Q::n('t')) + ->set('a', Q::int(1)) + ->applyIf($withLimit, static fn ($b) => $b->limit(Q::int(5))); + + expect($build(true))->toRenderSql('UPDATE t SET a = 1 LIMIT 5'); + expect($build(false))->toRenderSql('UPDATE t SET a = 1'); + }); +}); From 167cf35c156b2ab9bd02e86eee9f25c7e3d10428 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 00:05:39 +0200 Subject: [PATCH 12/40] wip: mysql DELETE builder (single + multi-table, ORDER BY/LIMIT, WITH); DML coverage ledger --- docs/mysql-mariadb-design.md | 23 ++- src/MySQL/Builder/DeleteBuilder.php | 212 +++++++++++++++++++++ src/MySQL/Builder/JoinDeleteBuilder.php | 64 +++++++ src/MySQL/Builder/OrderByDeleteBuilder.php | 37 ++++ src/MySQL/Builder/WithBuilder.php | 8 + src/MySQL/Q.php | 9 + tests/MySQL/Q/DeleteBuilderTest.php | 63 ++++++ 7 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 src/MySQL/Builder/DeleteBuilder.php create mode 100644 src/MySQL/Builder/JoinDeleteBuilder.php create mode 100644 src/MySQL/Builder/OrderByDeleteBuilder.php create mode 100644 tests/MySQL/Q/DeleteBuilderTest.php diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index 7d94af9..f4e6aa5 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -461,19 +461,40 @@ all **Supported**. The window-only functions `ROW_NUMBER`, `RANK`, `DENSE_RANK`, | aggregate-as-window beyond `count`/`sum`/`avg`/`min`/`max` | Deferred | the full curated aggregate set (and its `over()`) lands with the function facade (§7, stage 6) | ### INSERT / REPLACE + +INSERT (`columnNames`/`values`/`setMap`/`query`/`defaultValues`→`() VALUES ()`, +`IGNORE`, `ON DUPLICATE KEY UPDATE` with the `AS new` row alias reached via +`Q::inserted('col')`) and REPLACE (same source forms, no `ON DUPLICATE KEY UPDATE`) +are **Supported**. `RETURNING` on INSERT/REPLACE is MariaDB-only (stage 7); absent +on MySQL. + | Clause | Status | Reason | |---|---|---| | `LOW_PRIORITY`/`HIGH_PRIORITY`/`DELAYED` | Excluded | priority hints; `DELAYED` ignored in 8.4 | -| `INSERT ... SET` assignment form | Deferred | `(cols) VALUES (...)` covers it | +| `INSERT ... SET` / `REPLACE ... SET` assignment form | Deferred | `(cols) VALUES (...)` covers it | | `PARTITION (...)` | Deferred | partition selection | | MySQL `VALUES ROW(...)` / `TABLE tbl` source | Deferred | MySQL-only source forms | +| INSERT/REPLACE `RETURNING` | N/A (MariaDB-only) | availability split — added with the MariaDB ladder (stage 7) | ### UPDATE / DELETE + +Single-table UPDATE/DELETE (`set`/`setMap`, `where`, `orderBy`+`asc`/`desc`, +`limit`, target `as`), multi-table UPDATE/DELETE via `join`/`leftJoin`/`rightJoin`/ +`crossJoin` (DELETE renders the portable `DELETE tbl.* FROM ` form), and a +leading `WITH` are **Supported**. `ORDER BY`/`LIMIT` are build-validated off for +multi-table statements. `UPDATE ... RETURNING` is dropped for both engines (only +MariaDB 13+); `DELETE ... RETURNING` is MariaDB-only (stage 7). + | Clause | Status | Reason | |---|---|---| | `LOW_PRIORITY`/`QUICK` | Excluded | priority hints | | `IGNORE` | Deferred | error-demotion toggle; add as a modifier later | | `PARTITION (...)` | Deferred | partition selection | +| comma-separated table list (`UPDATE a,b` / `DELETE a,b FROM`) | Deferred | the `JOIN` form (incl. `CROSS JOIN`) covers it | +| multi-target DELETE (`DELETE t1,t2 FROM …`) | Deferred | single-target `DELETE t.* FROM ` is the portable form | +| `DELETE FROM t USING ` form | Deferred | the `DELETE t.* FROM ` form is rendered instead | +| `UPDATE ... RETURNING` | N/A | no engine in anchor (MariaDB 13+ only) | +| `DELETE ... RETURNING` | N/A (MariaDB-only) | availability split — added with the MariaDB ladder (stage 7) | | MariaDB `FOR PORTION OF period FROM..TO` | Excluded | application-time temporal tables, separate feature | | MariaDB DELETE index hints (11.8.1+) | Deferred | optimizer hint | diff --git a/src/MySQL/Builder/DeleteBuilder.php b/src/MySQL/Builder/DeleteBuilder.php new file mode 100644 index 0000000..90ea91a --- /dev/null +++ b/src/MySQL/Builder/DeleteBuilder.php @@ -0,0 +1,212 @@ + $withQueries the leading WITH clause, if any + * @param list $joins additional joined tables (multi-table delete) + * @param list $whereConjunction conditions joined with AND + * @param list $orderBys + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly array $withQueries = [], + protected readonly string $alias = '', + protected readonly array $joins = [], + protected readonly array $whereConjunction = [], + protected readonly array $orderBys = [], + protected readonly ?Exp $limit = null, + ) { + } + + /** + * Set an alias for the target table. + */ + public function as(string $alias): self + { + return $this->derive(self::class, alias: $alias); + } + + /** + * Join another table, turning this into a multi-table delete. Refine it via + * {@see JoinDeleteBuilder::on()} / {@see JoinDeleteBuilder::using()} / + * {@see JoinDeleteBuilder::as()}. + */ + public function join(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinType::Inner, $from); + } + + public function leftJoin(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinType::Left, $from); + } + + public function rightJoin(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinType::Right, $from); + } + + public function crossJoin(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinType::Cross, $from); + } + + private function addJoin(JoinType $joinType, FromExp $from): JoinDeleteBuilder + { + return $this->derive(JoinDeleteBuilder::class, joins: [...$this->joins, new FromItem(new Join($joinType, false, $from))]); + } + + /** + * Add a WHERE condition. Multiple calls are joined with AND. + */ + public function where(Exp $cond): self + { + return $this->derive(self::class, whereConjunction: [...$this->whereConjunction, $cond]); + } + + /** + * Add an ORDER BY expression (single-table delete only). Refine it via + * {@see OrderByDeleteBuilder}. + */ + public function orderBy(Exp $exp): OrderByDeleteBuilder + { + return $this->derive(OrderByDeleteBuilder::class, orderBys: [...$this->orderBys, new OrderByClause($exp)]); + } + + /** + * Limit the number of rows deleted (single-table delete only). + */ + public function limit(Exp $exp): self + { + return $this->derive(self::class, limit: $exp); + } + + /** + * @template T of DeleteBuilder + * @param class-string $class + * @param list|null $joins + * @param list|null $whereConjunction + * @param list|null $orderBys + * @return T + */ + protected function derive( + string $class, + ?string $alias = null, + ?array $joins = null, + ?array $whereConjunction = null, + ?array $orderBys = null, + ?Exp $limit = null, + ): DeleteBuilder { + return new $class( + $this->tableName, + $this->withQueries, + $alias ?? $this->alias, + $joins ?? $this->joins, + $whereConjunction ?? $this->whereConjunction, + $orderBys ?? $this->orderBys, + $limit ?? $this->limit, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + // ORDER BY / LIMIT bound which rows a single-table delete touches; they are + // not part of the multi-table grammar. + if ($this->joins !== [] && ($this->orderBys !== [] || $this->limit !== null)) { + $sb->addError(new QueryBuilderException('delete: ORDER BY / LIMIT not allowed in a multi-table delete')); + + return; + } + + if ($this->withQueries !== []) { + $this->writeWithQueries($sb, $this->withQueries); + } + + if ($this->joins !== []) { + $this->writeMultiTable($sb); + + return; + } + + $sb->writeString('DELETE FROM '); + $this->tableName->writeSql($sb); + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + + $this->writeWhere($sb); + + if ($this->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->limit !== null) { + $sb->writeString(' LIMIT '); + $this->limit->writeSql($sb); + } + } + + private function writeMultiTable(SqlBuilder $sb): void + { + // The target rows are named before FROM (`tbl.*`), the join graph after it. + $targetRef = ($this->alias !== '' ? $this->alias : $this->tableName->ident()) . '.*'; + $sb->writeString('DELETE '); + IdentExp::n($targetRef)->writeSql($sb); + + $sb->writeString(' FROM '); + $this->tableName->writeSql($sb); + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + foreach ($this->joins as $join) { + $sb->writeString(' '); + $join->writeSql($sb); + } + + $this->writeWhere($sb); + } + + private function writeWhere(SqlBuilder $sb): void + { + if ($this->whereConjunction !== []) { + $sb->writeString(' WHERE '); + Junction::and(...$this->whereConjunction)->writeSql($sb); + } + } +} diff --git a/src/MySQL/Builder/JoinDeleteBuilder.php b/src/MySQL/Builder/JoinDeleteBuilder.php new file mode 100644 index 0000000..ee468df --- /dev/null +++ b/src/MySQL/Builder/JoinDeleteBuilder.php @@ -0,0 +1,64 @@ +derive(self::class, joins: $this->rebuildLastJoin(alias: $alias)); + } + + /** + * Set the ON condition for the last added join. + */ + public function on(Exp $cond): DeleteBuilder + { + return $this->derive(DeleteBuilder::class, joins: $this->rebuildLastJoin(on: $cond)); + } + + /** + * Set the USING columns for the last added join. + */ + public function using(string ...$columns): DeleteBuilder + { + return $this->derive(DeleteBuilder::class, joins: $this->rebuildLastJoin(using: array_values($columns))); + } + + /** + * Return the join list with the last join replaced by a copy carrying the given + * overrides. + * + * @param list|null $using + * @return list + */ + private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array + { + $joins = $this->joins; + $lastIdx = array_key_last($joins); + assert($lastIdx !== null); + + $join = $joins[$lastIdx]->from; + assert($join instanceof Join); + + $joins[$lastIdx] = new FromItem(new Join( + $join->joinType, + $join->lateral, + $join->from, + $alias ?? $join->alias, + $on ?? $join->on, + $using ?? $join->using, + )); + + return $joins; + } +} diff --git a/src/MySQL/Builder/OrderByDeleteBuilder.php b/src/MySQL/Builder/OrderByDeleteBuilder.php new file mode 100644 index 0000000..eab6cc7 --- /dev/null +++ b/src/MySQL/Builder/OrderByDeleteBuilder.php @@ -0,0 +1,37 @@ +derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); + } + + public function desc(): self + { + return $this->derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); + } + + /** + * @return list + */ + private function rebuildLastOrderBy(SortOrder $order): array + { + $orderBys = $this->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $orderBys; + } +} diff --git a/src/MySQL/Builder/WithBuilder.php b/src/MySQL/Builder/WithBuilder.php index 5bfdef2..251c2df 100644 --- a/src/MySQL/Builder/WithBuilder.php +++ b/src/MySQL/Builder/WithBuilder.php @@ -65,4 +65,12 @@ public function update(IdentExp $tableName): UpdateBuilder { return new UpdateBuilder($tableName, $this->withQueries); } + + /** + * Start a DELETE statement using the WITH clause's common table expressions. + */ + public function deleteFrom(IdentExp $tableName): DeleteBuilder + { + return new DeleteBuilder($tableName, $this->withQueries); + } } diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index e23044f..a1d851e 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -10,6 +10,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\CaseBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\CastExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\DefaultLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\DeleteBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; use Flowpack\QueryObjectBuilder\MySQL\Builder\ExistsExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\Expressions; @@ -88,6 +89,14 @@ public static function update(IdentExp $tableName): UpdateBuilder return new UpdateBuilder($tableName); } + /** + * Start a DELETE statement on the given table. + */ + public static function deleteFrom(IdentExp $tableName): DeleteBuilder + { + return new DeleteBuilder($tableName); + } + /** * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. */ diff --git a/tests/MySQL/Q/DeleteBuilderTest.php b/tests/MySQL/Q/DeleteBuilderTest.php new file mode 100644 index 0000000..9628d63 --- /dev/null +++ b/tests/MySQL/Q/DeleteBuilderTest.php @@ -0,0 +1,63 @@ +where(Q::n('id')->eq(Q::arg(1))), + )->toRenderSql('DELETE FROM users WHERE id = ?', [1]); + }); + + it('aliases the target table', function () { + expect( + Q::deleteFrom(Q::n('users'))->as('u')->where(Q::n('u.id')->eq(Q::arg(1))), + )->toRenderSql('DELETE FROM users AS u WHERE u.id = ?', [1]); + }); + + it('renders ORDER BY and LIMIT on a single-table delete', function () { + expect( + Q::deleteFrom(Q::n('somelog')) + ->where(Q::n('user')->eq(Q::string('jcole'))) + ->orderBy(Q::n('timestamp_column')) + ->limit(Q::int(1)), + )->toRenderSql("DELETE FROM somelog WHERE user = 'jcole' ORDER BY timestamp_column LIMIT 1"); + }); + + it('renders a multi-table delete with a LEFT JOIN', function () { + expect( + Q::deleteFrom(Q::n('t1')) + ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->where(Q::n('t2.id')->isNull()), + )->toRenderSql('DELETE t1.* FROM t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.id IS NULL'); + }); + + it('renders a multi-table delete with aliased targets', function () { + expect( + Q::deleteFrom(Q::n('t1'))->as('a1') + ->join(Q::n('t2'))->as('a2') + ->where(Q::n('a1.id')->eq(Q::n('a2.id'))), + )->toRenderSql('DELETE a1.* FROM t1 AS a1 JOIN t2 AS a2 WHERE a1.id = a2.id'); + }); + + it('rejects ORDER BY / LIMIT on a multi-table delete', function () { + $q = Q::deleteFrom(Q::n('t1')) + ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->limit(Q::int(1)); + + expect(static fn () => Q::build($q)->toSql())->toThrow(QueryBuilderException::class); + }); + + it('renders a leading WITH clause', function () { + expect( + Q::with('stale')->as(Q::select(Q::n('id'))->from(Q::n('sessions'))->where(Q::n('expired')->eq(Q::int(1)))) + ->deleteFrom(Q::n('users')) + ->where(Q::n('id')->in(Q::select(Q::n('id'))->from(Q::n('stale')))), + )->toRenderSql( + 'WITH stale AS (SELECT id FROM sessions WHERE expired = 1) DELETE FROM users WHERE id IN (SELECT id FROM stale)', + ); + }); +}); From 642a513ed4844ce650a9dfa31dcb0225c5ee8bcf Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 00:09:33 +0200 Subject: [PATCH 13/40] wip: mysql special-shape functions (GROUP_CONCAT, EXTRACT, INTERVAL, TRIM, CONVERT, conditionals) --- src/MySQL/Builder/ConvertExp.php | 28 ++++++ src/MySQL/Builder/ExtractExp.php | 26 ++++++ src/MySQL/Builder/GroupConcatBuilder.php | 73 +++++++++++++++ src/MySQL/Builder/IntervalExp.php | 27 ++++++ .../Builder/OrderByGroupConcatBuilder.php | 34 +++++++ src/MySQL/Builder/TrimExp.php | 43 +++++++++ src/MySQL/Q.php | 43 +++++++++ src/MySQL/Q/Func.php | 93 +++++++++++++++++++ tests/MySQL/Q/FunctionsSpecialShapeTest.php | 83 +++++++++++++++++ 9 files changed, 450 insertions(+) create mode 100644 src/MySQL/Builder/ConvertExp.php create mode 100644 src/MySQL/Builder/ExtractExp.php create mode 100644 src/MySQL/Builder/GroupConcatBuilder.php create mode 100644 src/MySQL/Builder/IntervalExp.php create mode 100644 src/MySQL/Builder/OrderByGroupConcatBuilder.php create mode 100644 src/MySQL/Builder/TrimExp.php create mode 100644 tests/MySQL/Q/FunctionsSpecialShapeTest.php diff --git a/src/MySQL/Builder/ConvertExp.php b/src/MySQL/Builder/ConvertExp.php new file mode 100644 index 0000000..da121fd --- /dev/null +++ b/src/MySQL/Builder/ConvertExp.php @@ -0,0 +1,28 @@ +writeString('CONVERT('); + $this->expr->writeSql($sb); + $sb->writeString(', '); + $this->type->writeSql($sb); + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Builder/ExtractExp.php b/src/MySQL/Builder/ExtractExp.php new file mode 100644 index 0000000..590b270 --- /dev/null +++ b/src/MySQL/Builder/ExtractExp.php @@ -0,0 +1,26 @@ +writeString('EXTRACT(' . $this->unit . ' FROM '); + $this->from->writeSql($sb); + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Builder/GroupConcatBuilder.php b/src/MySQL/Builder/GroupConcatBuilder.php new file mode 100644 index 0000000..3e2f697 --- /dev/null +++ b/src/MySQL/Builder/GroupConcatBuilder.php @@ -0,0 +1,73 @@ + $exps + * @param list $orderBys + */ + public function __construct( + protected readonly array $exps, + protected readonly bool $distinct = false, + protected readonly array $orderBys = [], + protected readonly ?string $separator = null, + ) { + } + + public function distinct(): self + { + return new self($this->exps, true, $this->orderBys, $this->separator); + } + + /** + * Add an ORDER BY expression (refine via {@see OrderByGroupConcatBuilder}). + */ + public function orderBy(Exp $exp): OrderByGroupConcatBuilder + { + return new OrderByGroupConcatBuilder($this->exps, $this->distinct, [...$this->orderBys, new OrderByClause($exp)], $this->separator); + } + + /** + * Set the separator string placed between concatenated values (defaults to `,`). + */ + public function separator(string $separator): self + { + return new self($this->exps, $this->distinct, $this->orderBys, $separator); + } + + public function writeSql(SqlBuilder $sb): void + { + $sb->writeString('GROUP_CONCAT(' . ($this->distinct ? 'DISTINCT ' : '')); + foreach ($this->exps as $i => $exp) { + if ($i > 0) { + $sb->writeString(','); + } + $exp->writeSql($sb); + } + + if ($this->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->separator !== null) { + $sb->writeString(' SEPARATOR '); + (new StringLiteral($this->separator))->writeSql($sb); + } + + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Builder/IntervalExp.php b/src/MySQL/Builder/IntervalExp.php new file mode 100644 index 0000000..685782f --- /dev/null +++ b/src/MySQL/Builder/IntervalExp.php @@ -0,0 +1,27 @@ +writeString('INTERVAL '); + $this->expr->writeSql($sb); + $sb->writeString(' ' . $this->unit); + } +} diff --git a/src/MySQL/Builder/OrderByGroupConcatBuilder.php b/src/MySQL/Builder/OrderByGroupConcatBuilder.php new file mode 100644 index 0000000..294689f --- /dev/null +++ b/src/MySQL/Builder/OrderByGroupConcatBuilder.php @@ -0,0 +1,34 @@ +rebuildLastOrderBy(SortOrder::Asc); + } + + public function desc(): self + { + return $this->rebuildLastOrderBy(SortOrder::Desc); + } + + private function rebuildLastOrderBy(SortOrder $order): self + { + $orderBys = $this->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return new self($this->exps, $this->distinct, $orderBys, $this->separator); + } +} diff --git a/src/MySQL/Builder/TrimExp.php b/src/MySQL/Builder/TrimExp.php new file mode 100644 index 0000000..9c2970a --- /dev/null +++ b/src/MySQL/Builder/TrimExp.php @@ -0,0 +1,43 @@ +writeString('TRIM('); + + $s = ''; + if ($this->direction !== '') { + $s = $this->direction . ' '; + } + if ($this->remstr !== null) { + $sb->writeString($s); + $this->remstr->writeSql($sb); + $s = ' '; + } + // A direction or a remove-string introduces the FROM separator. + if ($this->direction !== '' || $this->remstr !== null) { + $sb->writeString($s . 'FROM '); + } + + $this->str->writeSql($sb); + $sb->writeString(')'); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index a1d851e..034b555 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -9,6 +9,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\BoolLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\CaseBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\CastExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\ConvertExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\DefaultLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\DeleteBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; @@ -19,6 +20,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IdentExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\InsertBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\IntervalExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IntLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Junction; use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; @@ -275,6 +277,23 @@ public static function cast(Exp $exp, string $type): CastExp return new CastExp($exp, new TypeExp($type)); } + /** + * A `CONVERT(expr, type)` expression — the function-call form of a type cast. + */ + public static function convert(Exp $exp, string $type): ConvertExp + { + return new ConvertExp($exp, new TypeExp($type)); + } + + /** + * A temporal `INTERVAL expr unit` operand (e.g. `Q::interval(Q::int(1), 'DAY')` + * for `INTERVAL 1 DAY`), for date arithmetic and `Q\Func::dateAdd()` / `dateSub()`. + */ + public static function interval(Exp $expr, string $unit): IntervalExp + { + return new IntervalExp($expr, $unit); + } + /** * Build a `COALESCE(...)` expression. */ @@ -283,6 +302,30 @@ public static function coalesce(Exp $exp, Exp ...$rest): FuncExp return new FuncExp('COALESCE', array_values([$exp, ...$rest])); } + /** + * Build a `NULLIF(a, b)` expression (returns NULL when the two are equal). + */ + public static function nullif(Exp $a, Exp $b): FuncExp + { + return new FuncExp('NULLIF', [$a, $b]); + } + + /** + * Build a `GREATEST(...)` expression (the largest of its arguments). + */ + public static function greatest(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('GREATEST', array_values([$exp, ...$rest])); + } + + /** + * Build a `LEAST(...)` expression (the smallest of its arguments). + */ + public static function least(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('LEAST', array_values([$exp, ...$rest])); + } + /** * Start a CASE expression (optionally with a leading expression to compare * each WHEN against). diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index 51e4e42..c7b24dc 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -6,7 +6,10 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\AggBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\ExtractExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\GroupConcatBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\TrimExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\WindowFuncBuilder; /** @@ -61,6 +64,96 @@ public static function max(Exp $expr): AggBuilder return new AggBuilder('MAX', [$expr]); } + /** + * Build a `GROUP_CONCAT(...)` aggregate. Refine with + * {@see GroupConcatBuilder::distinct()} / {@see GroupConcatBuilder::orderBy()} / + * {@see GroupConcatBuilder::separator()}. + */ + public static function groupConcat(Exp $expr, Exp ...$rest): GroupConcatBuilder + { + return new GroupConcatBuilder(array_values([$expr, ...$rest])); + } + + // Conditional functions + + /** + * Build an `IF(condition, then, else)` expression. + */ + public static function if(Exp $condition, Exp $then, Exp $else): FuncExp + { + return new FuncExp('IF', [$condition, $then, $else]); + } + + /** + * Build an `IFNULL(a, b)` expression (returns `b` when `a` is NULL). + */ + public static function ifnull(Exp $a, Exp $b): FuncExp + { + return new FuncExp('IFNULL', [$a, $b]); + } + + // Date / time functions with special shapes + + /** + * Build an `EXTRACT(unit FROM source)` expression (e.g. `extract('YEAR', $d)`). + */ + public static function extract(string $unit, Exp $from): ExtractExp + { + return new ExtractExp($unit, $from); + } + + /** + * Build a `DATE_ADD(date, interval)` expression. Pass the interval via + * `Q::interval(...)` (e.g. `dateAdd($d, Q::interval(Q::int(1), 'DAY'))`). + */ + public static function dateAdd(Exp $date, Exp $interval): FuncExp + { + return new FuncExp('DATE_ADD', [$date, $interval]); + } + + /** + * Build a `DATE_SUB(date, interval)` expression. Pass the interval via + * `Q::interval(...)`. + */ + public static function dateSub(Exp $date, Exp $interval): FuncExp + { + return new FuncExp('DATE_SUB', [$date, $interval]); + } + + // String trimming + + /** + * Build a `TRIM(str)` expression (removes leading and trailing spaces). + */ + public static function trim(Exp $str): TrimExp + { + return new TrimExp($str); + } + + /** + * Build a `TRIM(LEADING remstr FROM str)` expression. + */ + public static function trimLeading(Exp $remstr, Exp $str): TrimExp + { + return new TrimExp($str, 'LEADING', $remstr); + } + + /** + * Build a `TRIM(TRAILING remstr FROM str)` expression. + */ + public static function trimTrailing(Exp $remstr, Exp $str): TrimExp + { + return new TrimExp($str, 'TRAILING', $remstr); + } + + /** + * Build a `TRIM(BOTH remstr FROM str)` expression. + */ + public static function trimBoth(Exp $remstr, Exp $str): TrimExp + { + return new TrimExp($str, 'BOTH', $remstr); + } + // Nonaggregate window functions — each requires an `OVER` clause; call // {@see WindowFuncBuilder::over()} to add it. diff --git a/tests/MySQL/Q/FunctionsSpecialShapeTest.php b/tests/MySQL/Q/FunctionsSpecialShapeTest.php new file mode 100644 index 0000000..e1d3803 --- /dev/null +++ b/tests/MySQL/Q/FunctionsSpecialShapeTest.php @@ -0,0 +1,83 @@ +toRenderSql('GROUP_CONCAT(name)'); + }); + + it('renders DISTINCT, ORDER BY and SEPARATOR', function () { + expect( + Q\Func::groupConcat(Q::n('name')) + ->distinct() + ->orderBy(Q::n('name'))->desc() + ->separator(', '), + )->toRenderSql("GROUP_CONCAT(DISTINCT name ORDER BY name DESC SEPARATOR ', ')"); + }); + }); + + describe('EXTRACT', function () { + it('extracts a field from a date', function () { + expect(Q\Func::extract('YEAR', Q::n('created')))->toRenderSql('EXTRACT(YEAR FROM created)'); + }); + }); + + describe('INTERVAL and date arithmetic', function () { + it('renders DATE_ADD with an interval', function () { + expect( + Q\Func::dateAdd(Q::n('created'), Q::interval(Q::int(1), 'DAY')), + )->toRenderSql('DATE_ADD(created, INTERVAL 1 DAY)'); + }); + + it('renders DATE_SUB with an interval', function () { + expect( + Q\Func::dateSub(Q::n('created'), Q::interval(Q::int(2), 'MONTH')), + )->toRenderSql('DATE_SUB(created, INTERVAL 2 MONTH)'); + }); + + it('uses an interval as an arithmetic operand', function () { + expect( + Q::n('created')->plus(Q::interval(Q::int(7), 'DAY')), + )->toRenderSql('created + INTERVAL 7 DAY'); + }); + }); + + describe('TRIM', function () { + it('renders a plain trim', function () { + expect(Q\Func::trim(Q::n('name')))->toRenderSql('TRIM(name)'); + }); + + it('renders directional trims with a remove string', function () { + expect(Q\Func::trimLeading(Q::string('x'), Q::n('name'))) + ->toRenderSql("TRIM(LEADING 'x' FROM name)"); + expect(Q\Func::trimTrailing(Q::string('x'), Q::n('name'))) + ->toRenderSql("TRIM(TRAILING 'x' FROM name)"); + expect(Q\Func::trimBoth(Q::string('x'), Q::n('name'))) + ->toRenderSql("TRIM(BOTH 'x' FROM name)"); + }); + }); + + describe('conditional functions', function () { + it('renders NULLIF, GREATEST and LEAST', function () { + expect(Q::nullif(Q::n('a'), Q::int(0)))->toRenderSql('NULLIF(a, 0)'); + expect(Q::greatest(Q::n('a'), Q::n('b'), Q::int(0)))->toRenderSql('GREATEST(a, b, 0)'); + expect(Q::least(Q::n('a'), Q::n('b')))->toRenderSql('LEAST(a, b)'); + }); + + it('renders IF and IFNULL', function () { + expect(Q\Func::if(Q::n('a')->gt(Q::int(0)), Q::int(1), Q::int(0))) + ->toRenderSql('IF(a > 0, 1, 0)'); + expect(Q\Func::ifnull(Q::n('a'), Q::int(0)))->toRenderSql('IFNULL(a, 0)'); + }); + }); + + describe('CONVERT', function () { + it('renders the function-call cast form', function () { + expect(Q::convert(Q::n('a'), 'DECIMAL(10,2)'))->toRenderSql('CONVERT(a, DECIMAL(10,2))'); + }); + }); +}); From 154aae317885648755ecfb743b80f17f01f15d78 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 00:14:04 +0200 Subject: [PATCH 14/40] wip: mysql curated Q\Func set (string/regexp/numeric/datetime/json/misc + aggregates) --- src/MySQL/Builder/Keyword.php | 24 ++ src/MySQL/Q/Func.php | 739 ++++++++++++++++++++++++++++++++ tests/MySQL/Q/FunctionsTest.php | 121 ++++++ 3 files changed, 884 insertions(+) create mode 100644 src/MySQL/Builder/Keyword.php create mode 100644 tests/MySQL/Q/FunctionsTest.php diff --git a/src/MySQL/Builder/Keyword.php b/src/MySQL/Builder/Keyword.php new file mode 100644 index 0000000..90830ae --- /dev/null +++ b/src/MySQL/Builder/Keyword.php @@ -0,0 +1,24 @@ +writeString($this->keyword); + } +} diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index c7b24dc..4c17357 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -9,6 +9,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\ExtractExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\GroupConcatBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Keyword; use Flowpack\QueryObjectBuilder\MySQL\Builder\TrimExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\WindowFuncBuilder; @@ -74,6 +75,78 @@ public static function groupConcat(Exp $expr, Exp ...$rest): GroupConcatBuilder return new GroupConcatBuilder(array_values([$expr, ...$rest])); } + /** + * Build a `JSON_ARRAYAGG(expr)` aggregate (collects values into a JSON array). + */ + public static function jsonArrayAgg(Exp $expr): AggBuilder + { + return self::agg('JSON_ARRAYAGG', $expr); + } + + /** + * Build a `JSON_OBJECTAGG(key, value)` aggregate (collects pairs into a JSON object). + */ + public static function jsonObjectAgg(Exp $key, Exp $value): AggBuilder + { + return self::agg('JSON_OBJECTAGG', $key, $value); + } + + /** + * Build a `BIT_AND(expr)` aggregate (bitwise AND of all values). + */ + public static function bitAnd(Exp $expr): AggBuilder + { + return self::agg('BIT_AND', $expr); + } + + /** + * Build a `BIT_OR(expr)` aggregate (bitwise OR of all values). + */ + public static function bitOr(Exp $expr): AggBuilder + { + return self::agg('BIT_OR', $expr); + } + + /** + * Build a `BIT_XOR(expr)` aggregate (bitwise XOR of all values). + */ + public static function bitXor(Exp $expr): AggBuilder + { + return self::agg('BIT_XOR', $expr); + } + + /** + * Build a `STDDEV_POP(expr)` aggregate (population standard deviation). + */ + public static function stddevPop(Exp $expr): AggBuilder + { + return self::agg('STDDEV_POP', $expr); + } + + /** + * Build a `STDDEV_SAMP(expr)` aggregate (sample standard deviation). + */ + public static function stddevSamp(Exp $expr): AggBuilder + { + return self::agg('STDDEV_SAMP', $expr); + } + + /** + * Build a `VAR_POP(expr)` aggregate (population variance). + */ + public static function varPop(Exp $expr): AggBuilder + { + return self::agg('VAR_POP', $expr); + } + + /** + * Build a `VAR_SAMP(expr)` aggregate (sample variance). + */ + public static function varSamp(Exp $expr): AggBuilder + { + return self::agg('VAR_SAMP', $expr); + } + // Conditional functions /** @@ -154,6 +227,662 @@ public static function trimBoth(Exp $remstr, Exp $str): TrimExp return new TrimExp($str, 'BOTH', $remstr); } + // String functions + + /** `CONCAT(...)` — concatenate the arguments. */ + public static function concat(Exp $expr, Exp ...$rest): FuncExp + { + return self::call('CONCAT', $expr, ...$rest); + } + + /** `CONCAT_WS(separator, ...)` — concatenate with a separator, skipping NULLs. */ + public static function concatWs(Exp $separator, Exp $expr, Exp ...$rest): FuncExp + { + return self::call('CONCAT_WS', $separator, $expr, ...$rest); + } + + /** `LOWER(str)`. */ + public static function lower(Exp $str): FuncExp + { + return self::call('LOWER', $str); + } + + /** `UPPER(str)`. */ + public static function upper(Exp $str): FuncExp + { + return self::call('UPPER', $str); + } + + /** `LENGTH(str)` — length in bytes. */ + public static function length(Exp $str): FuncExp + { + return self::call('LENGTH', $str); + } + + /** `CHAR_LENGTH(str)` — length in characters. */ + public static function charLength(Exp $str): FuncExp + { + return self::call('CHAR_LENGTH', $str); + } + + /** `SUBSTRING(str, pos)` or `SUBSTRING(str, pos, len)`. */ + public static function substring(Exp $str, Exp $pos, ?Exp $len = null): FuncExp + { + return $len === null ? self::call('SUBSTRING', $str, $pos) : self::call('SUBSTRING', $str, $pos, $len); + } + + /** `LEFT(str, len)`. */ + public static function left(Exp $str, Exp $len): FuncExp + { + return self::call('LEFT', $str, $len); + } + + /** `RIGHT(str, len)`. */ + public static function right(Exp $str, Exp $len): FuncExp + { + return self::call('RIGHT', $str, $len); + } + + /** `LTRIM(str)` — remove leading spaces. */ + public static function ltrim(Exp $str): FuncExp + { + return self::call('LTRIM', $str); + } + + /** `RTRIM(str)` — remove trailing spaces. */ + public static function rtrim(Exp $str): FuncExp + { + return self::call('RTRIM', $str); + } + + /** `LPAD(str, len, pad)`. */ + public static function lpad(Exp $str, Exp $len, Exp $pad): FuncExp + { + return self::call('LPAD', $str, $len, $pad); + } + + /** `RPAD(str, len, pad)`. */ + public static function rpad(Exp $str, Exp $len, Exp $pad): FuncExp + { + return self::call('RPAD', $str, $len, $pad); + } + + /** `REPLACE(str, from, to)` — replace all occurrences of a substring. */ + public static function replace(Exp $str, Exp $from, Exp $to): FuncExp + { + return self::call('REPLACE', $str, $from, $to); + } + + /** `REPEAT(str, count)`. */ + public static function repeat(Exp $str, Exp $count): FuncExp + { + return self::call('REPEAT', $str, $count); + } + + /** `REVERSE(str)`. */ + public static function reverse(Exp $str): FuncExp + { + return self::call('REVERSE', $str); + } + + /** `LOCATE(substr, str)` or `LOCATE(substr, str, pos)`. */ + public static function locate(Exp $substr, Exp $str, ?Exp $pos = null): FuncExp + { + return $pos === null ? self::call('LOCATE', $substr, $str) : self::call('LOCATE', $substr, $str, $pos); + } + + /** `INSTR(str, substr)`. */ + public static function instr(Exp $str, Exp $substr): FuncExp + { + return self::call('INSTR', $str, $substr); + } + + /** `SUBSTRING_INDEX(str, delim, count)`. */ + public static function substringIndex(Exp $str, Exp $delim, Exp $count): FuncExp + { + return self::call('SUBSTRING_INDEX', $str, $delim, $count); + } + + /** `FIELD(needle, ...)` — the 1-based index of needle in the argument list. */ + public static function field(Exp $needle, Exp ...$haystack): FuncExp + { + return self::call('FIELD', $needle, ...$haystack); + } + + /** `FIND_IN_SET(needle, set)` — index of needle in a comma-separated string. */ + public static function findInSet(Exp $needle, Exp $set): FuncExp + { + return self::call('FIND_IN_SET', $needle, $set); + } + + /** `FORMAT(num, decimals)` or `FORMAT(num, decimals, locale)`. */ + public static function format(Exp $num, Exp $decimals, ?Exp $locale = null): FuncExp + { + return $locale === null ? self::call('FORMAT', $num, $decimals) : self::call('FORMAT', $num, $decimals, $locale); + } + + /** `HEX(n_or_str)`. */ + public static function hex(Exp $expr): FuncExp + { + return self::call('HEX', $expr); + } + + /** `UNHEX(str)`. */ + public static function unhex(Exp $str): FuncExp + { + return self::call('UNHEX', $str); + } + + // Regular-expression functions (match via the `REGEXP` operator on expressions) + + /** `REGEXP_REPLACE(subject, pattern, replacement, ...)`. */ + public static function regexpReplace(Exp $subject, Exp $pattern, Exp $replacement, Exp ...$rest): FuncExp + { + return self::call('REGEXP_REPLACE', $subject, $pattern, $replacement, ...$rest); + } + + /** `REGEXP_INSTR(subject, pattern, ...)`. */ + public static function regexpInstr(Exp $subject, Exp $pattern, Exp ...$rest): FuncExp + { + return self::call('REGEXP_INSTR', $subject, $pattern, ...$rest); + } + + /** `REGEXP_SUBSTR(subject, pattern, ...)`. */ + public static function regexpSubstr(Exp $subject, Exp $pattern, Exp ...$rest): FuncExp + { + return self::call('REGEXP_SUBSTR', $subject, $pattern, ...$rest); + } + + // Numeric functions + + /** `ABS(n)`. */ + public static function abs(Exp $n): FuncExp + { + return self::call('ABS', $n); + } + + /** `CEIL(n)`. */ + public static function ceil(Exp $n): FuncExp + { + return self::call('CEIL', $n); + } + + /** `FLOOR(n)`. */ + public static function floor(Exp $n): FuncExp + { + return self::call('FLOOR', $n); + } + + /** `ROUND(n)` or `ROUND(n, decimals)`. */ + public static function round(Exp $n, ?Exp $decimals = null): FuncExp + { + return $decimals === null ? self::call('ROUND', $n) : self::call('ROUND', $n, $decimals); + } + + /** `TRUNCATE(n, decimals)`. */ + public static function truncate(Exp $n, Exp $decimals): FuncExp + { + return self::call('TRUNCATE', $n, $decimals); + } + + /** `MOD(a, b)`. */ + public static function mod(Exp $a, Exp $b): FuncExp + { + return self::call('MOD', $a, $b); + } + + /** `POWER(base, exponent)`. */ + public static function power(Exp $base, Exp $exponent): FuncExp + { + return self::call('POWER', $base, $exponent); + } + + /** `SQRT(n)`. */ + public static function sqrt(Exp $n): FuncExp + { + return self::call('SQRT', $n); + } + + /** `EXP(n)`. */ + public static function exp(Exp $n): FuncExp + { + return self::call('EXP', $n); + } + + /** `LN(n)` — natural logarithm. */ + public static function ln(Exp $n): FuncExp + { + return self::call('LN', $n); + } + + /** `LOG(n)` (natural) or `LOG(base, n)`. */ + public static function log(Exp $arg, Exp ...$rest): FuncExp + { + return self::call('LOG', $arg, ...$rest); + } + + /** `LOG2(n)`. */ + public static function log2(Exp $n): FuncExp + { + return self::call('LOG2', $n); + } + + /** `LOG10(n)`. */ + public static function log10(Exp $n): FuncExp + { + return self::call('LOG10', $n); + } + + /** `SIGN(n)`. */ + public static function sign(Exp $n): FuncExp + { + return self::call('SIGN', $n); + } + + /** `RAND()` or `RAND(seed)`. */ + public static function rand(Exp ...$seed): FuncExp + { + return self::call('RAND', ...$seed); + } + + /** `PI()`. */ + public static function pi(): FuncExp + { + return self::call('PI'); + } + + /** `SIN(n)`. */ + public static function sin(Exp $n): FuncExp + { + return self::call('SIN', $n); + } + + /** `COS(n)`. */ + public static function cos(Exp $n): FuncExp + { + return self::call('COS', $n); + } + + /** `TAN(n)`. */ + public static function tan(Exp $n): FuncExp + { + return self::call('TAN', $n); + } + + /** `COT(n)`. */ + public static function cot(Exp $n): FuncExp + { + return self::call('COT', $n); + } + + /** `ASIN(n)`. */ + public static function asin(Exp $n): FuncExp + { + return self::call('ASIN', $n); + } + + /** `ACOS(n)`. */ + public static function acos(Exp $n): FuncExp + { + return self::call('ACOS', $n); + } + + /** `ATAN(n)` or `ATAN(y, x)`. */ + public static function atan(Exp $arg, Exp ...$rest): FuncExp + { + return self::call('ATAN', $arg, ...$rest); + } + + /** `ATAN2(y, x)`. */ + public static function atan2(Exp $y, Exp $x): FuncExp + { + return self::call('ATAN2', $y, $x); + } + + /** `RADIANS(degrees)`. */ + public static function radians(Exp $degrees): FuncExp + { + return self::call('RADIANS', $degrees); + } + + /** `DEGREES(radians)`. */ + public static function degrees(Exp $radians): FuncExp + { + return self::call('DEGREES', $radians); + } + + // Date / time functions + + /** `NOW()` — the current date and time. */ + public static function now(): FuncExp + { + return self::call('NOW'); + } + + /** `CURDATE()` — the current date. */ + public static function curdate(): FuncExp + { + return self::call('CURDATE'); + } + + /** `CURTIME()` — the current time. */ + public static function curtime(): FuncExp + { + return self::call('CURTIME'); + } + + /** `CURRENT_TIMESTAMP()`. */ + public static function currentTimestamp(): FuncExp + { + return self::call('CURRENT_TIMESTAMP'); + } + + /** `UTC_TIMESTAMP()`. */ + public static function utcTimestamp(): FuncExp + { + return self::call('UTC_TIMESTAMP'); + } + + /** `DATE(expr)` — the date part of a datetime. */ + public static function date(Exp $expr): FuncExp + { + return self::call('DATE', $expr); + } + + /** `TIME(expr)` — the time part of a datetime. */ + public static function time(Exp $expr): FuncExp + { + return self::call('TIME', $expr); + } + + /** `YEAR(date)`. */ + public static function year(Exp $date): FuncExp + { + return self::call('YEAR', $date); + } + + /** `MONTH(date)`. */ + public static function month(Exp $date): FuncExp + { + return self::call('MONTH', $date); + } + + /** `DAY(date)`. */ + public static function day(Exp $date): FuncExp + { + return self::call('DAY', $date); + } + + /** `HOUR(time)`. */ + public static function hour(Exp $time): FuncExp + { + return self::call('HOUR', $time); + } + + /** `MINUTE(time)`. */ + public static function minute(Exp $time): FuncExp + { + return self::call('MINUTE', $time); + } + + /** `SECOND(time)`. */ + public static function second(Exp $time): FuncExp + { + return self::call('SECOND', $time); + } + + /** `QUARTER(date)`. */ + public static function quarter(Exp $date): FuncExp + { + return self::call('QUARTER', $date); + } + + /** `WEEK(date)` or `WEEK(date, mode)`. */ + public static function week(Exp $date, Exp ...$mode): FuncExp + { + return self::call('WEEK', $date, ...$mode); + } + + /** `DAYOFWEEK(date)`. */ + public static function dayOfWeek(Exp $date): FuncExp + { + return self::call('DAYOFWEEK', $date); + } + + /** `DAYOFYEAR(date)`. */ + public static function dayOfYear(Exp $date): FuncExp + { + return self::call('DAYOFYEAR', $date); + } + + /** `DAYNAME(date)`. */ + public static function dayName(Exp $date): FuncExp + { + return self::call('DAYNAME', $date); + } + + /** `MONTHNAME(date)`. */ + public static function monthName(Exp $date): FuncExp + { + return self::call('MONTHNAME', $date); + } + + /** `LAST_DAY(date)` — the last day of the month. */ + public static function lastDay(Exp $date): FuncExp + { + return self::call('LAST_DAY', $date); + } + + /** `DATEDIFF(a, b)` — the number of days between two dates. */ + public static function dateDiff(Exp $a, Exp $b): FuncExp + { + return self::call('DATEDIFF', $a, $b); + } + + /** `TIMESTAMPDIFF(unit, a, b)` — the difference in the given unit. */ + public static function timestampDiff(string $unit, Exp $a, Exp $b): FuncExp + { + return self::call('TIMESTAMPDIFF', new Keyword($unit), $a, $b); + } + + /** `TIMESTAMPADD(unit, interval, datetime)`. */ + public static function timestampAdd(string $unit, Exp $interval, Exp $datetime): FuncExp + { + return self::call('TIMESTAMPADD', new Keyword($unit), $interval, $datetime); + } + + /** `DATE_FORMAT(date, format)`. */ + public static function dateFormat(Exp $date, Exp $format): FuncExp + { + return self::call('DATE_FORMAT', $date, $format); + } + + /** `STR_TO_DATE(str, format)`. */ + public static function strToDate(Exp $str, Exp $format): FuncExp + { + return self::call('STR_TO_DATE', $str, $format); + } + + /** `UNIX_TIMESTAMP()` or `UNIX_TIMESTAMP(date)`. */ + public static function unixTimestamp(Exp ...$date): FuncExp + { + return self::call('UNIX_TIMESTAMP', ...$date); + } + + /** `FROM_UNIXTIME(ts)` or `FROM_UNIXTIME(ts, format)`. */ + public static function fromUnixtime(Exp $ts, Exp ...$format): FuncExp + { + return self::call('FROM_UNIXTIME', $ts, ...$format); + } + + /** `CONVERT_TZ(dt, fromTz, toTz)`. */ + public static function convertTz(Exp $dt, Exp $fromTz, Exp $toTz): FuncExp + { + return self::call('CONVERT_TZ', $dt, $fromTz, $toTz); + } + + // JSON functions + + /** `JSON_OBJECT(key, value, ...)`. */ + public static function jsonObject(Exp ...$keysValues): FuncExp + { + return self::call('JSON_OBJECT', ...$keysValues); + } + + /** `JSON_ARRAY(...)`. */ + public static function jsonArray(Exp ...$values): FuncExp + { + return self::call('JSON_ARRAY', ...$values); + } + + /** `JSON_QUOTE(str)`. */ + public static function jsonQuote(Exp $str): FuncExp + { + return self::call('JSON_QUOTE', $str); + } + + /** `JSON_UNQUOTE(jsonVal)`. */ + public static function jsonUnquote(Exp $jsonVal): FuncExp + { + return self::call('JSON_UNQUOTE', $jsonVal); + } + + /** `JSON_EXTRACT(doc, path, ...)`. */ + public static function jsonExtract(Exp $doc, Exp $path, Exp ...$rest): FuncExp + { + return self::call('JSON_EXTRACT', $doc, $path, ...$rest); + } + + /** `JSON_CONTAINS(target, candidate)` or `JSON_CONTAINS(target, candidate, path)`. */ + public static function jsonContains(Exp $target, Exp $candidate, ?Exp $path = null): FuncExp + { + return $path === null + ? self::call('JSON_CONTAINS', $target, $candidate) + : self::call('JSON_CONTAINS', $target, $candidate, $path); + } + + /** `JSON_CONTAINS_PATH(doc, oneOrAll, path, ...)`. */ + public static function jsonContainsPath(Exp $doc, Exp $oneOrAll, Exp $path, Exp ...$rest): FuncExp + { + return self::call('JSON_CONTAINS_PATH', $doc, $oneOrAll, $path, ...$rest); + } + + /** `JSON_KEYS(doc)` or `JSON_KEYS(doc, path)`. */ + public static function jsonKeys(Exp $doc, Exp ...$path): FuncExp + { + return self::call('JSON_KEYS', $doc, ...$path); + } + + /** `JSON_SEARCH(doc, oneOrAll, searchStr, ...)`. */ + public static function jsonSearch(Exp $doc, Exp $oneOrAll, Exp $searchStr, Exp ...$rest): FuncExp + { + return self::call('JSON_SEARCH', $doc, $oneOrAll, $searchStr, ...$rest); + } + + /** `JSON_VALUE(doc, path)`. */ + public static function jsonValue(Exp $doc, Exp $path): FuncExp + { + return self::call('JSON_VALUE', $doc, $path); + } + + /** `JSON_SET(doc, path, value, ...)`. */ + public static function jsonSet(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_SET', $doc, $path, $value, ...$rest); + } + + /** `JSON_INSERT(doc, path, value, ...)`. */ + public static function jsonInsert(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_INSERT', $doc, $path, $value, ...$rest); + } + + /** `JSON_REPLACE(doc, path, value, ...)`. */ + public static function jsonReplace(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_REPLACE', $doc, $path, $value, ...$rest); + } + + /** `JSON_REMOVE(doc, path, ...)`. */ + public static function jsonRemove(Exp $doc, Exp $path, Exp ...$rest): FuncExp + { + return self::call('JSON_REMOVE', $doc, $path, ...$rest); + } + + /** `JSON_ARRAY_APPEND(doc, path, value, ...)`. */ + public static function jsonArrayAppend(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_ARRAY_APPEND', $doc, $path, $value, ...$rest); + } + + /** `JSON_ARRAY_INSERT(doc, path, value, ...)`. */ + public static function jsonArrayInsert(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_ARRAY_INSERT', $doc, $path, $value, ...$rest); + } + + /** `JSON_MERGE_PATCH(doc, doc, ...)` — RFC 7386 merge. */ + public static function jsonMergePatch(Exp $doc, Exp $other, Exp ...$rest): FuncExp + { + return self::call('JSON_MERGE_PATCH', $doc, $other, ...$rest); + } + + /** `JSON_MERGE_PRESERVE(doc, doc, ...)` — merge keeping duplicate keys. */ + public static function jsonMergePreserve(Exp $doc, Exp $other, Exp ...$rest): FuncExp + { + return self::call('JSON_MERGE_PRESERVE', $doc, $other, ...$rest); + } + + /** `JSON_TYPE(jsonVal)`. */ + public static function jsonType(Exp $jsonVal): FuncExp + { + return self::call('JSON_TYPE', $jsonVal); + } + + /** `JSON_DEPTH(doc)`. */ + public static function jsonDepth(Exp $doc): FuncExp + { + return self::call('JSON_DEPTH', $doc); + } + + /** `JSON_LENGTH(doc)` or `JSON_LENGTH(doc, path)`. */ + public static function jsonLength(Exp $doc, Exp ...$path): FuncExp + { + return self::call('JSON_LENGTH', $doc, ...$path); + } + + /** `JSON_VALID(val)`. */ + public static function jsonValid(Exp $val): FuncExp + { + return self::call('JSON_VALID', $val); + } + + // Miscellaneous functions + + /** `UUID()` — a version-1 UUID string. */ + public static function uuid(): FuncExp + { + return self::call('UUID'); + } + + /** `UUID_TO_BIN(uuid)` or `UUID_TO_BIN(uuid, swapFlag)`. */ + public static function uuidToBin(Exp $uuid, Exp ...$swapFlag): FuncExp + { + return self::call('UUID_TO_BIN', $uuid, ...$swapFlag); + } + + /** `BIN_TO_UUID(bin)` or `BIN_TO_UUID(bin, swapFlag)`. */ + public static function binToUuid(Exp $bin, Exp ...$swapFlag): FuncExp + { + return self::call('BIN_TO_UUID', $bin, ...$swapFlag); + } + + /** `IS_UUID(str)`. */ + public static function isUuid(Exp $str): FuncExp + { + return self::call('IS_UUID', $str); + } + // Nonaggregate window functions — each requires an `OVER` clause; call // {@see WindowFuncBuilder::over()} to add it. @@ -274,4 +1003,14 @@ private static function leadLagArgs(Exp $expr, ?Exp $offset, ?Exp $default): arr return $args; } + + private static function call(string $name, Exp ...$args): FuncExp + { + return new FuncExp($name, array_values($args)); + } + + private static function agg(string $name, Exp ...$args): AggBuilder + { + return new AggBuilder($name, array_values($args)); + } } diff --git a/tests/MySQL/Q/FunctionsTest.php b/tests/MySQL/Q/FunctionsTest.php new file mode 100644 index 0000000..14bfb29 --- /dev/null +++ b/tests/MySQL/Q/FunctionsTest.php @@ -0,0 +1,121 @@ +toRenderSql($sql); + })->with([ + // String + 'concat' => [fn () => Q\Func::concat(Q::n('a'), Q::string('-'), Q::n('b')), "CONCAT(a, '-', b)"], + 'concatWs' => [fn () => Q\Func::concatWs(Q::string(','), Q::n('a'), Q::n('b')), "CONCAT_WS(',', a, b)"], + 'lower' => [fn () => Q\Func::lower(Q::n('a')), 'LOWER(a)'], + 'upper' => [fn () => Q\Func::upper(Q::n('a')), 'UPPER(a)'], + 'length' => [fn () => Q\Func::length(Q::n('a')), 'LENGTH(a)'], + 'charLength' => [fn () => Q\Func::charLength(Q::n('a')), 'CHAR_LENGTH(a)'], + 'substring 2-arg' => [fn () => Q\Func::substring(Q::n('a'), Q::int(2)), 'SUBSTRING(a, 2)'], + 'substring 3-arg' => [fn () => Q\Func::substring(Q::n('a'), Q::int(2), Q::int(3)), 'SUBSTRING(a, 2, 3)'], + 'left' => [fn () => Q\Func::left(Q::n('a'), Q::int(3)), 'LEFT(a, 3)'], + 'right' => [fn () => Q\Func::right(Q::n('a'), Q::int(3)), 'RIGHT(a, 3)'], + 'ltrim' => [fn () => Q\Func::ltrim(Q::n('a')), 'LTRIM(a)'], + 'rtrim' => [fn () => Q\Func::rtrim(Q::n('a')), 'RTRIM(a)'], + 'lpad' => [fn () => Q\Func::lpad(Q::n('a'), Q::int(5), Q::string('0')), "LPAD(a, 5, '0')"], + 'rpad' => [fn () => Q\Func::rpad(Q::n('a'), Q::int(5), Q::string('0')), "RPAD(a, 5, '0')"], + 'replace' => [fn () => Q\Func::replace(Q::n('a'), Q::string('x'), Q::string('y')), "REPLACE(a, 'x', 'y')"], + 'repeat' => [fn () => Q\Func::repeat(Q::string('ab'), Q::int(3)), "REPEAT('ab', 3)"], + 'reverse' => [fn () => Q\Func::reverse(Q::n('a')), 'REVERSE(a)'], + 'locate' => [fn () => Q\Func::locate(Q::string('x'), Q::n('a')), "LOCATE('x', a)"], + 'instr' => [fn () => Q\Func::instr(Q::n('a'), Q::string('x')), "INSTR(a, 'x')"], + 'substringIndex' => [fn () => Q\Func::substringIndex(Q::n('a'), Q::string('.'), Q::int(2)), "SUBSTRING_INDEX(a, '.', 2)"], + 'field' => [fn () => Q\Func::field(Q::n('a'), Q::string('x'), Q::string('y')), "FIELD(a, 'x', 'y')"], + 'findInSet' => [fn () => Q\Func::findInSet(Q::string('b'), Q::n('tags')), "FIND_IN_SET('b', tags)"], + 'format' => [fn () => Q\Func::format(Q::n('n'), Q::int(2)), 'FORMAT(n, 2)'], + 'hex' => [fn () => Q\Func::hex(Q::n('a')), 'HEX(a)'], + 'unhex' => [fn () => Q\Func::unhex(Q::n('a')), 'UNHEX(a)'], + + // Regexp + 'regexpReplace' => [fn () => Q\Func::regexpReplace(Q::n('a'), Q::string('x'), Q::string('y')), "REGEXP_REPLACE(a, 'x', 'y')"], + 'regexpInstr' => [fn () => Q\Func::regexpInstr(Q::n('a'), Q::string('x')), "REGEXP_INSTR(a, 'x')"], + 'regexpSubstr' => [fn () => Q\Func::regexpSubstr(Q::n('a'), Q::string('x')), "REGEXP_SUBSTR(a, 'x')"], + + // Numeric + 'abs' => [fn () => Q\Func::abs(Q::n('n')), 'ABS(n)'], + 'ceil' => [fn () => Q\Func::ceil(Q::n('n')), 'CEIL(n)'], + 'floor' => [fn () => Q\Func::floor(Q::n('n')), 'FLOOR(n)'], + 'round 1-arg' => [fn () => Q\Func::round(Q::n('n')), 'ROUND(n)'], + 'round 2-arg' => [fn () => Q\Func::round(Q::n('n'), Q::int(2)), 'ROUND(n, 2)'], + 'truncate' => [fn () => Q\Func::truncate(Q::n('n'), Q::int(2)), 'TRUNCATE(n, 2)'], + 'mod' => [fn () => Q\Func::mod(Q::n('a'), Q::int(3)), 'MOD(a, 3)'], + 'power' => [fn () => Q\Func::power(Q::n('a'), Q::int(2)), 'POWER(a, 2)'], + 'sqrt' => [fn () => Q\Func::sqrt(Q::n('n')), 'SQRT(n)'], + 'exp' => [fn () => Q\Func::exp(Q::n('n')), 'EXP(n)'], + 'ln' => [fn () => Q\Func::ln(Q::n('n')), 'LN(n)'], + 'log 1-arg' => [fn () => Q\Func::log(Q::n('n')), 'LOG(n)'], + 'log 2-arg' => [fn () => Q\Func::log(Q::int(2), Q::n('n')), 'LOG(2, n)'], + 'log2' => [fn () => Q\Func::log2(Q::n('n')), 'LOG2(n)'], + 'log10' => [fn () => Q\Func::log10(Q::n('n')), 'LOG10(n)'], + 'sign' => [fn () => Q\Func::sign(Q::n('n')), 'SIGN(n)'], + 'rand' => [fn () => Q\Func::rand(), 'RAND()'], + 'rand seed' => [fn () => Q\Func::rand(Q::int(1)), 'RAND(1)'], + 'pi' => [fn () => Q\Func::pi(), 'PI()'], + 'sin' => [fn () => Q\Func::sin(Q::n('n')), 'SIN(n)'], + 'cos' => [fn () => Q\Func::cos(Q::n('n')), 'COS(n)'], + 'tan' => [fn () => Q\Func::tan(Q::n('n')), 'TAN(n)'], + 'atan2' => [fn () => Q\Func::atan2(Q::n('y'), Q::n('x')), 'ATAN2(y, x)'], + 'radians' => [fn () => Q\Func::radians(Q::n('d')), 'RADIANS(d)'], + 'degrees' => [fn () => Q\Func::degrees(Q::n('r')), 'DEGREES(r)'], + + // Date / time + 'now' => [fn () => Q\Func::now(), 'NOW()'], + 'curdate' => [fn () => Q\Func::curdate(), 'CURDATE()'], + 'currentTimestamp' => [fn () => Q\Func::currentTimestamp(), 'CURRENT_TIMESTAMP()'], + 'year' => [fn () => Q\Func::year(Q::n('d')), 'YEAR(d)'], + 'month' => [fn () => Q\Func::month(Q::n('d')), 'MONTH(d)'], + 'dayOfWeek' => [fn () => Q\Func::dayOfWeek(Q::n('d')), 'DAYOFWEEK(d)'], + 'week mode' => [fn () => Q\Func::week(Q::n('d'), Q::int(1)), 'WEEK(d, 1)'], + 'dateDiff' => [fn () => Q\Func::dateDiff(Q::n('a'), Q::n('b')), 'DATEDIFF(a, b)'], + 'timestampDiff' => [fn () => Q\Func::timestampDiff('DAY', Q::n('a'), Q::n('b')), 'TIMESTAMPDIFF(DAY, a, b)'], + 'timestampAdd' => [fn () => Q\Func::timestampAdd('HOUR', Q::int(2), Q::n('d')), 'TIMESTAMPADD(HOUR, 2, d)'], + 'dateFormat' => [fn () => Q\Func::dateFormat(Q::n('d'), Q::string('%Y')), "DATE_FORMAT(d, '%Y')"], + 'unixTimestamp' => [fn () => Q\Func::unixTimestamp(), 'UNIX_TIMESTAMP()'], + 'fromUnixtime' => [fn () => Q\Func::fromUnixtime(Q::n('ts')), 'FROM_UNIXTIME(ts)'], + 'convertTz' => [fn () => Q\Func::convertTz(Q::n('d'), Q::string('+00:00'), Q::string('+02:00')), "CONVERT_TZ(d, '+00:00', '+02:00')"], + + // JSON + 'jsonObject' => [fn () => Q\Func::jsonObject(Q::string('k'), Q::n('v')), "JSON_OBJECT('k', v)"], + 'jsonArray' => [fn () => Q\Func::jsonArray(Q::int(1), Q::int(2)), 'JSON_ARRAY(1, 2)'], + 'jsonExtract' => [fn () => Q\Func::jsonExtract(Q::n('doc'), Q::string('$.a')), "JSON_EXTRACT(doc, '$.a')"], + 'jsonContains 2-arg' => [fn () => Q\Func::jsonContains(Q::n('doc'), Q::string('1')), "JSON_CONTAINS(doc, '1')"], + 'jsonContains 3-arg' => [fn () => Q\Func::jsonContains(Q::n('doc'), Q::string('1'), Q::string('$.a')), "JSON_CONTAINS(doc, '1', '$.a')"], + 'jsonContainsPath' => [fn () => Q\Func::jsonContainsPath(Q::n('doc'), Q::string('one'), Q::string('$.a')), "JSON_CONTAINS_PATH(doc, 'one', '$.a')"], + 'jsonKeys' => [fn () => Q\Func::jsonKeys(Q::n('doc')), 'JSON_KEYS(doc)'], + 'jsonValue' => [fn () => Q\Func::jsonValue(Q::n('doc'), Q::string('$.a')), "JSON_VALUE(doc, '$.a')"], + 'jsonSet' => [fn () => Q\Func::jsonSet(Q::n('doc'), Q::string('$.a'), Q::int(1)), "JSON_SET(doc, '$.a', 1)"], + 'jsonRemove' => [fn () => Q\Func::jsonRemove(Q::n('doc'), Q::string('$.a')), "JSON_REMOVE(doc, '$.a')"], + 'jsonMergePatch' => [fn () => Q\Func::jsonMergePatch(Q::n('a'), Q::n('b')), 'JSON_MERGE_PATCH(a, b)'], + 'jsonType' => [fn () => Q\Func::jsonType(Q::n('v')), 'JSON_TYPE(v)'], + 'jsonLength' => [fn () => Q\Func::jsonLength(Q::n('doc')), 'JSON_LENGTH(doc)'], + 'jsonValid' => [fn () => Q\Func::jsonValid(Q::n('v')), 'JSON_VALID(v)'], + + // Misc + 'uuid' => [fn () => Q\Func::uuid(), 'UUID()'], + 'uuidToBin' => [fn () => Q\Func::uuidToBin(Q::n('u')), 'UUID_TO_BIN(u)'], + 'isUuid' => [fn () => Q\Func::isUuid(Q::n('u')), 'IS_UUID(u)'], + + // Aggregates + 'jsonArrayAgg' => [fn () => Q\Func::jsonArrayAgg(Q::n('v')), 'JSON_ARRAYAGG(v)'], + 'jsonObjectAgg' => [fn () => Q\Func::jsonObjectAgg(Q::n('k'), Q::n('v')), 'JSON_OBJECTAGG(k, v)'], + 'bitOr' => [fn () => Q\Func::bitOr(Q::n('flags')), 'BIT_OR(flags)'], + 'stddevPop' => [fn () => Q\Func::stddevPop(Q::n('v')), 'STDDEV_POP(v)'], + 'varSamp' => [fn () => Q\Func::varSamp(Q::n('v')), 'VAR_SAMP(v)'], + ]); + + it('uses an aggregate with DISTINCT and as a window function', function () { + expect(Q\Func::count(Q::n('id'))->distinct())->toRenderSql('COUNT(DISTINCT id)'); + expect(Q\Func::sum(Q::n('v'))->over()->partitionBy(Q::n('g'))) + ->toRenderSql('SUM(v) OVER (PARTITION BY g)'); + }); +}); From 2487cfee3ca2ea1dc5e5ee439728dcb2ad4e23ae Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 00:15:28 +0200 Subject: [PATCH 15/40] wip: mysql-only Q\Func functions (regexpLike/grouping/anyValue/json schema+storage/pretty/randomBytes); functions ledger --- docs/mysql-mariadb-design.md | 10 +++++ src/MySQL/Q/Func.php | 56 ++++++++++++++++++++++++ tests/MySQL/Q/FunctionsMysqlOnlyTest.php | 33 ++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 tests/MySQL/Q/FunctionsMysqlOnlyTest.php diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index f4e6aa5..64c4fd1 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -506,6 +506,16 @@ MariaDB 13+); `DELETE ... RETURNING` is MariaDB-only (stage 7). | `ILIKE`, `SIMILAR TO`, `::`, `\|\|`, `^`(pow), `@>`/`<@`, `#>`/`#>>`, `ARRAY` | N/A (PG-only) | dropped or mapped to functions — see §6 | ### Functions + +The curated `Q\Func` default set (§7) is **Supported**: the aggregates +(`count`/`sum`/`avg`/`min`/`max`/`groupConcat`/`jsonArrayAgg`/`jsonObjectAgg`/ +`bitAnd`/`bitOr`/`bitXor`/`stddevPop`/`stddevSamp`/`varPop`/`varSamp`, each usable +with `OVER`), the string/regexp/numeric/date-time/JSON/misc scalar families, the +window functions (stage 4), and the special-shape builders (`GROUP_CONCAT`, +`EXTRACT`, `INTERVAL`, `TRIM`, `CAST`/`CONVERT`). On `Q`: `coalesce`/`nullif`/ +`greatest`/`least`/`cast`/`convert`/`interval`; on `Q\Func`: `if`/`ifnull`. +Anything omitted stays reachable via `Q::func(name, ...)`. + | Category / function | Status | Reason | |---|---|---| | Spatial / GIS (`ST_*`, `MBR*`) | Excluded | large specialized surface; via `Q::func` | diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index 4c17357..f6bb02f 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -985,6 +985,62 @@ public static function nthValue(Exp $expr, Exp $n): WindowFuncBuilder return new WindowFuncBuilder(new FuncExp('NTH_VALUE', [$expr, $n])); } + // MySQL-only functions + + /** `REGEXP_LIKE(subject, pattern)` or `REGEXP_LIKE(subject, pattern, matchType)`. */ + public static function regexpLike(Exp $subject, Exp $pattern, Exp ...$matchType): FuncExp + { + return self::call('REGEXP_LIKE', $subject, $pattern, ...$matchType); + } + + /** `GROUPING(expr, ...)` — distinguishes super-aggregate `WITH ROLLUP` NULLs. */ + public static function grouping(Exp $expr, Exp ...$rest): FuncExp + { + return self::call('GROUPING', $expr, ...$rest); + } + + /** `ANY_VALUE(expr)` — suppress `ONLY_FULL_GROUP_BY` rejection for a column. */ + public static function anyValue(Exp $expr): FuncExp + { + return self::call('ANY_VALUE', $expr); + } + + /** `JSON_SCHEMA_VALID(schema, doc)`. */ + public static function jsonSchemaValid(Exp $schema, Exp $doc): FuncExp + { + return self::call('JSON_SCHEMA_VALID', $schema, $doc); + } + + /** `JSON_SCHEMA_VALIDATION_REPORT(schema, doc)`. */ + public static function jsonSchemaValidationReport(Exp $schema, Exp $doc): FuncExp + { + return self::call('JSON_SCHEMA_VALIDATION_REPORT', $schema, $doc); + } + + /** `JSON_STORAGE_SIZE(doc)`. */ + public static function jsonStorageSize(Exp $doc): FuncExp + { + return self::call('JSON_STORAGE_SIZE', $doc); + } + + /** `JSON_STORAGE_FREE(doc)`. */ + public static function jsonStorageFree(Exp $doc): FuncExp + { + return self::call('JSON_STORAGE_FREE', $doc); + } + + /** `JSON_PRETTY(doc)` — pretty-print a JSON document. */ + public static function jsonPretty(Exp $doc): FuncExp + { + return self::call('JSON_PRETTY', $doc); + } + + /** `RANDOM_BYTES(len)` — a string of cryptographically strong random bytes. */ + public static function randomBytes(Exp $len): FuncExp + { + return self::call('RANDOM_BYTES', $len); + } + /** * Build the argument list for LAG / LEAD: the default is only meaningful when an * offset is given, so it is dropped unless the offset is present. diff --git a/tests/MySQL/Q/FunctionsMysqlOnlyTest.php b/tests/MySQL/Q/FunctionsMysqlOnlyTest.php new file mode 100644 index 0000000..1e3a0cf --- /dev/null +++ b/tests/MySQL/Q/FunctionsMysqlOnlyTest.php @@ -0,0 +1,33 @@ +toRenderSql($sql); + })->with([ + 'regexpLike' => [fn () => Q\Func::regexpLike(Q::n('a'), Q::string('^x')), "REGEXP_LIKE(a, '^x')"], + 'regexpLike matchType' => [fn () => Q\Func::regexpLike(Q::n('a'), Q::string('^x'), Q::string('i')), "REGEXP_LIKE(a, '^x', 'i')"], + 'grouping' => [fn () => Q\Func::grouping(Q::n('a'), Q::n('b')), 'GROUPING(a, b)'], + 'anyValue' => [fn () => Q\Func::anyValue(Q::n('name')), 'ANY_VALUE(name)'], + 'jsonSchemaValid' => [fn () => Q\Func::jsonSchemaValid(Q::n('schema'), Q::n('doc')), 'JSON_SCHEMA_VALID(`schema`, doc)'], + 'jsonSchemaValidationReport' => [fn () => Q\Func::jsonSchemaValidationReport(Q::n('s'), Q::n('doc')), 'JSON_SCHEMA_VALIDATION_REPORT(s, doc)'], + 'jsonStorageSize' => [fn () => Q\Func::jsonStorageSize(Q::n('doc')), 'JSON_STORAGE_SIZE(doc)'], + 'jsonStorageFree' => [fn () => Q\Func::jsonStorageFree(Q::n('doc')), 'JSON_STORAGE_FREE(doc)'], + 'jsonPretty' => [fn () => Q\Func::jsonPretty(Q::n('doc')), 'JSON_PRETTY(doc)'], + 'randomBytes' => [fn () => Q\Func::randomBytes(Q::int(16)), 'RANDOM_BYTES(16)'], + ]); + + it('uses GROUPING with WITH ROLLUP', function () { + expect( + Q::select(Q::n('country'), Q\Func::sum(Q::n('amount')), Q\Func::grouping(Q::n('country'))) + ->from(Q::n('sales')) + ->groupBy(Q::n('country'))->withRollup(), + )->toRenderSql( + 'SELECT country, SUM(amount), GROUPING(country) FROM sales GROUP BY country WITH ROLLUP', + ); + }); +}); From a7a120243c2d537596688cf2c582ab83082b58e3 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 09:46:56 +0200 Subject: [PATCH 16/40] wip: extract AbstractSelectBuilder (shared SELECT logic + clause helpers); MySQL forShare via full lock string --- src/MySQL/Builder/AbstractSelectBuilder.php | 386 ++++++++++++++++++++ src/MySQL/Builder/ForSelectBuilder.php | 2 +- src/MySQL/Builder/LockingClause.php | 7 +- src/MySQL/Builder/SelectBuilder.php | 301 ++------------- 4 files changed, 419 insertions(+), 277 deletions(-) create mode 100644 src/MySQL/Builder/AbstractSelectBuilder.php diff --git a/src/MySQL/Builder/AbstractSelectBuilder.php b/src/MySQL/Builder/AbstractSelectBuilder.php new file mode 100644 index 0000000..91aabfd --- /dev/null +++ b/src/MySQL/Builder/AbstractSelectBuilder.php @@ -0,0 +1,386 @@ + $withQueries the leading WITH clause, if any + * @param list $combinations previous selects combined via UNION / INTERSECT / EXCEPT + */ + public function __construct( + protected readonly SelectQueryParts $parts = new SelectQueryParts(), + protected readonly array $withQueries = [], + protected readonly array $combinations = [], + ) { + } + + /** + * Whether this builder carries no content yet: no WITH queries, no + * combinations and empty select parts. Useful for conditional query building. + */ + public function isEmpty(): bool + { + return $this->withQueries === [] && $this->combinations === [] && $this->parts->isEmpty(); + } + + // Clause helpers — the field logic lives here once; each dialect's concrete + // builder exposes the public transitions that call these with its own target + // class and declare the matching concrete return type. + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @param list $exps + * @return T + */ + protected function addToSelectList(string $class, array $exps): AbstractSelectBuilder + { + $selectList = $this->parts->selectList; + foreach ($exps as $exp) { + $selectList[] = new OutputExpr($exp); + } + + return $this->derive($class, selectList: $selectList); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function addFromItem(string $class, FromExp $from, bool $lateral = false): AbstractSelectBuilder + { + return $this->derive($class, from: [...$this->parts->from, new FromItem($from, lateral: $lateral)]); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function addJoinItem(string $class, JoinType $joinType, FromExp $from, bool $lateral): AbstractSelectBuilder + { + return $this->derive($class, from: [...$this->parts->from, new FromItem(new Join($joinType, $lateral, $from))]); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function addWhereCondition(string $class, Exp $cond): AbstractSelectBuilder + { + return $this->derive($class, whereConjunction: [...$this->parts->whereConjunction, $cond]); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @param list $exps + * @return T + */ + protected function addGroupByExps(string $class, array $exps): AbstractSelectBuilder + { + return $this->derive($class, groupBys: [...$this->parts->groupBys, ...$exps]); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function addHavingCondition(string $class, Exp $cond): AbstractSelectBuilder + { + return $this->derive($class, havingConjunction: [...$this->parts->havingConjunction, $cond]); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function addOrderByExp(string $class, Exp $exp): AbstractSelectBuilder + { + return $this->derive($class, orderBys: [...$this->parts->orderBys, new OrderByClause($exp)]); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function withLimit(string $class, Exp $exp): AbstractSelectBuilder + { + return $this->derive($class, limit: $exp); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function withOffset(string $class, Exp $exp): AbstractSelectBuilder + { + return $this->derive($class, offset: $exp); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function startCombination(string $class, CombinationType $type): AbstractSelectBuilder + { + // Archive the current parts as a combination and start a fresh select. + return $this->derive( + $class, + parts: new SelectQueryParts(), + combinations: [...$this->combinations, new Combination($this->parts, $type)], + ); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function withLocking(string $class, LockingClause $lockingClause): AbstractSelectBuilder + { + return $this->derive($class, lockingClause: $lockingClause); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function addNamedWindow(string $class, string $name): AbstractSelectBuilder + { + return $this->derive($class, windows: [...$this->parts->windows, new NamedWindow($name)]); + } + + /** + * @template T of AbstractSelectBuilder + * @param class-string $class + * @param list $withQueries + * @return T + */ + protected function withAppendedWith(string $class, array $withQueries): AbstractSelectBuilder + { + return $this->derive($class, withQueries: [...$this->withQueries, ...$withQueries]); + } + + /** + * Assemble a new builder of the given type from the current state with the + * given fields replaced; a null argument keeps the current value. This is the + * single place where a derived {@see SelectQueryParts} and the type-state + * transition are produced. + * + * @template T of AbstractSelectBuilder + * @param class-string $class + * @param list|null $selectList + * @param list|null $from + * @param list|null $whereConjunction + * @param list|null $groupBys + * @param list|null $havingConjunction + * @param list|null $windows + * @param list|null $orderBys + * @param list|null $withQueries + * @param list|null $combinations + * @return T + */ + protected function derive( + string $class, + ?SelectQueryParts $parts = null, + ?bool $distinct = null, + ?array $selectList = null, + ?array $from = null, + ?array $whereConjunction = null, + ?array $groupBys = null, + ?bool $groupByWithRollup = null, + ?array $havingConjunction = null, + ?array $windows = null, + ?array $orderBys = null, + ?Exp $limit = null, + ?Exp $offset = null, + ?LockingClause $lockingClause = null, + ?array $withQueries = null, + ?array $combinations = null, + ): AbstractSelectBuilder { + $parts ??= new SelectQueryParts( + distinct: $distinct ?? $this->parts->distinct, + selectList: $selectList ?? $this->parts->selectList, + from: $from ?? $this->parts->from, + whereConjunction: $whereConjunction ?? $this->parts->whereConjunction, + groupBys: $groupBys ?? $this->parts->groupBys, + groupByWithRollup: $groupByWithRollup ?? $this->parts->groupByWithRollup, + havingConjunction: $havingConjunction ?? $this->parts->havingConjunction, + windows: $windows ?? $this->parts->windows, + orderBys: $orderBys ?? $this->parts->orderBys, + limit: $limit ?? $this->parts->limit, + offset: $offset ?? $this->parts->offset, + lockingClause: $lockingClause ?? $this->parts->lockingClause, + ); + + return new $class($parts, $withQueries ?? $this->withQueries, $combinations ?? $this->combinations); + } + + /** + * Write the select without the surrounding parentheses (the top-level query). + * + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + if ($this->withQueries !== []) { + $this->writeWithQueries($sb, $this->withQueries); + } + + // Previous selects combined via UNION / INTERSECT / EXCEPT come first. + foreach ($this->combinations as $c) { + $this->writeSelectParts($sb, $c->parts); + $s = ' ' . $c->type->value; + if ($c->all) { + $s .= ' ALL'; + } + $sb->writeString($s . ' '); + $c->query?->writeSql($sb); + } + + if (!$this->parts->isEmpty()) { + $this->writeSelectParts($sb, $this->parts); + } + + if ($this->parts->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->parts->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->parts->limit !== null) { + $sb->writeString(' LIMIT '); + $this->parts->limit->writeSql($sb); + } + + if ($this->parts->offset !== null) { + $sb->writeString(' OFFSET '); + $this->parts->offset->writeSql($sb); + } + + if ($this->parts->lockingClause !== null) { + $sb->writeString(' '); + $this->parts->lockingClause->writeSql($sb); + } + } + + private function writeSelectParts(SqlBuilder $sb, SelectQueryParts $parts): void + { + // Accumulate literal SQL into $s and only flush before a nested writer + // needs to write (and once at the very end). + $sb->writeString('SELECT '); + $s = $parts->distinct ? 'DISTINCT ' : ''; + $needComma = false; + + foreach ($parts->selectList as $out) { + if ($needComma) { + $s .= ','; + } + $sb->writeString($s); + $s = ''; + + $out->exp->writeSql($sb); + + if ($out->alias !== '') { + $s = ' AS ' . $out->alias; + } + $needComma = true; + } + + if ($parts->from !== []) { + $s .= ' FROM '; + foreach ($parts->from as $i => $fromItem) { + if ($i > 0) { + // A join attaches to the previous item; a plain item is comma-separated. + $s .= $fromItem->from instanceof Join ? ' ' : ','; + } + $sb->writeString($s); + $s = ''; + + $fromItem->writeSql($sb); + } + } + + if ($parts->whereConjunction !== []) { + $sb->writeString($s . ' WHERE '); + $s = ''; + + Junction::and(...$parts->whereConjunction)->writeSql($sb); + } + + if ($parts->groupBys !== []) { + $sb->writeString($s . ' GROUP BY '); + $s = ''; + + foreach ($parts->groupBys as $i => $groupBy) { + if ($i > 0) { + $sb->writeString(','); + } + $groupBy->writeSql($sb); + } + if ($parts->groupByWithRollup) { + $s = ' WITH ROLLUP'; + } + } + + if ($parts->havingConjunction !== []) { + $sb->writeString($s . ' HAVING '); + $s = ''; + + Junction::and(...$parts->havingConjunction)->writeSql($sb); + } + + if ($parts->windows !== []) { + $sb->writeString($s . ' WINDOW '); + $s = ''; + + foreach ($parts->windows as $i => $window) { + if ($i > 0) { + $sb->writeString(','); + } + $window->writeSql($sb); + } + } + + if ($s !== '') { + $sb->writeString($s); + } + } +} diff --git a/src/MySQL/Builder/ForSelectBuilder.php b/src/MySQL/Builder/ForSelectBuilder.php index 37965f2..6607d78 100644 --- a/src/MySQL/Builder/ForSelectBuilder.php +++ b/src/MySQL/Builder/ForSelectBuilder.php @@ -38,7 +38,7 @@ private function deriveLocking(?array $ofTables = null, ?string $waitPolicy = nu assert($lc !== null); return $this->derive(self::class, lockingClause: new LockingClause( - $lc->lockStrength, + $lc->clause, $ofTables ?? $lc->ofTables, $waitPolicy ?? $lc->waitPolicy, )); diff --git a/src/MySQL/Builder/LockingClause.php b/src/MySQL/Builder/LockingClause.php index 504cc4e..d6bc803 100644 --- a/src/MySQL/Builder/LockingClause.php +++ b/src/MySQL/Builder/LockingClause.php @@ -5,7 +5,8 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * A row-locking clause, e.g. `FOR UPDATE`, `FOR SHARE OF a, b SKIP LOCKED`. + * A row-locking clause, e.g. `FOR UPDATE`, `FOR SHARE OF a, b SKIP LOCKED`, or + * `LOCK IN SHARE MODE`. The lead keyword is dialect-chosen and stored verbatim. * * @internal */ @@ -15,7 +16,7 @@ final class LockingClause * @param list $ofTables */ public function __construct( - public readonly string $lockStrength, + public readonly string $clause, public readonly array $ofTables = [], public readonly string $waitPolicy = '', ) { @@ -23,7 +24,7 @@ public function __construct( public function writeSql(SqlBuilder $sb): void { - $s = 'FOR ' . $this->lockStrength; + $s = $this->clause; if ($this->ofTables !== []) { $s .= ' OF ' . implode(',', $this->ofTables); } diff --git a/src/MySQL/Builder/SelectBuilder.php b/src/MySQL/Builder/SelectBuilder.php index bf96644..33bf92a 100644 --- a/src/MySQL/Builder/SelectBuilder.php +++ b/src/MySQL/Builder/SelectBuilder.php @@ -5,48 +5,20 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds a SELECT query. + * Builds a MySQL SELECT query. * - * Two principles run through this family of builders: - * - * - Immutability: every method returns a new builder; the receiver is never - * modified. The state lives in an immutable {@see SelectQueryParts}, and a - * derived copy is assembled only by {@see derive()}. - * - Type-state: methods return a more specific builder type (e.g. {@see - * FromSelectBuilder} after {@see from()}) so that context-dependent methods - * like `as()`, `using()` or `on()` are only available — and only act on the - * relevant element — where they make sense. - * - * The specific builders extend this base class, so they keep access to all the - * generic clause methods (`select()`, `from()`, `join()`, ...). + * The shared state, rendering and clause logic live in {@see AbstractSelectBuilder}; + * this concrete builder adds MySQL's public transition methods (including the + * `LATERAL` from/join family) and renders the shared lock as `FOR SHARE`. */ -class SelectBuilder implements InnerSqlWriter, WithQuery, Exp, FromLateralExp, SelectOrExpressions +class SelectBuilder extends AbstractSelectBuilder { - use RendersWithQueries; - use WritesParenthesizedSql; - - /** - * @param list $withQueries the leading WITH clause, if any - * @param list $combinations previous selects combined via UNION / INTERSECT / EXCEPT - */ - public function __construct( - protected readonly SelectQueryParts $parts = new SelectQueryParts(), - protected readonly array $withQueries = [], - protected readonly array $combinations = [], - ) { - } - /** * Add the given expressions to the select list. */ public function select(Exp ...$exps): SelectSelectBuilder { - $selectList = $this->parts->selectList; - foreach ($exps as $exp) { - $selectList[] = new OutputExpr($exp); - } - - return $this->derive(SelectSelectBuilder::class, selectList: $selectList); + return $this->addToSelectList(SelectSelectBuilder::class, array_values($exps)); } /** @@ -54,7 +26,7 @@ public function select(Exp ...$exps): SelectSelectBuilder */ public function from(FromExp $from): FromSelectBuilder { - return $this->derive(FromSelectBuilder::class, from: [...$this->parts->from, new FromItem($from)]); + return $this->addFromItem(FromSelectBuilder::class, $from); } /** @@ -62,50 +34,42 @@ public function from(FromExp $from): FromSelectBuilder */ public function fromLateral(FromLateralExp $from): FromSelectBuilder { - return $this->derive(FromSelectBuilder::class, from: [...$this->parts->from, new FromItem($from, lateral: true)]); + return $this->addFromItem(FromSelectBuilder::class, $from, true); } public function join(FromExp $from): JoinSelectBuilder { - return $this->addJoin(JoinType::Inner, $from, false); + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Inner, $from, false); } public function joinLateral(FromExp $from): JoinSelectBuilder { - return $this->addJoin(JoinType::Inner, $from, true); + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Inner, $from, true); } public function leftJoin(FromExp $from): JoinSelectBuilder { - return $this->addJoin(JoinType::Left, $from, false); + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Left, $from, false); } public function leftJoinLateral(FromExp $from): JoinSelectBuilder { - return $this->addJoin(JoinType::Left, $from, true); + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Left, $from, true); } public function rightJoin(FromExp $from): JoinSelectBuilder { - return $this->addJoin(JoinType::Right, $from, false); + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Right, $from, false); } public function crossJoin(FromExp $from): JoinSelectBuilder { - return $this->addJoin(JoinType::Cross, $from, false); + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Cross, $from, false); } public function crossJoinLateral(FromExp $from): JoinSelectBuilder { - return $this->addJoin(JoinType::Cross, $from, true); - } - - private function addJoin(JoinType $joinType, FromExp $from, bool $lateral): JoinSelectBuilder - { - return $this->derive( - JoinSelectBuilder::class, - from: [...$this->parts->from, new FromItem(new Join($joinType, $lateral, $from))], - ); + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Cross, $from, true); } /** @@ -113,7 +77,7 @@ private function addJoin(JoinType $joinType, FromExp $from, bool $lateral): Join */ public function where(Exp $cond): SelectBuilder { - return $this->derive(SelectBuilder::class, whereConjunction: [...$this->parts->whereConjunction, $cond]); + return $this->addWhereCondition(SelectBuilder::class, $cond); } /** @@ -122,7 +86,7 @@ public function where(Exp $cond): SelectBuilder */ public function groupBy(Exp ...$exps): GroupBySelectBuilder { - return $this->derive(GroupBySelectBuilder::class, groupBys: [...$this->parts->groupBys, ...array_values($exps)]); + return $this->addGroupByExps(GroupBySelectBuilder::class, array_values($exps)); } /** @@ -130,7 +94,7 @@ public function groupBy(Exp ...$exps): GroupBySelectBuilder */ public function having(Exp $cond): SelectBuilder { - return $this->derive(SelectBuilder::class, havingConjunction: [...$this->parts->havingConjunction, $cond]); + return $this->addHavingCondition(SelectBuilder::class, $cond); } /** @@ -141,7 +105,7 @@ public function having(Exp $cond): SelectBuilder */ public function window(string $name): WindowSelectBuilder { - return $this->derive(WindowSelectBuilder::class, windows: [...$this->parts->windows, new NamedWindow($name)]); + return $this->addNamedWindow(WindowSelectBuilder::class, $name); } /** @@ -149,17 +113,17 @@ public function window(string $name): WindowSelectBuilder */ public function orderBy(Exp $exp): OrderBySelectBuilder { - return $this->derive(OrderBySelectBuilder::class, orderBys: [...$this->parts->orderBys, new OrderByClause($exp)]); + return $this->addOrderByExp(OrderBySelectBuilder::class, $exp); } public function limit(Exp $exp): SelectBuilder { - return $this->derive(SelectBuilder::class, limit: $exp); + return $this->withLimit(SelectBuilder::class, $exp); } public function offset(Exp $exp): SelectBuilder { - return $this->derive(SelectBuilder::class, offset: $exp); + return $this->withOffset(SelectBuilder::class, $exp); } /** @@ -169,37 +133,27 @@ public function offset(Exp $exp): SelectBuilder */ public function union(): CombinationBuilder { - return $this->addCombination(CombinationType::Union); + return $this->startCombination(CombinationBuilder::class, CombinationType::Union); } public function intersect(): CombinationBuilder { - return $this->addCombination(CombinationType::Intersect); + return $this->startCombination(CombinationBuilder::class, CombinationType::Intersect); } public function except(): CombinationBuilder { - return $this->addCombination(CombinationType::Except); - } - - private function addCombination(CombinationType $type): CombinationBuilder - { - // Archive the current parts as a combination and start a fresh select. - return $this->derive( - CombinationBuilder::class, - parts: new SelectQueryParts(), - combinations: [...$this->combinations, new Combination($this->parts, $type)], - ); + return $this->startCombination(CombinationBuilder::class, CombinationType::Except); } public function forUpdate(): ForSelectBuilder { - return $this->derive(ForSelectBuilder::class, lockingClause: new LockingClause('UPDATE')); + return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR UPDATE')); } public function forShare(): ForSelectBuilder { - return $this->derive(ForSelectBuilder::class, lockingClause: new LockingClause('SHARE')); + return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR SHARE')); } /** @@ -207,205 +161,6 @@ public function forShare(): ForSelectBuilder */ public function appendWith(WithBuilder $with): SelectBuilder { - return $this->derive(SelectBuilder::class, withQueries: [...$this->withQueries, ...$with->withQueryItems()]); - } - - /** - * Whether this builder carries no content yet: no WITH queries, no - * combinations and empty select parts. Useful for conditional query building. - */ - public function isEmpty(): bool - { - return $this->withQueries === [] && $this->combinations === [] && $this->parts->isEmpty(); - } - - /** - * Assemble a new builder of the given type from the current state with the - * given fields replaced; a null argument keeps the current value. This is the - * single place where a derived {@see SelectQueryParts} and the type-state - * transition are produced. - * - * @template T of SelectBuilder - * @param class-string $class - * @param list|null $selectList - * @param list|null $from - * @param list|null $whereConjunction - * @param list|null $groupBys - * @param list|null $havingConjunction - * @param list|null $windows - * @param list|null $orderBys - * @param list|null $withQueries - * @param list|null $combinations - * @return T - */ - protected function derive( - string $class, - ?SelectQueryParts $parts = null, - ?bool $distinct = null, - ?array $selectList = null, - ?array $from = null, - ?array $whereConjunction = null, - ?array $groupBys = null, - ?bool $groupByWithRollup = null, - ?array $havingConjunction = null, - ?array $windows = null, - ?array $orderBys = null, - ?Exp $limit = null, - ?Exp $offset = null, - ?LockingClause $lockingClause = null, - ?array $withQueries = null, - ?array $combinations = null, - ): SelectBuilder { - $parts ??= new SelectQueryParts( - distinct: $distinct ?? $this->parts->distinct, - selectList: $selectList ?? $this->parts->selectList, - from: $from ?? $this->parts->from, - whereConjunction: $whereConjunction ?? $this->parts->whereConjunction, - groupBys: $groupBys ?? $this->parts->groupBys, - groupByWithRollup: $groupByWithRollup ?? $this->parts->groupByWithRollup, - havingConjunction: $havingConjunction ?? $this->parts->havingConjunction, - windows: $windows ?? $this->parts->windows, - orderBys: $orderBys ?? $this->parts->orderBys, - limit: $limit ?? $this->parts->limit, - offset: $offset ?? $this->parts->offset, - lockingClause: $lockingClause ?? $this->parts->lockingClause, - ); - - return new $class($parts, $withQueries ?? $this->withQueries, $combinations ?? $this->combinations); - } - - /** - * Write the select without the surrounding parentheses (the top-level query). - * - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - if ($this->withQueries !== []) { - $this->writeWithQueries($sb, $this->withQueries); - } - - // Previous selects combined via UNION / INTERSECT / EXCEPT come first. - foreach ($this->combinations as $c) { - $this->writeSelectParts($sb, $c->parts); - $s = ' ' . $c->type->value; - if ($c->all) { - $s .= ' ALL'; - } - $sb->writeString($s . ' '); - $c->query?->writeSql($sb); - } - - if (!$this->parts->isEmpty()) { - $this->writeSelectParts($sb, $this->parts); - } - - if ($this->parts->orderBys !== []) { - $sb->writeString(' ORDER BY '); - foreach ($this->parts->orderBys as $i => $clause) { - if ($i > 0) { - $sb->writeString(','); - } - $clause->writeSql($sb); - } - } - - if ($this->parts->limit !== null) { - $sb->writeString(' LIMIT '); - $this->parts->limit->writeSql($sb); - } - - if ($this->parts->offset !== null) { - $sb->writeString(' OFFSET '); - $this->parts->offset->writeSql($sb); - } - - if ($this->parts->lockingClause !== null) { - $sb->writeString(' '); - $this->parts->lockingClause->writeSql($sb); - } - } - - private function writeSelectParts(SqlBuilder $sb, SelectQueryParts $parts): void - { - // Accumulate literal SQL into $s and only flush before a nested writer - // needs to write (and once at the very end). - $sb->writeString('SELECT '); - $s = $parts->distinct ? 'DISTINCT ' : ''; - $needComma = false; - - foreach ($parts->selectList as $out) { - if ($needComma) { - $s .= ','; - } - $sb->writeString($s); - $s = ''; - - $out->exp->writeSql($sb); - - if ($out->alias !== '') { - $s = ' AS ' . $out->alias; - } - $needComma = true; - } - - if ($parts->from !== []) { - $s .= ' FROM '; - foreach ($parts->from as $i => $fromItem) { - if ($i > 0) { - // A join attaches to the previous item; a plain item is comma-separated. - $s .= $fromItem->from instanceof Join ? ' ' : ','; - } - $sb->writeString($s); - $s = ''; - - $fromItem->writeSql($sb); - } - } - - if ($parts->whereConjunction !== []) { - $sb->writeString($s . ' WHERE '); - $s = ''; - - Junction::and(...$parts->whereConjunction)->writeSql($sb); - } - - if ($parts->groupBys !== []) { - $sb->writeString($s . ' GROUP BY '); - $s = ''; - - foreach ($parts->groupBys as $i => $groupBy) { - if ($i > 0) { - $sb->writeString(','); - } - $groupBy->writeSql($sb); - } - if ($parts->groupByWithRollup) { - $s = ' WITH ROLLUP'; - } - } - - if ($parts->havingConjunction !== []) { - $sb->writeString($s . ' HAVING '); - $s = ''; - - Junction::and(...$parts->havingConjunction)->writeSql($sb); - } - - if ($parts->windows !== []) { - $sb->writeString($s . ' WINDOW '); - $s = ''; - - foreach ($parts->windows as $i => $window) { - if ($i > 0) { - $sb->writeString(','); - } - $window->writeSql($sb); - } - } - - if ($s !== '') { - $sb->writeString($s); - } + return $this->withAppendedWith(SelectBuilder::class, $with->withQueryItems()); } } From 55e3f5b522abc9f600cddfdf0bb6809b7f620dec Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 09:53:18 +0200 Subject: [PATCH 17/40] wip: extract shared SELECT subbuilder traits (aliases/join/rollup/order/combination/lock); widen cross-dialect select type hints --- src/MySQL/Builder/AliasesLastFromItem.php | 43 ++++++++++++ src/MySQL/Builder/AliasesLastOutput.php | 37 ++++++++++ src/MySQL/Builder/AppliesRollup.php | 23 +++++++ src/MySQL/Builder/Combination.php | 2 +- src/MySQL/Builder/CombinationBuilder.php | 28 +------- src/MySQL/Builder/ExistsExp.php | 2 +- src/MySQL/Builder/ForSelectBuilder.php | 29 +------- src/MySQL/Builder/FromSelectBuilder.php | 28 +------- src/MySQL/Builder/GroupBySelectBuilder.php | 8 +-- src/MySQL/Builder/JoinSelectBuilder.php | 52 +------------- src/MySQL/Builder/OrderBySelectBuilder.php | 28 +------- .../Builder/OrderByWindowSelectBuilder.php | 4 +- src/MySQL/Builder/OrdersLastTerm.php | 43 ++++++++++++ src/MySQL/Builder/RefinesCombination.php | 44 ++++++++++++ src/MySQL/Builder/RefinesLastJoin.php | 68 +++++++++++++++++++ src/MySQL/Builder/SelectSelectBuilder.php | 21 +----- src/MySQL/Builder/SetsLockWaitPolicy.php | 41 +++++++++++ src/MySQL/Builder/SubqueryExp.php | 2 +- 18 files changed, 313 insertions(+), 190 deletions(-) create mode 100644 src/MySQL/Builder/AliasesLastFromItem.php create mode 100644 src/MySQL/Builder/AliasesLastOutput.php create mode 100644 src/MySQL/Builder/AppliesRollup.php create mode 100644 src/MySQL/Builder/OrdersLastTerm.php create mode 100644 src/MySQL/Builder/RefinesCombination.php create mode 100644 src/MySQL/Builder/RefinesLastJoin.php create mode 100644 src/MySQL/Builder/SetsLockWaitPolicy.php diff --git a/src/MySQL/Builder/AliasesLastFromItem.php b/src/MySQL/Builder/AliasesLastFromItem.php new file mode 100644 index 0000000..d3f100c --- /dev/null +++ b/src/MySQL/Builder/AliasesLastFromItem.php @@ -0,0 +1,43 @@ +parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + $item = $from[$lastIdx]; + $from[$lastIdx] = new FromItem($item->from, $alias, $item->lateral, $item->columnAliases); + + return $this->derive(static::class, from: $from); + } + + /** + * Set the column aliases for the last added from item. + */ + public function columnAliases(string ...$aliases): static + { + $from = $this->parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + $item = $from[$lastIdx]; + $from[$lastIdx] = new FromItem($item->from, $item->alias, $item->lateral, array_values($aliases)); + + return $this->derive(static::class, from: $from); + } +} diff --git a/src/MySQL/Builder/AliasesLastOutput.php b/src/MySQL/Builder/AliasesLastOutput.php new file mode 100644 index 0000000..acff6c7 --- /dev/null +++ b/src/MySQL/Builder/AliasesLastOutput.php @@ -0,0 +1,37 @@ +parts->selectList; + $lastIdx = array_key_last($selectList); + assert($lastIdx !== null); + $selectList[$lastIdx] = new OutputExpr($selectList[$lastIdx]->exp, $alias); + + return $this->derive(static::class, selectList: $selectList); + } + + /** + * Make the select `DISTINCT`. + */ + public function distinct(): static + { + return $this->derive(static::class, distinct: true); + } +} diff --git a/src/MySQL/Builder/AppliesRollup.php b/src/MySQL/Builder/AppliesRollup.php new file mode 100644 index 0000000..e390eaa --- /dev/null +++ b/src/MySQL/Builder/AppliesRollup.php @@ -0,0 +1,23 @@ +derive(static::class, groupByWithRollup: true); + } +} diff --git a/src/MySQL/Builder/Combination.php b/src/MySQL/Builder/Combination.php index cbbd290..47bd894 100644 --- a/src/MySQL/Builder/Combination.php +++ b/src/MySQL/Builder/Combination.php @@ -17,7 +17,7 @@ public function __construct( public readonly SelectQueryParts $parts, public readonly CombinationType $type, public readonly bool $all = false, - public readonly ?SelectBuilder $query = null, + public readonly ?AbstractSelectBuilder $query = null, ) { } } diff --git a/src/MySQL/Builder/CombinationBuilder.php b/src/MySQL/Builder/CombinationBuilder.php index 4451794..b2bb281 100644 --- a/src/MySQL/Builder/CombinationBuilder.php +++ b/src/MySQL/Builder/CombinationBuilder.php @@ -12,31 +12,5 @@ */ final class CombinationBuilder extends SelectBuilder { - public function all(): self - { - return $this->derive(self::class, combinations: $this->rebuildLastCombination(all: true)); - } - - public function query(SelectBuilder $query): self - { - return $this->derive(self::class, combinations: $this->rebuildLastCombination(query: $query)); - } - - /** - * Return the combinations with the last one replaced by a copy carrying the - * given overrides. - * - * @return list - */ - private function rebuildLastCombination(?bool $all = null, ?SelectBuilder $query = null): array - { - $combinations = $this->combinations; - $lastIdx = array_key_last($combinations); - assert($lastIdx !== null); - - $c = $combinations[$lastIdx]; - $combinations[$lastIdx] = new Combination($c->parts, $c->type, $all ?? $c->all, $query ?? $c->query); - - return $combinations; - } + use RefinesCombination; } diff --git a/src/MySQL/Builder/ExistsExp.php b/src/MySQL/Builder/ExistsExp.php index 5172134..549a52a 100644 --- a/src/MySQL/Builder/ExistsExp.php +++ b/src/MySQL/Builder/ExistsExp.php @@ -10,7 +10,7 @@ final class ExistsExp implements Exp { public function __construct( - private readonly SelectBuilder $subquery, + private readonly AbstractSelectBuilder $subquery, ) { } diff --git a/src/MySQL/Builder/ForSelectBuilder.php b/src/MySQL/Builder/ForSelectBuilder.php index 6607d78..17c5be8 100644 --- a/src/MySQL/Builder/ForSelectBuilder.php +++ b/src/MySQL/Builder/ForSelectBuilder.php @@ -11,36 +11,13 @@ */ final class ForSelectBuilder extends SelectBuilder { + use SetsLockWaitPolicy; + /** * Restrict the lock to the given tables (`OF table [, ...]`). */ - public function of(string ...$tables): self + public function of(string ...$tables): static { return $this->deriveLocking(ofTables: array_values($tables)); } - - public function nowait(): self - { - return $this->deriveLocking(waitPolicy: 'NOWAIT'); - } - - public function skipLocked(): self - { - return $this->deriveLocking(waitPolicy: 'SKIP LOCKED'); - } - - /** - * @param list|null $ofTables - */ - private function deriveLocking(?array $ofTables = null, ?string $waitPolicy = null): self - { - $lc = $this->parts->lockingClause; - assert($lc !== null); - - return $this->derive(self::class, lockingClause: new LockingClause( - $lc->clause, - $ofTables ?? $lc->ofTables, - $waitPolicy ?? $lc->waitPolicy, - )); - } } diff --git a/src/MySQL/Builder/FromSelectBuilder.php b/src/MySQL/Builder/FromSelectBuilder.php index 5d05f56..bbce213 100644 --- a/src/MySQL/Builder/FromSelectBuilder.php +++ b/src/MySQL/Builder/FromSelectBuilder.php @@ -11,31 +11,5 @@ */ final class FromSelectBuilder extends SelectBuilder { - /** - * Set the alias for the last added from item. - */ - public function as(string $alias): self - { - $from = $this->parts->from; - $lastIdx = array_key_last($from); - assert($lastIdx !== null); - $item = $from[$lastIdx]; - $from[$lastIdx] = new FromItem($item->from, $alias, $item->lateral, $item->columnAliases); - - return $this->derive(self::class, from: $from); - } - - /** - * Set the column aliases for the last added from item. - */ - public function columnAliases(string ...$aliases): self - { - $from = $this->parts->from; - $lastIdx = array_key_last($from); - assert($lastIdx !== null); - $item = $from[$lastIdx]; - $from[$lastIdx] = new FromItem($item->from, $item->alias, $item->lateral, array_values($aliases)); - - return $this->derive(self::class, from: $from); - } + use AliasesLastFromItem; } diff --git a/src/MySQL/Builder/GroupBySelectBuilder.php b/src/MySQL/Builder/GroupBySelectBuilder.php index f2aace9..862afa1 100644 --- a/src/MySQL/Builder/GroupBySelectBuilder.php +++ b/src/MySQL/Builder/GroupBySelectBuilder.php @@ -10,11 +10,5 @@ */ final class GroupBySelectBuilder extends SelectBuilder { - /** - * Add super-aggregate rows over the grouping (`GROUP BY ... WITH ROLLUP`). - */ - public function withRollup(): self - { - return $this->derive(self::class, groupByWithRollup: true); - } + use AppliesRollup; } diff --git a/src/MySQL/Builder/JoinSelectBuilder.php b/src/MySQL/Builder/JoinSelectBuilder.php index c36ed46..0ad8620 100644 --- a/src/MySQL/Builder/JoinSelectBuilder.php +++ b/src/MySQL/Builder/JoinSelectBuilder.php @@ -10,55 +10,5 @@ */ final class JoinSelectBuilder extends SelectBuilder { - /** - * Set the alias for the last added join. - */ - public function as(string $alias): self - { - return $this->derive(self::class, from: $this->rebuildLastJoin(alias: $alias)); - } - - /** - * Set the ON condition for the last added join. - */ - public function on(Exp $cond): SelectBuilder - { - return $this->derive(SelectBuilder::class, from: $this->rebuildLastJoin(on: $cond)); - } - - /** - * Set the USING columns for the last added join. - */ - public function using(string ...$columns): SelectBuilder - { - return $this->derive(SelectBuilder::class, from: $this->rebuildLastJoin(using: array_values($columns))); - } - - /** - * Return the from list with the last join replaced by a copy carrying the - * given overrides. - * - * @param list|null $using - * @return list - */ - private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array - { - $from = $this->parts->from; - $lastIdx = array_key_last($from); - assert($lastIdx !== null); - - $join = $from[$lastIdx]->from; - assert($join instanceof Join); - - $from[$lastIdx] = new FromItem(new Join( - $join->joinType, - $join->lateral, - $join->from, - $alias ?? $join->alias, - $on ?? $join->on, - $using ?? $join->using, - )); - - return $from; - } + use RefinesLastJoin; } diff --git a/src/MySQL/Builder/OrderBySelectBuilder.php b/src/MySQL/Builder/OrderBySelectBuilder.php index c0964e1..6cad33c 100644 --- a/src/MySQL/Builder/OrderBySelectBuilder.php +++ b/src/MySQL/Builder/OrderBySelectBuilder.php @@ -10,31 +10,5 @@ */ class OrderBySelectBuilder extends SelectBuilder { - public function asc(): self - { - return $this->derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); - } - - public function desc(): self - { - return $this->derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); - } - - /** - * Return the order by list with the last term replaced by a copy carrying the - * given sort direction. - * - * @return list - */ - private function rebuildLastOrderBy(SortOrder $order): array - { - $orderBys = $this->parts->orderBys; - $lastIdx = array_key_last($orderBys); - assert($lastIdx !== null); - - $clause = $orderBys[$lastIdx]; - $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); - - return $orderBys; - } + use OrdersLastTerm; } diff --git a/src/MySQL/Builder/OrderByWindowSelectBuilder.php b/src/MySQL/Builder/OrderByWindowSelectBuilder.php index 645b716..9e8eac7 100644 --- a/src/MySQL/Builder/OrderByWindowSelectBuilder.php +++ b/src/MySQL/Builder/OrderByWindowSelectBuilder.php @@ -13,12 +13,12 @@ final class OrderByWindowSelectBuilder extends OrderBySelectBuilder { use WindowDefining; - public function asc(): self + public function asc(): static { return $this->rebuildLastWindowOrderBy(SortOrder::Asc); } - public function desc(): self + public function desc(): static { return $this->rebuildLastWindowOrderBy(SortOrder::Desc); } diff --git a/src/MySQL/Builder/OrdersLastTerm.php b/src/MySQL/Builder/OrdersLastTerm.php new file mode 100644 index 0000000..2797797 --- /dev/null +++ b/src/MySQL/Builder/OrdersLastTerm.php @@ -0,0 +1,43 @@ +derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); + } + + public function desc(): static + { + return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); + } + + /** + * Return the order by list with the last term replaced by a copy carrying the + * given sort direction. + * + * @return list + */ + private function rebuildLastOrderBy(SortOrder $order): array + { + $orderBys = $this->parts->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $orderBys; + } +} diff --git a/src/MySQL/Builder/RefinesCombination.php b/src/MySQL/Builder/RefinesCombination.php new file mode 100644 index 0000000..2136950 --- /dev/null +++ b/src/MySQL/Builder/RefinesCombination.php @@ -0,0 +1,44 @@ +derive(static::class, combinations: $this->rebuildLastCombination(all: true)); + } + + public function query(AbstractSelectBuilder $query): static + { + return $this->derive(static::class, combinations: $this->rebuildLastCombination(query: $query)); + } + + /** + * Return the combinations with the last one replaced by a copy carrying the + * given overrides. + * + * @return list + */ + private function rebuildLastCombination(?bool $all = null, ?AbstractSelectBuilder $query = null): array + { + $combinations = $this->combinations; + $lastIdx = array_key_last($combinations); + assert($lastIdx !== null); + + $c = $combinations[$lastIdx]; + $combinations[$lastIdx] = new Combination($c->parts, $c->type, $all ?? $c->all, $query ?? $c->query); + + return $combinations; + } +} diff --git a/src/MySQL/Builder/RefinesLastJoin.php b/src/MySQL/Builder/RefinesLastJoin.php new file mode 100644 index 0000000..2e4228f --- /dev/null +++ b/src/MySQL/Builder/RefinesLastJoin.php @@ -0,0 +1,68 @@ +derive(static::class, from: $this->rebuildLastJoin(alias: $alias)); + } + + /** + * Set the ON condition for the last added join. + */ + public function on(Exp $cond): static + { + return $this->derive(static::class, from: $this->rebuildLastJoin(on: $cond)); + } + + /** + * Set the USING columns for the last added join. + */ + public function using(string ...$columns): static + { + return $this->derive(static::class, from: $this->rebuildLastJoin(using: array_values($columns))); + } + + /** + * Return the from list with the last join replaced by a copy carrying the + * given overrides. + * + * @param list|null $using + * @return list + */ + private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array + { + $from = $this->parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + + $join = $from[$lastIdx]->from; + assert($join instanceof Join); + + $from[$lastIdx] = new FromItem(new Join( + $join->joinType, + $join->lateral, + $join->from, + $alias ?? $join->alias, + $on ?? $join->on, + $using ?? $join->using, + )); + + return $from; + } +} diff --git a/src/MySQL/Builder/SelectSelectBuilder.php b/src/MySQL/Builder/SelectSelectBuilder.php index 044927f..c332b4d 100644 --- a/src/MySQL/Builder/SelectSelectBuilder.php +++ b/src/MySQL/Builder/SelectSelectBuilder.php @@ -11,24 +11,5 @@ */ final class SelectSelectBuilder extends SelectBuilder { - /** - * Set the output alias for the last added select expression. - */ - public function as(string $alias): self - { - $selectList = $this->parts->selectList; - $lastIdx = array_key_last($selectList); - assert($lastIdx !== null); - $selectList[$lastIdx] = new OutputExpr($selectList[$lastIdx]->exp, $alias); - - return $this->derive(self::class, selectList: $selectList); - } - - /** - * Make the select `DISTINCT`. - */ - public function distinct(): SelectBuilder - { - return $this->derive(SelectBuilder::class, distinct: true); - } + use AliasesLastOutput; } diff --git a/src/MySQL/Builder/SetsLockWaitPolicy.php b/src/MySQL/Builder/SetsLockWaitPolicy.php new file mode 100644 index 0000000..29a97f3 --- /dev/null +++ b/src/MySQL/Builder/SetsLockWaitPolicy.php @@ -0,0 +1,41 @@ +deriveLocking(waitPolicy: 'NOWAIT'); + } + + public function skipLocked(): static + { + return $this->deriveLocking(waitPolicy: 'SKIP LOCKED'); + } + + /** + * @param list|null $ofTables + */ + protected function deriveLocking(?array $ofTables = null, ?string $waitPolicy = null): static + { + $lc = $this->parts->lockingClause; + assert($lc !== null); + + return $this->derive(static::class, lockingClause: new LockingClause( + $lc->clause, + $ofTables ?? $lc->ofTables, + $waitPolicy ?? $lc->waitPolicy, + )); + } +} diff --git a/src/MySQL/Builder/SubqueryExp.php b/src/MySQL/Builder/SubqueryExp.php index aa92a9f..39a5d30 100644 --- a/src/MySQL/Builder/SubqueryExp.php +++ b/src/MySQL/Builder/SubqueryExp.php @@ -20,7 +20,7 @@ public function writeSql(SqlBuilder $sb): void { $sb->writeString($this->op . ' '); - $isSelect = $this->exp instanceof SelectBuilder; + $isSelect = $this->exp instanceof AbstractSelectBuilder; if (!$isSelect) { $sb->writeString('('); } From b7b63fb404a12935d280ab0beea26264318c6b17 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 10:00:59 +0200 Subject: [PATCH 18/40] wip: MariaDB SELECT ladder (LOCK IN SHARE MODE, no lateral/of) + MariaDB\Q facade; shared BuildsExpressions facade trait --- src/MariaDB/Builder/CombinationBuilder.php | 18 + src/MariaDB/Builder/ForSelectBuilder.php | 17 + src/MariaDB/Builder/FromSelectBuilder.php | 17 + src/MariaDB/Builder/GroupBySelectBuilder.php | 16 + src/MariaDB/Builder/JoinSelectBuilder.php | 17 + src/MariaDB/Builder/OrderBySelectBuilder.php | 17 + .../Builder/OrderByWindowSelectBuilder.php | 43 +++ src/MariaDB/Builder/SelectBuilder.php | 151 +++++++++ src/MariaDB/Builder/SelectSelectBuilder.php | 17 + src/MariaDB/Builder/WindowDefining.php | 98 ++++++ src/MariaDB/Builder/WindowSelectBuilder.php | 41 +++ src/MariaDB/Builder/WithBuilder.php | 64 ++++ src/MariaDB/Builder/WithWithBuilder.php | 54 +++ src/MariaDB/Q.php | 51 +++ src/MySQL/BuildsExpressions.php | 314 ++++++++++++++++++ src/MySQL/Q.php | 300 +---------------- tests/MariaDB/Q/SelectBuilderTest.php | 106 ++++++ 17 files changed, 1046 insertions(+), 295 deletions(-) create mode 100644 src/MariaDB/Builder/CombinationBuilder.php create mode 100644 src/MariaDB/Builder/ForSelectBuilder.php create mode 100644 src/MariaDB/Builder/FromSelectBuilder.php create mode 100644 src/MariaDB/Builder/GroupBySelectBuilder.php create mode 100644 src/MariaDB/Builder/JoinSelectBuilder.php create mode 100644 src/MariaDB/Builder/OrderBySelectBuilder.php create mode 100644 src/MariaDB/Builder/OrderByWindowSelectBuilder.php create mode 100644 src/MariaDB/Builder/SelectBuilder.php create mode 100644 src/MariaDB/Builder/SelectSelectBuilder.php create mode 100644 src/MariaDB/Builder/WindowDefining.php create mode 100644 src/MariaDB/Builder/WindowSelectBuilder.php create mode 100644 src/MariaDB/Builder/WithBuilder.php create mode 100644 src/MariaDB/Builder/WithWithBuilder.php create mode 100644 src/MariaDB/Q.php create mode 100644 src/MySQL/BuildsExpressions.php create mode 100644 tests/MariaDB/Q/SelectBuilderTest.php diff --git a/src/MariaDB/Builder/CombinationBuilder.php b/src/MariaDB/Builder/CombinationBuilder.php new file mode 100644 index 0000000..f961f97 --- /dev/null +++ b/src/MariaDB/Builder/CombinationBuilder.php @@ -0,0 +1,18 @@ +rebuildLastWindowOrderBy(SortOrder::Asc); + } + + public function desc(): static + { + return $this->rebuildLastWindowOrderBy(SortOrder::Desc); + } + + private function rebuildLastWindowOrderBy(SortOrder $order): self + { + $def = $this->lastWindowDefinition(); + + $orderBys = $def->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $this->deriveWindow(self::class, new WindowDefinition($def->existingWindowName, $def->partitionBy, $orderBys, $def->frame)); + } +} diff --git a/src/MariaDB/Builder/SelectBuilder.php b/src/MariaDB/Builder/SelectBuilder.php new file mode 100644 index 0000000..b6ed03d --- /dev/null +++ b/src/MariaDB/Builder/SelectBuilder.php @@ -0,0 +1,151 @@ +addToSelectList(SelectSelectBuilder::class, array_values($exps)); + } + + /** + * Add a table / subquery to the FROM clause. + */ + public function from(FromExp $from): FromSelectBuilder + { + return $this->addFromItem(FromSelectBuilder::class, $from); + } + + public function join(FromExp $from): JoinSelectBuilder + { + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Inner, $from, false); + } + + public function leftJoin(FromExp $from): JoinSelectBuilder + { + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Left, $from, false); + } + + public function rightJoin(FromExp $from): JoinSelectBuilder + { + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Right, $from, false); + } + + public function crossJoin(FromExp $from): JoinSelectBuilder + { + return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Cross, $from, false); + } + + /** + * Add a WHERE condition. Multiple calls are joined with AND. + */ + public function where(Exp $cond): SelectBuilder + { + return $this->addWhereCondition(SelectBuilder::class, $cond); + } + + /** + * Add expressions to the GROUP BY clause. Refine with + * {@see GroupBySelectBuilder::withRollup()}. + */ + public function groupBy(Exp ...$exps): GroupBySelectBuilder + { + return $this->addGroupByExps(GroupBySelectBuilder::class, array_values($exps)); + } + + /** + * Add a HAVING condition. Multiple calls are joined with AND. + */ + public function having(Exp $cond): SelectBuilder + { + return $this->addHavingCondition(SelectBuilder::class, $cond); + } + + /** + * Add a named window to the WINDOW clause. + */ + public function window(string $name): WindowSelectBuilder + { + return $this->addNamedWindow(WindowSelectBuilder::class, $name); + } + + /** + * Add an ORDER BY expression (refine it via {@see OrderBySelectBuilder}). + */ + public function orderBy(Exp $exp): OrderBySelectBuilder + { + return $this->addOrderByExp(OrderBySelectBuilder::class, $exp); + } + + public function limit(Exp $exp): SelectBuilder + { + return $this->withLimit(SelectBuilder::class, $exp); + } + + public function offset(Exp $exp): SelectBuilder + { + return $this->withOffset(SelectBuilder::class, $exp); + } + + /** + * Combine this select with the following one using UNION. Refine with + * {@see CombinationBuilder::all()} or supply the query via + * {@see CombinationBuilder::query()}. + */ + public function union(): CombinationBuilder + { + return $this->startCombination(CombinationBuilder::class, CombinationType::Union); + } + + public function intersect(): CombinationBuilder + { + return $this->startCombination(CombinationBuilder::class, CombinationType::Intersect); + } + + public function except(): CombinationBuilder + { + return $this->startCombination(CombinationBuilder::class, CombinationType::Except); + } + + public function forUpdate(): ForSelectBuilder + { + return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR UPDATE')); + } + + /** + * Lock the selected rows for sharing (`LOCK IN SHARE MODE`). + */ + public function forShare(): SelectBuilder + { + return $this->withLocking(SelectBuilder::class, new LockingClause('LOCK IN SHARE MODE')); + } + + /** + * Append the given WITH queries to this select's WITH clause. + */ + public function appendWith(WithBuilder $with): SelectBuilder + { + return $this->withAppendedWith(SelectBuilder::class, $with->withQueryItems()); + } +} diff --git a/src/MariaDB/Builder/SelectSelectBuilder.php b/src/MariaDB/Builder/SelectSelectBuilder.php new file mode 100644 index 0000000..011ee32 --- /dev/null +++ b/src/MariaDB/Builder/SelectSelectBuilder.php @@ -0,0 +1,17 @@ +lastWindowDefinition(); + + return $this->deriveWindow(OrderByWindowSelectBuilder::class, new WindowDefinition( + $def->existingWindowName, + $def->partitionBy, + [...$def->orderBys, new OrderByClause($exp)], + $def->frame, + )); + } + + /** + * Bound the current window's frame in `ROWS` units. + */ + public function rows(FrameBound $start, ?FrameBound $end = null): WindowSelectBuilder + { + return $this->withWindowFrame(new WindowFrame(FrameUnits::Rows, $start, $end)); + } + + /** + * Bound the current window's frame in `RANGE` units. + */ + public function range(FrameBound $start, ?FrameBound $end = null): WindowSelectBuilder + { + return $this->withWindowFrame(new WindowFrame(FrameUnits::Range, $start, $end)); + } + + private function withWindowFrame(WindowFrame $frame): WindowSelectBuilder + { + $def = $this->lastWindowDefinition(); + + return $this->deriveWindow(WindowSelectBuilder::class, new WindowDefinition( + $def->existingWindowName, + $def->partitionBy, + $def->orderBys, + $frame, + )); + } + + protected function lastWindowDefinition(): WindowDefinition + { + $windows = $this->parts->windows; + $lastIdx = array_key_last($windows); + assert($lastIdx !== null); + + return $windows[$lastIdx]->definition; + } + + /** + * Return a builder of the given type with the last named window's definition + * replaced. + * + * @template T of AbstractSelectBuilder + * @param class-string $class + * @return T + */ + protected function deriveWindow(string $class, WindowDefinition $definition): AbstractSelectBuilder + { + $windows = $this->parts->windows; + $lastIdx = array_key_last($windows); + assert($lastIdx !== null); + + $windows[$lastIdx] = new NamedWindow($windows[$lastIdx]->name, $definition); + + return $this->derive($class, windows: $windows); + } +} diff --git a/src/MariaDB/Builder/WindowSelectBuilder.php b/src/MariaDB/Builder/WindowSelectBuilder.php new file mode 100644 index 0000000..83d5233 --- /dev/null +++ b/src/MariaDB/Builder/WindowSelectBuilder.php @@ -0,0 +1,41 @@ +lastWindowDefinition(); + + return $this->deriveWindow(self::class, new WindowDefinition($existingWindowName, $def->partitionBy, $def->orderBys, $def->frame)); + } + + public function partitionBy(Exp $exp, Exp ...$exps): self + { + $def = $this->lastWindowDefinition(); + + return $this->deriveWindow(self::class, new WindowDefinition( + $def->existingWindowName, + [...$def->partitionBy, $exp, ...array_values($exps)], + $def->orderBys, + $def->frame, + )); + } +} diff --git a/src/MariaDB/Builder/WithBuilder.php b/src/MariaDB/Builder/WithBuilder.php new file mode 100644 index 0000000..9243d83 --- /dev/null +++ b/src/MariaDB/Builder/WithBuilder.php @@ -0,0 +1,64 @@ + $withQueries + */ + public function __construct( + private readonly array $withQueries, + ) { + } + + /** + * The WITH query items, for appending to another select via + * {@see SelectBuilder::appendWith()}. + * + * @return list + */ + public function withQueryItems(): array + { + return $this->withQueries; + } + + /** + * Add another WITH query (its body is supplied via {@see WithWithBuilder::as()}). + */ + public function with(string $queryName): WithWithBuilder + { + return $this->startWithQuery($queryName, false); + } + + /** + * Add another WITH RECURSIVE query. + */ + public function withRecursive(string $queryName): WithWithBuilder + { + return $this->startWithQuery($queryName, true); + } + + private function startWithQuery(string $queryName, bool $recursive): WithWithBuilder + { + return new WithWithBuilder([...$this->withQueries, new WithQueryItem($recursive, $queryName)]); + } + + /** + * Start the SELECT statement following the WITH clause. + */ + public function select(Exp ...$exps): SelectSelectBuilder + { + return (new SelectBuilder(withQueries: $this->withQueries))->select(...$exps); + } +} diff --git a/src/MariaDB/Builder/WithWithBuilder.php b/src/MariaDB/Builder/WithWithBuilder.php new file mode 100644 index 0000000..8d80e23 --- /dev/null +++ b/src/MariaDB/Builder/WithWithBuilder.php @@ -0,0 +1,54 @@ + $withQueries + */ + public function __construct( + private readonly array $withQueries, + ) { + } + + /** + * Set the column names for the currently started WITH query. + */ + public function columnNames(string ...$names): self + { + $withQueries = $this->withQueries; + $lastIdx = array_key_last($withQueries); + assert($lastIdx !== null); + + $item = $withQueries[$lastIdx]; + $withQueries[$lastIdx] = new WithQueryItem($item->recursive, $item->queryName, array_values($names), $item->query); + + return new self($withQueries); + } + + /** + * Supply the body for the currently started WITH query. + */ + public function as(WithQuery $query): WithBuilder + { + $withQueries = $this->withQueries; + $lastIdx = array_key_last($withQueries); + assert($lastIdx !== null); + + $item = $withQueries[$lastIdx]; + $withQueries[$lastIdx] = new WithQueryItem($item->recursive, $item->queryName, $item->columnNames, $query); + + return new WithBuilder($withQueries); + } +} diff --git a/src/MariaDB/Q.php b/src/MariaDB/Q.php new file mode 100644 index 0000000..f0c5486 --- /dev/null +++ b/src/MariaDB/Q.php @@ -0,0 +1,51 @@ +select(...$exps); + } + + /** + * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. + */ + public static function with(string $queryName): WithWithBuilder + { + return new WithWithBuilder([new WithQueryItem(false, $queryName)]); + } + + /** + * Start a WITH RECURSIVE clause. + */ + public static function withRecursive(string $queryName): WithWithBuilder + { + return new WithWithBuilder([new WithQueryItem(true, $queryName)]); + } +} diff --git a/src/MySQL/BuildsExpressions.php b/src/MySQL/BuildsExpressions.php new file mode 100644 index 0000000..5e62df6 --- /dev/null +++ b/src/MySQL/BuildsExpressions.php @@ -0,0 +1,314 @@ + new Arg($a), array_values($arguments))); + } + + /** + * Combine the given expressions with AND. + */ + public static function and(Exp ...$exps): Junction + { + return Junction::and(...$exps); + } + + /** + * Combine the given expressions with OR. + */ + public static function or(Exp ...$exps): Junction + { + return Junction::or(...$exps); + } + + /** + * Negate an expression (`NOT ...`). + */ + public static function not(Exp $exp): UnaryExp + { + return new UnaryExp($exp, Precedence::of('NOT'), prefix: 'NOT'); + } + + /** + * Arithmetic negation (`- ...`) of a numeric expression. + */ + public static function neg(Exp $exp): UnaryExp + { + return new UnaryExp($exp, 5, prefix: '-'); // unary minus binds tightly + } + + /** + * A function-call expression, e.g. `Q::func('CONCAT', $a, $b)` for + * `CONCAT(a, b)`. Common functions also have dedicated helpers on `Q\Func`. + */ + public static function func(string $name, Exp ...$args): FuncExp + { + return new FuncExp($name, array_values($args)); + } + + /** + * A `CAST(expr AS type)` expression (e.g. `Q::cast($x, 'UNSIGNED')`). + */ + public static function cast(Exp $exp, string $type): CastExp + { + return new CastExp($exp, new TypeExp($type)); + } + + /** + * A `CONVERT(expr, type)` expression — the function-call form of a type cast. + */ + public static function convert(Exp $exp, string $type): ConvertExp + { + return new ConvertExp($exp, new TypeExp($type)); + } + + /** + * A temporal `INTERVAL expr unit` operand (e.g. `Q::interval(Q::int(1), 'DAY')` + * for `INTERVAL 1 DAY`), for date arithmetic and `Q\Func::dateAdd()` / `dateSub()`. + */ + public static function interval(Exp $expr, string $unit): IntervalExp + { + return new IntervalExp($expr, $unit); + } + + /** + * Build a `COALESCE(...)` expression. + */ + public static function coalesce(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('COALESCE', array_values([$exp, ...$rest])); + } + + /** + * Build a `NULLIF(a, b)` expression (returns NULL when the two are equal). + */ + public static function nullif(Exp $a, Exp $b): FuncExp + { + return new FuncExp('NULLIF', [$a, $b]); + } + + /** + * Build a `GREATEST(...)` expression (the largest of its arguments). + */ + public static function greatest(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('GREATEST', array_values([$exp, ...$rest])); + } + + /** + * Build a `LEAST(...)` expression (the smallest of its arguments). + */ + public static function least(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('LEAST', array_values([$exp, ...$rest])); + } + + /** + * Start a CASE expression (optionally with a leading expression to compare + * each WHEN against). + */ + public static function case(Exp ...$exp): CaseBuilder + { + return new CaseBuilder($exp[0] ?? null); + } + + // Window frame bounds (for `ROWS` / `RANGE` frame clauses) + + /** + * The `CURRENT ROW` frame bound. + */ + public static function currentRow(): FrameBound + { + return FrameBound::currentRow(); + } + + /** + * The `UNBOUNDED PRECEDING` frame bound (the start of the partition). + */ + public static function unboundedPreceding(): FrameBound + { + return FrameBound::unboundedPreceding(); + } + + /** + * The `UNBOUNDED FOLLOWING` frame bound (the end of the partition). + */ + public static function unboundedFollowing(): FrameBound + { + return FrameBound::unboundedFollowing(); + } + + /** + * An `expr PRECEDING` frame bound (the given offset before the current row). + */ + public static function preceding(Exp $offset): FrameBound + { + return FrameBound::preceding($offset); + } + + /** + * An `expr FOLLOWING` frame bound (the given offset after the current row). + */ + public static function following(Exp $offset): FrameBound + { + return FrameBound::following($offset); + } + + /** + * Start a new query builder for the given writer. + */ + public static function build(SqlWriter $writer): QueryBuilder + { + return QueryBuilder::build($writer); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 034b555..604b1eb 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -4,48 +4,28 @@ namespace Flowpack\QueryObjectBuilder\MySQL; -use Flowpack\QueryObjectBuilder\MySQL\Builder\Arg; -use Flowpack\QueryObjectBuilder\MySQL\Builder\BindExp; -use Flowpack\QueryObjectBuilder\MySQL\Builder\BoolLiteral; -use Flowpack\QueryObjectBuilder\MySQL\Builder\CaseBuilder; -use Flowpack\QueryObjectBuilder\MySQL\Builder\CastExp; -use Flowpack\QueryObjectBuilder\MySQL\Builder\ConvertExp; -use Flowpack\QueryObjectBuilder\MySQL\Builder\DefaultLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\DeleteBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; -use Flowpack\QueryObjectBuilder\MySQL\Builder\ExistsExp; -use Flowpack\QueryObjectBuilder\MySQL\Builder\Expressions; -use Flowpack\QueryObjectBuilder\MySQL\Builder\FloatLiteral; -use Flowpack\QueryObjectBuilder\MySQL\Builder\FrameBound; -use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IdentExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\InsertBuilder; -use Flowpack\QueryObjectBuilder\MySQL\Builder\IntervalExp; -use Flowpack\QueryObjectBuilder\MySQL\Builder\IntLiteral; -use Flowpack\QueryObjectBuilder\MySQL\Builder\Junction; -use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; -use Flowpack\QueryObjectBuilder\MySQL\Builder\Precedence; -use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\ReplaceBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectSelectBuilder; -use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter; -use Flowpack\QueryObjectBuilder\MySQL\Builder\StringLiteral; -use Flowpack\QueryObjectBuilder\MySQL\Builder\SubqueryExp; -use Flowpack\QueryObjectBuilder\MySQL\Builder\TypeExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\UpdateBuilder; -use Flowpack\QueryObjectBuilder\MySQL\Builder\UnaryExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithQueryItem; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithWithBuilder; /** * Entry point (facade) for building MySQL queries. * - * It exposes the builder package as a small set of static functions so the - * underlying builder types and interfaces don't have to be referenced directly. + * The dialect-agnostic expression surface lives in {@see BuildsExpressions}; this + * facade adds MySQL's statement entry points (SELECT, INSERT with the `new.col` + * upsert reference, REPLACE, UPDATE, DELETE and WITH). */ final class Q { + use BuildsExpressions; + private function __construct() { } @@ -114,274 +94,4 @@ public static function withRecursive(string $queryName): WithWithBuilder { return new WithWithBuilder([new WithQueryItem(true, $queryName)]); } - - /** - * An `EXISTS (subquery)` expression. - */ - public static function exists(SelectBuilder $subquery): ExistsExp - { - return new ExistsExp($subquery); - } - - /** - * An `ANY (...)` row/subquery comparison operand. - */ - public static function any(Exp $exp): SubqueryExp - { - return new SubqueryExp('ANY', $exp); - } - - /** - * An `ALL (...)` row/subquery comparison operand. - */ - public static function all(Exp $exp): SubqueryExp - { - return new SubqueryExp('ALL', $exp); - } - - /** - * Write the given name / identifier (validated when the query is built). - */ - public static function n(string $s): IdentExp - { - return IdentExp::n($s); - } - - /** - * A string literal. - */ - public static function string(string $s): StringLiteral - { - return new StringLiteral($s); - } - - /** - * An integer literal. - */ - public static function int(int $i): IntLiteral - { - return new IntLiteral($i); - } - - /** - * A floating-point literal. - */ - public static function float(float $f): FloatLiteral - { - return new FloatLiteral($f); - } - - /** - * A boolean literal (`TRUE` / `FALSE`). - */ - public static function bool(bool $b): BoolLiteral - { - return new BoolLiteral($b); - } - - /** - * The SQL `NULL` literal. - */ - public static function null(): NullLiteral - { - return new NullLiteral(); - } - - /** - * The SQL `DEFAULT` keyword, usable as a value in INSERT / UPDATE. - */ - public static function default(): DefaultLiteral - { - return new DefaultLiteral(); - } - - /** - * Create a bound argument expression (a positional `?` placeholder). - */ - public static function arg(mixed $argument): Arg - { - return new Arg($argument); - } - - /** - * A named argument placeholder; bind its value via - * {@see QueryBuilder::withNamedArgs()}. Each occurrence emits its own `?`. - */ - public static function bind(string $name): BindExp - { - return new BindExp($name); - } - - /** - * A parenthesized list of expressions, e.g. for `IN (...)`. - */ - public static function exps(Exp ...$exps): Expressions - { - return new Expressions(array_values($exps)); - } - - /** - * A parenthesized list of bound arguments, e.g. for `IN (?, ?, ?)`. - */ - public static function args(mixed ...$arguments): Expressions - { - return new Expressions(array_map(static fn (mixed $a): Arg => new Arg($a), array_values($arguments))); - } - - /** - * Combine the given expressions with AND. - */ - public static function and(Exp ...$exps): Junction - { - return Junction::and(...$exps); - } - - /** - * Combine the given expressions with OR. - */ - public static function or(Exp ...$exps): Junction - { - return Junction::or(...$exps); - } - - /** - * Negate an expression (`NOT ...`). - */ - public static function not(Exp $exp): UnaryExp - { - return new UnaryExp($exp, Precedence::of('NOT'), prefix: 'NOT'); - } - - /** - * Arithmetic negation (`- ...`) of a numeric expression. - */ - public static function neg(Exp $exp): UnaryExp - { - return new UnaryExp($exp, 5, prefix: '-'); // unary minus binds tightly - } - - /** - * A function-call expression, e.g. `Q::func('CONCAT', $a, $b)` for - * `CONCAT(a, b)`. Common functions also have dedicated helpers on `Q\Func`. - */ - public static function func(string $name, Exp ...$args): FuncExp - { - return new FuncExp($name, array_values($args)); - } - - /** - * A `CAST(expr AS type)` expression (e.g. `Q::cast($x, 'UNSIGNED')`). - */ - public static function cast(Exp $exp, string $type): CastExp - { - return new CastExp($exp, new TypeExp($type)); - } - - /** - * A `CONVERT(expr, type)` expression — the function-call form of a type cast. - */ - public static function convert(Exp $exp, string $type): ConvertExp - { - return new ConvertExp($exp, new TypeExp($type)); - } - - /** - * A temporal `INTERVAL expr unit` operand (e.g. `Q::interval(Q::int(1), 'DAY')` - * for `INTERVAL 1 DAY`), for date arithmetic and `Q\Func::dateAdd()` / `dateSub()`. - */ - public static function interval(Exp $expr, string $unit): IntervalExp - { - return new IntervalExp($expr, $unit); - } - - /** - * Build a `COALESCE(...)` expression. - */ - public static function coalesce(Exp $exp, Exp ...$rest): FuncExp - { - return new FuncExp('COALESCE', array_values([$exp, ...$rest])); - } - - /** - * Build a `NULLIF(a, b)` expression (returns NULL when the two are equal). - */ - public static function nullif(Exp $a, Exp $b): FuncExp - { - return new FuncExp('NULLIF', [$a, $b]); - } - - /** - * Build a `GREATEST(...)` expression (the largest of its arguments). - */ - public static function greatest(Exp $exp, Exp ...$rest): FuncExp - { - return new FuncExp('GREATEST', array_values([$exp, ...$rest])); - } - - /** - * Build a `LEAST(...)` expression (the smallest of its arguments). - */ - public static function least(Exp $exp, Exp ...$rest): FuncExp - { - return new FuncExp('LEAST', array_values([$exp, ...$rest])); - } - - /** - * Start a CASE expression (optionally with a leading expression to compare - * each WHEN against). - */ - public static function case(Exp ...$exp): CaseBuilder - { - return new CaseBuilder($exp[0] ?? null); - } - - // Window frame bounds (for `ROWS` / `RANGE` frame clauses) - - /** - * The `CURRENT ROW` frame bound. - */ - public static function currentRow(): FrameBound - { - return FrameBound::currentRow(); - } - - /** - * The `UNBOUNDED PRECEDING` frame bound (the start of the partition). - */ - public static function unboundedPreceding(): FrameBound - { - return FrameBound::unboundedPreceding(); - } - - /** - * The `UNBOUNDED FOLLOWING` frame bound (the end of the partition). - */ - public static function unboundedFollowing(): FrameBound - { - return FrameBound::unboundedFollowing(); - } - - /** - * An `expr PRECEDING` frame bound (the given offset before the current row). - */ - public static function preceding(Exp $offset): FrameBound - { - return FrameBound::preceding($offset); - } - - /** - * An `expr FOLLOWING` frame bound (the given offset after the current row). - */ - public static function following(Exp $offset): FrameBound - { - return FrameBound::following($offset); - } - - /** - * Start a new query builder for the given writer. - */ - public static function build(SqlWriter $writer): QueryBuilder - { - return QueryBuilder::build($writer); - } } diff --git a/tests/MariaDB/Q/SelectBuilderTest.php b/tests/MariaDB/Q/SelectBuilderTest.php new file mode 100644 index 0000000..a5c0d87 --- /dev/null +++ b/tests/MariaDB/Q/SelectBuilderTest.php @@ -0,0 +1,106 @@ +from(Q::n('orders')) + ->where(Q::n('id')->eq(Q::arg(1))) + )->toRenderSql('SELECT id,email FROM orders WHERE id = ?', [1]); + + // A reserved keyword used as an identifier is backtick-quoted. + expect(Q::select(Q::n('id'))->from(Q::n('order'))) + ->toRenderSql('SELECT id FROM `order`'); + }); + + it('aliases select expressions and from items', function () { + expect( + Q::select(Q::n('u.id'))->as('user_id') + ->from(Q::n('users'))->as('u') + )->toRenderSql('SELECT u.id AS user_id FROM users AS u'); + }); + + it('renders DISTINCT', function () { + expect( + Q::select(Q::n('country'))->distinct()->from(Q::n('users')) + )->toRenderSql('SELECT DISTINCT country FROM users'); + }); + + it('renders joins with ON and USING', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->leftJoin(Q::n('orders'))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) + )->toRenderSql('SELECT * FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id'); + + expect( + Q::select(Q::n('*'))->from(Q::n('a'))->join(Q::n('b'))->using('id') + )->toRenderSql('SELECT * FROM a JOIN b USING (id)'); + }); + + it('renders GROUP BY with rollup, HAVING and ORDER BY', function () { + expect( + Q::select(Q::n('country')) + ->from(Q::n('users')) + ->groupBy(Q::n('country'))->withRollup() + ->having(Q::n('country')->isNotNull()) + ->orderBy(Q::n('country'))->desc() + )->toRenderSql('SELECT country FROM users GROUP BY country WITH ROLLUP HAVING country IS NOT NULL ORDER BY country DESC'); + }); + + it('renders LIMIT and OFFSET', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->limit(Q::int(10))->offset(Q::int(20)) + )->toRenderSql('SELECT id FROM users LIMIT 10 OFFSET 20'); + }); + + it('renders locking clauses', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->forUpdate()->skipLocked() + )->toRenderSql('SELECT id FROM users FOR UPDATE SKIP LOCKED'); + + // MariaDB spells the shared lock as LOCK IN SHARE MODE. + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->forShare() + )->toRenderSql('SELECT id FROM users LOCK IN SHARE MODE'); + }); + + it('renders UNION and INTERSECT combinations', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('a')) + ->union()->all() + ->query(Q::select(Q::n('id'))->from(Q::n('b'))) + )->toRenderSql('SELECT id FROM a UNION ALL (SELECT id FROM b)'); + }); + + it('renders a CTE', function () { + expect( + Q::with('recent')->as(Q::select(Q::n('id'))->from(Q::n('orders'))) + ->select(Q::n('*'))->from(Q::n('recent')) + )->toRenderSql('WITH recent AS (SELECT id FROM orders) SELECT * FROM recent'); + }); + + it('renders EXISTS and IN with a subquery', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users')) + ->where(Q::exists(Q::select(Q::int(1))->from(Q::n('orders')))) + )->toRenderSql('SELECT id FROM users WHERE EXISTS (SELECT 1 FROM orders)'); + + expect( + Q::select(Q::n('id'))->from(Q::n('users')) + ->where(Q::n('id')->in(Q::select(Q::n('user_id'))->from(Q::n('orders')))) + )->toRenderSql('SELECT id FROM users WHERE id IN (SELECT user_id FROM orders)'); + }); + + it('renders a named WINDOW clause with a frame', function () { + expect( + Q::select(Q::n('val')) + ->from(Q::n('t')) + ->window('w')->as()->orderBy(Q::n('val'))->rows(Q::unboundedPreceding()) + )->toRenderSql('SELECT val FROM t WINDOW w AS (ORDER BY val ROWS UNBOUNDED PRECEDING)'); + }); +}); From 38088dcb7d210d5ff570cc06a8296b9e3688f2cd Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 10:11:21 +0200 Subject: [PATCH 19/40] wip: DML abstract bases (INSERT/REPLACE/DELETE) + MariaDB DML with RETURNING (VALUES upsert, no AS new); UPDATE shared --- src/MariaDB/Builder/DeleteBuilder.php | 61 +++++ src/MariaDB/Builder/InsertBuilder.php | 40 +++ src/MariaDB/Builder/JoinDeleteBuilder.php | 16 ++ .../OnDuplicateKeyUpdateInsertBuilder.php | 17 ++ src/MariaDB/Builder/OrderByDeleteBuilder.php | 16 ++ src/MariaDB/Builder/ReplaceBuilder.php | 29 +++ .../Builder/ReturningDeleteBuilder.php | 29 +++ .../Builder/ReturningInsertBuilder.php | 29 +++ .../Builder/ReturningReplaceBuilder.php | 29 +++ src/MariaDB/Q.php | 47 ++++ src/MySQL/Builder/AbstractDeleteBuilder.php | 203 ++++++++++++++++ src/MySQL/Builder/AbstractInsertBuilder.php | 229 ++++++++++++++++++ src/MySQL/Builder/AbstractReplaceBuilder.php | 186 ++++++++++++++ src/MySQL/Builder/AddsUpsertAssignment.php | 27 +++ src/MySQL/Builder/DeleteBuilder.php | 182 +------------- src/MySQL/Builder/InsertBuilder.php | 205 +--------------- src/MySQL/Builder/JoinDeleteBuilder.php | 52 +--- .../OnDuplicateKeyUpdateInsertBuilder.php | 15 +- src/MySQL/Builder/OrderByDeleteBuilder.php | 25 +- src/MySQL/Builder/OrdersLastDeleteTerm.php | 40 +++ src/MySQL/Builder/RefinesLastDeleteJoin.php | 64 +++++ src/MySQL/Builder/RendersReturning.php | 27 +++ src/MySQL/Builder/ReplaceBuilder.php | 159 +----------- src/MySQL/Builder/ReturningItem.php | 28 +++ tests/MariaDB/Q/DmlTest.php | 78 ++++++ 25 files changed, 1218 insertions(+), 615 deletions(-) create mode 100644 src/MariaDB/Builder/DeleteBuilder.php create mode 100644 src/MariaDB/Builder/InsertBuilder.php create mode 100644 src/MariaDB/Builder/JoinDeleteBuilder.php create mode 100644 src/MariaDB/Builder/OnDuplicateKeyUpdateInsertBuilder.php create mode 100644 src/MariaDB/Builder/OrderByDeleteBuilder.php create mode 100644 src/MariaDB/Builder/ReplaceBuilder.php create mode 100644 src/MariaDB/Builder/ReturningDeleteBuilder.php create mode 100644 src/MariaDB/Builder/ReturningInsertBuilder.php create mode 100644 src/MariaDB/Builder/ReturningReplaceBuilder.php create mode 100644 src/MySQL/Builder/AbstractDeleteBuilder.php create mode 100644 src/MySQL/Builder/AbstractInsertBuilder.php create mode 100644 src/MySQL/Builder/AbstractReplaceBuilder.php create mode 100644 src/MySQL/Builder/AddsUpsertAssignment.php create mode 100644 src/MySQL/Builder/OrdersLastDeleteTerm.php create mode 100644 src/MySQL/Builder/RefinesLastDeleteJoin.php create mode 100644 src/MySQL/Builder/RendersReturning.php create mode 100644 src/MySQL/Builder/ReturningItem.php create mode 100644 tests/MariaDB/Q/DmlTest.php diff --git a/src/MariaDB/Builder/DeleteBuilder.php b/src/MariaDB/Builder/DeleteBuilder.php new file mode 100644 index 0000000..502da76 --- /dev/null +++ b/src/MariaDB/Builder/DeleteBuilder.php @@ -0,0 +1,61 @@ +`), adding a single-table `RETURNING` clause. + */ +class DeleteBuilder extends AbstractDeleteBuilder +{ + public function join(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Inner, $from); + } + + public function leftJoin(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Left, $from); + } + + public function rightJoin(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Right, $from); + } + + public function crossJoin(FromExp $from): JoinDeleteBuilder + { + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Cross, $from); + } + + /** + * Add an ORDER BY expression (single-table delete only). Refine it via + * {@see OrderByDeleteBuilder}. + */ + public function orderBy(Exp $exp): OrderByDeleteBuilder + { + return $this->addOrderBy(OrderByDeleteBuilder::class, $exp); + } + + /** + * Add a RETURNING clause (single-table delete only). Refine the output name of + * the last expression via {@see ReturningDeleteBuilder::as()}. + */ + public function returning(Exp $outputExpression, Exp ...$exps): ReturningDeleteBuilder + { + $returningItems = $this->returningItems; + foreach ([$outputExpression, ...array_values($exps)] as $exp) { + $returningItems[] = new ReturningItem($exp); + } + + return $this->derive(ReturningDeleteBuilder::class, returningItems: $returningItems); + } +} diff --git a/src/MariaDB/Builder/InsertBuilder.php b/src/MariaDB/Builder/InsertBuilder.php new file mode 100644 index 0000000..17db9f9 --- /dev/null +++ b/src/MariaDB/Builder/InsertBuilder.php @@ -0,0 +1,40 @@ +derive(OnDuplicateKeyUpdateInsertBuilder::class); + } + + /** + * Add a RETURNING clause. Refine the output name of the last expression via + * {@see ReturningInsertBuilder::as()}. + */ + public function returning(Exp $outputExpression, Exp ...$exps): ReturningInsertBuilder + { + $returningItems = $this->returningItems; + foreach ([$outputExpression, ...array_values($exps)] as $exp) { + $returningItems[] = new ReturningItem($exp); + } + + return $this->derive(ReturningInsertBuilder::class, returningItems: $returningItems); + } +} diff --git a/src/MariaDB/Builder/JoinDeleteBuilder.php b/src/MariaDB/Builder/JoinDeleteBuilder.php new file mode 100644 index 0000000..a263ffb --- /dev/null +++ b/src/MariaDB/Builder/JoinDeleteBuilder.php @@ -0,0 +1,16 @@ +returningItems; + foreach ([$outputExpression, ...array_values($exps)] as $exp) { + $returningItems[] = new ReturningItem($exp); + } + + return $this->derive(ReturningReplaceBuilder::class, returningItems: $returningItems); + } +} diff --git a/src/MariaDB/Builder/ReturningDeleteBuilder.php b/src/MariaDB/Builder/ReturningDeleteBuilder.php new file mode 100644 index 0000000..dcb882d --- /dev/null +++ b/src/MariaDB/Builder/ReturningDeleteBuilder.php @@ -0,0 +1,29 @@ +returningItems; + $lastIdx = array_key_last($returningItems); + assert($lastIdx !== null); + + $item = $returningItems[$lastIdx]; + $returningItems[$lastIdx] = new ReturningItem($item->outputExpression, $outputName); + + return $this->derive(static::class, returningItems: $returningItems); + } +} diff --git a/src/MariaDB/Builder/ReturningInsertBuilder.php b/src/MariaDB/Builder/ReturningInsertBuilder.php new file mode 100644 index 0000000..7dcfa30 --- /dev/null +++ b/src/MariaDB/Builder/ReturningInsertBuilder.php @@ -0,0 +1,29 @@ +returningItems; + $lastIdx = array_key_last($returningItems); + assert($lastIdx !== null); + + $item = $returningItems[$lastIdx]; + $returningItems[$lastIdx] = new ReturningItem($item->outputExpression, $outputName); + + return $this->derive(static::class, returningItems: $returningItems); + } +} diff --git a/src/MariaDB/Builder/ReturningReplaceBuilder.php b/src/MariaDB/Builder/ReturningReplaceBuilder.php new file mode 100644 index 0000000..47fe646 --- /dev/null +++ b/src/MariaDB/Builder/ReturningReplaceBuilder.php @@ -0,0 +1,29 @@ +returningItems; + $lastIdx = array_key_last($returningItems); + assert($lastIdx !== null); + + $item = $returningItems[$lastIdx]; + $returningItems[$lastIdx] = new ReturningItem($item->outputExpression, $outputName); + + return $this->derive(static::class, returningItems: $returningItems); + } +} diff --git a/src/MariaDB/Q.php b/src/MariaDB/Q.php index f0c5486..d259f8d 100644 --- a/src/MariaDB/Q.php +++ b/src/MariaDB/Q.php @@ -4,10 +4,16 @@ namespace Flowpack\QueryObjectBuilder\MariaDB; +use Flowpack\QueryObjectBuilder\MariaDB\Builder\DeleteBuilder; +use Flowpack\QueryObjectBuilder\MariaDB\Builder\InsertBuilder; +use Flowpack\QueryObjectBuilder\MariaDB\Builder\ReplaceBuilder; use Flowpack\QueryObjectBuilder\MariaDB\Builder\SelectBuilder; use Flowpack\QueryObjectBuilder\MariaDB\Builder\SelectSelectBuilder; use Flowpack\QueryObjectBuilder\MariaDB\Builder\WithWithBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\IdentExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\UpdateBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithQueryItem; use Flowpack\QueryObjectBuilder\MySQL\BuildsExpressions; @@ -33,6 +39,47 @@ public static function select(Exp ...$exps): SelectSelectBuilder return (new SelectBuilder())->select(...$exps); } + /** + * Start an INSERT statement into the given table. + */ + public static function insertInto(IdentExp $tableName): InsertBuilder + { + return new InsertBuilder($tableName); + } + + /** + * Reference the value of `column` from the row that would have been inserted, + * for use inside `ON DUPLICATE KEY UPDATE` (rendered as `VALUES(column)`). + */ + public static function inserted(string $column): FuncExp + { + return new FuncExp('VALUES', [IdentExp::n($column)]); + } + + /** + * Start a REPLACE statement into the given table. + */ + public static function replaceInto(IdentExp $tableName): ReplaceBuilder + { + return new ReplaceBuilder($tableName); + } + + /** + * Start an UPDATE statement on the given table. + */ + public static function update(IdentExp $tableName): UpdateBuilder + { + return new UpdateBuilder($tableName); + } + + /** + * Start a DELETE statement on the given table. + */ + public static function deleteFrom(IdentExp $tableName): DeleteBuilder + { + return new DeleteBuilder($tableName); + } + /** * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. */ diff --git a/src/MySQL/Builder/AbstractDeleteBuilder.php b/src/MySQL/Builder/AbstractDeleteBuilder.php new file mode 100644 index 0000000..fd2e9f6 --- /dev/null +++ b/src/MySQL/Builder/AbstractDeleteBuilder.php @@ -0,0 +1,203 @@ + $withQueries the leading WITH clause, if any + * @param list $joins additional joined tables (multi-table delete) + * @param list $whereConjunction conditions joined with AND + * @param list $orderBys + * @param list $returningItems + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly array $withQueries = [], + protected readonly string $alias = '', + protected readonly array $joins = [], + protected readonly array $whereConjunction = [], + protected readonly array $orderBys = [], + protected readonly ?Exp $limit = null, + protected readonly array $returningItems = [], + ) { + } + + /** + * Set an alias for the target table. + */ + public function as(string $alias): static + { + return $this->derive(static::class, alias: $alias); + } + + /** + * Add a WHERE condition. Multiple calls are joined with AND. + */ + public function where(Exp $cond): static + { + return $this->derive(static::class, whereConjunction: [...$this->whereConjunction, $cond]); + } + + /** + * Limit the number of rows deleted (single-table delete only). + */ + public function limit(Exp $exp): static + { + return $this->derive(static::class, limit: $exp); + } + + /** + * @template T of AbstractDeleteBuilder + * @param class-string $class + * @return T + */ + protected function addJoin(string $class, JoinType $joinType, FromExp $from): AbstractDeleteBuilder + { + return $this->derive($class, joins: [...$this->joins, new FromItem(new Join($joinType, false, $from))]); + } + + /** + * @template T of AbstractDeleteBuilder + * @param class-string $class + * @return T + */ + protected function addOrderBy(string $class, Exp $exp): AbstractDeleteBuilder + { + return $this->derive($class, orderBys: [...$this->orderBys, new OrderByClause($exp)]); + } + + /** + * @template T of AbstractDeleteBuilder + * @param class-string $class + * @param list|null $joins + * @param list|null $whereConjunction + * @param list|null $orderBys + * @param list|null $returningItems + * @return T + */ + protected function derive( + string $class, + ?string $alias = null, + ?array $joins = null, + ?array $whereConjunction = null, + ?array $orderBys = null, + ?Exp $limit = null, + ?array $returningItems = null, + ): AbstractDeleteBuilder { + return new $class( + $this->tableName, + $this->withQueries, + $alias ?? $this->alias, + $joins ?? $this->joins, + $whereConjunction ?? $this->whereConjunction, + $orderBys ?? $this->orderBys, + $limit ?? $this->limit, + $returningItems ?? $this->returningItems, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + // ORDER BY / LIMIT / RETURNING bound or read a single target; they are not + // part of the multi-table grammar. + if ($this->joins !== [] && ($this->orderBys !== [] || $this->limit !== null || $this->returningItems !== [])) { + $sb->addError(new QueryBuilderException('delete: ORDER BY / LIMIT / RETURNING not allowed in a multi-table delete')); + + return; + } + + if ($this->withQueries !== []) { + $this->writeWithQueries($sb, $this->withQueries); + } + + if ($this->joins !== []) { + $this->writeMultiTable($sb); + + return; + } + + $sb->writeString('DELETE FROM '); + $this->tableName->writeSql($sb); + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + + $this->writeWhere($sb); + + if ($this->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->limit !== null) { + $sb->writeString(' LIMIT '); + $this->limit->writeSql($sb); + } + + if ($this->returningItems !== []) { + $this->writeReturning($sb, $this->returningItems); + } + } + + private function writeMultiTable(SqlBuilder $sb): void + { + // The target rows are named before FROM (`tbl.*`), the join graph after it. + $targetRef = ($this->alias !== '' ? $this->alias : $this->tableName->ident()) . '.*'; + $sb->writeString('DELETE '); + IdentExp::n($targetRef)->writeSql($sb); + + $sb->writeString(' FROM '); + $this->tableName->writeSql($sb); + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + foreach ($this->joins as $join) { + $sb->writeString(' '); + $join->writeSql($sb); + } + + $this->writeWhere($sb); + } + + private function writeWhere(SqlBuilder $sb): void + { + if ($this->whereConjunction !== []) { + $sb->writeString(' WHERE '); + Junction::and(...$this->whereConjunction)->writeSql($sb); + } + } +} diff --git a/src/MySQL/Builder/AbstractInsertBuilder.php b/src/MySQL/Builder/AbstractInsertBuilder.php new file mode 100644 index 0000000..582bdc0 --- /dev/null +++ b/src/MySQL/Builder/AbstractInsertBuilder.php @@ -0,0 +1,229 @@ + $columnNames + * @param list> $valueLists + * @param list $onDuplicateKeyUpdateSetItems + * @param list $returningItems + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly bool $ignore = false, + protected readonly array $columnNames = [], + protected readonly bool $defaultValues = false, + protected readonly array $valueLists = [], + protected readonly ?AbstractSelectBuilder $query = null, + protected readonly array $onDuplicateKeyUpdateSetItems = [], + protected readonly array $returningItems = [], + ) { + } + + /** + * Demote insert errors (e.g. duplicate-key, foreign-key) to warnings so the + * offending rows are skipped instead of aborting the statement. + */ + public function ignore(): static + { + return $this->derive(static::class, ignore: true); + } + + /** + * Set the column names to insert into. + */ + public function columnNames(string $columnName, string ...$rest): static + { + return $this->derive(static::class, columnNames: array_values([$columnName, ...$rest])); + } + + /** + * Insert a row consisting entirely of default values, rendered as `() VALUES ()`. + * Calling {@see values()} afterwards overrules this. + */ + public function defaultValues(): static + { + return $this->derive(static::class, defaultValues: true); + } + + /** + * Append a row of values to insert. Call multiple times to insert several rows. + */ + public function values(Exp ...$values): static + { + return $this->derive(static::class, valueLists: [...$this->valueLists, array_values($values)]); + } + + /** + * Set the column names and values from the given map (column name => value). + * Values are bound as arguments and the column order is stable (sorted by name). + * Overwrites any previous column names and values. + * + * @param array $map + */ + public function setMap(array $map): static + { + ksort($map, SORT_STRING); + + $values = []; + foreach ($map as $value) { + $values[] = new Arg($value); + } + + return $this->derive(static::class, columnNames: array_keys($map), valueLists: [$values]); + } + + /** + * Insert the result of the given select query. + */ + public function query(AbstractSelectBuilder $query): static + { + return $this->derive(static::class, query: $query); + } + + /** + * Assemble a new builder of the given type with the given fields replaced; a + * null argument keeps the current value. + * + * @template T of AbstractInsertBuilder + * @param class-string $class + * @param list|null $columnNames + * @param list>|null $valueLists + * @param list|null $onDuplicateKeyUpdateSetItems + * @param list|null $returningItems + * @return T + */ + protected function derive( + string $class, + ?bool $ignore = null, + ?array $columnNames = null, + ?bool $defaultValues = null, + ?array $valueLists = null, + ?AbstractSelectBuilder $query = null, + ?array $onDuplicateKeyUpdateSetItems = null, + ?array $returningItems = null, + ): AbstractInsertBuilder { + return new $class( + $this->tableName, + $ignore ?? $this->ignore, + $columnNames ?? $this->columnNames, + $defaultValues ?? $this->defaultValues, + $valueLists ?? $this->valueLists, + $query ?? $this->query, + $onDuplicateKeyUpdateSetItems ?? $this->onDuplicateKeyUpdateSetItems, + $returningItems ?? $this->returningItems, + ); + } + + /** + * Emit the proposed-row alias that precedes `ON DUPLICATE KEY UPDATE`, if the + * dialect uses one. Called only when there are upsert assignments. + */ + protected function writeUpsertRowAlias(SqlBuilder $sb): void + { + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + $sb->writeString($this->ignore ? 'INSERT IGNORE INTO ' : 'INSERT INTO '); + $this->tableName->writeSql($sb); + + if ($this->valueLists !== [] && $this->query !== null) { + $sb->addError(new QueryBuilderException('insert: cannot set both values and query')); + + return; + } + + // A row of all defaults is the empty column list with an empty value row. + if ($this->defaultValues) { + $sb->writeString(' () VALUES ()'); + } else { + $this->writeColumnNames($sb); + $this->writeSource($sb); + } + + if ($this->onDuplicateKeyUpdateSetItems !== []) { + $this->writeUpsertRowAlias($sb); + + $sb->writeString(' ON DUPLICATE KEY UPDATE '); + foreach ($this->onDuplicateKeyUpdateSetItems as $i => $item) { + if ($i > 0) { + $sb->writeString(','); + } + $item->writeSql($sb); + } + } + + if ($this->returningItems !== []) { + $this->writeReturning($sb, $this->returningItems); + } + } + + private function writeColumnNames(SqlBuilder $sb): void + { + if ($this->columnNames === []) { + return; + } + + $s = ' ('; + foreach ($this->columnNames as $i => $columnName) { + $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); + } + $sb->writeString($s . ')'); + } + + private function writeSource(SqlBuilder $sb): void + { + if ($this->query !== null) { + $sb->writeString(' '); + $this->query->innerWriteSql($sb); + + return; + } + + if ($this->valueLists === []) { + return; + } + + $sb->writeString(' VALUES '); + foreach ($this->valueLists as $i => $valueList) { + $sb->writeString(($i > 0 ? ',' : '') . '('); + foreach ($valueList as $j => $value) { + if ($j > 0) { + $sb->writeString(','); + } + $value->writeSql($sb); + } + $sb->writeString(')'); + } + } +} diff --git a/src/MySQL/Builder/AbstractReplaceBuilder.php b/src/MySQL/Builder/AbstractReplaceBuilder.php new file mode 100644 index 0000000..c954f9c --- /dev/null +++ b/src/MySQL/Builder/AbstractReplaceBuilder.php @@ -0,0 +1,186 @@ + $columnNames + * @param list> $valueLists + * @param list $returningItems + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly array $columnNames = [], + protected readonly bool $defaultValues = false, + protected readonly array $valueLists = [], + protected readonly ?AbstractSelectBuilder $query = null, + protected readonly array $returningItems = [], + ) { + } + + /** + * Set the column names to replace into. + */ + public function columnNames(string $columnName, string ...$rest): static + { + return $this->derive(static::class, columnNames: array_values([$columnName, ...$rest])); + } + + /** + * Replace with a row consisting entirely of default values, rendered as + * `() VALUES ()`. Calling {@see values()} afterwards overrules this. + */ + public function defaultValues(): static + { + return $this->derive(static::class, defaultValues: true); + } + + /** + * Append a row of values. Call multiple times to replace several rows. + */ + public function values(Exp ...$values): static + { + return $this->derive(static::class, valueLists: [...$this->valueLists, array_values($values)]); + } + + /** + * Set the column names and values from the given map (column name => value). + * Values are bound as arguments and the column order is stable (sorted by name). + * Overwrites any previous column names and values. + * + * @param array $map + */ + public function setMap(array $map): static + { + ksort($map, SORT_STRING); + + $values = []; + foreach ($map as $value) { + $values[] = new Arg($value); + } + + return $this->derive(static::class, columnNames: array_keys($map), valueLists: [$values]); + } + + /** + * Replace with the result of the given select query. + */ + public function query(AbstractSelectBuilder $query): static + { + return $this->derive(static::class, query: $query); + } + + /** + * @template T of AbstractReplaceBuilder + * @param class-string $class + * @param list|null $columnNames + * @param list>|null $valueLists + * @param list|null $returningItems + * @return T + */ + protected function derive( + string $class, + ?array $columnNames = null, + ?bool $defaultValues = null, + ?array $valueLists = null, + ?AbstractSelectBuilder $query = null, + ?array $returningItems = null, + ): AbstractReplaceBuilder { + return new $class( + $this->tableName, + $columnNames ?? $this->columnNames, + $defaultValues ?? $this->defaultValues, + $valueLists ?? $this->valueLists, + $query ?? $this->query, + $returningItems ?? $this->returningItems, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + $sb->writeString('REPLACE INTO '); + $this->tableName->writeSql($sb); + + if ($this->valueLists !== [] && $this->query !== null) { + $sb->addError(new QueryBuilderException('replace: cannot set both values and query')); + + return; + } + + if ($this->defaultValues) { + $sb->writeString(' () VALUES ()'); + } else { + $this->writeColumnNames($sb); + $this->writeSource($sb); + } + + if ($this->returningItems !== []) { + $this->writeReturning($sb, $this->returningItems); + } + } + + private function writeColumnNames(SqlBuilder $sb): void + { + if ($this->columnNames === []) { + return; + } + + $s = ' ('; + foreach ($this->columnNames as $i => $columnName) { + $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); + } + $sb->writeString($s . ')'); + } + + private function writeSource(SqlBuilder $sb): void + { + if ($this->query !== null) { + $sb->writeString(' '); + $this->query->innerWriteSql($sb); + + return; + } + + if ($this->valueLists === []) { + return; + } + + $sb->writeString(' VALUES '); + foreach ($this->valueLists as $i => $valueList) { + $sb->writeString(($i > 0 ? ',' : '') . '('); + foreach ($valueList as $j => $value) { + if ($j > 0) { + $sb->writeString(','); + } + $value->writeSql($sb); + } + $sb->writeString(')'); + } + } +} diff --git a/src/MySQL/Builder/AddsUpsertAssignment.php b/src/MySQL/Builder/AddsUpsertAssignment.php new file mode 100644 index 0000000..758d8b0 --- /dev/null +++ b/src/MySQL/Builder/AddsUpsertAssignment.php @@ -0,0 +1,27 @@ +derive(static::class, onDuplicateKeyUpdateSetItems: [ + ...$this->onDuplicateKeyUpdateSetItems, + new UpdateSetItem($columnName, $value), + ]); + } +} diff --git a/src/MySQL/Builder/DeleteBuilder.php b/src/MySQL/Builder/DeleteBuilder.php index 90ea91a..295734a 100644 --- a/src/MySQL/Builder/DeleteBuilder.php +++ b/src/MySQL/Builder/DeleteBuilder.php @@ -5,46 +5,11 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds a DELETE statement. - * - * A single-table delete renders `DELETE FROM tbl ...` and may carry `ORDER BY` - * and `LIMIT`. Joining further tables via {@see join()} turns it into a - * multi-table delete (`DELETE tbl.* FROM tbl JOIN ...`), where those clauses are - * not allowed; the joined tables only restrict which rows of the target are - * deleted. - * - * Immutable: every method returns a new instance and the receiver is never - * modified. A derived copy is assembled only by {@see derive()}. + * Builds a MySQL DELETE statement (single-table or, via {@see join()}, multi-table + * `DELETE tbl.* FROM `). */ -class DeleteBuilder implements InnerSqlWriter +class DeleteBuilder extends AbstractDeleteBuilder { - use RendersWithQueries; - - /** - * @param list $withQueries the leading WITH clause, if any - * @param list $joins additional joined tables (multi-table delete) - * @param list $whereConjunction conditions joined with AND - * @param list $orderBys - */ - public function __construct( - protected readonly IdentExp $tableName, - protected readonly array $withQueries = [], - protected readonly string $alias = '', - protected readonly array $joins = [], - protected readonly array $whereConjunction = [], - protected readonly array $orderBys = [], - protected readonly ?Exp $limit = null, - ) { - } - - /** - * Set an alias for the target table. - */ - public function as(string $alias): self - { - return $this->derive(self::class, alias: $alias); - } - /** * Join another table, turning this into a multi-table delete. Refine it via * {@see JoinDeleteBuilder::on()} / {@see JoinDeleteBuilder::using()} / @@ -52,35 +17,22 @@ public function as(string $alias): self */ public function join(FromExp $from): JoinDeleteBuilder { - return $this->addJoin(JoinType::Inner, $from); + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Inner, $from); } public function leftJoin(FromExp $from): JoinDeleteBuilder { - return $this->addJoin(JoinType::Left, $from); + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Left, $from); } public function rightJoin(FromExp $from): JoinDeleteBuilder { - return $this->addJoin(JoinType::Right, $from); + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Right, $from); } public function crossJoin(FromExp $from): JoinDeleteBuilder { - return $this->addJoin(JoinType::Cross, $from); - } - - private function addJoin(JoinType $joinType, FromExp $from): JoinDeleteBuilder - { - return $this->derive(JoinDeleteBuilder::class, joins: [...$this->joins, new FromItem(new Join($joinType, false, $from))]); - } - - /** - * Add a WHERE condition. Multiple calls are joined with AND. - */ - public function where(Exp $cond): self - { - return $this->derive(self::class, whereConjunction: [...$this->whereConjunction, $cond]); + return $this->addJoin(JoinDeleteBuilder::class, JoinType::Cross, $from); } /** @@ -89,124 +41,6 @@ public function where(Exp $cond): self */ public function orderBy(Exp $exp): OrderByDeleteBuilder { - return $this->derive(OrderByDeleteBuilder::class, orderBys: [...$this->orderBys, new OrderByClause($exp)]); - } - - /** - * Limit the number of rows deleted (single-table delete only). - */ - public function limit(Exp $exp): self - { - return $this->derive(self::class, limit: $exp); - } - - /** - * @template T of DeleteBuilder - * @param class-string $class - * @param list|null $joins - * @param list|null $whereConjunction - * @param list|null $orderBys - * @return T - */ - protected function derive( - string $class, - ?string $alias = null, - ?array $joins = null, - ?array $whereConjunction = null, - ?array $orderBys = null, - ?Exp $limit = null, - ): DeleteBuilder { - return new $class( - $this->tableName, - $this->withQueries, - $alias ?? $this->alias, - $joins ?? $this->joins, - $whereConjunction ?? $this->whereConjunction, - $orderBys ?? $this->orderBys, - $limit ?? $this->limit, - ); - } - - /** - * @internal - */ - public function writeSql(SqlBuilder $sb): void - { - $this->innerWriteSql($sb); - } - - /** - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - // ORDER BY / LIMIT bound which rows a single-table delete touches; they are - // not part of the multi-table grammar. - if ($this->joins !== [] && ($this->orderBys !== [] || $this->limit !== null)) { - $sb->addError(new QueryBuilderException('delete: ORDER BY / LIMIT not allowed in a multi-table delete')); - - return; - } - - if ($this->withQueries !== []) { - $this->writeWithQueries($sb, $this->withQueries); - } - - if ($this->joins !== []) { - $this->writeMultiTable($sb); - - return; - } - - $sb->writeString('DELETE FROM '); - $this->tableName->writeSql($sb); - if ($this->alias !== '') { - $sb->writeString(' AS ' . $this->alias); - } - - $this->writeWhere($sb); - - if ($this->orderBys !== []) { - $sb->writeString(' ORDER BY '); - foreach ($this->orderBys as $i => $clause) { - if ($i > 0) { - $sb->writeString(','); - } - $clause->writeSql($sb); - } - } - - if ($this->limit !== null) { - $sb->writeString(' LIMIT '); - $this->limit->writeSql($sb); - } - } - - private function writeMultiTable(SqlBuilder $sb): void - { - // The target rows are named before FROM (`tbl.*`), the join graph after it. - $targetRef = ($this->alias !== '' ? $this->alias : $this->tableName->ident()) . '.*'; - $sb->writeString('DELETE '); - IdentExp::n($targetRef)->writeSql($sb); - - $sb->writeString(' FROM '); - $this->tableName->writeSql($sb); - if ($this->alias !== '') { - $sb->writeString(' AS ' . $this->alias); - } - foreach ($this->joins as $join) { - $sb->writeString(' '); - $join->writeSql($sb); - } - - $this->writeWhere($sb); - } - - private function writeWhere(SqlBuilder $sb): void - { - if ($this->whereConjunction !== []) { - $sb->writeString(' WHERE '); - Junction::and(...$this->whereConjunction)->writeSql($sb); - } + return $this->addOrderBy(OrderByDeleteBuilder::class, $exp); } } diff --git a/src/MySQL/Builder/InsertBuilder.php b/src/MySQL/Builder/InsertBuilder.php index 1682646..2f9b4a1 100644 --- a/src/MySQL/Builder/InsertBuilder.php +++ b/src/MySQL/Builder/InsertBuilder.php @@ -5,93 +5,15 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds an INSERT statement. - * - * Immutable: every method returns a new instance and the receiver is never - * modified. A derived copy is assembled only by {@see derive()}. + * Builds a MySQL INSERT statement. Adds the `ON DUPLICATE KEY UPDATE` entry point + * and emits the `AS new` proposed-row alias that makes `Q::inserted('col')` + * (`new.col`) reachable in the upsert assignments. */ -class InsertBuilder implements InnerSqlWriter +class InsertBuilder extends AbstractInsertBuilder { - /** - * @param list $columnNames - * @param list> $valueLists - * @param list $onDuplicateKeyUpdateSetItems - */ - public function __construct( - protected readonly IdentExp $tableName, - protected readonly bool $ignore = false, - protected readonly array $columnNames = [], - protected readonly bool $defaultValues = false, - protected readonly array $valueLists = [], - protected readonly ?SelectBuilder $query = null, - protected readonly array $onDuplicateKeyUpdateSetItems = [], - ) { - } - - /** - * Demote insert errors (e.g. duplicate-key, foreign-key) to warnings so the - * offending rows are skipped instead of aborting the statement. - */ - public function ignore(): self - { - return $this->derive(self::class, ignore: true); - } - - /** - * Set the column names to insert into. - */ - public function columnNames(string $columnName, string ...$rest): self - { - return $this->derive(self::class, columnNames: array_values([$columnName, ...$rest])); - } - - /** - * Insert a row consisting entirely of default values, rendered as `() VALUES ()`. - * Calling {@see values()} afterwards overrules this. - */ - public function defaultValues(): self - { - return $this->derive(self::class, defaultValues: true); - } - - /** - * Append a row of values to insert. Call multiple times to insert several rows. - */ - public function values(Exp ...$values): self - { - return $this->derive(self::class, valueLists: [...$this->valueLists, array_values($values)]); - } - - /** - * Set the column names and values from the given map (column name => value). - * Values are bound as arguments and the column order is stable (sorted by name). - * Overwrites any previous column names and values. - * - * @param array $map - */ - public function setMap(array $map): self - { - ksort($map, SORT_STRING); - - $values = []; - foreach ($map as $value) { - $values[] = new Arg($value); - } - - return $this->derive(self::class, columnNames: array_keys($map), valueLists: [$values]); - } - - /** - * Insert the result of the given select query. - */ - public function query(SelectBuilder $query): self - { - return $this->derive(self::class, query: $query); - } - /** * Add an `ON DUPLICATE KEY UPDATE` clause. Reference the row that would have - * been inserted via `Q::inserted('col')`. + * been inserted via `Q::inserted('col')` (rendered as `new.col`). */ public function onDuplicateKeyUpdate(): OnDuplicateKeyUpdateInsertBuilder { @@ -99,124 +21,13 @@ public function onDuplicateKeyUpdate(): OnDuplicateKeyUpdateInsertBuilder } /** - * Assemble a new builder of the given type with the given fields replaced; a - * null argument keeps the current value. - * - * @template T of InsertBuilder - * @param class-string $class - * @param list|null $columnNames - * @param list>|null $valueLists - * @param list|null $onDuplicateKeyUpdateSetItems - * @return T + * The `AS new` row alias follows the value rows so the proposed row is reachable + * as `new.col`. It is not used with the `INSERT ... SELECT` source form. */ - protected function derive( - string $class, - ?bool $ignore = null, - ?array $columnNames = null, - ?bool $defaultValues = null, - ?array $valueLists = null, - ?SelectBuilder $query = null, - ?array $onDuplicateKeyUpdateSetItems = null, - ): InsertBuilder { - return new $class( - $this->tableName, - $ignore ?? $this->ignore, - $columnNames ?? $this->columnNames, - $defaultValues ?? $this->defaultValues, - $valueLists ?? $this->valueLists, - $query ?? $this->query, - $onDuplicateKeyUpdateSetItems ?? $this->onDuplicateKeyUpdateSetItems, - ); - } - - /** - * @internal - */ - public function writeSql(SqlBuilder $sb): void - { - $this->innerWriteSql($sb); - } - - /** - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - $sb->writeString($this->ignore ? 'INSERT IGNORE INTO ' : 'INSERT INTO '); - $this->tableName->writeSql($sb); - - if ($this->valueLists !== [] && $this->query !== null) { - $sb->addError(new QueryBuilderException('insert: cannot set both values and query')); - - return; - } - - // A row of all defaults is the empty column list with an empty value row. - if ($this->defaultValues) { - $sb->writeString(' () VALUES ()'); - } else { - $this->writeColumnNames($sb); - $this->writeSource($sb); - } - - if ($this->onDuplicateKeyUpdateSetItems !== []) { - $this->writeOnDuplicateKeyUpdate($sb); - } - } - - private function writeColumnNames(SqlBuilder $sb): void - { - if ($this->columnNames === []) { - return; - } - - $s = ' ('; - foreach ($this->columnNames as $i => $columnName) { - $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); - } - $sb->writeString($s . ')'); - } - - private function writeSource(SqlBuilder $sb): void - { - if ($this->query !== null) { - $sb->writeString(' '); - $this->query->innerWriteSql($sb); - - return; - } - - if ($this->valueLists === []) { - return; - } - - $sb->writeString(' VALUES '); - foreach ($this->valueLists as $i => $valueList) { - $sb->writeString(($i > 0 ? ',' : '') . '('); - foreach ($valueList as $j => $value) { - if ($j > 0) { - $sb->writeString(','); - } - $value->writeSql($sb); - } - $sb->writeString(')'); - } - } - - private function writeOnDuplicateKeyUpdate(SqlBuilder $sb): void + protected function writeUpsertRowAlias(SqlBuilder $sb): void { - // The `AS new` row alias makes the proposed row reachable as `new.col` in - // the update assignments (see `Q::inserted()`); it follows the value rows. if ($this->query === null) { $sb->writeString(' AS new'); } - - $sb->writeString(' ON DUPLICATE KEY UPDATE '); - foreach ($this->onDuplicateKeyUpdateSetItems as $i => $item) { - if ($i > 0) { - $sb->writeString(','); - } - $item->writeSql($sb); - } } } diff --git a/src/MySQL/Builder/JoinDeleteBuilder.php b/src/MySQL/Builder/JoinDeleteBuilder.php index ee468df..9f7e44f 100644 --- a/src/MySQL/Builder/JoinDeleteBuilder.php +++ b/src/MySQL/Builder/JoinDeleteBuilder.php @@ -10,55 +10,5 @@ */ final class JoinDeleteBuilder extends DeleteBuilder { - /** - * Set the alias for the last added join. - */ - public function as(string $alias): self - { - return $this->derive(self::class, joins: $this->rebuildLastJoin(alias: $alias)); - } - - /** - * Set the ON condition for the last added join. - */ - public function on(Exp $cond): DeleteBuilder - { - return $this->derive(DeleteBuilder::class, joins: $this->rebuildLastJoin(on: $cond)); - } - - /** - * Set the USING columns for the last added join. - */ - public function using(string ...$columns): DeleteBuilder - { - return $this->derive(DeleteBuilder::class, joins: $this->rebuildLastJoin(using: array_values($columns))); - } - - /** - * Return the join list with the last join replaced by a copy carrying the given - * overrides. - * - * @param list|null $using - * @return list - */ - private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array - { - $joins = $this->joins; - $lastIdx = array_key_last($joins); - assert($lastIdx !== null); - - $join = $joins[$lastIdx]->from; - assert($join instanceof Join); - - $joins[$lastIdx] = new FromItem(new Join( - $join->joinType, - $join->lateral, - $join->from, - $alias ?? $join->alias, - $on ?? $join->on, - $using ?? $join->using, - )); - - return $joins; - } + use RefinesLastDeleteJoin; } diff --git a/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php b/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php index 152cdca..566fdec 100644 --- a/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php +++ b/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php @@ -6,19 +6,10 @@ /** * The INSERT builder state inside an `ON DUPLICATE KEY UPDATE` clause, where - * {@see set()} adds the assignments applied when a unique key already exists. + * {@see AddsUpsertAssignment::set()} adds the assignments applied when a unique + * key already exists. */ final class OnDuplicateKeyUpdateInsertBuilder extends InsertBuilder { - /** - * Add a `column = value` assignment. Reference the row that would have been - * inserted via `Q::inserted('col')`. - */ - public function set(string $columnName, Exp $value): self - { - return $this->derive(self::class, onDuplicateKeyUpdateSetItems: [ - ...$this->onDuplicateKeyUpdateSetItems, - new UpdateSetItem($columnName, $value), - ]); - } + use AddsUpsertAssignment; } diff --git a/src/MySQL/Builder/OrderByDeleteBuilder.php b/src/MySQL/Builder/OrderByDeleteBuilder.php index eab6cc7..ef6b14c 100644 --- a/src/MySQL/Builder/OrderByDeleteBuilder.php +++ b/src/MySQL/Builder/OrderByDeleteBuilder.php @@ -10,28 +10,5 @@ */ final class OrderByDeleteBuilder extends DeleteBuilder { - public function asc(): self - { - return $this->derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); - } - - public function desc(): self - { - return $this->derive(self::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); - } - - /** - * @return list - */ - private function rebuildLastOrderBy(SortOrder $order): array - { - $orderBys = $this->orderBys; - $lastIdx = array_key_last($orderBys); - assert($lastIdx !== null); - - $clause = $orderBys[$lastIdx]; - $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); - - return $orderBys; - } + use OrdersLastDeleteTerm; } diff --git a/src/MySQL/Builder/OrdersLastDeleteTerm.php b/src/MySQL/Builder/OrdersLastDeleteTerm.php new file mode 100644 index 0000000..4db3c8b --- /dev/null +++ b/src/MySQL/Builder/OrdersLastDeleteTerm.php @@ -0,0 +1,40 @@ +derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); + } + + public function desc(): static + { + return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); + } + + /** + * @return list + */ + private function rebuildLastOrderBy(SortOrder $order): array + { + $orderBys = $this->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $orderBys; + } +} diff --git a/src/MySQL/Builder/RefinesLastDeleteJoin.php b/src/MySQL/Builder/RefinesLastDeleteJoin.php new file mode 100644 index 0000000..e3db605 --- /dev/null +++ b/src/MySQL/Builder/RefinesLastDeleteJoin.php @@ -0,0 +1,64 @@ +derive(static::class, joins: $this->rebuildLastJoin(alias: $alias)); + } + + /** + * Set the ON condition for the last added join. + */ + public function on(Exp $cond): static + { + return $this->derive(static::class, joins: $this->rebuildLastJoin(on: $cond)); + } + + /** + * Set the USING columns for the last added join. + */ + public function using(string ...$columns): static + { + return $this->derive(static::class, joins: $this->rebuildLastJoin(using: array_values($columns))); + } + + /** + * @param list|null $using + * @return list + */ + private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array + { + $joins = $this->joins; + $lastIdx = array_key_last($joins); + assert($lastIdx !== null); + + $join = $joins[$lastIdx]->from; + assert($join instanceof Join); + + $joins[$lastIdx] = new FromItem(new Join( + $join->joinType, + $join->lateral, + $join->from, + $alias ?? $join->alias, + $on ?? $join->on, + $using ?? $join->using, + )); + + return $joins; + } +} diff --git a/src/MySQL/Builder/RendersReturning.php b/src/MySQL/Builder/RendersReturning.php new file mode 100644 index 0000000..ac73661 --- /dev/null +++ b/src/MySQL/Builder/RendersReturning.php @@ -0,0 +1,27 @@ + $returningItems + */ + protected function writeReturning(SqlBuilder $sb, array $returningItems): void + { + $sb->writeString(' RETURNING '); + foreach ($returningItems as $i => $item) { + if ($i > 0) { + $sb->writeString(','); + } + $item->writeSql($sb); + } + } +} diff --git a/src/MySQL/Builder/ReplaceBuilder.php b/src/MySQL/Builder/ReplaceBuilder.php index 4b7c0bb..fc280a0 100644 --- a/src/MySQL/Builder/ReplaceBuilder.php +++ b/src/MySQL/Builder/ReplaceBuilder.php @@ -5,163 +5,8 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds a REPLACE statement: like INSERT, but an existing row with the same - * primary or unique key is deleted before the new row is inserted. - * - * Immutable: every method returns a new instance and the receiver is never - * modified. A derived copy is assembled only by {@see derive()}. + * Builds a MySQL REPLACE statement. */ -final class ReplaceBuilder implements InnerSqlWriter +final class ReplaceBuilder extends AbstractReplaceBuilder { - /** - * @param list $columnNames - * @param list> $valueLists - */ - public function __construct( - private readonly IdentExp $tableName, - private readonly array $columnNames = [], - private readonly bool $defaultValues = false, - private readonly array $valueLists = [], - private readonly ?SelectBuilder $query = null, - ) { - } - - /** - * Set the column names to replace into. - */ - public function columnNames(string $columnName, string ...$rest): self - { - return $this->derive(columnNames: array_values([$columnName, ...$rest])); - } - - /** - * Replace with a row consisting entirely of default values, rendered as - * `() VALUES ()`. Calling {@see values()} afterwards overrules this. - */ - public function defaultValues(): self - { - return $this->derive(defaultValues: true); - } - - /** - * Append a row of values. Call multiple times to replace several rows. - */ - public function values(Exp ...$values): self - { - return $this->derive(valueLists: [...$this->valueLists, array_values($values)]); - } - - /** - * Set the column names and values from the given map (column name => value). - * Values are bound as arguments and the column order is stable (sorted by name). - * Overwrites any previous column names and values. - * - * @param array $map - */ - public function setMap(array $map): self - { - ksort($map, SORT_STRING); - - $values = []; - foreach ($map as $value) { - $values[] = new Arg($value); - } - - return $this->derive(columnNames: array_keys($map), valueLists: [$values]); - } - - /** - * Replace with the result of the given select query. - */ - public function query(SelectBuilder $query): self - { - return $this->derive(query: $query); - } - - /** - * @param list|null $columnNames - * @param list>|null $valueLists - */ - private function derive( - ?array $columnNames = null, - ?bool $defaultValues = null, - ?array $valueLists = null, - ?SelectBuilder $query = null, - ): self { - return new self( - $this->tableName, - $columnNames ?? $this->columnNames, - $defaultValues ?? $this->defaultValues, - $valueLists ?? $this->valueLists, - $query ?? $this->query, - ); - } - - /** - * @internal - */ - public function writeSql(SqlBuilder $sb): void - { - $this->innerWriteSql($sb); - } - - /** - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - $sb->writeString('REPLACE INTO '); - $this->tableName->writeSql($sb); - - if ($this->valueLists !== [] && $this->query !== null) { - $sb->addError(new QueryBuilderException('replace: cannot set both values and query')); - - return; - } - - // A row of all defaults is the empty column list with an empty value row. - if ($this->defaultValues) { - $sb->writeString(' () VALUES ()'); - - return; - } - - $this->writeColumnNames($sb); - - if ($this->query !== null) { - $sb->writeString(' '); - $this->query->innerWriteSql($sb); - - return; - } - - if ($this->valueLists === []) { - return; - } - - $sb->writeString(' VALUES '); - foreach ($this->valueLists as $i => $valueList) { - $sb->writeString(($i > 0 ? ',' : '') . '('); - foreach ($valueList as $j => $value) { - if ($j > 0) { - $sb->writeString(','); - } - $value->writeSql($sb); - } - $sb->writeString(')'); - } - } - - private function writeColumnNames(SqlBuilder $sb): void - { - if ($this->columnNames === []) { - return; - } - - $s = ' ('; - foreach ($this->columnNames as $i => $columnName) { - $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); - } - $sb->writeString($s . ')'); - } } diff --git a/src/MySQL/Builder/ReturningItem.php b/src/MySQL/Builder/ReturningItem.php new file mode 100644 index 0000000..35cf584 --- /dev/null +++ b/src/MySQL/Builder/ReturningItem.php @@ -0,0 +1,28 @@ +outputExpression->writeSql($sb); + if ($this->outputName !== '') { + $sb->writeString(' AS ' . $this->outputName); + } + } +} diff --git a/tests/MariaDB/Q/DmlTest.php b/tests/MariaDB/Q/DmlTest.php new file mode 100644 index 0000000..f6935ad --- /dev/null +++ b/tests/MariaDB/Q/DmlTest.php @@ -0,0 +1,78 @@ +columnNames('id', 'email')->values(Q::arg(1), Q::arg('a@b.c')), + )->toRenderSql('INSERT INTO users (id,email) VALUES (?,?)', [1, 'a@b.c']); + }); + + it('renders ON DUPLICATE KEY UPDATE with the VALUES() reference (no row alias)', function () { + expect( + Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits') + ->values(Q::arg(1), Q::arg(10)) + ->onDuplicateKeyUpdate() + ->set('hits', Q::inserted('hits')) + ->set('seen', Q::int(1)), + )->toRenderSql( + 'INSERT INTO t (id,hits) VALUES (?,?) ON DUPLICATE KEY UPDATE hits = VALUES(hits),seen = 1', + [1, 10], + ); + }); + + it('renders a RETURNING clause', function () { + expect( + Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::arg(1)) + ->returning(Q::n('id'))->as('new_id'), + )->toRenderSql('INSERT INTO t (a) VALUES (?) RETURNING id AS new_id', [1]); + }); +}); + +describe('MariaDB REPLACE', function () { + it('renders a RETURNING clause', function () { + expect( + Q::replaceInto(Q::n('t'))->columnNames('a')->values(Q::arg(1)) + ->returning(Q::n('id')), + )->toRenderSql('REPLACE INTO t (a) VALUES (?) RETURNING id', [1]); + }); +}); + +describe('MariaDB UPDATE', function () { + it('reuses the shared multi-table update', function () { + expect( + Q::update(Q::n('t1')) + ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->set('t1.a', Q::n('t2.b')), + )->toRenderSql('UPDATE t1 JOIN t2 ON t1.id = t2.id SET t1.a = t2.b'); + }); +}); + +describe('MariaDB DELETE', function () { + it('deletes with RETURNING (single-table)', function () { + expect( + Q::deleteFrom(Q::n('t'))->where(Q::n('id')->eq(Q::arg(1)))->returning(Q::n('id')), + )->toRenderSql('DELETE FROM t WHERE id = ? RETURNING id', [1]); + }); + + it('renders a multi-table delete', function () { + expect( + Q::deleteFrom(Q::n('t1')) + ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->where(Q::n('t2.id')->isNull()), + )->toRenderSql('DELETE t1.* FROM t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.id IS NULL'); + }); + + it('rejects RETURNING on a multi-table delete', function () { + $q = Q::deleteFrom(Q::n('t1')) + ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->returning(Q::n('t1.id')); + + expect(static fn () => Q::build($q)->toSql())->toThrow(QueryBuilderException::class); + }); +}); From 25226382b8807c6e41ecea29f49177888515a61c Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 10:20:59 +0200 Subject: [PATCH 20/40] wip: MariaDB Q\Func via shared SharedFunctions trait (+ MariaDB-only funcs); ledger updates --- docs/mysql-mariadb-design.md | 4 +- src/MariaDB/Q/Func.php | 85 +++ src/MySQL/Q/Func.php | 1007 +--------------------------- src/MySQL/Q/SharedFunctions.php | 1018 +++++++++++++++++++++++++++++ tests/MariaDB/Q/FunctionsTest.php | 32 + 5 files changed, 1144 insertions(+), 1002 deletions(-) create mode 100644 src/MariaDB/Q/Func.php create mode 100644 src/MySQL/Q/SharedFunctions.php create mode 100644 tests/MariaDB/Q/FunctionsTest.php diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index 64c4fd1..db458cb 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -503,6 +503,7 @@ MariaDB 13+); `DELETE ... RETURNING` is MariaDB-only (stage 7). |---|---|---| | bitwise `&` `\|` `^`(XOR) `<<` `>>` `~` | Deferred | not in the PG surface; add if wanted | | case-sensitive regex (`~`/`!~` equivalents) | Deferred | engine-divergent (`REGEXP_LIKE(...,'c')` vs `REGEXP BINARY`); only the ci-default `REGEXP` ships now | +| JSON `->`/`->>` operators on MariaDB expressions | MySQL-native (shared base) | MariaDB lacks these operators (MDEV-13594); they live on the shared `ExpBase` as MySQL syntax — MariaDB code should use `Q\Func::jsonExtract`/`jsonUnquote`. A full expression-layer fork to remove two methods from MariaDB is deliberately deferred as disproportionate. | | `ILIKE`, `SIMILAR TO`, `::`, `\|\|`, `^`(pow), `@>`/`<@`, `#>`/`#>>`, `ARRAY` | N/A (PG-only) | dropped or mapped to functions — see §6 | ### Functions @@ -525,4 +526,5 @@ Anything omitted stays reachable via `Q::func(name, ...)`. | `JSON_TABLE` | Deferred | FROM-clause table function (FROM machinery, not `Q\Func`) | | `MEMBER OF` operator | Deferred | JSON membership; add to the expression layer if wanted | | MySQL-only `REGEXP_LIKE`/`GROUPING`/`ANY_VALUE`/`JSON_SCHEMA*`/`JSON_STORAGE*`/`JSON_PRETTY`/`RANDOM_BYTES` | Supported (MySQL facade only) | gated; absent on MariaDB | -| MariaDB-only `JSON_QUERY`/`JSON_DETAILED`/`MEDIAN`/`PERCENTILE_*`/Oracle-compat | Supported (MariaDB facade only) | gated; absent on MySQL | +| MariaDB-only `JSON_QUERY`/`JSON_DETAILED`/`JSON_EXISTS`/`MEDIAN`/Oracle-compat (`TO_CHAR`/`ADD_MONTHS`/`MONTHS_BETWEEN`/`CHR`/`OCT`) | Supported (MariaDB facade only) | gated; absent on MySQL | +| MariaDB `PERCENTILE_CONT`/`PERCENTILE_DISC` | Deferred | ordered-set aggregates with `WITHIN GROUP (ORDER BY …)` — a distinct builder shape; via `Q::func` until added | diff --git a/src/MariaDB/Q/Func.php b/src/MariaDB/Q/Func.php new file mode 100644 index 0000000..c57591c --- /dev/null +++ b/src/MariaDB/Q/Func.php @@ -0,0 +1,85 @@ + - */ - private static function leadLagArgs(Exp $expr, ?Exp $offset, ?Exp $default): array - { - $args = [$expr]; - if ($offset !== null) { - $args[] = $offset; - if ($default !== null) { - $args[] = $default; - } - } - - return $args; - } - - private static function call(string $name, Exp ...$args): FuncExp - { - return new FuncExp($name, array_values($args)); - } - - private static function agg(string $name, Exp ...$args): AggBuilder - { - return new AggBuilder($name, array_values($args)); - } } diff --git a/src/MySQL/Q/SharedFunctions.php b/src/MySQL/Q/SharedFunctions.php new file mode 100644 index 0000000..c861ba8 --- /dev/null +++ b/src/MySQL/Q/SharedFunctions.php @@ -0,0 +1,1018 @@ +toRenderSql('LOWER(a)'); + expect(Q\Func::count(Q::n('*')))->toRenderSql('COUNT(*)'); + expect(Q\Func::jsonExtract(Q::n('doc'), Q::string('$.a')))->toRenderSql("JSON_EXTRACT(doc, '$.a')"); + expect(Q\Func::rowNumber()->over()->orderBy(Q::n('x')))->toRenderSql('ROW_NUMBER() OVER (ORDER BY x)'); + expect(Q\Func::groupConcat(Q::n('name'))->separator(', '))->toRenderSql("GROUP_CONCAT(name SEPARATOR ', ')"); + }); + + it('renders MariaDB-only functions', function () { + expect(Q\Func::jsonQuery(Q::n('doc'), Q::string('$.a')))->toRenderSql("JSON_QUERY(doc, '$.a')"); + expect(Q\Func::jsonExists(Q::n('doc'), Q::string('$.a')))->toRenderSql("JSON_EXISTS(doc, '$.a')"); + expect(Q\Func::jsonDetailed(Q::n('doc')))->toRenderSql('JSON_DETAILED(doc)'); + expect(Q\Func::median(Q::n('v'))->over()->partitionBy(Q::n('g'))) + ->toRenderSql('MEDIAN(v) OVER (PARTITION BY g)'); + expect(Q\Func::toChar(Q::n('d'), Q::string('YYYY-MM-DD')))->toRenderSql("TO_CHAR(d, 'YYYY-MM-DD')"); + expect(Q\Func::addMonths(Q::n('d'), Q::int(3)))->toRenderSql('ADD_MONTHS(d, 3)'); + expect(Q\Func::monthsBetween(Q::n('a'), Q::n('b')))->toRenderSql('MONTHS_BETWEEN(a, b)'); + expect(Q\Func::chr(Q::int(65)))->toRenderSql('CHR(65)'); + expect(Q\Func::oct(Q::int(8)))->toRenderSql('OCT(8)'); + }); + + // MySQL-only functions (regexpLike / jsonPretty / randomBytes / ...) are absent + // from this facade by construction — calling them is a compile-time type error, + // which is the intended guarantee, so there is nothing to assert at runtime. +}); From 27ae328fdf3798853048bbd716f9a6f73c8affc4 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 10:24:10 +0200 Subject: [PATCH 21/40] =?UTF-8?q?wip:=20comment=20sweep=20=E2=80=94=20self?= =?UTF-8?q?-standing=20docblocks=20(drop=20cross-dialect=20framing=20in=20?= =?UTF-8?q?per-dialect=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/MariaDB/Builder/SelectBuilder.php | 7 +++---- src/MariaDB/Q.php | 4 ++-- src/MariaDB/Q/Func.php | 4 ++-- src/MySQL/Builder/AbstractDeleteBuilder.php | 2 +- src/MySQL/Builder/AbstractInsertBuilder.php | 2 +- src/MySQL/Builder/AbstractReplaceBuilder.php | 2 +- src/MySQL/Builder/AbstractSelectBuilder.php | 2 +- src/MySQL/Builder/SetsLockWaitPolicy.php | 2 +- src/MySQL/Q/Func.php | 4 ++-- 9 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/MariaDB/Builder/SelectBuilder.php b/src/MariaDB/Builder/SelectBuilder.php index b6ed03d..92a5266 100644 --- a/src/MariaDB/Builder/SelectBuilder.php +++ b/src/MariaDB/Builder/SelectBuilder.php @@ -14,10 +14,9 @@ /** * Builds a MariaDB SELECT query. * - * Shares state, rendering and clause logic with the MySQL family via - * {@see AbstractSelectBuilder}; this concrete builder adds MariaDB's public - * transition methods and renders the shared lock as `LOCK IN SHARE MODE`. It has - * no `LATERAL` from/join family (unsupported by MariaDB). + * The shared state, rendering and clause logic live in {@see AbstractSelectBuilder}; + * this concrete builder adds MariaDB's public transition methods and renders the + * shared lock as `LOCK IN SHARE MODE`. There is no `LATERAL` from/join family. */ class SelectBuilder extends AbstractSelectBuilder { diff --git a/src/MariaDB/Q.php b/src/MariaDB/Q.php index d259f8d..8eab847 100644 --- a/src/MariaDB/Q.php +++ b/src/MariaDB/Q.php @@ -20,8 +20,8 @@ /** * Entry point (facade) for building MariaDB queries. * - * The dialect-agnostic expression surface lives in {@see BuildsExpressions} (shared - * with the MySQL facade); this facade adds MariaDB's statement entry points. + * The dialect-agnostic expression surface lives in the shared {@see BuildsExpressions} + * trait; this facade adds MariaDB's statement entry points. */ final class Q { diff --git a/src/MariaDB/Q/Func.php b/src/MariaDB/Q/Func.php index c57591c..da38e9d 100644 --- a/src/MariaDB/Q/Func.php +++ b/src/MariaDB/Q/Func.php @@ -12,8 +12,8 @@ /** * Facade for MariaDB SQL function expressions, accessed as `Q\Func`. * - * The dialect-agnostic function set lives in {@see SharedFunctions} (shared with the - * MySQL facade); this facade adds MariaDB-only functions (absent on MySQL). + * The dialect-agnostic function set lives in the shared {@see SharedFunctions} trait; + * this facade adds functions specific to MariaDB. */ final class Func { diff --git a/src/MySQL/Builder/AbstractDeleteBuilder.php b/src/MySQL/Builder/AbstractDeleteBuilder.php index fd2e9f6..ba41c24 100644 --- a/src/MySQL/Builder/AbstractDeleteBuilder.php +++ b/src/MySQL/Builder/AbstractDeleteBuilder.php @@ -5,7 +5,7 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Shared foundation of the DELETE builder for the MySQL family: state, the single + * Shared foundation of the DELETE builder: state, the single * {@see derive()} assembly point, the clause helpers and the rendering. * * A single-table delete renders `DELETE FROM tbl ...` and may carry `ORDER BY`, diff --git a/src/MySQL/Builder/AbstractInsertBuilder.php b/src/MySQL/Builder/AbstractInsertBuilder.php index 582bdc0..bf2e9e9 100644 --- a/src/MySQL/Builder/AbstractInsertBuilder.php +++ b/src/MySQL/Builder/AbstractInsertBuilder.php @@ -5,7 +5,7 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Shared foundation of the INSERT builder for the MySQL family: state, the single + * Shared foundation of the INSERT builder: state, the single * {@see derive()} assembly point, the shared row-source methods, and the rendering. * * The proposed-row alias that precedes `ON DUPLICATE KEY UPDATE` is a dialect diff --git a/src/MySQL/Builder/AbstractReplaceBuilder.php b/src/MySQL/Builder/AbstractReplaceBuilder.php index c954f9c..088ede7 100644 --- a/src/MySQL/Builder/AbstractReplaceBuilder.php +++ b/src/MySQL/Builder/AbstractReplaceBuilder.php @@ -5,7 +5,7 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Shared foundation of the REPLACE builder for the MySQL family: like INSERT, but + * Shared foundation of the REPLACE builder: like INSERT, but * an existing row with the same primary or unique key is deleted before the new * row is inserted. There is no `ON DUPLICATE KEY UPDATE`. `RETURNING` is available * only on the dialect that exposes {@see returning()}; the field is carried here so diff --git a/src/MySQL/Builder/AbstractSelectBuilder.php b/src/MySQL/Builder/AbstractSelectBuilder.php index 91aabfd..18fa5e3 100644 --- a/src/MySQL/Builder/AbstractSelectBuilder.php +++ b/src/MySQL/Builder/AbstractSelectBuilder.php @@ -5,7 +5,7 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Shared foundation of the SELECT builder ladder for the MySQL family. + * Shared foundation of the SELECT builder ladder. * * Holds the immutable state, the single {@see derive()} assembly point, the * rendering, and protected helpers that carry the clause logic. Each dialect's diff --git a/src/MySQL/Builder/SetsLockWaitPolicy.php b/src/MySQL/Builder/SetsLockWaitPolicy.php index 29a97f3..112de01 100644 --- a/src/MySQL/Builder/SetsLockWaitPolicy.php +++ b/src/MySQL/Builder/SetsLockWaitPolicy.php @@ -7,7 +7,7 @@ /** * The lock wait-policy refinements (`NOWAIT` / `SKIP LOCKED`) shared by both * dialects' `ForSelectBuilder`. Reconstructing the {@see LockingClause} happens - * here in {@see deriveLocking()} alone, which the MySQL-only `of()` also uses. + * here in {@see deriveLocking()} alone, which the `of()` refinement also uses. * * @internal * @phpstan-require-extends AbstractSelectBuilder diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index d119c65..d0677ec 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -10,8 +10,8 @@ /** * Facade for MySQL SQL function expressions, accessed as `Q\Func`. * - * The dialect-agnostic function set lives in {@see SharedFunctions}; this facade - * adds MySQL-only functions (absent on MariaDB). + * The dialect-agnostic function set lives in the shared {@see SharedFunctions} trait; + * this facade adds functions specific to MySQL. */ final class Func { From 99b5c4f20e9030d11ad9d631d16d3a4c9454ae37 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 10:26:29 +0200 Subject: [PATCH 22/40] wip: MySQL/MariaDB usage docs (docs/mysql-mariadb.md) + README dialects pointer --- README.md | 9 ++ docs/mysql-mariadb.md | 219 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 docs/mysql-mariadb.md diff --git a/README.md b/README.md index 23bbb32..b860240 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ var_dump($args); // [true] PostgreSQL numbered placeholders (`$1`, `$2`, …) and the positional argument list to bind. See [Executing queries](#executing-queries) for how to run it. +## Dialects + +This README covers the PostgreSQL builder (`Flowpack\QueryObjectBuilder\PostgreSQL\Q`). +The package also ships **MySQL 8.4** and **MariaDB 11.x** facades +(`MySQL\Q` and `MariaDB\Q`) with the same fluent, immutable, type-safe design, +each modelling its own dialect (backtick identifiers, `?` placeholders, native +operators and functions). See **[MySQL & MariaDB](docs/mysql-mariadb.md)** for +usage, the per-engine differences, and the coverage/limitations. + ## Core concepts ### The `Q` facade diff --git a/docs/mysql-mariadb.md b/docs/mysql-mariadb.md new file mode 100644 index 0000000..37fab59 --- /dev/null +++ b/docs/mysql-mariadb.md @@ -0,0 +1,219 @@ +# MySQL & MariaDB dialects + +Alongside the PostgreSQL builder, the package ships two more dialect facades with +the same fluent, immutable, fully-typed design: + +- `Flowpack\QueryObjectBuilder\MySQL\Q` — targets **MySQL 8.4 (LTS)** +- `Flowpack\QueryObjectBuilder\MariaDB\Q` — targets **MariaDB 11.x** + +Both render the MySQL-family SQL conventions: identifiers are backtick-quoted +(`` `order` ``), parameters are positional `?` placeholders, and string literals +escape both the backslash and the quote. Pick the facade for your engine; the two +expose the same surface except where the engines genuinely differ (see +[Dialect differences](#dialect-differences)). + +```php +use Flowpack\QueryObjectBuilder\MySQL\Q; // or MariaDB\Q + +$q = Q::select(Q::n('id'), Q::n('email')) + ->from(Q::n('orders')) + ->where(Q::n('id')->eq(Q::arg(1))); + +[$sql, $args] = Q::build($q)->toSql(); + +echo $sql; // SELECT id,email FROM orders WHERE id = ? +var_dump($args); // [1] +``` + +`toSql()` returns a `[$sql, $args]` pair with positional `?` placeholders and the +argument list to bind — feed both to PDO (`$pdo->prepare($sql)->execute($args)`) +or any layer that speaks MySQL/MariaDB placeholders. + +> Note: a `?` placeholder is not reusable, so a named `Q::bind('x')` used twice +> emits two `?` and binds its value into each. + +## Facades + +- **`Q`** — statements (`select`, `insertInto`, `replaceInto`, `update`, + `deleteFrom`, `with`, `withRecursive`), identifiers (`n`), literals (`string`, + `int`, `float`, `bool`, `null`, `default`), parameters (`arg`, `bind`), + composition (`and`, `or`, `not`, `exists`, `any`, `all`, `case`, `coalesce`, + `nullif`, `greatest`, `least`, `func`, `cast`, `convert`, `interval`), and the + window frame bounds (`currentRow`, `unboundedPreceding`, `unboundedFollowing`, + `preceding`, `following`). +- **`Q\Func`** — SQL functions: aggregates (`count`, `sum`, `avg`, `groupConcat`, + `jsonArrayAgg`, `bitOr`, `stddevPop`, …), string / numeric / date-time / JSON / + misc scalars, the window functions (`rowNumber`, `rank`, `lag`, `lead`, + `firstValue`, …), and the special shapes (`GROUP_CONCAT`, `EXTRACT`, `TRIM`). + It is named `Func` (not `Fn`) because `fn` is a reserved keyword in PHP. + +Operators are chainable on the expression objects that `Q::n()`, literals and +functions return: `->eq()`, `->neq()`, `->lt()`, `->like()`, `->regexp()`, +`->nullSafeEq()` (`<=>`), `->in()`, `->isNull()`, `->plus()`, `->jsonExtract()` +(`->`, MySQL), … Things that read as function calls — `CONCAT`, `POW`, `CAST`, +`JSON_CONTAINS` — are built through the facade (`Q::func` / `Q::cast` / `Q\Func`), +not as chained operators. + +## Examples + +### SELECT, joins, grouping + +```php +$q = Q::select(Q::n('country')) + ->select(Q\Func::count(Q::n('*')))->as('n') + ->from(Q::n('users')) + ->groupBy(Q::n('country'))->withRollup() + ->having(Q::n('country')->isNotNull()) + ->orderBy(Q::n('n'))->desc(); +``` + +```sql +SELECT country,COUNT(*) AS n FROM users +GROUP BY country WITH ROLLUP HAVING country IS NOT NULL ORDER BY n DESC +``` + +```php +$q = Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->leftJoin(Q::n('orders'))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))); +``` + +```sql +SELECT * FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id +``` + +### Locking + +`forUpdate()` is shared; the shared lock is spelled differently per engine: + +```php +// MySQL\Q +Q::select(Q::n('id'))->from(Q::n('t'))->forShare()->of('t')->nowait(); +// SELECT id FROM t FOR SHARE OF t NOWAIT + +// MariaDB\Q +Q::select(Q::n('id'))->from(Q::n('t'))->forShare(); +// SELECT id FROM t LOCK IN SHARE MODE +``` + +### Window functions + +```php +$q = Q::select( + Q::n('depname'), + Q\Func::rank()->over()->partitionBy(Q::n('depname'))->orderBy(Q::n('salary'))->desc(), + Q\Func::sum(Q::n('salary'))->over() + ->partitionBy(Q::n('subject'))->orderBy(Q::n('t')) + ->rows(Q::unboundedPreceding()), +)->from(Q::n('empsalary')) + ->window('w')->as()->orderBy(Q::n('salary')); +``` + +```sql +SELECT depname, + RANK() OVER (PARTITION BY depname ORDER BY salary DESC), + SUM(salary) OVER (PARTITION BY subject ORDER BY t ROWS UNBOUNDED PRECEDING) +FROM empsalary WINDOW w AS (ORDER BY salary) +``` + +### INSERT & upsert + +```php +// MySQL: proposed row via the `AS new` alias → Q::inserted('col') is `new.col` +Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits')->values(Q::arg(1), Q::arg(10)) + ->onDuplicateKeyUpdate()->set('hits', Q::inserted('hits')); +// INSERT INTO t (id,hits) VALUES (?,?) AS new ON DUPLICATE KEY UPDATE hits = new.hits + +// MariaDB: proposed row via VALUES(col) → Q::inserted('col') is `VALUES(col)` +Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits')->values(Q::arg(1), Q::arg(10)) + ->onDuplicateKeyUpdate()->set('hits', Q::inserted('hits')); +// INSERT INTO t (id,hits) VALUES (?,?) ON DUPLICATE KEY UPDATE hits = VALUES(hits) +``` + +`Q::insertInto(...)->ignore()` renders `INSERT IGNORE`; `Q::replaceInto(...)` +builds a `REPLACE` statement with the same value/column/query surface. + +### Multi-table UPDATE / DELETE + +```php +Q::update(Q::n('t1')) + ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->set('t1.col1', Q::n('t2.col1')) + ->where(Q::n('t2.col2')->isNull()); +// UPDATE t1 LEFT JOIN t2 ON t1.id = t2.id SET t1.col1 = t2.col1 WHERE t2.col2 IS NULL + +Q::deleteFrom(Q::n('t1')) + ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->where(Q::n('t2.id')->isNull()); +// DELETE t1.* FROM t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.id IS NULL +``` + +`ORDER BY` and `LIMIT` are available on single-table UPDATE/DELETE only; using +them with a join raises a `QueryBuilderException` when the query is built. + +### RETURNING (MariaDB only) + +```php +use Flowpack\QueryObjectBuilder\MariaDB\Q; + +Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::arg(1)) + ->returning(Q::n('id'))->as('new_id'); +// INSERT INTO t (a) VALUES (?) RETURNING id AS new_id +``` + +`returning()` is available on MariaDB INSERT, REPLACE and (single-table) DELETE. +It does not exist on the MySQL facade — calling it there is a compile-time error. + +## Dialect differences + +| Feature | `MySQL\Q` | `MariaDB\Q` | +|---|---|---| +| `LATERAL` from/join (`fromLateral`, `joinLateral`, …) | ✓ | — (not in MariaDB) | +| Shared row lock | `forShare()` → `FOR SHARE` (+ `of()` / `nowait()` / `skipLocked()`) | `forShare()` → `LOCK IN SHARE MODE` | +| `RETURNING` on INSERT / REPLACE / DELETE | — | ✓ (single-table) | +| Leading `WITH` on UPDATE / DELETE | ✓ | — (MariaDB 12.3+; off on the 11.x anchor) | +| Upsert proposed-row ref (`Q::inserted('c')`) | `new.c` (with `AS new`) | `VALUES(c)` | +| JSON path operators `->` / `->>` | ✓ (`->jsonExtract()` / `->jsonExtractText()`) | use `Q\Func::jsonExtract()` / `Q\Func::jsonUnquote()` | +| Dialect-only functions | `regexpLike`, `grouping`, `anyValue`, `jsonPretty`, `jsonSchemaValid*`, `jsonStorage*`, `randomBytes` | `jsonQuery`, `jsonDetailed`, `jsonExists`, `median`, `toChar`, `addMonths`, `monthsBetween`, `chr`, `oct` | + +Everything else — the SELECT clause set, joins, `WITH ROLLUP`, `HAVING`, +`ORDER BY`, `LIMIT`/`OFFSET`, `UNION`/`INTERSECT`/`EXCEPT`, CTEs, window functions +and frames, `ON DUPLICATE KEY UPDATE`, multi-table UPDATE/DELETE, and the curated +function set — is identical across the two facades. + +## Limitations + +The dialects target a curated, query-shaping surface; the following are +deliberately out of scope. Anything omitted remains reachable through the raw +`Q::func(name, ...)` escape hatch. + +- **Deferred** (in scope later, behind an explicit method): `PARTITION (...)` + selection, index hints, `STRAIGHT_JOIN` / `NATURAL JOIN`, the `LIMIT off,count` + short form, MariaDB `OFFSET..FETCH` and recursive-CTE `CYCLE`, the + `INSERT/REPLACE ... SET` assignment form, MySQL `VALUES ROW()` / `TABLE` + sources, the comma-separated multi-table list (the `JOIN` form covers it), + multi-target `DELETE t1,t2 FROM …`, `UPDATE/DELETE IGNORE`, `MEMBER OF`, + `JSON_TABLE`, and MariaDB `PERCENTILE_CONT`/`PERCENTILE_DISC` (the `WITHIN + GROUP` ordered-set shape). +- **Excluded** (not query shape): `INTO OUTFILE/DUMPFILE/@var`, priority / + optimizer / result hints (`LOW_PRIORITY`, `SQL_CALC_FOUND_ROWS`, …), + `PROCEDURE`, `ROWS EXAMINED` / `WAIT n`, `FOR PORTION OF`, spatial / GIS, + encryption / compression, and XML functions. +- **`UPDATE ... RETURNING`** exists on neither engine within the version anchor. + +The full per-production ledger lives in +[`mysql-mariadb-design.md` §12](mysql-mariadb-design.md). + +## Relationship to the PostgreSQL builder + +The MySQL/MariaDB facades mirror the PostgreSQL builder's structure (immutability, +type-state transitions, the `Q` / `Q\Func` split) but model each engine's own SQL +rather than PostgreSQL's. Notably: PostgreSQL operators that MySQL/MariaDB spell as +functions are dropped from the expression surface and reached through `Q\Func` +(`::`→`CAST`/`CONVERT`, `||`→`CONCAT`, `^`→`POW`, `@>`→`JSON_CONTAINS`); PG-only +clauses (`DISTINCT ON`, `FULL JOIN`, `GROUPING SETS`/`CUBE`, `NULLS FIRST/LAST`, +materialized CTEs, `SEARCH`) are absent; and `ON CONFLICT` becomes +`onDuplicateKeyUpdate()`, `RETURNING` becomes MariaDB-only, and PG's +`UPDATE ... FROM` / `DELETE ... USING` become the multi-table `JOIN` forms. From a8e89597687cda077672697d24e08c20636d8e86 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 10:27:17 +0200 Subject: [PATCH 23/40] wip: mark MySQL/MariaDB port implemented in design-doc status header --- docs/mysql-mariadb-design.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index db458cb..97a1184 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -9,10 +9,17 @@ exhaustive per-area findings live next to this file's source material (the **Version anchors:** MySQL **8.4 (LTS)**, MariaDB **11.x GA** (11.8 LTS). Every feature gated below the anchor is treated as *not available*. -> Status: architecture **decided** (per-variant subclasses; `src/MySQL` + -> `src/MariaDB`). Implementation in progress — foundation, expression layer and -> SELECT have landed for MySQL. The keep/drop/replace/add tables, function set and -> version gates are dialect-fact. +> Status: **implemented** for both engines (per-variant subclasses; `src/MySQL` + +> `src/MariaDB`). All staged areas (§9) have landed and are green on +> `vendor/bin/pest` + `vendor/bin/phpstan analyse` (level max): foundation, +> expression layer, SELECT, window functions, DML, the curated function facade, and +> the MariaDB variant. Usage docs live in `mysql-mariadb.md`; the coverage ledger +> (§12) records every deferred/excluded production. The compile-time-safe variant +> split is realised via shared abstract base builders (`AbstractSelectBuilder`, +> `Abstract{Insert,Replace,Delete}Builder`) plus shared subbuilder/facade traits, +> with per-dialect concrete ladders. One deliberate deviation: the expression layer +> is single-sourced (see §6 / §12 — the JSON `->`/`->>` operators stay on the shared +> base as MySQL-native; MariaDB uses the function form). --- From 982d812871e33aea2a389a9c6b2328d6580f3e60 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 11:56:24 +0200 Subject: [PATCH 24/40] =?UTF-8?q?wip:=20dialect-validation=20plumbing=20(D?= =?UTF-8?q?ialect,=20Target,=20SqlBuilder::requireDialect,=20QueryBuilder:?= =?UTF-8?q?:withValidateTarget)=20=E2=80=94=20validation-only,=20rendering?= =?UTF-8?q?=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/MySQL/Builder/Dialect.php | 27 +++++++++++++++++++ src/MySQL/Builder/QueryBuilder.php | 18 ++++++++++--- src/MySQL/Builder/SqlBuilder.php | 26 ++++++++++++++++++ src/MySQL/Builder/Target.php | 42 ++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/MySQL/Builder/Dialect.php create mode 100644 src/MySQL/Builder/Target.php diff --git a/src/MySQL/Builder/Dialect.php b/src/MySQL/Builder/Dialect.php new file mode 100644 index 0000000..2a52969 --- /dev/null +++ b/src/MySQL/Builder/Dialect.php @@ -0,0 +1,27 @@ + 'MySQL', + self::MariaDb => 'MariaDB', + }; + } +} diff --git a/src/MySQL/Builder/QueryBuilder.php b/src/MySQL/Builder/QueryBuilder.php index 4674b0e..2919509 100644 --- a/src/MySQL/Builder/QueryBuilder.php +++ b/src/MySQL/Builder/QueryBuilder.php @@ -16,6 +16,7 @@ public function __construct( private readonly SqlWriter $writer, private readonly array $namedArgs = [], private readonly bool $validating = true, + private readonly ?Target $validationTarget = null, ) { } @@ -24,6 +25,17 @@ public static function build(SqlWriter $writer): self return new self($writer); } + /** + * Validate that the query only uses features available on the given engine + * (and version). Rendering is unaffected — it is fully determined by how the + * query was constructed; this opts into raising a {@see QueryBuilderException} + * for constructs the target does not support. + */ + public function withValidateTarget(Target $target): self + { + return new self($this->writer, $this->namedArgs, $this->validating, $target); + } + /** * Generate the SQL and the list of positional arguments. * @@ -32,7 +44,7 @@ public static function build(SqlWriter $writer): self */ public function toSql(): array { - $sb = new SqlBuilder($this->validating); + $sb = new SqlBuilder($this->validating, $this->validationTarget); // A top-level query is written without the parentheses it would get as a subquery. if ($this->writer instanceof InnerSqlWriter) { @@ -70,7 +82,7 @@ public function toSql(): array */ public function withNamedArgs(array $args): self { - return new self($this->writer, $args, $this->validating); + return new self($this->writer, $args, $this->validating, $this->validationTarget); } /** @@ -78,6 +90,6 @@ public function withNamedArgs(array $args): self */ public function withoutValidation(): self { - return new self($this->writer, $this->namedArgs, false); + return new self($this->writer, $this->namedArgs, false, $this->validationTarget); } } diff --git a/src/MySQL/Builder/SqlBuilder.php b/src/MySQL/Builder/SqlBuilder.php index 52c6516..ff530ce 100644 --- a/src/MySQL/Builder/SqlBuilder.php +++ b/src/MySQL/Builder/SqlBuilder.php @@ -40,11 +40,37 @@ final class SqlBuilder /** @var list<\Throwable> */ private array $errors = []; + /** + * The target to validate constructs against, or null to skip target validation. + * Rendering never depends on it — the SQL is fully determined by the query. + */ public function __construct( private readonly bool $validating = true, + private readonly ?Target $validationTarget = null, ) { } + /** + * Record that the construct being written requires the given dialect (and, + * optionally, a minimum version). When a validation target is set and does not + * satisfy the requirement, an error is added; otherwise this is a no-op. + */ + public function requireDialect(Dialect $required, string $feature, ?string $minVersion = null): void + { + if ($this->validationTarget === null || $this->validationTarget->satisfies($required, $minVersion)) { + return; + } + + $versionNote = $minVersion !== null ? ' ' . $minVersion . '+' : ''; + $this->addError(new QueryBuilderException(sprintf( + '%s requires %s%s, but the query is validated against %s', + $feature, + $required->label(), + $versionNote, + $this->validationTarget->dialect->label(), + ))); + } + public function writeString(string $s): void { $this->sql .= $s; diff --git a/src/MySQL/Builder/Target.php b/src/MySQL/Builder/Target.php new file mode 100644 index 0000000..ab9e86a --- /dev/null +++ b/src/MySQL/Builder/Target.php @@ -0,0 +1,42 @@ +dialect !== $dialect) { + return false; + } + + return $minVersion === null || $this->version === null || version_compare($this->version, $minVersion, '>='); + } +} From 01cd7e721ed3799a81eafa3c23ead03973730297 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 12:38:09 +0200 Subject: [PATCH 25/40] wip: MySQL vs MariaDB structural-differences reference (docs/mysql-mariadb-differences.md) --- docs/mysql-mariadb-differences.md | 240 ++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/mysql-mariadb-differences.md diff --git a/docs/mysql-mariadb-differences.md b/docs/mysql-mariadb-differences.md new file mode 100644 index 0000000..78d098f --- /dev/null +++ b/docs/mysql-mariadb-differences.md @@ -0,0 +1,240 @@ +# MySQL vs MariaDB — structural SQL differences + +A catalogue of every place MySQL and MariaDB **structurally** diverge, based on the +port analysis. Its purpose is to inform how the query builder should model the two +engines: which differences are cheap token swaps, which are whole-clause +availability, and which are just function presence. + +**Version anchors:** MySQL **8.4 (LTS)**, MariaDB **11.x** (11.8 LTS). A few gates +below are version-dependent and called out as such. + +Each entry shows the *same intent* rendered for both engines. Differences are +grouped by how a single, dialect-parameterised builder would handle them: + +- **Render-branch** — one builder method, a dialect flag picks the spelling. +- **Availability** — a clause/feature exists on one engine only (method present but + validated against the configured dialect, or absent). +- **Function presence** — same call shape, available on one side only. + +--- + +## A. Render-branches — same query shape, different tokens + +The cheap ones: identical builder call, a flag selects the SQL text. No effect on +composition. + +### A1. Shared row lock + +```sql +-- MySQL +SELECT * FROM t WHERE id = ? FOR SHARE; +SELECT * FROM t WHERE id = ? FOR SHARE OF t NOWAIT; -- OF / wait-policy allowed + +-- MariaDB +SELECT * FROM t WHERE id = ? LOCK IN SHARE MODE; -- no OF, no wait-policy +``` + +`FOR UPDATE` (optionally `NOWAIT` / `SKIP LOCKED`) is **identical** in both; only the +*shared* lock diverges. `OF tbl` is MySQL-only (even on `FOR UPDATE`). + +### A2. Upsert — reference to the proposed row + +```sql +-- MySQL (8.0.19+): row alias `AS new`, referenced as new.col +INSERT INTO t (id, hits) VALUES (?, ?) AS new + ON DUPLICATE KEY UPDATE hits = new.hits; + +-- MariaDB: no alias, VALUES(col) function +INSERT INTO t (id, hits) VALUES (?, ?) + ON DUPLICATE KEY UPDATE hits = VALUES(hits); +``` + +MySQL emits an extra `AS new` after the value rows and refers to `new.col`; MariaDB +has no alias and wraps the column in `VALUES(...)`. (MySQL's `VALUES(col)` still +works but is deprecated since 8.0.20; MariaDB has no `AS new`.) The rest of the +`ON DUPLICATE KEY UPDATE` structure is identical. + +### A3. JSON path access + +```sql +-- MySQL: operators +SELECT doc -> '$.name' FROM t; +SELECT doc ->> '$.name' FROM t; + +-- MariaDB: function form (no -> / ->> operators — MDEV-13594) +SELECT JSON_EXTRACT(doc, '$.name') FROM t; +SELECT JSON_UNQUOTE(JSON_EXTRACT(doc, '$.name')) FROM t; +``` + +`->>` is the most structural: MariaDB needs the nested +`JSON_UNQUOTE(JSON_EXTRACT(...))`. This is the case that would force a large +expression-layer fork under a strict per-dialect type split; under a dialect flag +it is a single branch. + +### A4. JSON pretty-print + +```sql +-- MySQL +SELECT JSON_PRETTY(doc) FROM t; + +-- MariaDB +SELECT JSON_DETAILED(doc) FROM t; +``` + +Same shape, different function name. + +--- + +## B. Availability — a whole clause/feature on one engine only + +Modelled as a method that is present but validated against the configured dialect +(or simply absent on the engine that lacks it). + +### B1. `RETURNING` — MariaDB only + +```sql +-- MariaDB +INSERT INTO t (a) VALUES (?) RETURNING id, created_at; +DELETE FROM t WHERE id = ? RETURNING id; +REPLACE INTO t (a) VALUES (?) RETURNING id; + +-- MySQL: no equivalent — separate round-trip +INSERT INTO t (a) VALUES (?); +SELECT LAST_INSERT_ID(); +``` + +MariaDB supports `RETURNING` on INSERT, REPLACE and (single-table) DELETE. Neither +engine has `UPDATE ... RETURNING` within the version anchor. + +### B2. `LATERAL` — MySQL only + +```sql +-- MySQL (8.0.14+) +SELECT * FROM orders o + JOIN LATERAL (SELECT * FROM items i WHERE i.order_id = o.id LIMIT 3) AS top ON TRUE; + +-- MariaDB: no LATERAL — must be rewritten (correlated subquery / different shape); +-- there is no 1:1 equivalent. +``` + +### B3. Leading `WITH` before UPDATE / DELETE — MySQL only (in the 11.x anchor) + +```sql +-- MySQL +WITH stale AS (SELECT id FROM sessions WHERE expired = 1) +DELETE FROM users WHERE id IN (SELECT id FROM stale); + +-- MariaDB 11.x: WITH only before SELECT (and INSERT via a feeding SELECT) — inline it +DELETE FROM users WHERE id IN (SELECT id FROM sessions WHERE expired = 1); +``` + +Version gate: MariaDB 12.3+ lifts this restriction. `WITH ... SELECT` works in both. + +### B4. Ordered-set / distribution aggregates — MariaDB only + +```sql +-- MariaDB +SELECT MEDIAN(salary) OVER (PARTITION BY dept) FROM emp; +SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) OVER (PARTITION BY dept) FROM emp; +SELECT PERCENTILE_DISC(0.9) WITHIN GROUP (ORDER BY salary) OVER (PARTITION BY dept) FROM emp; + +-- MySQL: none of these exist +``` + +`PERCENTILE_CONT` / `PERCENTILE_DISC` are structurally special even within MariaDB +(the `WITHIN GROUP (ORDER BY …)` ordered-set shape). + +### B5. `OFFSET … FETCH` — MariaDB only + +```sql +-- MariaDB +SELECT * FROM t ORDER BY id OFFSET 10 ROWS FETCH FIRST 5 ROWS ONLY; -- also WITH TIES + +-- MySQL: LIMIT only +SELECT * FROM t ORDER BY id LIMIT 5 OFFSET 10; +``` + +`LIMIT … OFFSET …` works in **both**; `FETCH FIRST … ONLY / WITH TIES` is a MariaDB +extra. + +### B6. Recursive-CTE `CYCLE` — MariaDB only + +```sql +-- MariaDB (relaxed, non-standard) +WITH RECURSIVE g AS ( + SELECT ... UNION ... SELECT ... +) CYCLE id RESTRICT +SELECT * FROM g; + +-- MySQL: no CYCLE / SEARCH clauses at all +``` + +--- + +## C. Function *sets* that differ (presence only, not structural) + +Same call shape; available on one side only. A single builder treats these as +union membership plus a dialect check (or documentation). + +| MySQL-only | MariaDB-only | +|---|---| +| `REGEXP_LIKE(s, p)` | `JSON_QUERY(doc, path)` | +| `GROUPING(c)` | `JSON_EXISTS(doc, path)` | +| `ANY_VALUE(c)` | `MEDIAN(x)` | +| `JSON_SCHEMA_VALID(schema, doc)`, `JSON_SCHEMA_VALIDATION_REPORT(...)` | `TO_CHAR(x[, fmt])` | +| `JSON_STORAGE_SIZE(doc)`, `JSON_STORAGE_FREE(doc)` | `ADD_MONTHS(d, n)`, `MONTHS_BETWEEN(a, b)` | +| `RANDOM_BYTES(n)` | `CHR(n)`, `OCT(n)` | + +> This table is version-sensitive — re-check against your exact MySQL 8.4 / +> MariaDB 11.x builds. The structural items in sections A and B are the confident +> ones. + +--- + +## What is identical + +So the divergence stays small, the shared surface is worth stating: + +- Backtick identifier quoting, positional `?` placeholders, string escaping + (backslash + doubled quote). +- `SELECT`, all join variants, `WHERE`, `HAVING`. +- `GROUP BY … WITH ROLLUP` — both use `WITH ROLLUP` (MySQL has no `ROLLUP(...)` + form). +- `ORDER BY`, `LIMIT` / `OFFSET`. +- `UNION` / `INTERSECT` / `EXCEPT` (± `ALL`). +- CTEs before `SELECT` (and before INSERT via a feeding SELECT). +- The entire window-function and frame-clause grammar (`OVER`, named `WINDOW`, + `ROWS`/`RANGE` frames). +- `ON DUPLICATE KEY UPDATE` structure (only the proposed-row reference in A2 + differs). +- Multi-table `UPDATE` / `DELETE`; `INSERT` / `REPLACE` / `INSERT IGNORE`. +- The whole curated scalar / aggregate / window function set. + +--- + +## Summary — the divergence surface + +| # | Difference | Kind | Handling in a unified builder | +|---|---|---|---| +| A1 | shared lock (`FOR SHARE` vs `LOCK IN SHARE MODE`, `OF`/wait) | render-branch | flag picks tokens | +| A2 | upsert row-ref (`new.col` + `AS new` vs `VALUES(col)`) | render-branch | flag picks tokens | +| A3 | JSON path (`->`/`->>` vs `JSON_EXTRACT`/`JSON_UNQUOTE`) | render-branch | flag picks tokens | +| A4 | pretty-print (`JSON_PRETTY` vs `JSON_DETAILED`) | render-branch | flag picks name | +| B1 | `RETURNING` (MariaDB) | availability | validated method | +| B2 | `LATERAL` (MySQL) | availability | validated method | +| B3 | `WITH` before UPDATE/DELETE (MySQL; MariaDB 12.3+) | availability | validated method (version gate) | +| B4 | `MEDIAN` / `PERCENTILE_CONT`/`DISC` `WITHIN GROUP` (MariaDB) | availability | validated method | +| B5 | `OFFSET … FETCH` (MariaDB) | availability | validated method | +| B6 | recursive-CTE `CYCLE` (MariaDB) | availability | validated method | +| C | ~11 dialect-only functions | presence | union + dialect check | + +The entire structural divergence is **~10 items — 4 of them mere token swaps**. +That small, well-known surface is what a `Dialect` flag plus a handful of validated +guards would cover, and it is why merging the MySQL family into one builder (while +keeping PostgreSQL a separate builder) is proportionate: the engines share their +rendering primitives and ~95% of the grammar, and the differences that remain are +either one-line branches or a short list of feature guards. + +See also: [`mysql-mariadb.md`](mysql-mariadb.md) (usage) and +[`mysql-mariadb-design.md`](mysql-mariadb-design.md) (the full design + §12 coverage +ledger). From d9e663144475d3ea8f130b7bb4249a83d05fd143 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 13:06:04 +0200 Subject: [PATCH 26/40] wip: collapse MySQL+MariaDB into one dialect-validated builder (delete MariaDB\, version-aware requireDialect gates, InsertValuesBuilder as()/Q::values, differences test corpus) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MariaDB/Builder/CombinationBuilder.php | 18 - src/MariaDB/Builder/DeleteBuilder.php | 61 --- src/MariaDB/Builder/ForSelectBuilder.php | 17 - src/MariaDB/Builder/FromSelectBuilder.php | 17 - src/MariaDB/Builder/GroupBySelectBuilder.php | 16 - src/MariaDB/Builder/InsertBuilder.php | 40 -- src/MariaDB/Builder/JoinDeleteBuilder.php | 16 - src/MariaDB/Builder/JoinSelectBuilder.php | 17 - .../OnDuplicateKeyUpdateInsertBuilder.php | 17 - src/MariaDB/Builder/OrderByDeleteBuilder.php | 16 - src/MariaDB/Builder/OrderBySelectBuilder.php | 17 - .../Builder/OrderByWindowSelectBuilder.php | 43 -- src/MariaDB/Builder/ReplaceBuilder.php | 29 -- src/MariaDB/Builder/SelectBuilder.php | 150 ------- src/MariaDB/Builder/SelectSelectBuilder.php | 17 - src/MariaDB/Builder/WindowDefining.php | 98 ----- src/MariaDB/Builder/WindowSelectBuilder.php | 41 -- src/MariaDB/Builder/WithBuilder.php | 64 --- src/MariaDB/Builder/WithWithBuilder.php | 54 --- src/MariaDB/Q.php | 98 ----- src/MariaDB/Q/Func.php | 85 ---- src/MySQL/Builder/AbstractDeleteBuilder.php | 203 --------- src/MySQL/Builder/AbstractInsertBuilder.php | 229 ---------- src/MySQL/Builder/AbstractReplaceBuilder.php | 186 -------- src/MySQL/Builder/AbstractSelectBuilder.php | 386 ----------------- src/MySQL/Builder/AddsUpsertAssignment.php | 10 +- src/MySQL/Builder/AggBuilder.php | 8 +- src/MySQL/Builder/AliasesLastFromItem.php | 2 +- src/MySQL/Builder/AliasesLastOutput.php | 2 +- src/MySQL/Builder/AppliesRollup.php | 2 +- src/MySQL/Builder/Combination.php | 2 +- src/MySQL/Builder/DeleteBuilder.php | 212 +++++++++- src/MySQL/Builder/ExistsExp.php | 2 +- src/MySQL/Builder/ExpBase.php | 4 +- src/MySQL/Builder/FromItem.php | 1 + src/MySQL/Builder/FuncExp.php | 6 + src/MySQL/Builder/InsertBuilder.php | 243 ++++++++++- src/MySQL/Builder/InsertValuesBuilder.php | 22 + src/MySQL/Builder/Join.php | 1 + src/MySQL/Builder/LockingClause.php | 8 + src/MySQL/Builder/OpExp.php | 5 + src/MySQL/Builder/OrdersLastDeleteTerm.php | 2 +- src/MySQL/Builder/OrdersLastTerm.php | 2 +- src/MySQL/Builder/RefinesCombination.php | 6 +- src/MySQL/Builder/RefinesLastDeleteJoin.php | 2 +- src/MySQL/Builder/RefinesLastJoin.php | 2 +- src/MySQL/Builder/RendersReturning.php | 1 + src/MySQL/Builder/ReplaceBuilder.php | 191 ++++++++- src/MySQL/Builder/Requirement.php | 68 +++ .../Builder/ReturningDeleteBuilder.php | 4 +- .../Builder/ReturningInsertBuilder.php | 4 +- .../Builder/ReturningReplaceBuilder.php | 4 +- src/MySQL/Builder/SelectBuilder.php | 398 +++++++++++++++++- src/MySQL/Builder/SetsLockWaitPolicy.php | 3 +- src/MySQL/Builder/SqlBuilder.php | 34 +- src/MySQL/Builder/SubqueryExp.php | 2 +- src/MySQL/Builder/Target.php | 11 +- src/MySQL/Builder/UpdateBuilder.php | 1 + src/MySQL/BuildsExpressions.php | 14 +- src/MySQL/Q.php | 13 +- src/MySQL/Q/Func.php | 86 +++- src/MySQL/Q/SharedFunctions.php | 19 + tests/MariaDB/Q/DmlTest.php | 78 ---- tests/MariaDB/Q/FunctionsTest.php | 32 -- tests/MariaDB/Q/SelectBuilderTest.php | 106 ----- tests/MySQL/Q/DialectDifferencesTest.php | 214 ++++++++++ tests/MySQL/Q/InsertBuilderTest.php | 19 +- tests/Pest.php | 47 ++- 68 files changed, 1572 insertions(+), 2256 deletions(-) delete mode 100644 src/MariaDB/Builder/CombinationBuilder.php delete mode 100644 src/MariaDB/Builder/DeleteBuilder.php delete mode 100644 src/MariaDB/Builder/ForSelectBuilder.php delete mode 100644 src/MariaDB/Builder/FromSelectBuilder.php delete mode 100644 src/MariaDB/Builder/GroupBySelectBuilder.php delete mode 100644 src/MariaDB/Builder/InsertBuilder.php delete mode 100644 src/MariaDB/Builder/JoinDeleteBuilder.php delete mode 100644 src/MariaDB/Builder/JoinSelectBuilder.php delete mode 100644 src/MariaDB/Builder/OnDuplicateKeyUpdateInsertBuilder.php delete mode 100644 src/MariaDB/Builder/OrderByDeleteBuilder.php delete mode 100644 src/MariaDB/Builder/OrderBySelectBuilder.php delete mode 100644 src/MariaDB/Builder/OrderByWindowSelectBuilder.php delete mode 100644 src/MariaDB/Builder/ReplaceBuilder.php delete mode 100644 src/MariaDB/Builder/SelectBuilder.php delete mode 100644 src/MariaDB/Builder/SelectSelectBuilder.php delete mode 100644 src/MariaDB/Builder/WindowDefining.php delete mode 100644 src/MariaDB/Builder/WindowSelectBuilder.php delete mode 100644 src/MariaDB/Builder/WithBuilder.php delete mode 100644 src/MariaDB/Builder/WithWithBuilder.php delete mode 100644 src/MariaDB/Q.php delete mode 100644 src/MariaDB/Q/Func.php delete mode 100644 src/MySQL/Builder/AbstractDeleteBuilder.php delete mode 100644 src/MySQL/Builder/AbstractInsertBuilder.php delete mode 100644 src/MySQL/Builder/AbstractReplaceBuilder.php delete mode 100644 src/MySQL/Builder/AbstractSelectBuilder.php create mode 100644 src/MySQL/Builder/InsertValuesBuilder.php create mode 100644 src/MySQL/Builder/Requirement.php rename src/{MariaDB => MySQL}/Builder/ReturningDeleteBuilder.php (86%) rename src/{MariaDB => MySQL}/Builder/ReturningInsertBuilder.php (86%) rename src/{MariaDB => MySQL}/Builder/ReturningReplaceBuilder.php (86%) delete mode 100644 tests/MariaDB/Q/DmlTest.php delete mode 100644 tests/MariaDB/Q/FunctionsTest.php delete mode 100644 tests/MariaDB/Q/SelectBuilderTest.php create mode 100644 tests/MySQL/Q/DialectDifferencesTest.php diff --git a/src/MariaDB/Builder/CombinationBuilder.php b/src/MariaDB/Builder/CombinationBuilder.php deleted file mode 100644 index f961f97..0000000 --- a/src/MariaDB/Builder/CombinationBuilder.php +++ /dev/null @@ -1,18 +0,0 @@ -`), adding a single-table `RETURNING` clause. - */ -class DeleteBuilder extends AbstractDeleteBuilder -{ - public function join(FromExp $from): JoinDeleteBuilder - { - return $this->addJoin(JoinDeleteBuilder::class, JoinType::Inner, $from); - } - - public function leftJoin(FromExp $from): JoinDeleteBuilder - { - return $this->addJoin(JoinDeleteBuilder::class, JoinType::Left, $from); - } - - public function rightJoin(FromExp $from): JoinDeleteBuilder - { - return $this->addJoin(JoinDeleteBuilder::class, JoinType::Right, $from); - } - - public function crossJoin(FromExp $from): JoinDeleteBuilder - { - return $this->addJoin(JoinDeleteBuilder::class, JoinType::Cross, $from); - } - - /** - * Add an ORDER BY expression (single-table delete only). Refine it via - * {@see OrderByDeleteBuilder}. - */ - public function orderBy(Exp $exp): OrderByDeleteBuilder - { - return $this->addOrderBy(OrderByDeleteBuilder::class, $exp); - } - - /** - * Add a RETURNING clause (single-table delete only). Refine the output name of - * the last expression via {@see ReturningDeleteBuilder::as()}. - */ - public function returning(Exp $outputExpression, Exp ...$exps): ReturningDeleteBuilder - { - $returningItems = $this->returningItems; - foreach ([$outputExpression, ...array_values($exps)] as $exp) { - $returningItems[] = new ReturningItem($exp); - } - - return $this->derive(ReturningDeleteBuilder::class, returningItems: $returningItems); - } -} diff --git a/src/MariaDB/Builder/ForSelectBuilder.php b/src/MariaDB/Builder/ForSelectBuilder.php deleted file mode 100644 index bb77752..0000000 --- a/src/MariaDB/Builder/ForSelectBuilder.php +++ /dev/null @@ -1,17 +0,0 @@ -derive(OnDuplicateKeyUpdateInsertBuilder::class); - } - - /** - * Add a RETURNING clause. Refine the output name of the last expression via - * {@see ReturningInsertBuilder::as()}. - */ - public function returning(Exp $outputExpression, Exp ...$exps): ReturningInsertBuilder - { - $returningItems = $this->returningItems; - foreach ([$outputExpression, ...array_values($exps)] as $exp) { - $returningItems[] = new ReturningItem($exp); - } - - return $this->derive(ReturningInsertBuilder::class, returningItems: $returningItems); - } -} diff --git a/src/MariaDB/Builder/JoinDeleteBuilder.php b/src/MariaDB/Builder/JoinDeleteBuilder.php deleted file mode 100644 index a263ffb..0000000 --- a/src/MariaDB/Builder/JoinDeleteBuilder.php +++ /dev/null @@ -1,16 +0,0 @@ -rebuildLastWindowOrderBy(SortOrder::Asc); - } - - public function desc(): static - { - return $this->rebuildLastWindowOrderBy(SortOrder::Desc); - } - - private function rebuildLastWindowOrderBy(SortOrder $order): self - { - $def = $this->lastWindowDefinition(); - - $orderBys = $def->orderBys; - $lastIdx = array_key_last($orderBys); - assert($lastIdx !== null); - - $clause = $orderBys[$lastIdx]; - $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); - - return $this->deriveWindow(self::class, new WindowDefinition($def->existingWindowName, $def->partitionBy, $orderBys, $def->frame)); - } -} diff --git a/src/MariaDB/Builder/ReplaceBuilder.php b/src/MariaDB/Builder/ReplaceBuilder.php deleted file mode 100644 index 382a868..0000000 --- a/src/MariaDB/Builder/ReplaceBuilder.php +++ /dev/null @@ -1,29 +0,0 @@ -returningItems; - foreach ([$outputExpression, ...array_values($exps)] as $exp) { - $returningItems[] = new ReturningItem($exp); - } - - return $this->derive(ReturningReplaceBuilder::class, returningItems: $returningItems); - } -} diff --git a/src/MariaDB/Builder/SelectBuilder.php b/src/MariaDB/Builder/SelectBuilder.php deleted file mode 100644 index 92a5266..0000000 --- a/src/MariaDB/Builder/SelectBuilder.php +++ /dev/null @@ -1,150 +0,0 @@ -addToSelectList(SelectSelectBuilder::class, array_values($exps)); - } - - /** - * Add a table / subquery to the FROM clause. - */ - public function from(FromExp $from): FromSelectBuilder - { - return $this->addFromItem(FromSelectBuilder::class, $from); - } - - public function join(FromExp $from): JoinSelectBuilder - { - return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Inner, $from, false); - } - - public function leftJoin(FromExp $from): JoinSelectBuilder - { - return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Left, $from, false); - } - - public function rightJoin(FromExp $from): JoinSelectBuilder - { - return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Right, $from, false); - } - - public function crossJoin(FromExp $from): JoinSelectBuilder - { - return $this->addJoinItem(JoinSelectBuilder::class, JoinType::Cross, $from, false); - } - - /** - * Add a WHERE condition. Multiple calls are joined with AND. - */ - public function where(Exp $cond): SelectBuilder - { - return $this->addWhereCondition(SelectBuilder::class, $cond); - } - - /** - * Add expressions to the GROUP BY clause. Refine with - * {@see GroupBySelectBuilder::withRollup()}. - */ - public function groupBy(Exp ...$exps): GroupBySelectBuilder - { - return $this->addGroupByExps(GroupBySelectBuilder::class, array_values($exps)); - } - - /** - * Add a HAVING condition. Multiple calls are joined with AND. - */ - public function having(Exp $cond): SelectBuilder - { - return $this->addHavingCondition(SelectBuilder::class, $cond); - } - - /** - * Add a named window to the WINDOW clause. - */ - public function window(string $name): WindowSelectBuilder - { - return $this->addNamedWindow(WindowSelectBuilder::class, $name); - } - - /** - * Add an ORDER BY expression (refine it via {@see OrderBySelectBuilder}). - */ - public function orderBy(Exp $exp): OrderBySelectBuilder - { - return $this->addOrderByExp(OrderBySelectBuilder::class, $exp); - } - - public function limit(Exp $exp): SelectBuilder - { - return $this->withLimit(SelectBuilder::class, $exp); - } - - public function offset(Exp $exp): SelectBuilder - { - return $this->withOffset(SelectBuilder::class, $exp); - } - - /** - * Combine this select with the following one using UNION. Refine with - * {@see CombinationBuilder::all()} or supply the query via - * {@see CombinationBuilder::query()}. - */ - public function union(): CombinationBuilder - { - return $this->startCombination(CombinationBuilder::class, CombinationType::Union); - } - - public function intersect(): CombinationBuilder - { - return $this->startCombination(CombinationBuilder::class, CombinationType::Intersect); - } - - public function except(): CombinationBuilder - { - return $this->startCombination(CombinationBuilder::class, CombinationType::Except); - } - - public function forUpdate(): ForSelectBuilder - { - return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR UPDATE')); - } - - /** - * Lock the selected rows for sharing (`LOCK IN SHARE MODE`). - */ - public function forShare(): SelectBuilder - { - return $this->withLocking(SelectBuilder::class, new LockingClause('LOCK IN SHARE MODE')); - } - - /** - * Append the given WITH queries to this select's WITH clause. - */ - public function appendWith(WithBuilder $with): SelectBuilder - { - return $this->withAppendedWith(SelectBuilder::class, $with->withQueryItems()); - } -} diff --git a/src/MariaDB/Builder/SelectSelectBuilder.php b/src/MariaDB/Builder/SelectSelectBuilder.php deleted file mode 100644 index 011ee32..0000000 --- a/src/MariaDB/Builder/SelectSelectBuilder.php +++ /dev/null @@ -1,17 +0,0 @@ -lastWindowDefinition(); - - return $this->deriveWindow(OrderByWindowSelectBuilder::class, new WindowDefinition( - $def->existingWindowName, - $def->partitionBy, - [...$def->orderBys, new OrderByClause($exp)], - $def->frame, - )); - } - - /** - * Bound the current window's frame in `ROWS` units. - */ - public function rows(FrameBound $start, ?FrameBound $end = null): WindowSelectBuilder - { - return $this->withWindowFrame(new WindowFrame(FrameUnits::Rows, $start, $end)); - } - - /** - * Bound the current window's frame in `RANGE` units. - */ - public function range(FrameBound $start, ?FrameBound $end = null): WindowSelectBuilder - { - return $this->withWindowFrame(new WindowFrame(FrameUnits::Range, $start, $end)); - } - - private function withWindowFrame(WindowFrame $frame): WindowSelectBuilder - { - $def = $this->lastWindowDefinition(); - - return $this->deriveWindow(WindowSelectBuilder::class, new WindowDefinition( - $def->existingWindowName, - $def->partitionBy, - $def->orderBys, - $frame, - )); - } - - protected function lastWindowDefinition(): WindowDefinition - { - $windows = $this->parts->windows; - $lastIdx = array_key_last($windows); - assert($lastIdx !== null); - - return $windows[$lastIdx]->definition; - } - - /** - * Return a builder of the given type with the last named window's definition - * replaced. - * - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function deriveWindow(string $class, WindowDefinition $definition): AbstractSelectBuilder - { - $windows = $this->parts->windows; - $lastIdx = array_key_last($windows); - assert($lastIdx !== null); - - $windows[$lastIdx] = new NamedWindow($windows[$lastIdx]->name, $definition); - - return $this->derive($class, windows: $windows); - } -} diff --git a/src/MariaDB/Builder/WindowSelectBuilder.php b/src/MariaDB/Builder/WindowSelectBuilder.php deleted file mode 100644 index 83d5233..0000000 --- a/src/MariaDB/Builder/WindowSelectBuilder.php +++ /dev/null @@ -1,41 +0,0 @@ -lastWindowDefinition(); - - return $this->deriveWindow(self::class, new WindowDefinition($existingWindowName, $def->partitionBy, $def->orderBys, $def->frame)); - } - - public function partitionBy(Exp $exp, Exp ...$exps): self - { - $def = $this->lastWindowDefinition(); - - return $this->deriveWindow(self::class, new WindowDefinition( - $def->existingWindowName, - [...$def->partitionBy, $exp, ...array_values($exps)], - $def->orderBys, - $def->frame, - )); - } -} diff --git a/src/MariaDB/Builder/WithBuilder.php b/src/MariaDB/Builder/WithBuilder.php deleted file mode 100644 index 9243d83..0000000 --- a/src/MariaDB/Builder/WithBuilder.php +++ /dev/null @@ -1,64 +0,0 @@ - $withQueries - */ - public function __construct( - private readonly array $withQueries, - ) { - } - - /** - * The WITH query items, for appending to another select via - * {@see SelectBuilder::appendWith()}. - * - * @return list - */ - public function withQueryItems(): array - { - return $this->withQueries; - } - - /** - * Add another WITH query (its body is supplied via {@see WithWithBuilder::as()}). - */ - public function with(string $queryName): WithWithBuilder - { - return $this->startWithQuery($queryName, false); - } - - /** - * Add another WITH RECURSIVE query. - */ - public function withRecursive(string $queryName): WithWithBuilder - { - return $this->startWithQuery($queryName, true); - } - - private function startWithQuery(string $queryName, bool $recursive): WithWithBuilder - { - return new WithWithBuilder([...$this->withQueries, new WithQueryItem($recursive, $queryName)]); - } - - /** - * Start the SELECT statement following the WITH clause. - */ - public function select(Exp ...$exps): SelectSelectBuilder - { - return (new SelectBuilder(withQueries: $this->withQueries))->select(...$exps); - } -} diff --git a/src/MariaDB/Builder/WithWithBuilder.php b/src/MariaDB/Builder/WithWithBuilder.php deleted file mode 100644 index 8d80e23..0000000 --- a/src/MariaDB/Builder/WithWithBuilder.php +++ /dev/null @@ -1,54 +0,0 @@ - $withQueries - */ - public function __construct( - private readonly array $withQueries, - ) { - } - - /** - * Set the column names for the currently started WITH query. - */ - public function columnNames(string ...$names): self - { - $withQueries = $this->withQueries; - $lastIdx = array_key_last($withQueries); - assert($lastIdx !== null); - - $item = $withQueries[$lastIdx]; - $withQueries[$lastIdx] = new WithQueryItem($item->recursive, $item->queryName, array_values($names), $item->query); - - return new self($withQueries); - } - - /** - * Supply the body for the currently started WITH query. - */ - public function as(WithQuery $query): WithBuilder - { - $withQueries = $this->withQueries; - $lastIdx = array_key_last($withQueries); - assert($lastIdx !== null); - - $item = $withQueries[$lastIdx]; - $withQueries[$lastIdx] = new WithQueryItem($item->recursive, $item->queryName, $item->columnNames, $query); - - return new WithBuilder($withQueries); - } -} diff --git a/src/MariaDB/Q.php b/src/MariaDB/Q.php deleted file mode 100644 index 8eab847..0000000 --- a/src/MariaDB/Q.php +++ /dev/null @@ -1,98 +0,0 @@ -select(...$exps); - } - - /** - * Start an INSERT statement into the given table. - */ - public static function insertInto(IdentExp $tableName): InsertBuilder - { - return new InsertBuilder($tableName); - } - - /** - * Reference the value of `column` from the row that would have been inserted, - * for use inside `ON DUPLICATE KEY UPDATE` (rendered as `VALUES(column)`). - */ - public static function inserted(string $column): FuncExp - { - return new FuncExp('VALUES', [IdentExp::n($column)]); - } - - /** - * Start a REPLACE statement into the given table. - */ - public static function replaceInto(IdentExp $tableName): ReplaceBuilder - { - return new ReplaceBuilder($tableName); - } - - /** - * Start an UPDATE statement on the given table. - */ - public static function update(IdentExp $tableName): UpdateBuilder - { - return new UpdateBuilder($tableName); - } - - /** - * Start a DELETE statement on the given table. - */ - public static function deleteFrom(IdentExp $tableName): DeleteBuilder - { - return new DeleteBuilder($tableName); - } - - /** - * Start a WITH clause. Supply the query body via {@see WithWithBuilder::as()}. - */ - public static function with(string $queryName): WithWithBuilder - { - return new WithWithBuilder([new WithQueryItem(false, $queryName)]); - } - - /** - * Start a WITH RECURSIVE clause. - */ - public static function withRecursive(string $queryName): WithWithBuilder - { - return new WithWithBuilder([new WithQueryItem(true, $queryName)]); - } -} diff --git a/src/MariaDB/Q/Func.php b/src/MariaDB/Q/Func.php deleted file mode 100644 index da38e9d..0000000 --- a/src/MariaDB/Q/Func.php +++ /dev/null @@ -1,85 +0,0 @@ - $withQueries the leading WITH clause, if any - * @param list $joins additional joined tables (multi-table delete) - * @param list $whereConjunction conditions joined with AND - * @param list $orderBys - * @param list $returningItems - */ - public function __construct( - protected readonly IdentExp $tableName, - protected readonly array $withQueries = [], - protected readonly string $alias = '', - protected readonly array $joins = [], - protected readonly array $whereConjunction = [], - protected readonly array $orderBys = [], - protected readonly ?Exp $limit = null, - protected readonly array $returningItems = [], - ) { - } - - /** - * Set an alias for the target table. - */ - public function as(string $alias): static - { - return $this->derive(static::class, alias: $alias); - } - - /** - * Add a WHERE condition. Multiple calls are joined with AND. - */ - public function where(Exp $cond): static - { - return $this->derive(static::class, whereConjunction: [...$this->whereConjunction, $cond]); - } - - /** - * Limit the number of rows deleted (single-table delete only). - */ - public function limit(Exp $exp): static - { - return $this->derive(static::class, limit: $exp); - } - - /** - * @template T of AbstractDeleteBuilder - * @param class-string $class - * @return T - */ - protected function addJoin(string $class, JoinType $joinType, FromExp $from): AbstractDeleteBuilder - { - return $this->derive($class, joins: [...$this->joins, new FromItem(new Join($joinType, false, $from))]); - } - - /** - * @template T of AbstractDeleteBuilder - * @param class-string $class - * @return T - */ - protected function addOrderBy(string $class, Exp $exp): AbstractDeleteBuilder - { - return $this->derive($class, orderBys: [...$this->orderBys, new OrderByClause($exp)]); - } - - /** - * @template T of AbstractDeleteBuilder - * @param class-string $class - * @param list|null $joins - * @param list|null $whereConjunction - * @param list|null $orderBys - * @param list|null $returningItems - * @return T - */ - protected function derive( - string $class, - ?string $alias = null, - ?array $joins = null, - ?array $whereConjunction = null, - ?array $orderBys = null, - ?Exp $limit = null, - ?array $returningItems = null, - ): AbstractDeleteBuilder { - return new $class( - $this->tableName, - $this->withQueries, - $alias ?? $this->alias, - $joins ?? $this->joins, - $whereConjunction ?? $this->whereConjunction, - $orderBys ?? $this->orderBys, - $limit ?? $this->limit, - $returningItems ?? $this->returningItems, - ); - } - - /** - * @internal - */ - public function writeSql(SqlBuilder $sb): void - { - $this->innerWriteSql($sb); - } - - /** - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - // ORDER BY / LIMIT / RETURNING bound or read a single target; they are not - // part of the multi-table grammar. - if ($this->joins !== [] && ($this->orderBys !== [] || $this->limit !== null || $this->returningItems !== [])) { - $sb->addError(new QueryBuilderException('delete: ORDER BY / LIMIT / RETURNING not allowed in a multi-table delete')); - - return; - } - - if ($this->withQueries !== []) { - $this->writeWithQueries($sb, $this->withQueries); - } - - if ($this->joins !== []) { - $this->writeMultiTable($sb); - - return; - } - - $sb->writeString('DELETE FROM '); - $this->tableName->writeSql($sb); - if ($this->alias !== '') { - $sb->writeString(' AS ' . $this->alias); - } - - $this->writeWhere($sb); - - if ($this->orderBys !== []) { - $sb->writeString(' ORDER BY '); - foreach ($this->orderBys as $i => $clause) { - if ($i > 0) { - $sb->writeString(','); - } - $clause->writeSql($sb); - } - } - - if ($this->limit !== null) { - $sb->writeString(' LIMIT '); - $this->limit->writeSql($sb); - } - - if ($this->returningItems !== []) { - $this->writeReturning($sb, $this->returningItems); - } - } - - private function writeMultiTable(SqlBuilder $sb): void - { - // The target rows are named before FROM (`tbl.*`), the join graph after it. - $targetRef = ($this->alias !== '' ? $this->alias : $this->tableName->ident()) . '.*'; - $sb->writeString('DELETE '); - IdentExp::n($targetRef)->writeSql($sb); - - $sb->writeString(' FROM '); - $this->tableName->writeSql($sb); - if ($this->alias !== '') { - $sb->writeString(' AS ' . $this->alias); - } - foreach ($this->joins as $join) { - $sb->writeString(' '); - $join->writeSql($sb); - } - - $this->writeWhere($sb); - } - - private function writeWhere(SqlBuilder $sb): void - { - if ($this->whereConjunction !== []) { - $sb->writeString(' WHERE '); - Junction::and(...$this->whereConjunction)->writeSql($sb); - } - } -} diff --git a/src/MySQL/Builder/AbstractInsertBuilder.php b/src/MySQL/Builder/AbstractInsertBuilder.php deleted file mode 100644 index bf2e9e9..0000000 --- a/src/MySQL/Builder/AbstractInsertBuilder.php +++ /dev/null @@ -1,229 +0,0 @@ - $columnNames - * @param list> $valueLists - * @param list $onDuplicateKeyUpdateSetItems - * @param list $returningItems - */ - public function __construct( - protected readonly IdentExp $tableName, - protected readonly bool $ignore = false, - protected readonly array $columnNames = [], - protected readonly bool $defaultValues = false, - protected readonly array $valueLists = [], - protected readonly ?AbstractSelectBuilder $query = null, - protected readonly array $onDuplicateKeyUpdateSetItems = [], - protected readonly array $returningItems = [], - ) { - } - - /** - * Demote insert errors (e.g. duplicate-key, foreign-key) to warnings so the - * offending rows are skipped instead of aborting the statement. - */ - public function ignore(): static - { - return $this->derive(static::class, ignore: true); - } - - /** - * Set the column names to insert into. - */ - public function columnNames(string $columnName, string ...$rest): static - { - return $this->derive(static::class, columnNames: array_values([$columnName, ...$rest])); - } - - /** - * Insert a row consisting entirely of default values, rendered as `() VALUES ()`. - * Calling {@see values()} afterwards overrules this. - */ - public function defaultValues(): static - { - return $this->derive(static::class, defaultValues: true); - } - - /** - * Append a row of values to insert. Call multiple times to insert several rows. - */ - public function values(Exp ...$values): static - { - return $this->derive(static::class, valueLists: [...$this->valueLists, array_values($values)]); - } - - /** - * Set the column names and values from the given map (column name => value). - * Values are bound as arguments and the column order is stable (sorted by name). - * Overwrites any previous column names and values. - * - * @param array $map - */ - public function setMap(array $map): static - { - ksort($map, SORT_STRING); - - $values = []; - foreach ($map as $value) { - $values[] = new Arg($value); - } - - return $this->derive(static::class, columnNames: array_keys($map), valueLists: [$values]); - } - - /** - * Insert the result of the given select query. - */ - public function query(AbstractSelectBuilder $query): static - { - return $this->derive(static::class, query: $query); - } - - /** - * Assemble a new builder of the given type with the given fields replaced; a - * null argument keeps the current value. - * - * @template T of AbstractInsertBuilder - * @param class-string $class - * @param list|null $columnNames - * @param list>|null $valueLists - * @param list|null $onDuplicateKeyUpdateSetItems - * @param list|null $returningItems - * @return T - */ - protected function derive( - string $class, - ?bool $ignore = null, - ?array $columnNames = null, - ?bool $defaultValues = null, - ?array $valueLists = null, - ?AbstractSelectBuilder $query = null, - ?array $onDuplicateKeyUpdateSetItems = null, - ?array $returningItems = null, - ): AbstractInsertBuilder { - return new $class( - $this->tableName, - $ignore ?? $this->ignore, - $columnNames ?? $this->columnNames, - $defaultValues ?? $this->defaultValues, - $valueLists ?? $this->valueLists, - $query ?? $this->query, - $onDuplicateKeyUpdateSetItems ?? $this->onDuplicateKeyUpdateSetItems, - $returningItems ?? $this->returningItems, - ); - } - - /** - * Emit the proposed-row alias that precedes `ON DUPLICATE KEY UPDATE`, if the - * dialect uses one. Called only when there are upsert assignments. - */ - protected function writeUpsertRowAlias(SqlBuilder $sb): void - { - } - - /** - * @internal - */ - public function writeSql(SqlBuilder $sb): void - { - $this->innerWriteSql($sb); - } - - /** - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - $sb->writeString($this->ignore ? 'INSERT IGNORE INTO ' : 'INSERT INTO '); - $this->tableName->writeSql($sb); - - if ($this->valueLists !== [] && $this->query !== null) { - $sb->addError(new QueryBuilderException('insert: cannot set both values and query')); - - return; - } - - // A row of all defaults is the empty column list with an empty value row. - if ($this->defaultValues) { - $sb->writeString(' () VALUES ()'); - } else { - $this->writeColumnNames($sb); - $this->writeSource($sb); - } - - if ($this->onDuplicateKeyUpdateSetItems !== []) { - $this->writeUpsertRowAlias($sb); - - $sb->writeString(' ON DUPLICATE KEY UPDATE '); - foreach ($this->onDuplicateKeyUpdateSetItems as $i => $item) { - if ($i > 0) { - $sb->writeString(','); - } - $item->writeSql($sb); - } - } - - if ($this->returningItems !== []) { - $this->writeReturning($sb, $this->returningItems); - } - } - - private function writeColumnNames(SqlBuilder $sb): void - { - if ($this->columnNames === []) { - return; - } - - $s = ' ('; - foreach ($this->columnNames as $i => $columnName) { - $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); - } - $sb->writeString($s . ')'); - } - - private function writeSource(SqlBuilder $sb): void - { - if ($this->query !== null) { - $sb->writeString(' '); - $this->query->innerWriteSql($sb); - - return; - } - - if ($this->valueLists === []) { - return; - } - - $sb->writeString(' VALUES '); - foreach ($this->valueLists as $i => $valueList) { - $sb->writeString(($i > 0 ? ',' : '') . '('); - foreach ($valueList as $j => $value) { - if ($j > 0) { - $sb->writeString(','); - } - $value->writeSql($sb); - } - $sb->writeString(')'); - } - } -} diff --git a/src/MySQL/Builder/AbstractReplaceBuilder.php b/src/MySQL/Builder/AbstractReplaceBuilder.php deleted file mode 100644 index 088ede7..0000000 --- a/src/MySQL/Builder/AbstractReplaceBuilder.php +++ /dev/null @@ -1,186 +0,0 @@ - $columnNames - * @param list> $valueLists - * @param list $returningItems - */ - public function __construct( - protected readonly IdentExp $tableName, - protected readonly array $columnNames = [], - protected readonly bool $defaultValues = false, - protected readonly array $valueLists = [], - protected readonly ?AbstractSelectBuilder $query = null, - protected readonly array $returningItems = [], - ) { - } - - /** - * Set the column names to replace into. - */ - public function columnNames(string $columnName, string ...$rest): static - { - return $this->derive(static::class, columnNames: array_values([$columnName, ...$rest])); - } - - /** - * Replace with a row consisting entirely of default values, rendered as - * `() VALUES ()`. Calling {@see values()} afterwards overrules this. - */ - public function defaultValues(): static - { - return $this->derive(static::class, defaultValues: true); - } - - /** - * Append a row of values. Call multiple times to replace several rows. - */ - public function values(Exp ...$values): static - { - return $this->derive(static::class, valueLists: [...$this->valueLists, array_values($values)]); - } - - /** - * Set the column names and values from the given map (column name => value). - * Values are bound as arguments and the column order is stable (sorted by name). - * Overwrites any previous column names and values. - * - * @param array $map - */ - public function setMap(array $map): static - { - ksort($map, SORT_STRING); - - $values = []; - foreach ($map as $value) { - $values[] = new Arg($value); - } - - return $this->derive(static::class, columnNames: array_keys($map), valueLists: [$values]); - } - - /** - * Replace with the result of the given select query. - */ - public function query(AbstractSelectBuilder $query): static - { - return $this->derive(static::class, query: $query); - } - - /** - * @template T of AbstractReplaceBuilder - * @param class-string $class - * @param list|null $columnNames - * @param list>|null $valueLists - * @param list|null $returningItems - * @return T - */ - protected function derive( - string $class, - ?array $columnNames = null, - ?bool $defaultValues = null, - ?array $valueLists = null, - ?AbstractSelectBuilder $query = null, - ?array $returningItems = null, - ): AbstractReplaceBuilder { - return new $class( - $this->tableName, - $columnNames ?? $this->columnNames, - $defaultValues ?? $this->defaultValues, - $valueLists ?? $this->valueLists, - $query ?? $this->query, - $returningItems ?? $this->returningItems, - ); - } - - /** - * @internal - */ - public function writeSql(SqlBuilder $sb): void - { - $this->innerWriteSql($sb); - } - - /** - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - $sb->writeString('REPLACE INTO '); - $this->tableName->writeSql($sb); - - if ($this->valueLists !== [] && $this->query !== null) { - $sb->addError(new QueryBuilderException('replace: cannot set both values and query')); - - return; - } - - if ($this->defaultValues) { - $sb->writeString(' () VALUES ()'); - } else { - $this->writeColumnNames($sb); - $this->writeSource($sb); - } - - if ($this->returningItems !== []) { - $this->writeReturning($sb, $this->returningItems); - } - } - - private function writeColumnNames(SqlBuilder $sb): void - { - if ($this->columnNames === []) { - return; - } - - $s = ' ('; - foreach ($this->columnNames as $i => $columnName) { - $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); - } - $sb->writeString($s . ')'); - } - - private function writeSource(SqlBuilder $sb): void - { - if ($this->query !== null) { - $sb->writeString(' '); - $this->query->innerWriteSql($sb); - - return; - } - - if ($this->valueLists === []) { - return; - } - - $sb->writeString(' VALUES '); - foreach ($this->valueLists as $i => $valueList) { - $sb->writeString(($i > 0 ? ',' : '') . '('); - foreach ($valueList as $j => $value) { - if ($j > 0) { - $sb->writeString(','); - } - $value->writeSql($sb); - } - $sb->writeString(')'); - } - } -} diff --git a/src/MySQL/Builder/AbstractSelectBuilder.php b/src/MySQL/Builder/AbstractSelectBuilder.php deleted file mode 100644 index 18fa5e3..0000000 --- a/src/MySQL/Builder/AbstractSelectBuilder.php +++ /dev/null @@ -1,386 +0,0 @@ - $withQueries the leading WITH clause, if any - * @param list $combinations previous selects combined via UNION / INTERSECT / EXCEPT - */ - public function __construct( - protected readonly SelectQueryParts $parts = new SelectQueryParts(), - protected readonly array $withQueries = [], - protected readonly array $combinations = [], - ) { - } - - /** - * Whether this builder carries no content yet: no WITH queries, no - * combinations and empty select parts. Useful for conditional query building. - */ - public function isEmpty(): bool - { - return $this->withQueries === [] && $this->combinations === [] && $this->parts->isEmpty(); - } - - // Clause helpers — the field logic lives here once; each dialect's concrete - // builder exposes the public transitions that call these with its own target - // class and declare the matching concrete return type. - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @param list $exps - * @return T - */ - protected function addToSelectList(string $class, array $exps): AbstractSelectBuilder - { - $selectList = $this->parts->selectList; - foreach ($exps as $exp) { - $selectList[] = new OutputExpr($exp); - } - - return $this->derive($class, selectList: $selectList); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function addFromItem(string $class, FromExp $from, bool $lateral = false): AbstractSelectBuilder - { - return $this->derive($class, from: [...$this->parts->from, new FromItem($from, lateral: $lateral)]); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function addJoinItem(string $class, JoinType $joinType, FromExp $from, bool $lateral): AbstractSelectBuilder - { - return $this->derive($class, from: [...$this->parts->from, new FromItem(new Join($joinType, $lateral, $from))]); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function addWhereCondition(string $class, Exp $cond): AbstractSelectBuilder - { - return $this->derive($class, whereConjunction: [...$this->parts->whereConjunction, $cond]); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @param list $exps - * @return T - */ - protected function addGroupByExps(string $class, array $exps): AbstractSelectBuilder - { - return $this->derive($class, groupBys: [...$this->parts->groupBys, ...$exps]); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function addHavingCondition(string $class, Exp $cond): AbstractSelectBuilder - { - return $this->derive($class, havingConjunction: [...$this->parts->havingConjunction, $cond]); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function addOrderByExp(string $class, Exp $exp): AbstractSelectBuilder - { - return $this->derive($class, orderBys: [...$this->parts->orderBys, new OrderByClause($exp)]); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function withLimit(string $class, Exp $exp): AbstractSelectBuilder - { - return $this->derive($class, limit: $exp); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function withOffset(string $class, Exp $exp): AbstractSelectBuilder - { - return $this->derive($class, offset: $exp); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function startCombination(string $class, CombinationType $type): AbstractSelectBuilder - { - // Archive the current parts as a combination and start a fresh select. - return $this->derive( - $class, - parts: new SelectQueryParts(), - combinations: [...$this->combinations, new Combination($this->parts, $type)], - ); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function withLocking(string $class, LockingClause $lockingClause): AbstractSelectBuilder - { - return $this->derive($class, lockingClause: $lockingClause); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @return T - */ - protected function addNamedWindow(string $class, string $name): AbstractSelectBuilder - { - return $this->derive($class, windows: [...$this->parts->windows, new NamedWindow($name)]); - } - - /** - * @template T of AbstractSelectBuilder - * @param class-string $class - * @param list $withQueries - * @return T - */ - protected function withAppendedWith(string $class, array $withQueries): AbstractSelectBuilder - { - return $this->derive($class, withQueries: [...$this->withQueries, ...$withQueries]); - } - - /** - * Assemble a new builder of the given type from the current state with the - * given fields replaced; a null argument keeps the current value. This is the - * single place where a derived {@see SelectQueryParts} and the type-state - * transition are produced. - * - * @template T of AbstractSelectBuilder - * @param class-string $class - * @param list|null $selectList - * @param list|null $from - * @param list|null $whereConjunction - * @param list|null $groupBys - * @param list|null $havingConjunction - * @param list|null $windows - * @param list|null $orderBys - * @param list|null $withQueries - * @param list|null $combinations - * @return T - */ - protected function derive( - string $class, - ?SelectQueryParts $parts = null, - ?bool $distinct = null, - ?array $selectList = null, - ?array $from = null, - ?array $whereConjunction = null, - ?array $groupBys = null, - ?bool $groupByWithRollup = null, - ?array $havingConjunction = null, - ?array $windows = null, - ?array $orderBys = null, - ?Exp $limit = null, - ?Exp $offset = null, - ?LockingClause $lockingClause = null, - ?array $withQueries = null, - ?array $combinations = null, - ): AbstractSelectBuilder { - $parts ??= new SelectQueryParts( - distinct: $distinct ?? $this->parts->distinct, - selectList: $selectList ?? $this->parts->selectList, - from: $from ?? $this->parts->from, - whereConjunction: $whereConjunction ?? $this->parts->whereConjunction, - groupBys: $groupBys ?? $this->parts->groupBys, - groupByWithRollup: $groupByWithRollup ?? $this->parts->groupByWithRollup, - havingConjunction: $havingConjunction ?? $this->parts->havingConjunction, - windows: $windows ?? $this->parts->windows, - orderBys: $orderBys ?? $this->parts->orderBys, - limit: $limit ?? $this->parts->limit, - offset: $offset ?? $this->parts->offset, - lockingClause: $lockingClause ?? $this->parts->lockingClause, - ); - - return new $class($parts, $withQueries ?? $this->withQueries, $combinations ?? $this->combinations); - } - - /** - * Write the select without the surrounding parentheses (the top-level query). - * - * @internal - */ - public function innerWriteSql(SqlBuilder $sb): void - { - if ($this->withQueries !== []) { - $this->writeWithQueries($sb, $this->withQueries); - } - - // Previous selects combined via UNION / INTERSECT / EXCEPT come first. - foreach ($this->combinations as $c) { - $this->writeSelectParts($sb, $c->parts); - $s = ' ' . $c->type->value; - if ($c->all) { - $s .= ' ALL'; - } - $sb->writeString($s . ' '); - $c->query?->writeSql($sb); - } - - if (!$this->parts->isEmpty()) { - $this->writeSelectParts($sb, $this->parts); - } - - if ($this->parts->orderBys !== []) { - $sb->writeString(' ORDER BY '); - foreach ($this->parts->orderBys as $i => $clause) { - if ($i > 0) { - $sb->writeString(','); - } - $clause->writeSql($sb); - } - } - - if ($this->parts->limit !== null) { - $sb->writeString(' LIMIT '); - $this->parts->limit->writeSql($sb); - } - - if ($this->parts->offset !== null) { - $sb->writeString(' OFFSET '); - $this->parts->offset->writeSql($sb); - } - - if ($this->parts->lockingClause !== null) { - $sb->writeString(' '); - $this->parts->lockingClause->writeSql($sb); - } - } - - private function writeSelectParts(SqlBuilder $sb, SelectQueryParts $parts): void - { - // Accumulate literal SQL into $s and only flush before a nested writer - // needs to write (and once at the very end). - $sb->writeString('SELECT '); - $s = $parts->distinct ? 'DISTINCT ' : ''; - $needComma = false; - - foreach ($parts->selectList as $out) { - if ($needComma) { - $s .= ','; - } - $sb->writeString($s); - $s = ''; - - $out->exp->writeSql($sb); - - if ($out->alias !== '') { - $s = ' AS ' . $out->alias; - } - $needComma = true; - } - - if ($parts->from !== []) { - $s .= ' FROM '; - foreach ($parts->from as $i => $fromItem) { - if ($i > 0) { - // A join attaches to the previous item; a plain item is comma-separated. - $s .= $fromItem->from instanceof Join ? ' ' : ','; - } - $sb->writeString($s); - $s = ''; - - $fromItem->writeSql($sb); - } - } - - if ($parts->whereConjunction !== []) { - $sb->writeString($s . ' WHERE '); - $s = ''; - - Junction::and(...$parts->whereConjunction)->writeSql($sb); - } - - if ($parts->groupBys !== []) { - $sb->writeString($s . ' GROUP BY '); - $s = ''; - - foreach ($parts->groupBys as $i => $groupBy) { - if ($i > 0) { - $sb->writeString(','); - } - $groupBy->writeSql($sb); - } - if ($parts->groupByWithRollup) { - $s = ' WITH ROLLUP'; - } - } - - if ($parts->havingConjunction !== []) { - $sb->writeString($s . ' HAVING '); - $s = ''; - - Junction::and(...$parts->havingConjunction)->writeSql($sb); - } - - if ($parts->windows !== []) { - $sb->writeString($s . ' WINDOW '); - $s = ''; - - foreach ($parts->windows as $i => $window) { - if ($i > 0) { - $sb->writeString(','); - } - $window->writeSql($sb); - } - } - - if ($s !== '') { - $sb->writeString($s); - } - } -} diff --git a/src/MySQL/Builder/AddsUpsertAssignment.php b/src/MySQL/Builder/AddsUpsertAssignment.php index 758d8b0..3142be4 100644 --- a/src/MySQL/Builder/AddsUpsertAssignment.php +++ b/src/MySQL/Builder/AddsUpsertAssignment.php @@ -5,17 +5,17 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * The `ON DUPLICATE KEY UPDATE` assignment refinement, shared by both dialects' - * `OnDuplicateKeyUpdateInsertBuilder`. + * The `ON DUPLICATE KEY UPDATE` assignment refinement. * * @internal - * @phpstan-require-extends AbstractInsertBuilder + * @phpstan-require-extends InsertBuilder */ trait AddsUpsertAssignment { /** - * Add a `column = value` assignment. Reference the row that would have been - * inserted via `Q::inserted('col')`. + * Add a `column = value` assignment. Reference the proposed row via + * `Q::values('col')`, or via `Q::n('new.col')` after aliasing it with + * {@see InsertValuesBuilder::as()}. */ public function set(string $columnName, Exp $value): static { diff --git a/src/MySQL/Builder/AggBuilder.php b/src/MySQL/Builder/AggBuilder.php index 55da42d..88a05c2 100644 --- a/src/MySQL/Builder/AggBuilder.php +++ b/src/MySQL/Builder/AggBuilder.php @@ -12,17 +12,19 @@ class AggBuilder extends ExpBase { /** * @param list $exps + * @param Requirement|null $requires the dialect this aggregate is available on, if it is dialect-specific */ public function __construct( protected readonly string $name, protected readonly array $exps, protected readonly bool $distinct = false, + protected readonly ?Requirement $requires = null, ) { } public function distinct(): self { - return new self($this->name, $this->exps, true); + return new self($this->name, $this->exps, true, $this->requires); } /** @@ -38,6 +40,10 @@ public function over(string $existingWindowName = ''): WindowFuncCallBuilder public function writeSql(SqlBuilder $sb): void { + if ($this->requires !== null) { + $sb->requireAnyDialect($this->name, $this->requires); + } + $sb->writeString($this->name . '(' . ($this->distinct ? 'DISTINCT ' : '')); foreach ($this->exps as $i => $exp) { if ($i > 0) { diff --git a/src/MySQL/Builder/AliasesLastFromItem.php b/src/MySQL/Builder/AliasesLastFromItem.php index d3f100c..dce89ae 100644 --- a/src/MySQL/Builder/AliasesLastFromItem.php +++ b/src/MySQL/Builder/AliasesLastFromItem.php @@ -9,7 +9,7 @@ * aliases. Shared by both dialects' `FromSelectBuilder`. * * @internal - * @phpstan-require-extends AbstractSelectBuilder + * @phpstan-require-extends SelectBuilder */ trait AliasesLastFromItem { diff --git a/src/MySQL/Builder/AliasesLastOutput.php b/src/MySQL/Builder/AliasesLastOutput.php index acff6c7..30dadaa 100644 --- a/src/MySQL/Builder/AliasesLastOutput.php +++ b/src/MySQL/Builder/AliasesLastOutput.php @@ -10,7 +10,7 @@ * `SelectSelectBuilder`. * * @internal - * @phpstan-require-extends AbstractSelectBuilder + * @phpstan-require-extends SelectBuilder */ trait AliasesLastOutput { diff --git a/src/MySQL/Builder/AppliesRollup.php b/src/MySQL/Builder/AppliesRollup.php index e390eaa..3c32ee5 100644 --- a/src/MySQL/Builder/AppliesRollup.php +++ b/src/MySQL/Builder/AppliesRollup.php @@ -9,7 +9,7 @@ * dialects' `GroupBySelectBuilder`. * * @internal - * @phpstan-require-extends AbstractSelectBuilder + * @phpstan-require-extends SelectBuilder */ trait AppliesRollup { diff --git a/src/MySQL/Builder/Combination.php b/src/MySQL/Builder/Combination.php index 47bd894..cbbd290 100644 --- a/src/MySQL/Builder/Combination.php +++ b/src/MySQL/Builder/Combination.php @@ -17,7 +17,7 @@ public function __construct( public readonly SelectQueryParts $parts, public readonly CombinationType $type, public readonly bool $all = false, - public readonly ?AbstractSelectBuilder $query = null, + public readonly ?SelectBuilder $query = null, ) { } } diff --git a/src/MySQL/Builder/DeleteBuilder.php b/src/MySQL/Builder/DeleteBuilder.php index 295734a..b75ec04 100644 --- a/src/MySQL/Builder/DeleteBuilder.php +++ b/src/MySQL/Builder/DeleteBuilder.php @@ -5,11 +5,65 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds a MySQL DELETE statement (single-table or, via {@see join()}, multi-table - * `DELETE tbl.* FROM `). + * Builds a DELETE statement. + * + * A single-table delete renders `DELETE FROM tbl ...` and may carry `ORDER BY`, + * `LIMIT` and `RETURNING`. Joining further tables via {@see join()} turns it into a + * multi-table delete (`DELETE tbl.* FROM tbl JOIN ...`), where those clauses are not + * allowed. A leading `WITH` and `RETURNING` mark themselves while rendering, so + * validating against a {@see Target} reports the ones the target cannot express. + * + * Immutable: every method returns a new instance; a derived copy is assembled only + * by {@see derive()}. */ -class DeleteBuilder extends AbstractDeleteBuilder +class DeleteBuilder implements InnerSqlWriter { + use RendersWithQueries; + use RendersReturning; + + /** + * @param list $withQueries the leading WITH clause, if any + * @param list $joins additional joined tables (multi-table delete) + * @param list $whereConjunction conditions joined with AND + * @param list $orderBys + * @param list $returningItems + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly array $withQueries = [], + protected readonly string $alias = '', + protected readonly array $joins = [], + protected readonly array $whereConjunction = [], + protected readonly array $orderBys = [], + protected readonly ?Exp $limit = null, + protected readonly array $returningItems = [], + ) { + } + + /** + * Set an alias for the target table. + */ + public function as(string $alias): static + { + return $this->derive(static::class, alias: $alias); + } + + /** + * Add a WHERE condition. Multiple calls are joined with AND. + */ + public function where(Exp $cond): static + { + return $this->derive(static::class, whereConjunction: [...$this->whereConjunction, $cond]); + } + + /** + * Limit the number of rows deleted (single-table delete only). + */ + public function limit(Exp $exp): static + { + return $this->derive(static::class, limit: $exp); + } + /** * Join another table, turning this into a multi-table delete. Refine it via * {@see JoinDeleteBuilder::on()} / {@see JoinDeleteBuilder::using()} / @@ -43,4 +97,156 @@ public function orderBy(Exp $exp): OrderByDeleteBuilder { return $this->addOrderBy(OrderByDeleteBuilder::class, $exp); } + + /** + * Add a RETURNING clause (single-table delete only). Refine the output name of + * the last expression via {@see ReturningDeleteBuilder::as()}. + */ + public function returning(Exp $outputExpression, Exp ...$exps): ReturningDeleteBuilder + { + $returningItems = $this->returningItems; + foreach ([$outputExpression, ...array_values($exps)] as $exp) { + $returningItems[] = new ReturningItem($exp); + } + + return $this->derive(ReturningDeleteBuilder::class, returningItems: $returningItems); + } + + /** + * @template T of DeleteBuilder + * @param class-string $class + * @return T + */ + protected function addJoin(string $class, JoinType $joinType, FromExp $from): DeleteBuilder + { + return $this->derive($class, joins: [...$this->joins, new FromItem(new Join($joinType, false, $from))]); + } + + /** + * @template T of DeleteBuilder + * @param class-string $class + * @return T + */ + protected function addOrderBy(string $class, Exp $exp): DeleteBuilder + { + return $this->derive($class, orderBys: [...$this->orderBys, new OrderByClause($exp)]); + } + + /** + * @template T of DeleteBuilder + * @param class-string $class + * @param list|null $joins + * @param list|null $whereConjunction + * @param list|null $orderBys + * @param list|null $returningItems + * @return T + */ + protected function derive( + string $class, + ?string $alias = null, + ?array $joins = null, + ?array $whereConjunction = null, + ?array $orderBys = null, + ?Exp $limit = null, + ?array $returningItems = null, + ): DeleteBuilder { + return new $class( + $this->tableName, + $this->withQueries, + $alias ?? $this->alias, + $joins ?? $this->joins, + $whereConjunction ?? $this->whereConjunction, + $orderBys ?? $this->orderBys, + $limit ?? $this->limit, + $returningItems ?? $this->returningItems, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + // ORDER BY / LIMIT / RETURNING bind or read a single target; they are not + // part of the multi-table grammar. + if ($this->joins !== [] && ($this->orderBys !== [] || $this->limit !== null || $this->returningItems !== [])) { + $sb->addError(new QueryBuilderException('delete: ORDER BY / LIMIT / RETURNING not allowed in a multi-table delete')); + + return; + } + + if ($this->withQueries !== []) { + $sb->requireAnyDialect('WITH before DELETE', new Requirement(Dialect::Mysql), new Requirement(Dialect::MariaDb, gteVersion: '12.3')); + $this->writeWithQueries($sb, $this->withQueries); + } + + if ($this->joins !== []) { + $this->writeMultiTable($sb); + + return; + } + + $sb->writeString('DELETE FROM '); + $this->tableName->writeSql($sb); + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + + $this->writeWhere($sb); + + if ($this->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->limit !== null) { + $sb->writeString(' LIMIT '); + $this->limit->writeSql($sb); + } + + if ($this->returningItems !== []) { + $this->writeReturning($sb, $this->returningItems); + } + } + + private function writeMultiTable(SqlBuilder $sb): void + { + // The target rows are named before FROM (`tbl.*`), the join graph after it. + $targetRef = ($this->alias !== '' ? $this->alias : $this->tableName->ident()) . '.*'; + $sb->writeString('DELETE '); + IdentExp::n($targetRef)->writeSql($sb); + + $sb->writeString(' FROM '); + $this->tableName->writeSql($sb); + if ($this->alias !== '') { + $sb->writeString(' AS ' . $this->alias); + } + foreach ($this->joins as $join) { + $sb->writeString(' '); + $join->writeSql($sb); + } + + $this->writeWhere($sb); + } + + private function writeWhere(SqlBuilder $sb): void + { + if ($this->whereConjunction !== []) { + $sb->writeString(' WHERE '); + Junction::and(...$this->whereConjunction)->writeSql($sb); + } + } } diff --git a/src/MySQL/Builder/ExistsExp.php b/src/MySQL/Builder/ExistsExp.php index 549a52a..5172134 100644 --- a/src/MySQL/Builder/ExistsExp.php +++ b/src/MySQL/Builder/ExistsExp.php @@ -10,7 +10,7 @@ final class ExistsExp implements Exp { public function __construct( - private readonly AbstractSelectBuilder $subquery, + private readonly SelectBuilder $subquery, ) { } diff --git a/src/MySQL/Builder/ExpBase.php b/src/MySQL/Builder/ExpBase.php index 3dfba80..69183e0 100644 --- a/src/MySQL/Builder/ExpBase.php +++ b/src/MySQL/Builder/ExpBase.php @@ -97,7 +97,7 @@ public function mod(Exp $rgt): OpExp */ public function jsonExtract(Exp $rgt): OpExp { - return $this->op('->', $rgt); + return new OpExp($this, '->', $rgt, requires: new Requirement(Dialect::Mysql)); } /** @@ -105,7 +105,7 @@ public function jsonExtract(Exp $rgt): OpExp */ public function jsonExtractText(Exp $rgt): OpExp { - return $this->op('->>', $rgt); + return new OpExp($this, '->>', $rgt, requires: new Requirement(Dialect::Mysql)); } // Pattern matching diff --git a/src/MySQL/Builder/FromItem.php b/src/MySQL/Builder/FromItem.php index 36a8078..ae5a386 100644 --- a/src/MySQL/Builder/FromItem.php +++ b/src/MySQL/Builder/FromItem.php @@ -26,6 +26,7 @@ public function __construct( public function writeSql(SqlBuilder $sb): void { if ($this->lateral) { + $sb->requireDialect(Dialect::Mysql, 'LATERAL'); $sb->writeString('LATERAL '); } diff --git a/src/MySQL/Builder/FuncExp.php b/src/MySQL/Builder/FuncExp.php index c9c13ff..7b8ff64 100644 --- a/src/MySQL/Builder/FuncExp.php +++ b/src/MySQL/Builder/FuncExp.php @@ -11,15 +11,21 @@ final class FuncExp extends ExpBase { /** * @param list $args + * @param Requirement|null $requires the dialect this function is available on, if it is dialect-specific */ public function __construct( private readonly string $name, private readonly array $args, + private readonly ?Requirement $requires = null, ) { } public function writeSql(SqlBuilder $sb): void { + if ($this->requires !== null) { + $sb->requireAnyDialect($this->name, $this->requires); + } + $sb->writeString($this->name . '('); foreach ($this->args as $i => $arg) { if ($i > 0) { diff --git a/src/MySQL/Builder/InsertBuilder.php b/src/MySQL/Builder/InsertBuilder.php index 2f9b4a1..e7bcce0 100644 --- a/src/MySQL/Builder/InsertBuilder.php +++ b/src/MySQL/Builder/InsertBuilder.php @@ -5,15 +5,106 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds a MySQL INSERT statement. Adds the `ON DUPLICATE KEY UPDATE` entry point - * and emits the `AS new` proposed-row alias that makes `Q::inserted('col')` - * (`new.col`) reachable in the upsert assignments. + * Builds an INSERT statement: the row source (value rows, a feeding SELECT, or a + * default-values row), an optional `ON DUPLICATE KEY UPDATE` upsert, and an + * optional `RETURNING` clause. + * + * The proposed-row alias (`AS new`, set via {@see InsertValuesBuilder::as()}) and + * `RETURNING` mark themselves while rendering, so validating the query against a + * {@see Target} reports the one the target cannot express. + * + * Immutable: every method returns a new instance; a derived copy is assembled only + * by {@see derive()}. */ -class InsertBuilder extends AbstractInsertBuilder +class InsertBuilder implements InnerSqlWriter { + use RendersReturning; + + /** + * @param list $columnNames + * @param list> $valueLists + * @param list $onDuplicateKeyUpdateSetItems + * @param list $returningItems + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly bool $ignore = false, + protected readonly array $columnNames = [], + protected readonly bool $defaultValues = false, + protected readonly array $valueLists = [], + protected readonly ?SelectBuilder $query = null, + protected readonly string $rowAlias = '', + protected readonly array $onDuplicateKeyUpdateSetItems = [], + protected readonly array $returningItems = [], + ) { + } + + /** + * Demote insert errors (e.g. duplicate-key, foreign-key) to warnings so the + * offending rows are skipped instead of aborting the statement. + */ + public function ignore(): static + { + return $this->derive(static::class, ignore: true); + } + + /** + * Set the column names to insert into. + */ + public function columnNames(string $columnName, string ...$rest): static + { + return $this->derive(static::class, columnNames: array_values([$columnName, ...$rest])); + } + + /** + * Insert a row consisting entirely of default values, rendered as `() VALUES ()`. + * Calling {@see values()} afterwards overrules this. + */ + public function defaultValues(): static + { + return $this->derive(static::class, defaultValues: true); + } + /** - * Add an `ON DUPLICATE KEY UPDATE` clause. Reference the row that would have - * been inserted via `Q::inserted('col')` (rendered as `new.col`). + * Append a row of values to insert. Call multiple times to insert several rows. + * Continue with {@see InsertValuesBuilder::as()} to alias the proposed row. + */ + public function values(Exp ...$values): InsertValuesBuilder + { + return $this->derive(InsertValuesBuilder::class, valueLists: [...$this->valueLists, array_values($values)]); + } + + /** + * Set the column names and values from the given map (column name => value). + * Values are bound as arguments and the column order is stable (sorted by name). + * Overwrites any previous column names and values. + * + * @param array $map + */ + public function setMap(array $map): InsertValuesBuilder + { + ksort($map, SORT_STRING); + + $values = []; + foreach ($map as $value) { + $values[] = new Arg($value); + } + + return $this->derive(InsertValuesBuilder::class, columnNames: array_keys($map), valueLists: [$values]); + } + + /** + * Insert the result of the given select query. + */ + public function query(SelectBuilder $query): static + { + return $this->derive(static::class, query: $query); + } + + /** + * Add an `ON DUPLICATE KEY UPDATE` clause. Reference the proposed row via + * `Q::values('col')`, or via `Q::n('new.col')` after aliasing it with + * {@see InsertValuesBuilder::as()}. */ public function onDuplicateKeyUpdate(): OnDuplicateKeyUpdateInsertBuilder { @@ -21,13 +112,143 @@ public function onDuplicateKeyUpdate(): OnDuplicateKeyUpdateInsertBuilder } /** - * The `AS new` row alias follows the value rows so the proposed row is reachable - * as `new.col`. It is not used with the `INSERT ... SELECT` source form. + * Add a RETURNING clause. Refine the output name of the last expression via + * {@see ReturningInsertBuilder::as()}. */ - protected function writeUpsertRowAlias(SqlBuilder $sb): void + public function returning(Exp $outputExpression, Exp ...$exps): ReturningInsertBuilder { - if ($this->query === null) { - $sb->writeString(' AS new'); + $returningItems = $this->returningItems; + foreach ([$outputExpression, ...array_values($exps)] as $exp) { + $returningItems[] = new ReturningItem($exp); + } + + return $this->derive(ReturningInsertBuilder::class, returningItems: $returningItems); + } + + /** + * Assemble a new builder of the given type with the given fields replaced; a + * null argument keeps the current value. + * + * @template T of InsertBuilder + * @param class-string $class + * @param list|null $columnNames + * @param list>|null $valueLists + * @param list|null $onDuplicateKeyUpdateSetItems + * @param list|null $returningItems + * @return T + */ + protected function derive( + string $class, + ?bool $ignore = null, + ?array $columnNames = null, + ?bool $defaultValues = null, + ?array $valueLists = null, + ?SelectBuilder $query = null, + ?string $rowAlias = null, + ?array $onDuplicateKeyUpdateSetItems = null, + ?array $returningItems = null, + ): InsertBuilder { + return new $class( + $this->tableName, + $ignore ?? $this->ignore, + $columnNames ?? $this->columnNames, + $defaultValues ?? $this->defaultValues, + $valueLists ?? $this->valueLists, + $query ?? $this->query, + $rowAlias ?? $this->rowAlias, + $onDuplicateKeyUpdateSetItems ?? $this->onDuplicateKeyUpdateSetItems, + $returningItems ?? $this->returningItems, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + $sb->writeString($this->ignore ? 'INSERT IGNORE INTO ' : 'INSERT INTO '); + $this->tableName->writeSql($sb); + + if ($this->valueLists !== [] && $this->query !== null) { + $sb->addError(new QueryBuilderException('insert: cannot set both values and query')); + + return; + } + + // A row of all defaults is the empty column list with an empty value row. + if ($this->defaultValues) { + $sb->writeString(' () VALUES ()'); + } else { + $this->writeColumnNames($sb); + $this->writeSource($sb); + } + + // The row alias follows the value rows; it makes the proposed row reachable + // as `alias.col`, and is not used with the `INSERT ... SELECT` source form. + if ($this->rowAlias !== '' && $this->query === null) { + $sb->requireDialect(Dialect::Mysql, 'the INSERT row alias (AS ...)'); + $sb->writeString(' AS ' . $this->rowAlias); + } + + if ($this->onDuplicateKeyUpdateSetItems !== []) { + $sb->writeString(' ON DUPLICATE KEY UPDATE '); + foreach ($this->onDuplicateKeyUpdateSetItems as $i => $item) { + if ($i > 0) { + $sb->writeString(','); + } + $item->writeSql($sb); + } + } + + if ($this->returningItems !== []) { + $this->writeReturning($sb, $this->returningItems); + } + } + + private function writeColumnNames(SqlBuilder $sb): void + { + if ($this->columnNames === []) { + return; + } + + $s = ' ('; + foreach ($this->columnNames as $i => $columnName) { + $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); + } + $sb->writeString($s . ')'); + } + + private function writeSource(SqlBuilder $sb): void + { + if ($this->query !== null) { + $sb->writeString(' '); + $this->query->innerWriteSql($sb); + + return; + } + + if ($this->valueLists === []) { + return; + } + + $sb->writeString(' VALUES '); + foreach ($this->valueLists as $i => $valueList) { + $sb->writeString(($i > 0 ? ',' : '') . '('); + foreach ($valueList as $j => $value) { + if ($j > 0) { + $sb->writeString(','); + } + $value->writeSql($sb); + } + $sb->writeString(')'); } } } diff --git a/src/MySQL/Builder/InsertValuesBuilder.php b/src/MySQL/Builder/InsertValuesBuilder.php new file mode 100644 index 0000000..d697548 --- /dev/null +++ b/src/MySQL/Builder/InsertValuesBuilder.php @@ -0,0 +1,22 @@ +derive(static::class, rowAlias: $rowAlias); + } +} diff --git a/src/MySQL/Builder/Join.php b/src/MySQL/Builder/Join.php index 24b16f1..9a7779f 100644 --- a/src/MySQL/Builder/Join.php +++ b/src/MySQL/Builder/Join.php @@ -28,6 +28,7 @@ public function writeSql(SqlBuilder $sb): void { $s = $this->joinType->value; if ($this->lateral) { + $sb->requireDialect(Dialect::Mysql, 'LATERAL'); $s .= ' LATERAL'; } $s .= ' '; diff --git a/src/MySQL/Builder/LockingClause.php b/src/MySQL/Builder/LockingClause.php index d6bc803..6bb4a8e 100644 --- a/src/MySQL/Builder/LockingClause.php +++ b/src/MySQL/Builder/LockingClause.php @@ -14,18 +14,26 @@ final class LockingClause { /** * @param list $ofTables + * @param Requirement|null $requires the dialect this lock spelling is available on, if it is dialect-specific */ public function __construct( public readonly string $clause, public readonly array $ofTables = [], public readonly string $waitPolicy = '', + public readonly ?Requirement $requires = null, ) { } public function writeSql(SqlBuilder $sb): void { + if ($this->requires !== null) { + $sb->requireAnyDialect($this->clause, $this->requires); + } + $s = $this->clause; if ($this->ofTables !== []) { + // The OF table list is a MySQL extension, even on FOR UPDATE. + $sb->requireDialect(Dialect::Mysql, 'the locking OF clause'); $s .= ' OF ' . implode(',', $this->ofTables); } if ($this->waitPolicy !== '') { diff --git a/src/MySQL/Builder/OpExp.php b/src/MySQL/Builder/OpExp.php index 39cf969..88c6a1e 100644 --- a/src/MySQL/Builder/OpExp.php +++ b/src/MySQL/Builder/OpExp.php @@ -17,6 +17,7 @@ public function __construct( private readonly string $op, private readonly Exp $rgt, private readonly bool $unspaced = false, + private readonly ?Requirement $requires = null, ) { } @@ -27,6 +28,10 @@ public function precedence(): int public function writeSql(SqlBuilder $sb): void { + if ($this->requires !== null) { + $sb->requireAnyDialect('the ' . $this->op . ' operator', $this->requires); + } + $lftNeedsParens = $this->lft instanceof Precedencer && $this->lft->precedence() < $this->precedence(); if ($lftNeedsParens) { diff --git a/src/MySQL/Builder/OrdersLastDeleteTerm.php b/src/MySQL/Builder/OrdersLastDeleteTerm.php index 4db3c8b..151142f 100644 --- a/src/MySQL/Builder/OrdersLastDeleteTerm.php +++ b/src/MySQL/Builder/OrdersLastDeleteTerm.php @@ -9,7 +9,7 @@ * single-table DELETE. Shared by both dialects' `OrderByDeleteBuilder`. * * @internal - * @phpstan-require-extends AbstractDeleteBuilder + * @phpstan-require-extends DeleteBuilder */ trait OrdersLastDeleteTerm { diff --git a/src/MySQL/Builder/OrdersLastTerm.php b/src/MySQL/Builder/OrdersLastTerm.php index 2797797..de4aee3 100644 --- a/src/MySQL/Builder/OrdersLastTerm.php +++ b/src/MySQL/Builder/OrdersLastTerm.php @@ -9,7 +9,7 @@ * both dialects' `OrderBySelectBuilder`. * * @internal - * @phpstan-require-extends AbstractSelectBuilder + * @phpstan-require-extends SelectBuilder */ trait OrdersLastTerm { diff --git a/src/MySQL/Builder/RefinesCombination.php b/src/MySQL/Builder/RefinesCombination.php index 2136950..a1fde7c 100644 --- a/src/MySQL/Builder/RefinesCombination.php +++ b/src/MySQL/Builder/RefinesCombination.php @@ -10,7 +10,7 @@ * `CombinationBuilder`. * * @internal - * @phpstan-require-extends AbstractSelectBuilder + * @phpstan-require-extends SelectBuilder */ trait RefinesCombination { @@ -19,7 +19,7 @@ public function all(): static return $this->derive(static::class, combinations: $this->rebuildLastCombination(all: true)); } - public function query(AbstractSelectBuilder $query): static + public function query(SelectBuilder $query): static { return $this->derive(static::class, combinations: $this->rebuildLastCombination(query: $query)); } @@ -30,7 +30,7 @@ public function query(AbstractSelectBuilder $query): static * * @return list */ - private function rebuildLastCombination(?bool $all = null, ?AbstractSelectBuilder $query = null): array + private function rebuildLastCombination(?bool $all = null, ?SelectBuilder $query = null): array { $combinations = $this->combinations; $lastIdx = array_key_last($combinations); diff --git a/src/MySQL/Builder/RefinesLastDeleteJoin.php b/src/MySQL/Builder/RefinesLastDeleteJoin.php index e3db605..3adc4c0 100644 --- a/src/MySQL/Builder/RefinesLastDeleteJoin.php +++ b/src/MySQL/Builder/RefinesLastDeleteJoin.php @@ -9,7 +9,7 @@ * condition and `USING` columns. Shared by both dialects' `JoinDeleteBuilder`. * * @internal - * @phpstan-require-extends AbstractDeleteBuilder + * @phpstan-require-extends DeleteBuilder */ trait RefinesLastDeleteJoin { diff --git a/src/MySQL/Builder/RefinesLastJoin.php b/src/MySQL/Builder/RefinesLastJoin.php index 2e4228f..2460dd0 100644 --- a/src/MySQL/Builder/RefinesLastJoin.php +++ b/src/MySQL/Builder/RefinesLastJoin.php @@ -10,7 +10,7 @@ * Shared by both dialects' `JoinSelectBuilder`. * * @internal - * @phpstan-require-extends AbstractSelectBuilder + * @phpstan-require-extends SelectBuilder */ trait RefinesLastJoin { diff --git a/src/MySQL/Builder/RendersReturning.php b/src/MySQL/Builder/RendersReturning.php index ac73661..b82f7f5 100644 --- a/src/MySQL/Builder/RendersReturning.php +++ b/src/MySQL/Builder/RendersReturning.php @@ -16,6 +16,7 @@ trait RendersReturning */ protected function writeReturning(SqlBuilder $sb, array $returningItems): void { + $sb->requireDialect(Dialect::MariaDb, 'RETURNING'); $sb->writeString(' RETURNING '); foreach ($returningItems as $i => $item) { if ($i > 0) { diff --git a/src/MySQL/Builder/ReplaceBuilder.php b/src/MySQL/Builder/ReplaceBuilder.php index fc280a0..7bfd199 100644 --- a/src/MySQL/Builder/ReplaceBuilder.php +++ b/src/MySQL/Builder/ReplaceBuilder.php @@ -5,8 +5,195 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds a MySQL REPLACE statement. + * Builds a REPLACE statement: like INSERT, but an existing row with the same primary + * or unique key is deleted before the new row is inserted. There is no + * `ON DUPLICATE KEY UPDATE`. The optional `RETURNING` clause marks itself while + * rendering, so validating against a {@see Target} reports it where unsupported. + * + * Immutable: every method returns a new instance; a derived copy is assembled only + * by {@see derive()}. */ -final class ReplaceBuilder extends AbstractReplaceBuilder +class ReplaceBuilder implements InnerSqlWriter { + use RendersReturning; + + /** + * @param list $columnNames + * @param list> $valueLists + * @param list $returningItems + */ + public function __construct( + protected readonly IdentExp $tableName, + protected readonly array $columnNames = [], + protected readonly bool $defaultValues = false, + protected readonly array $valueLists = [], + protected readonly ?SelectBuilder $query = null, + protected readonly array $returningItems = [], + ) { + } + + /** + * Set the column names to replace into. + */ + public function columnNames(string $columnName, string ...$rest): static + { + return $this->derive(static::class, columnNames: array_values([$columnName, ...$rest])); + } + + /** + * Replace with a row consisting entirely of default values, rendered as + * `() VALUES ()`. Calling {@see values()} afterwards overrules this. + */ + public function defaultValues(): static + { + return $this->derive(static::class, defaultValues: true); + } + + /** + * Append a row of values. Call multiple times to replace several rows. + */ + public function values(Exp ...$values): static + { + return $this->derive(static::class, valueLists: [...$this->valueLists, array_values($values)]); + } + + /** + * Set the column names and values from the given map (column name => value). + * Values are bound as arguments and the column order is stable (sorted by name). + * Overwrites any previous column names and values. + * + * @param array $map + */ + public function setMap(array $map): static + { + ksort($map, SORT_STRING); + + $values = []; + foreach ($map as $value) { + $values[] = new Arg($value); + } + + return $this->derive(static::class, columnNames: array_keys($map), valueLists: [$values]); + } + + /** + * Replace with the result of the given select query. + */ + public function query(SelectBuilder $query): static + { + return $this->derive(static::class, query: $query); + } + + /** + * Add a RETURNING clause. Refine the output name of the last expression via + * {@see ReturningReplaceBuilder::as()}. + */ + public function returning(Exp $outputExpression, Exp ...$exps): ReturningReplaceBuilder + { + $returningItems = $this->returningItems; + foreach ([$outputExpression, ...array_values($exps)] as $exp) { + $returningItems[] = new ReturningItem($exp); + } + + return $this->derive(ReturningReplaceBuilder::class, returningItems: $returningItems); + } + + /** + * @template T of ReplaceBuilder + * @param class-string $class + * @param list|null $columnNames + * @param list>|null $valueLists + * @param list|null $returningItems + * @return T + */ + protected function derive( + string $class, + ?array $columnNames = null, + ?bool $defaultValues = null, + ?array $valueLists = null, + ?SelectBuilder $query = null, + ?array $returningItems = null, + ): ReplaceBuilder { + return new $class( + $this->tableName, + $columnNames ?? $this->columnNames, + $defaultValues ?? $this->defaultValues, + $valueLists ?? $this->valueLists, + $query ?? $this->query, + $returningItems ?? $this->returningItems, + ); + } + + /** + * @internal + */ + public function writeSql(SqlBuilder $sb): void + { + $this->innerWriteSql($sb); + } + + /** + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + $sb->writeString('REPLACE INTO '); + $this->tableName->writeSql($sb); + + if ($this->valueLists !== [] && $this->query !== null) { + $sb->addError(new QueryBuilderException('replace: cannot set both values and query')); + + return; + } + + if ($this->defaultValues) { + $sb->writeString(' () VALUES ()'); + } else { + $this->writeColumnNames($sb); + $this->writeSource($sb); + } + + if ($this->returningItems !== []) { + $this->writeReturning($sb, $this->returningItems); + } + } + + private function writeColumnNames(SqlBuilder $sb): void + { + if ($this->columnNames === []) { + return; + } + + $s = ' ('; + foreach ($this->columnNames as $i => $columnName) { + $s .= ($i > 0 ? ',' : '') . Keywords::quoteIdentifierIfKeyword($columnName); + } + $sb->writeString($s . ')'); + } + + private function writeSource(SqlBuilder $sb): void + { + if ($this->query !== null) { + $sb->writeString(' '); + $this->query->innerWriteSql($sb); + + return; + } + + if ($this->valueLists === []) { + return; + } + + $sb->writeString(' VALUES '); + foreach ($this->valueLists as $i => $valueList) { + $sb->writeString(($i > 0 ? ',' : '') . '('); + foreach ($valueList as $j => $value) { + if ($j > 0) { + $sb->writeString(','); + } + $value->writeSql($sb); + } + $sb->writeString(')'); + } + } } diff --git a/src/MySQL/Builder/Requirement.php b/src/MySQL/Builder/Requirement.php new file mode 100644 index 0000000..aba9c53 --- /dev/null +++ b/src/MySQL/Builder/Requirement.php @@ -0,0 +1,68 @@ +dialect !== $this->dialect) { + return false; + } + if ($target->version === null) { + return true; + } + if ($this->gteVersion !== null && version_compare($target->version, $this->gteVersion, '<')) { + return false; + } + if ($this->ltVersion !== null && version_compare($target->version, $this->ltVersion, '>=')) { + return false; + } + + return true; + } + + /** + * A human-readable description for error messages, e.g. `MySQL`, `MariaDB 12.3+` + * or `MariaDB 10.5–13.0`. + */ + public function describe(): string + { + $s = $this->dialect->label(); + if ($this->gteVersion !== null && $this->ltVersion !== null) { + return $s . ' ' . $this->gteVersion . '–' . $this->ltVersion; + } + if ($this->gteVersion !== null) { + return $s . ' ' . $this->gteVersion . '+'; + } + if ($this->ltVersion !== null) { + return $s . ' < ' . $this->ltVersion; + } + + return $s; + } +} diff --git a/src/MariaDB/Builder/ReturningDeleteBuilder.php b/src/MySQL/Builder/ReturningDeleteBuilder.php similarity index 86% rename from src/MariaDB/Builder/ReturningDeleteBuilder.php rename to src/MySQL/Builder/ReturningDeleteBuilder.php index dcb882d..80011b6 100644 --- a/src/MariaDB/Builder/ReturningDeleteBuilder.php +++ b/src/MySQL/Builder/ReturningDeleteBuilder.php @@ -2,9 +2,7 @@ declare(strict_types=1); -namespace Flowpack\QueryObjectBuilder\MariaDB\Builder; - -use Flowpack\QueryObjectBuilder\MySQL\Builder\ReturningItem; +namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** * The DELETE builder state right after a RETURNING expression, where {@see as()} diff --git a/src/MariaDB/Builder/ReturningInsertBuilder.php b/src/MySQL/Builder/ReturningInsertBuilder.php similarity index 86% rename from src/MariaDB/Builder/ReturningInsertBuilder.php rename to src/MySQL/Builder/ReturningInsertBuilder.php index 7dcfa30..7c08d8f 100644 --- a/src/MariaDB/Builder/ReturningInsertBuilder.php +++ b/src/MySQL/Builder/ReturningInsertBuilder.php @@ -2,9 +2,7 @@ declare(strict_types=1); -namespace Flowpack\QueryObjectBuilder\MariaDB\Builder; - -use Flowpack\QueryObjectBuilder\MySQL\Builder\ReturningItem; +namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** * The INSERT builder state right after a RETURNING expression, where {@see as()} diff --git a/src/MariaDB/Builder/ReturningReplaceBuilder.php b/src/MySQL/Builder/ReturningReplaceBuilder.php similarity index 86% rename from src/MariaDB/Builder/ReturningReplaceBuilder.php rename to src/MySQL/Builder/ReturningReplaceBuilder.php index 47fe646..cd9db51 100644 --- a/src/MariaDB/Builder/ReturningReplaceBuilder.php +++ b/src/MySQL/Builder/ReturningReplaceBuilder.php @@ -2,9 +2,7 @@ declare(strict_types=1); -namespace Flowpack\QueryObjectBuilder\MariaDB\Builder; - -use Flowpack\QueryObjectBuilder\MySQL\Builder\ReturningItem; +namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** * The REPLACE builder state right after a RETURNING expression, where {@see as()} diff --git a/src/MySQL/Builder/SelectBuilder.php b/src/MySQL/Builder/SelectBuilder.php index 33bf92a..12e8471 100644 --- a/src/MySQL/Builder/SelectBuilder.php +++ b/src/MySQL/Builder/SelectBuilder.php @@ -5,14 +5,50 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * Builds a MySQL SELECT query. + * Builds a SELECT query. * - * The shared state, rendering and clause logic live in {@see AbstractSelectBuilder}; - * this concrete builder adds MySQL's public transition methods (including the - * `LATERAL` from/join family) and renders the shared lock as `FOR SHARE`. + * Two principles run through this family of builders: + * + * - Immutability: every method returns a new builder; the receiver is never + * modified. The state lives in an immutable {@see SelectQueryParts}, and a + * derived copy is assembled only by {@see derive()}. + * - Type-state: methods return a more specific builder type so context-dependent + * methods like `as()`, `using()` or `on()` are only available — and only act on + * the relevant element — where they make sense. + * + * Constructs that only one engine of the MySQL family accepts (the `LATERAL` + * from/join family, `FOR SHARE` with its `OF` / wait-policy refinements, and the + * `LOCK IN SHARE MODE` shared lock) mark themselves while rendering, so validating + * the query against a {@see Target} reports the ones that target cannot express. */ -class SelectBuilder extends AbstractSelectBuilder +class SelectBuilder implements InnerSqlWriter, WithQuery, Exp, FromLateralExp, SelectOrExpressions { + use RendersWithQueries; + use WritesParenthesizedSql; + + /** + * @param list $withQueries the leading WITH clause, if any + * @param list $combinations previous selects combined via UNION / INTERSECT / EXCEPT + */ + public function __construct( + protected readonly SelectQueryParts $parts = new SelectQueryParts(), + protected readonly array $withQueries = [], + protected readonly array $combinations = [], + ) { + } + + /** + * Whether this builder carries no content yet: no WITH queries, no + * combinations and empty select parts. Useful for conditional query building. + */ + public function isEmpty(): bool + { + return $this->withQueries === [] && $this->combinations === [] && $this->parts->isEmpty(); + } + + // Public transition methods — each returns the builder type where the following + // context-dependent refinements make sense. + /** * Add the given expressions to the select list. */ @@ -151,9 +187,22 @@ public function forUpdate(): ForSelectBuilder return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR UPDATE')); } + /** + * Lock the selected rows for sharing (`FOR SHARE`). Refine with + * {@see ForSelectBuilder::of()} / {@see SetsLockWaitPolicy::nowait()} / + * {@see SetsLockWaitPolicy::skipLocked()}. + */ public function forShare(): ForSelectBuilder { - return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR SHARE')); + return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR SHARE', requires: new Requirement(Dialect::Mysql))); + } + + /** + * Lock the selected rows for sharing, spelled `LOCK IN SHARE MODE`. + */ + public function lockInShareMode(): SelectBuilder + { + return $this->withLocking(SelectBuilder::class, new LockingClause('LOCK IN SHARE MODE', requires: new Requirement(Dialect::MariaDb))); } /** @@ -163,4 +212,341 @@ public function appendWith(WithBuilder $with): SelectBuilder { return $this->withAppendedWith(SelectBuilder::class, $with->withQueryItems()); } + + // Clause helpers — the field logic lives here once; the public transitions above + // (and the type-state subbuilders) call these with their own target class and + // declare the matching concrete return type. + + /** + * @template T of SelectBuilder + * @param class-string $class + * @param list $exps + * @return T + */ + protected function addToSelectList(string $class, array $exps): SelectBuilder + { + $selectList = $this->parts->selectList; + foreach ($exps as $exp) { + $selectList[] = new OutputExpr($exp); + } + + return $this->derive($class, selectList: $selectList); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function addFromItem(string $class, FromExp $from, bool $lateral = false): SelectBuilder + { + return $this->derive($class, from: [...$this->parts->from, new FromItem($from, lateral: $lateral)]); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function addJoinItem(string $class, JoinType $joinType, FromExp $from, bool $lateral): SelectBuilder + { + return $this->derive($class, from: [...$this->parts->from, new FromItem(new Join($joinType, $lateral, $from))]); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function addWhereCondition(string $class, Exp $cond): SelectBuilder + { + return $this->derive($class, whereConjunction: [...$this->parts->whereConjunction, $cond]); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @param list $exps + * @return T + */ + protected function addGroupByExps(string $class, array $exps): SelectBuilder + { + return $this->derive($class, groupBys: [...$this->parts->groupBys, ...$exps]); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function addHavingCondition(string $class, Exp $cond): SelectBuilder + { + return $this->derive($class, havingConjunction: [...$this->parts->havingConjunction, $cond]); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function addOrderByExp(string $class, Exp $exp): SelectBuilder + { + return $this->derive($class, orderBys: [...$this->parts->orderBys, new OrderByClause($exp)]); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function withLimit(string $class, Exp $exp): SelectBuilder + { + return $this->derive($class, limit: $exp); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function withOffset(string $class, Exp $exp): SelectBuilder + { + return $this->derive($class, offset: $exp); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function startCombination(string $class, CombinationType $type): SelectBuilder + { + // Archive the current parts as a combination and start a fresh select. + return $this->derive( + $class, + parts: new SelectQueryParts(), + combinations: [...$this->combinations, new Combination($this->parts, $type)], + ); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function withLocking(string $class, LockingClause $lockingClause): SelectBuilder + { + return $this->derive($class, lockingClause: $lockingClause); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @return T + */ + protected function addNamedWindow(string $class, string $name): SelectBuilder + { + return $this->derive($class, windows: [...$this->parts->windows, new NamedWindow($name)]); + } + + /** + * @template T of SelectBuilder + * @param class-string $class + * @param list $withQueries + * @return T + */ + protected function withAppendedWith(string $class, array $withQueries): SelectBuilder + { + return $this->derive($class, withQueries: [...$this->withQueries, ...$withQueries]); + } + + /** + * Assemble a new builder of the given type from the current state with the + * given fields replaced; a null argument keeps the current value. This is the + * single place where a derived {@see SelectQueryParts} and the type-state + * transition are produced. + * + * @template T of SelectBuilder + * @param class-string $class + * @param list|null $selectList + * @param list|null $from + * @param list|null $whereConjunction + * @param list|null $groupBys + * @param list|null $havingConjunction + * @param list|null $windows + * @param list|null $orderBys + * @param list|null $withQueries + * @param list|null $combinations + * @return T + */ + protected function derive( + string $class, + ?SelectQueryParts $parts = null, + ?bool $distinct = null, + ?array $selectList = null, + ?array $from = null, + ?array $whereConjunction = null, + ?array $groupBys = null, + ?bool $groupByWithRollup = null, + ?array $havingConjunction = null, + ?array $windows = null, + ?array $orderBys = null, + ?Exp $limit = null, + ?Exp $offset = null, + ?LockingClause $lockingClause = null, + ?array $withQueries = null, + ?array $combinations = null, + ): SelectBuilder { + $parts ??= new SelectQueryParts( + distinct: $distinct ?? $this->parts->distinct, + selectList: $selectList ?? $this->parts->selectList, + from: $from ?? $this->parts->from, + whereConjunction: $whereConjunction ?? $this->parts->whereConjunction, + groupBys: $groupBys ?? $this->parts->groupBys, + groupByWithRollup: $groupByWithRollup ?? $this->parts->groupByWithRollup, + havingConjunction: $havingConjunction ?? $this->parts->havingConjunction, + windows: $windows ?? $this->parts->windows, + orderBys: $orderBys ?? $this->parts->orderBys, + limit: $limit ?? $this->parts->limit, + offset: $offset ?? $this->parts->offset, + lockingClause: $lockingClause ?? $this->parts->lockingClause, + ); + + return new $class($parts, $withQueries ?? $this->withQueries, $combinations ?? $this->combinations); + } + + /** + * Write the select without the surrounding parentheses (the top-level query). + * + * @internal + */ + public function innerWriteSql(SqlBuilder $sb): void + { + if ($this->withQueries !== []) { + $this->writeWithQueries($sb, $this->withQueries); + } + + // Previous selects combined via UNION / INTERSECT / EXCEPT come first. + foreach ($this->combinations as $c) { + $this->writeSelectParts($sb, $c->parts); + $s = ' ' . $c->type->value; + if ($c->all) { + $s .= ' ALL'; + } + $sb->writeString($s . ' '); + $c->query?->writeSql($sb); + } + + if (!$this->parts->isEmpty()) { + $this->writeSelectParts($sb, $this->parts); + } + + if ($this->parts->orderBys !== []) { + $sb->writeString(' ORDER BY '); + foreach ($this->parts->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } + + if ($this->parts->limit !== null) { + $sb->writeString(' LIMIT '); + $this->parts->limit->writeSql($sb); + } + + if ($this->parts->offset !== null) { + $sb->writeString(' OFFSET '); + $this->parts->offset->writeSql($sb); + } + + if ($this->parts->lockingClause !== null) { + $sb->writeString(' '); + $this->parts->lockingClause->writeSql($sb); + } + } + + private function writeSelectParts(SqlBuilder $sb, SelectQueryParts $parts): void + { + // Accumulate literal SQL into $s and only flush before a nested writer + // needs to write (and once at the very end). + $sb->writeString('SELECT '); + $s = $parts->distinct ? 'DISTINCT ' : ''; + $needComma = false; + + foreach ($parts->selectList as $out) { + if ($needComma) { + $s .= ','; + } + $sb->writeString($s); + $s = ''; + + $out->exp->writeSql($sb); + + if ($out->alias !== '') { + $s = ' AS ' . $out->alias; + } + $needComma = true; + } + + if ($parts->from !== []) { + $s .= ' FROM '; + foreach ($parts->from as $i => $fromItem) { + if ($i > 0) { + // A join attaches to the previous item; a plain item is comma-separated. + $s .= $fromItem->from instanceof Join ? ' ' : ','; + } + $sb->writeString($s); + $s = ''; + + $fromItem->writeSql($sb); + } + } + + if ($parts->whereConjunction !== []) { + $sb->writeString($s . ' WHERE '); + $s = ''; + + Junction::and(...$parts->whereConjunction)->writeSql($sb); + } + + if ($parts->groupBys !== []) { + $sb->writeString($s . ' GROUP BY '); + $s = ''; + + foreach ($parts->groupBys as $i => $groupBy) { + if ($i > 0) { + $sb->writeString(','); + } + $groupBy->writeSql($sb); + } + if ($parts->groupByWithRollup) { + $s = ' WITH ROLLUP'; + } + } + + if ($parts->havingConjunction !== []) { + $sb->writeString($s . ' HAVING '); + $s = ''; + + Junction::and(...$parts->havingConjunction)->writeSql($sb); + } + + if ($parts->windows !== []) { + $sb->writeString($s . ' WINDOW '); + $s = ''; + + foreach ($parts->windows as $i => $window) { + if ($i > 0) { + $sb->writeString(','); + } + $window->writeSql($sb); + } + } + + if ($s !== '') { + $sb->writeString($s); + } + } } diff --git a/src/MySQL/Builder/SetsLockWaitPolicy.php b/src/MySQL/Builder/SetsLockWaitPolicy.php index 112de01..c749a50 100644 --- a/src/MySQL/Builder/SetsLockWaitPolicy.php +++ b/src/MySQL/Builder/SetsLockWaitPolicy.php @@ -10,7 +10,7 @@ * here in {@see deriveLocking()} alone, which the `of()` refinement also uses. * * @internal - * @phpstan-require-extends AbstractSelectBuilder + * @phpstan-require-extends SelectBuilder */ trait SetsLockWaitPolicy { @@ -36,6 +36,7 @@ protected function deriveLocking(?array $ofTables = null, ?string $waitPolicy = $lc->clause, $ofTables ?? $lc->ofTables, $waitPolicy ?? $lc->waitPolicy, + $lc->requires, )); } } diff --git a/src/MySQL/Builder/SqlBuilder.php b/src/MySQL/Builder/SqlBuilder.php index ff530ce..10b636c 100644 --- a/src/MySQL/Builder/SqlBuilder.php +++ b/src/MySQL/Builder/SqlBuilder.php @@ -51,23 +51,37 @@ public function __construct( } /** - * Record that the construct being written requires the given dialect (and, - * optionally, a minimum version). When a validation target is set and does not - * satisfy the requirement, an error is added; otherwise this is a no-op. + * Record that the construct being written requires the given dialect, optionally + * within a half-open version window `[gteVersion, ltVersion)`. Sugar for the + * single-allowance case of {@see requireAnyDialect()}. */ - public function requireDialect(Dialect $required, string $feature, ?string $minVersion = null): void + public function requireDialect(Dialect $required, string $feature, ?string $gteVersion = null, ?string $ltVersion = null): void { - if ($this->validationTarget === null || $this->validationTarget->satisfies($required, $minVersion)) { + $this->requireAnyDialect($feature, new Requirement($required, $gteVersion, $ltVersion)); + } + + /** + * Record that the construct being written is supported on any of the given + * requirements (dialect + optional version window). When a validation target is + * set and satisfies none of them, an error is added; otherwise this is a no-op. + */ + public function requireAnyDialect(string $feature, Requirement ...$allowed): void + { + if ($this->validationTarget === null) { return; } + foreach ($allowed as $requirement) { + if ($requirement->satisfiedBy($this->validationTarget)) { + return; + } + } - $versionNote = $minVersion !== null ? ' ' . $minVersion . '+' : ''; + $supported = implode(' or ', array_map(static fn (Requirement $r): string => $r->describe(), $allowed)); $this->addError(new QueryBuilderException(sprintf( - '%s requires %s%s, but the query is validated against %s', + '%s requires %s, but the query is validated against %s', $feature, - $required->label(), - $versionNote, - $this->validationTarget->dialect->label(), + $supported, + $this->validationTarget->describe(), ))); } diff --git a/src/MySQL/Builder/SubqueryExp.php b/src/MySQL/Builder/SubqueryExp.php index 39a5d30..aa92a9f 100644 --- a/src/MySQL/Builder/SubqueryExp.php +++ b/src/MySQL/Builder/SubqueryExp.php @@ -20,7 +20,7 @@ public function writeSql(SqlBuilder $sb): void { $sb->writeString($this->op . ' '); - $isSelect = $this->exp instanceof AbstractSelectBuilder; + $isSelect = $this->exp instanceof SelectBuilder; if (!$isSelect) { $sb->writeString('('); } diff --git a/src/MySQL/Builder/Target.php b/src/MySQL/Builder/Target.php index ab9e86a..788ca0a 100644 --- a/src/MySQL/Builder/Target.php +++ b/src/MySQL/Builder/Target.php @@ -28,15 +28,10 @@ public static function mariaDb(?string $version = null): self } /** - * Whether this target is the given dialect and (if a minimum is given and the - * target carries a version) at least that version. + * A human-readable description for error messages, e.g. `MySQL` or `MariaDB 11.4`. */ - public function satisfies(Dialect $dialect, ?string $minVersion = null): bool + public function describe(): string { - if ($this->dialect !== $dialect) { - return false; - } - - return $minVersion === null || $this->version === null || version_compare($this->version, $minVersion, '>='); + return $this->version === null ? $this->dialect->label() : $this->dialect->label() . ' ' . $this->version; } } diff --git a/src/MySQL/Builder/UpdateBuilder.php b/src/MySQL/Builder/UpdateBuilder.php index fa533b9..9c8be0a 100644 --- a/src/MySQL/Builder/UpdateBuilder.php +++ b/src/MySQL/Builder/UpdateBuilder.php @@ -191,6 +191,7 @@ public function innerWriteSql(SqlBuilder $sb): void } if ($this->withQueries !== []) { + $sb->requireAnyDialect('WITH before UPDATE', new Requirement(Dialect::Mysql), new Requirement(Dialect::MariaDb, gteVersion: '12.3')); $this->writeWithQueries($sb, $this->withQueries); } diff --git a/src/MySQL/BuildsExpressions.php b/src/MySQL/BuildsExpressions.php index 5e62df6..e720e43 100644 --- a/src/MySQL/BuildsExpressions.php +++ b/src/MySQL/BuildsExpressions.php @@ -4,7 +4,6 @@ namespace Flowpack\QueryObjectBuilder\MySQL; -use Flowpack\QueryObjectBuilder\MySQL\Builder\AbstractSelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Arg; use Flowpack\QueryObjectBuilder\MySQL\Builder\BindExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\BoolLiteral; @@ -25,6 +24,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Precedence; use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter; use Flowpack\QueryObjectBuilder\MySQL\Builder\StringLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\SubqueryExp; @@ -45,7 +45,7 @@ trait BuildsExpressions /** * An `EXISTS (subquery)` expression. */ - public static function exists(AbstractSelectBuilder $subquery): ExistsExp + public static function exists(SelectBuilder $subquery): ExistsExp { return new ExistsExp($subquery); } @@ -196,6 +196,16 @@ public static function func(string $name, Exp ...$args): FuncExp return new FuncExp($name, array_values($args)); } + /** + * Reference the value proposed for `column` inside `ON DUPLICATE KEY UPDATE`, + * rendered as `VALUES(column)`. (With the `AS new` row alias, reference it as + * `Q::n('new.column')` instead.) + */ + public static function values(string $column): FuncExp + { + return new FuncExp('VALUES', [IdentExp::n($column)]); + } + /** * A `CAST(expr AS type)` expression (e.g. `Q::cast($x, 'UNSIGNED')`). */ diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 604b1eb..04db011 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -19,8 +19,8 @@ * Entry point (facade) for building MySQL queries. * * The dialect-agnostic expression surface lives in {@see BuildsExpressions}; this - * facade adds MySQL's statement entry points (SELECT, INSERT with the `new.col` - * upsert reference, REPLACE, UPDATE, DELETE and WITH). + * facade adds the statement entry points (SELECT, INSERT, REPLACE, UPDATE, DELETE + * and WITH). */ final class Q { @@ -46,15 +46,6 @@ public static function insertInto(IdentExp $tableName): InsertBuilder return new InsertBuilder($tableName); } - /** - * Reference the value of `column` from the row that would have been inserted, - * for use inside `ON DUPLICATE KEY UPDATE` (rendered as `new.column`). - */ - public static function inserted(string $column): IdentExp - { - return IdentExp::n('new.' . $column); - } - /** * Start a REPLACE statement into the given table. */ diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index d0677ec..5991c7a 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -4,14 +4,18 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Q; +use Flowpack\QueryObjectBuilder\MySQL\Builder\AggBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Dialect; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; /** - * Facade for MySQL SQL function expressions, accessed as `Q\Func`. + * Facade for SQL function expressions, accessed as `Q\Func`. * - * The dialect-agnostic function set lives in the shared {@see SharedFunctions} trait; - * this facade adds functions specific to MySQL. + * The bulk of the set lives in {@see SharedFunctions}. This facade adds the + * functions only one engine of the MySQL family provides; each marks itself while + * rendering, so validating the query against a {@see \Flowpack\QueryObjectBuilder\MySQL\Builder\Target} + * reports the ones the target cannot express. */ final class Func { @@ -21,57 +25,115 @@ private function __construct() { } + // MySQL-only functions + /** `REGEXP_LIKE(subject, pattern)` or `REGEXP_LIKE(subject, pattern, matchType)`. */ public static function regexpLike(Exp $subject, Exp $pattern, Exp ...$matchType): FuncExp { - return self::call('REGEXP_LIKE', $subject, $pattern, ...$matchType); + return self::gated(Dialect::Mysql, 'REGEXP_LIKE', $subject, $pattern, ...$matchType); } /** `GROUPING(expr, ...)` — distinguishes super-aggregate `WITH ROLLUP` NULLs. */ public static function grouping(Exp $expr, Exp ...$rest): FuncExp { - return self::call('GROUPING', $expr, ...$rest); + return self::gated(Dialect::Mysql, 'GROUPING', $expr, ...$rest); } /** `ANY_VALUE(expr)` — suppress `ONLY_FULL_GROUP_BY` rejection for a column. */ public static function anyValue(Exp $expr): FuncExp { - return self::call('ANY_VALUE', $expr); + return self::gated(Dialect::Mysql, 'ANY_VALUE', $expr); } /** `JSON_SCHEMA_VALID(schema, doc)`. */ public static function jsonSchemaValid(Exp $schema, Exp $doc): FuncExp { - return self::call('JSON_SCHEMA_VALID', $schema, $doc); + return self::gated(Dialect::Mysql, 'JSON_SCHEMA_VALID', $schema, $doc); } /** `JSON_SCHEMA_VALIDATION_REPORT(schema, doc)`. */ public static function jsonSchemaValidationReport(Exp $schema, Exp $doc): FuncExp { - return self::call('JSON_SCHEMA_VALIDATION_REPORT', $schema, $doc); + return self::gated(Dialect::Mysql, 'JSON_SCHEMA_VALIDATION_REPORT', $schema, $doc); } /** `JSON_STORAGE_SIZE(doc)`. */ public static function jsonStorageSize(Exp $doc): FuncExp { - return self::call('JSON_STORAGE_SIZE', $doc); + return self::gated(Dialect::Mysql, 'JSON_STORAGE_SIZE', $doc); } /** `JSON_STORAGE_FREE(doc)`. */ public static function jsonStorageFree(Exp $doc): FuncExp { - return self::call('JSON_STORAGE_FREE', $doc); + return self::gated(Dialect::Mysql, 'JSON_STORAGE_FREE', $doc); } /** `JSON_PRETTY(doc)` — pretty-print a JSON document. */ public static function jsonPretty(Exp $doc): FuncExp { - return self::call('JSON_PRETTY', $doc); + return self::gated(Dialect::Mysql, 'JSON_PRETTY', $doc); } /** `RANDOM_BYTES(len)` — a string of cryptographically strong random bytes. */ public static function randomBytes(Exp $len): FuncExp { - return self::call('RANDOM_BYTES', $len); + return self::gated(Dialect::Mysql, 'RANDOM_BYTES', $len); + } + + // MariaDB-only functions + + /** `JSON_QUERY(doc, path)` — the object or array at the given path. */ + public static function jsonQuery(Exp $doc, Exp $path): FuncExp + { + return self::gated(Dialect::MariaDb, 'JSON_QUERY', $doc, $path); + } + + /** `JSON_EXISTS(doc, path)` — whether a value exists at the given path. */ + public static function jsonExists(Exp $doc, Exp $path): FuncExp + { + return self::gated(Dialect::MariaDb, 'JSON_EXISTS', $doc, $path); + } + + /** `JSON_DETAILED(doc)` — pretty-print a JSON document. */ + public static function jsonDetailed(Exp $doc): FuncExp + { + return self::gated(Dialect::MariaDb, 'JSON_DETAILED', $doc); + } + + /** `MEDIAN(expr)` — the median value; use with `OVER (...)` via {@see AggBuilder::over()}. */ + public static function median(Exp $expr): AggBuilder + { + return self::gatedAgg(Dialect::MariaDb, 'MEDIAN', $expr); + } + + /** `TO_CHAR(expr)` or `TO_CHAR(expr, format)`. */ + public static function toChar(Exp $expr, Exp ...$format): FuncExp + { + return self::gated(Dialect::MariaDb, 'TO_CHAR', $expr, ...$format); + } + + /** `ADD_MONTHS(date, months)`. */ + public static function addMonths(Exp $date, Exp $months): FuncExp + { + return self::gated(Dialect::MariaDb, 'ADD_MONTHS', $date, $months); + } + + /** `MONTHS_BETWEEN(a, b)`. */ + public static function monthsBetween(Exp $a, Exp $b): FuncExp + { + return self::gated(Dialect::MariaDb, 'MONTHS_BETWEEN', $a, $b); + } + + /** `CHR(n)` — the character for the given code point. */ + public static function chr(Exp $n): FuncExp + { + return self::gated(Dialect::MariaDb, 'CHR', $n); + } + + /** `OCT(n)` — the octal string for the given number. */ + public static function oct(Exp $n): FuncExp + { + return self::gated(Dialect::MariaDb, 'OCT', $n); } } diff --git a/src/MySQL/Q/SharedFunctions.php b/src/MySQL/Q/SharedFunctions.php index c861ba8..b98aeb5 100644 --- a/src/MySQL/Q/SharedFunctions.php +++ b/src/MySQL/Q/SharedFunctions.php @@ -5,11 +5,13 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Q; use Flowpack\QueryObjectBuilder\MySQL\Builder\AggBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Dialect; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; use Flowpack\QueryObjectBuilder\MySQL\Builder\ExtractExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\GroupConcatBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Keyword; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Requirement; use Flowpack\QueryObjectBuilder\MySQL\Builder\TrimExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\WindowFuncBuilder; @@ -1014,5 +1016,22 @@ private static function agg(string $name, Exp ...$args): AggBuilder { return new AggBuilder($name, array_values($args)); } + + /** + * A function available only on the given dialect; validating against another + * target reports it as unsupported. + */ + private static function gated(Dialect $dialect, string $name, Exp ...$args): FuncExp + { + return new FuncExp($name, array_values($args), new Requirement($dialect)); + } + + /** + * An aggregate available only on the given dialect. + */ + private static function gatedAgg(Dialect $dialect, string $name, Exp ...$args): AggBuilder + { + return new AggBuilder($name, array_values($args), requires: new Requirement($dialect)); + } } diff --git a/tests/MariaDB/Q/DmlTest.php b/tests/MariaDB/Q/DmlTest.php deleted file mode 100644 index f6935ad..0000000 --- a/tests/MariaDB/Q/DmlTest.php +++ /dev/null @@ -1,78 +0,0 @@ -columnNames('id', 'email')->values(Q::arg(1), Q::arg('a@b.c')), - )->toRenderSql('INSERT INTO users (id,email) VALUES (?,?)', [1, 'a@b.c']); - }); - - it('renders ON DUPLICATE KEY UPDATE with the VALUES() reference (no row alias)', function () { - expect( - Q::insertInto(Q::n('t')) - ->columnNames('id', 'hits') - ->values(Q::arg(1), Q::arg(10)) - ->onDuplicateKeyUpdate() - ->set('hits', Q::inserted('hits')) - ->set('seen', Q::int(1)), - )->toRenderSql( - 'INSERT INTO t (id,hits) VALUES (?,?) ON DUPLICATE KEY UPDATE hits = VALUES(hits),seen = 1', - [1, 10], - ); - }); - - it('renders a RETURNING clause', function () { - expect( - Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::arg(1)) - ->returning(Q::n('id'))->as('new_id'), - )->toRenderSql('INSERT INTO t (a) VALUES (?) RETURNING id AS new_id', [1]); - }); -}); - -describe('MariaDB REPLACE', function () { - it('renders a RETURNING clause', function () { - expect( - Q::replaceInto(Q::n('t'))->columnNames('a')->values(Q::arg(1)) - ->returning(Q::n('id')), - )->toRenderSql('REPLACE INTO t (a) VALUES (?) RETURNING id', [1]); - }); -}); - -describe('MariaDB UPDATE', function () { - it('reuses the shared multi-table update', function () { - expect( - Q::update(Q::n('t1')) - ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) - ->set('t1.a', Q::n('t2.b')), - )->toRenderSql('UPDATE t1 JOIN t2 ON t1.id = t2.id SET t1.a = t2.b'); - }); -}); - -describe('MariaDB DELETE', function () { - it('deletes with RETURNING (single-table)', function () { - expect( - Q::deleteFrom(Q::n('t'))->where(Q::n('id')->eq(Q::arg(1)))->returning(Q::n('id')), - )->toRenderSql('DELETE FROM t WHERE id = ? RETURNING id', [1]); - }); - - it('renders a multi-table delete', function () { - expect( - Q::deleteFrom(Q::n('t1')) - ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) - ->where(Q::n('t2.id')->isNull()), - )->toRenderSql('DELETE t1.* FROM t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.id IS NULL'); - }); - - it('rejects RETURNING on a multi-table delete', function () { - $q = Q::deleteFrom(Q::n('t1')) - ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) - ->returning(Q::n('t1.id')); - - expect(static fn () => Q::build($q)->toSql())->toThrow(QueryBuilderException::class); - }); -}); diff --git a/tests/MariaDB/Q/FunctionsTest.php b/tests/MariaDB/Q/FunctionsTest.php deleted file mode 100644 index c8f6f33..0000000 --- a/tests/MariaDB/Q/FunctionsTest.php +++ /dev/null @@ -1,32 +0,0 @@ -toRenderSql('LOWER(a)'); - expect(Q\Func::count(Q::n('*')))->toRenderSql('COUNT(*)'); - expect(Q\Func::jsonExtract(Q::n('doc'), Q::string('$.a')))->toRenderSql("JSON_EXTRACT(doc, '$.a')"); - expect(Q\Func::rowNumber()->over()->orderBy(Q::n('x')))->toRenderSql('ROW_NUMBER() OVER (ORDER BY x)'); - expect(Q\Func::groupConcat(Q::n('name'))->separator(', '))->toRenderSql("GROUP_CONCAT(name SEPARATOR ', ')"); - }); - - it('renders MariaDB-only functions', function () { - expect(Q\Func::jsonQuery(Q::n('doc'), Q::string('$.a')))->toRenderSql("JSON_QUERY(doc, '$.a')"); - expect(Q\Func::jsonExists(Q::n('doc'), Q::string('$.a')))->toRenderSql("JSON_EXISTS(doc, '$.a')"); - expect(Q\Func::jsonDetailed(Q::n('doc')))->toRenderSql('JSON_DETAILED(doc)'); - expect(Q\Func::median(Q::n('v'))->over()->partitionBy(Q::n('g'))) - ->toRenderSql('MEDIAN(v) OVER (PARTITION BY g)'); - expect(Q\Func::toChar(Q::n('d'), Q::string('YYYY-MM-DD')))->toRenderSql("TO_CHAR(d, 'YYYY-MM-DD')"); - expect(Q\Func::addMonths(Q::n('d'), Q::int(3)))->toRenderSql('ADD_MONTHS(d, 3)'); - expect(Q\Func::monthsBetween(Q::n('a'), Q::n('b')))->toRenderSql('MONTHS_BETWEEN(a, b)'); - expect(Q\Func::chr(Q::int(65)))->toRenderSql('CHR(65)'); - expect(Q\Func::oct(Q::int(8)))->toRenderSql('OCT(8)'); - }); - - // MySQL-only functions (regexpLike / jsonPretty / randomBytes / ...) are absent - // from this facade by construction — calling them is a compile-time type error, - // which is the intended guarantee, so there is nothing to assert at runtime. -}); diff --git a/tests/MariaDB/Q/SelectBuilderTest.php b/tests/MariaDB/Q/SelectBuilderTest.php deleted file mode 100644 index a5c0d87..0000000 --- a/tests/MariaDB/Q/SelectBuilderTest.php +++ /dev/null @@ -1,106 +0,0 @@ -from(Q::n('orders')) - ->where(Q::n('id')->eq(Q::arg(1))) - )->toRenderSql('SELECT id,email FROM orders WHERE id = ?', [1]); - - // A reserved keyword used as an identifier is backtick-quoted. - expect(Q::select(Q::n('id'))->from(Q::n('order'))) - ->toRenderSql('SELECT id FROM `order`'); - }); - - it('aliases select expressions and from items', function () { - expect( - Q::select(Q::n('u.id'))->as('user_id') - ->from(Q::n('users'))->as('u') - )->toRenderSql('SELECT u.id AS user_id FROM users AS u'); - }); - - it('renders DISTINCT', function () { - expect( - Q::select(Q::n('country'))->distinct()->from(Q::n('users')) - )->toRenderSql('SELECT DISTINCT country FROM users'); - }); - - it('renders joins with ON and USING', function () { - expect( - Q::select(Q::n('*')) - ->from(Q::n('users'))->as('u') - ->leftJoin(Q::n('orders'))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) - )->toRenderSql('SELECT * FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id'); - - expect( - Q::select(Q::n('*'))->from(Q::n('a'))->join(Q::n('b'))->using('id') - )->toRenderSql('SELECT * FROM a JOIN b USING (id)'); - }); - - it('renders GROUP BY with rollup, HAVING and ORDER BY', function () { - expect( - Q::select(Q::n('country')) - ->from(Q::n('users')) - ->groupBy(Q::n('country'))->withRollup() - ->having(Q::n('country')->isNotNull()) - ->orderBy(Q::n('country'))->desc() - )->toRenderSql('SELECT country FROM users GROUP BY country WITH ROLLUP HAVING country IS NOT NULL ORDER BY country DESC'); - }); - - it('renders LIMIT and OFFSET', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('users'))->limit(Q::int(10))->offset(Q::int(20)) - )->toRenderSql('SELECT id FROM users LIMIT 10 OFFSET 20'); - }); - - it('renders locking clauses', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('users'))->forUpdate()->skipLocked() - )->toRenderSql('SELECT id FROM users FOR UPDATE SKIP LOCKED'); - - // MariaDB spells the shared lock as LOCK IN SHARE MODE. - expect( - Q::select(Q::n('id'))->from(Q::n('users'))->forShare() - )->toRenderSql('SELECT id FROM users LOCK IN SHARE MODE'); - }); - - it('renders UNION and INTERSECT combinations', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('a')) - ->union()->all() - ->query(Q::select(Q::n('id'))->from(Q::n('b'))) - )->toRenderSql('SELECT id FROM a UNION ALL (SELECT id FROM b)'); - }); - - it('renders a CTE', function () { - expect( - Q::with('recent')->as(Q::select(Q::n('id'))->from(Q::n('orders'))) - ->select(Q::n('*'))->from(Q::n('recent')) - )->toRenderSql('WITH recent AS (SELECT id FROM orders) SELECT * FROM recent'); - }); - - it('renders EXISTS and IN with a subquery', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('users')) - ->where(Q::exists(Q::select(Q::int(1))->from(Q::n('orders')))) - )->toRenderSql('SELECT id FROM users WHERE EXISTS (SELECT 1 FROM orders)'); - - expect( - Q::select(Q::n('id'))->from(Q::n('users')) - ->where(Q::n('id')->in(Q::select(Q::n('user_id'))->from(Q::n('orders')))) - )->toRenderSql('SELECT id FROM users WHERE id IN (SELECT user_id FROM orders)'); - }); - - it('renders a named WINDOW clause with a frame', function () { - expect( - Q::select(Q::n('val')) - ->from(Q::n('t')) - ->window('w')->as()->orderBy(Q::n('val'))->rows(Q::unboundedPreceding()) - )->toRenderSql('SELECT val FROM t WINDOW w AS (ORDER BY val ROWS UNBOUNDED PRECEDING)'); - }); -}); diff --git a/tests/MySQL/Q/DialectDifferencesTest.php b/tests/MySQL/Q/DialectDifferencesTest.php new file mode 100644 index 0000000..c2cfa77 --- /dev/null +++ b/tests/MySQL/Q/DialectDifferencesTest.php @@ -0,0 +1,214 @@ +from(Q::n('t'))->where(Q::n('id')->eq(Q::arg(1)))->forShare(); + + expect($q)->toRenderSql('SELECT * FROM t WHERE id = ? FOR SHARE', [1], $mysql); + expect($q)->toFailValidationFor($mariaDb, 'FOR SHARE requires MySQL'); + }); + + it('renders FOR SHARE OF ... NOWAIT for MySQL', function () use ($mysql, $mariaDb) { + $q = Q::select(Q::n('*'))->from(Q::n('t'))->where(Q::n('id')->eq(Q::arg(1)))->forShare()->of('t')->nowait(); + + expect($q)->toRenderSql('SELECT * FROM t WHERE id = ? FOR SHARE OF t NOWAIT', [1], $mysql); + // The MySQL-only OF clause is reported even though the wait policy is portable. + expect($q)->toFailValidationFor($mariaDb, 'FOR SHARE requires MySQL'); + }); + + it('renders LOCK IN SHARE MODE for MariaDB', function () use ($mysql, $mariaDb) { + $q = Q::select(Q::n('*'))->from(Q::n('t'))->where(Q::n('id')->eq(Q::arg(1)))->lockInShareMode(); + + expect($q)->toRenderSql('SELECT * FROM t WHERE id = ? LOCK IN SHARE MODE', [1], $mariaDb); + expect($q)->toFailValidationFor($mysql, 'LOCK IN SHARE MODE requires MariaDB'); + }); + + it('shares FOR UPDATE, but OF is MySQL-only even there', function () use ($mysql, $mariaDb) { + // FOR UPDATE (+ wait policy) validates against both engines. + $shared = Q::select(Q::n('id'))->from(Q::n('t'))->forUpdate()->skipLocked(); + expect($shared)->toRenderSql('SELECT id FROM t FOR UPDATE SKIP LOCKED', null, $mysql); + expect($shared)->toRenderSql('SELECT id FROM t FOR UPDATE SKIP LOCKED', null, $mariaDb); + + // Adding OF makes it MySQL-only. + $withOf = Q::select(Q::n('id'))->from(Q::n('t'))->forUpdate()->of('t'); + expect($withOf)->toRenderSql('SELECT id FROM t FOR UPDATE OF t', null, $mysql); + expect($withOf)->toFailValidationFor($mariaDb, 'the locking OF clause requires MySQL'); + }); +}); + +describe('A2 upsert proposed-row reference', function () { + $mysql = Target::mysql(); + $mariaDb = Target::mariaDb(); + + it('uses the AS new row alias for MySQL', function () use ($mysql, $mariaDb) { + $q = Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits') + ->values(Q::arg(1), Q::arg(2))->as('new') + ->onDuplicateKeyUpdate() + ->set('hits', Q::n('new.hits')); + + expect($q)->toRenderSql('INSERT INTO t (id,hits) VALUES (?,?) AS new ON DUPLICATE KEY UPDATE hits = new.hits', [1, 2], $mysql); + expect($q)->toFailValidationFor($mariaDb, 'the INSERT row alias (AS ...) requires MySQL'); + }); + + it('uses the portable VALUES() reference (both engines)', function () use ($mysql, $mariaDb) { + $q = Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits') + ->values(Q::arg(1), Q::arg(2)) + ->onDuplicateKeyUpdate() + ->set('hits', Q::values('hits')); + + $sql = 'INSERT INTO t (id,hits) VALUES (?,?) ON DUPLICATE KEY UPDATE hits = VALUES(hits)'; + expect($q)->toRenderSql($sql, [1, 2], $mysql); + expect($q)->toRenderSql($sql, [1, 2], $mariaDb); + }); +}); + +describe('A3 JSON path access', function () { + $mysql = Target::mysql(); + $mariaDb = Target::mariaDb(); + + it('uses the -> and ->> operators for MySQL', function () use ($mysql, $mariaDb) { + $extract = Q::select(Q::n('doc')->jsonExtract(Q::string('$.name')))->from(Q::n('t')); + expect($extract)->toRenderSql("SELECT doc -> '$.name' FROM t", null, $mysql); + expect($extract)->toFailValidationFor($mariaDb, 'the -> operator requires MySQL'); + + $extractText = Q::select(Q::n('doc')->jsonExtractText(Q::string('$.name')))->from(Q::n('t')); + expect($extractText)->toRenderSql("SELECT doc ->> '$.name' FROM t", null, $mysql); + expect($extractText)->toFailValidationFor($mariaDb, 'the ->> operator requires MySQL'); + }); + + it('uses the function form for MariaDB (both engines)', function () use ($mysql, $mariaDb) { + $extract = Q::select(Q\Func::jsonExtract(Q::n('doc'), Q::string('$.name')))->from(Q::n('t')); + expect($extract)->toRenderSql("SELECT JSON_EXTRACT(doc, '$.name') FROM t", null, $mariaDb); + expect($extract)->toRenderSql("SELECT JSON_EXTRACT(doc, '$.name') FROM t", null, $mysql); + + $extractText = Q::select(Q\Func::jsonUnquote(Q\Func::jsonExtract(Q::n('doc'), Q::string('$.name'))))->from(Q::n('t')); + expect($extractText)->toRenderSql("SELECT JSON_UNQUOTE(JSON_EXTRACT(doc, '$.name')) FROM t", null, $mariaDb); + }); +}); + +describe('A4 JSON pretty-print', function () { + $mysql = Target::mysql(); + $mariaDb = Target::mariaDb(); + + it('is JSON_PRETTY on MySQL', function () use ($mysql, $mariaDb) { + $q = Q::select(Q\Func::jsonPretty(Q::n('doc')))->from(Q::n('t')); + expect($q)->toRenderSql('SELECT JSON_PRETTY(doc) FROM t', null, $mysql); + expect($q)->toFailValidationFor($mariaDb, 'JSON_PRETTY requires MySQL'); + }); + + it('is JSON_DETAILED on MariaDB', function () use ($mysql, $mariaDb) { + $q = Q::select(Q\Func::jsonDetailed(Q::n('doc')))->from(Q::n('t')); + expect($q)->toRenderSql('SELECT JSON_DETAILED(doc) FROM t', null, $mariaDb); + expect($q)->toFailValidationFor($mysql, 'JSON_DETAILED requires MariaDB'); + }); +}); + +describe('B1 RETURNING (MariaDB only)', function () { + $mysql = Target::mysql(); + $mariaDb = Target::mariaDb(); + + it('returns from INSERT', function () use ($mysql, $mariaDb) { + $q = Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::arg(1))->returning(Q::n('id'), Q::n('created_at')); + expect($q)->toRenderSql('INSERT INTO t (a) VALUES (?) RETURNING id,created_at', [1], $mariaDb); + expect($q)->toFailValidationFor($mysql, 'RETURNING requires MariaDB'); + }); + + it('returns from DELETE', function () use ($mysql, $mariaDb) { + $q = Q::deleteFrom(Q::n('t'))->where(Q::n('id')->eq(Q::arg(1)))->returning(Q::n('id')); + expect($q)->toRenderSql('DELETE FROM t WHERE id = ? RETURNING id', [1], $mariaDb); + expect($q)->toFailValidationFor($mysql, 'RETURNING requires MariaDB'); + }); + + it('returns from REPLACE', function () use ($mysql, $mariaDb) { + $q = Q::replaceInto(Q::n('t'))->columnNames('a')->values(Q::arg(1))->returning(Q::n('id')); + expect($q)->toRenderSql('REPLACE INTO t (a) VALUES (?) RETURNING id', [1], $mariaDb); + expect($q)->toFailValidationFor($mysql, 'RETURNING requires MariaDB'); + }); +}); + +describe('B2 LATERAL (MySQL only)', function () { + $mysql = Target::mysql(); + $mariaDb = Target::mariaDb(); + + it('joins a LATERAL derived table', function () use ($mysql, $mariaDb) { + $q = Q::select(Q::n('*')) + ->from(Q::n('orders'))->as('o') + ->joinLateral( + Q::select(Q::n('*'))->from(Q::n('items'))->as('i') + ->where(Q::n('i.order_id')->eq(Q::n('o.id'))) + ->limit(Q::int(3)), + )->as('top')->on(Q::bool(true)); + + expect($q)->toRenderSql( + 'SELECT * FROM orders AS o JOIN LATERAL (SELECT * FROM items AS i WHERE i.order_id = o.id LIMIT 3) AS top ON TRUE', + null, + $mysql, + ); + expect($q)->toFailValidationFor($mariaDb, 'LATERAL requires MySQL'); + }); +}); + +describe('B3 WITH before UPDATE / DELETE (MySQL; MariaDB 12.3+)', function () { + it('gates a leading WITH on DELETE by dialect and version', function () { + $q = Q::with('stale')->as(Q::select(Q::n('id'))->from(Q::n('sessions'))->where(Q::n('expired')->eq(Q::int(1)))) + ->deleteFrom(Q::n('users'))->where(Q::n('id')->in(Q::select(Q::n('id'))->from(Q::n('stale')))); + + $sql = 'WITH stale AS (SELECT id FROM sessions WHERE expired = 1) DELETE FROM users WHERE id IN (SELECT id FROM stale)'; + + // MySQL: any version. MariaDB: only 12.3+. + expect($q)->toRenderSql($sql, null, Target::mysql()); + expect($q)->toRenderSql($sql, null, Target::mariaDb('12.3')); + expect($q)->toFailValidationFor(Target::mariaDb('11.4'), 'WITH before DELETE requires MySQL or MariaDB 12.3+'); + }); + + it('gates a leading WITH on UPDATE by dialect and version', function () { + $q = Q::with('bump')->as(Q::select(Q::n('id'))->from(Q::n('flagged'))) + ->update(Q::n('users'))->set('active', Q::int(0)) + ->where(Q::n('id')->in(Q::select(Q::n('id'))->from(Q::n('bump')))); + + expect($q)->toRenderSql( + 'WITH bump AS (SELECT id FROM flagged) UPDATE users SET active = 0 WHERE id IN (SELECT id FROM bump)', + null, + Target::mysql(), + ); + expect($q)->toFailValidationFor(Target::mariaDb('11.4'), 'WITH before UPDATE requires MySQL or MariaDB 12.3+'); + }); +}); + +describe('B4 distribution aggregates (MariaDB only)', function () { + it('renders MEDIAN as a window function', function () { + $q = Q\Func::median(Q::n('salary'))->over()->partitionBy(Q::n('dept')); + + expect($q)->toRenderSql('MEDIAN(salary) OVER (PARTITION BY dept)', null, Target::mariaDb()); + expect($q)->toFailValidationFor(Target::mysql(), 'MEDIAN requires MariaDB'); + }); +}); + +describe('C dialect-only functions', function () { + it('reports MySQL-only functions against MariaDB', function () { + $q = Q\Func::regexpLike(Q::n('s'), Q::string('^a')); + expect($q)->toRenderSql("REGEXP_LIKE(s, '^a')", null, Target::mysql()); + expect($q)->toFailValidationFor(Target::mariaDb(), 'REGEXP_LIKE requires MySQL'); + }); + + it('reports MariaDB-only functions against MySQL', function () { + $q = Q\Func::jsonQuery(Q::n('doc'), Q::string('$.a')); + expect($q)->toRenderSql("JSON_QUERY(doc, '$.a')", null, Target::mariaDb()); + expect($q)->toFailValidationFor(Target::mysql(), 'JSON_QUERY requires MariaDB'); + }); +}); diff --git a/tests/MySQL/Q/InsertBuilderTest.php b/tests/MySQL/Q/InsertBuilderTest.php index 76885d7..9adfd4f 100644 --- a/tests/MySQL/Q/InsertBuilderTest.php +++ b/tests/MySQL/Q/InsertBuilderTest.php @@ -58,17 +58,30 @@ )->toRenderSql('INSERT INTO t () VALUES ()'); }); - it('renders ON DUPLICATE KEY UPDATE referencing the inserted row', function () { + it('renders ON DUPLICATE KEY UPDATE with the AS new row alias', function () { expect( Q::insertInto(Q::n('t')) ->columnNames('id', 'hits') - ->values(Q::arg(1), Q::arg(10)) + ->values(Q::arg(1), Q::arg(10))->as('new') ->onDuplicateKeyUpdate() - ->set('hits', Q::inserted('hits')) + ->set('hits', Q::n('new.hits')) ->set('seen', Q::int(1)), )->toRenderSql( 'INSERT INTO t (id,hits) VALUES (?,?) AS new ON DUPLICATE KEY UPDATE hits = new.hits,seen = 1', [1, 10], ); }); + + it('renders ON DUPLICATE KEY UPDATE with the portable VALUES() reference', function () { + expect( + Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits') + ->values(Q::arg(1), Q::arg(10)) + ->onDuplicateKeyUpdate() + ->set('hits', Q::values('hits')), + )->toRenderSql( + 'INSERT INTO t (id,hits) VALUES (?,?) ON DUPLICATE KEY UPDATE hits = VALUES(hits)', + [1, 10], + ); + }); }); diff --git a/tests/Pest.php b/tests/Pest.php index f295890..572a226 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,35 +3,70 @@ declare(strict_types=1); use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder as MySQLQueryBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilderException; use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter as MySQLSqlWriter; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Target; use Flowpack\QueryObjectBuilder\PostgreSQL\Builder\QueryBuilder as PostgreSQLQueryBuilder; use Flowpack\QueryObjectBuilder\PostgreSQL\Builder\SqlWriter as PostgreSQLSqlWriter; use PHPUnit\Framework\Assert; // Expect the query under test to render to the given SQL — ignoring insignificant // whitespace — and to bind exactly the given positional arguments. Works for any -// dialect: the matching QueryBuilder is chosen from the writer's type. -expect()->extend('toRenderSql', function (string $expectedSql, ?array $args = null): void { - [$sql, $boundArgs] = renderQuery($this->value); +// dialect: the matching QueryBuilder is chosen from the writer's type. Pass a +// MySQL-family $target to also validate the query against that engine while +// rendering (rendering itself never depends on it). +expect()->extend('toRenderSql', function (string $expectedSql, ?array $args = null, ?Target $target = null): void { + [$sql, $boundArgs] = renderQuery($this->value, $target); Assert::assertSame(normalizeSql($expectedSql), normalizeSql($sql)); Assert::assertSame($args ?? [], $boundArgs); }); +// Expect building the query under test against the given MySQL-family target to fail +// validation with a QueryBuilderException whose message contains $expectedMessage. +expect()->extend('toFailValidationFor', function (Target $target, string $expectedMessage): void { + $writer = asMySQLSqlWriter($this->value); + + try { + MySQLQueryBuilder::build($writer)->withValidateTarget($target)->toSql(); + Assert::fail('Expected a QueryBuilderException for the ' . $target->describe() . ' target, but none was thrown.'); + } catch (QueryBuilderException $e) { + Assert::assertStringContainsString($expectedMessage, $e->getMessage()); + } +}); + /** - * Build the query under test through the QueryBuilder matching its dialect. + * Build the query under test through the QueryBuilder matching its dialect, + * optionally validating against a MySQL-family target. * * @return array{0: string, 1: array} */ -function renderQuery(mixed $value): array +function renderQuery(mixed $value, ?Target $target = null): array { if ($value instanceof MySQLSqlWriter) { - return MySQLQueryBuilder::build($value)->toSql(); + $builder = MySQLQueryBuilder::build($value); + if ($target !== null) { + $builder = $builder->withValidateTarget($target); + } + + return $builder->toSql(); } return PostgreSQLQueryBuilder::build(asPostgreSQLSqlWriter($value))->toSql(); } +/** + * Narrow the (statically untyped) expectation value to a MySQL SqlWriter. + */ +function asMySQLSqlWriter(mixed $value): MySQLSqlWriter +{ + if (!$value instanceof MySQLSqlWriter) { + throw new InvalidArgumentException('Expected a ' . MySQLSqlWriter::class . ' value.'); + } + + return $value; +} + /** * Narrow the (statically untyped) expectation value to a PostgreSQL SqlWriter. */ From dabc7079899f756649c2b61d8c51a3347a790b4c Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 13:13:03 +0200 Subject: [PATCH 27/40] =?UTF-8?q?wip:=20full=20fold=20=E2=80=94=20inline?= =?UTF-8?q?=20single-use=20subbuilder=20traits=20into=20their=20builders?= =?UTF-8?q?=20and=20BuildsExpressions/SharedFunctions=20into=20Q/Q\Func?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/AddsUpsertAssignment.php | 27 - src/MySQL/Builder/AliasesLastFromItem.php | 43 - src/MySQL/Builder/AliasesLastOutput.php | 37 - src/MySQL/Builder/AppliesRollup.php | 23 - src/MySQL/Builder/CombinationBuilder.php | 34 +- src/MySQL/Builder/ForSelectBuilder.php | 31 +- src/MySQL/Builder/FromSelectBuilder.php | 28 +- src/MySQL/Builder/GroupBySelectBuilder.php | 8 +- src/MySQL/Builder/JoinDeleteBuilder.php | 52 +- src/MySQL/Builder/JoinSelectBuilder.php | 52 +- .../OnDuplicateKeyUpdateInsertBuilder.php | 16 +- src/MySQL/Builder/OrderByDeleteBuilder.php | 28 +- src/MySQL/Builder/OrderBySelectBuilder.php | 28 +- src/MySQL/Builder/OrdersLastDeleteTerm.php | 40 - src/MySQL/Builder/OrdersLastTerm.php | 43 - src/MySQL/Builder/RefinesCombination.php | 44 - src/MySQL/Builder/RefinesLastDeleteJoin.php | 64 - src/MySQL/Builder/RefinesLastJoin.php | 68 -- src/MySQL/Builder/SelectSelectBuilder.php | 21 +- src/MySQL/Builder/SetsLockWaitPolicy.php | 42 - src/MySQL/BuildsExpressions.php | 324 ----- src/MySQL/Q.php | 325 +++++- src/MySQL/Q/Func.php | 1023 +++++++++++++++- src/MySQL/Q/SharedFunctions.php | 1037 ----------------- 24 files changed, 1620 insertions(+), 1818 deletions(-) delete mode 100644 src/MySQL/Builder/AddsUpsertAssignment.php delete mode 100644 src/MySQL/Builder/AliasesLastFromItem.php delete mode 100644 src/MySQL/Builder/AliasesLastOutput.php delete mode 100644 src/MySQL/Builder/AppliesRollup.php delete mode 100644 src/MySQL/Builder/OrdersLastDeleteTerm.php delete mode 100644 src/MySQL/Builder/OrdersLastTerm.php delete mode 100644 src/MySQL/Builder/RefinesCombination.php delete mode 100644 src/MySQL/Builder/RefinesLastDeleteJoin.php delete mode 100644 src/MySQL/Builder/RefinesLastJoin.php delete mode 100644 src/MySQL/Builder/SetsLockWaitPolicy.php delete mode 100644 src/MySQL/BuildsExpressions.php delete mode 100644 src/MySQL/Q/SharedFunctions.php diff --git a/src/MySQL/Builder/AddsUpsertAssignment.php b/src/MySQL/Builder/AddsUpsertAssignment.php deleted file mode 100644 index 3142be4..0000000 --- a/src/MySQL/Builder/AddsUpsertAssignment.php +++ /dev/null @@ -1,27 +0,0 @@ -derive(static::class, onDuplicateKeyUpdateSetItems: [ - ...$this->onDuplicateKeyUpdateSetItems, - new UpdateSetItem($columnName, $value), - ]); - } -} diff --git a/src/MySQL/Builder/AliasesLastFromItem.php b/src/MySQL/Builder/AliasesLastFromItem.php deleted file mode 100644 index dce89ae..0000000 --- a/src/MySQL/Builder/AliasesLastFromItem.php +++ /dev/null @@ -1,43 +0,0 @@ -parts->from; - $lastIdx = array_key_last($from); - assert($lastIdx !== null); - $item = $from[$lastIdx]; - $from[$lastIdx] = new FromItem($item->from, $alias, $item->lateral, $item->columnAliases); - - return $this->derive(static::class, from: $from); - } - - /** - * Set the column aliases for the last added from item. - */ - public function columnAliases(string ...$aliases): static - { - $from = $this->parts->from; - $lastIdx = array_key_last($from); - assert($lastIdx !== null); - $item = $from[$lastIdx]; - $from[$lastIdx] = new FromItem($item->from, $item->alias, $item->lateral, array_values($aliases)); - - return $this->derive(static::class, from: $from); - } -} diff --git a/src/MySQL/Builder/AliasesLastOutput.php b/src/MySQL/Builder/AliasesLastOutput.php deleted file mode 100644 index 30dadaa..0000000 --- a/src/MySQL/Builder/AliasesLastOutput.php +++ /dev/null @@ -1,37 +0,0 @@ -parts->selectList; - $lastIdx = array_key_last($selectList); - assert($lastIdx !== null); - $selectList[$lastIdx] = new OutputExpr($selectList[$lastIdx]->exp, $alias); - - return $this->derive(static::class, selectList: $selectList); - } - - /** - * Make the select `DISTINCT`. - */ - public function distinct(): static - { - return $this->derive(static::class, distinct: true); - } -} diff --git a/src/MySQL/Builder/AppliesRollup.php b/src/MySQL/Builder/AppliesRollup.php deleted file mode 100644 index 3c32ee5..0000000 --- a/src/MySQL/Builder/AppliesRollup.php +++ /dev/null @@ -1,23 +0,0 @@ -derive(static::class, groupByWithRollup: true); - } -} diff --git a/src/MySQL/Builder/CombinationBuilder.php b/src/MySQL/Builder/CombinationBuilder.php index b2bb281..c26fc92 100644 --- a/src/MySQL/Builder/CombinationBuilder.php +++ b/src/MySQL/Builder/CombinationBuilder.php @@ -12,5 +12,37 @@ */ final class CombinationBuilder extends SelectBuilder { - use RefinesCombination; + /** + * Switch the combination to its `ALL` variant. + */ + public function all(): static + { + return $this->derive(static::class, combinations: $this->rebuildLastCombination(all: true)); + } + + /** + * Supply the query following the combination. + */ + public function query(SelectBuilder $query): static + { + return $this->derive(static::class, combinations: $this->rebuildLastCombination(query: $query)); + } + + /** + * Return the combinations with the last one replaced by a copy carrying the + * given overrides. + * + * @return list + */ + private function rebuildLastCombination(?bool $all = null, ?SelectBuilder $query = null): array + { + $combinations = $this->combinations; + $lastIdx = array_key_last($combinations); + assert($lastIdx !== null); + + $c = $combinations[$lastIdx]; + $combinations[$lastIdx] = new Combination($c->parts, $c->type, $all ?? $c->all, $query ?? $c->query); + + return $combinations; + } } diff --git a/src/MySQL/Builder/ForSelectBuilder.php b/src/MySQL/Builder/ForSelectBuilder.php index 17c5be8..25d51c6 100644 --- a/src/MySQL/Builder/ForSelectBuilder.php +++ b/src/MySQL/Builder/ForSelectBuilder.php @@ -11,8 +11,6 @@ */ final class ForSelectBuilder extends SelectBuilder { - use SetsLockWaitPolicy; - /** * Restrict the lock to the given tables (`OF table [, ...]`). */ @@ -20,4 +18,33 @@ public function of(string ...$tables): static { return $this->deriveLocking(ofTables: array_values($tables)); } + + public function nowait(): static + { + return $this->deriveLocking(waitPolicy: 'NOWAIT'); + } + + public function skipLocked(): static + { + return $this->deriveLocking(waitPolicy: 'SKIP LOCKED'); + } + + /** + * Reconstruct the locking clause with the given OF tables / wait policy applied + * (this is the one place the {@see LockingClause} is rebuilt). + * + * @param list|null $ofTables + */ + private function deriveLocking(?array $ofTables = null, ?string $waitPolicy = null): static + { + $lc = $this->parts->lockingClause; + assert($lc !== null); + + return $this->derive(static::class, lockingClause: new LockingClause( + $lc->clause, + $ofTables ?? $lc->ofTables, + $waitPolicy ?? $lc->waitPolicy, + $lc->requires, + )); + } } diff --git a/src/MySQL/Builder/FromSelectBuilder.php b/src/MySQL/Builder/FromSelectBuilder.php index bbce213..7e1b138 100644 --- a/src/MySQL/Builder/FromSelectBuilder.php +++ b/src/MySQL/Builder/FromSelectBuilder.php @@ -11,5 +11,31 @@ */ final class FromSelectBuilder extends SelectBuilder { - use AliasesLastFromItem; + /** + * Set the alias for the last added from item. + */ + public function as(string $alias): static + { + $from = $this->parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + $item = $from[$lastIdx]; + $from[$lastIdx] = new FromItem($item->from, $alias, $item->lateral, $item->columnAliases); + + return $this->derive(static::class, from: $from); + } + + /** + * Set the column aliases for the last added from item. + */ + public function columnAliases(string ...$aliases): static + { + $from = $this->parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + $item = $from[$lastIdx]; + $from[$lastIdx] = new FromItem($item->from, $item->alias, $item->lateral, array_values($aliases)); + + return $this->derive(static::class, from: $from); + } } diff --git a/src/MySQL/Builder/GroupBySelectBuilder.php b/src/MySQL/Builder/GroupBySelectBuilder.php index 862afa1..e580e73 100644 --- a/src/MySQL/Builder/GroupBySelectBuilder.php +++ b/src/MySQL/Builder/GroupBySelectBuilder.php @@ -10,5 +10,11 @@ */ final class GroupBySelectBuilder extends SelectBuilder { - use AppliesRollup; + /** + * Add super-aggregate rows over the grouping (`GROUP BY ... WITH ROLLUP`). + */ + public function withRollup(): static + { + return $this->derive(static::class, groupByWithRollup: true); + } } diff --git a/src/MySQL/Builder/JoinDeleteBuilder.php b/src/MySQL/Builder/JoinDeleteBuilder.php index 9f7e44f..d2ca90f 100644 --- a/src/MySQL/Builder/JoinDeleteBuilder.php +++ b/src/MySQL/Builder/JoinDeleteBuilder.php @@ -10,5 +10,55 @@ */ final class JoinDeleteBuilder extends DeleteBuilder { - use RefinesLastDeleteJoin; + /** + * Set the alias for the last added join. + */ + public function as(string $alias): static + { + return $this->derive(static::class, joins: $this->rebuildLastJoin(alias: $alias)); + } + + /** + * Set the ON condition for the last added join. + */ + public function on(Exp $cond): static + { + return $this->derive(static::class, joins: $this->rebuildLastJoin(on: $cond)); + } + + /** + * Set the USING columns for the last added join. + */ + public function using(string ...$columns): static + { + return $this->derive(static::class, joins: $this->rebuildLastJoin(using: array_values($columns))); + } + + /** + * Return the join list with the last join replaced by a copy carrying the + * given overrides. + * + * @param list|null $using + * @return list + */ + private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array + { + $joins = $this->joins; + $lastIdx = array_key_last($joins); + assert($lastIdx !== null); + + $join = $joins[$lastIdx]->from; + assert($join instanceof Join); + + $joins[$lastIdx] = new FromItem(new Join( + $join->joinType, + $join->lateral, + $join->from, + $alias ?? $join->alias, + $on ?? $join->on, + $using ?? $join->using, + )); + + return $joins; + } } diff --git a/src/MySQL/Builder/JoinSelectBuilder.php b/src/MySQL/Builder/JoinSelectBuilder.php index 0ad8620..f9eb7f1 100644 --- a/src/MySQL/Builder/JoinSelectBuilder.php +++ b/src/MySQL/Builder/JoinSelectBuilder.php @@ -10,5 +10,55 @@ */ final class JoinSelectBuilder extends SelectBuilder { - use RefinesLastJoin; + /** + * Set the alias for the last added join. + */ + public function as(string $alias): static + { + return $this->derive(static::class, from: $this->rebuildLastJoin(alias: $alias)); + } + + /** + * Set the ON condition for the last added join. + */ + public function on(Exp $cond): static + { + return $this->derive(static::class, from: $this->rebuildLastJoin(on: $cond)); + } + + /** + * Set the USING columns for the last added join. + */ + public function using(string ...$columns): static + { + return $this->derive(static::class, from: $this->rebuildLastJoin(using: array_values($columns))); + } + + /** + * Return the from list with the last join replaced by a copy carrying the + * given overrides. + * + * @param list|null $using + * @return list + */ + private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array + { + $from = $this->parts->from; + $lastIdx = array_key_last($from); + assert($lastIdx !== null); + + $join = $from[$lastIdx]->from; + assert($join instanceof Join); + + $from[$lastIdx] = new FromItem(new Join( + $join->joinType, + $join->lateral, + $join->from, + $alias ?? $join->alias, + $on ?? $join->on, + $using ?? $join->using, + )); + + return $from; + } } diff --git a/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php b/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php index 566fdec..c98ebfd 100644 --- a/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php +++ b/src/MySQL/Builder/OnDuplicateKeyUpdateInsertBuilder.php @@ -6,10 +6,20 @@ /** * The INSERT builder state inside an `ON DUPLICATE KEY UPDATE` clause, where - * {@see AddsUpsertAssignment::set()} adds the assignments applied when a unique - * key already exists. + * {@see set()} adds the assignments applied when a unique key already exists. */ final class OnDuplicateKeyUpdateInsertBuilder extends InsertBuilder { - use AddsUpsertAssignment; + /** + * Add a `column = value` assignment. Reference the proposed row via + * `Q::values('col')`, or via `Q::n('new.col')` after aliasing it with + * {@see InsertValuesBuilder::as()}. + */ + public function set(string $columnName, Exp $value): static + { + return $this->derive(static::class, onDuplicateKeyUpdateSetItems: [ + ...$this->onDuplicateKeyUpdateSetItems, + new UpdateSetItem($columnName, $value), + ]); + } } diff --git a/src/MySQL/Builder/OrderByDeleteBuilder.php b/src/MySQL/Builder/OrderByDeleteBuilder.php index ef6b14c..4ed955f 100644 --- a/src/MySQL/Builder/OrderByDeleteBuilder.php +++ b/src/MySQL/Builder/OrderByDeleteBuilder.php @@ -10,5 +10,31 @@ */ final class OrderByDeleteBuilder extends DeleteBuilder { - use OrdersLastDeleteTerm; + public function asc(): static + { + return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); + } + + public function desc(): static + { + return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); + } + + /** + * Return the order by list with the last term replaced by a copy carrying the + * given sort direction. + * + * @return list + */ + private function rebuildLastOrderBy(SortOrder $order): array + { + $orderBys = $this->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $orderBys; + } } diff --git a/src/MySQL/Builder/OrderBySelectBuilder.php b/src/MySQL/Builder/OrderBySelectBuilder.php index 6cad33c..d86e55b 100644 --- a/src/MySQL/Builder/OrderBySelectBuilder.php +++ b/src/MySQL/Builder/OrderBySelectBuilder.php @@ -10,5 +10,31 @@ */ class OrderBySelectBuilder extends SelectBuilder { - use OrdersLastTerm; + public function asc(): static + { + return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); + } + + public function desc(): static + { + return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); + } + + /** + * Return the order by list with the last term replaced by a copy carrying the + * given sort direction. + * + * @return list + */ + private function rebuildLastOrderBy(SortOrder $order): array + { + $orderBys = $this->parts->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return $orderBys; + } } diff --git a/src/MySQL/Builder/OrdersLastDeleteTerm.php b/src/MySQL/Builder/OrdersLastDeleteTerm.php deleted file mode 100644 index 151142f..0000000 --- a/src/MySQL/Builder/OrdersLastDeleteTerm.php +++ /dev/null @@ -1,40 +0,0 @@ -derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); - } - - public function desc(): static - { - return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); - } - - /** - * @return list - */ - private function rebuildLastOrderBy(SortOrder $order): array - { - $orderBys = $this->orderBys; - $lastIdx = array_key_last($orderBys); - assert($lastIdx !== null); - - $clause = $orderBys[$lastIdx]; - $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); - - return $orderBys; - } -} diff --git a/src/MySQL/Builder/OrdersLastTerm.php b/src/MySQL/Builder/OrdersLastTerm.php deleted file mode 100644 index de4aee3..0000000 --- a/src/MySQL/Builder/OrdersLastTerm.php +++ /dev/null @@ -1,43 +0,0 @@ -derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Asc)); - } - - public function desc(): static - { - return $this->derive(static::class, orderBys: $this->rebuildLastOrderBy(SortOrder::Desc)); - } - - /** - * Return the order by list with the last term replaced by a copy carrying the - * given sort direction. - * - * @return list - */ - private function rebuildLastOrderBy(SortOrder $order): array - { - $orderBys = $this->parts->orderBys; - $lastIdx = array_key_last($orderBys); - assert($lastIdx !== null); - - $clause = $orderBys[$lastIdx]; - $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); - - return $orderBys; - } -} diff --git a/src/MySQL/Builder/RefinesCombination.php b/src/MySQL/Builder/RefinesCombination.php deleted file mode 100644 index a1fde7c..0000000 --- a/src/MySQL/Builder/RefinesCombination.php +++ /dev/null @@ -1,44 +0,0 @@ -derive(static::class, combinations: $this->rebuildLastCombination(all: true)); - } - - public function query(SelectBuilder $query): static - { - return $this->derive(static::class, combinations: $this->rebuildLastCombination(query: $query)); - } - - /** - * Return the combinations with the last one replaced by a copy carrying the - * given overrides. - * - * @return list - */ - private function rebuildLastCombination(?bool $all = null, ?SelectBuilder $query = null): array - { - $combinations = $this->combinations; - $lastIdx = array_key_last($combinations); - assert($lastIdx !== null); - - $c = $combinations[$lastIdx]; - $combinations[$lastIdx] = new Combination($c->parts, $c->type, $all ?? $c->all, $query ?? $c->query); - - return $combinations; - } -} diff --git a/src/MySQL/Builder/RefinesLastDeleteJoin.php b/src/MySQL/Builder/RefinesLastDeleteJoin.php deleted file mode 100644 index 3adc4c0..0000000 --- a/src/MySQL/Builder/RefinesLastDeleteJoin.php +++ /dev/null @@ -1,64 +0,0 @@ -derive(static::class, joins: $this->rebuildLastJoin(alias: $alias)); - } - - /** - * Set the ON condition for the last added join. - */ - public function on(Exp $cond): static - { - return $this->derive(static::class, joins: $this->rebuildLastJoin(on: $cond)); - } - - /** - * Set the USING columns for the last added join. - */ - public function using(string ...$columns): static - { - return $this->derive(static::class, joins: $this->rebuildLastJoin(using: array_values($columns))); - } - - /** - * @param list|null $using - * @return list - */ - private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array - { - $joins = $this->joins; - $lastIdx = array_key_last($joins); - assert($lastIdx !== null); - - $join = $joins[$lastIdx]->from; - assert($join instanceof Join); - - $joins[$lastIdx] = new FromItem(new Join( - $join->joinType, - $join->lateral, - $join->from, - $alias ?? $join->alias, - $on ?? $join->on, - $using ?? $join->using, - )); - - return $joins; - } -} diff --git a/src/MySQL/Builder/RefinesLastJoin.php b/src/MySQL/Builder/RefinesLastJoin.php deleted file mode 100644 index 2460dd0..0000000 --- a/src/MySQL/Builder/RefinesLastJoin.php +++ /dev/null @@ -1,68 +0,0 @@ -derive(static::class, from: $this->rebuildLastJoin(alias: $alias)); - } - - /** - * Set the ON condition for the last added join. - */ - public function on(Exp $cond): static - { - return $this->derive(static::class, from: $this->rebuildLastJoin(on: $cond)); - } - - /** - * Set the USING columns for the last added join. - */ - public function using(string ...$columns): static - { - return $this->derive(static::class, from: $this->rebuildLastJoin(using: array_values($columns))); - } - - /** - * Return the from list with the last join replaced by a copy carrying the - * given overrides. - * - * @param list|null $using - * @return list - */ - private function rebuildLastJoin(?string $alias = null, ?Exp $on = null, ?array $using = null): array - { - $from = $this->parts->from; - $lastIdx = array_key_last($from); - assert($lastIdx !== null); - - $join = $from[$lastIdx]->from; - assert($join instanceof Join); - - $from[$lastIdx] = new FromItem(new Join( - $join->joinType, - $join->lateral, - $join->from, - $alias ?? $join->alias, - $on ?? $join->on, - $using ?? $join->using, - )); - - return $from; - } -} diff --git a/src/MySQL/Builder/SelectSelectBuilder.php b/src/MySQL/Builder/SelectSelectBuilder.php index c332b4d..ab7488c 100644 --- a/src/MySQL/Builder/SelectSelectBuilder.php +++ b/src/MySQL/Builder/SelectSelectBuilder.php @@ -11,5 +11,24 @@ */ final class SelectSelectBuilder extends SelectBuilder { - use AliasesLastOutput; + /** + * Set the output alias for the last added select expression. + */ + public function as(string $alias): static + { + $selectList = $this->parts->selectList; + $lastIdx = array_key_last($selectList); + assert($lastIdx !== null); + $selectList[$lastIdx] = new OutputExpr($selectList[$lastIdx]->exp, $alias); + + return $this->derive(static::class, selectList: $selectList); + } + + /** + * Make the select `DISTINCT`. + */ + public function distinct(): static + { + return $this->derive(static::class, distinct: true); + } } diff --git a/src/MySQL/Builder/SetsLockWaitPolicy.php b/src/MySQL/Builder/SetsLockWaitPolicy.php deleted file mode 100644 index c749a50..0000000 --- a/src/MySQL/Builder/SetsLockWaitPolicy.php +++ /dev/null @@ -1,42 +0,0 @@ -deriveLocking(waitPolicy: 'NOWAIT'); - } - - public function skipLocked(): static - { - return $this->deriveLocking(waitPolicy: 'SKIP LOCKED'); - } - - /** - * @param list|null $ofTables - */ - protected function deriveLocking(?array $ofTables = null, ?string $waitPolicy = null): static - { - $lc = $this->parts->lockingClause; - assert($lc !== null); - - return $this->derive(static::class, lockingClause: new LockingClause( - $lc->clause, - $ofTables ?? $lc->ofTables, - $waitPolicy ?? $lc->waitPolicy, - $lc->requires, - )); - } -} diff --git a/src/MySQL/BuildsExpressions.php b/src/MySQL/BuildsExpressions.php deleted file mode 100644 index e720e43..0000000 --- a/src/MySQL/BuildsExpressions.php +++ /dev/null @@ -1,324 +0,0 @@ - new Arg($a), array_values($arguments))); - } - - /** - * Combine the given expressions with AND. - */ - public static function and(Exp ...$exps): Junction - { - return Junction::and(...$exps); - } - - /** - * Combine the given expressions with OR. - */ - public static function or(Exp ...$exps): Junction - { - return Junction::or(...$exps); - } - - /** - * Negate an expression (`NOT ...`). - */ - public static function not(Exp $exp): UnaryExp - { - return new UnaryExp($exp, Precedence::of('NOT'), prefix: 'NOT'); - } - - /** - * Arithmetic negation (`- ...`) of a numeric expression. - */ - public static function neg(Exp $exp): UnaryExp - { - return new UnaryExp($exp, 5, prefix: '-'); // unary minus binds tightly - } - - /** - * A function-call expression, e.g. `Q::func('CONCAT', $a, $b)` for - * `CONCAT(a, b)`. Common functions also have dedicated helpers on `Q\Func`. - */ - public static function func(string $name, Exp ...$args): FuncExp - { - return new FuncExp($name, array_values($args)); - } - - /** - * Reference the value proposed for `column` inside `ON DUPLICATE KEY UPDATE`, - * rendered as `VALUES(column)`. (With the `AS new` row alias, reference it as - * `Q::n('new.column')` instead.) - */ - public static function values(string $column): FuncExp - { - return new FuncExp('VALUES', [IdentExp::n($column)]); - } - - /** - * A `CAST(expr AS type)` expression (e.g. `Q::cast($x, 'UNSIGNED')`). - */ - public static function cast(Exp $exp, string $type): CastExp - { - return new CastExp($exp, new TypeExp($type)); - } - - /** - * A `CONVERT(expr, type)` expression — the function-call form of a type cast. - */ - public static function convert(Exp $exp, string $type): ConvertExp - { - return new ConvertExp($exp, new TypeExp($type)); - } - - /** - * A temporal `INTERVAL expr unit` operand (e.g. `Q::interval(Q::int(1), 'DAY')` - * for `INTERVAL 1 DAY`), for date arithmetic and `Q\Func::dateAdd()` / `dateSub()`. - */ - public static function interval(Exp $expr, string $unit): IntervalExp - { - return new IntervalExp($expr, $unit); - } - - /** - * Build a `COALESCE(...)` expression. - */ - public static function coalesce(Exp $exp, Exp ...$rest): FuncExp - { - return new FuncExp('COALESCE', array_values([$exp, ...$rest])); - } - - /** - * Build a `NULLIF(a, b)` expression (returns NULL when the two are equal). - */ - public static function nullif(Exp $a, Exp $b): FuncExp - { - return new FuncExp('NULLIF', [$a, $b]); - } - - /** - * Build a `GREATEST(...)` expression (the largest of its arguments). - */ - public static function greatest(Exp $exp, Exp ...$rest): FuncExp - { - return new FuncExp('GREATEST', array_values([$exp, ...$rest])); - } - - /** - * Build a `LEAST(...)` expression (the smallest of its arguments). - */ - public static function least(Exp $exp, Exp ...$rest): FuncExp - { - return new FuncExp('LEAST', array_values([$exp, ...$rest])); - } - - /** - * Start a CASE expression (optionally with a leading expression to compare - * each WHEN against). - */ - public static function case(Exp ...$exp): CaseBuilder - { - return new CaseBuilder($exp[0] ?? null); - } - - // Window frame bounds (for `ROWS` / `RANGE` frame clauses) - - /** - * The `CURRENT ROW` frame bound. - */ - public static function currentRow(): FrameBound - { - return FrameBound::currentRow(); - } - - /** - * The `UNBOUNDED PRECEDING` frame bound (the start of the partition). - */ - public static function unboundedPreceding(): FrameBound - { - return FrameBound::unboundedPreceding(); - } - - /** - * The `UNBOUNDED FOLLOWING` frame bound (the end of the partition). - */ - public static function unboundedFollowing(): FrameBound - { - return FrameBound::unboundedFollowing(); - } - - /** - * An `expr PRECEDING` frame bound (the given offset before the current row). - */ - public static function preceding(Exp $offset): FrameBound - { - return FrameBound::preceding($offset); - } - - /** - * An `expr FOLLOWING` frame bound (the given offset after the current row). - */ - public static function following(Exp $offset): FrameBound - { - return FrameBound::following($offset); - } - - /** - * Start a new query builder for the given writer. - */ - public static function build(SqlWriter $writer): QueryBuilder - { - return QueryBuilder::build($writer); - } -} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 04db011..7bb5214 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -4,32 +4,55 @@ namespace Flowpack\QueryObjectBuilder\MySQL; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Arg; +use Flowpack\QueryObjectBuilder\MySQL\Builder\BindExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\BoolLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\CaseBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\CastExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\ConvertExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\DefaultLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\DeleteBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\ExistsExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Expressions; +use Flowpack\QueryObjectBuilder\MySQL\Builder\FloatLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\FrameBound; +use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IdentExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\InsertBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\IntervalExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\IntLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Junction; +use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Precedence; +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\ReplaceBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectSelectBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter; +use Flowpack\QueryObjectBuilder\MySQL\Builder\StringLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SubqueryExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\TypeExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\UnaryExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\UpdateBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithQueryItem; use Flowpack\QueryObjectBuilder\MySQL\Builder\WithWithBuilder; /** - * Entry point (facade) for building MySQL queries. - * - * The dialect-agnostic expression surface lives in {@see BuildsExpressions}; this - * facade adds the statement entry points (SELECT, INSERT, REPLACE, UPDATE, DELETE - * and WITH). + * Entry point (facade) for building queries: the statement entry points (SELECT, + * INSERT, REPLACE, UPDATE, DELETE and WITH) plus the expression surface — literals, + * arguments, facade-built operators/functions, CASE, EXISTS/ANY/ALL, window frame + * bounds and the query-builder entry point. Dedicated function helpers live on + * {@see Q\Func}. */ final class Q { - use BuildsExpressions; - private function __construct() { } + // Statement entry points + /** * Select the given output expressions and start a new select builder. */ @@ -85,4 +108,292 @@ public static function withRecursive(string $queryName): WithWithBuilder { return new WithWithBuilder([new WithQueryItem(true, $queryName)]); } + + // Subquery predicates + + /** + * An `EXISTS (subquery)` expression. + */ + public static function exists(SelectBuilder $subquery): ExistsExp + { + return new ExistsExp($subquery); + } + + /** + * An `ANY (...)` row/subquery comparison operand. + */ + public static function any(Exp $exp): SubqueryExp + { + return new SubqueryExp('ANY', $exp); + } + + /** + * An `ALL (...)` row/subquery comparison operand. + */ + public static function all(Exp $exp): SubqueryExp + { + return new SubqueryExp('ALL', $exp); + } + + // Identifiers, literals and arguments + + /** + * Write the given name / identifier (validated when the query is built). + */ + public static function n(string $s): IdentExp + { + return IdentExp::n($s); + } + + /** + * A string literal. + */ + public static function string(string $s): StringLiteral + { + return new StringLiteral($s); + } + + /** + * An integer literal. + */ + public static function int(int $i): IntLiteral + { + return new IntLiteral($i); + } + + /** + * A floating-point literal. + */ + public static function float(float $f): FloatLiteral + { + return new FloatLiteral($f); + } + + /** + * A boolean literal (`TRUE` / `FALSE`). + */ + public static function bool(bool $b): BoolLiteral + { + return new BoolLiteral($b); + } + + /** + * The SQL `NULL` literal. + */ + public static function null(): NullLiteral + { + return new NullLiteral(); + } + + /** + * The SQL `DEFAULT` keyword, usable as a value in INSERT / UPDATE. + */ + public static function default(): DefaultLiteral + { + return new DefaultLiteral(); + } + + /** + * Create a bound argument expression (a positional `?` placeholder). + */ + public static function arg(mixed $argument): Arg + { + return new Arg($argument); + } + + /** + * A named argument placeholder; bind its value via + * {@see QueryBuilder::withNamedArgs()}. Each occurrence emits its own `?`. + */ + public static function bind(string $name): BindExp + { + return new BindExp($name); + } + + /** + * A parenthesized list of expressions, e.g. for `IN (...)`. + */ + public static function exps(Exp ...$exps): Expressions + { + return new Expressions(array_values($exps)); + } + + /** + * A parenthesized list of bound arguments, e.g. for `IN (?, ?, ?)`. + */ + public static function args(mixed ...$arguments): Expressions + { + return new Expressions(array_map(static fn (mixed $a): Arg => new Arg($a), array_values($arguments))); + } + + // Boolean and arithmetic combinators + + /** + * Combine the given expressions with AND. + */ + public static function and(Exp ...$exps): Junction + { + return Junction::and(...$exps); + } + + /** + * Combine the given expressions with OR. + */ + public static function or(Exp ...$exps): Junction + { + return Junction::or(...$exps); + } + + /** + * Negate an expression (`NOT ...`). + */ + public static function not(Exp $exp): UnaryExp + { + return new UnaryExp($exp, Precedence::of('NOT'), prefix: 'NOT'); + } + + /** + * Arithmetic negation (`- ...`) of a numeric expression. + */ + public static function neg(Exp $exp): UnaryExp + { + return new UnaryExp($exp, 5, prefix: '-'); // unary minus binds tightly + } + + // Facade-built functions and casts + + /** + * A function-call expression, e.g. `Q::func('CONCAT', $a, $b)` for + * `CONCAT(a, b)`. Common functions also have dedicated helpers on `Q\Func`. + */ + public static function func(string $name, Exp ...$args): FuncExp + { + return new FuncExp($name, array_values($args)); + } + + /** + * Reference the value proposed for `column` inside `ON DUPLICATE KEY UPDATE`, + * rendered as `VALUES(column)`. (With the `AS new` row alias, reference it as + * `Q::n('new.column')` instead.) + */ + public static function values(string $column): FuncExp + { + return new FuncExp('VALUES', [IdentExp::n($column)]); + } + + /** + * A `CAST(expr AS type)` expression (e.g. `Q::cast($x, 'UNSIGNED')`). + */ + public static function cast(Exp $exp, string $type): CastExp + { + return new CastExp($exp, new TypeExp($type)); + } + + /** + * A `CONVERT(expr, type)` expression — the function-call form of a type cast. + */ + public static function convert(Exp $exp, string $type): ConvertExp + { + return new ConvertExp($exp, new TypeExp($type)); + } + + /** + * A temporal `INTERVAL expr unit` operand (e.g. `Q::interval(Q::int(1), 'DAY')` + * for `INTERVAL 1 DAY`), for date arithmetic and `Q\Func::dateAdd()` / `dateSub()`. + */ + public static function interval(Exp $expr, string $unit): IntervalExp + { + return new IntervalExp($expr, $unit); + } + + /** + * Build a `COALESCE(...)` expression. + */ + public static function coalesce(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('COALESCE', array_values([$exp, ...$rest])); + } + + /** + * Build a `NULLIF(a, b)` expression (returns NULL when the two are equal). + */ + public static function nullif(Exp $a, Exp $b): FuncExp + { + return new FuncExp('NULLIF', [$a, $b]); + } + + /** + * Build a `GREATEST(...)` expression (the largest of its arguments). + */ + public static function greatest(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('GREATEST', array_values([$exp, ...$rest])); + } + + /** + * Build a `LEAST(...)` expression (the smallest of its arguments). + */ + public static function least(Exp $exp, Exp ...$rest): FuncExp + { + return new FuncExp('LEAST', array_values([$exp, ...$rest])); + } + + /** + * Start a CASE expression (optionally with a leading expression to compare + * each WHEN against). + */ + public static function case(Exp ...$exp): CaseBuilder + { + return new CaseBuilder($exp[0] ?? null); + } + + // Window frame bounds (for `ROWS` / `RANGE` frame clauses) + + /** + * The `CURRENT ROW` frame bound. + */ + public static function currentRow(): FrameBound + { + return FrameBound::currentRow(); + } + + /** + * The `UNBOUNDED PRECEDING` frame bound (the start of the partition). + */ + public static function unboundedPreceding(): FrameBound + { + return FrameBound::unboundedPreceding(); + } + + /** + * The `UNBOUNDED FOLLOWING` frame bound (the end of the partition). + */ + public static function unboundedFollowing(): FrameBound + { + return FrameBound::unboundedFollowing(); + } + + /** + * An `expr PRECEDING` frame bound (the given offset before the current row). + */ + public static function preceding(Exp $offset): FrameBound + { + return FrameBound::preceding($offset); + } + + /** + * An `expr FOLLOWING` frame bound (the given offset after the current row). + */ + public static function following(Exp $offset): FrameBound + { + return FrameBound::following($offset); + } + + /** + * Start a new query builder for the given writer. + */ + public static function build(SqlWriter $writer): QueryBuilder + { + return QueryBuilder::build($writer); + } } diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index 5991c7a..7b74403 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -7,20 +7,24 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\AggBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Dialect; use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\ExtractExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\GroupConcatBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Keyword; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Requirement; +use Flowpack\QueryObjectBuilder\MySQL\Builder\TrimExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\WindowFuncBuilder; /** - * Facade for SQL function expressions, accessed as `Q\Func`. - * - * The bulk of the set lives in {@see SharedFunctions}. This facade adds the - * functions only one engine of the MySQL family provides; each marks itself while + * Facade for SQL function expressions, accessed as `Q\Func`: aggregates, the + * string / regexp / numeric / date-time / JSON / misc scalar families, the window + * functions and the special-shape builders, plus the functions only one engine of + * the MySQL family provides. Each dialect-only function marks itself while * rendering, so validating the query against a {@see \Flowpack\QueryObjectBuilder\MySQL\Builder\Target} * reports the ones the target cannot express. */ final class Func { - use SharedFunctions; - private function __construct() { } @@ -136,4 +140,1011 @@ public static function oct(Exp $n): FuncExp { return self::gated(Dialect::MariaDb, 'OCT', $n); } + + // Aggregate functions — usable on their own or, via {@see AggBuilder::over()}, + // as window functions. + + /** + * Build a `COUNT(expr)` aggregate (pass `Q::n('*')` for `COUNT(*)`). + */ + public static function count(Exp $expr): AggBuilder + { + return new AggBuilder('COUNT', [$expr]); + } + + /** + * Build a `SUM(expr)` aggregate. + */ + public static function sum(Exp $expr): AggBuilder + { + return new AggBuilder('SUM', [$expr]); + } + + /** + * Build an `AVG(expr)` aggregate. + */ + public static function avg(Exp $expr): AggBuilder + { + return new AggBuilder('AVG', [$expr]); + } + + /** + * Build a `MIN(expr)` aggregate. + */ + public static function min(Exp $expr): AggBuilder + { + return new AggBuilder('MIN', [$expr]); + } + + /** + * Build a `MAX(expr)` aggregate. + */ + public static function max(Exp $expr): AggBuilder + { + return new AggBuilder('MAX', [$expr]); + } + + /** + * Build a `GROUP_CONCAT(...)` aggregate. Refine with + * {@see GroupConcatBuilder::distinct()} / {@see GroupConcatBuilder::orderBy()} / + * {@see GroupConcatBuilder::separator()}. + */ + public static function groupConcat(Exp $expr, Exp ...$rest): GroupConcatBuilder + { + return new GroupConcatBuilder(array_values([$expr, ...$rest])); + } + + /** + * Build a `JSON_ARRAYAGG(expr)` aggregate (collects values into a JSON array). + */ + public static function jsonArrayAgg(Exp $expr): AggBuilder + { + return self::agg('JSON_ARRAYAGG', $expr); + } + + /** + * Build a `JSON_OBJECTAGG(key, value)` aggregate (collects pairs into a JSON object). + */ + public static function jsonObjectAgg(Exp $key, Exp $value): AggBuilder + { + return self::agg('JSON_OBJECTAGG', $key, $value); + } + + /** + * Build a `BIT_AND(expr)` aggregate (bitwise AND of all values). + */ + public static function bitAnd(Exp $expr): AggBuilder + { + return self::agg('BIT_AND', $expr); + } + + /** + * Build a `BIT_OR(expr)` aggregate (bitwise OR of all values). + */ + public static function bitOr(Exp $expr): AggBuilder + { + return self::agg('BIT_OR', $expr); + } + + /** + * Build a `BIT_XOR(expr)` aggregate (bitwise XOR of all values). + */ + public static function bitXor(Exp $expr): AggBuilder + { + return self::agg('BIT_XOR', $expr); + } + + /** + * Build a `STDDEV_POP(expr)` aggregate (population standard deviation). + */ + public static function stddevPop(Exp $expr): AggBuilder + { + return self::agg('STDDEV_POP', $expr); + } + + /** + * Build a `STDDEV_SAMP(expr)` aggregate (sample standard deviation). + */ + public static function stddevSamp(Exp $expr): AggBuilder + { + return self::agg('STDDEV_SAMP', $expr); + } + + /** + * Build a `VAR_POP(expr)` aggregate (population variance). + */ + public static function varPop(Exp $expr): AggBuilder + { + return self::agg('VAR_POP', $expr); + } + + /** + * Build a `VAR_SAMP(expr)` aggregate (sample variance). + */ + public static function varSamp(Exp $expr): AggBuilder + { + return self::agg('VAR_SAMP', $expr); + } + + // Conditional functions + + /** + * Build an `IF(condition, then, else)` expression. + */ + public static function if(Exp $condition, Exp $then, Exp $else): FuncExp + { + return new FuncExp('IF', [$condition, $then, $else]); + } + + /** + * Build an `IFNULL(a, b)` expression (returns `b` when `a` is NULL). + */ + public static function ifnull(Exp $a, Exp $b): FuncExp + { + return new FuncExp('IFNULL', [$a, $b]); + } + + // Date / time functions with special shapes + + /** + * Build an `EXTRACT(unit FROM source)` expression (e.g. `extract('YEAR', $d)`). + */ + public static function extract(string $unit, Exp $from): ExtractExp + { + return new ExtractExp($unit, $from); + } + + /** + * Build a `DATE_ADD(date, interval)` expression. Pass the interval via + * `Q::interval(...)` (e.g. `dateAdd($d, Q::interval(Q::int(1), 'DAY'))`). + */ + public static function dateAdd(Exp $date, Exp $interval): FuncExp + { + return new FuncExp('DATE_ADD', [$date, $interval]); + } + + /** + * Build a `DATE_SUB(date, interval)` expression. Pass the interval via + * `Q::interval(...)`. + */ + public static function dateSub(Exp $date, Exp $interval): FuncExp + { + return new FuncExp('DATE_SUB', [$date, $interval]); + } + + // String trimming + + /** + * Build a `TRIM(str)` expression (removes leading and trailing spaces). + */ + public static function trim(Exp $str): TrimExp + { + return new TrimExp($str); + } + + /** + * Build a `TRIM(LEADING remstr FROM str)` expression. + */ + public static function trimLeading(Exp $remstr, Exp $str): TrimExp + { + return new TrimExp($str, 'LEADING', $remstr); + } + + /** + * Build a `TRIM(TRAILING remstr FROM str)` expression. + */ + public static function trimTrailing(Exp $remstr, Exp $str): TrimExp + { + return new TrimExp($str, 'TRAILING', $remstr); + } + + /** + * Build a `TRIM(BOTH remstr FROM str)` expression. + */ + public static function trimBoth(Exp $remstr, Exp $str): TrimExp + { + return new TrimExp($str, 'BOTH', $remstr); + } + + // String functions + + /** `CONCAT(...)` — concatenate the arguments. */ + public static function concat(Exp $expr, Exp ...$rest): FuncExp + { + return self::call('CONCAT', $expr, ...$rest); + } + + /** `CONCAT_WS(separator, ...)` — concatenate with a separator, skipping NULLs. */ + public static function concatWs(Exp $separator, Exp $expr, Exp ...$rest): FuncExp + { + return self::call('CONCAT_WS', $separator, $expr, ...$rest); + } + + /** `LOWER(str)`. */ + public static function lower(Exp $str): FuncExp + { + return self::call('LOWER', $str); + } + + /** `UPPER(str)`. */ + public static function upper(Exp $str): FuncExp + { + return self::call('UPPER', $str); + } + + /** `LENGTH(str)` — length in bytes. */ + public static function length(Exp $str): FuncExp + { + return self::call('LENGTH', $str); + } + + /** `CHAR_LENGTH(str)` — length in characters. */ + public static function charLength(Exp $str): FuncExp + { + return self::call('CHAR_LENGTH', $str); + } + + /** `SUBSTRING(str, pos)` or `SUBSTRING(str, pos, len)`. */ + public static function substring(Exp $str, Exp $pos, ?Exp $len = null): FuncExp + { + return $len === null ? self::call('SUBSTRING', $str, $pos) : self::call('SUBSTRING', $str, $pos, $len); + } + + /** `LEFT(str, len)`. */ + public static function left(Exp $str, Exp $len): FuncExp + { + return self::call('LEFT', $str, $len); + } + + /** `RIGHT(str, len)`. */ + public static function right(Exp $str, Exp $len): FuncExp + { + return self::call('RIGHT', $str, $len); + } + + /** `LTRIM(str)` — remove leading spaces. */ + public static function ltrim(Exp $str): FuncExp + { + return self::call('LTRIM', $str); + } + + /** `RTRIM(str)` — remove trailing spaces. */ + public static function rtrim(Exp $str): FuncExp + { + return self::call('RTRIM', $str); + } + + /** `LPAD(str, len, pad)`. */ + public static function lpad(Exp $str, Exp $len, Exp $pad): FuncExp + { + return self::call('LPAD', $str, $len, $pad); + } + + /** `RPAD(str, len, pad)`. */ + public static function rpad(Exp $str, Exp $len, Exp $pad): FuncExp + { + return self::call('RPAD', $str, $len, $pad); + } + + /** `REPLACE(str, from, to)` — replace all occurrences of a substring. */ + public static function replace(Exp $str, Exp $from, Exp $to): FuncExp + { + return self::call('REPLACE', $str, $from, $to); + } + + /** `REPEAT(str, count)`. */ + public static function repeat(Exp $str, Exp $count): FuncExp + { + return self::call('REPEAT', $str, $count); + } + + /** `REVERSE(str)`. */ + public static function reverse(Exp $str): FuncExp + { + return self::call('REVERSE', $str); + } + + /** `LOCATE(substr, str)` or `LOCATE(substr, str, pos)`. */ + public static function locate(Exp $substr, Exp $str, ?Exp $pos = null): FuncExp + { + return $pos === null ? self::call('LOCATE', $substr, $str) : self::call('LOCATE', $substr, $str, $pos); + } + + /** `INSTR(str, substr)`. */ + public static function instr(Exp $str, Exp $substr): FuncExp + { + return self::call('INSTR', $str, $substr); + } + + /** `SUBSTRING_INDEX(str, delim, count)`. */ + public static function substringIndex(Exp $str, Exp $delim, Exp $count): FuncExp + { + return self::call('SUBSTRING_INDEX', $str, $delim, $count); + } + + /** `FIELD(needle, ...)` — the 1-based index of needle in the argument list. */ + public static function field(Exp $needle, Exp ...$haystack): FuncExp + { + return self::call('FIELD', $needle, ...$haystack); + } + + /** `FIND_IN_SET(needle, set)` — index of needle in a comma-separated string. */ + public static function findInSet(Exp $needle, Exp $set): FuncExp + { + return self::call('FIND_IN_SET', $needle, $set); + } + + /** `FORMAT(num, decimals)` or `FORMAT(num, decimals, locale)`. */ + public static function format(Exp $num, Exp $decimals, ?Exp $locale = null): FuncExp + { + return $locale === null ? self::call('FORMAT', $num, $decimals) : self::call('FORMAT', $num, $decimals, $locale); + } + + /** `HEX(n_or_str)`. */ + public static function hex(Exp $expr): FuncExp + { + return self::call('HEX', $expr); + } + + /** `UNHEX(str)`. */ + public static function unhex(Exp $str): FuncExp + { + return self::call('UNHEX', $str); + } + + // Regular-expression functions (match via the `REGEXP` operator on expressions) + + /** `REGEXP_REPLACE(subject, pattern, replacement, ...)`. */ + public static function regexpReplace(Exp $subject, Exp $pattern, Exp $replacement, Exp ...$rest): FuncExp + { + return self::call('REGEXP_REPLACE', $subject, $pattern, $replacement, ...$rest); + } + + /** `REGEXP_INSTR(subject, pattern, ...)`. */ + public static function regexpInstr(Exp $subject, Exp $pattern, Exp ...$rest): FuncExp + { + return self::call('REGEXP_INSTR', $subject, $pattern, ...$rest); + } + + /** `REGEXP_SUBSTR(subject, pattern, ...)`. */ + public static function regexpSubstr(Exp $subject, Exp $pattern, Exp ...$rest): FuncExp + { + return self::call('REGEXP_SUBSTR', $subject, $pattern, ...$rest); + } + + // Numeric functions + + /** `ABS(n)`. */ + public static function abs(Exp $n): FuncExp + { + return self::call('ABS', $n); + } + + /** `CEIL(n)`. */ + public static function ceil(Exp $n): FuncExp + { + return self::call('CEIL', $n); + } + + /** `FLOOR(n)`. */ + public static function floor(Exp $n): FuncExp + { + return self::call('FLOOR', $n); + } + + /** `ROUND(n)` or `ROUND(n, decimals)`. */ + public static function round(Exp $n, ?Exp $decimals = null): FuncExp + { + return $decimals === null ? self::call('ROUND', $n) : self::call('ROUND', $n, $decimals); + } + + /** `TRUNCATE(n, decimals)`. */ + public static function truncate(Exp $n, Exp $decimals): FuncExp + { + return self::call('TRUNCATE', $n, $decimals); + } + + /** `MOD(a, b)`. */ + public static function mod(Exp $a, Exp $b): FuncExp + { + return self::call('MOD', $a, $b); + } + + /** `POWER(base, exponent)`. */ + public static function power(Exp $base, Exp $exponent): FuncExp + { + return self::call('POWER', $base, $exponent); + } + + /** `SQRT(n)`. */ + public static function sqrt(Exp $n): FuncExp + { + return self::call('SQRT', $n); + } + + /** `EXP(n)`. */ + public static function exp(Exp $n): FuncExp + { + return self::call('EXP', $n); + } + + /** `LN(n)` — natural logarithm. */ + public static function ln(Exp $n): FuncExp + { + return self::call('LN', $n); + } + + /** `LOG(n)` (natural) or `LOG(base, n)`. */ + public static function log(Exp $arg, Exp ...$rest): FuncExp + { + return self::call('LOG', $arg, ...$rest); + } + + /** `LOG2(n)`. */ + public static function log2(Exp $n): FuncExp + { + return self::call('LOG2', $n); + } + + /** `LOG10(n)`. */ + public static function log10(Exp $n): FuncExp + { + return self::call('LOG10', $n); + } + + /** `SIGN(n)`. */ + public static function sign(Exp $n): FuncExp + { + return self::call('SIGN', $n); + } + + /** `RAND()` or `RAND(seed)`. */ + public static function rand(Exp ...$seed): FuncExp + { + return self::call('RAND', ...$seed); + } + + /** `PI()`. */ + public static function pi(): FuncExp + { + return self::call('PI'); + } + + /** `SIN(n)`. */ + public static function sin(Exp $n): FuncExp + { + return self::call('SIN', $n); + } + + /** `COS(n)`. */ + public static function cos(Exp $n): FuncExp + { + return self::call('COS', $n); + } + + /** `TAN(n)`. */ + public static function tan(Exp $n): FuncExp + { + return self::call('TAN', $n); + } + + /** `COT(n)`. */ + public static function cot(Exp $n): FuncExp + { + return self::call('COT', $n); + } + + /** `ASIN(n)`. */ + public static function asin(Exp $n): FuncExp + { + return self::call('ASIN', $n); + } + + /** `ACOS(n)`. */ + public static function acos(Exp $n): FuncExp + { + return self::call('ACOS', $n); + } + + /** `ATAN(n)` or `ATAN(y, x)`. */ + public static function atan(Exp $arg, Exp ...$rest): FuncExp + { + return self::call('ATAN', $arg, ...$rest); + } + + /** `ATAN2(y, x)`. */ + public static function atan2(Exp $y, Exp $x): FuncExp + { + return self::call('ATAN2', $y, $x); + } + + /** `RADIANS(degrees)`. */ + public static function radians(Exp $degrees): FuncExp + { + return self::call('RADIANS', $degrees); + } + + /** `DEGREES(radians)`. */ + public static function degrees(Exp $radians): FuncExp + { + return self::call('DEGREES', $radians); + } + + // Date / time functions + + /** `NOW()` — the current date and time. */ + public static function now(): FuncExp + { + return self::call('NOW'); + } + + /** `CURDATE()` — the current date. */ + public static function curdate(): FuncExp + { + return self::call('CURDATE'); + } + + /** `CURTIME()` — the current time. */ + public static function curtime(): FuncExp + { + return self::call('CURTIME'); + } + + /** `CURRENT_TIMESTAMP()`. */ + public static function currentTimestamp(): FuncExp + { + return self::call('CURRENT_TIMESTAMP'); + } + + /** `UTC_TIMESTAMP()`. */ + public static function utcTimestamp(): FuncExp + { + return self::call('UTC_TIMESTAMP'); + } + + /** `DATE(expr)` — the date part of a datetime. */ + public static function date(Exp $expr): FuncExp + { + return self::call('DATE', $expr); + } + + /** `TIME(expr)` — the time part of a datetime. */ + public static function time(Exp $expr): FuncExp + { + return self::call('TIME', $expr); + } + + /** `YEAR(date)`. */ + public static function year(Exp $date): FuncExp + { + return self::call('YEAR', $date); + } + + /** `MONTH(date)`. */ + public static function month(Exp $date): FuncExp + { + return self::call('MONTH', $date); + } + + /** `DAY(date)`. */ + public static function day(Exp $date): FuncExp + { + return self::call('DAY', $date); + } + + /** `HOUR(time)`. */ + public static function hour(Exp $time): FuncExp + { + return self::call('HOUR', $time); + } + + /** `MINUTE(time)`. */ + public static function minute(Exp $time): FuncExp + { + return self::call('MINUTE', $time); + } + + /** `SECOND(time)`. */ + public static function second(Exp $time): FuncExp + { + return self::call('SECOND', $time); + } + + /** `QUARTER(date)`. */ + public static function quarter(Exp $date): FuncExp + { + return self::call('QUARTER', $date); + } + + /** `WEEK(date)` or `WEEK(date, mode)`. */ + public static function week(Exp $date, Exp ...$mode): FuncExp + { + return self::call('WEEK', $date, ...$mode); + } + + /** `DAYOFWEEK(date)`. */ + public static function dayOfWeek(Exp $date): FuncExp + { + return self::call('DAYOFWEEK', $date); + } + + /** `DAYOFYEAR(date)`. */ + public static function dayOfYear(Exp $date): FuncExp + { + return self::call('DAYOFYEAR', $date); + } + + /** `DAYNAME(date)`. */ + public static function dayName(Exp $date): FuncExp + { + return self::call('DAYNAME', $date); + } + + /** `MONTHNAME(date)`. */ + public static function monthName(Exp $date): FuncExp + { + return self::call('MONTHNAME', $date); + } + + /** `LAST_DAY(date)` — the last day of the month. */ + public static function lastDay(Exp $date): FuncExp + { + return self::call('LAST_DAY', $date); + } + + /** `DATEDIFF(a, b)` — the number of days between two dates. */ + public static function dateDiff(Exp $a, Exp $b): FuncExp + { + return self::call('DATEDIFF', $a, $b); + } + + /** `TIMESTAMPDIFF(unit, a, b)` — the difference in the given unit. */ + public static function timestampDiff(string $unit, Exp $a, Exp $b): FuncExp + { + return self::call('TIMESTAMPDIFF', new Keyword($unit), $a, $b); + } + + /** `TIMESTAMPADD(unit, interval, datetime)`. */ + public static function timestampAdd(string $unit, Exp $interval, Exp $datetime): FuncExp + { + return self::call('TIMESTAMPADD', new Keyword($unit), $interval, $datetime); + } + + /** `DATE_FORMAT(date, format)`. */ + public static function dateFormat(Exp $date, Exp $format): FuncExp + { + return self::call('DATE_FORMAT', $date, $format); + } + + /** `STR_TO_DATE(str, format)`. */ + public static function strToDate(Exp $str, Exp $format): FuncExp + { + return self::call('STR_TO_DATE', $str, $format); + } + + /** `UNIX_TIMESTAMP()` or `UNIX_TIMESTAMP(date)`. */ + public static function unixTimestamp(Exp ...$date): FuncExp + { + return self::call('UNIX_TIMESTAMP', ...$date); + } + + /** `FROM_UNIXTIME(ts)` or `FROM_UNIXTIME(ts, format)`. */ + public static function fromUnixtime(Exp $ts, Exp ...$format): FuncExp + { + return self::call('FROM_UNIXTIME', $ts, ...$format); + } + + /** `CONVERT_TZ(dt, fromTz, toTz)`. */ + public static function convertTz(Exp $dt, Exp $fromTz, Exp $toTz): FuncExp + { + return self::call('CONVERT_TZ', $dt, $fromTz, $toTz); + } + + // JSON functions + + /** `JSON_OBJECT(key, value, ...)`. */ + public static function jsonObject(Exp ...$keysValues): FuncExp + { + return self::call('JSON_OBJECT', ...$keysValues); + } + + /** `JSON_ARRAY(...)`. */ + public static function jsonArray(Exp ...$values): FuncExp + { + return self::call('JSON_ARRAY', ...$values); + } + + /** `JSON_QUOTE(str)`. */ + public static function jsonQuote(Exp $str): FuncExp + { + return self::call('JSON_QUOTE', $str); + } + + /** `JSON_UNQUOTE(jsonVal)`. */ + public static function jsonUnquote(Exp $jsonVal): FuncExp + { + return self::call('JSON_UNQUOTE', $jsonVal); + } + + /** `JSON_EXTRACT(doc, path, ...)`. */ + public static function jsonExtract(Exp $doc, Exp $path, Exp ...$rest): FuncExp + { + return self::call('JSON_EXTRACT', $doc, $path, ...$rest); + } + + /** `JSON_CONTAINS(target, candidate)` or `JSON_CONTAINS(target, candidate, path)`. */ + public static function jsonContains(Exp $target, Exp $candidate, ?Exp $path = null): FuncExp + { + return $path === null + ? self::call('JSON_CONTAINS', $target, $candidate) + : self::call('JSON_CONTAINS', $target, $candidate, $path); + } + + /** `JSON_CONTAINS_PATH(doc, oneOrAll, path, ...)`. */ + public static function jsonContainsPath(Exp $doc, Exp $oneOrAll, Exp $path, Exp ...$rest): FuncExp + { + return self::call('JSON_CONTAINS_PATH', $doc, $oneOrAll, $path, ...$rest); + } + + /** `JSON_KEYS(doc)` or `JSON_KEYS(doc, path)`. */ + public static function jsonKeys(Exp $doc, Exp ...$path): FuncExp + { + return self::call('JSON_KEYS', $doc, ...$path); + } + + /** `JSON_SEARCH(doc, oneOrAll, searchStr, ...)`. */ + public static function jsonSearch(Exp $doc, Exp $oneOrAll, Exp $searchStr, Exp ...$rest): FuncExp + { + return self::call('JSON_SEARCH', $doc, $oneOrAll, $searchStr, ...$rest); + } + + /** `JSON_VALUE(doc, path)`. */ + public static function jsonValue(Exp $doc, Exp $path): FuncExp + { + return self::call('JSON_VALUE', $doc, $path); + } + + /** `JSON_SET(doc, path, value, ...)`. */ + public static function jsonSet(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_SET', $doc, $path, $value, ...$rest); + } + + /** `JSON_INSERT(doc, path, value, ...)`. */ + public static function jsonInsert(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_INSERT', $doc, $path, $value, ...$rest); + } + + /** `JSON_REPLACE(doc, path, value, ...)`. */ + public static function jsonReplace(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_REPLACE', $doc, $path, $value, ...$rest); + } + + /** `JSON_REMOVE(doc, path, ...)`. */ + public static function jsonRemove(Exp $doc, Exp $path, Exp ...$rest): FuncExp + { + return self::call('JSON_REMOVE', $doc, $path, ...$rest); + } + + /** `JSON_ARRAY_APPEND(doc, path, value, ...)`. */ + public static function jsonArrayAppend(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_ARRAY_APPEND', $doc, $path, $value, ...$rest); + } + + /** `JSON_ARRAY_INSERT(doc, path, value, ...)`. */ + public static function jsonArrayInsert(Exp $doc, Exp $path, Exp $value, Exp ...$rest): FuncExp + { + return self::call('JSON_ARRAY_INSERT', $doc, $path, $value, ...$rest); + } + + /** `JSON_MERGE_PATCH(doc, doc, ...)` — RFC 7386 merge. */ + public static function jsonMergePatch(Exp $doc, Exp $other, Exp ...$rest): FuncExp + { + return self::call('JSON_MERGE_PATCH', $doc, $other, ...$rest); + } + + /** `JSON_MERGE_PRESERVE(doc, doc, ...)` — merge keeping duplicate keys. */ + public static function jsonMergePreserve(Exp $doc, Exp $other, Exp ...$rest): FuncExp + { + return self::call('JSON_MERGE_PRESERVE', $doc, $other, ...$rest); + } + + /** `JSON_TYPE(jsonVal)`. */ + public static function jsonType(Exp $jsonVal): FuncExp + { + return self::call('JSON_TYPE', $jsonVal); + } + + /** `JSON_DEPTH(doc)`. */ + public static function jsonDepth(Exp $doc): FuncExp + { + return self::call('JSON_DEPTH', $doc); + } + + /** `JSON_LENGTH(doc)` or `JSON_LENGTH(doc, path)`. */ + public static function jsonLength(Exp $doc, Exp ...$path): FuncExp + { + return self::call('JSON_LENGTH', $doc, ...$path); + } + + /** `JSON_VALID(val)`. */ + public static function jsonValid(Exp $val): FuncExp + { + return self::call('JSON_VALID', $val); + } + + // Miscellaneous functions + + /** `UUID()` — a version-1 UUID string. */ + public static function uuid(): FuncExp + { + return self::call('UUID'); + } + + /** `UUID_TO_BIN(uuid)` or `UUID_TO_BIN(uuid, swapFlag)`. */ + public static function uuidToBin(Exp $uuid, Exp ...$swapFlag): FuncExp + { + return self::call('UUID_TO_BIN', $uuid, ...$swapFlag); + } + + /** `BIN_TO_UUID(bin)` or `BIN_TO_UUID(bin, swapFlag)`. */ + public static function binToUuid(Exp $bin, Exp ...$swapFlag): FuncExp + { + return self::call('BIN_TO_UUID', $bin, ...$swapFlag); + } + + /** `IS_UUID(str)`. */ + public static function isUuid(Exp $str): FuncExp + { + return self::call('IS_UUID', $str); + } + + // Nonaggregate window functions — each requires an `OVER` clause; call + // {@see WindowFuncBuilder::over()} to add it. + + /** + * The `ROW_NUMBER()` window function: the sequential row number within the + * window partition. + */ + public static function rowNumber(): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('ROW_NUMBER', [])); + } + + /** + * The `RANK()` window function: the rank within the partition, with gaps after + * ties. + */ + public static function rank(): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('RANK', [])); + } + + /** + * The `DENSE_RANK()` window function: the rank within the partition, without + * gaps after ties. + */ + public static function denseRank(): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('DENSE_RANK', [])); + } + + /** + * The `PERCENT_RANK()` window function: the relative rank as a value in + * `[0, 1]`. + */ + public static function percentRank(): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('PERCENT_RANK', [])); + } + + /** + * The `CUME_DIST()` window function: the cumulative distribution of the current + * row within the partition. + */ + public static function cumeDist(): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('CUME_DIST', [])); + } + + /** + * The `NTILE(n)` window function: the bucket number when the partition is split + * into `n` buckets. + */ + public static function ntile(Exp $buckets): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('NTILE', [$buckets])); + } + + /** + * The `LAG(expr[, offset[, default]])` window function: the value `offset` rows + * before the current row within the partition. + */ + public static function lag(Exp $expr, ?Exp $offset = null, ?Exp $default = null): WindowFuncBuilder + { + // The default is only meaningful with an offset, so it is dropped without one. + $args = [$expr]; + if ($offset !== null) { + $args[] = $offset; + if ($default !== null) { + $args[] = $default; + } + } + + return new WindowFuncBuilder(new FuncExp('LAG', $args)); + } + + /** + * The `LEAD(expr[, offset[, default]])` window function: the value `offset` rows + * after the current row within the partition. + */ + public static function lead(Exp $expr, ?Exp $offset = null, ?Exp $default = null): WindowFuncBuilder + { + $args = [$expr]; + if ($offset !== null) { + $args[] = $offset; + if ($default !== null) { + $args[] = $default; + } + } + + return new WindowFuncBuilder(new FuncExp('LEAD', $args)); + } + + /** + * The `FIRST_VALUE(expr)` window function: the value of `expr` in the first row + * of the window frame. + */ + public static function firstValue(Exp $expr): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('FIRST_VALUE', [$expr])); + } + + /** + * The `LAST_VALUE(expr)` window function: the value of `expr` in the last row + * of the window frame. + */ + public static function lastValue(Exp $expr): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('LAST_VALUE', [$expr])); + } + + /** + * The `NTH_VALUE(expr, n)` window function: the value of `expr` in the `n`-th + * row of the window frame. + */ + public static function nthValue(Exp $expr, Exp $n): WindowFuncBuilder + { + return new WindowFuncBuilder(new FuncExp('NTH_VALUE', [$expr, $n])); + } + + private static function call(string $name, Exp ...$args): FuncExp + { + return new FuncExp($name, array_values($args)); + } + + private static function agg(string $name, Exp ...$args): AggBuilder + { + return new AggBuilder($name, array_values($args)); + } + + /** + * A function available only on the given dialect; validating against another + * target reports it as unsupported. + */ + private static function gated(Dialect $dialect, string $name, Exp ...$args): FuncExp + { + return new FuncExp($name, array_values($args), new Requirement($dialect)); + } + + /** + * An aggregate available only on the given dialect. + */ + private static function gatedAgg(Dialect $dialect, string $name, Exp ...$args): AggBuilder + { + return new AggBuilder($name, array_values($args), requires: new Requirement($dialect)); + } } diff --git a/src/MySQL/Q/SharedFunctions.php b/src/MySQL/Q/SharedFunctions.php deleted file mode 100644 index b98aeb5..0000000 --- a/src/MySQL/Q/SharedFunctions.php +++ /dev/null @@ -1,1037 +0,0 @@ - Date: Wed, 1 Jul 2026 13:17:12 +0200 Subject: [PATCH 28/40] wip: docs for the unified MySQL-family builder + target validation (mysql-mariadb.md rewrite, README/AGENTS, design-doc status header) Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 28 +++++-- README.md | 12 +-- docs/mysql-mariadb-design.md | 32 +++++--- docs/mysql-mariadb.md | 146 +++++++++++++++++++++++------------ 4 files changed, 146 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aefa43b..3b3606b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (PostgreSQL and MySQL; MariaDB planned); the query model and -rendering live in a per-dialect `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*). @@ -19,8 +29,9 @@ 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 supported dialect has its own top-level namespace (`src/PostgreSQL/`, -`src/MySQL/`, …) mirroring this layout; the paths above show the PostgreSQL one. +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 @@ -102,9 +113,12 @@ never built as a diff against another. - No references to the Go port; no "what PHP can't do that Go can" explanations; no TODO / "not yet supported" lists. -- No cross-dialect framing: describe what the code does in *this* dialect, never - relative to another ("PostgreSQL has X", "no FULL JOIN here", "unlike PG") — - same spirit as the no-Go rule above. +- 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"). diff --git a/README.md b/README.md index b860240..e420db0 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,13 @@ list to bind. See [Executing queries](#executing-queries) for how to run it. ## Dialects This README covers the PostgreSQL builder (`Flowpack\QueryObjectBuilder\PostgreSQL\Q`). -The package also ships **MySQL 8.4** and **MariaDB 11.x** facades -(`MySQL\Q` and `MariaDB\Q`) with the same fluent, immutable, type-safe design, -each modelling its own dialect (backtick identifiers, `?` placeholders, native -operators and functions). See **[MySQL & MariaDB](docs/mysql-mariadb.md)** for -usage, the per-engine differences, and the coverage/limitations. +The package also ships a **MySQL-family** facade (`MySQL\Q`) covering **MySQL 8.4** +and **MariaDB 11.x** in one builder, with the same fluent, immutable, type-safe +design (backtick identifiers, `?` placeholders, native operators and functions). +Where the two engines diverge you build the engine's own form, and an opt-in +`->withValidateTarget(Target::mysql()|mariaDb())` pass reports any construct the +target cannot express. See **[MySQL & MariaDB](docs/mysql-mariadb.md)** for usage, +the engine differences, and the coverage/limitations. ## Core concepts diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index 97a1184..1132479 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -9,17 +9,31 @@ exhaustive per-area findings live next to this file's source material (the **Version anchors:** MySQL **8.4 (LTS)**, MariaDB **11.x GA** (11.8 LTS). Every feature gated below the anchor is treated as *not available*. -> Status: **implemented** for both engines (per-variant subclasses; `src/MySQL` + -> `src/MariaDB`). All staged areas (§9) have landed and are green on +> Status: **implemented** as a single MySQL-family builder in `src/MySQL` (there is +> no `src/MariaDB`). All staged areas (§9) have landed and are green on > `vendor/bin/pest` + `vendor/bin/phpstan analyse` (level max): foundation, > expression layer, SELECT, window functions, DML, the curated function facade, and -> the MariaDB variant. Usage docs live in `mysql-mariadb.md`; the coverage ledger -> (§12) records every deferred/excluded production. The compile-time-safe variant -> split is realised via shared abstract base builders (`AbstractSelectBuilder`, -> `Abstract{Insert,Replace,Delete}Builder`) plus shared subbuilder/facade traits, -> with per-dialect concrete ladders. One deliberate deviation: the expression layer -> is single-sourced (see §6 / §12 — the JSON `->`/`->>` operators stay on the shared -> base as MySQL-native; MariaDB uses the function form). +> the engine divergences. Usage docs live in `mysql-mariadb.md`; the structural +> engine differences are catalogued in `mysql-mariadb-differences.md`; the coverage +> ledger (§12) records every deferred/excluded production. +> +> **Architecture note — supersedes the per-variant framing throughout the body +> (§2, §4, §8, §9, §11).** Those sections are the original blueprint and still read +> as two facades (`MySQL\Q` + `MariaDB\Q`), abstract base builders, per-class +> `writeSql` render-branches and `Q::inserted()`; none of that is how it landed. +> The two engines were collapsed into one builder rather than the originally-planned +> per-variant subclasses. Rendering is fully determined by construction (never a dialect flag), +> so engine-divergent SQL comes from *different construction* — `forShare()` vs +> `lockInShareMode()`, `->as('new')` + `Q::n('new.col')` vs `Q::values('col')`, +> the `->jsonExtract()` operator vs `Q\Func::jsonExtract()`. Each divergent +> construct calls `$sb->requireDialect(...)` / `requireAnyDialect(...)` while +> rendering; validation is opt-in via +> `Q::build($q)->withValidateTarget(Target::mysql()|mariaDb($version))`, which +> reports (never silently changes) the constructs the target cannot express. +> `Requirement` carries an optional `[gteVersion, ltVersion)` window for +> version-gated features (e.g. leading `WITH` on UPDATE/DELETE = MySQL, or MariaDB +> 12.3+). The §8 split matrix still lists *which* engine each construct belongs to; +> only its "handling" column (render-branch / per-class writeSql) is superseded. --- diff --git a/docs/mysql-mariadb.md b/docs/mysql-mariadb.md index 37fab59..deaea9b 100644 --- a/docs/mysql-mariadb.md +++ b/docs/mysql-mariadb.md @@ -1,19 +1,20 @@ -# MySQL & MariaDB dialects - -Alongside the PostgreSQL builder, the package ships two more dialect facades with -the same fluent, immutable, fully-typed design: - -- `Flowpack\QueryObjectBuilder\MySQL\Q` — targets **MySQL 8.4 (LTS)** -- `Flowpack\QueryObjectBuilder\MariaDB\Q` — targets **MariaDB 11.x** - -Both render the MySQL-family SQL conventions: identifiers are backtick-quoted +# MySQL & MariaDB dialect + +Alongside the PostgreSQL builder, the package ships one **MySQL-family** facade — +`Flowpack\QueryObjectBuilder\MySQL\Q` — covering both **MySQL 8.4 (LTS)** and +**MariaDB 11.x**. The two engines share ~95% of their grammar and all of their +rendering conventions, so a single builder models both; where they genuinely +diverge you construct the engine's own form (see +[Dialect differences](#dialect-differences)), and an opt-in +[target validation](#validating-against-a-target) pass reports any construct the +engine you are targeting cannot express. + +It renders the MySQL-family SQL conventions: identifiers are backtick-quoted (`` `order` ``), parameters are positional `?` placeholders, and string literals -escape both the backslash and the quote. Pick the facade for your engine; the two -expose the same surface except where the engines genuinely differ (see -[Dialect differences](#dialect-differences)). +escape both the backslash and the quote. ```php -use Flowpack\QueryObjectBuilder\MySQL\Q; // or MariaDB\Q +use Flowpack\QueryObjectBuilder\MySQL\Q; $q = Q::select(Q::n('id'), Q::n('email')) ->from(Q::n('orders')) @@ -38,14 +39,16 @@ or any layer that speaks MySQL/MariaDB placeholders. `deleteFrom`, `with`, `withRecursive`), identifiers (`n`), literals (`string`, `int`, `float`, `bool`, `null`, `default`), parameters (`arg`, `bind`), composition (`and`, `or`, `not`, `exists`, `any`, `all`, `case`, `coalesce`, - `nullif`, `greatest`, `least`, `func`, `cast`, `convert`, `interval`), and the - window frame bounds (`currentRow`, `unboundedPreceding`, `unboundedFollowing`, - `preceding`, `following`). + `nullif`, `greatest`, `least`, `func`, `cast`, `convert`, `interval`), the + upsert value reference (`values`), and the window frame bounds (`currentRow`, + `unboundedPreceding`, `unboundedFollowing`, `preceding`, `following`). - **`Q\Func`** — SQL functions: aggregates (`count`, `sum`, `avg`, `groupConcat`, `jsonArrayAgg`, `bitOr`, `stddevPop`, …), string / numeric / date-time / JSON / misc scalars, the window functions (`rowNumber`, `rank`, `lag`, `lead`, - `firstValue`, …), and the special shapes (`GROUP_CONCAT`, `EXTRACT`, `TRIM`). - It is named `Func` (not `Fn`) because `fn` is a reserved keyword in PHP. + `firstValue`, …), the special shapes (`GROUP_CONCAT`, `EXTRACT`, `TRIM`), and + the engine-specific functions (`jsonPretty`, `regexpLike`, … for MySQL; + `jsonDetailed`, `median`, … for MariaDB). It is named `Func` (not `Fn`) because + `fn` is a reserved keyword in PHP. Operators are chainable on the expression objects that `Q::n()`, literals and functions return: `->eq()`, `->neq()`, `->lt()`, `->like()`, `->regexp()`, @@ -54,6 +57,29 @@ functions return: `->eq()`, `->neq()`, `->lt()`, `->like()`, `->regexp()`, `JSON_CONTAINS` — are built through the facade (`Q::func` / `Q::cast` / `Q\Func`), not as chained operators. +## Validating against a target + +Rendering is fully determined by how you build the query — it never branches on a +dialect flag. Building without a target renders exactly what you constructed and +never fails on dialect grounds. To check a query against a specific engine (and, +optionally, version), opt in with `withValidateTarget()`: + +```php +use Flowpack\QueryObjectBuilder\MySQL\Builder\Target; + +$q = Q::select(Q::n('id'))->from(Q::n('t'))->forShare(); // FOR SHARE is MySQL-only + +Q::build($q)->withValidateTarget(Target::mysql())->toSql(); // ok +Q::build($q)->withValidateTarget(Target::mariaDb())->toSql(); // throws QueryBuilderException: +// "FOR SHARE requires MySQL, but the query is validated against MariaDB" +``` + +`Target::mysql($version)` / `Target::mariaDb($version)` carry an optional version. +Version-gated features are checked when a version is supplied — e.g. a leading +`WITH` on `UPDATE`/`DELETE` is valid on MySQL and on MariaDB 12.3+, so it passes +against `Target::mariaDb('12.3')` but fails against `Target::mariaDb('11.4')`. A +target with no version only checks the dialect. + ## Examples ### SELECT, joins, grouping @@ -84,18 +110,22 @@ SELECT * FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id ### Locking -`forUpdate()` is shared; the shared lock is spelled differently per engine: +`forUpdate()` (optionally `nowait()` / `skipLocked()`) is shared. The shared lock +has a different spelling per engine, so it is two methods: ```php -// MySQL\Q +// MySQL: FOR SHARE (+ of() / nowait() / skipLocked()) Q::select(Q::n('id'))->from(Q::n('t'))->forShare()->of('t')->nowait(); // SELECT id FROM t FOR SHARE OF t NOWAIT -// MariaDB\Q -Q::select(Q::n('id'))->from(Q::n('t'))->forShare(); +// MariaDB: LOCK IN SHARE MODE +Q::select(Q::n('id'))->from(Q::n('t'))->lockInShareMode(); // SELECT id FROM t LOCK IN SHARE MODE ``` +`of()` is MySQL-only even on `FOR UPDATE`; validating a query that uses it against +`Target::mariaDb()` reports it. + ### Window functions ```php @@ -118,20 +148,26 @@ FROM empsalary WINDOW w AS (ORDER BY salary) ### INSERT & upsert +The two engines reference the proposed row differently, so you build each one its +own way: + ```php -// MySQL: proposed row via the `AS new` alias → Q::inserted('col') is `new.col` +// MySQL: alias the proposed row with AS new, then reference it as new.col Q::insertInto(Q::n('t')) - ->columnNames('id', 'hits')->values(Q::arg(1), Q::arg(10)) - ->onDuplicateKeyUpdate()->set('hits', Q::inserted('hits')); + ->columnNames('id', 'hits')->values(Q::arg(1), Q::arg(10))->as('new') + ->onDuplicateKeyUpdate()->set('hits', Q::n('new.hits')); // INSERT INTO t (id,hits) VALUES (?,?) AS new ON DUPLICATE KEY UPDATE hits = new.hits -// MariaDB: proposed row via VALUES(col) → Q::inserted('col') is `VALUES(col)` +// Portable: the VALUES(col) function works on both engines Q::insertInto(Q::n('t')) ->columnNames('id', 'hits')->values(Q::arg(1), Q::arg(10)) - ->onDuplicateKeyUpdate()->set('hits', Q::inserted('hits')); + ->onDuplicateKeyUpdate()->set('hits', Q::values('hits')); // INSERT INTO t (id,hits) VALUES (?,?) ON DUPLICATE KEY UPDATE hits = VALUES(hits) ``` +`->as('new')` (the row alias) is MySQL-only and is reported when validated against +MariaDB. `Q::values('col')` renders `VALUES(col)`, which both engines accept. + `Q::insertInto(...)->ignore()` renders `INSERT IGNORE`; `Q::replaceInto(...)` builds a `REPLACE` statement with the same value/column/query surface. @@ -153,39 +189,42 @@ Q::deleteFrom(Q::n('t1')) `ORDER BY` and `LIMIT` are available on single-table UPDATE/DELETE only; using them with a join raises a `QueryBuilderException` when the query is built. -### RETURNING (MariaDB only) +### RETURNING (MariaDB) ```php -use Flowpack\QueryObjectBuilder\MariaDB\Q; - Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::arg(1)) ->returning(Q::n('id'))->as('new_id'); // INSERT INTO t (a) VALUES (?) RETURNING id AS new_id ``` -`returning()` is available on MariaDB INSERT, REPLACE and (single-table) DELETE. -It does not exist on the MySQL facade — calling it there is a compile-time error. +`returning()` is available on INSERT, REPLACE and (single-table) DELETE. It is +MariaDB-only — validating a query that uses it against `Target::mysql()` reports +it. ## Dialect differences -| Feature | `MySQL\Q` | `MariaDB\Q` | +Each row shows how to build the same intent for each engine; the differences are +reported by [target validation](#validating-against-a-target). + +| Feature | MySQL | MariaDB | |---|---|---| -| `LATERAL` from/join (`fromLateral`, `joinLateral`, …) | ✓ | — (not in MariaDB) | -| Shared row lock | `forShare()` → `FOR SHARE` (+ `of()` / `nowait()` / `skipLocked()`) | `forShare()` → `LOCK IN SHARE MODE` | -| `RETURNING` on INSERT / REPLACE / DELETE | — | ✓ (single-table) | -| Leading `WITH` on UPDATE / DELETE | ✓ | — (MariaDB 12.3+; off on the 11.x anchor) | -| Upsert proposed-row ref (`Q::inserted('c')`) | `new.c` (with `AS new`) | `VALUES(c)` | -| JSON path operators `->` / `->>` | ✓ (`->jsonExtract()` / `->jsonExtractText()`) | use `Q\Func::jsonExtract()` / `Q\Func::jsonUnquote()` | -| Dialect-only functions | `regexpLike`, `grouping`, `anyValue`, `jsonPretty`, `jsonSchemaValid*`, `jsonStorage*`, `randomBytes` | `jsonQuery`, `jsonDetailed`, `jsonExists`, `median`, `toChar`, `addMonths`, `monthsBetween`, `chr`, `oct` | +| `LATERAL` from/join (`fromLateral`, `joinLateral`, …) | ✓ | — (no equivalent) | +| Shared row lock | `forShare()` → `FOR SHARE` (+ `of()` / `nowait()` / `skipLocked()`) | `lockInShareMode()` → `LOCK IN SHARE MODE` | +| `RETURNING` on INSERT / REPLACE / DELETE | — | `returning()` (single-table) | +| Leading `WITH` on UPDATE / DELETE | ✓ | MariaDB 12.3+ (off on the 11.x anchor) | +| Upsert proposed-row ref | `->as('new')` + `Q::n('new.col')` | `Q::values('col')` (also works on MySQL) | +| JSON path | `->jsonExtract()` / `->jsonExtractText()` (`->` / `->>`) | `Q\Func::jsonExtract()` / `Q\Func::jsonUnquote()` (also work on MySQL) | +| Pretty-print JSON | `Q\Func::jsonPretty()` | `Q\Func::jsonDetailed()` | +| Dialect-only functions | `regexpLike`, `grouping`, `anyValue`, `jsonSchemaValid*`, `jsonStorage*`, `randomBytes` | `jsonQuery`, `jsonExists`, `median`, `toChar`, `addMonths`, `monthsBetween`, `chr`, `oct` | Everything else — the SELECT clause set, joins, `WITH ROLLUP`, `HAVING`, `ORDER BY`, `LIMIT`/`OFFSET`, `UNION`/`INTERSECT`/`EXCEPT`, CTEs, window functions and frames, `ON DUPLICATE KEY UPDATE`, multi-table UPDATE/DELETE, and the curated -function set — is identical across the two facades. +function set — is identical for both engines. ## Limitations -The dialects target a curated, query-shaping surface; the following are +The dialect targets a curated, query-shaping surface; the following are deliberately out of scope. Anything omitted remains reachable through the raw `Q::func(name, ...)` escape hatch. @@ -204,16 +243,21 @@ deliberately out of scope. Anything omitted remains reachable through the raw - **`UPDATE ... RETURNING`** exists on neither engine within the version anchor. The full per-production ledger lives in -[`mysql-mariadb-design.md` §12](mysql-mariadb-design.md). +[`mysql-mariadb-design.md` §12](mysql-mariadb-design.md); the structural engine +differences are catalogued in +[`mysql-mariadb-differences.md`](mysql-mariadb-differences.md). ## Relationship to the PostgreSQL builder -The MySQL/MariaDB facades mirror the PostgreSQL builder's structure (immutability, -type-state transitions, the `Q` / `Q\Func` split) but model each engine's own SQL -rather than PostgreSQL's. Notably: PostgreSQL operators that MySQL/MariaDB spell as -functions are dropped from the expression surface and reached through `Q\Func` -(`::`→`CAST`/`CONVERT`, `||`→`CONCAT`, `^`→`POW`, `@>`→`JSON_CONTAINS`); PG-only -clauses (`DISTINCT ON`, `FULL JOIN`, `GROUPING SETS`/`CUBE`, `NULLS FIRST/LAST`, -materialized CTEs, `SEARCH`) are absent; and `ON CONFLICT` becomes -`onDuplicateKeyUpdate()`, `RETURNING` becomes MariaDB-only, and PG's +The MySQL-family facade mirrors the PostgreSQL builder's structure (immutability, +type-state transitions, the `Q` / `Q\Func` split) but models the MySQL family's +own SQL rather than PostgreSQL's. Notably: PostgreSQL operators that MySQL/MariaDB +spell as functions are dropped from the expression surface and reached through +`Q\Func` (`::`→`CAST`/`CONVERT`, `||`→`CONCAT`, `^`→`POW`, `@>`→`JSON_CONTAINS`); +PG-only clauses (`DISTINCT ON`, `FULL JOIN`, `GROUPING SETS`/`CUBE`, +`NULLS FIRST/LAST`, materialized CTEs, `SEARCH`) are absent; and `ON CONFLICT` +becomes `onDuplicateKeyUpdate()`, `RETURNING` becomes MariaDB-only, and PG's `UPDATE ... FROM` / `DELETE ... USING` become the multi-table `JOIN` forms. + +It is a separate builder from PostgreSQL: `PostgreSQL\Q` and `MySQL\Q` do not +share types, and a query built with one is rendered by its own `QueryBuilder`. From d20864f0f0624d3ad80970013715cc0a215f642b Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 13:47:42 +0200 Subject: [PATCH 29/40] =?UTF-8?q?wip:=20fold=20MySQL-only=20function=20cov?= =?UTF-8?q?erage=20into=20DialectDifferencesTest=20=C2=A7C=20(both=20engin?= =?UTF-8?q?es,=20render=20+=20validation);=20drop=20pre-collapse=20Functio?= =?UTF-8?q?nsMysqlOnlyTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/MySQL/Q/DialectDifferencesTest.php | 53 +++++++++++++++++++----- tests/MySQL/Q/FunctionsMysqlOnlyTest.php | 33 --------------- 2 files changed, 43 insertions(+), 43 deletions(-) delete mode 100644 tests/MySQL/Q/FunctionsMysqlOnlyTest.php diff --git a/tests/MySQL/Q/DialectDifferencesTest.php b/tests/MySQL/Q/DialectDifferencesTest.php index c2cfa77..a7f7243 100644 --- a/tests/MySQL/Q/DialectDifferencesTest.php +++ b/tests/MySQL/Q/DialectDifferencesTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; use Flowpack\QueryObjectBuilder\MySQL\Builder\Target; use Flowpack\QueryObjectBuilder\MySQL\Q; @@ -200,15 +201,47 @@ }); describe('C dialect-only functions', function () { - it('reports MySQL-only functions against MariaDB', function () { - $q = Q\Func::regexpLike(Q::n('s'), Q::string('^a')); - expect($q)->toRenderSql("REGEXP_LIKE(s, '^a')", null, Target::mysql()); - expect($q)->toFailValidationFor(Target::mariaDb(), 'REGEXP_LIKE requires MySQL'); - }); - - it('reports MariaDB-only functions against MySQL', function () { - $q = Q\Func::jsonQuery(Q::n('doc'), Q::string('$.a')); - expect($q)->toRenderSql("JSON_QUERY(doc, '$.a')", null, Target::mariaDb()); - expect($q)->toFailValidationFor(Target::mysql(), 'JSON_QUERY requires MariaDB'); + // The pretty-print pair (jsonPretty / jsonDetailed) is covered in A4 and MEDIAN + // in B4; these datasets cover the rest of each engine's function set. Each + // renders and validates against its own engine, and is reported against the other. + + it('renders and validates MySQL-only functions', function (Exp $exp, string $sql, string $feature) { + expect($exp)->toRenderSql($sql, null, Target::mysql()); + expect($exp)->toFailValidationFor(Target::mariaDb(), $feature . ' requires MySQL'); + })->with([ + 'regexpLike' => [fn () => Q\Func::regexpLike(Q::n('a'), Q::string('^x')), "REGEXP_LIKE(a, '^x')", 'REGEXP_LIKE'], + 'regexpLike matchType' => [fn () => Q\Func::regexpLike(Q::n('a'), Q::string('^x'), Q::string('i')), "REGEXP_LIKE(a, '^x', 'i')", 'REGEXP_LIKE'], + 'grouping' => [fn () => Q\Func::grouping(Q::n('a'), Q::n('b')), 'GROUPING(a, b)', 'GROUPING'], + 'anyValue' => [fn () => Q\Func::anyValue(Q::n('name')), 'ANY_VALUE(name)', 'ANY_VALUE'], + 'jsonSchemaValid' => [fn () => Q\Func::jsonSchemaValid(Q::n('schema'), Q::n('doc')), 'JSON_SCHEMA_VALID(`schema`, doc)', 'JSON_SCHEMA_VALID'], + 'jsonSchemaValidationReport' => [fn () => Q\Func::jsonSchemaValidationReport(Q::n('s'), Q::n('doc')), 'JSON_SCHEMA_VALIDATION_REPORT(s, doc)', 'JSON_SCHEMA_VALIDATION_REPORT'], + 'jsonStorageSize' => [fn () => Q\Func::jsonStorageSize(Q::n('doc')), 'JSON_STORAGE_SIZE(doc)', 'JSON_STORAGE_SIZE'], + 'jsonStorageFree' => [fn () => Q\Func::jsonStorageFree(Q::n('doc')), 'JSON_STORAGE_FREE(doc)', 'JSON_STORAGE_FREE'], + 'randomBytes' => [fn () => Q\Func::randomBytes(Q::int(16)), 'RANDOM_BYTES(16)', 'RANDOM_BYTES'], + ]); + + it('renders and validates MariaDB-only functions', function (Exp $exp, string $sql, string $feature) { + expect($exp)->toRenderSql($sql, null, Target::mariaDb()); + expect($exp)->toFailValidationFor(Target::mysql(), $feature . ' requires MariaDB'); + })->with([ + 'jsonQuery' => [fn () => Q\Func::jsonQuery(Q::n('doc'), Q::string('$.a')), "JSON_QUERY(doc, '$.a')", 'JSON_QUERY'], + 'jsonExists' => [fn () => Q\Func::jsonExists(Q::n('doc'), Q::string('$.a')), "JSON_EXISTS(doc, '$.a')", 'JSON_EXISTS'], + 'toChar' => [fn () => Q\Func::toChar(Q::n('d'), Q::string('YYYY-MM-DD')), "TO_CHAR(d, 'YYYY-MM-DD')", 'TO_CHAR'], + 'addMonths' => [fn () => Q\Func::addMonths(Q::n('d'), Q::int(3)), 'ADD_MONTHS(d, 3)', 'ADD_MONTHS'], + 'monthsBetween' => [fn () => Q\Func::monthsBetween(Q::n('a'), Q::n('b')), 'MONTHS_BETWEEN(a, b)', 'MONTHS_BETWEEN'], + 'chr' => [fn () => Q\Func::chr(Q::int(65)), 'CHR(65)', 'CHR'], + 'oct' => [fn () => Q\Func::oct(Q::int(8)), 'OCT(8)', 'OCT'], + ]); + + it('uses a MySQL-only GROUPING with WITH ROLLUP', function () { + expect( + Q::select(Q::n('country'), Q\Func::sum(Q::n('amount')), Q\Func::grouping(Q::n('country'))) + ->from(Q::n('sales')) + ->groupBy(Q::n('country'))->withRollup(), + )->toRenderSql( + 'SELECT country, SUM(amount), GROUPING(country) FROM sales GROUP BY country WITH ROLLUP', + null, + Target::mysql(), + ); }); }); diff --git a/tests/MySQL/Q/FunctionsMysqlOnlyTest.php b/tests/MySQL/Q/FunctionsMysqlOnlyTest.php deleted file mode 100644 index 1e3a0cf..0000000 --- a/tests/MySQL/Q/FunctionsMysqlOnlyTest.php +++ /dev/null @@ -1,33 +0,0 @@ -toRenderSql($sql); - })->with([ - 'regexpLike' => [fn () => Q\Func::regexpLike(Q::n('a'), Q::string('^x')), "REGEXP_LIKE(a, '^x')"], - 'regexpLike matchType' => [fn () => Q\Func::regexpLike(Q::n('a'), Q::string('^x'), Q::string('i')), "REGEXP_LIKE(a, '^x', 'i')"], - 'grouping' => [fn () => Q\Func::grouping(Q::n('a'), Q::n('b')), 'GROUPING(a, b)'], - 'anyValue' => [fn () => Q\Func::anyValue(Q::n('name')), 'ANY_VALUE(name)'], - 'jsonSchemaValid' => [fn () => Q\Func::jsonSchemaValid(Q::n('schema'), Q::n('doc')), 'JSON_SCHEMA_VALID(`schema`, doc)'], - 'jsonSchemaValidationReport' => [fn () => Q\Func::jsonSchemaValidationReport(Q::n('s'), Q::n('doc')), 'JSON_SCHEMA_VALIDATION_REPORT(s, doc)'], - 'jsonStorageSize' => [fn () => Q\Func::jsonStorageSize(Q::n('doc')), 'JSON_STORAGE_SIZE(doc)'], - 'jsonStorageFree' => [fn () => Q\Func::jsonStorageFree(Q::n('doc')), 'JSON_STORAGE_FREE(doc)'], - 'jsonPretty' => [fn () => Q\Func::jsonPretty(Q::n('doc')), 'JSON_PRETTY(doc)'], - 'randomBytes' => [fn () => Q\Func::randomBytes(Q::int(16)), 'RANDOM_BYTES(16)'], - ]); - - it('uses GROUPING with WITH ROLLUP', function () { - expect( - Q::select(Q::n('country'), Q\Func::sum(Q::n('amount')), Q\Func::grouping(Q::n('country'))) - ->from(Q::n('sales')) - ->groupBy(Q::n('country'))->withRollup(), - )->toRenderSql( - 'SELECT country, SUM(amount), GROUPING(country) FROM sales GROUP BY country WITH ROLLUP', - ); - }); -}); From 168f492ddfd2d6115436079fb2265a8e5b636b0d Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 13:50:35 +0200 Subject: [PATCH 30/40] wip: add Requirement::mysql()/mariaDb() named constructors; use them at literal call sites Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/DeleteBuilder.php | 2 +- src/MySQL/Builder/ExpBase.php | 4 ++-- src/MySQL/Builder/Requirement.php | 16 ++++++++++++++++ src/MySQL/Builder/SelectBuilder.php | 4 ++-- src/MySQL/Builder/UpdateBuilder.php | 2 +- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/MySQL/Builder/DeleteBuilder.php b/src/MySQL/Builder/DeleteBuilder.php index b75ec04..55faca1 100644 --- a/src/MySQL/Builder/DeleteBuilder.php +++ b/src/MySQL/Builder/DeleteBuilder.php @@ -184,7 +184,7 @@ public function innerWriteSql(SqlBuilder $sb): void } if ($this->withQueries !== []) { - $sb->requireAnyDialect('WITH before DELETE', new Requirement(Dialect::Mysql), new Requirement(Dialect::MariaDb, gteVersion: '12.3')); + $sb->requireAnyDialect('WITH before DELETE', Requirement::mysql(), Requirement::mariaDb(gteVersion: '12.3')); $this->writeWithQueries($sb, $this->withQueries); } diff --git a/src/MySQL/Builder/ExpBase.php b/src/MySQL/Builder/ExpBase.php index 69183e0..bd29cd4 100644 --- a/src/MySQL/Builder/ExpBase.php +++ b/src/MySQL/Builder/ExpBase.php @@ -97,7 +97,7 @@ public function mod(Exp $rgt): OpExp */ public function jsonExtract(Exp $rgt): OpExp { - return new OpExp($this, '->', $rgt, requires: new Requirement(Dialect::Mysql)); + return new OpExp($this, '->', $rgt, requires: Requirement::mysql()); } /** @@ -105,7 +105,7 @@ public function jsonExtract(Exp $rgt): OpExp */ public function jsonExtractText(Exp $rgt): OpExp { - return new OpExp($this, '->>', $rgt, requires: new Requirement(Dialect::Mysql)); + return new OpExp($this, '->>', $rgt, requires: Requirement::mysql()); } // Pattern matching diff --git a/src/MySQL/Builder/Requirement.php b/src/MySQL/Builder/Requirement.php index aba9c53..23a1837 100644 --- a/src/MySQL/Builder/Requirement.php +++ b/src/MySQL/Builder/Requirement.php @@ -23,6 +23,22 @@ public function __construct( ) { } + /** + * A requirement on MySQL, optionally within a `[gteVersion, ltVersion)` window. + */ + public static function mysql(?string $gteVersion = null, ?string $ltVersion = null): self + { + return new self(Dialect::Mysql, $gteVersion, $ltVersion); + } + + /** + * A requirement on MariaDB, optionally within a `[gteVersion, ltVersion)` window. + */ + public static function mariaDb(?string $gteVersion = null, ?string $ltVersion = null): self + { + return new self(Dialect::MariaDb, $gteVersion, $ltVersion); + } + /** * Whether the given target is this dialect and — if the target carries a version * — falls within the version window. A target with no version is version-agnostic diff --git a/src/MySQL/Builder/SelectBuilder.php b/src/MySQL/Builder/SelectBuilder.php index 12e8471..c2ddc00 100644 --- a/src/MySQL/Builder/SelectBuilder.php +++ b/src/MySQL/Builder/SelectBuilder.php @@ -194,7 +194,7 @@ public function forUpdate(): ForSelectBuilder */ public function forShare(): ForSelectBuilder { - return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR SHARE', requires: new Requirement(Dialect::Mysql))); + return $this->withLocking(ForSelectBuilder::class, new LockingClause('FOR SHARE', requires: Requirement::mysql())); } /** @@ -202,7 +202,7 @@ public function forShare(): ForSelectBuilder */ public function lockInShareMode(): SelectBuilder { - return $this->withLocking(SelectBuilder::class, new LockingClause('LOCK IN SHARE MODE', requires: new Requirement(Dialect::MariaDb))); + return $this->withLocking(SelectBuilder::class, new LockingClause('LOCK IN SHARE MODE', requires: Requirement::mariaDb())); } /** diff --git a/src/MySQL/Builder/UpdateBuilder.php b/src/MySQL/Builder/UpdateBuilder.php index 9c8be0a..16f40e5 100644 --- a/src/MySQL/Builder/UpdateBuilder.php +++ b/src/MySQL/Builder/UpdateBuilder.php @@ -191,7 +191,7 @@ public function innerWriteSql(SqlBuilder $sb): void } if ($this->withQueries !== []) { - $sb->requireAnyDialect('WITH before UPDATE', new Requirement(Dialect::Mysql), new Requirement(Dialect::MariaDb, gteVersion: '12.3')); + $sb->requireAnyDialect('WITH before UPDATE', Requirement::mysql(), Requirement::mariaDb(gteVersion: '12.3')); $this->writeWithQueries($sb, $this->withQueries); } From 9bca3d1f4a16ae7aa82cd0f728d162e283c5603d Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 14:38:33 +0200 Subject: [PATCH 31/40] =?UTF-8?q?wip:=20bitwise=20operators=20(&=20|=20^?= =?UTF-8?q?=20<<=20>>=20on=20ExpBase,=20~=20via=20Q::bitNot)=20with=20prec?= =?UTF-8?q?edence=20rescale;=20AGENTS=20note=20=E2=80=94=20Q\Func=20return?= =?UTF-8?q?s=20Exp,=20FROM-only=20constructs=20live=20on=20Q?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 8 ++++++ src/MySQL/Builder/ExpBase.php | 43 +++++++++++++++++++++++++++++++ src/MySQL/Builder/Precedence.php | 29 ++++++++++++++------- src/MySQL/Q.php | 11 +++++++- tests/MySQL/Q/ExpressionsTest.php | 18 +++++++++++++ 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3b3606b..46a813b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,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`. diff --git a/src/MySQL/Builder/ExpBase.php b/src/MySQL/Builder/ExpBase.php index bd29cd4..cd3cbbf 100644 --- a/src/MySQL/Builder/ExpBase.php +++ b/src/MySQL/Builder/ExpBase.php @@ -89,6 +89,49 @@ public function mod(Exp $rgt): OpExp return $this->op('%', $rgt); } + // Bitwise operators — the infix operator form. (The like-named aggregates + // `BIT_AND` / `BIT_OR` / `BIT_XOR` are built via {@see Q\Func}.) + + /** + * Bitwise AND (`a & b`). + */ + public function bitAnd(Exp $rgt): OpExp + { + return $this->op('&', $rgt); + } + + /** + * Bitwise OR (`a | b`). + */ + public function bitOr(Exp $rgt): OpExp + { + return $this->op('|', $rgt); + } + + /** + * Bitwise XOR (`a ^ b`). + */ + public function bitXor(Exp $rgt): OpExp + { + return $this->op('^', $rgt); + } + + /** + * Left bit shift (`a << b`). + */ + public function shiftLeft(Exp $rgt): OpExp + { + return $this->op('<<', $rgt); + } + + /** + * Right bit shift (`a >> b`). + */ + public function shiftRight(Exp $rgt): OpExp + { + return $this->op('>>', $rgt); + } + // JSON path operators /** diff --git a/src/MySQL/Builder/Precedence.php b/src/MySQL/Builder/Precedence.php index 2a65632..0a7e7e5 100644 --- a/src/MySQL/Builder/Precedence.php +++ b/src/MySQL/Builder/Precedence.php @@ -16,19 +16,28 @@ */ final class Precedence { + /** The precedence of the unary prefixes `-` (negation) and `~` (bitwise NOT). */ + public const UNARY = 12; + /** @var array */ private const MAP = [ // JSON path operators bind tightest among the operators we render. - '->' => 6, - '->>' => 6, - '*' => 4, - '/' => 4, - '%' => 4, - 'DIV' => 4, - 'MOD' => 4, - '+' => 3, - '-' => 3, - // comparison row (default 0): = <=> < > <= >= <> IS LIKE REGEXP IN ... + '->' => 13, + '->>' => 13, + // (unary - and ~ sit at UNARY = 12, between the JSON operators and ^) + '^' => 11, + '*' => 10, + '/' => 10, + '%' => 10, + 'DIV' => 10, + 'MOD' => 10, + '+' => 9, + '-' => 9, + '<<' => 8, + '>>' => 8, + '&' => 7, + '|' => 6, + // comparison row (default 0): = <=> < > <= >= <> IS LIKE REGEXP IN MEMBER OF ... 'BETWEEN' => -1, 'NOT' => -2, 'AND' => -3, diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 7bb5214..6ce23da 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -257,7 +257,16 @@ public static function not(Exp $exp): UnaryExp */ public static function neg(Exp $exp): UnaryExp { - return new UnaryExp($exp, 5, prefix: '-'); // unary minus binds tightly + return new UnaryExp($exp, Precedence::UNARY, prefix: '-'); + } + + /** + * Bitwise NOT (`~ ...`). (The like-named aggregate does not exist; the bitwise + * infix operators are chained on the expression, e.g. `->bitAnd()`.) + */ + public static function bitNot(Exp $exp): UnaryExp + { + return new UnaryExp($exp, Precedence::UNARY, prefix: '~'); } // Facade-built functions and casts diff --git a/tests/MySQL/Q/ExpressionsTest.php b/tests/MySQL/Q/ExpressionsTest.php index 9493056..90bd39f 100644 --- a/tests/MySQL/Q/ExpressionsTest.php +++ b/tests/MySQL/Q/ExpressionsTest.php @@ -45,6 +45,24 @@ expect(Q::n('doc')->jsonExtractText(Q::string('$.name')))->toRenderSql("doc ->> '$.name'"); }); + it('renders bitwise operators', function () { + expect(Q::n('flags')->bitAnd(Q::int(4)))->toRenderSql('flags & 4'); + expect(Q::n('flags')->bitOr(Q::int(1)))->toRenderSql('flags | 1'); + expect(Q::n('a')->bitXor(Q::n('b')))->toRenderSql('a ^ b'); + expect(Q::n('x')->shiftLeft(Q::int(2)))->toRenderSql('x << 2'); + expect(Q::n('x')->shiftRight(Q::int(2)))->toRenderSql('x >> 2'); + expect(Q::bitNot(Q::n('flags')))->toRenderSql('~ flags'); + }); + + it('parenthesizes bitwise operators by precedence', function () { + // & binds looser than +, so a + 1 needs no parentheses under & + expect(Q::n('a')->plus(Q::int(1))->bitAnd(Q::int(4)))->toRenderSql('a + 1 & 4'); + // | binds looser than *, so forcing the OR first parenthesizes it + expect(Q::n('a')->bitOr(Q::n('b'))->mult(Q::int(2)))->toRenderSql('(a | b) * 2'); + // ^ binds tighter than *, so no parentheses are needed + expect(Q::n('a')->bitXor(Q::n('b'))->mult(Q::int(2)))->toRenderSql('a ^ b * 2'); + }); + it('renders IN with an argument list', function () { expect(Q::n('a')->in(Q::args(1, 2, 3)))->toRenderSql('a IN (?,?,?)', [1, 2, 3]); expect(Q::n('a')->notIn(Q::exps(Q::int(1), Q::int(2))))->toRenderSql('a NOT IN (1,2)'); From f7f64e92ffe2ede427c52742b10cf9a76e005839 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 14:39:53 +0200 Subject: [PATCH 32/40] =?UTF-8?q?wip:=20MEMBER=20OF=20operator=20(ExpBase:?= =?UTF-8?q?:memberOf=20=E2=86=92=20value=20MEMBER=20OF=20(json=5Farray),?= =?UTF-8?q?=20parenthesized=20RHS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/ExpBase.php | 9 +++++++++ src/MySQL/Builder/MemberOfExp.php | 26 ++++++++++++++++++++++++++ tests/MySQL/Q/ExpressionsTest.php | 6 ++++++ 3 files changed, 41 insertions(+) create mode 100644 src/MySQL/Builder/MemberOfExp.php diff --git a/src/MySQL/Builder/ExpBase.php b/src/MySQL/Builder/ExpBase.php index cd3cbbf..f6c72a7 100644 --- a/src/MySQL/Builder/ExpBase.php +++ b/src/MySQL/Builder/ExpBase.php @@ -191,6 +191,15 @@ public function notIn(SelectOrExpressions $rgt): Exp return new InExp($this, 'NOT IN', $rgt); } + /** + * JSON array membership (`value MEMBER OF (json_array)`): whether this value is + * an element of the given JSON array. + */ + public function memberOf(Exp $jsonArray): Exp + { + return new MemberOfExp($this, $jsonArray); + } + public function isNull(): Exp { return new UnaryExp($this, Precedence::of('IS'), suffix: 'IS NULL'); diff --git a/src/MySQL/Builder/MemberOfExp.php b/src/MySQL/Builder/MemberOfExp.php new file mode 100644 index 0000000..bf1c772 --- /dev/null +++ b/src/MySQL/Builder/MemberOfExp.php @@ -0,0 +1,26 @@ +value->writeSql($sb); + $sb->writeString(' MEMBER OF ('); + $this->jsonArray->writeSql($sb); + $sb->writeString(')'); + } +} diff --git a/tests/MySQL/Q/ExpressionsTest.php b/tests/MySQL/Q/ExpressionsTest.php index 90bd39f..e820ca8 100644 --- a/tests/MySQL/Q/ExpressionsTest.php +++ b/tests/MySQL/Q/ExpressionsTest.php @@ -68,6 +68,12 @@ expect(Q::n('a')->notIn(Q::exps(Q::int(1), Q::int(2))))->toRenderSql('a NOT IN (1,2)'); }); + it('renders MEMBER OF with a parenthesized JSON array', function () { + expect(Q::arg('x')->memberOf(Q::n('tags')))->toRenderSql('? MEMBER OF (tags)', ['x']); + expect(Q::arg(1)->memberOf(Q::n('doc')->jsonExtract(Q::string('$.ids')))) + ->toRenderSql("? MEMBER OF (doc -> '$.ids')", [1]); + }); + it('renders REGEXP and LIKE', function () { expect(Q::n('a')->like(Q::string('%x%')))->toRenderSql("a LIKE '%x%'"); expect(Q::n('a')->regexp(Q::string('^x')))->toRenderSql("a REGEXP '^x'"); From 821cd04d9a3bf3887d0d8f71ceb8e6d5a0213dd2 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 14:43:38 +0200 Subject: [PATCH 33/40] =?UTF-8?q?wip:=20PERCENTILE=5FCONT/DISC=20ordered-s?= =?UTF-8?q?et=20aggregates=20(MariaDB-gated)=20=E2=80=94=20OrderedSetAggBu?= =?UTF-8?q?ilder->withinGroup()->orderBy()->desc()->over(),=20mirroring=20?= =?UTF-8?q?the=20PG=20canonical=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Builder/OrderByOrderedSetAggBuilder.php | 34 +++++++ src/MySQL/Builder/OrderedSetAggBuilder.php | 97 +++++++++++++++++++ src/MySQL/Q/Func.php | 19 ++++ tests/MySQL/Q/DialectDifferencesTest.php | 24 +++++ 4 files changed, 174 insertions(+) create mode 100644 src/MySQL/Builder/OrderByOrderedSetAggBuilder.php create mode 100644 src/MySQL/Builder/OrderedSetAggBuilder.php diff --git a/src/MySQL/Builder/OrderByOrderedSetAggBuilder.php b/src/MySQL/Builder/OrderByOrderedSetAggBuilder.php new file mode 100644 index 0000000..49713bd --- /dev/null +++ b/src/MySQL/Builder/OrderByOrderedSetAggBuilder.php @@ -0,0 +1,34 @@ +rebuildLastOrderBy(SortOrder::Asc); + } + + public function desc(): self + { + return $this->rebuildLastOrderBy(SortOrder::Desc); + } + + private function rebuildLastOrderBy(SortOrder $order): self + { + $orderBys = $this->orderBys; + $lastIdx = array_key_last($orderBys); + assert($lastIdx !== null); + + $clause = $orderBys[$lastIdx]; + $orderBys[$lastIdx] = new OrderByClause($clause->exp, $order); + + return new self($this->name, $this->args, $this->requires, $orderBys, $this->withinGroupOrderBy); + } +} diff --git a/src/MySQL/Builder/OrderedSetAggBuilder.php b/src/MySQL/Builder/OrderedSetAggBuilder.php new file mode 100644 index 0000000..a32f30e --- /dev/null +++ b/src/MySQL/Builder/OrderedSetAggBuilder.php @@ -0,0 +1,97 @@ + $args + * @param list $orderBys + */ + public function __construct( + protected readonly string $name, + protected readonly array $args, + protected readonly Requirement $requires, + protected readonly array $orderBys = [], + protected readonly bool $withinGroupOrderBy = false, + ) { + } + + /** + * Render the following {@see orderBy()} inside `WITHIN GROUP (ORDER BY ...)`. + */ + public function withinGroup(): self + { + return new self($this->name, $this->args, $this->requires, $this->orderBys, true); + } + + /** + * Add the ordering expression (refine its direction via {@see OrderByOrderedSetAggBuilder}). + */ + public function orderBy(Exp $exp): OrderByOrderedSetAggBuilder + { + return new OrderByOrderedSetAggBuilder( + $this->name, + $this->args, + $this->requires, + [...$this->orderBys, new OrderByClause($exp)], + $this->withinGroupOrderBy, + ); + } + + /** + * Use this aggregate as a window function. Pass an existing window name to + * reference a window from the query's `WINDOW` clause, or omit it and refine + * the window inline via {@see WindowFuncCallBuilder::partitionBy()} / + * {@see WindowFuncCallBuilder::orderBy()}. + */ + public function over(string $existingWindowName = ''): WindowFuncCallBuilder + { + return new WindowFuncCallBuilder($this, new WindowDefinition($existingWindowName)); + } + + public function writeSql(SqlBuilder $sb): void + { + $sb->requireAnyDialect($this->name, $this->requires); + + $sb->writeString($this->name . '('); + foreach ($this->args as $i => $arg) { + if ($i > 0) { + $sb->writeString(','); + } + $arg->writeSql($sb); + } + if (!$this->withinGroupOrderBy && $this->orderBys !== []) { + $sb->writeString(' ORDER BY '); + $this->writeOrderBys($sb); + } + $sb->writeString(')'); + + if ($this->withinGroupOrderBy) { + $sb->writeString(' WITHIN GROUP (ORDER BY '); + $this->writeOrderBys($sb); + $sb->writeString(')'); + } + } + + private function writeOrderBys(SqlBuilder $sb): void + { + foreach ($this->orderBys as $i => $clause) { + if ($i > 0) { + $sb->writeString(','); + } + $clause->writeSql($sb); + } + } +} diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index 7b74403..b1ca784 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -11,6 +11,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\GroupConcatBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Keyword; +use Flowpack\QueryObjectBuilder\MySQL\Builder\OrderedSetAggBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Requirement; use Flowpack\QueryObjectBuilder\MySQL\Builder\TrimExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\WindowFuncBuilder; @@ -111,6 +112,24 @@ public static function median(Exp $expr): AggBuilder return self::gatedAgg(Dialect::MariaDb, 'MEDIAN', $expr); } + /** + * `PERCENTILE_CONT(fraction)` — a continuous percentile. Continue with + * {@see OrderedSetAggBuilder::withinGroup()} / {@see OrderedSetAggBuilder::orderBy()}. + */ + public static function percentileCont(Exp $fraction): OrderedSetAggBuilder + { + return new OrderedSetAggBuilder('PERCENTILE_CONT', [$fraction], Requirement::mariaDb()); + } + + /** + * `PERCENTILE_DISC(fraction)` — a discrete percentile. Continue with + * {@see OrderedSetAggBuilder::withinGroup()} / {@see OrderedSetAggBuilder::orderBy()}. + */ + public static function percentileDisc(Exp $fraction): OrderedSetAggBuilder + { + return new OrderedSetAggBuilder('PERCENTILE_DISC', [$fraction], Requirement::mariaDb()); + } + /** `TO_CHAR(expr)` or `TO_CHAR(expr, format)`. */ public static function toChar(Exp $expr, Exp ...$format): FuncExp { diff --git a/tests/MySQL/Q/DialectDifferencesTest.php b/tests/MySQL/Q/DialectDifferencesTest.php index a7f7243..0b47b99 100644 --- a/tests/MySQL/Q/DialectDifferencesTest.php +++ b/tests/MySQL/Q/DialectDifferencesTest.php @@ -198,6 +198,30 @@ expect($q)->toRenderSql('MEDIAN(salary) OVER (PARTITION BY dept)', null, Target::mariaDb()); expect($q)->toFailValidationFor(Target::mysql(), 'MEDIAN requires MariaDB'); }); + + it('renders PERCENTILE_CONT with WITHIN GROUP over a partition', function () { + $q = Q\Func::percentileCont(Q::float(0.5))->withinGroup()->orderBy(Q::n('salary')) + ->over()->partitionBy(Q::n('dept')); + + expect($q)->toRenderSql( + 'PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) OVER (PARTITION BY dept)', + null, + Target::mariaDb(), + ); + expect($q)->toFailValidationFor(Target::mysql(), 'PERCENTILE_CONT requires MariaDB'); + }); + + it('renders PERCENTILE_DISC with a descending WITHIN GROUP order', function () { + $q = Q\Func::percentileDisc(Q::float(0.9))->withinGroup()->orderBy(Q::n('salary'))->desc() + ->over()->partitionBy(Q::n('dept')); + + expect($q)->toRenderSql( + 'PERCENTILE_DISC(0.9) WITHIN GROUP (ORDER BY salary DESC) OVER (PARTITION BY dept)', + null, + Target::mariaDb(), + ); + expect($q)->toFailValidationFor(Target::mysql(), 'PERCENTILE_DISC requires MariaDB'); + }); }); describe('C dialect-only functions', function () { From 8677808da0bd9020ead83e7bb2d09772dcce2c30 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 14:45:37 +0200 Subject: [PATCH 34/40] wip: JSON_TABLE FROM-clause table function (Q::jsonTable -> JsonTableBuilder implements FromExp, column()/columnForOrdinality()) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/MySQL/Builder/JsonTableBuilder.php | 61 ++++++++++++++++++++++++++ src/MySQL/Builder/JsonTableColumn.php | 45 +++++++++++++++++++ src/MySQL/Q.php | 12 +++++ tests/MySQL/Q/SelectBuilderTest.php | 15 +++++++ 4 files changed, 133 insertions(+) create mode 100644 src/MySQL/Builder/JsonTableBuilder.php create mode 100644 src/MySQL/Builder/JsonTableColumn.php diff --git a/src/MySQL/Builder/JsonTableBuilder.php b/src/MySQL/Builder/JsonTableBuilder.php new file mode 100644 index 0000000..c507456 --- /dev/null +++ b/src/MySQL/Builder/JsonTableBuilder.php @@ -0,0 +1,61 @@ +from(Q::jsonTable(...))->as('jt')`). + * + * It is a {@see FromExp}, not a general expression: `JSON_TABLE` is only valid in a + * FROM clause. + * + * Immutable: every method returns a new instance. + */ +final class JsonTableBuilder implements FromExp +{ + /** + * @param list $columns + */ + public function __construct( + private readonly Exp $doc, + private readonly string $path, + private readonly array $columns = [], + ) { + } + + /** + * Add a column extracted at the given JSON path (`name type PATH 'path'`). + */ + public function column(string $name, string $type, string $path): self + { + return new self($this->doc, $this->path, [...$this->columns, JsonTableColumn::path($name, $type, $path)]); + } + + /** + * Add a 1-based row counter column (`name FOR ORDINALITY`). + */ + public function columnForOrdinality(string $name): self + { + return new self($this->doc, $this->path, [...$this->columns, JsonTableColumn::ordinality($name)]); + } + + public function writeSql(SqlBuilder $sb): void + { + $sb->writeString('JSON_TABLE('); + $this->doc->writeSql($sb); + $sb->writeString(', '); + (new StringLiteral($this->path))->writeSql($sb); + + $sb->writeString(' COLUMNS ('); + foreach ($this->columns as $i => $column) { + if ($i > 0) { + $sb->writeString(','); + } + $column->writeSql($sb); + } + $sb->writeString('))'); + } +} diff --git a/src/MySQL/Builder/JsonTableColumn.php b/src/MySQL/Builder/JsonTableColumn.php new file mode 100644 index 0000000..0144031 --- /dev/null +++ b/src/MySQL/Builder/JsonTableColumn.php @@ -0,0 +1,45 @@ +name); + if ($this->forOrdinality) { + $sb->writeString($name . ' FOR ORDINALITY'); + + return; + } + + $sb->writeString($name . ' ' . $this->type . ' PATH '); + (new StringLiteral($this->path))->writeSql($sb); + } +} diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index 6ce23da..cfce964 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -22,6 +22,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\InsertBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\IntervalExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IntLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\JsonTableBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Junction; use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; use Flowpack\QueryObjectBuilder\MySQL\Builder\Precedence; @@ -109,6 +110,17 @@ public static function withRecursive(string $queryName): WithWithBuilder return new WithWithBuilder([new WithQueryItem(true, $queryName)]); } + /** + * A `JSON_TABLE(doc, 'path' COLUMNS (...))` table function for the FROM clause. + * Add columns via {@see JsonTableBuilder::column()} / + * {@see JsonTableBuilder::columnForOrdinality()} and alias it with the from + * item's `as()`. + */ + public static function jsonTable(Exp $doc, string $path): JsonTableBuilder + { + return new JsonTableBuilder($doc, $path); + } + // Subquery predicates /** diff --git a/tests/MySQL/Q/SelectBuilderTest.php b/tests/MySQL/Q/SelectBuilderTest.php index 482768d..a0c3cac 100644 --- a/tests/MySQL/Q/SelectBuilderTest.php +++ b/tests/MySQL/Q/SelectBuilderTest.php @@ -102,4 +102,19 @@ ->joinLateral(Q::select(Q::n('*'))->from(Q::n('orders')))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) )->toRenderSql('SELECT * FROM users AS u JOIN LATERAL (SELECT * FROM orders) AS o ON o.user_id = u.id'); }); + + it('renders a JSON_TABLE derived table', function () { + expect( + Q::select(Q::n('jt.id'), Q::n('jt.name')) + ->from(Q::n('t')) + ->from( + Q::jsonTable(Q::n('t.doc'), '$[*]') + ->column('id', 'INT', '$.id') + ->column('name', 'VARCHAR(100)', '$.name') + ->columnForOrdinality('rownum'), + )->as('jt') + )->toRenderSql( + "SELECT jt.id,jt.name FROM t,JSON_TABLE(t.doc, '$[*]' COLUMNS (id INT PATH '$.id',name VARCHAR(100) PATH '$.name',rownum FOR ORDINALITY)) AS jt", + ); + }); }); From 22e98ea00d576e86c2f1591e3a62bde366856c41 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 14:47:11 +0200 Subject: [PATCH 35/40] =?UTF-8?q?wip:=20mark=20bitwise=20/=20MEMBER=20OF?= =?UTF-8?q?=20/=20PERCENTILE=5FCONT+DISC=20/=20JSON=5FTABLE=20as=20Support?= =?UTF-8?q?ed=20in=20=C2=A712=20ledger;=20update=20usage-doc=20operators/f?= =?UTF-8?q?unctions=20+=20Limitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mysql-mariadb-design.md | 8 ++++---- docs/mysql-mariadb.md | 16 +++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index 1132479..a6449bb 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -522,7 +522,7 @@ MariaDB 13+); `DELETE ... RETURNING` is MariaDB-only (stage 7). ### Expressions | Item | Status | Reason | |---|---|---| -| bitwise `&` `\|` `^`(XOR) `<<` `>>` `~` | Deferred | not in the PG surface; add if wanted | +| bitwise `&` `\|` `^`(XOR) `<<` `>>` `~` | Supported | operators on `ExpBase` (`bitAnd`/`bitOr`/`bitXor`/`shiftLeft`/`shiftRight`) + `Q::bitNot` | | case-sensitive regex (`~`/`!~` equivalents) | Deferred | engine-divergent (`REGEXP_LIKE(...,'c')` vs `REGEXP BINARY`); only the ci-default `REGEXP` ships now | | JSON `->`/`->>` operators on MariaDB expressions | MySQL-native (shared base) | MariaDB lacks these operators (MDEV-13594); they live on the shared `ExpBase` as MySQL syntax — MariaDB code should use `Q\Func::jsonExtract`/`jsonUnquote`. A full expression-layer fork to remove two methods from MariaDB is deliberately deferred as disproportionate. | | `ILIKE`, `SIMILAR TO`, `::`, `\|\|`, `^`(pow), `@>`/`<@`, `#>`/`#>>`, `ARRAY` | N/A (PG-only) | dropped or mapped to functions — see §6 | @@ -544,8 +544,8 @@ Anything omitted stays reachable via `Q::func(name, ...)`. | Encryption / compression (`AES_*`, `MD5`/`SHA*`, `COMPRESS`) | Excluded from default | security/config-sensitive; via `Q::func` | | Locking / information / replication / internal funcs | Excluded | connection/server state, not query shape | | XML (`ExtractValue`, `UpdateXML`) | Excluded | niche | -| `JSON_TABLE` | Deferred | FROM-clause table function (FROM machinery, not `Q\Func`) | -| `MEMBER OF` operator | Deferred | JSON membership; add to the expression layer if wanted | +| `JSON_TABLE` | Supported | FROM-clause table function on the `Q` facade (`Q::jsonTable(...)->column()/columnForOrdinality()`); `NESTED PATH` / `DEFAULT…ON EMPTY\|ERROR` / `EXISTS PATH` deferred | +| `MEMBER OF` operator | Supported | `ExpBase::memberOf()` → `value MEMBER OF (json_array)` | | MySQL-only `REGEXP_LIKE`/`GROUPING`/`ANY_VALUE`/`JSON_SCHEMA*`/`JSON_STORAGE*`/`JSON_PRETTY`/`RANDOM_BYTES` | Supported (MySQL facade only) | gated; absent on MariaDB | | MariaDB-only `JSON_QUERY`/`JSON_DETAILED`/`JSON_EXISTS`/`MEDIAN`/Oracle-compat (`TO_CHAR`/`ADD_MONTHS`/`MONTHS_BETWEEN`/`CHR`/`OCT`) | Supported (MariaDB facade only) | gated; absent on MySQL | -| MariaDB `PERCENTILE_CONT`/`PERCENTILE_DISC` | Deferred | ordered-set aggregates with `WITHIN GROUP (ORDER BY …)` — a distinct builder shape; via `Q::func` until added | +| MariaDB `PERCENTILE_CONT`/`PERCENTILE_DISC` | Supported (MariaDB, gated) | ordered-set aggregates `Q\Func::percentileCont/Disc(frac)->withinGroup()->orderBy(...)`, usable with `over()` | diff --git a/docs/mysql-mariadb.md b/docs/mysql-mariadb.md index deaea9b..7028d69 100644 --- a/docs/mysql-mariadb.md +++ b/docs/mysql-mariadb.md @@ -47,14 +47,16 @@ or any layer that speaks MySQL/MariaDB placeholders. misc scalars, the window functions (`rowNumber`, `rank`, `lag`, `lead`, `firstValue`, …), the special shapes (`GROUP_CONCAT`, `EXTRACT`, `TRIM`), and the engine-specific functions (`jsonPretty`, `regexpLike`, … for MySQL; - `jsonDetailed`, `median`, … for MariaDB). It is named `Func` (not `Fn`) because - `fn` is a reserved keyword in PHP. + `jsonDetailed`, `median`, `percentileCont`/`percentileDisc`, … for MariaDB). It + is named `Func` (not `Fn`) because `fn` is a reserved keyword in PHP. Operators are chainable on the expression objects that `Q::n()`, literals and functions return: `->eq()`, `->neq()`, `->lt()`, `->like()`, `->regexp()`, `->nullSafeEq()` (`<=>`), `->in()`, `->isNull()`, `->plus()`, `->jsonExtract()` -(`->`, MySQL), … Things that read as function calls — `CONCAT`, `POW`, `CAST`, -`JSON_CONTAINS` — are built through the facade (`Q::func` / `Q::cast` / `Q\Func`), +(`->`, MySQL), the bitwise operators (`->bitAnd()`, `->bitOr()`, `->bitXor()`, +`->shiftLeft()`, `->shiftRight()`, `Q::bitNot()`) and `->memberOf()` +(`x MEMBER OF (arr)`), … Things that read as function calls — `CONCAT`, `POW`, +`CAST`, `JSON_CONTAINS` — are built through the facade (`Q::func` / `Q::cast` / `Q\Func`), not as chained operators. ## Validating against a target @@ -233,9 +235,9 @@ deliberately out of scope. Anything omitted remains reachable through the raw short form, MariaDB `OFFSET..FETCH` and recursive-CTE `CYCLE`, the `INSERT/REPLACE ... SET` assignment form, MySQL `VALUES ROW()` / `TABLE` sources, the comma-separated multi-table list (the `JOIN` form covers it), - multi-target `DELETE t1,t2 FROM …`, `UPDATE/DELETE IGNORE`, `MEMBER OF`, - `JSON_TABLE`, and MariaDB `PERCENTILE_CONT`/`PERCENTILE_DISC` (the `WITHIN - GROUP` ordered-set shape). + multi-target `DELETE t1,t2 FROM …`, `UPDATE/DELETE IGNORE`, and the heavier + `JSON_TABLE` column forms (`NESTED PATH`, `DEFAULT…ON EMPTY|ERROR`, `EXISTS + PATH`). - **Excluded** (not query shape): `INTO OUTFILE/DUMPFILE/@var`, priority / optimizer / result hints (`LOW_PRIORITY`, `SQL_CALC_FOUND_ROWS`, …), `PROCEDURE`, `ROWS EXAMINED` / `WAIT n`, `FOR PORTION OF`, spatial / GIS, From aeee4885d79e1c20668c20473bc67750ed70d31f Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 15:14:49 +0200 Subject: [PATCH 36/40] =?UTF-8?q?wip:=20JSON=5FTABLE=20column=20API=20rede?= =?UTF-8?q?sign=20=E2=80=94=20recursive=20JsonTableColumns=20via=20->colum?= =?UTF-8?q?ns(closure);=20path()/existsPath()/forOrdinality()/on-empty+on-?= =?UTF-8?q?error/nested()->path()->columns();=20docs=20updated=20(ledger,?= =?UTF-8?q?=20usage=20example,=20limitations)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/mysql-mariadb-design.md | 2 +- docs/mysql-mariadb.md | 32 ++++- src/MySQL/Builder/JsonTableBuilder.php | 34 ++--- src/MySQL/Builder/JsonTableColumn.php | 49 ++++--- src/MySQL/Builder/JsonTableColumnKind.php | 20 +++ src/MySQL/Builder/JsonTableColumns.php | 149 ++++++++++++++++++++++ src/MySQL/Builder/JsonTableOnClause.php | 34 +++++ tests/MySQL/Q/SelectBuilderTest.php | 36 +++++- 8 files changed, 307 insertions(+), 49 deletions(-) create mode 100644 src/MySQL/Builder/JsonTableColumnKind.php create mode 100644 src/MySQL/Builder/JsonTableColumns.php create mode 100644 src/MySQL/Builder/JsonTableOnClause.php diff --git a/docs/mysql-mariadb-design.md b/docs/mysql-mariadb-design.md index a6449bb..afb56ab 100644 --- a/docs/mysql-mariadb-design.md +++ b/docs/mysql-mariadb-design.md @@ -544,7 +544,7 @@ Anything omitted stays reachable via `Q::func(name, ...)`. | Encryption / compression (`AES_*`, `MD5`/`SHA*`, `COMPRESS`) | Excluded from default | security/config-sensitive; via `Q::func` | | Locking / information / replication / internal funcs | Excluded | connection/server state, not query shape | | XML (`ExtractValue`, `UpdateXML`) | Excluded | niche | -| `JSON_TABLE` | Supported | FROM-clause table function on the `Q` facade (`Q::jsonTable(...)->column()/columnForOrdinality()`); `NESTED PATH` / `DEFAULT…ON EMPTY\|ERROR` / `EXISTS PATH` deferred | +| `JSON_TABLE` | Supported | FROM-clause table function on the `Q` facade (`Q::jsonTable(doc, path)->columns(fn (JsonTableColumns) => …)`): value / `EXISTS PATH` / `FOR ORDINALITY` columns, `ON EMPTY` / `ON ERROR`, and recursive `NESTED PATH` | | `MEMBER OF` operator | Supported | `ExpBase::memberOf()` → `value MEMBER OF (json_array)` | | MySQL-only `REGEXP_LIKE`/`GROUPING`/`ANY_VALUE`/`JSON_SCHEMA*`/`JSON_STORAGE*`/`JSON_PRETTY`/`RANDOM_BYTES` | Supported (MySQL facade only) | gated; absent on MariaDB | | MariaDB-only `JSON_QUERY`/`JSON_DETAILED`/`JSON_EXISTS`/`MEDIAN`/Oracle-compat (`TO_CHAR`/`ADD_MONTHS`/`MONTHS_BETWEEN`/`CHR`/`OCT`) | Supported (MariaDB facade only) | gated; absent on MySQL | diff --git a/docs/mysql-mariadb.md b/docs/mysql-mariadb.md index 7028d69..6ebf37b 100644 --- a/docs/mysql-mariadb.md +++ b/docs/mysql-mariadb.md @@ -148,6 +148,34 @@ SELECT depname, FROM empsalary WINDOW w AS (ORDER BY salary) ``` +### JSON_TABLE + +`Q::jsonTable(doc, path)` is a FROM-clause table function; define its columns with +`columns()` (a closure receiving a column-list builder). `column()` opens a value +column and `path()` gives its JSON path; `existsPath()` and `forOrdinality()` pick +the other leaf forms, `defaultOnEmpty()`/`nullOnError()`/… add the miss handlers, +and `nested()->path()->columns()` recurses. + +```php +$q = Q::select(Q::n('jt.id'), Q::n('jt.tag')) + ->from(Q::n('t')) + ->from( + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('id', 'INT')->path('$.id') + ->column('ord')->forOrdinality() + ->nested()->path('$.tags[*]')->columns(fn ($tags) => $tags + ->column('tag', 'VARCHAR(50)')->path('$'))) + )->as('jt'); +``` + +```sql +SELECT jt.id,jt.tag FROM t, + JSON_TABLE(t.doc, '$[*]' COLUMNS ( + id INT PATH '$.id', + ord FOR ORDINALITY, + NESTED PATH '$.tags[*]' COLUMNS (tag VARCHAR(50) PATH '$'))) AS jt +``` + ### INSERT & upsert The two engines reference the proposed row differently, so you build each one its @@ -235,9 +263,7 @@ deliberately out of scope. Anything omitted remains reachable through the raw short form, MariaDB `OFFSET..FETCH` and recursive-CTE `CYCLE`, the `INSERT/REPLACE ... SET` assignment form, MySQL `VALUES ROW()` / `TABLE` sources, the comma-separated multi-table list (the `JOIN` form covers it), - multi-target `DELETE t1,t2 FROM …`, `UPDATE/DELETE IGNORE`, and the heavier - `JSON_TABLE` column forms (`NESTED PATH`, `DEFAULT…ON EMPTY|ERROR`, `EXISTS - PATH`). + multi-target `DELETE t1,t2 FROM …`, and `UPDATE/DELETE IGNORE`. - **Excluded** (not query shape): `INTO OUTFILE/DUMPFILE/@var`, priority / optimizer / result hints (`LOW_PRIORITY`, `SQL_CALC_FOUND_ROWS`, …), `PROCEDURE`, `ROWS EXAMINED` / `WAIT n`, `FOR PORTION OF`, spatial / GIS, diff --git a/src/MySQL/Builder/JsonTableBuilder.php b/src/MySQL/Builder/JsonTableBuilder.php index c507456..b5398bc 100644 --- a/src/MySQL/Builder/JsonTableBuilder.php +++ b/src/MySQL/Builder/JsonTableBuilder.php @@ -6,8 +6,9 @@ /** * Builds a `JSON_TABLE(doc, 'path' COLUMNS (...))` table function for the FROM - * clause, expanding a JSON document into rows. Give it an alias with the from - * item's `as()` (`->from(Q::jsonTable(...))->as('jt')`). + * clause, expanding a JSON document into rows. Define the columns with + * {@see columns()} and give it an alias via the from item's `as()` + * (`->from(Q::jsonTable(...))->as('jt')`). * * It is a {@see FromExp}, not a general expression: `JSON_TABLE` is only valid in a * FROM clause. @@ -16,30 +17,22 @@ */ final class JsonTableBuilder implements FromExp { - /** - * @param list $columns - */ public function __construct( private readonly Exp $doc, private readonly string $path, - private readonly array $columns = [], + private readonly JsonTableColumns $columns = new JsonTableColumns(), ) { } /** - * Add a column extracted at the given JSON path (`name type PATH 'path'`). - */ - public function column(string $name, string $type, string $path): self - { - return new self($this->doc, $this->path, [...$this->columns, JsonTableColumn::path($name, $type, $path)]); - } - - /** - * Add a 1-based row counter column (`name FOR ORDINALITY`). + * Define the column list. The closure receives a {@see JsonTableColumns} builder + * and returns it configured. + * + * @param \Closure(JsonTableColumns): JsonTableColumns $build */ - public function columnForOrdinality(string $name): self + public function columns(\Closure $build): self { - return new self($this->doc, $this->path, [...$this->columns, JsonTableColumn::ordinality($name)]); + return new self($this->doc, $this->path, $build(new JsonTableColumns())); } public function writeSql(SqlBuilder $sb): void @@ -50,12 +43,7 @@ public function writeSql(SqlBuilder $sb): void (new StringLiteral($this->path))->writeSql($sb); $sb->writeString(' COLUMNS ('); - foreach ($this->columns as $i => $column) { - if ($i > 0) { - $sb->writeString(','); - } - $column->writeSql($sb); - } + $this->columns->writeColumns($sb); $sb->writeString('))'); } } diff --git a/src/MySQL/Builder/JsonTableColumn.php b/src/MySQL/Builder/JsonTableColumn.php index 0144031..c1e9a4e 100644 --- a/src/MySQL/Builder/JsonTableColumn.php +++ b/src/MySQL/Builder/JsonTableColumn.php @@ -5,41 +5,54 @@ namespace Flowpack\QueryObjectBuilder\MySQL\Builder; /** - * A single `JSON_TABLE` column: either `name type PATH 'jsonpath'` or the - * row-counter `name FOR ORDINALITY`. + * A single `JSON_TABLE` column. Depending on its {@see JsonTableColumnKind} it + * renders as `name type PATH 'p' [on_empty] [on_error]`, `name type EXISTS PATH + * 'p'`, `name FOR ORDINALITY`, or `NESTED PATH 'p' COLUMNS (...)`. * * @internal */ final class JsonTableColumn { - private function __construct( - private readonly string $name, - private readonly bool $forOrdinality, - private readonly string $type, - private readonly string $path, + public function __construct( + public readonly string $name, + public readonly string $type, + public readonly JsonTableColumnKind $kind, + public readonly string $path, + public readonly ?JsonTableOnClause $onEmpty, + public readonly ?JsonTableOnClause $onError, + public readonly ?JsonTableColumns $nested, ) { } - public static function path(string $name, string $type, string $path): self + public function writeSql(SqlBuilder $sb): void { - return new self($name, false, $type, $path); - } + if ($this->kind === JsonTableColumnKind::Nested) { + assert($this->nested !== null); + $sb->writeString('NESTED PATH '); + (new StringLiteral($this->path))->writeSql($sb); + $sb->writeString(' COLUMNS ('); + $this->nested->writeColumns($sb); + $sb->writeString(')'); - public static function ordinality(string $name): self - { - return new self($name, true, '', ''); - } + return; + } - public function writeSql(SqlBuilder $sb): void - { $name = Keywords::quoteIdentifierIfKeyword($this->name); - if ($this->forOrdinality) { + if ($this->kind === JsonTableColumnKind::Ordinality) { $sb->writeString($name . ' FOR ORDINALITY'); return; } - $sb->writeString($name . ' ' . $this->type . ' PATH '); + $keyword = $this->kind === JsonTableColumnKind::Exists ? ' EXISTS PATH ' : ' PATH '; + $sb->writeString($name . ' ' . $this->type . $keyword); (new StringLiteral($this->path))->writeSql($sb); + + // ON EMPTY / ON ERROR apply to a plain value (PATH) column only, and MySQL + // requires ON EMPTY before ON ERROR. + if ($this->kind === JsonTableColumnKind::Path) { + $this->onEmpty?->writeSql($sb, 'EMPTY'); + $this->onError?->writeSql($sb, 'ERROR'); + } } } diff --git a/src/MySQL/Builder/JsonTableColumnKind.php b/src/MySQL/Builder/JsonTableColumnKind.php new file mode 100644 index 0000000..0ebc171 --- /dev/null +++ b/src/MySQL/Builder/JsonTableColumnKind.php @@ -0,0 +1,20 @@ + $columns + */ + public function __construct( + private readonly array $columns = [], + ) { + } + + /** + * Open a value column `name [type]`. Give it a path with {@see path()} or + * {@see existsPath()}, or make it a row counter with {@see forOrdinality()}. + */ + public function column(string $name, string $type = ''): self + { + return new self([...$this->columns, new JsonTableColumn($name, $type, JsonTableColumnKind::Path, '', null, null, null)]); + } + + /** + * Set the JSON path of the last column (`PATH 'path'`, or the `NESTED PATH` of a + * {@see nested()} column). + */ + public function path(string $path): self + { + return $this->rebuildLastColumn(path: $path); + } + + /** + * Make the last column an existence flag (`type EXISTS PATH 'path'`). + */ + public function existsPath(string $path): self + { + return $this->rebuildLastColumn(kind: JsonTableColumnKind::Exists, path: $path); + } + + /** + * Make the last column the row counter (`name FOR ORDINALITY`). + */ + public function forOrdinality(): self + { + return $this->rebuildLastColumn(kind: JsonTableColumnKind::Ordinality); + } + + public function nullOnEmpty(): self + { + return $this->rebuildLastColumn(onEmpty: new JsonTableOnClause('NULL')); + } + + public function defaultOnEmpty(string $value): self + { + return $this->rebuildLastColumn(onEmpty: new JsonTableOnClause('DEFAULT', $value)); + } + + public function errorOnEmpty(): self + { + return $this->rebuildLastColumn(onEmpty: new JsonTableOnClause('ERROR')); + } + + public function nullOnError(): self + { + return $this->rebuildLastColumn(onError: new JsonTableOnClause('NULL')); + } + + public function defaultOnError(string $value): self + { + return $this->rebuildLastColumn(onError: new JsonTableOnClause('DEFAULT', $value)); + } + + public function errorOnError(): self + { + return $this->rebuildLastColumn(onError: new JsonTableOnClause('ERROR')); + } + + /** + * Open a nested column. Set its path with {@see path()} and its child column + * list with {@see columns()}. + */ + public function nested(): self + { + return new self([...$this->columns, new JsonTableColumn('', '', JsonTableColumnKind::Nested, '', null, null, null)]); + } + + /** + * Supply the child column list of the last (nested) column. + * + * @param \Closure(JsonTableColumns): JsonTableColumns $build + */ + public function columns(\Closure $build): self + { + return $this->rebuildLastColumn(nested: $build(new self())); + } + + /** + * @internal + */ + public function writeColumns(SqlBuilder $sb): void + { + foreach ($this->columns as $i => $column) { + if ($i > 0) { + $sb->writeString(','); + } + $column->writeSql($sb); + } + } + + private function rebuildLastColumn( + ?JsonTableColumnKind $kind = null, + ?string $path = null, + ?JsonTableOnClause $onEmpty = null, + ?JsonTableOnClause $onError = null, + ?JsonTableColumns $nested = null, + ): self { + $columns = $this->columns; + $lastIdx = array_key_last($columns); + assert($lastIdx !== null); + + $c = $columns[$lastIdx]; + $columns[$lastIdx] = new JsonTableColumn( + $c->name, + $c->type, + $kind ?? $c->kind, + $path ?? $c->path, + $onEmpty ?? $c->onEmpty, + $onError ?? $c->onError, + $nested ?? $c->nested, + ); + + return new self($columns); + } +} diff --git a/src/MySQL/Builder/JsonTableOnClause.php b/src/MySQL/Builder/JsonTableOnClause.php new file mode 100644 index 0000000..e44de08 --- /dev/null +++ b/src/MySQL/Builder/JsonTableOnClause.php @@ -0,0 +1,34 @@ +default !== null) { + $sb->writeString(' DEFAULT '); + (new StringLiteral($this->default))->writeSql($sb); + } else { + $sb->writeString(' ' . $this->behavior); + } + $sb->writeString(' ON ' . $event); + } +} diff --git a/tests/MySQL/Q/SelectBuilderTest.php b/tests/MySQL/Q/SelectBuilderTest.php index a0c3cac..580d348 100644 --- a/tests/MySQL/Q/SelectBuilderTest.php +++ b/tests/MySQL/Q/SelectBuilderTest.php @@ -108,13 +108,41 @@ Q::select(Q::n('jt.id'), Q::n('jt.name')) ->from(Q::n('t')) ->from( - Q::jsonTable(Q::n('t.doc'), '$[*]') - ->column('id', 'INT', '$.id') - ->column('name', 'VARCHAR(100)', '$.name') - ->columnForOrdinality('rownum'), + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('id', 'INT')->path('$.id') + ->column('name', 'VARCHAR(100)')->path('$.name') + ->column('rownum')->forOrdinality()), )->as('jt') )->toRenderSql( "SELECT jt.id,jt.name FROM t,JSON_TABLE(t.doc, '$[*]' COLUMNS (id INT PATH '$.id',name VARCHAR(100) PATH '$.name',rownum FOR ORDINALITY)) AS jt", ); }); + + it('renders JSON_TABLE EXISTS PATH and ON EMPTY / ON ERROR columns', function () { + expect( + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('ac', 'VARCHAR(100)')->path('$.a')->defaultOnEmpty('111')->defaultOnError('999') + ->column('cx', 'VARCHAR(20)')->path('$.c')->nullOnEmpty()->errorOnError() + ->column('bx', 'INT')->existsPath('$.b')) + )->toRenderSql( + "JSON_TABLE(t.doc, '$[*]' COLUMNS (ac VARCHAR(100) PATH '$.a' DEFAULT '111' ON EMPTY DEFAULT '999' ON ERROR,cx VARCHAR(20) PATH '$.c' NULL ON EMPTY ERROR ON ERROR,bx INT EXISTS PATH '$.b'))", + ); + }); + + it('renders nested JSON_TABLE columns', function () { + expect( + Q::select(Q::n('*'))->from( + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('top_ord')->forOrdinality() + ->column('apath', 'VARCHAR(10)')->path('$.a') + ->nested()->path('$.b[*]')->columns(fn ($b) => $b + ->column('bpath', 'VARCHAR(10)')->path('$.c') + ->column('ord')->forOrdinality() + ->nested()->path('$.l[*]')->columns(fn ($l) => $l + ->column('lpath', 'VARCHAR(10)')->path('$')))), + )->as('jt') + )->toRenderSql( + "SELECT * FROM JSON_TABLE(t.doc, '$[*]' COLUMNS (top_ord FOR ORDINALITY,apath VARCHAR(10) PATH '$.a',NESTED PATH '$.b[*]' COLUMNS (bpath VARCHAR(10) PATH '$.c',ord FOR ORDINALITY,NESTED PATH '$.l[*]' COLUMNS (lpath VARCHAR(10) PATH '$')))) AS jt", + ); + }); }); From 6580bff29a8c7e2c8b132c2c6488aba94893a8c8 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 15:55:07 +0200 Subject: [PATCH 37/40] wip: MySQL JSON-shape selection tests + AggBuilder DISTINCT gating; document validation-error categories in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/MySQL SelectJsonShapeTest: JSON_OBJECT / JSON_ARRAYAGG / JSON_OBJECTAGG selection shapes (nested objects, roll-ups via join+GROUP BY, correlated subselects, COALESCE(...,JSON_ARRAY()) empty-array idiom), grounded in the MySQL 8.4 reference - AggBuilder: gate DISTINCT to the aggregates whose grammar accepts it (AVG/COUNT/MAX/MIN/SUM) via $sb->addError when validating; doc-cited allow-list - FunctionsTest: accepted/rejected DISTINCT + withoutValidation() bypass; ExpressionsTest: comparison/arithmetic/unary/REGEXP/junction/ANY-ALL coverage - AGENTS.md: new "Validation errors" section — advisory value checks gate on isValidating() and keep rendering; mutually-exclusive builder state always throws Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 30 ++ src/MySQL/Builder/AggBuilder.php | 17 + tests/MySQL/Q/ExpressionsTest.php | 45 ++- tests/MySQL/Q/FunctionsTest.php | 77 ++++- tests/MySQL/Q/SelectJsonShapeTest.php | 474 ++++++++++++++++++++++++++ 5 files changed, 639 insertions(+), 4 deletions(-) create mode 100644 tests/MySQL/Q/SelectJsonShapeTest.php diff --git a/AGENTS.md b/AGENTS.md index 46a813b..8084628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,36 @@ 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 diff --git a/src/MySQL/Builder/AggBuilder.php b/src/MySQL/Builder/AggBuilder.php index 88a05c2..0d7feec 100644 --- a/src/MySQL/Builder/AggBuilder.php +++ b/src/MySQL/Builder/AggBuilder.php @@ -10,6 +10,19 @@ */ class AggBuilder extends ExpBase { + /** + * Aggregates whose grammar accepts a leading DISTINCT, per the MySQL 8.4 + * reference (https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html). + * The others built through this class — JSON_ARRAYAGG / JSON_OBJECTAGG, the + * BIT_AND / BIT_OR / BIT_XOR bit aggregates, the STDDEV_* / VAR_* statistics + * functions and MEDIAN — reject it. Stored uppercase for case-insensitive lookup. + * + * @var array + */ + private const DISTINCT_SUPPORTED = [ + 'AVG' => true, 'COUNT' => true, 'MAX' => true, 'MIN' => true, 'SUM' => true, + ]; + /** * @param list $exps * @param Requirement|null $requires the dialect this aggregate is available on, if it is dialect-specific @@ -44,6 +57,10 @@ public function writeSql(SqlBuilder $sb): void $sb->requireAnyDialect($this->name, $this->requires); } + if ($this->distinct && $sb->isValidating() && !isset(self::DISTINCT_SUPPORTED[strtoupper($this->name)])) { + $sb->addError(new QueryBuilderException(sprintf('aggregate: %s does not support DISTINCT', $this->name))); + } + $sb->writeString($this->name . '(' . ($this->distinct ? 'DISTINCT ' : '')); foreach ($this->exps as $i => $exp) { if ($i > 0) { diff --git a/tests/MySQL/Q/ExpressionsTest.php b/tests/MySQL/Q/ExpressionsTest.php index e820ca8..cf2e418 100644 --- a/tests/MySQL/Q/ExpressionsTest.php +++ b/tests/MySQL/Q/ExpressionsTest.php @@ -40,6 +40,28 @@ expect(Q::n('a')->nullSafeEq(Q::arg(1)))->toRenderSql('a <=> ?', [1]); }); + it('renders comparison operators', function () { + expect(Q::n('a')->eq(Q::int(1)))->toRenderSql('a = 1'); + expect(Q::n('a')->neq(Q::int(1)))->toRenderSql('a <> 1'); + expect(Q::n('a')->lt(Q::int(1)))->toRenderSql('a < 1'); + expect(Q::n('a')->lte(Q::int(1)))->toRenderSql('a <= 1'); + expect(Q::n('a')->gt(Q::int(1)))->toRenderSql('a > 1'); + expect(Q::n('a')->gte(Q::int(1)))->toRenderSql('a >= 1'); + }); + + it('renders arithmetic operators', function () { + expect(Q::n('a')->plus(Q::int(1)))->toRenderSql('a + 1'); + expect(Q::n('a')->minus(Q::int(1)))->toRenderSql('a - 1'); + expect(Q::n('a')->mult(Q::int(2)))->toRenderSql('a * 2'); + expect(Q::n('a')->divide(Q::int(2)))->toRenderSql('a / 2'); + expect(Q::n('a')->mod(Q::int(2)))->toRenderSql('a % 2'); + }); + + it('renders unary negation, keeping precedence', function () { + expect(Q::neg(Q::n('a')))->toRenderSql('- a'); + expect(Q::neg(Q::n('a'))->plus(Q::int(1)))->toRenderSql('- a + 1'); + }); + it('renders JSON path extraction operators', function () { expect(Q::n('doc')->jsonExtract(Q::string('$.name')))->toRenderSql("doc -> '$.name'"); expect(Q::n('doc')->jsonExtractText(Q::string('$.name')))->toRenderSql("doc ->> '$.name'"); @@ -74,15 +96,36 @@ ->toRenderSql("? MEMBER OF (doc -> '$.ids')", [1]); }); - it('renders REGEXP and LIKE', function () { + it('renders REGEXP and LIKE, negated and not', function () { expect(Q::n('a')->like(Q::string('%x%')))->toRenderSql("a LIKE '%x%'"); + expect(Q::n('a')->notLike(Q::string('%x%')))->toRenderSql("a NOT LIKE '%x%'"); expect(Q::n('a')->regexp(Q::string('^x')))->toRenderSql("a REGEXP '^x'"); + expect(Q::n('a')->notRegexp(Q::string('^x')))->toRenderSql("a NOT REGEXP '^x'"); }); it('combines conditions with AND / OR / NOT', function () { expect(Q::and(Q::n('a')->eq(Q::int(1)), Q::n('b')->eq(Q::int(2)))) ->toRenderSql('a = 1 AND b = 2'); + expect(Q::or(Q::n('a')->eq(Q::int(1)), Q::n('b')->eq(Q::int(2)))) + ->toRenderSql('a = 1 OR b = 2'); expect(Q::not(Q::n('a')))->toRenderSql('NOT a'); expect(Q::coalesce(Q::n('a'), Q::int(0)))->toRenderSql('COALESCE(a, 0)'); }); + + it('parenthesizes a nested junction by precedence', function () { + // A junction nested inside another is wrapped in parentheses. + expect(Q::and(Q::or(Q::n('a'), Q::n('b')), Q::n('c'))) + ->toRenderSql('(a OR b) AND c'); + // NOT binds tighter than OR, so its operand junction is parenthesized. + expect(Q::not(Q::or(Q::n('a'), Q::n('b')))) + ->toRenderSql('NOT (a OR b)'); + }); + + it('renders ANY / ALL subquery comparisons', function () { + expect( + Q::n('id')->eq(Q::any(Q::select(Q::n('user_id'))->from(Q::n('orders')))) + )->toRenderSql('id = ANY (SELECT user_id FROM orders)'); + + expect(Q::n('x')->gt(Q::all(Q::n('vals'))))->toRenderSql('x > ALL (vals)'); + }); }); diff --git a/tests/MySQL/Q/FunctionsTest.php b/tests/MySQL/Q/FunctionsTest.php index 14bfb29..c43e8f7 100644 --- a/tests/MySQL/Q/FunctionsTest.php +++ b/tests/MySQL/Q/FunctionsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Flowpack\QueryObjectBuilder\MySQL\Builder\Exp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilderException; use Flowpack\QueryObjectBuilder\MySQL\Q; describe('MySQL curated function set', function () { @@ -64,6 +65,11 @@ 'sin' => [fn () => Q\Func::sin(Q::n('n')), 'SIN(n)'], 'cos' => [fn () => Q\Func::cos(Q::n('n')), 'COS(n)'], 'tan' => [fn () => Q\Func::tan(Q::n('n')), 'TAN(n)'], + 'cot' => [fn () => Q\Func::cot(Q::n('n')), 'COT(n)'], + 'asin' => [fn () => Q\Func::asin(Q::n('n')), 'ASIN(n)'], + 'acos' => [fn () => Q\Func::acos(Q::n('n')), 'ACOS(n)'], + 'atan' => [fn () => Q\Func::atan(Q::n('n')), 'ATAN(n)'], + 'atan 2-arg' => [fn () => Q\Func::atan(Q::n('y'), Q::n('x')), 'ATAN(y, x)'], 'atan2' => [fn () => Q\Func::atan2(Q::n('y'), Q::n('x')), 'ATAN2(y, x)'], 'radians' => [fn () => Q\Func::radians(Q::n('d')), 'RADIANS(d)'], 'degrees' => [fn () => Q\Func::degrees(Q::n('r')), 'DEGREES(r)'], @@ -71,15 +77,29 @@ // Date / time 'now' => [fn () => Q\Func::now(), 'NOW()'], 'curdate' => [fn () => Q\Func::curdate(), 'CURDATE()'], + 'curtime' => [fn () => Q\Func::curtime(), 'CURTIME()'], 'currentTimestamp' => [fn () => Q\Func::currentTimestamp(), 'CURRENT_TIMESTAMP()'], + 'utcTimestamp' => [fn () => Q\Func::utcTimestamp(), 'UTC_TIMESTAMP()'], + 'date' => [fn () => Q\Func::date(Q::n('d')), 'DATE(d)'], + 'time' => [fn () => Q\Func::time(Q::n('d')), 'TIME(d)'], 'year' => [fn () => Q\Func::year(Q::n('d')), 'YEAR(d)'], 'month' => [fn () => Q\Func::month(Q::n('d')), 'MONTH(d)'], + 'day' => [fn () => Q\Func::day(Q::n('d')), 'DAY(d)'], + 'hour' => [fn () => Q\Func::hour(Q::n('t')), 'HOUR(t)'], + 'minute' => [fn () => Q\Func::minute(Q::n('t')), 'MINUTE(t)'], + 'second' => [fn () => Q\Func::second(Q::n('t')), 'SECOND(t)'], + 'quarter' => [fn () => Q\Func::quarter(Q::n('d')), 'QUARTER(d)'], 'dayOfWeek' => [fn () => Q\Func::dayOfWeek(Q::n('d')), 'DAYOFWEEK(d)'], + 'dayOfYear' => [fn () => Q\Func::dayOfYear(Q::n('d')), 'DAYOFYEAR(d)'], + 'dayName' => [fn () => Q\Func::dayName(Q::n('d')), 'DAYNAME(d)'], + 'monthName' => [fn () => Q\Func::monthName(Q::n('d')), 'MONTHNAME(d)'], + 'lastDay' => [fn () => Q\Func::lastDay(Q::n('d')), 'LAST_DAY(d)'], 'week mode' => [fn () => Q\Func::week(Q::n('d'), Q::int(1)), 'WEEK(d, 1)'], 'dateDiff' => [fn () => Q\Func::dateDiff(Q::n('a'), Q::n('b')), 'DATEDIFF(a, b)'], 'timestampDiff' => [fn () => Q\Func::timestampDiff('DAY', Q::n('a'), Q::n('b')), 'TIMESTAMPDIFF(DAY, a, b)'], 'timestampAdd' => [fn () => Q\Func::timestampAdd('HOUR', Q::int(2), Q::n('d')), 'TIMESTAMPADD(HOUR, 2, d)'], 'dateFormat' => [fn () => Q\Func::dateFormat(Q::n('d'), Q::string('%Y')), "DATE_FORMAT(d, '%Y')"], + 'strToDate' => [fn () => Q\Func::strToDate(Q::n('s'), Q::string('%Y-%m-%d')), "STR_TO_DATE(s, '%Y-%m-%d')"], 'unixTimestamp' => [fn () => Q\Func::unixTimestamp(), 'UNIX_TIMESTAMP()'], 'fromUnixtime' => [fn () => Q\Func::fromUnixtime(Q::n('ts')), 'FROM_UNIXTIME(ts)'], 'convertTz' => [fn () => Q\Func::convertTz(Q::n('d'), Q::string('+00:00'), Q::string('+02:00')), "CONVERT_TZ(d, '+00:00', '+02:00')"], @@ -87,30 +107,51 @@ // JSON 'jsonObject' => [fn () => Q\Func::jsonObject(Q::string('k'), Q::n('v')), "JSON_OBJECT('k', v)"], 'jsonArray' => [fn () => Q\Func::jsonArray(Q::int(1), Q::int(2)), 'JSON_ARRAY(1, 2)'], + 'jsonQuote' => [fn () => Q\Func::jsonQuote(Q::n('s')), 'JSON_QUOTE(s)'], 'jsonExtract' => [fn () => Q\Func::jsonExtract(Q::n('doc'), Q::string('$.a')), "JSON_EXTRACT(doc, '$.a')"], 'jsonContains 2-arg' => [fn () => Q\Func::jsonContains(Q::n('doc'), Q::string('1')), "JSON_CONTAINS(doc, '1')"], 'jsonContains 3-arg' => [fn () => Q\Func::jsonContains(Q::n('doc'), Q::string('1'), Q::string('$.a')), "JSON_CONTAINS(doc, '1', '$.a')"], 'jsonContainsPath' => [fn () => Q\Func::jsonContainsPath(Q::n('doc'), Q::string('one'), Q::string('$.a')), "JSON_CONTAINS_PATH(doc, 'one', '$.a')"], 'jsonKeys' => [fn () => Q\Func::jsonKeys(Q::n('doc')), 'JSON_KEYS(doc)'], + 'jsonSearch' => [fn () => Q\Func::jsonSearch(Q::n('doc'), Q::string('one'), Q::string('x')), "JSON_SEARCH(doc, 'one', 'x')"], 'jsonValue' => [fn () => Q\Func::jsonValue(Q::n('doc'), Q::string('$.a')), "JSON_VALUE(doc, '$.a')"], 'jsonSet' => [fn () => Q\Func::jsonSet(Q::n('doc'), Q::string('$.a'), Q::int(1)), "JSON_SET(doc, '$.a', 1)"], + 'jsonInsert' => [fn () => Q\Func::jsonInsert(Q::n('doc'), Q::string('$.a'), Q::int(1)), "JSON_INSERT(doc, '$.a', 1)"], + 'jsonReplace' => [fn () => Q\Func::jsonReplace(Q::n('doc'), Q::string('$.a'), Q::int(1)), "JSON_REPLACE(doc, '$.a', 1)"], 'jsonRemove' => [fn () => Q\Func::jsonRemove(Q::n('doc'), Q::string('$.a')), "JSON_REMOVE(doc, '$.a')"], + 'jsonArrayAppend' => [fn () => Q\Func::jsonArrayAppend(Q::n('doc'), Q::string('$'), Q::int(1)), "JSON_ARRAY_APPEND(doc, '$', 1)"], + 'jsonArrayInsert' => [fn () => Q\Func::jsonArrayInsert(Q::n('doc'), Q::string('$[0]'), Q::int(1)), "JSON_ARRAY_INSERT(doc, '$[0]', 1)"], 'jsonMergePatch' => [fn () => Q\Func::jsonMergePatch(Q::n('a'), Q::n('b')), 'JSON_MERGE_PATCH(a, b)'], + 'jsonMergePreserve' => [fn () => Q\Func::jsonMergePreserve(Q::n('a'), Q::n('b')), 'JSON_MERGE_PRESERVE(a, b)'], 'jsonType' => [fn () => Q\Func::jsonType(Q::n('v')), 'JSON_TYPE(v)'], + 'jsonDepth' => [fn () => Q\Func::jsonDepth(Q::n('doc')), 'JSON_DEPTH(doc)'], 'jsonLength' => [fn () => Q\Func::jsonLength(Q::n('doc')), 'JSON_LENGTH(doc)'], 'jsonValid' => [fn () => Q\Func::jsonValid(Q::n('v')), 'JSON_VALID(v)'], // Misc 'uuid' => [fn () => Q\Func::uuid(), 'UUID()'], 'uuidToBin' => [fn () => Q\Func::uuidToBin(Q::n('u')), 'UUID_TO_BIN(u)'], + 'binToUuid' => [fn () => Q\Func::binToUuid(Q::n('b')), 'BIN_TO_UUID(b)'], 'isUuid' => [fn () => Q\Func::isUuid(Q::n('u')), 'IS_UUID(u)'], + ]); - // Aggregates - 'jsonArrayAgg' => [fn () => Q\Func::jsonArrayAgg(Q::n('v')), 'JSON_ARRAYAGG(v)'], - 'jsonObjectAgg' => [fn () => Q\Func::jsonObjectAgg(Q::n('k'), Q::n('v')), 'JSON_OBJECTAGG(k, v)'], + it('renders aggregate functions', function (Exp $exp, string $sql) { + expect($exp)->toRenderSql($sql); + })->with([ + 'avg' => [fn () => Q\Func::avg(Q::n('v')), 'AVG(v)'], + 'count' => [fn () => Q\Func::count(Q::n('id')), 'COUNT(id)'], + 'sum' => [fn () => Q\Func::sum(Q::n('v')), 'SUM(v)'], + 'min' => [fn () => Q\Func::min(Q::n('v')), 'MIN(v)'], + 'max' => [fn () => Q\Func::max(Q::n('v')), 'MAX(v)'], + 'bitAnd' => [fn () => Q\Func::bitAnd(Q::n('flags')), 'BIT_AND(flags)'], 'bitOr' => [fn () => Q\Func::bitOr(Q::n('flags')), 'BIT_OR(flags)'], + 'bitXor' => [fn () => Q\Func::bitXor(Q::n('flags')), 'BIT_XOR(flags)'], 'stddevPop' => [fn () => Q\Func::stddevPop(Q::n('v')), 'STDDEV_POP(v)'], + 'stddevSamp' => [fn () => Q\Func::stddevSamp(Q::n('v')), 'STDDEV_SAMP(v)'], + 'varPop' => [fn () => Q\Func::varPop(Q::n('v')), 'VAR_POP(v)'], 'varSamp' => [fn () => Q\Func::varSamp(Q::n('v')), 'VAR_SAMP(v)'], + 'jsonArrayAgg' => [fn () => Q\Func::jsonArrayAgg(Q::n('v')), 'JSON_ARRAYAGG(v)'], + 'jsonObjectAgg' => [fn () => Q\Func::jsonObjectAgg(Q::n('k'), Q::n('v')), 'JSON_OBJECTAGG(k, v)'], ]); it('uses an aggregate with DISTINCT and as a window function', function () { @@ -118,4 +159,34 @@ expect(Q\Func::sum(Q::n('v'))->over()->partitionBy(Q::n('g'))) ->toRenderSql('SUM(v) OVER (PARTITION BY g)'); }); + + // DISTINCT is only grammatical on a subset of the aggregates; see the MySQL 8.4 + // reference at https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html. + it('renders DISTINCT on the aggregates that accept it', function (Exp $exp, string $sql) { + expect($exp)->toRenderSql($sql); + })->with([ + 'avg' => [fn () => Q\Func::avg(Q::n('v'))->distinct(), 'AVG(DISTINCT v)'], + 'count' => [fn () => Q\Func::count(Q::n('id'))->distinct(), 'COUNT(DISTINCT id)'], + 'max' => [fn () => Q\Func::max(Q::n('v'))->distinct(), 'MAX(DISTINCT v)'], + 'min' => [fn () => Q\Func::min(Q::n('v'))->distinct(), 'MIN(DISTINCT v)'], + 'sum' => [fn () => Q\Func::sum(Q::n('v'))->distinct(), 'SUM(DISTINCT v)'], + ]); + + it('rejects DISTINCT on aggregates that do not support it', function (Exp $exp) { + expect(static fn () => Q::build($exp)->toSql()) + ->toThrow(QueryBuilderException::class, 'does not support DISTINCT'); + })->with([ + 'jsonArrayAgg' => [fn () => Q\Func::jsonArrayAgg(Q::n('v'))->distinct()], + 'jsonObjectAgg' => [fn () => Q\Func::jsonObjectAgg(Q::n('k'), Q::n('v'))->distinct()], + 'bitOr' => [fn () => Q\Func::bitOr(Q::n('flags'))->distinct()], + 'stddevPop' => [fn () => Q\Func::stddevPop(Q::n('v'))->distinct()], + 'varSamp' => [fn () => Q\Func::varSamp(Q::n('v'))->distinct()], + 'median' => [fn () => Q\Func::median(Q::n('v'))->distinct()], + ]); + + it('does not flag an unsupported DISTINCT when validation is disabled', function () { + [$sql] = Q::build(Q\Func::jsonArrayAgg(Q::n('v'))->distinct())->withoutValidation()->toSql(); + + expect($sql)->toBe('JSON_ARRAYAGG(DISTINCT v)'); + }); }); diff --git a/tests/MySQL/Q/SelectJsonShapeTest.php b/tests/MySQL/Q/SelectJsonShapeTest.php new file mode 100644 index 0000000..9fa35f8 --- /dev/null +++ b/tests/MySQL/Q/SelectJsonShapeTest.php @@ -0,0 +1,474 @@ +as( + Q::select(Q::n('authors.author_id')) + ->select( + Q\Func::jsonObject( + Q::string('id'), Q::n('authors.author_id'), + Q::string('name'), Q::n('authors.name'), + ), + )->as('json') + ->from(Q::n('authors')), + ) + ->select( + Q::n('posts.post_id'), + Q\Func::jsonObject( + Q::string('title'), Q::n('posts.title'), + Q::string('author'), Q::n('author_json.json'), + ), + ) + ->from(Q::n('posts')) + ->leftJoin(Q::n('author_json'))->on(Q::n('posts.author_id')->eq(Q::n('author_json.author_id'))) + ->where(Q::n('posts.category')->eq(Q::arg($category))) + ->orderBy(Q::n('posts.created_at'))->desc(); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + WITH + author_json AS ( + SELECT + authors.author_id, + JSON_OBJECT('id', authors.author_id, 'name', authors.name) AS json + FROM + authors + ) + SELECT + posts.post_id, + JSON_OBJECT('title', posts.title, 'author', author_json.json) + FROM + posts + LEFT JOIN author_json ON posts.author_id = author_json.author_id + WHERE + posts.category = ? + ORDER BY + posts.created_at DESC + SQL, [$category]); + }); + }); + + describe('JSON_ARRAYAGG of child objects', function () { + it('rolls up child rows into a JSON array with a LEFT JOIN and GROUP BY', function () { + $q = Q::select( + Q::n('u.id'), + Q::n('u.name'), + Q\Func::jsonArrayAgg( + Q\Func::jsonObject( + Q::string('id'), Q::n('o.id'), + Q::string('total'), Q::n('o.total'), + ), + ), + )->as('orders') + ->from(Q::n('users'))->as('u') + ->leftJoin(Q::n('orders'))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) + ->groupBy(Q::n('u.id'), Q::n('u.name')); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + SELECT + u.id, + u.name, + JSON_ARRAYAGG(JSON_OBJECT('id', o.id, 'total', o.total)) AS orders + FROM + users AS u + LEFT JOIN orders AS o ON o.user_id = u.id + GROUP BY + u.id, u.name + SQL, null); + }); + + it('collects child rows via a correlated subquery', function () { + $q = Q::select( + Q::n('p.id'), + Q::n('p.name'), + Q::select( + Q::coalesce( + Q\Func::jsonArrayAgg( + Q\Func::jsonObject( + Q::string('id'), Q::n('c.id'), + Q::string('name'), Q::n('c.name'), + ), + ), + Q\Func::jsonArray(), + ), + ) + ->from(Q::n('child'))->as('c') + ->where(Q::n('c.parent_id')->eq(Q::n('p.id'))), + )->as('children') + ->from(Q::n('parent'))->as('p'); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + SELECT + p.id, + p.name, + ( + SELECT + COALESCE(JSON_ARRAYAGG(JSON_OBJECT('id', c.id, 'name', c.name)), JSON_ARRAY()) + FROM + child AS c + WHERE + c.parent_id = p.id + ) AS children + FROM + parent AS p + SQL, null); + }); + + // JSON_ARRAYAGG returns NULL (not []) when a LEFT JOIN produces no child rows, + // so wrap it in COALESCE(..., JSON_ARRAY()) to normalise the empty case. + it('forces an empty JSON array instead of NULL with COALESCE', function () { + $q = Q::select( + Q::n('a.author_id'), + Q::coalesce( + Q\Func::jsonArrayAgg( + Q\Func::jsonObject( + Q::string('ID'), Q::n('b.book_id'), + Q::string('Title'), Q::n('b.title'), + Q::string('PublicationYear'), Q::n('b.publication_year'), + ), + ), + Q\Func::jsonArray(), + ), + )->as('books') + ->from(Q::n('authors'))->as('a') + ->leftJoin(Q::n('books'))->as('b')->on(Q::n('b.author_id')->eq(Q::n('a.author_id'))) + ->groupBy(Q::n('a.author_id')); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + SELECT + a.author_id, + COALESCE( + JSON_ARRAYAGG( + JSON_OBJECT('ID', b.book_id, 'Title', b.title, 'PublicationYear', b.publication_year) + ), + JSON_ARRAY() + ) AS books + FROM + authors AS a + LEFT JOIN books AS b ON b.author_id = a.author_id + GROUP BY + a.author_id + SQL, null); + }); + }); + + describe('JSON_OBJECTAGG keyed by a column', function () { + it('builds an object keyed by child id', function () { + $q = Q::select( + Q::n('p.id'), + Q::n('p.name'), + Q\Func::jsonObjectAgg( + Q::n('c.id'), + Q\Func::jsonObject( + Q::string('id'), Q::n('c.id'), + Q::string('name'), Q::n('c.name'), + ), + ), + )->as('children_by_id') + ->from(Q::n('parent'))->as('p') + ->leftJoin(Q::n('child'))->as('c')->on(Q::n('c.parent_id')->eq(Q::n('p.id'))) + ->groupBy(Q::n('p.id'), Q::n('p.name')); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + SELECT + p.id, + p.name, + JSON_OBJECTAGG(c.id, JSON_OBJECT('id', c.id, 'name', c.name)) AS children_by_id + FROM + parent AS p + LEFT JOIN child AS c ON c.parent_id = p.id + GROUP BY + p.id, p.name + SQL, null); + }); + }); + + describe('complex nested JSON', function () { + it('with CTEs', function () { + $q = Q::with('author_books')->as( + Q::select(Q::n('author_id')) + ->select( + Q::coalesce( + Q\Func::jsonArrayAgg(Q\Func::jsonObject( + Q::string('Title'), Q::n('books.title'), + Q::string('AuthorID'), Q::n('books.author_id'), + Q::string('PublicationYear'), Q::n('books.publication_year'), + Q::string('ID'), Q::n('books.book_id'), + )), + Q\Func::jsonArray(), + ), + )->as('books') + ->from(Q::n('books')) + ->groupBy(Q::n('author_id')), + ) + ->with('book_genres')->as( + Q::select(Q::n('book_id')) + ->select( + Q::coalesce( + Q\Func::jsonArrayAgg(Q\Func::jsonObject( + Q::string('GenreID'), Q::n('genres.genre_id'), + Q::string('Name'), Q::n('genres.name'), + )), + Q\Func::jsonArray(), + ), + )->as('genres') + ->from(Q::n('book_genre')) + ->join(Q::n('genres'))->using('genre_id') + ->groupBy(Q::n('book_id')), + ) + ->select( + Q\Func::jsonObject( + Q::string('Title'), Q::n('books.title'), + Q::string('AuthorID'), Q::n('books.author_id'), + Q::string('PublicationYear'), Q::n('books.publication_year'), + Q::string('ID'), Q::n('books.book_id'), + Q::string('Author'), Q\Func::jsonObject( + Q::string('AuthorID'), Q::n('authors.author_id'), + Q::string('Name'), Q::n('authors.name'), + Q::string('Books'), Q::n('author_books.books'), + ), + Q::string('Genres'), Q::n('book_genres.genres'), + ), + ) + ->from(Q::n('books')) + ->leftJoin(Q::n('authors'))->using('author_id') + ->leftJoin(Q::n('author_books'))->using('author_id') + ->leftJoin(Q::n('book_genres'))->using('book_id') + ->where(Q::n('books.book_id')->eq(Q::arg(2))); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + WITH + author_books AS ( + SELECT + author_id, + COALESCE( + JSON_ARRAYAGG( + JSON_OBJECT( + 'Title', books.title, + 'AuthorID', books.author_id, + 'PublicationYear', books.publication_year, + 'ID', books.book_id + ) + ), + JSON_ARRAY() + ) AS books + FROM + books + GROUP BY + author_id + ), + book_genres AS ( + SELECT + book_id, + COALESCE( + JSON_ARRAYAGG(JSON_OBJECT('GenreID', genres.genre_id, 'Name', genres.name)), + JSON_ARRAY() + ) AS genres + FROM + book_genre + JOIN genres USING (genre_id) + GROUP BY + book_id + ) + SELECT + JSON_OBJECT( + 'Title', books.title, + 'AuthorID', books.author_id, + 'PublicationYear', books.publication_year, + 'ID', books.book_id, + 'Author', JSON_OBJECT( + 'AuthorID', authors.author_id, + 'Name', authors.name, + 'Books', author_books.books + ), + 'Genres', book_genres.genres + ) + FROM + books + LEFT JOIN authors USING (author_id) + LEFT JOIN author_books USING (author_id) + LEFT JOIN book_genres USING (book_id) + WHERE + books.book_id = ? + SQL, [2]); + }); + + it('with correlated subselects', function () { + $q = Q::select( + Q\Func::jsonObject( + Q::string('Title'), Q::n('books.title'), + Q::string('AuthorID'), Q::n('books.author_id'), + Q::string('PublicationYear'), Q::n('books.publication_year'), + Q::string('ID'), Q::n('books.book_id'), + Q::string('Author'), Q::select( + Q\Func::jsonObject( + Q::string('AuthorID'), Q::n('authors.author_id'), + Q::string('Name'), Q::n('authors.name'), + Q::string('Books'), Q::select( + Q::coalesce( + Q\Func::jsonArrayAgg(Q\Func::jsonObject( + Q::string('Title'), Q::n('books.title'), + Q::string('ID'), Q::n('books.book_id'), + )), + Q\Func::jsonArray(), + ), + ) + ->from(Q::n('books')) + ->where(Q::n('books.author_id')->eq(Q::n('authors.author_id'))), + ), + ) + ->from(Q::n('authors')) + ->where(Q::n('authors.author_id')->eq(Q::n('books.author_id'))), + Q::string('Genres'), Q::select( + Q::coalesce( + Q\Func::jsonArrayAgg(Q\Func::jsonObject( + Q::string('GenreID'), Q::n('genres.genre_id'), + Q::string('Name'), Q::n('genres.name'), + )), + Q\Func::jsonArray(), + ), + ) + ->from(Q::n('book_genre')) + ->leftJoin(Q::n('genres'))->using('genre_id') + ->where(Q::n('book_genre.book_id')->eq(Q::n('books.book_id'))), + ), + ) + ->from(Q::n('books')) + ->where(Q::n('books.book_id')->eq(Q::arg(2))); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + SELECT + JSON_OBJECT( + 'Title', books.title, + 'AuthorID', books.author_id, + 'PublicationYear', books.publication_year, + 'ID', books.book_id, + 'Author', ( + SELECT + JSON_OBJECT( + 'AuthorID', authors.author_id, + 'Name', authors.name, + 'Books', ( + SELECT + COALESCE( + JSON_ARRAYAGG(JSON_OBJECT('Title', books.title, 'ID', books.book_id)), + JSON_ARRAY() + ) + FROM + books + WHERE + books.author_id = authors.author_id + ) + ) + FROM + authors + WHERE + authors.author_id = books.author_id + ), + 'Genres', ( + SELECT + COALESCE( + JSON_ARRAYAGG(JSON_OBJECT('GenreID', genres.genre_id, 'Name', genres.name)), + JSON_ARRAY() + ) + FROM + book_genre + LEFT JOIN genres USING (genre_id) + WHERE + book_genre.book_id = books.book_id + ) + ) + FROM + books + WHERE + books.book_id = ? + SQL, [2]); + }); + + it('without nested relations', function () { + $q = Q::select( + Q\Func::jsonObject( + Q::string('Title'), Q::n('books.title'), + Q::string('AuthorID'), Q::n('books.author_id'), + Q::string('PublicationYear'), Q::n('books.publication_year'), + Q::string('ID'), Q::n('books.book_id'), + ), + ) + ->from(Q::n('books')) + ->where(Q::n('books.book_id')->eq(Q::arg(2))); + + // language=MySQL + expect($q)->toRenderSql(<<<'SQL' + SELECT + JSON_OBJECT( + 'Title', books.title, + 'AuthorID', books.author_id, + 'PublicationYear', books.publication_year, + 'ID', books.book_id + ) + FROM + books + WHERE + books.book_id = ? + SQL, [2]); + }); + + it('builds the JSON object conditionally in application code', function () { + // JSON_OBJECT takes a flat key/value list, so a JSON shape is assembled + // by composing the argument array before the call — the same immutable + // building the fluent API relies on. + $buildBookJson = static function (bool $includeAuthor): FuncExp { + $props = [ + Q::string('Title'), Q::n('books.title'), + Q::string('ID'), Q::n('books.book_id'), + ]; + if ($includeAuthor) { + $props[] = Q::string('AuthorID'); + $props[] = Q::n('books.author_id'); + } + + return Q\Func::jsonObject(...$props); + }; + + $with = Q::select($buildBookJson(true))->from(Q::n('books')); + $without = Q::select($buildBookJson(false))->from(Q::n('books')); + + // language=MySQL + expect($with)->toRenderSql(<<<'SQL' + SELECT + JSON_OBJECT('Title', books.title, 'ID', books.book_id, 'AuthorID', books.author_id) + FROM + books + SQL, null); + + // language=MySQL + expect($without)->toRenderSql(<<<'SQL' + SELECT + JSON_OBJECT('Title', books.title, 'ID', books.book_id) + FROM + books + SQL, null); + }); + }); +}); From 1abf7d4b625fb395e25ac80aa325a40393dd4263 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 16:32:03 +0200 Subject: [PATCH 38/40] =?UTF-8?q?wip:=20broaden=20test=20coverage=20across?= =?UTF-8?q?=20MySQL=20+=20PostgreSQL=20builders=20(=E2=86=92=20~98.6%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for previously-uncovered API surface: expression operators, function facades, join/lateral/set-op/CTE/window builder methods, DML alias & RETURNING paths, MySQL named-arg binding (new ArgsTest), and validation branches (empty CASE, invalid identifier/type, values+query / ON CONFLICT constraint+targets). Regroup the MySQL SELECT test nest-by-clause to match PostgreSQL. Document the coverage tooling and intentionally-uncovered set in AGENTS.md, and add the xdebug extension to devbox so coverage runs out of the box. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + AGENTS.md | 18 + devbox.json | 7 +- devbox.lock | 48 +++ tests/MySQL/Q/ArgsTest.php | 73 ++++ tests/MySQL/Q/CaseTest.php | 12 + tests/MySQL/Q/DeleteBuilderTest.php | 38 ++ tests/MySQL/Q/DialectDifferencesTest.php | 32 ++ tests/MySQL/Q/ExpressionsTest.php | 24 ++ tests/MySQL/Q/FunctionsSpecialShapeTest.php | 6 + tests/MySQL/Q/InsertBuilderTest.php | 14 + tests/MySQL/Q/ReplaceBuilderTest.php | 14 + tests/MySQL/Q/SelectBuilderTest.php | 353 ++++++++++++------ tests/MySQL/Q/UpdateBuilderTest.php | 40 ++ tests/MySQL/Q/WindowFuncBuilderTest.php | 44 ++- tests/PostgreSQL/Q/AggregateFunctionsTest.php | 1 + tests/PostgreSQL/Q/DeleteBuilderTest.php | 36 ++ tests/PostgreSQL/Q/FunctionsMatchingTest.php | 17 + tests/PostgreSQL/Q/InsertBuilderTest.php | 41 ++ tests/PostgreSQL/Q/OpTest.php | 30 ++ tests/PostgreSQL/Q/SelectBuilderTest.php | 95 +++++ tests/PostgreSQL/Q/UpdateBuilderTest.php | 25 ++ tests/PostgreSQL/Q/WindowFuncBuilderTest.php | 26 ++ 23 files changed, 880 insertions(+), 115 deletions(-) create mode 100644 tests/MySQL/Q/ArgsTest.php diff --git a/.gitignore b/.gitignore index 178d88b..fe68dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ vendor .phpunit.cache +tmp diff --git a/AGENTS.md b/AGENTS.md index 8084628..a243a08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,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=` 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`). diff --git a/devbox.json b/devbox.json index bb4582f..aae6b67 100644 --- a/devbox.json +++ b/devbox.json @@ -1,6 +1,9 @@ { - "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.3/.schema/devbox.schema.json", - "packages": ["php@8.4"], + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.3/.schema/devbox.schema.json", + "packages": [ + "php@8.4", + "php84Extensions.xdebug@latest" + ], "shell": { "init_hook": [ "echo 'Welcome to devbox!' > /dev/null" diff --git a/devbox.lock b/devbox.lock index 87f1e79..afaaa8f 100644 --- a/devbox.lock +++ b/devbox.lock @@ -5,6 +5,54 @@ "last_modified": "2026-06-16T10:57:20Z", "resolved": "github:NixOS/nixpkgs/3e41b24abd260e8f71dbe2f5737d24122f972158?lastModified=1781607440&narHash=sha256-rxO%2Buc%2FKFbSJp%2BpgyXRuAX6QlG9hJdnt0BXpEQRXY%2BU%3D" }, + "php84Extensions.xdebug@latest": { + "last_modified": "2026-06-27T07:37:20Z", + "resolved": "github:NixOS/nixpkgs/3d46470bb3030020f7e1361f33514854f5bfa86d#php84Extensions.xdebug", + "source": "devbox-search", + "version": "3.5.3", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/gxx3xmg8pf0w65kqgl0csdb04sd7m0pk-php-xdebug-3.5.3", + "default": true + } + ], + "store_path": "/nix/store/gxx3xmg8pf0w65kqgl0csdb04sd7m0pk-php-xdebug-3.5.3" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/f3nllmrpxv9nqy076cy4yjkpkfwdgr37-php-xdebug-3.5.3", + "default": true + } + ], + "store_path": "/nix/store/f3nllmrpxv9nqy076cy4yjkpkfwdgr37-php-xdebug-3.5.3" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4mhr08izcz21qrzhai8vhmdvppm1v5p3-php-xdebug-3.5.3", + "default": true + } + ], + "store_path": "/nix/store/4mhr08izcz21qrzhai8vhmdvppm1v5p3-php-xdebug-3.5.3" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qrdc75zsflb8rhd84kfkiwkmc7dm569x-php-xdebug-3.5.3", + "default": true + } + ], + "store_path": "/nix/store/qrdc75zsflb8rhd84kfkiwkmc7dm569x-php-xdebug-3.5.3" + } + } + }, "php@8.4": { "last_modified": "2026-06-08T15:01:26Z", "plugin_version": "0.0.3", diff --git a/tests/MySQL/Q/ArgsTest.php b/tests/MySQL/Q/ArgsTest.php new file mode 100644 index 0000000..6f06912 --- /dev/null +++ b/tests/MySQL/Q/ArgsTest.php @@ -0,0 +1,73 @@ +from(Q::n('employees'))->where(Q::n('id')->eq(Q::bind('id'))); + + [$sql, $args] = Q::build($q)->withNamedArgs(['id' => 42])->toSql(); + + expect($sql)->toBe('SELECT * FROM employees WHERE id = ?'); + expect($args)->toBe([42]); + }); + + // Positional '?' placeholders are not reusable, so each occurrence of a name + // emits its own placeholder, each filled with the same bound value. + it('emits a separate placeholder per occurrence of a re-used name', function () { + $q = Q::select(Q::n('*')) + ->from(Q::n('employees')) + ->where(Q::or( + Q::n('firstname')->like(Q::bind('search')), + Q::n('lastname')->like(Q::bind('search')), + )); + + [$sql, $args] = Q::build($q)->withNamedArgs(['search' => 'Jo%'])->toSql(); + + expect($sql)->toBe('SELECT * FROM employees WHERE firstname LIKE ? OR lastname LIKE ?'); + expect($args)->toBe(['Jo%', 'Jo%']); + }); + + it('binds multiple named args', function () { + $q = Q::select(Q::n('*')) + ->from(Q::n('employees')) + ->where(Q::and( + Q::or( + Q::n('firstname')->like(Q::bind('search')), + Q::n('lastname')->like(Q::bind('search')), + ), + Q::n('active')->eq(Q::bind('active')), + )); + + [$sql, $args] = Q::build($q)->withNamedArgs(['search' => 'Jo%', 'active' => true])->toSql(); + + expect($sql)->toBe('SELECT * FROM employees WHERE (firstname LIKE ? OR lastname LIKE ?) AND active = ?'); + expect($args)->toBe(['Jo%', 'Jo%', true]); + }); + + it('errors on a missing named arg', function () { + $q = Q::select(Q::n('*'))->from(Q::n('employees'))->where(Q::n('id')->eq(Q::bind('id'))); + + expect(static fn () => Q::build($q)->toSql())->toThrow(QueryBuilderException::class, 'missing named argument "id"'); + }); + + it('mixes arg and bind', function () { + $q = Q::select(Q::n('*')) + ->from(Q::n('employees')) + ->where(Q::and( + Q::or( + Q::n('firstname')->like(Q::bind('search')), + Q::n('lastname')->like(Q::bind('search')), + ), + Q::n('active')->eq(Q::arg(true)), + )); + + [$sql, $args] = Q::build($q)->withNamedArgs(['search' => 'Jo%'])->toSql(); + + expect($sql)->toBe('SELECT * FROM employees WHERE (firstname LIKE ? OR lastname LIKE ?) AND active = ?'); + expect($args)->toBe(['Jo%', 'Jo%', true]); + }); +}); diff --git a/tests/MySQL/Q/CaseTest.php b/tests/MySQL/Q/CaseTest.php index daa351b..c5d1949 100644 --- a/tests/MySQL/Q/CaseTest.php +++ b/tests/MySQL/Q/CaseTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilderException; use Flowpack\QueryObjectBuilder\MySQL\Q; describe('MySQL CASE', function () { @@ -23,4 +24,15 @@ expect($case)->toRenderSql("CASE status WHEN 1 THEN 'active' ELSE 'inactive' END"); }); + + it('rejects a CASE with no conditions, unless validation is disabled', function () { + $case = Q::case()->end(); + + expect(static fn () => Q::build($case)->toSql()) + ->toThrow(QueryBuilderException::class, 'case: no conditions given'); + + // The check is advisory: withoutValidation() still emits the (degenerate) SQL. + [$sql] = Q::build($case)->withoutValidation()->toSql(); + expect($sql)->toBe('CASE END'); + }); }); diff --git a/tests/MySQL/Q/DeleteBuilderTest.php b/tests/MySQL/Q/DeleteBuilderTest.php index 9628d63..fd37382 100644 --- a/tests/MySQL/Q/DeleteBuilderTest.php +++ b/tests/MySQL/Q/DeleteBuilderTest.php @@ -27,6 +27,22 @@ )->toRenderSql("DELETE FROM somelog WHERE user = 'jcole' ORDER BY timestamp_column LIMIT 1"); }); + it('orders a single-table delete ascending and descending', function () { + expect( + Q::deleteFrom(Q::n('somelog'))->orderBy(Q::n('ts'))->desc()->limit(Q::int(1)), + )->toRenderSql('DELETE FROM somelog ORDER BY ts DESC LIMIT 1'); + + expect( + Q::deleteFrom(Q::n('somelog'))->orderBy(Q::n('ts'))->asc()->limit(Q::int(1)), + )->toRenderSql('DELETE FROM somelog ORDER BY ts ASC LIMIT 1'); + }); + + it('orders a single-table delete by multiple columns', function () { + expect( + Q::deleteFrom(Q::n('somelog'))->orderBy(Q::n('a'))->orderBy(Q::n('b'))->desc()->limit(Q::int(1)), + )->toRenderSql('DELETE FROM somelog ORDER BY a,b DESC LIMIT 1'); + }); + it('renders a multi-table delete with a LEFT JOIN', function () { expect( Q::deleteFrom(Q::n('t1')) @@ -43,6 +59,28 @@ )->toRenderSql('DELETE a1.* FROM t1 AS a1 JOIN t2 AS a2 WHERE a1.id = a2.id'); }); + it('renders a multi-table delete with RIGHT and CROSS JOIN', function () { + expect( + Q::deleteFrom(Q::n('t1')) + ->rightJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->where(Q::n('t2.active')->eq(Q::int(0))), + )->toRenderSql('DELETE t1.* FROM t1 RIGHT JOIN t2 ON t1.id = t2.id WHERE t2.active = 0'); + + expect( + Q::deleteFrom(Q::n('t1')) + ->crossJoin(Q::n('t2')) + ->where(Q::n('t1.id')->eq(Q::n('t2.id'))), + )->toRenderSql('DELETE t1.* FROM t1 CROSS JOIN t2 WHERE t1.id = t2.id'); + }); + + it('renders a multi-table delete joining USING', function () { + expect( + Q::deleteFrom(Q::n('t1')) + ->join(Q::n('t2'))->using('id') + ->where(Q::n('t2.active')->eq(Q::int(0))), + )->toRenderSql('DELETE t1.* FROM t1 JOIN t2 USING (id) WHERE t2.active = 0'); + }); + it('rejects ORDER BY / LIMIT on a multi-table delete', function () { $q = Q::deleteFrom(Q::n('t1')) ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) diff --git a/tests/MySQL/Q/DialectDifferencesTest.php b/tests/MySQL/Q/DialectDifferencesTest.php index 0b47b99..9ec348e 100644 --- a/tests/MySQL/Q/DialectDifferencesTest.php +++ b/tests/MySQL/Q/DialectDifferencesTest.php @@ -140,6 +140,20 @@ expect($q)->toRenderSql('REPLACE INTO t (a) VALUES (?) RETURNING id', [1], $mariaDb); expect($q)->toFailValidationFor($mysql, 'RETURNING requires MariaDB'); }); + + it('aliases a returned expression via as()', function () use ($mariaDb) { + expect( + Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::arg(1))->returning(Q::n('id'))->as('key'), + )->toRenderSql('INSERT INTO t (a) VALUES (?) RETURNING id AS key', [1], $mariaDb); + + expect( + Q::deleteFrom(Q::n('t'))->where(Q::n('id')->eq(Q::arg(1)))->returning(Q::n('id'))->as('key'), + )->toRenderSql('DELETE FROM t WHERE id = ? RETURNING id AS key', [1], $mariaDb); + + expect( + Q::replaceInto(Q::n('t'))->columnNames('a')->values(Q::arg(1))->returning(Q::n('id'))->as('key'), + )->toRenderSql('REPLACE INTO t (a) VALUES (?) RETURNING id AS key', [1], $mariaDb); + }); }); describe('B2 LATERAL (MySQL only)', function () { @@ -211,6 +225,24 @@ expect($q)->toFailValidationFor(Target::mysql(), 'PERCENTILE_CONT requires MariaDB'); }); + it('renders a bare ORDER BY (without WITHIN GROUP) with multiple keys', function () { + // The builder also supports the direct NAME(args ORDER BY ...) form. + $q = Q\Func::percentileCont(Q::float(0.5))->orderBy(Q::n('a'))->orderBy(Q::n('b')); + + expect($q)->toRenderSql('PERCENTILE_CONT(0.5 ORDER BY a,b)', null, Target::mariaDb()); + }); + + it('renders PERCENTILE_CONT with an ascending WITHIN GROUP order', function () { + $q = Q\Func::percentileCont(Q::float(0.5))->withinGroup()->orderBy(Q::n('salary'))->asc() + ->over()->partitionBy(Q::n('dept')); + + expect($q)->toRenderSql( + 'PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary ASC) OVER (PARTITION BY dept)', + null, + Target::mariaDb(), + ); + }); + it('renders PERCENTILE_DISC with a descending WITHIN GROUP order', function () { $q = Q\Func::percentileDisc(Q::float(0.9))->withinGroup()->orderBy(Q::n('salary'))->desc() ->over()->partitionBy(Q::n('dept')); diff --git a/tests/MySQL/Q/ExpressionsTest.php b/tests/MySQL/Q/ExpressionsTest.php index cf2e418..5c0d4a4 100644 --- a/tests/MySQL/Q/ExpressionsTest.php +++ b/tests/MySQL/Q/ExpressionsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilderException; use Flowpack\QueryObjectBuilder\MySQL\Q; describe('MySQL expressions', function () { @@ -62,6 +63,13 @@ expect(Q::neg(Q::n('a'))->plus(Q::int(1)))->toRenderSql('- a + 1'); }); + it('parenthesizes the right operand by precedence', function () { + // The right operand binds looser, so it is parenthesized. + expect(Q::n('a')->mult(Q::n('b')->plus(Q::n('c'))))->toRenderSql('a * (b + c)'); + // Same precedence but a different operator also forces parentheses. + expect(Q::n('a')->plus(Q::n('b')->minus(Q::n('c'))))->toRenderSql('a + (b - c)'); + }); + it('renders JSON path extraction operators', function () { expect(Q::n('doc')->jsonExtract(Q::string('$.name')))->toRenderSql("doc -> '$.name'"); expect(Q::n('doc')->jsonExtractText(Q::string('$.name')))->toRenderSql("doc ->> '$.name'"); @@ -128,4 +136,20 @@ expect(Q::n('x')->gt(Q::all(Q::n('vals'))))->toRenderSql('x > ALL (vals)'); }); + + it('rejects an invalid identifier, unless validation is disabled', function () { + expect(static fn () => Q::build(Q::n('foo bar'))->toSql()) + ->toThrow(QueryBuilderException::class, 'identifier: invalid: foo bar'); + + [$sql] = Q::build(Q::n('foo bar'))->withoutValidation()->toSql(); + expect($sql)->toBe('foo bar'); + }); + + it('rejects an invalid cast type, unless validation is disabled', function () { + expect(static fn () => Q::build(Q::cast(Q::n('a'), 'NONESUCH'))->toSql()) + ->toThrow(QueryBuilderException::class, 'type: invalid: NONESUCH'); + + [$sql] = Q::build(Q::cast(Q::n('a'), 'NONESUCH'))->withoutValidation()->toSql(); + expect($sql)->toBe('CAST(a AS NONESUCH)'); + }); }); diff --git a/tests/MySQL/Q/FunctionsSpecialShapeTest.php b/tests/MySQL/Q/FunctionsSpecialShapeTest.php index e1d3803..d89c092 100644 --- a/tests/MySQL/Q/FunctionsSpecialShapeTest.php +++ b/tests/MySQL/Q/FunctionsSpecialShapeTest.php @@ -18,6 +18,12 @@ ->separator(', '), )->toRenderSql("GROUP_CONCAT(DISTINCT name ORDER BY name DESC SEPARATOR ', ')"); }); + + it('orders ascending', function () { + expect( + Q\Func::groupConcat(Q::n('name'))->orderBy(Q::n('name'))->asc(), + )->toRenderSql('GROUP_CONCAT(name ORDER BY name ASC)'); + }); }); describe('EXTRACT', function () { diff --git a/tests/MySQL/Q/InsertBuilderTest.php b/tests/MySQL/Q/InsertBuilderTest.php index 9adfd4f..9bcf89d 100644 --- a/tests/MySQL/Q/InsertBuilderTest.php +++ b/tests/MySQL/Q/InsertBuilderTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilderException; use Flowpack\QueryObjectBuilder\MySQL\Q; describe('MySQL INSERT', function () { @@ -58,6 +59,19 @@ )->toRenderSql('INSERT INTO t () VALUES ()'); }); + it('inserts values without column names', function () { + expect( + Q::insertInto(Q::n('t'))->values(Q::int(1), Q::int(2)), + )->toRenderSql('INSERT INTO t VALUES (1,2)'); + }); + + it('rejects setting both values and a query', function () { + $q = Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::int(1))->query(Q::select(Q::int(2))); + + expect(static fn () => Q::build($q)->toSql()) + ->toThrow(QueryBuilderException::class, 'insert: cannot set both values and query'); + }); + it('renders ON DUPLICATE KEY UPDATE with the AS new row alias', function () { expect( Q::insertInto(Q::n('t')) diff --git a/tests/MySQL/Q/ReplaceBuilderTest.php b/tests/MySQL/Q/ReplaceBuilderTest.php index 8ba1abc..4605407 100644 --- a/tests/MySQL/Q/ReplaceBuilderTest.php +++ b/tests/MySQL/Q/ReplaceBuilderTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilderException; use Flowpack\QueryObjectBuilder\MySQL\Q; describe('MySQL REPLACE', function () { @@ -49,4 +50,17 @@ Q::replaceInto(Q::n('t'))->defaultValues(), )->toRenderSql('REPLACE INTO t () VALUES ()'); }); + + it('replaces values without column names', function () { + expect( + Q::replaceInto(Q::n('t'))->values(Q::int(1), Q::int(2)), + )->toRenderSql('REPLACE INTO t VALUES (1,2)'); + }); + + it('rejects setting both values and a query', function () { + $q = Q::replaceInto(Q::n('t'))->columnNames('a')->values(Q::int(1))->query(Q::select(Q::int(2))); + + expect(static fn () => Q::build($q)->toSql()) + ->toThrow(QueryBuilderException::class, 'replace: cannot set both values and query'); + }); }); diff --git a/tests/MySQL/Q/SelectBuilderTest.php b/tests/MySQL/Q/SelectBuilderTest.php index 580d348..edc1990 100644 --- a/tests/MySQL/Q/SelectBuilderTest.php +++ b/tests/MySQL/Q/SelectBuilderTest.php @@ -5,144 +5,273 @@ use Flowpack\QueryObjectBuilder\MySQL\Q; describe('MySQL SELECT', function () { - it('renders a basic select with where and bound args', function () { - expect( - Q::select(Q::n('id'), Q::n('email')) - ->from(Q::n('orders')) - ->where(Q::n('id')->eq(Q::arg(1))) - )->toRenderSql('SELECT id,email FROM orders WHERE id = ?', [1]); - - // A reserved keyword used as an identifier is backtick-quoted. - expect(Q::select(Q::n('id'))->from(Q::n('order'))) - ->toRenderSql('SELECT id FROM `order`'); - }); + describe('basics', function () { + it('renders a basic select with where and bound args', function () { + expect( + Q::select(Q::n('id'), Q::n('email')) + ->from(Q::n('orders')) + ->where(Q::n('id')->eq(Q::arg(1))) + )->toRenderSql('SELECT id,email FROM orders WHERE id = ?', [1]); - it('aliases select expressions and from items', function () { - expect( - Q::select(Q::n('u.id'))->as('user_id') - ->from(Q::n('users'))->as('u') - )->toRenderSql('SELECT u.id AS user_id FROM users AS u'); - }); + // A reserved keyword used as an identifier is backtick-quoted. + expect(Q::select(Q::n('id'))->from(Q::n('order'))) + ->toRenderSql('SELECT id FROM `order`'); + }); - it('renders DISTINCT', function () { - expect( - Q::select(Q::n('country'))->distinct()->from(Q::n('users')) - )->toRenderSql('SELECT DISTINCT country FROM users'); - }); + it('aliases select expressions and from items', function () { + expect( + Q::select(Q::n('u.id'))->as('user_id') + ->from(Q::n('users'))->as('u') + )->toRenderSql('SELECT u.id AS user_id FROM users AS u'); + }); - it('renders joins with ON and USING', function () { - expect( - Q::select(Q::n('*')) - ->from(Q::n('users'))->as('u') - ->leftJoin(Q::n('orders'))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) - )->toRenderSql('SELECT * FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id'); + it('aliases derived-table columns', function () { + expect( + Q::select(Q::n('t.a')) + ->from(Q::select(Q::n('x'))->from(Q::n('src')))->as('t')->columnAliases('a') + )->toRenderSql('SELECT t.a FROM (SELECT x FROM src) AS t (a)'); + }); - expect( - Q::select(Q::n('*'))->from(Q::n('a'))->join(Q::n('b'))->using('id') - )->toRenderSql('SELECT * FROM a JOIN b USING (id)'); + it('renders DISTINCT', function () { + expect( + Q::select(Q::n('country'))->distinct()->from(Q::n('users')) + )->toRenderSql('SELECT DISTINCT country FROM users'); + }); }); - it('renders GROUP BY with rollup, HAVING and ORDER BY', function () { - expect( - Q::select(Q::n('country')) - ->from(Q::n('users')) - ->groupBy(Q::n('country'))->withRollup() - ->having(Q::n('country')->isNotNull()) - ->orderBy(Q::n('country'))->desc() - )->toRenderSql('SELECT country FROM users GROUP BY country WITH ROLLUP HAVING country IS NOT NULL ORDER BY country DESC'); + describe('joins', function () { + it('renders joins with ON and USING', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->leftJoin(Q::n('orders'))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) + )->toRenderSql('SELECT * FROM users AS u LEFT JOIN orders AS o ON o.user_id = u.id'); + + expect( + Q::select(Q::n('*'))->from(Q::n('a'))->join(Q::n('b'))->using('id') + )->toRenderSql('SELECT * FROM a JOIN b USING (id)'); + }); + + it('renders RIGHT JOIN and CROSS JOIN', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('a')) + ->rightJoin(Q::n('b'))->on(Q::n('b.a_id')->eq(Q::n('a.id'))) + )->toRenderSql('SELECT * FROM a RIGHT JOIN b ON b.a_id = a.id'); + + expect( + Q::select(Q::n('*'))->from(Q::n('a'))->crossJoin(Q::n('b')) + )->toRenderSql('SELECT * FROM a CROSS JOIN b'); + }); }); - it('renders LIMIT and OFFSET', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('users'))->limit(Q::int(10))->offset(Q::int(20)) - )->toRenderSql('SELECT id FROM users LIMIT 10 OFFSET 20'); + describe('LATERAL', function () { + it('joins a LATERAL derived table', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->joinLateral(Q::select(Q::n('*'))->from(Q::n('orders')))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) + )->toRenderSql('SELECT * FROM users AS u JOIN LATERAL (SELECT * FROM orders) AS o ON o.user_id = u.id'); + }); + + it('adds a LATERAL subquery to FROM', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->fromLateral(Q::select(Q::n('*'))->from(Q::n('orders')))->as('o') + )->toRenderSql('SELECT * FROM users AS u,LATERAL (SELECT * FROM orders) AS o'); + }); + + it('left- and cross-joins a LATERAL subquery', function () { + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->leftJoinLateral(Q::select(Q::n('*'))->from(Q::n('orders')))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) + )->toRenderSql('SELECT * FROM users AS u LEFT JOIN LATERAL (SELECT * FROM orders) AS o ON o.user_id = u.id'); + + expect( + Q::select(Q::n('*')) + ->from(Q::n('users'))->as('u') + ->crossJoinLateral(Q::select(Q::n('*'))->from(Q::n('orders')))->as('o') + )->toRenderSql('SELECT * FROM users AS u CROSS JOIN LATERAL (SELECT * FROM orders) AS o'); + }); }); - it('renders locking clauses', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('users'))->forUpdate()->skipLocked() - )->toRenderSql('SELECT id FROM users FOR UPDATE SKIP LOCKED'); + describe('grouping and ordering', function () { + it('renders GROUP BY with rollup, HAVING and ORDER BY', function () { + expect( + Q::select(Q::n('country')) + ->from(Q::n('users')) + ->groupBy(Q::n('country'))->withRollup() + ->having(Q::n('country')->isNotNull()) + ->orderBy(Q::n('country'))->desc() + )->toRenderSql('SELECT country FROM users GROUP BY country WITH ROLLUP HAVING country IS NOT NULL ORDER BY country DESC'); + }); - expect( - Q::select(Q::n('id'))->from(Q::n('users'))->forShare()->of('users')->nowait() - )->toRenderSql('SELECT id FROM users FOR SHARE OF users NOWAIT'); + it('orders ascending', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->orderBy(Q::n('id'))->asc() + )->toRenderSql('SELECT id FROM users ORDER BY id ASC'); + }); + + it('orders by multiple columns', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users')) + ->orderBy(Q::n('country'))->asc() + ->orderBy(Q::n('name'))->desc() + )->toRenderSql('SELECT id FROM users ORDER BY country ASC,name DESC'); + }); }); - it('renders UNION and INTERSECT combinations', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('a')) - ->union()->all() - ->query(Q::select(Q::n('id'))->from(Q::n('b'))) - )->toRenderSql('SELECT id FROM a UNION ALL (SELECT id FROM b)'); + describe('limit and locking', function () { + it('renders LIMIT and OFFSET', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->limit(Q::int(10))->offset(Q::int(20)) + )->toRenderSql('SELECT id FROM users LIMIT 10 OFFSET 20'); + }); + + it('renders locking clauses', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->forUpdate()->skipLocked() + )->toRenderSql('SELECT id FROM users FOR UPDATE SKIP LOCKED'); + + expect( + Q::select(Q::n('id'))->from(Q::n('users'))->forShare()->of('users')->nowait() + )->toRenderSql('SELECT id FROM users FOR SHARE OF users NOWAIT'); + }); }); - it('renders a CTE', function () { - expect( - Q::with('recent')->as(Q::select(Q::n('id'))->from(Q::n('orders'))) - ->select(Q::n('*'))->from(Q::n('recent')) - )->toRenderSql('WITH recent AS (SELECT id FROM orders) SELECT * FROM recent'); + describe('combinations', function () { + it('renders a UNION ALL combination', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('a')) + ->union()->all() + ->query(Q::select(Q::n('id'))->from(Q::n('b'))) + )->toRenderSql('SELECT id FROM a UNION ALL (SELECT id FROM b)'); + }); + + it('renders INTERSECT and EXCEPT combinations', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('a')) + ->intersect() + ->query(Q::select(Q::n('id'))->from(Q::n('b'))) + )->toRenderSql('SELECT id FROM a INTERSECT (SELECT id FROM b)'); + + expect( + Q::select(Q::n('id'))->from(Q::n('a')) + ->except() + ->query(Q::select(Q::n('id'))->from(Q::n('b'))) + )->toRenderSql('SELECT id FROM a EXCEPT (SELECT id FROM b)'); + }); }); - it('renders EXISTS and IN with a subquery', function () { - expect( - Q::select(Q::n('id'))->from(Q::n('users')) - ->where(Q::exists(Q::select(Q::int(1))->from(Q::n('orders')))) - )->toRenderSql('SELECT id FROM users WHERE EXISTS (SELECT 1 FROM orders)'); + describe('CTEs', function () { + it('renders a CTE', function () { + expect( + Q::with('recent')->as(Q::select(Q::n('id'))->from(Q::n('orders'))) + ->select(Q::n('*'))->from(Q::n('recent')) + )->toRenderSql('WITH recent AS (SELECT id FROM orders) SELECT * FROM recent'); + }); + + it('renders a recursive CTE with column names', function () { + expect( + Q::withRecursive('t')->columnNames('id', 'n') + ->as(Q::select(Q::n('id'), Q::n('n'))->from(Q::n('src'))) + ->select(Q::n('*'))->from(Q::n('t')) + )->toRenderSql('WITH RECURSIVE t(id, n) AS (SELECT id,n FROM src) SELECT * FROM t'); + }); - expect( - Q::select(Q::n('id'))->from(Q::n('users')) - ->where(Q::n('id')->in(Q::select(Q::n('user_id'))->from(Q::n('orders')))) - )->toRenderSql('SELECT id FROM users WHERE id IN (SELECT user_id FROM orders)'); + it('appends WITH queries', function () { + expect( + Q::with('foo')->as(Q::select(Q::int(1)))->select(Q::n('foo')) + ->appendWith(Q::with('bar')->as(Q::select(Q::int(2)))) + )->toRenderSql('WITH foo AS (SELECT 1),bar AS (SELECT 2) SELECT foo'); + }); + + it('chains a second recursive WITH query', function () { + expect( + Q::with('foo')->as(Q::select(Q::int(1))) + ->withRecursive('bar')->as(Q::select(Q::int(2))) + ->select(Q::n('foo')) + )->toRenderSql('WITH RECURSIVE foo AS (SELECT 1),bar AS (SELECT 2) SELECT foo'); + }); }); - it('renders a LATERAL derived table', function () { - expect( - Q::select(Q::n('*')) - ->from(Q::n('users'))->as('u') - ->joinLateral(Q::select(Q::n('*'))->from(Q::n('orders')))->as('o')->on(Q::n('o.user_id')->eq(Q::n('u.id'))) - )->toRenderSql('SELECT * FROM users AS u JOIN LATERAL (SELECT * FROM orders) AS o ON o.user_id = u.id'); + describe('subqueries', function () { + it('renders EXISTS and IN with a subquery', function () { + expect( + Q::select(Q::n('id'))->from(Q::n('users')) + ->where(Q::exists(Q::select(Q::int(1))->from(Q::n('orders')))) + )->toRenderSql('SELECT id FROM users WHERE EXISTS (SELECT 1 FROM orders)'); + + expect( + Q::select(Q::n('id'))->from(Q::n('users')) + ->where(Q::n('id')->in(Q::select(Q::n('user_id'))->from(Q::n('orders')))) + )->toRenderSql('SELECT id FROM users WHERE id IN (SELECT user_id FROM orders)'); + }); }); - it('renders a JSON_TABLE derived table', function () { - expect( - Q::select(Q::n('jt.id'), Q::n('jt.name')) - ->from(Q::n('t')) - ->from( + describe('JSON_TABLE', function () { + it('renders a JSON_TABLE derived table', function () { + expect( + Q::select(Q::n('jt.id'), Q::n('jt.name')) + ->from(Q::n('t')) + ->from( + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('id', 'INT')->path('$.id') + ->column('name', 'VARCHAR(100)')->path('$.name') + ->column('rownum')->forOrdinality()), + )->as('jt') + )->toRenderSql( + "SELECT jt.id,jt.name FROM t,JSON_TABLE(t.doc, '$[*]' COLUMNS (id INT PATH '$.id',name VARCHAR(100) PATH '$.name',rownum FOR ORDINALITY)) AS jt", + ); + }); + + it('renders ERROR ON EMPTY and NULL ON ERROR columns', function () { + expect( + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('x', 'INT')->path('$.x')->errorOnEmpty()->nullOnError()) + )->toRenderSql( + "JSON_TABLE(t.doc, '$[*]' COLUMNS (x INT PATH '$.x' ERROR ON EMPTY NULL ON ERROR))", + ); + }); + + it('renders JSON_TABLE EXISTS PATH and ON EMPTY / ON ERROR columns', function () { + expect( + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('ac', 'VARCHAR(100)')->path('$.a')->defaultOnEmpty('111')->defaultOnError('999') + ->column('cx', 'VARCHAR(20)')->path('$.c')->nullOnEmpty()->errorOnError() + ->column('bx', 'INT')->existsPath('$.b')) + )->toRenderSql( + "JSON_TABLE(t.doc, '$[*]' COLUMNS (ac VARCHAR(100) PATH '$.a' DEFAULT '111' ON EMPTY DEFAULT '999' ON ERROR,cx VARCHAR(20) PATH '$.c' NULL ON EMPTY ERROR ON ERROR,bx INT EXISTS PATH '$.b'))", + ); + }); + + it('renders nested JSON_TABLE columns', function () { + expect( + Q::select(Q::n('*'))->from( Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c - ->column('id', 'INT')->path('$.id') - ->column('name', 'VARCHAR(100)')->path('$.name') - ->column('rownum')->forOrdinality()), + ->column('top_ord')->forOrdinality() + ->column('apath', 'VARCHAR(10)')->path('$.a') + ->nested()->path('$.b[*]')->columns(fn ($b) => $b + ->column('bpath', 'VARCHAR(10)')->path('$.c') + ->column('ord')->forOrdinality() + ->nested()->path('$.l[*]')->columns(fn ($l) => $l + ->column('lpath', 'VARCHAR(10)')->path('$')))), )->as('jt') - )->toRenderSql( - "SELECT jt.id,jt.name FROM t,JSON_TABLE(t.doc, '$[*]' COLUMNS (id INT PATH '$.id',name VARCHAR(100) PATH '$.name',rownum FOR ORDINALITY)) AS jt", - ); + )->toRenderSql( + "SELECT * FROM JSON_TABLE(t.doc, '$[*]' COLUMNS (top_ord FOR ORDINALITY,apath VARCHAR(10) PATH '$.a',NESTED PATH '$.b[*]' COLUMNS (bpath VARCHAR(10) PATH '$.c',ord FOR ORDINALITY,NESTED PATH '$.l[*]' COLUMNS (lpath VARCHAR(10) PATH '$')))) AS jt", + ); + }); }); - it('renders JSON_TABLE EXISTS PATH and ON EMPTY / ON ERROR columns', function () { - expect( - Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c - ->column('ac', 'VARCHAR(100)')->path('$.a')->defaultOnEmpty('111')->defaultOnError('999') - ->column('cx', 'VARCHAR(20)')->path('$.c')->nullOnEmpty()->errorOnError() - ->column('bx', 'INT')->existsPath('$.b')) - )->toRenderSql( - "JSON_TABLE(t.doc, '$[*]' COLUMNS (ac VARCHAR(100) PATH '$.a' DEFAULT '111' ON EMPTY DEFAULT '999' ON ERROR,cx VARCHAR(20) PATH '$.c' NULL ON EMPTY ERROR ON ERROR,bx INT EXISTS PATH '$.b'))", - ); - }); + describe('isEmpty', function () { + it('is empty for a fresh builder', function () { + expect(Q::select()->isEmpty())->toBeTrue(); + }); - it('renders nested JSON_TABLE columns', function () { - expect( - Q::select(Q::n('*'))->from( - Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c - ->column('top_ord')->forOrdinality() - ->column('apath', 'VARCHAR(10)')->path('$.a') - ->nested()->path('$.b[*]')->columns(fn ($b) => $b - ->column('bpath', 'VARCHAR(10)')->path('$.c') - ->column('ord')->forOrdinality() - ->nested()->path('$.l[*]')->columns(fn ($l) => $l - ->column('lpath', 'VARCHAR(10)')->path('$')))), - )->as('jt') - )->toRenderSql( - "SELECT * FROM JSON_TABLE(t.doc, '$[*]' COLUMNS (top_ord FOR ORDINALITY,apath VARCHAR(10) PATH '$.a',NESTED PATH '$.b[*]' COLUMNS (bpath VARCHAR(10) PATH '$.c',ord FOR ORDINALITY,NESTED PATH '$.l[*]' COLUMNS (lpath VARCHAR(10) PATH '$')))) AS jt", - ); + it('is not empty once it has content', function () { + expect(Q::select(Q::n('id'))->from(Q::n('users'))->isEmpty())->toBeFalse(); + }); }); }); diff --git a/tests/MySQL/Q/UpdateBuilderTest.php b/tests/MySQL/Q/UpdateBuilderTest.php index fd0ee90..6474ffe 100644 --- a/tests/MySQL/Q/UpdateBuilderTest.php +++ b/tests/MySQL/Q/UpdateBuilderTest.php @@ -41,6 +41,24 @@ )->toRenderSql('UPDATE t SET id = id + 1 ORDER BY id DESC LIMIT 10'); }); + it('orders a single-table update ascending', function () { + expect( + Q::update(Q::n('t')) + ->set('a', Q::int(1)) + ->orderBy(Q::n('id'))->asc() + ->limit(Q::int(10)), + )->toRenderSql('UPDATE t SET a = 1 ORDER BY id ASC LIMIT 10'); + }); + + it('orders a single-table update by multiple columns', function () { + expect( + Q::update(Q::n('t')) + ->set('a', Q::int(1)) + ->orderBy(Q::n('x'))->orderBy(Q::n('y'))->desc() + ->limit(Q::int(10)), + )->toRenderSql('UPDATE t SET a = 1 ORDER BY x,y DESC LIMIT 10'); + }); + it('renders a multi-table update with a LEFT JOIN', function () { expect( Q::update(Q::n('t1')) @@ -60,6 +78,28 @@ )->toRenderSql('UPDATE items JOIN month ON items.id = month.id SET items.price = month.price'); }); + it('renders a multi-table update with RIGHT and CROSS JOIN', function () { + expect( + Q::update(Q::n('t1')) + ->rightJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->set('t1.a', Q::n('t2.a')), + )->toRenderSql('UPDATE t1 RIGHT JOIN t2 ON t1.id = t2.id SET t1.a = t2.a'); + + expect( + Q::update(Q::n('t1')) + ->crossJoin(Q::n('t2')) + ->set('t1.a', Q::int(1)), + )->toRenderSql('UPDATE t1 CROSS JOIN t2 SET t1.a = 1'); + }); + + it('renders a multi-table update with an aliased join and USING', function () { + expect( + Q::update(Q::n('t1')) + ->join(Q::n('t2'))->as('b')->using('id') + ->set('t1.a', Q::n('b.a')), + )->toRenderSql('UPDATE t1 JOIN t2 AS b USING (id) SET t1.a = b.a'); + }); + it('rejects ORDER BY / LIMIT on a multi-table update', function () { $q = Q::update(Q::n('t1')) ->join(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) diff --git a/tests/MySQL/Q/WindowFuncBuilderTest.php b/tests/MySQL/Q/WindowFuncBuilderTest.php index fab6069..4e0c8da 100644 --- a/tests/MySQL/Q/WindowFuncBuilderTest.php +++ b/tests/MySQL/Q/WindowFuncBuilderTest.php @@ -174,6 +174,18 @@ ); }); + it('renders ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING', function () { + expect( + Q::select( + Q\Func::sum(Q::n('x'))->over() + ->orderBy(Q::n('x')) + ->rows(Q::unboundedPreceding(), Q::unboundedFollowing()), + )->from(Q::n('t')), + )->toRenderSql( + 'SELECT SUM(x) OVER (ORDER BY x ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) FROM t', + ); + }); + it('renders a frame inside a named window', function () { expect( Q::select( @@ -185,6 +197,35 @@ 'SELECT SUM(val) OVER w FROM observations WINDOW w AS (ORDER BY time ROWS UNBOUNDED PRECEDING)', ); }); + + it('renders a RANGE frame inside a named window', function () { + expect( + Q::select( + Q\Func::sum(Q::n('val'))->over('w'), + ) + ->from(Q::n('t')) + ->window('w')->as()->orderBy(Q::n('time'))->range(Q::unboundedPreceding(), Q::currentRow()), + )->toRenderSql( + 'SELECT SUM(val) OVER w FROM t WINDOW w AS (ORDER BY time RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', + ); + }); + }); + + describe('window order by', function () { + it('renders an explicit direction on an inline window ORDER BY', function () { + expect(Q\Func::rank()->over()->orderBy(Q::n('x'))->asc()) + ->toRenderSql('RANK() OVER (ORDER BY x ASC)'); + expect(Q\Func::rank()->over()->orderBy(Q::n('x'))->desc()) + ->toRenderSql('RANK() OVER (ORDER BY x DESC)'); + }); + + it('renders an explicit direction on a named-window ORDER BY', function () { + expect( + Q::select(Q\Func::rowNumber()->over('w')) + ->from(Q::n('t')) + ->window('w')->as()->orderBy(Q::n('val'))->asc(), + )->toRenderSql('SELECT ROW_NUMBER() OVER w FROM t WINDOW w AS (ORDER BY val ASC)'); + }); }); describe('window-only functions', function () { @@ -223,10 +264,11 @@ expect( Q::select( Q\Func::lag(Q::n('val'))->over()->orderBy(Q::n('t')), + Q\Func::lag(Q::n('val'), Q::int(1), Q::int(0))->over()->orderBy(Q::n('t')), Q\Func::lead(Q::n('val'), Q::int(1), Q::int(0))->over()->orderBy(Q::n('t')), )->from(Q::n('t')), )->toRenderSql( - 'SELECT LAG(val) OVER (ORDER BY t), LEAD(val, 1, 0) OVER (ORDER BY t) FROM t', + 'SELECT LAG(val) OVER (ORDER BY t), LAG(val, 1, 0) OVER (ORDER BY t), LEAD(val, 1, 0) OVER (ORDER BY t) FROM t', ); }); diff --git a/tests/PostgreSQL/Q/AggregateFunctionsTest.php b/tests/PostgreSQL/Q/AggregateFunctionsTest.php index 4b5d904..3f1c450 100644 --- a/tests/PostgreSQL/Q/AggregateFunctionsTest.php +++ b/tests/PostgreSQL/Q/AggregateFunctionsTest.php @@ -73,6 +73,7 @@ 'json_agg' => fn () => [Q\Func::jsonAgg(Q::n('title')), 'json_agg(title)'], 'jsonb_agg' => fn () => [Q\Func::jsonbAgg(Q::n('title')), 'jsonb_agg(title)'], 'json_object_agg' => fn () => [Q\Func::jsonObjectAgg(Q::n('title'), Q::n('price')), 'json_object_agg(title, price)'], + 'jsonb_object_agg' => fn () => [Q\Func::jsonbObjectAgg(Q::n('title'), Q::n('price')), 'jsonb_object_agg(title, price)'], 'string_agg' => fn () => [Q\Func::stringAgg(Q::n('title'), Q::string(','))->orderBy(Q::n('title'))->desc(), "string_agg(title, ',' ORDER BY title DESC)"], 'max' => fn () => [Q\Func::max(Q::n('price')), 'max(price)'], 'min' => fn () => [Q\Func::min(Q::n('price')), 'min(price)'], diff --git a/tests/PostgreSQL/Q/DeleteBuilderTest.php b/tests/PostgreSQL/Q/DeleteBuilderTest.php index eef9c51..76ca4a4 100644 --- a/tests/PostgreSQL/Q/DeleteBuilderTest.php +++ b/tests/PostgreSQL/Q/DeleteBuilderTest.php @@ -60,6 +60,42 @@ }); }); + it('aliases the target table', function () { + $q = Q::deleteFrom(Q::n('films'))->as('f')->where(Q::n('f.kind')->eq(Q::string('Musical'))); + + expect($q)->toRenderSql("DELETE FROM films AS f WHERE f.kind = 'Musical'", null); + }); + + it('aliases a USING item and its columns', function () { + $q = Q::deleteFrom(Q::n('films')) + ->using(Q::select(Q::n('id'), Q::n('name'))->from(Q::n('producers')))->as('p')->columnAliases('pid', 'pname') + ->where(Q::n('films.producer_id')->eq(Q::n('p.pid'))); + + expect($q)->toRenderSql( + 'DELETE FROM films USING (SELECT id,name FROM producers) AS p (pid,pname) WHERE films.producer_id = p.pid', + null, + ); + }); + + it('lists multiple USING items', function () { + $q = Q::deleteFrom(Q::n('films')) + ->using(Q::n('producers')) + ->using(Q::n('studios')) + ->where(Q::n('films.producer_id')->eq(Q::n('producers.id'))); + + expect($q)->toRenderSql( + 'DELETE FROM films USING producers,studios WHERE films.producer_id = producers.id', + null, + ); + }); + + it('returns an aliased column', function () { + $q = Q::deleteFrom(Q::n('tasks'))->where(Q::n('status')->eq(Q::string('DONE'))) + ->returning(Q::n('id'))->as('task_id'); + + expect($q)->toRenderSql("DELETE FROM tasks WHERE status = 'DONE' RETURNING id AS task_id", null); + }); + it('renders with', function () { $listens = Q::n('listens'); diff --git a/tests/PostgreSQL/Q/FunctionsMatchingTest.php b/tests/PostgreSQL/Q/FunctionsMatchingTest.php index 61e4023..34631b4 100644 --- a/tests/PostgreSQL/Q/FunctionsMatchingTest.php +++ b/tests/PostgreSQL/Q/FunctionsMatchingTest.php @@ -12,4 +12,21 @@ it('similar to', function () { expect(Q::n('name')->similarTo(Q::string('%(b|d)%')))->toRenderSql("name SIMILAR TO '%(b|d)%'", null); }); + + it('negated like and similar to', function () { + expect(Q::n('name')->notLike(Q::string('foo%')))->toRenderSql("name NOT LIKE 'foo%'", null); + expect(Q::n('name')->notILike(Q::string('foo%')))->toRenderSql("name NOT ILIKE 'foo%'", null); + expect(Q::n('name')->notSimilarTo(Q::string('%(b|d)%')))->toRenderSql("name NOT SIMILAR TO '%(b|d)%'", null); + }); + + it('ilike', function () { + expect(Q::n('name')->ilike(Q::string('foo%')))->toRenderSql("name ILIKE 'foo%'", null); + }); + + it('POSIX regular-expression operators', function () { + expect(Q::n('name')->regexpMatch(Q::string('^foo')))->toRenderSql("name ~ '^foo'", null); + expect(Q::n('name')->regexpIMatch(Q::string('^foo')))->toRenderSql("name ~* '^foo'", null); + expect(Q::n('name')->regexpNotMatch(Q::string('^foo')))->toRenderSql("name !~ '^foo'", null); + expect(Q::n('name')->regexpINotMatch(Q::string('^foo')))->toRenderSql("name !~* '^foo'", null); + }); }); diff --git a/tests/PostgreSQL/Q/InsertBuilderTest.php b/tests/PostgreSQL/Q/InsertBuilderTest.php index dccde57..dde4c04 100644 --- a/tests/PostgreSQL/Q/InsertBuilderTest.php +++ b/tests/PostgreSQL/Q/InsertBuilderTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Flowpack\QueryObjectBuilder\PostgreSQL\Builder\QueryBuilderException; use Flowpack\QueryObjectBuilder\PostgreSQL\Q; describe('InsertBuilder', function () { @@ -132,6 +133,18 @@ ); }); + it('renders example 8 - returning an aliased column', function () { + $q = Q::insertInto(Q::n('distributors')) + ->columnNames('did', 'dname') + ->values(Q::default(), Q::string('XYZ Widgets')) + ->returning(Q::n('did'))->as('id'); + + expect($q)->toRenderSql( + "INSERT INTO distributors (did, dname) VALUES (DEFAULT, 'XYZ Widgets') RETURNING did AS id", + null, + ); + }); + it('renders example 9', function () { $employeesLog = Q::n('employees_log'); @@ -293,4 +306,32 @@ null, ); }); + + it('rejects setting both values and a query', function () { + $q = Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::int(1))->query(Q::select(Q::int(2))); + + expect(static fn () => Q::build($q)->toSql()) + ->toThrow(QueryBuilderException::class, 'insert: cannot set both values and query'); + }); + + it('rejects setting both an ON CONFLICT constraint name and targets', function () { + $q = Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::int(1)) + ->onConflict(Q::n('id'))->onConstraint('t_pkey')->doNothing(); + + expect(static fn () => Q::build($q)->toSql()) + ->toThrow(QueryBuilderException::class, 'insert: cannot set both conflict constraint name and targets'); + }); + + it('upserts on multiple conflict targets updating multiple columns', function () { + $q = Q::insertInto(Q::n('t'))->columnNames('a', 'b') + ->values(Q::int(1), Q::int(2)) + ->onConflict(Q::n('a'), Q::n('b'))->doUpdate() + ->set('a', Q::n('EXCLUDED.a')) + ->set('b', Q::n('EXCLUDED.b')); + + expect($q)->toRenderSql( + 'INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT (a,b) DO UPDATE SET a = EXCLUDED.a,b = EXCLUDED.b', + null, + ); + }); }); diff --git a/tests/PostgreSQL/Q/OpTest.php b/tests/PostgreSQL/Q/OpTest.php index 94f736b..ad3b1a5 100644 --- a/tests/PostgreSQL/Q/OpTest.php +++ b/tests/PostgreSQL/Q/OpTest.php @@ -95,6 +95,31 @@ }); }); + describe('arithmetic operators', function () { + it('divide, mod and pow', function () { + expect(Q::n('a')->divide(Q::n('b')))->toRenderSql('a / b', null); + expect(Q::n('a')->mod(Q::n('b')))->toRenderSql('a % b', null); + expect(Q::n('a')->pow(Q::n('b')))->toRenderSql('a ^ b', null); + }); + + it('negates', function () { + expect(Q::neg(Q::n('a')))->toRenderSql('- a', null); + }); + }); + + describe('json operators', function () { + it('extracts a json value and path', function () { + expect(Q::n('doc')->jsonExtract(Q::string('a')))->toRenderSql("doc -> 'a'", null); + expect(Q::n('doc')->jsonExtractPath(Q::arg('{a,b}')))->toRenderSql('doc #> $1', ['{a,b}']); + expect(Q::n('doc')->jsonExtractPathText(Q::arg('{a,b}')))->toRenderSql('doc #>> $1', ['{a,b}']); + }); + + it('tests containment', function () { + expect(Q::n('doc')->contains(Q::arg('{"a":1}')))->toRenderSql('doc @> $1', ['{"a":1}']); + expect(Q::n('doc')->containedBy(Q::arg('{"a":1}')))->toRenderSql('doc <@ $1', ['{"a":1}']); + }); + }); + describe('comparison operators', function () { it('is distinct from', function () { expect(Q::n('a')->plus(Q::int(1))->isDistinctFrom(Q::n('b')))->toRenderSql('a + 1 IS DISTINCT FROM b', null); @@ -103,5 +128,10 @@ it('is not distinct from', function () { expect(Q::not(Q::n('a'))->isNotDistinctFrom(Q::n('b')))->toRenderSql('(NOT a) IS NOT DISTINCT FROM b', null); }); + + it('lte and gte', function () { + expect(Q::n('a')->lte(Q::int(1)))->toRenderSql('a <= 1', null); + expect(Q::n('a')->gte(Q::int(1)))->toRenderSql('a >= 1', null); + }); }); }); diff --git a/tests/PostgreSQL/Q/SelectBuilderTest.php b/tests/PostgreSQL/Q/SelectBuilderTest.php index 9f6f7cb..0d6a39e 100644 --- a/tests/PostgreSQL/Q/SelectBuilderTest.php +++ b/tests/PostgreSQL/Q/SelectBuilderTest.php @@ -610,6 +610,13 @@ expect($q2)->toRenderSql('SELECT 1 FROM ONLY foo,bar', null); }); + it('names columns without a table alias', function () { + // Column aliases with no table alias emit a bare `AS (...)`. + $q = Q::select(Q::n('a'))->from(Q::n('t'))->columnAliases('a', 'b'); + + expect($q)->toRenderSql('SELECT a FROM t AS (a,b)', null); + }); + it('selects from a subquery', function () { $q = Q::select(Q::n('avg_quantity'))->from( Q::select(Q\Func::avg(Q::n('quantity')))->as('avg_quantity')->from(Q::n('sales'))->groupBy(Q::n('brand')), @@ -676,6 +683,30 @@ }); }); + describe('RightJoin', function () { + it('right joins immutably', function () { + $q = Q::select(Q::int(1))->from(Q::n('foo'))->rightJoin(Q::n('bar'))->on(Q::n('foo.id')->eq(Q::n('bar.id'))); + + expect($q)->toRenderSql('SELECT 1 FROM foo RIGHT JOIN bar ON foo.id = bar.id', null); + }); + }); + + describe('FullJoin', function () { + it('full joins immutably', function () { + $q = Q::select(Q::int(1))->from(Q::n('foo'))->fullJoin(Q::n('bar'))->on(Q::n('foo.id')->eq(Q::n('bar.id'))); + + expect($q)->toRenderSql('SELECT 1 FROM foo FULL JOIN bar ON foo.id = bar.id', null); + }); + }); + + describe('JoinLateral', function () { + it('joins lateral immutably', function () { + $q = Q::select(Q::int(1))->from(Q::n('foo'))->joinLateral(Q::n('bar'))->on(Q::n('foo.id')->eq(Q::n('bar.id'))); + + expect($q)->toRenderSql('SELECT 1 FROM foo JOIN LATERAL bar ON foo.id = bar.id', null); + }); + }); + describe('Select', function () { it('selects immutably', function () { $q1 = Q::select(Q::int(1)); @@ -768,6 +799,19 @@ SQL, [1, 2, 3]); }); + it('where not in args', function () { + $q = Q::select(Q::n('username')) + ->from(Q::n('accounts')) + ->where(Q::n('id')->notIn(Q::args(1, 2, 3))); + + // language=PostgreSQL + expect($q)->toRenderSql(<<<'SQL' + SELECT username + FROM accounts + WHERE id NOT IN ($1, $2, $3) + SQL, [1, 2, 3]); + }); + it('where in exps', function () { $q = Q::select(Q::n('username')) ->from(Q::n('accounts')) @@ -940,6 +984,12 @@ expect($q1)->toRenderSql('SELECT foo ORDER BY foo DESC', null); expect($q2)->toRenderSql('SELECT foo,bar ORDER BY foo DESC,bar ASC NULLS LAST', null); }); + + it('orders nulls first', function () { + $q = Q::select(Q::n('foo'))->orderBy(Q::n('foo'))->nullsFirst(); + + expect($q)->toRenderSql('SELECT foo ORDER BY foo NULLS FIRST', null); + }); }); describe('With', function () { @@ -971,6 +1021,14 @@ expect($q)->toRenderSql('WITH RECURSIVE foo AS (SELECT 1),bar AS (SELECT 2) SELECT foo', null); }); + it('chains a second with query', function () { + $q = Q::with('foo')->as(Q::select(Q::int(1))) + ->with('bar')->as(Q::select(Q::int(2))) + ->select(Q::n('foo')); + + expect($q)->toRenderSql('WITH foo AS (SELECT 1),bar AS (SELECT 2) SELECT foo', null); + }); + it('recursive with search depth', function () { $q = Q::withRecursive('search_tree')->columnNames('id', 'link', 'data')->as( Q::select(Q::n('t.id'), Q::n('t.link'), Q::n('t.data')) @@ -996,6 +1054,23 @@ SELECT * FROM search_tree ORDER BY ordercol SQL, null); }); + + it('recursive with search breadth', function () { + $q = Q::withRecursive('t')->columnNames('n')->as( + Q::select(Q::int(1)) + ->union()->all() + ->select(Q::n('n')->plus(Q::int(1)))->from(Q::n('t'))->where(Q::n('n')->lt(Q::int(5))), + )->searchBreadthFirst()->by(Q::n('n'))->set('seq') + ->select(Q::n('*'))->from(Q::n('t')); + + // language=PostgreSQL + expect($q)->toRenderSql(<<<'SQL' + WITH RECURSIVE t(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM t WHERE n < 5 + ) SEARCH BREADTH FIRST BY n SET seq + SELECT * FROM t + SQL, null); + }); }); describe('For', function () { @@ -1010,6 +1085,18 @@ expect($q)->toRenderSql('SELECT foo FROM bar FOR KEY SHARE OF table1,table2 SKIP LOCKED', null); }); + + it('for no key update', function () { + $q = Q::select(Q::n('foo'))->from(Q::n('bar'))->forNoKeyUpdate(); + + expect($q)->toRenderSql('SELECT foo FROM bar FOR NO KEY UPDATE', null); + }); + + it('for share nowait', function () { + $q = Q::select(Q::n('foo'))->from(Q::n('bar'))->forShare()->nowait(); + + expect($q)->toRenderSql('SELECT foo FROM bar FOR SHARE NOWAIT', null); + }); }); describe('Combinations', function () { @@ -1030,6 +1117,14 @@ (SELECT * FROM input_not_exists UNION ALL SELECT * FROM input_alternative_already_exists) SQL, null); }); + + it('INTERSECT', function () { + $q = Q::select(Q::n('*'))->from(Q::n('a')) + ->intersect() + ->query(Q::select(Q::n('*'))->from(Q::n('b'))); + + expect($q)->toRenderSql('SELECT * FROM a INTERSECT (SELECT * FROM b)', null); + }); }); describe('IsEmpty', function () { diff --git a/tests/PostgreSQL/Q/UpdateBuilderTest.php b/tests/PostgreSQL/Q/UpdateBuilderTest.php index 3fd6e17..58f138b 100644 --- a/tests/PostgreSQL/Q/UpdateBuilderTest.php +++ b/tests/PostgreSQL/Q/UpdateBuilderTest.php @@ -95,6 +95,31 @@ ); }); + it('returns an aliased column', function () { + $q = Q::update(Q::n('t'))->set('a', Q::int(1))->returning(Q::n('a'))->as('new_a'); + + expect($q)->toRenderSql('UPDATE t SET a = 1 RETURNING a AS new_a', null); + }); + + it('updates from a subquery with column aliases', function () { + $q = Q::update(Q::n('t')) + ->set('a', Q::n('s.x')) + ->from(Q::select(Q::n('x'))->from(Q::n('src')))->as('s')->columnAliases('x') + ->where(Q::n('t.id')->eq(Q::n('s.x'))); + + expect($q)->toRenderSql('UPDATE t SET a = s.x FROM (SELECT x FROM src) AS s (x) WHERE t.id = s.x', null); + }); + + it('updates from multiple tables', function () { + $q = Q::update(Q::n('t')) + ->set('a', Q::n('x.v')) + ->from(Q::n('x')) + ->from(Q::n('y')) + ->where(Q::n('t.id')->eq(Q::n('x.id'))); + + expect($q)->toRenderSql('UPDATE t SET a = x.v FROM x,y WHERE t.id = x.id', null); + }); + it('renders set map', function () { $q = Q::update(Q::n('films')) ->setMap([ diff --git a/tests/PostgreSQL/Q/WindowFuncBuilderTest.php b/tests/PostgreSQL/Q/WindowFuncBuilderTest.php index 7af85db..ee6e58f 100644 --- a/tests/PostgreSQL/Q/WindowFuncBuilderTest.php +++ b/tests/PostgreSQL/Q/WindowFuncBuilderTest.php @@ -138,4 +138,30 @@ null, ); }); + + it('applies direction and nulls ordering to an inline window ORDER BY', function () { + expect( + Q::select(Q\Func::sum(Q::n('salary'))->over()->orderBy(Q::n('salary'))->asc()->nullsFirst()) + ->from(Q::n('empsalary')), + )->toRenderSql('SELECT sum(salary) OVER (ORDER BY salary ASC NULLS FIRST) FROM empsalary', null); + + expect( + Q::select(Q\Func::sum(Q::n('salary'))->over()->orderBy(Q::n('salary'))->nullsLast()) + ->from(Q::n('empsalary')), + )->toRenderSql('SELECT sum(salary) OVER (ORDER BY salary NULLS LAST) FROM empsalary', null); + }); + + it('applies direction and nulls ordering to a named-window ORDER BY', function () { + expect( + Q::select(Q\Func::sum(Q::n('salary'))->over('w')) + ->from(Q::n('empsalary')) + ->window('w')->as()->orderBy(Q::n('salary'))->asc()->nullsLast(), + )->toRenderSql('SELECT sum(salary) OVER w FROM empsalary WINDOW w AS (ORDER BY salary ASC NULLS LAST)', null); + + expect( + Q::select(Q\Func::sum(Q::n('salary'))->over('w')) + ->from(Q::n('empsalary')) + ->window('w')->as()->orderBy(Q::n('salary'))->nullsFirst(), + )->toRenderSql('SELECT sum(salary) OVER w FROM empsalary WINDOW w AS (ORDER BY salary NULLS FIRST)', null); + }); }); From 6b54c468ee5661576ed8d6b1f19271d1777147e2 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 18:39:27 +0200 Subject: [PATCH 39/40] =?UTF-8?q?wip:=20cover=20remaining=20branch=20gaps?= =?UTF-8?q?=20(Keywords/Requirement/WindowDefinition/GroupConcat)=20(?= =?UTF-8?q?=E2=86=92=2099%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL Keywords: backtick-quoted part in a dotted identifier - MySQL Requirement: version-window satisfiedBy upper bound + describe() variants (new RequirementTest) - PostgreSQL WindowDefinition: named window refining another, multi-key PARTITION BY - MySQL GroupConcatBuilder: multiple expressions and multiple ORDER BY keys Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/MySQL/Builder/RequirementTest.php | 19 +++++++++++++++++++ tests/MySQL/Q/ExpressionsTest.php | 2 ++ tests/MySQL/Q/FunctionsSpecialShapeTest.php | 6 ++++++ tests/PostgreSQL/Q/WindowFuncBuilderTest.php | 12 ++++++++++++ 4 files changed, 39 insertions(+) create mode 100644 tests/MySQL/Builder/RequirementTest.php diff --git a/tests/MySQL/Builder/RequirementTest.php b/tests/MySQL/Builder/RequirementTest.php new file mode 100644 index 0000000..63730a1 --- /dev/null +++ b/tests/MySQL/Builder/RequirementTest.php @@ -0,0 +1,19 @@ +satisfiedBy(Target::mysql('8.0')))->toBeFalse(); + expect(Requirement::mysql(ltVersion: '8.0')->satisfiedBy(Target::mysql('5.7')))->toBeTrue(); + }); + + it('describes a version window', function () { + expect(Requirement::mysql(gteVersion: '10.5', ltVersion: '13.0')->describe())->toBe('MySQL 10.5–13.0'); + expect(Requirement::mysql(ltVersion: '8.0')->describe())->toBe('MySQL < 8.0'); + }); +}); diff --git a/tests/MySQL/Q/ExpressionsTest.php b/tests/MySQL/Q/ExpressionsTest.php index 5c0d4a4..827846e 100644 --- a/tests/MySQL/Q/ExpressionsTest.php +++ b/tests/MySQL/Q/ExpressionsTest.php @@ -10,6 +10,8 @@ expect(Q::n('users'))->toRenderSql('users'); expect(Q::n('order'))->toRenderSql('`order`'); expect(Q::n('u.name'))->toRenderSql('u.name'); + // A part that is already backtick-quoted is kept as-is when splitting the path. + expect(Q::n('`order`.id'))->toRenderSql('`order`.id'); }); it('quotes string literals with doubled quotes and escaped backslashes', function () { diff --git a/tests/MySQL/Q/FunctionsSpecialShapeTest.php b/tests/MySQL/Q/FunctionsSpecialShapeTest.php index d89c092..699cd1f 100644 --- a/tests/MySQL/Q/FunctionsSpecialShapeTest.php +++ b/tests/MySQL/Q/FunctionsSpecialShapeTest.php @@ -24,6 +24,12 @@ Q\Func::groupConcat(Q::n('name'))->orderBy(Q::n('name'))->asc(), )->toRenderSql('GROUP_CONCAT(name ORDER BY name ASC)'); }); + + it('concatenates multiple expressions ordered by multiple keys', function () { + expect( + Q\Func::groupConcat(Q::n('a'), Q::n('b'))->orderBy(Q::n('a'))->orderBy(Q::n('b')), + )->toRenderSql('GROUP_CONCAT(a,b ORDER BY a,b)'); + }); }); describe('EXTRACT', function () { diff --git a/tests/PostgreSQL/Q/WindowFuncBuilderTest.php b/tests/PostgreSQL/Q/WindowFuncBuilderTest.php index ee6e58f..9a43cb0 100644 --- a/tests/PostgreSQL/Q/WindowFuncBuilderTest.php +++ b/tests/PostgreSQL/Q/WindowFuncBuilderTest.php @@ -151,6 +151,18 @@ )->toRenderSql('SELECT sum(salary) OVER (ORDER BY salary NULLS LAST) FROM empsalary', null); }); + it('defines a named window that refines another, partitioned by multiple keys', function () { + $b = Q::select(Q\Func::sum(Q::n('salary'))->over('w2')) + ->from(Q::n('empsalary')) + ->window('w1')->as()->partitionBy(Q::n('depname'), Q::n('region')) + ->window('w2')->as('w1')->orderBy(Q::n('salary')); + + expect($b)->toRenderSql( + 'SELECT sum(salary) OVER w2 FROM empsalary WINDOW w1 AS (PARTITION BY depname,region),w2 AS (w1 ORDER BY salary)', + null, + ); + }); + it('applies direction and nulls ordering to a named-window ORDER BY', function () { expect( Q::select(Q\Func::sum(Q::n('salary'))->over('w')) From 6759cd9f9515a501a214c8731f92dc3be1f6a2f8 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Wed, 1 Jul 2026 19:22:43 +0200 Subject: [PATCH 40/40] MySQL/MariaDB JSON parity + two-family README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the MySQL family to parity with the PostgreSQL builder for JSON construction, and restructure the README around both dialect families. JSON object builder: - Q\Func::jsonObject() now returns an immutable JsonObjectBuilder (prop/propIf/applyIf/unset) rendering JSON_OBJECT(...), replacing the varargs passthrough. Computed keys stay on the Q::func('JSON_OBJECT', ...) escape hatch. JSON-first entry point: - Q::selectJson()/applySelectJson()/as() make a JSON object the query's first select element, via a new SelectJsonSelectBuilder and selectJson / selectJsonAlias threaded through SelectQueryParts, derive() and writeSelectParts(). The prop-builder pattern is shared architecture, not a PG SQL feature, so the name and SQL stay dialect-native (jsonObject / JSON_OBJECT). README: rewritten as a balanced two-dialect hub — shared philosophy, dialect overview, intermixed per-engine examples with an index, and a prominent "MySQL & MariaDB: one builder, two engines" section covering construction-determined rendering and opt-in target validation. composer.json now advertises MySQL/MariaDB. Tests: ported the PostgreSQL JsonBuildObject/JsonQuery suites to the MySQL family. 664 pass, PHPStan level max clean, new builders 100% covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1165 ++++++++++------- composer.json | 4 +- src/MySQL/Builder/JsonObjectBuilder.php | 83 ++ src/MySQL/Builder/SelectBuilder.php | 30 + src/MySQL/Builder/SelectJsonSelectBuilder.php | 21 + src/MySQL/Builder/SelectQueryParts.php | 4 +- src/MySQL/Q.php | 13 + src/MySQL/Q/Func.php | 12 +- tests/MySQL/Q/FunctionsTest.php | 2 +- tests/MySQL/Q/JsonObjectBuilderTest.php | 58 + tests/MySQL/Q/JsonQueryTest.php | 66 + tests/MySQL/Q/SelectJsonShapeTest.php | 164 +-- 12 files changed, 1041 insertions(+), 581 deletions(-) create mode 100644 src/MySQL/Builder/JsonObjectBuilder.php create mode 100644 src/MySQL/Builder/SelectJsonSelectBuilder.php create mode 100644 tests/MySQL/Q/JsonObjectBuilderTest.php create mode 100644 tests/MySQL/Q/JsonQueryTest.php diff --git a/README.md b/README.md index e420db0..0df6377 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,60 @@ [![CI](https://github.com/Flowpack/query-object-builder/actions/workflows/ci.yml/badge.svg)](https://github.com/Flowpack/query-object-builder/actions/workflows/ci.yml) [![License](https://img.shields.io/packagist/l/flowpack/query-object-builder.svg)](LICENSE) -A fluent, immutable, fully-typed SQL query builder for PHP 8.4+ with extensive -support for PostgreSQL-specific features. +A fluent, immutable, fully-typed SQL query builder for PHP 8.4+. You compose a query from small, type-safe expression objects and render it to a parameterized SQL string with bound arguments — never by concatenating strings. +The package ships **two dialect families**, each modelling *its own* SQL rather +than a lowest-common-denominator subset: + +- **`PostgreSQL\Q`** — the PostgreSQL builder. +- **`MySQL\Q`** — a single **MySQL-family** builder covering both **MySQL** *and* + **MariaDB**. Every construct is buildable regardless of engine or version; where + the two engines diverge you build the engine's own form, and an opt-in + [target-validation](#mysql--mariadb-one-builder-two-engines) pass reports any + construct the engine (and version) you are targeting cannot express. + +Both families share the same design — fluent, immutable, type-state builders and +the `Q` / `Q\Func` facade split — so once you know one, you know the other. + +## Contents + +- [Why Query Object Builder?](#why-query-object-builder) +- [Requirements](#requirements) +- [Installation](#installation) +- [The two dialect families](#the-two-dialect-families) +- [Quick start](#quick-start) +- [Core concepts](#core-concepts) +- [MySQL & MariaDB: one builder, two engines](#mysql--mariadb-one-builder-two-engines) +- [Examples](#examples) +- [Parameters](#parameters) +- [Validation & errors](#validation--errors) +- [Executing queries](#executing-queries) +- [Best practices](#best-practices) +- [Development](#development) +- [License](#license) ## Why Query Object Builder? -- **Pure PostgreSQL focus** — no compromises for lowest-common-denominator - multi-database support. -- **JSON-first design** — first-class support for `json_build_object` and - `json_agg` to build hierarchical data directly in the database. -- **Complete feature set** — CTEs, window functions, arrays, grouping sets, - `ON CONFLICT`, `RETURNING`, set-returning functions, and more. +- **Dialect-native, not lowest-common-denominator** — each family's facade and + builders model that engine's own SQL (PostgreSQL arrays and `ON CONFLICT`; + MySQL/MariaDB `JSON_TABLE`, `ON DUPLICATE KEY UPDATE`, backtick quoting). No + feature is dropped to fit a shared subset. +- **JSON-first** — first-class support for building hierarchical data directly in + the database (`json_build_object` / `json_agg` on PostgreSQL, `JSON_OBJECT` / + `JSON_ARRAYAGG` on MySQL/MariaDB). +- **Complete feature set** — CTEs, window functions and frames, grouping, + subqueries, upserts, `RETURNING`, set-returning / table functions, and more. - **Type-safe** — builder methods only expose what is valid in the current context, so invalid queries are hard to express. - **Immutable** — every builder method returns a new instance, so base queries can be shared and specialised without surprises. +- **Runtime target validation** — the MySQL family renders both engines from one + builder and can *report* (never silently rewrite) any construct a specific + engine or version cannot express. +- **Zero runtime dependencies** — requires only PHP 8.4+. Everything else is a + dev-only dependency (PHPUnit, Pest, PHPStan). ## Requirements @@ -34,62 +70,92 @@ parameterized SQL string with bound arguments — never by concatenating strings composer require flowpack/query-object-builder ``` +## The two dialect families + +Pick the facade for your database; the fluent API is the same shape on both. + +| | PostgreSQL | MySQL / MariaDB | +|---------------------|----------------------------------------------|---------------------------------------------------------| +| Import | `Flowpack\QueryObjectBuilder\PostgreSQL\Q` | `Flowpack\QueryObjectBuilder\MySQL\Q` | +| Placeholders | numbered `$1`, `$2`, … | positional `?` | +| Identifier quoting | `"col"` (when needed) | `` `col` `` (when needed) | +| Boolean literal | `true` / `false` | `TRUE` / `FALSE` | +| Function casing | `count(*)`, `json_agg(...)` | `COUNT(*)`, `JSON_ARRAYAGG(...)` | +| Cast | `expr::type` | `CAST(expr AS type)` / `CONVERT(...)` | +| Engines | PostgreSQL | MySQL **and** MariaDB (one builder) | + +`PostgreSQL\Q` and `MySQL\Q` do **not** share types — a query built with one is +rendered by its own `QueryBuilder`. + ## Quick start +**PostgreSQL:** + ```php use Flowpack\QueryObjectBuilder\PostgreSQL\Q; -$active = true; - $q = Q::select(Q::n('name'), Q::n('email')) ->from(Q::n('users')) - ->where(Q::n('active')->eq(Q::arg($active))) + ->where(Q::n('active')->eq(Q::arg(true))) ->orderBy(Q::n('name')); [$sql, $args] = Q::build($q)->toSql(); -echo $sql; // SELECT name,email FROM users WHERE active = $1 ORDER BY name -var_dump($args); // [true] +echo $sql; // SELECT name,email FROM users WHERE active = $1 ORDER BY name +var_dump($args); // [true] ``` -`Q::build($q)->toSql()` returns a `[$sql, $args]` pair: a SQL string with -PostgreSQL numbered placeholders (`$1`, `$2`, …) and the positional argument -list to bind. See [Executing queries](#executing-queries) for how to run it. +**MySQL / MariaDB:** + +```php +use Flowpack\QueryObjectBuilder\MySQL\Q; + +$q = Q::select(Q::n('name'), Q::n('email')) + ->from(Q::n('users')) + ->where(Q::n('active')->eq(Q::arg(true))) + ->orderBy(Q::n('name')); + +[$sql, $args] = Q::build($q)->toSql(); -## Dialects +echo $sql; // SELECT name,email FROM users WHERE active = ? ORDER BY name +var_dump($args); // [true] +``` -This README covers the PostgreSQL builder (`Flowpack\QueryObjectBuilder\PostgreSQL\Q`). -The package also ships a **MySQL-family** facade (`MySQL\Q`) covering **MySQL 8.4** -and **MariaDB 11.x** in one builder, with the same fluent, immutable, type-safe -design (backtick identifiers, `?` placeholders, native operators and functions). -Where the two engines diverge you build the engine's own form, and an opt-in -`->withValidateTarget(Target::mysql()|mariaDb())` pass reports any construct the -target cannot express. See **[MySQL & MariaDB](docs/mysql-mariadb.md)** for usage, -the engine differences, and the coverage/limitations. +`Q::build($q)->toSql()` returns a `[$sql, $args]` pair: a SQL string with the +dialect's placeholders and the positional argument list to bind. See +[Executing queries](#executing-queries) for how to run it. ## Core concepts ### The `Q` facade `Q` is the single entry point for building queries. It exposes the builder -package as a small set of static factory methods so you never reference the -underlying builder types directly: +package as static factory methods so you never reference the underlying builder +types directly: - **Statements**: `Q::select()`, `Q::insertInto()`, `Q::update()`, - `Q::deleteFrom()`, `Q::with()`, `Q::withRecursive()` -- **Identifiers**: `Q::n('table.column')` for names/columns + `Q::deleteFrom()`, `Q::with()`, `Q::withRecursive()` (MySQL family adds + `Q::replaceInto()`). +- **Identifiers**: `Q::n('table.column')` for names/columns. - **Literals**: `Q::string()`, `Q::int()`, `Q::float()`, `Q::bool()`, - `Q::null()`, `Q::default()`, `Q::array()`, `Q::interval()` -- **Parameters**: `Q::arg()` (positional), `Q::bind()` (named) -- **Composition**: `Q::and()`, `Q::or()`, `Q::not()`, `Q::exists()`, - `Q::case()`, `Q::coalesce()`, `Q::func()`, `Q::agg()` + `Q::null()`, `Q::default()` (PostgreSQL adds `Q::array()`, `Q::interval()`). +- **Parameters**: `Q::arg()` (positional), `Q::bind()` (named). +- **Composition**: `Q::and()`, `Q::or()`, `Q::not()`, `Q::exists()`, `Q::any()`, + `Q::all()`, `Q::case()`, `Q::coalesce()`, `Q::func()`. ### The `Q\Func` facade -SQL functions live on the `Q\Func` facade: `Q\Func::jsonBuildObject()`, -`Q\Func::jsonAgg()`, `Q\Func::count()`, `Q\Func::sum()`, `Q\Func::upper()`, -`Q\Func::rowNumber()`, `Q\Func::unnest()`, and many more. It is named `Func` -(not `Fn`) because `fn` is a reserved keyword in PHP. +SQL functions live on `Q\Func`. On PostgreSQL: +`Q\Func::jsonBuildObject()`, `Q\Func::jsonAgg()`, `Q\Func::count()`, +`Q\Func::rowNumber()`, `Q\Func::unnest()`, … On MySQL/MariaDB: +`Q\Func::jsonObject()`, `Q\Func::jsonArrayAgg()`, `Q\Func::count()`, +`Q\Func::groupConcat()`, `Q\Func::rank()`, … It is named `Func` (not `Fn`) +because `fn` is a reserved keyword in PHP. + +`Q\Func` is the *expression* facade: every method returns something usable +anywhere an expression is valid. Constructs that are not general expressions — a +statement, or a FROM-only producer like `JSON_TABLE` — live on `Q` instead +(`Q::jsonTable()`, PostgreSQL's `Q::rowsFrom()`). ### Immutability @@ -106,244 +172,195 @@ $recent = $base->where(Q::n('created_at')->gt(Q::string('2024-01-01'))); ### Operators on expressions -Expressions returned by `Q::n()`, `Q::arg()`, literals, and functions carry the -SQL operators as fluent methods: `->eq()`, `->neq()`, `->lt()`, `->gt()`, -`->like()`, `->ilike()`, `->in()`, `->isNull()`, `->isNotNull()`, `->plus()`, -`->minus()`, `->mult()`, `->concat()`, `->cast()`, `->op('*', …)`, and more. -Parentheses are added automatically based on operator precedence. +Expressions returned by `Q::n()`, `Q::arg()`, literals and functions carry the +SQL **operators** as fluent methods; what reads as a **function** is built +through the facade. Each family models the operator set its dialect actually has: -## Examples +- **Shared**: `->eq()`, `->neq()`, `->lt()`, `->lte()`, `->gt()`, `->gte()`, + `->like()`, `->in()`, `->isNull()`, `->isNotNull()`, `->plus()`, `->minus()`, + `->mult()`, `->and()`/`->or()`, … +- **PostgreSQL-specific**: `->ilike()`, `->cast('text')` (`::`), `->concat()` + (`||`), array/JSON operators. +- **MySQL/MariaDB-specific**: `->nullSafeEq()` (`<=>`), `->regexp()`, + `->memberOf()` (`MEMBER OF`), `->jsonExtract()` / `->jsonExtractText()` + (`->` / `->>`, MySQL), the bitwise operators (`->bitAnd()`, `->bitOr()`, + `->shiftLeft()`, …). -> The builder emits compact, single-line SQL. The SQL shown below is formatted -> for readability — it is otherwise exactly what each query renders. - -### Basic queries - -#### Simple SELECT - -```php -$q = Q::select(Q::n('*'))->from(Q::n('users')); -``` +Parentheses are added automatically based on each dialect's operator precedence. -```sql -SELECT * FROM users -``` +### Building and rendering -#### SELECT with WHERE +A finished query is handed to `Q::build($q)` to configure rendering, then +`->toSql()` produces the `[$sql, $args]` pair: ```php -$q = Q::select(Q::n('name'), Q::n('email')) - ->from(Q::n('users')) - ->where(Q::n('active')->eq(Q::bool(true))); +[$sql, $args] = Q::build($q)->toSql(); // validate + render +[$sql, $args] = Q::build($q)->withoutValidation()->toSql(); // skip value checks +[$sql, $args] = Q::build($q)->withNamedArgs([...])->toSql(); // bind Q::bind() names ``` -```sql -SELECT name, email FROM users WHERE active = true -``` - -#### SELECT with multiple conditions - -```php -$q = Q::select(Q::n('*')) - ->from(Q::n('employees')) - ->where(Q::and( - Q::or( - Q::n('firstname')->ilike(Q::arg('John%')), - Q::n('lastname')->ilike(Q::arg('John%')), - ), - Q::n('active')->eq(Q::bool(true)), - )); -``` +See [Validation & errors](#validation--errors) for what is checked and how to +opt out, and the MySQL-only [target validation](#opt-in-target-validation) pass. -```sql -SELECT * FROM employees -WHERE (firstname ILIKE $1 OR lastname ILIKE $2) AND active = true -``` +## MySQL & MariaDB: one builder, two engines -#### SELECT DISTINCT +The MySQL family is a **single builder** for both MySQL and MariaDB. The two +engines share ~95% of their grammar and *all* of their rendering conventions +(backtick identifiers, `?` placeholders, string escaping), so one builder models +both. This section is the design goal that makes that safe. -```php -$q = Q::select()->distinct() - ->select(Q::n('department')) - ->from(Q::n('employees')); -``` +**Nothing is gated at build time.** Every construct is buildable regardless of +engine or version — the builder never refuses a feature because your engine is +the wrong flavour or too old. Engine and version are inputs to the *opt-in* +validation pass below, which *reports* (never rewrites or blocks) what a target +cannot express. This is also why the PostgreSQL builder carries no version: it +models a single engine, so it needs no target at all. -```sql -SELECT DISTINCT department FROM employees -``` +### Rendering is determined by construction -#### SELECT with ORDER BY, LIMIT and OFFSET +Rendering **never branches on a dialect flag**. What you build is exactly what is +rendered — so the engine-divergent constructs are reached by *building the +engine's own form*, not by toggling a mode: -```php -$q = Q::select(Q::n('name'), Q::n('salary')) - ->from(Q::n('employees')) - ->orderBy(Q::n('salary'))->desc()->nullsLast() - ->limit(Q::int(10)) - ->offset(Q::int(20)); -``` +| Intent | MySQL | MariaDB | +|---------------------------|------------------------------------------------|-----------------------------------------------| +| Shared row lock | `->forShare()` | `->lockInShareMode()` | +| Upsert proposed-row ref | `->as('new')` + `Q::n('new.col')` | `Q::values('col')` *(also works on MySQL)* | +| JSON path | `->jsonExtract()` / `->jsonExtractText()` | `Q\Func::jsonExtract()` / `Q\Func::jsonUnquote()` | +| Pretty-print JSON | `Q\Func::jsonPretty()` | `Q\Func::jsonDetailed()` | +| `RETURNING` | — (not supported) | `->returning(...)` | +| `LATERAL` | `->joinLateral()` / `->fromLateral()` | — (no equivalent) | -```sql -SELECT name, salary FROM employees -ORDER BY salary DESC NULLS LAST -LIMIT 10 OFFSET 20 -``` +Building without a target renders precisely what you constructed and never fails +on dialect grounds. -### CRUD operations +### Opt-in target validation -#### INSERT with VALUES +To check a query against a specific engine (and, optionally, version), opt in +with `withValidateTarget()`. Each divergent construct reports itself while +rendering, so you get a `QueryBuilderException` naming what the target cannot +express: ```php -$q = Q::insertInto(Q::n('users')) - ->columnNames('name', 'email', 'active') - ->values(Q::string('John Doe'), Q::string('john@example.com'), Q::bool(true)); -``` +use Flowpack\QueryObjectBuilder\MySQL\Q; +use Flowpack\QueryObjectBuilder\MySQL\Builder\Target; -```sql -INSERT INTO users (name, email, active) -VALUES ('John Doe', 'john@example.com', true) -``` +$q = Q::select(Q::n('id'))->from(Q::n('t'))->forShare(); // FOR SHARE is MySQL-only -#### INSERT multiple rows - -```php -$q = Q::insertInto(Q::n('products')) - ->columnNames('name', 'price', 'category') - ->values(Q::string('Laptop'), Q::float(999.99), Q::string('Electronics')) - ->values(Q::string('Book'), Q::float(19.99), Q::string('Literature')); -``` - -```sql -INSERT INTO products (name, price, category) VALUES - ('Laptop', 999.99, 'Electronics'), - ('Book', 19.99, 'Literature') +Q::build($q)->withValidateTarget(Target::mysql())->toSql(); // ok +Q::build($q)->withValidateTarget(Target::mariaDb())->toSql(); // throws QueryBuilderException: +// "FOR SHARE requires MySQL, but the query is validated against MariaDB" ``` -#### INSERT from a map of values +`Target::mysql($version)` / `Target::mariaDb($version)` carry an optional +version. Version-gated features are checked only when a version is supplied — a +leading `WITH` on `UPDATE`/`DELETE` is valid on MySQL and on MariaDB 12.3+, so it +passes against `Target::mariaDb('12.3')` but fails against `Target::mariaDb('11.4')`. +A target with no version only checks the dialect. -```php -$q = Q::insertInto(Q::n('films')) - ->setMap([ - 'code' => 'UA502', - 'title' => 'Bananas', - 'did' => 105, - ]); -``` +Worked per-engine variants for each divergent construct are in the +[Examples](#examples) below. -```sql -INSERT INTO films (code,did,title) VALUES ($1, $2, $3) --- args: ['UA502', 105, 'Bananas'] -``` +## Examples -#### INSERT from a SELECT +> **How to read these examples.** Unless a snippet is labelled for a specific +> engine, the PHP builds **identically on both facades** — import `PostgreSQL\Q` +> or `MySQL\Q` as `Q`. Only the rendered SQL differs by dialect (`$1` vs `?`, +> identifier quoting, `true` vs `TRUE`, lower- vs upper-case function names). +> Divergent constructs show a snippet per engine. The builder emits compact, +> single-line SQL; the SQL below is formatted for readability. + +**Jump to:** + +- [Basic queries](#basic-queries) · + [Joins](#joins) · + [Aggregation & grouping](#aggregation--grouping) · + [Window functions](#window-functions) · + [JSON](#json) · + [Arrays (PostgreSQL)](#arrays-postgresql) · + [Subqueries](#subqueries) · + [CTEs (WITH)](#ctes-with) · + [INSERT & upsert](#insert--upsert) · + [UPDATE](#update) · + [DELETE](#delete) · + [Functions & operators](#functions--operators) · + [Locking](#locking) -```php -$q = Q::insertInto(Q::n('archived_users')) - ->query(Q::select(Q::n('*'))->from(Q::n('users'))->where(Q::n('active')->eq(Q::bool(false)))); -``` - -```sql -INSERT INTO archived_users SELECT * FROM users WHERE active = false -``` +### Basic queries -#### INSERT with RETURNING +#### SELECT with WHERE ```php -$q = Q::insertInto(Q::n('users')) - ->columnNames('name', 'email') - ->values(Q::string('Jane Doe'), Q::string('jane@example.com')) - ->returning(Q::n('id'), Q::n('created_at')); +$q = Q::select(Q::n('name'), Q::n('email')) + ->from(Q::n('users')) + ->where(Q::n('active')->eq(Q::arg(true))); ``` ```sql -INSERT INTO users (name, email) VALUES ('Jane Doe', 'jane@example.com') -RETURNING id, created_at +-- PostgreSQL +SELECT name, email FROM users WHERE active = $1 -- args: [true] +-- MySQL / MariaDB +SELECT name, email FROM users WHERE active = ? -- args: [true] ``` -#### UPSERT (INSERT … ON CONFLICT) +#### Multiple conditions ```php -$q = Q::insertInto(Q::n('distributors')) - ->columnNames('did', 'dname') - ->values(Q::int(5), Q::string('Gizmo Transglobal')) - ->onConflict(Q::n('did'))->doUpdate() - ->set('dname', Q::n('EXCLUDED.dname')); +$q = Q::select(Q::n('*')) + ->from(Q::n('employees')) + ->where(Q::and( + Q::or( + Q::n('firstname')->like(Q::arg('John%')), + Q::n('lastname')->like(Q::arg('John%')), + ), + Q::n('active')->eq(Q::bool(true)), + )); ``` ```sql -INSERT INTO distributors (did, dname) VALUES (5, 'Gizmo Transglobal') -ON CONFLICT (did) DO UPDATE SET dname = EXCLUDED.dname -``` - -#### UPDATE - -```php -$q = Q::update(Q::n('films')) - ->set('kind', Q::string('Dramatic')) - ->where(Q::n('kind')->eq(Q::string('Drama'))); +-- PostgreSQL +SELECT * FROM employees +WHERE (firstname LIKE $1 OR lastname LIKE $2) AND active = true +-- MySQL / MariaDB +SELECT * FROM employees +WHERE (firstname LIKE ? OR lastname LIKE ?) AND active = TRUE ``` -```sql -UPDATE films SET kind = 'Dramatic' WHERE kind = 'Drama' -``` +> PostgreSQL also has `->ilike()` for case-insensitive matching; MySQL/MariaDB +> use `->like()` (case-insensitivity follows the column collation) or `->regexp()`. -#### UPDATE with FROM +#### DISTINCT ```php -$q = Q::update(Q::n('employees'))->as('e') - ->set('department_name', Q::n('d.name')) - ->from(Q::n('departments'))->as('d') - ->where(Q::n('e.department_id')->eq(Q::n('d.id'))); +$q = Q::select(Q::n('department'))->distinct()->from(Q::n('employees')); ``` ```sql -UPDATE employees AS e SET department_name = d.name -FROM departments AS d -WHERE e.department_id = d.id +-- PostgreSQL / MySQL / MariaDB +SELECT DISTINCT department FROM employees ``` -#### DELETE +#### ORDER BY, LIMIT and OFFSET ```php -$q = Q::deleteFrom(Q::n('films')) - ->where(Q::n('kind')->neq(Q::string('Musical'))); +$q = Q::select(Q::n('name'), Q::n('salary')) + ->from(Q::n('employees')) + ->orderBy(Q::n('salary'))->desc() + ->limit(Q::int(10)) + ->offset(Q::int(20)); ``` ```sql -DELETE FROM films WHERE kind <> 'Musical' -``` - -#### DELETE with USING - -```php -$q = Q::deleteFrom(Q::n('films')) - ->using(Q::n('producers')) - ->where(Q::and( - Q::n('producer_id')->eq(Q::n('producers.id')), - Q::n('producers.name')->eq(Q::string('foo')), - )); +-- PostgreSQL / MySQL / MariaDB +SELECT name, salary FROM employees ORDER BY salary DESC LIMIT 10 OFFSET 20 ``` -```sql -DELETE FROM films USING producers -WHERE producer_id = producers.id AND producers.name = 'foo' -``` +> `NULLS FIRST` / `NULLS LAST` (`->nullsLast()`) is PostgreSQL-only. ### Joins -#### INNER JOIN - -```php -$q = Q::select(Q::n('u.name'), Q::n('p.title')) - ->from(Q::n('users'))->as('u') - ->join(Q::n('posts'))->as('p')->on(Q::n('u.id')->eq(Q::n('p.user_id'))); -``` - -```sql -SELECT u.name, p.title FROM users AS u -JOIN posts AS p ON u.id = p.user_id -``` - -#### LEFT JOIN +`join()`, `leftJoin()`, `rightJoin()`, `crossJoin()` are shared; alias with +`->as()` and constrain with `->on(...)` or `->using('col')`. ```php $q = Q::select(Q::n('u.name'), Q::n('p.title')) @@ -352,97 +369,82 @@ $q = Q::select(Q::n('u.name'), Q::n('p.title')) ``` ```sql +-- PostgreSQL / MySQL / MariaDB SELECT u.name, p.title FROM users AS u LEFT JOIN posts AS p ON u.id = p.user_id ``` -#### JOIN with USING - -```php -$q = Q::select(Q::n('u.name'), Q::n('p.title')) - ->from(Q::n('users'))->as('u') - ->join(Q::n('posts'))->as('p')->using('user_id'); -``` - -```sql -SELECT u.name, p.title FROM users AS u -JOIN posts AS p USING (user_id) -``` - -### Aggregation & grouping +#### LATERAL join -#### GROUP BY with an aggregate +Supported by PostgreSQL and MySQL — MariaDB has no `LATERAL`. ```php -$q = Q::select(Q::n('department')) - ->select(Q\Func::count(Q::n('*')))->as('employee_count') - ->from(Q::n('employees')) - ->groupBy(Q::n('department')); +$q = Q::select(Q::n('*')) + ->from(Q::n('orders'))->as('o') + ->joinLateral( + Q::select(Q::n('*'))->from(Q::n('items'))->as('i') + ->where(Q::n('i.order_id')->eq(Q::n('o.id'))) + ->limit(Q::int(3)), + )->as('top')->on(Q::bool(true)); ``` ```sql -SELECT department, count(*) AS employee_count -FROM employees -GROUP BY department +-- PostgreSQL +SELECT * FROM orders AS o +JOIN LATERAL (SELECT * FROM items AS i WHERE i.order_id = o.id LIMIT 3) AS top ON true +-- MySQL +SELECT * FROM orders AS o +JOIN LATERAL (SELECT * FROM items AS i WHERE i.order_id = o.id LIMIT 3) AS top ON TRUE ``` -#### GROUP BY with HAVING +`fromLateral()`, `leftJoinLateral()` and `crossJoinLateral()` are also available. +Within the MySQL family, `LATERAL` is MySQL-only — validating against +`Target::mariaDb()` reports *"LATERAL requires MySQL"*. + +### Aggregation & grouping ```php -$q = Q::select(Q::n('department')) - ->select(Q\Func::avg(Q::n('salary')))->as('avg_salary') +$q = Q::select(Q::n('department'), Q\Func::count(Q::n('*')))->as('n') ->from(Q::n('employees')) ->groupBy(Q::n('department')) - ->having(Q\Func::avg(Q::n('salary'))->gt(Q::int(50000))); + ->having(Q\Func::count(Q::n('*'))->gt(Q::int(5))); ``` ```sql -SELECT department, avg(salary) AS avg_salary -FROM employees -GROUP BY department -HAVING avg(salary) > 50000 +-- PostgreSQL +SELECT department, count(*) AS n FROM employees +GROUP BY department HAVING count(*) > 5 +-- MySQL / MariaDB +SELECT department, COUNT(*) AS n FROM employees +GROUP BY department HAVING COUNT(*) > 5 ``` -#### GROUP BY ROLLUP +#### ROLLUP + +The engines spell super-aggregate grouping differently: ```php +// PostgreSQL: GROUP BY ROLLUP (...) $q = Q::select(Q::n('department'), Q::n('job_title'), Q\Func::sum(Q::n('salary'))) ->from(Q::n('employees')) - ->groupBy() - ->rollup( - Q::exps(Q::n('department')), - Q::exps(Q::n('job_title')), - ); -``` + ->groupBy()->rollup(Q::exps(Q::n('department')), Q::exps(Q::n('job_title'))); +// SELECT department, job_title, sum(salary) FROM employees +// GROUP BY ROLLUP (department, job_title) -```sql -SELECT department, job_title, sum(salary) -FROM employees -GROUP BY ROLLUP (department, job_title) -``` - -#### GROUP BY GROUPING SETS - -```php +// MySQL / MariaDB: GROUP BY ... WITH ROLLUP $q = Q::select(Q::n('department'), Q::n('job_title'), Q\Func::sum(Q::n('salary'))) ->from(Q::n('employees')) - ->groupBy() - ->groupingSets( - Q::exps(Q::n('department')), - Q::exps(Q::n('job_title')), - Q::exps(), - ); + ->groupBy(Q::n('department'), Q::n('job_title'))->withRollup(); +// SELECT department, job_title, SUM(salary) FROM employees +// GROUP BY department, job_title WITH ROLLUP ``` -```sql -SELECT department, job_title, sum(salary) -FROM employees -GROUP BY GROUPING SETS (department, job_title, ()) -``` +> PostgreSQL also supports `->groupingSets(...)` and `->cube(...)`. ### Window functions -#### ROW_NUMBER over a partition +Aggregate and window functions carry `->over()` (inline) or `->over('w')` +(named), refined with `->partitionBy(...)`, `->orderBy(...)` and frame clauses. ```php $q = Q::select( @@ -453,9 +455,14 @@ $q = Q::select( ``` ```sql +-- PostgreSQL SELECT name, salary, row_number() OVER (PARTITION BY department ORDER BY salary DESC) FROM employees +-- MySQL / MariaDB +SELECT name, salary, + ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) +FROM employees ``` #### Named windows @@ -470,130 +477,203 @@ $q = Q::select( ``` ```sql -SELECT sum(salary) OVER w, avg(salary) OVER w +-- MySQL / MariaDB (PostgreSQL renders the same, lower-cased) +SELECT SUM(salary) OVER w, AVG(salary) OVER w FROM empsalary WINDOW w AS (PARTITION BY depname ORDER BY salary DESC) ``` -### JSON operations +#### Frames + +```php +use Flowpack\QueryObjectBuilder\MySQL\Q; + +// Running total: ROWS UNBOUNDED PRECEDING +$q = Q::select( + Q\Func::sum(Q::n('val'))->over() + ->partitionBy(Q::n('subject'))->orderBy(Q::n('time')) + ->rows(Q::unboundedPreceding()), +)->from(Q::n('observations')); +// SELECT SUM(val) OVER (PARTITION BY subject ORDER BY time ROWS UNBOUNDED PRECEDING) FROM observations + +// Moving average: ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING +$q = Q::select( + Q\Func::avg(Q::n('val'))->over() + ->partitionBy(Q::n('subject'))->orderBy(Q::n('time')) + ->rows(Q::preceding(Q::int(1)), Q::following(Q::int(1))), +)->from(Q::n('observations')); +// SELECT AVG(val) OVER (PARTITION BY subject ORDER BY time ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) FROM observations +``` + +> MariaDB additionally offers distribution aggregates — `Q\Func::median()`, +> `Q\Func::percentileCont()` / `percentileDisc()` with `->withinGroup()` — which +> validate against `Target::mariaDb()` only. + +### JSON + +Both families build hierarchical data in the database, but with each engine's own +function set. #### Build a JSON object +Both families build objects from key/value properties with a `->prop()` builder, +under each dialect's own function name: + ```php +// PostgreSQL: json_build_object() $q = Q::select( Q\Func::jsonBuildObject() ->prop('id', Q::n('id')) - ->prop('name', Q::n('name')) - ->prop('email', Q::n('email')), + ->prop('name', Q::n('name')), )->from(Q::n('users')); -``` +// SELECT json_build_object('id', id, 'name', name) FROM users -```sql -SELECT json_build_object('id', id, 'name', name, 'email', email) -FROM users +// MySQL / MariaDB: JSON_OBJECT() +$q = Q::select( + Q\Func::jsonObject() + ->prop('id', Q::n('id')) + ->prop('name', Q::n('name')), +)->from(Q::n('users')); +// SELECT JSON_OBJECT('id', id, 'name', name) FROM users ``` -#### JSON aggregation +The builder keeps insertion order, and `->propIf($cond, 'key', $value)` / +`->applyIf(...)` / `->unset('key')` let you shape the object incrementally: ```php -$q = Q::select( - Q::n('department'), - Q\Func::jsonAgg( - Q\Func::jsonBuildObject() - ->prop('name', Q::n('name')) - ->prop('salary', Q::n('salary')), - )->orderBy(Q::n('name')), -) - ->from(Q::n('employees')) - ->groupBy(Q::n('department')); +$obj = Q\Func::jsonObject() + ->prop('id', Q::n('id')) + ->propIf($includeName, 'name', Q::n('name')); ``` -```sql -SELECT department, - json_agg(json_build_object('name', name, 'salary', salary) ORDER BY name) -FROM employees -GROUP BY department -``` +Property keys are string literals; for a computed key, drop to the +`Q::func('json_build_object'|'JSON_OBJECT', ...)` escape hatch. -#### `selectJson` for a JSON-first query +#### JSON-first query (`selectJson`) -When the query's primary output is a single JSON object, `Q::selectJson()` -makes it the first selection and lets you refine it later with -`applySelectJson()`: +When a query's primary output is a single JSON object, `Q::selectJson($obj)` +makes it the first select element; refine it later with `applySelectJson()` and +name it with `->as()`. Both families support it — pass the family's own object +builder. ```php $q = Q::selectJson( - Q\Func::jsonBuildObject() - ->prop('Title', Q::n('books.title')) - ->prop('ID', Q::n('books.book_id')), + // PostgreSQL: Q\Func::jsonBuildObject() — MySQL / MariaDB: Q\Func::jsonObject() + Q\Func::jsonObject() + ->prop('id', Q::n('authors.author_id')) + ->prop('name', Q::n('authors.name')), ) - ->from(Q::n('books')) - ->where(Q::n('books.book_id')->eq(Q::arg(2))); + ->from(Q::n('authors')) + ->where(Q::n('authors.author_id')->eq(Q::arg(123))); + +// The builder is a blueprint — add to the JSON selection later: +$q = $q->applySelectJson(fn ($obj) => $obj->prop('postCount', Q\Func::count(Q::n('posts')))); ``` ```sql -SELECT json_build_object('Title', books.title, 'ID', books.book_id) -FROM books -WHERE books.book_id = $1 +-- PostgreSQL +SELECT json_build_object('id', authors.author_id, 'name', authors.name, 'postCount', count(posts)) +FROM authors WHERE authors.author_id = $1 +-- MySQL / MariaDB +SELECT JSON_OBJECT('id', authors.author_id, 'name', authors.name, 'postCount', COUNT(posts)) +FROM authors WHERE authors.author_id = ? ``` -### Array operations - -#### Array construction +#### Aggregate rows into a JSON array ```php -$q = Q::select(Q::array(Q::string('a'), Q::string('b'), Q::string('c'))); -``` +// PostgreSQL: json_agg(...) +$q = Q::select( + Q::n('department'), + Q\Func::jsonAgg( + Q\Func::jsonBuildObject()->prop('name', Q::n('name'))->prop('salary', Q::n('salary')), + )->orderBy(Q::n('name')), +)->from(Q::n('employees'))->groupBy(Q::n('department')); +// SELECT department, json_agg(json_build_object('name', name, 'salary', salary) ORDER BY name) +// FROM employees GROUP BY department -```sql -SELECT ARRAY['a','b','c'] +// MySQL / MariaDB: JSON_ARRAYAGG(...); COALESCE with JSON_ARRAY() to avoid NULL on empty sets +$q = Q::select( + Q::n('department'), + Q::coalesce( + Q\Func::jsonArrayAgg( + Q\Func::jsonObject()->prop('name', Q::n('name'))->prop('salary', Q::n('salary')), + ), + Q\Func::jsonArray(), + ), +)->from(Q::n('employees'))->groupBy(Q::n('department')); +// SELECT department, COALESCE(JSON_ARRAYAGG(JSON_OBJECT('name', name, 'salary', salary)), JSON_ARRAY()) +// FROM employees GROUP BY department ``` -#### Array functions +#### JSON path access — MySQL family ```php -$q = Q::select( - Q\Func::arrayAppend(Q::array(Q::int(1), Q::int(2)), Q::int(3)), - Q\Func::arrayLength(Q::array(Q::int(1), Q::int(2), Q::int(3)), Q::int(1)), -); -``` +use Flowpack\QueryObjectBuilder\MySQL\Q; -```sql -SELECT array_append(ARRAY[1,2], 3), array_length(ARRAY[1,2,3], 1) +// MySQL: the -> and ->> operators +$q = Q::select(Q::n('doc')->jsonExtract(Q::string('$.name')))->from(Q::n('t')); +// SELECT doc -> '$.name' FROM t + +// MariaDB: the function form (also works on MySQL) +$q = Q::select(Q\Func::jsonExtract(Q::n('doc'), Q::string('$.name')))->from(Q::n('t')); +// SELECT JSON_EXTRACT(doc, '$.name') FROM t ``` -#### UNNEST +The `->` / `->>` operators validate against `Target::mysql()` only; the +`JSON_EXTRACT` / `JSON_UNQUOTE` function form is portable across both engines. + +#### JSON_TABLE — MySQL family + +`Q::jsonTable(doc, path)` is a FROM-clause table function; define its columns +with `->columns(closure)`. `->column()` opens a value column and `->path()` gives +its JSON path; `->forOrdinality()` / `->existsPath()` pick the other leaf forms, +the miss handlers (`->defaultOnEmpty()` / `->nullOnError()` / …) attach, and +`->nested()->path()->columns()` recurses. ```php -$q = Q::select(Q::n('*')) - ->from(Q\Func::unnest(Q::array(Q::string('a'), Q::string('b'), Q::string('c')))) - ->as('t')->columnAliases('value'); +$q = Q::select(Q::n('jt.id'), Q::n('jt.tag')) + ->from(Q::n('t')) + ->from( + Q::jsonTable(Q::n('t.doc'), '$[*]')->columns(fn ($c) => $c + ->column('id', 'INT')->path('$.id') + ->column('ord')->forOrdinality() + ->nested()->path('$.tags[*]')->columns(fn ($tags) => $tags + ->column('tag', 'VARCHAR(50)')->path('$'))), + )->as('jt'); ``` ```sql -SELECT * FROM unnest(ARRAY['a','b','c']) AS t (value) +-- MySQL / MariaDB +SELECT jt.id, jt.tag FROM t, + JSON_TABLE(t.doc, '$[*]' COLUMNS ( + id INT PATH '$.id', + ord FOR ORDINALITY, + NESTED PATH '$.tags[*]' COLUMNS (tag VARCHAR(50) PATH '$'))) AS jt ``` -#### Array aggregation +### Arrays (PostgreSQL) + +Native arrays are a PostgreSQL feature. ```php +use Flowpack\QueryObjectBuilder\PostgreSQL\Q; + $q = Q::select( - Q::n('department'), - Q\Func::arrayAgg(Q::n('name'))->orderBy(Q::n('name')), -) - ->from(Q::n('employees')) - ->groupBy(Q::n('department')); -``` + Q\Func::arrayAppend(Q::array(Q::int(1), Q::int(2)), Q::int(3)), + Q\Func::arrayLength(Q::array(Q::int(1), Q::int(2), Q::int(3)), Q::int(1)), +); +// SELECT array_append(ARRAY[1,2], 3), array_length(ARRAY[1,2,3], 1) -```sql -SELECT department, array_agg(name ORDER BY name) -FROM employees -GROUP BY department +$q = Q::select(Q::n('*')) + ->from(Q\Func::unnest(Q::array(Q::string('a'), Q::string('b')))) + ->as('t')->columnAliases('value'); +// SELECT * FROM unnest(ARRAY['a','b']) AS t (value) ``` ### Subqueries -#### EXISTS +#### EXISTS and IN ```php $q = Q::select(Q::n('name')) @@ -606,27 +686,11 @@ $q = Q::select(Q::n('name')) ``` ```sql +-- PostgreSQL / MySQL / MariaDB SELECT name FROM users WHERE EXISTS (SELECT 1 FROM posts WHERE posts.user_id = users.id) ``` -#### IN with a subquery - -```php -$q = Q::select(Q::n('name')) - ->from(Q::n('users')) - ->where(Q::n('id')->in( - Q::select(Q::n('user_id')) - ->from(Q::n('posts')) - ->where(Q::n('published')->eq(Q::bool(true))), - )); -``` - -```sql -SELECT name FROM users -WHERE id IN (SELECT user_id FROM posts WHERE published = true) -``` - #### IN with bound arguments ```php @@ -638,56 +702,33 @@ $q = Q::select(Q::n('username')) ``` ```sql -SELECT username FROM accounts WHERE id IN ($1, $2, $3) --- args: [1, 2, 3] -``` - -#### Correlated subquery - -```php -$q = Q::select(Q::n('name'), Q::n('salary')) - ->from(Q::n('employees'))->as('e1') - ->where(Q::n('salary')->gt( - Q::select(Q\Func::avg(Q::n('salary'))) - ->from(Q::n('employees'))->as('e2') - ->where(Q::n('e1.department')->eq(Q::n('e2.department'))), - )); -``` - -```sql -SELECT name, salary FROM employees AS e1 -WHERE salary > ( - SELECT avg(salary) FROM employees AS e2 - WHERE e1.department = e2.department -) +-- PostgreSQL +SELECT username FROM accounts WHERE id IN ($1, $2, $3) -- args: [1, 2, 3] +-- MySQL / MariaDB +SELECT username FROM accounts WHERE id IN (?, ?, ?) -- args: [1, 2, 3] ``` -#### Subquery in FROM +#### ANY / ALL ```php -$q = Q::select(Q::n('avg_quantity')) - ->from( - Q::select(Q\Func::avg(Q::n('quantity')))->as('avg_quantity') - ->from(Q::n('sales')) - ->groupBy(Q::n('brand')), - )->as('t'); +$q = Q::select(Q::n('id'))->from(Q::n('users')) + ->where(Q::n('id')->eq(Q::any( + Q::select(Q::n('user_id'))->from(Q::n('orders')), + ))); ``` ```sql -SELECT avg_quantity FROM ( - SELECT avg(quantity) AS avg_quantity FROM sales GROUP BY brand -) AS t +-- PostgreSQL / MySQL / MariaDB +SELECT id FROM users WHERE id = ANY (SELECT user_id FROM orders) ``` -### Common Table Expressions (WITH) - -#### Simple CTE +### CTEs (WITH) ```php $q = Q::with('recent_orders')->as( Q::select(Q::n('*')) ->from(Q::n('orders')) - ->where(Q::n('created_at')->gt(Q::string('2023-01-01'))), + ->where(Q::n('created_at')->gt(Q::arg('2023-01-01'))), ) ->select(Q::n('customer_name'), Q\Func::count(Q::n('*'))) ->from(Q::n('recent_orders')) @@ -695,85 +736,177 @@ $q = Q::with('recent_orders')->as( ``` ```sql +-- PostgreSQL (MySQL/MariaDB render the same, with ? and upper-cased count) WITH recent_orders AS ( - SELECT * FROM orders WHERE created_at > '2023-01-01' + SELECT * FROM orders WHERE created_at > $1 ) SELECT customer_name, count(*) FROM recent_orders GROUP BY customer_name ``` -#### Recursive CTE +`Q::withRecursive('t')->columnNames(...)->as(...)` builds recursive CTEs, and +`->appendWith(...)` chains several. On PostgreSQL a `WITH` precedes any +statement; on the MySQL family a leading `WITH` before `UPDATE`/`DELETE` is +MySQL-only (and MariaDB 12.3+): + +```php +use Flowpack\QueryObjectBuilder\MySQL\Q; + +$q = Q::with('stale')->as(Q::select(Q::n('id'))->from(Q::n('sessions'))->where(Q::n('expired')->eq(Q::int(1)))) + ->deleteFrom(Q::n('users'))->where(Q::n('id')->in(Q::select(Q::n('id'))->from(Q::n('stale')))); +// WITH stale AS (SELECT id FROM sessions WHERE expired = 1) DELETE FROM users WHERE id IN (SELECT id FROM stale) +// ok against Target::mysql() and Target::mariaDb('12.3'); reported against Target::mariaDb('11.4') +``` + +### INSERT & upsert + +The basic INSERT surface is shared: `->columnNames(...)`, `->values(...)` +(repeat for multiple rows), `->setMap([...])`, and `->query(...)` to insert from +a SELECT. ```php -$q = Q::withRecursive('employee_recursive') - ->columnNames('distance', 'employee_name', 'manager_name')->as( - Q::select(Q::int(1), Q::n('employee_name'), Q::n('manager_name')) - ->from(Q::n('employee')) - ->where(Q::n('manager_name')->eq(Q::string('Mary'))) - ->union()->all() - ->select(Q::n('er.distance')->op('+', Q::int(1)), Q::n('e.employee_name'), Q::n('e.manager_name')) - ->from(Q::n('employee_recursive'))->as('er') - ->from(Q::n('employee'))->as('e') - ->where(Q::n('er.employee_name')->eq(Q::n('e.manager_name'))), - ) - ->select(Q::n('distance'), Q::n('employee_name'))->from(Q::n('employee_recursive')); +$q = Q::insertInto(Q::n('users')) + ->columnNames('name', 'email') + ->values(Q::arg('Jane Doe'), Q::arg('jane@example.com')); ``` ```sql -WITH RECURSIVE employee_recursive(distance, employee_name, manager_name) AS ( - SELECT 1, employee_name, manager_name - FROM employee - WHERE manager_name = 'Mary' - UNION ALL - SELECT er.distance + 1, e.employee_name, e.manager_name - FROM employee_recursive AS er, employee AS e - WHERE er.employee_name = e.manager_name -) -SELECT distance, employee_name FROM employee_recursive +-- PostgreSQL +INSERT INTO users (name, email) VALUES ($1, $2) -- args: ['Jane Doe', 'jane@example.com'] +-- MySQL / MariaDB +INSERT INTO users (name, email) VALUES (?, ?) -- args: ['Jane Doe', 'jane@example.com'] ``` -### Functions & operators +#### Upsert -#### String functions +The engines model conflict handling differently: ```php -$q = Q::select( - Q\Func::upper(Q::n('name')), - Q\Func::lower(Q::n('email')), - Q\Func::initcap(Q::n('title')), -)->from(Q::n('users')); +// PostgreSQL: INSERT ... ON CONFLICT ... DO UPDATE +use Flowpack\QueryObjectBuilder\PostgreSQL\Q; + +$q = Q::insertInto(Q::n('distributors')) + ->columnNames('did', 'dname') + ->values(Q::int(5), Q::string('Gizmo Transglobal')) + ->onConflict(Q::n('did'))->doUpdate() + ->set('dname', Q::n('EXCLUDED.dname')); +// INSERT INTO distributors (did, dname) VALUES (5, 'Gizmo Transglobal') +// ON CONFLICT (did) DO UPDATE SET dname = EXCLUDED.dname ``` -```sql -SELECT upper(name), lower(email), initcap(title) -FROM users +```php +// MySQL / MariaDB: INSERT ... ON DUPLICATE KEY UPDATE +use Flowpack\QueryObjectBuilder\MySQL\Q; + +// MySQL: alias the proposed row with AS new, reference it as new.col +$q = Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits')->values(Q::arg(1), Q::arg(10))->as('new') + ->onDuplicateKeyUpdate()->set('hits', Q::n('new.hits')); +// INSERT INTO t (id,hits) VALUES (?,?) AS new ON DUPLICATE KEY UPDATE hits = new.hits + +// Portable: the VALUES(col) function works on both engines +$q = Q::insertInto(Q::n('t')) + ->columnNames('id', 'hits')->values(Q::arg(1), Q::arg(10)) + ->onDuplicateKeyUpdate()->set('hits', Q::values('hits')); +// INSERT INTO t (id,hits) VALUES (?,?) ON DUPLICATE KEY UPDATE hits = VALUES(hits) ``` -#### Date/time functions +`->as('new')` is MySQL-only (reported against MariaDB). The MySQL family also has +`Q::insertInto(...)->ignore()` (`INSERT IGNORE`) and `Q::replaceInto(...)` (a +`REPLACE` statement with the same surface). + +#### RETURNING ```php -$q = Q::select( - Q\Func::extract('year', Q::n('created_at')), - Q::n('created_at')->plus(Q::interval('1 day')), -)->from(Q::n('orders')); +// PostgreSQL +use Flowpack\QueryObjectBuilder\PostgreSQL\Q; + +$q = Q::insertInto(Q::n('users')) + ->columnNames('name')->values(Q::arg('Jane')) + ->returning(Q::n('id'), Q::n('created_at')); +// INSERT INTO users (name) VALUES ($1) RETURNING id, created_at +``` + +```php +// MariaDB (INSERT / REPLACE / single-table DELETE) — reported against Target::mysql() +use Flowpack\QueryObjectBuilder\MySQL\Q; + +$q = Q::insertInto(Q::n('t'))->columnNames('a')->values(Q::arg(1)) + ->returning(Q::n('id'))->as('new_id'); +// INSERT INTO t (a) VALUES (?) RETURNING id AS new_id +``` + +### UPDATE + +```php +$q = Q::update(Q::n('films')) + ->set('kind', Q::arg('Dramatic')) + ->where(Q::n('kind')->eq(Q::arg('Drama'))); ``` ```sql -SELECT EXTRACT(year FROM created_at), created_at + INTERVAL '1 day' -FROM orders +-- PostgreSQL +UPDATE films SET kind = $1 WHERE kind = $2 -- args: ['Dramatic', 'Drama'] +-- MySQL / MariaDB +UPDATE films SET kind = ? WHERE kind = ? -- args: ['Dramatic', 'Drama'] +``` + +Joining another table is spelled per family — PostgreSQL uses `UPDATE ... FROM`, +the MySQL family uses a multi-table `JOIN`: + +```php +// PostgreSQL +$q = Q::update(Q::n('employees'))->as('e') + ->set('department_name', Q::n('d.name')) + ->from(Q::n('departments'))->as('d') + ->where(Q::n('e.department_id')->eq(Q::n('d.id'))); +// UPDATE employees AS e SET department_name = d.name FROM departments AS d WHERE e.department_id = d.id + +// MySQL / MariaDB +$q = Q::update(Q::n('t1')) + ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->set('t1.col1', Q::n('t2.col1')) + ->where(Q::n('t2.col2')->isNull()); +// UPDATE t1 LEFT JOIN t2 ON t1.id = t2.id SET t1.col1 = t2.col1 WHERE t2.col2 IS NULL ``` -#### Mathematical operators +> On the MySQL family, `->orderBy()` / `->limit()` are available on +> *single-table* UPDATE only; combining them with a join raises a +> `QueryBuilderException` when the query is built. + +### DELETE ```php -$q = Q::select(Q::n('price')->op('*', Q::n('quantity')))->as('total') - ->from(Q::n('order_items')); +$q = Q::deleteFrom(Q::n('films')) + ->where(Q::n('kind')->neq(Q::arg('Musical'))); ``` ```sql -SELECT price * quantity AS total FROM order_items +-- PostgreSQL +DELETE FROM films WHERE kind <> $1 -- args: ['Musical'] +-- MySQL / MariaDB +DELETE FROM films WHERE kind <> ? -- args: ['Musical'] ``` -#### CASE expressions +Joining is `DELETE ... USING` on PostgreSQL and a multi-table `JOIN` on the MySQL +family: + +```php +// PostgreSQL +$q = Q::deleteFrom(Q::n('films')) + ->using(Q::n('producers')) + ->where(Q::n('producer_id')->eq(Q::n('producers.id'))); +// DELETE FROM films USING producers WHERE producer_id = producers.id + +// MySQL / MariaDB +$q = Q::deleteFrom(Q::n('t1')) + ->leftJoin(Q::n('t2'))->on(Q::n('t1.id')->eq(Q::n('t2.id'))) + ->where(Q::n('t2.id')->isNull()); +// DELETE t1.* FROM t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.id IS NULL +``` + +### Functions & operators + +#### CASE ```php $q = Q::select( @@ -787,33 +920,71 @@ $q = Q::select( ``` ```sql +-- PostgreSQL / MySQL / MariaDB SELECT name, - CASE - WHEN salary < 30000 THEN 'Low' - WHEN salary < 70000 THEN 'Medium' - ELSE 'High' - END + CASE WHEN salary < 30000 THEN 'Low' + WHEN salary < 70000 THEN 'Medium' + ELSE 'High' END FROM employees ``` #### Casts ```php -$q = Q::select(Q::n('articles.content')->cast('text')) - ->from(Q::n('articles')) - ->where(Q::n('articles.content')->cast('text')->ilike(Q::arg('%foo%'))); +// PostgreSQL: the :: operator via ->cast() +$q = Q::select(Q::n('articles.content')->cast('text'))->from(Q::n('articles')); +// SELECT articles.content::text FROM articles + +// MySQL / MariaDB: CAST / CONVERT through the facade +$q = Q::select(Q::cast(Q::n('a'), 'UNSIGNED'), Q::convert(Q::n('a'), 'DECIMAL(10,2)')); +// SELECT CAST(a AS UNSIGNED), CONVERT(a, DECIMAL(10,2)) ``` -```sql -SELECT articles.content::text FROM articles WHERE articles.content::text ILIKE $1 --- args: ['%foo%'] +#### Scalar functions + +Each family exposes its own curated function set via `Q\Func`: + +```php +// PostgreSQL +$q = Q::select(Q\Func::upper(Q::n('name')), Q\Func::extract('year', Q::n('created_at'))) + ->from(Q::n('users')); +// SELECT upper(name), EXTRACT(year FROM created_at) FROM users + +// MySQL / MariaDB +$q = Q::select(Q\Func::upper(Q::n('name')), Q\Func::dateAdd(Q::n('created'), Q::interval(Q::int(1), 'DAY'))) + ->from(Q::n('users')); +// SELECT UPPER(name), DATE_ADD(created, INTERVAL 1 DAY) FROM users +``` + +Anything not on `Q\Func` is reachable through the raw escape hatch +`Q::func('NAME', ...args)`. + +### Locking + +`->forUpdate()` (optionally `->nowait()` / `->skipLocked()`) is shared. The +shared lock diverges within the MySQL family: + +```php +use Flowpack\QueryObjectBuilder\MySQL\Q; + +// MySQL: FOR SHARE (+ of() / nowait() / skipLocked()) +$q = Q::select(Q::n('id'))->from(Q::n('t'))->forShare()->of('t')->nowait(); +// SELECT id FROM t FOR SHARE OF t NOWAIT + +// MariaDB: LOCK IN SHARE MODE +$q = Q::select(Q::n('id'))->from(Q::n('t'))->lockInShareMode(); +// SELECT id FROM t LOCK IN SHARE MODE ``` +`->of(...)` is MySQL-only even on `FOR UPDATE`; validating a query that uses it +against `Target::mariaDb()` reports it. + ## Parameters ### Positional parameters -Each `Q::arg()` becomes a numbered placeholder in order of appearance: +Each `Q::arg()` becomes a placeholder in order of appearance — `$1`, `$2`, … on +PostgreSQL, `?` on the MySQL family: ```php $q = Q::select(Q::n('*')) @@ -824,44 +995,60 @@ $q = Q::select(Q::n('*')) )); [$sql, $args] = Q::build($q)->toSql(); -``` - -```sql -SELECT * FROM users WHERE name LIKE $1 AND active = $2 --- args: ['John%', true] +// PostgreSQL: SELECT * FROM users WHERE name LIKE $1 AND active = $2 args: ['John%', true] +// MySQL: SELECT * FROM users WHERE name LIKE ? AND active = ? args: ['John%', true] ``` ### Named parameters -`Q::bind()` declares a named placeholder; bind the values with -`withNamedArgs()`. Reusing the same name reuses its placeholder: +`Q::bind()` declares a named placeholder; bind the values with `withNamedArgs()`: ```php $q = Q::select(Q::n('*')) ->from(Q::n('users')) - ->where(Q::and( - Q::n('name')->like(Q::bind('search')), - Q::n('active')->eq(Q::bind('is_active')), - )); + ->where(Q::n('name')->like(Q::bind('search'))); -[$sql, $args] = Q::build($q) - ->withNamedArgs(['search' => 'John%', 'is_active' => true]) - ->toSql(); +[$sql, $args] = Q::build($q)->withNamedArgs(['search' => 'John%'])->toSql(); ``` -```sql -SELECT * FROM users WHERE name LIKE $1 AND active = $2 --- args: ['John%', true] -``` +On PostgreSQL a reused name reuses its `$n` placeholder. On the MySQL family a +`?` placeholder is not reusable, so each occurrence of a name emits its own `?`, +each bound to the same value. Named and positional parameters can be mixed. + +## Validation & errors + +By default the builder validates while rendering; problems are collected and +thrown together as one `QueryBuilderException` from `toSql()`. There are three +mechanisms: + +- **Advisory value checks** — a suspect *value or modifier* in an otherwise + well-formed statement: an invalid identifier or cast type, an empty `CASE`, a + `DISTINCT` on an aggregate whose grammar rejects it. These throw when built but + still render under `Q::build($q)->withoutValidation()->toSql()` — the escape + hatch for callers who know better. + + ```php + Q::build(Q::n('foo bar'))->toSql(); // throws: identifier: invalid: foo bar + [$sql] = Q::build(Q::n('foo bar'))->withoutValidation()->toSql(); // 'foo bar' + ``` -Named and positional parameters can be mixed in the same query. +- **Mutually-exclusive builder state** — two options that cannot coexist in one + statement (e.g. setting both `values` and a `query` on an INSERT, or + `ORDER BY`/`LIMIT` on a multi-table UPDATE/DELETE). This is builder-API misuse, + so it *always* throws, even with validation disabled. + +- **Target validation** (MySQL family only, opt-in) — + `Q::build($q)->withValidateTarget(Target::mysql() | mariaDb($version))` reports + constructs the target engine/version cannot express. See + [MySQL & MariaDB](#opt-in-target-validation). ## Executing queries -The builder is driver-agnostic: it produces a SQL string with PostgreSQL -numbered placeholders (`$1`, `$2`, …) and a positional argument list. Feed both -to any layer that speaks PostgreSQL's native placeholders, for example the -[`pgsql` extension](https://www.php.net/manual/en/book.pgsql.php): +The builder is driver-agnostic: it produces a SQL string with the dialect's +placeholders and a positional argument list. Feed both to any layer that speaks +that dialect's placeholders. + +**PostgreSQL** (e.g. the [`pgsql` extension](https://www.php.net/manual/en/book.pgsql.php)): ```php use Flowpack\QueryObjectBuilder\PostgreSQL\Q; @@ -880,9 +1067,25 @@ while ($row = pg_fetch_assoc($result)) { } ``` -By default, identifiers are validated while building and an invalid name throws -a `QueryBuilderException`. Skip validation with -`Q::build($q)->withoutValidation()->toSql()` when you trust the input. +**MySQL / MariaDB** (e.g. PDO): + +```php +use Flowpack\QueryObjectBuilder\MySQL\Q; + +$pdo = new PDO('mysql:host=localhost;dbname=app', 'app', 'secret'); + +$q = Q::select(Q::n('name'), Q::n('email')) + ->from(Q::n('users')) + ->where(Q::n('active')->eq(Q::arg(true))); + +[$sql, $args] = Q::build($q)->toSql(); + +$stmt = $pdo->prepare($sql); +$stmt->execute($args); +foreach ($stmt as $row) { + printf("Name: %s, Email: %s\n", $row['name'], $row['email']); +} +``` ## Best practices @@ -902,8 +1105,8 @@ breaking the fluent chain: ```php $q = Q::update(Q::n('films')) - ->set('kind', Q::string('Dramatic')) - ->where(Q::n('kind')->eq(Q::string('Drama'))) + ->set('kind', Q::arg('Dramatic')) + ->where(Q::n('kind')->eq(Q::arg('Drama'))) ->applyIf($onlyActive, fn ($q) => $q->where(Q::n('archived')->eq(Q::bool(false)))); ``` diff --git a/composer.json b/composer.json index 3ac7d6f..070ff31 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,14 @@ { "name": "flowpack/query-object-builder", - "description": "A fluent, immutable, fully-typed SQL query builder for PHP (PostgreSQL, with more dialects planned).", + "description": "A fluent, immutable, fully-typed SQL query builder for PHP (PostgreSQL, MySQL and MariaDB).", "type": "library", "keywords": [ "sql", "query-builder", "postgresql", "postgres", + "mysql", + "mariadb", "database", "immutable", "json" diff --git a/src/MySQL/Builder/JsonObjectBuilder.php b/src/MySQL/Builder/JsonObjectBuilder.php new file mode 100644 index 0000000..c663e29 --- /dev/null +++ b/src/MySQL/Builder/JsonObjectBuilder.php @@ -0,0 +1,83 @@ + $props + */ + public function __construct( + private readonly array $props = [], + ) { + } + + /** + * Set a property; if the key already exists its value is replaced in place. + */ + public function prop(string $key, Exp $value): self + { + $props = $this->props; + $props[$key] = $value; + + return new self($props); + } + + /** + * Set a property only if the condition is true. + */ + public function propIf(bool $condition, string $key, Exp $value): self + { + return $condition ? $this->prop($key, $value) : $this; + } + + /** + * Apply the given function to this builder only if the condition is true. + * + * @param callable(self): self $apply + */ + public function applyIf(bool $condition, callable $apply): self + { + return $condition ? $apply($this) : $this; + } + + /** + * Remove a property by key. + */ + public function unset(string $key): self + { + $props = $this->props; + unset($props[$key]); + + return new self($props); + } + + public function writeSql(SqlBuilder $sb): void + { + $s = 'JSON_OBJECT('; + + $i = 0; + foreach ($this->props as $key => $value) { + if ($i > 0) { + $s .= ','; + } + $s .= Literals::quoteLiteral($key) . ','; + $sb->writeString($s); + $s = ''; + + $value->writeSql($sb); + $i++; + } + + $sb->writeString($s . ')'); + } +} diff --git a/src/MySQL/Builder/SelectBuilder.php b/src/MySQL/Builder/SelectBuilder.php index c2ddc00..6965529 100644 --- a/src/MySQL/Builder/SelectBuilder.php +++ b/src/MySQL/Builder/SelectBuilder.php @@ -57,6 +57,20 @@ public function select(Exp ...$exps): SelectSelectBuilder return $this->addToSelectList(SelectSelectBuilder::class, array_values($exps)); } + /** + * Apply a function to the JSON selection (an empty JSON_OBJECT if none is set + * yet). The JSON selection is always written as the first select element. + * + * @param callable(JsonObjectBuilder): JsonObjectBuilder $apply + */ + public function applySelectJson(callable $apply): SelectJsonSelectBuilder + { + return $this->derive( + SelectJsonSelectBuilder::class, + selectJson: $apply($this->parts->selectJson ?? new JsonObjectBuilder()), + ); + } + /** * Add a table / subquery to the FROM clause. */ @@ -383,6 +397,8 @@ protected function derive( string $class, ?SelectQueryParts $parts = null, ?bool $distinct = null, + ?JsonObjectBuilder $selectJson = null, + ?string $selectJsonAlias = null, ?array $selectList = null, ?array $from = null, ?array $whereConjunction = null, @@ -399,6 +415,8 @@ protected function derive( ): SelectBuilder { $parts ??= new SelectQueryParts( distinct: $distinct ?? $this->parts->distinct, + selectJson: $selectJson ?? $this->parts->selectJson, + selectJsonAlias: $selectJsonAlias ?? $this->parts->selectJsonAlias, selectList: $selectList ?? $this->parts->selectList, from: $from ?? $this->parts->from, whereConjunction: $whereConjunction ?? $this->parts->whereConjunction, @@ -475,6 +493,18 @@ private function writeSelectParts(SqlBuilder $sb, SelectQueryParts $parts): void $s = $parts->distinct ? 'DISTINCT ' : ''; $needComma = false; + // The JSON selection is always the first select element. + if ($parts->selectJson !== null) { + $sb->writeString($s); + $s = ''; + + $parts->selectJson->writeSql($sb); + if ($parts->selectJsonAlias !== '') { + $s = ' AS ' . $parts->selectJsonAlias; + } + $needComma = true; + } + foreach ($parts->selectList as $out) { if ($needComma) { $s .= ','; diff --git a/src/MySQL/Builder/SelectJsonSelectBuilder.php b/src/MySQL/Builder/SelectJsonSelectBuilder.php new file mode 100644 index 0000000..9363ad9 --- /dev/null +++ b/src/MySQL/Builder/SelectJsonSelectBuilder.php @@ -0,0 +1,21 @@ +derive(self::class, selectJsonAlias: $alias); + } +} diff --git a/src/MySQL/Builder/SelectQueryParts.php b/src/MySQL/Builder/SelectQueryParts.php index 361bb3b..0cb2fbb 100644 --- a/src/MySQL/Builder/SelectQueryParts.php +++ b/src/MySQL/Builder/SelectQueryParts.php @@ -25,6 +25,8 @@ final class SelectQueryParts */ public function __construct( public readonly bool $distinct = false, + public readonly ?JsonObjectBuilder $selectJson = null, + public readonly string $selectJsonAlias = '', public readonly array $selectList = [], public readonly array $from = [], public readonly array $whereConjunction = [], @@ -41,7 +43,7 @@ public function __construct( public function isEmpty(): bool { - return !$this->distinct && $this->selectList === [] && $this->from === [] + return !$this->distinct && $this->selectJson === null && $this->selectList === [] && $this->from === [] && $this->whereConjunction === [] && $this->groupBys === [] && $this->havingConjunction === [] && $this->windows === [] && $this->orderBys === [] && $this->limit === null && $this->offset === null && $this->lockingClause === null; diff --git a/src/MySQL/Q.php b/src/MySQL/Q.php index cfce964..b16b415 100644 --- a/src/MySQL/Q.php +++ b/src/MySQL/Q.php @@ -22,6 +22,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\InsertBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\IntervalExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\IntLiteral; +use Flowpack\QueryObjectBuilder\MySQL\Builder\JsonObjectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\JsonTableBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Junction; use Flowpack\QueryObjectBuilder\MySQL\Builder\NullLiteral; @@ -29,6 +30,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\QueryBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\ReplaceBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectJsonSelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SelectSelectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\SqlWriter; use Flowpack\QueryObjectBuilder\MySQL\Builder\StringLiteral; @@ -62,6 +64,17 @@ public static function select(Exp ...$exps): SelectSelectBuilder return (new SelectBuilder())->select(...$exps); } + /** + * Start a select whose primary output is a single JSON object. + * + * The JSON selection is always the first select element; refine it later with + * `applySelectJson()` and alias it with `as()`. + */ + public static function selectJson(JsonObjectBuilder $obj): SelectJsonSelectBuilder + { + return (new SelectBuilder())->applySelectJson(static fn (JsonObjectBuilder $existing): JsonObjectBuilder => $obj); + } + /** * Start an INSERT statement into the given table. */ diff --git a/src/MySQL/Q/Func.php b/src/MySQL/Q/Func.php index b1ca784..7f66189 100644 --- a/src/MySQL/Q/Func.php +++ b/src/MySQL/Q/Func.php @@ -10,6 +10,7 @@ use Flowpack\QueryObjectBuilder\MySQL\Builder\ExtractExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; use Flowpack\QueryObjectBuilder\MySQL\Builder\GroupConcatBuilder; +use Flowpack\QueryObjectBuilder\MySQL\Builder\JsonObjectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Keyword; use Flowpack\QueryObjectBuilder\MySQL\Builder\OrderedSetAggBuilder; use Flowpack\QueryObjectBuilder\MySQL\Builder\Requirement; @@ -861,10 +862,15 @@ public static function convertTz(Exp $dt, Exp $fromTz, Exp $toTz): FuncExp // JSON functions - /** `JSON_OBJECT(key, value, ...)`. */ - public static function jsonObject(Exp ...$keysValues): FuncExp + /** + * `JSON_OBJECT(key, value, ...)`, built from key/value properties. + * + * Add properties with `->prop('key', $value)` (string keys only; for a + * computed key use the `Q::func('JSON_OBJECT', ...)` escape hatch). + */ + public static function jsonObject(): JsonObjectBuilder { - return self::call('JSON_OBJECT', ...$keysValues); + return new JsonObjectBuilder(); } /** `JSON_ARRAY(...)`. */ diff --git a/tests/MySQL/Q/FunctionsTest.php b/tests/MySQL/Q/FunctionsTest.php index c43e8f7..ad79f25 100644 --- a/tests/MySQL/Q/FunctionsTest.php +++ b/tests/MySQL/Q/FunctionsTest.php @@ -105,7 +105,7 @@ 'convertTz' => [fn () => Q\Func::convertTz(Q::n('d'), Q::string('+00:00'), Q::string('+02:00')), "CONVERT_TZ(d, '+00:00', '+02:00')"], // JSON - 'jsonObject' => [fn () => Q\Func::jsonObject(Q::string('k'), Q::n('v')), "JSON_OBJECT('k', v)"], + 'jsonObject' => [fn () => Q\Func::jsonObject()->prop('k', Q::n('v')), "JSON_OBJECT('k', v)"], 'jsonArray' => [fn () => Q\Func::jsonArray(Q::int(1), Q::int(2)), 'JSON_ARRAY(1, 2)'], 'jsonQuote' => [fn () => Q\Func::jsonQuote(Q::n('s')), 'JSON_QUOTE(s)'], 'jsonExtract' => [fn () => Q\Func::jsonExtract(Q::n('doc'), Q::string('$.a')), "JSON_EXTRACT(doc, '$.a')"], diff --git a/tests/MySQL/Q/JsonObjectBuilderTest.php b/tests/MySQL/Q/JsonObjectBuilderTest.php new file mode 100644 index 0000000..fca05ea --- /dev/null +++ b/tests/MySQL/Q/JsonObjectBuilderTest.php @@ -0,0 +1,58 @@ +toRenderSql('JSON_OBJECT()'); + }); + + it('nests objects', function () { + $b = Q\Func::jsonObject() + ->prop('name', Q::string('Henry')) + ->prop('address', Q\Func::jsonObject() + ->prop('street', Q::string('Main Street'))); + + expect($b)->toRenderSql( + "JSON_OBJECT('name', 'Henry', 'address', JSON_OBJECT('street', 'Main Street'))", + null, + ); + }); + + it('is immutable across prop and unset', function () { + $b1 = Q\Func::jsonObject()->prop('name', Q::string('Henry')); + $b2 = $b1->prop('age', Q::int(42)); + // Setting an existing key replaces its value while keeping its position. + $b3 = $b2->prop('name', Q::string('John J.')); + $b4 = $b3->unset('age'); + + expect($b1)->toRenderSql("JSON_OBJECT('name', 'Henry')", null); + expect($b2)->toRenderSql("JSON_OBJECT('name', 'Henry', 'age', 42)", null); + expect($b3)->toRenderSql("JSON_OBJECT('name', 'John J.', 'age', 42)", null); + expect($b4)->toRenderSql("JSON_OBJECT('name', 'John J.')", null); + }); + + it('adds a property only when the condition holds with propIf', function () { + $build = static fn (bool $withAge): JsonObjectBuilder => Q\Func::jsonObject() + ->prop('name', Q::string('Henry')) + ->propIf($withAge, 'age', Q::int(42)); + + expect($build(true))->toRenderSql("JSON_OBJECT('name', 'Henry', 'age', 42)"); + expect($build(false))->toRenderSql("JSON_OBJECT('name', 'Henry')"); + }); + + it('applies a block only when the condition holds with applyIf', function () { + $build = static fn (bool $withAddress): JsonObjectBuilder => Q\Func::jsonObject() + ->prop('name', Q::string('Henry')) + ->applyIf( + $withAddress, + static fn (JsonObjectBuilder $b): JsonObjectBuilder => $b->prop('city', Q::string('Berlin')), + ); + + expect($build(true))->toRenderSql("JSON_OBJECT('name', 'Henry', 'city', 'Berlin')"); + expect($build(false))->toRenderSql("JSON_OBJECT('name', 'Henry')"); + }); +}); diff --git a/tests/MySQL/Q/JsonQueryTest.php b/tests/MySQL/Q/JsonQueryTest.php new file mode 100644 index 0000000..0a8279b --- /dev/null +++ b/tests/MySQL/Q/JsonQueryTest.php @@ -0,0 +1,66 @@ +prop('id', Q::n('authors.author_id')) + ->prop('name', Q::n('authors.name')), + ) + ->from(Q::n('authors')) + ->where(Q::n('authors.author_id')->eq(Q::arg(123))); + + expect($b)->toRenderSql( + "SELECT JSON_OBJECT('id', authors.author_id, 'name', authors.name) FROM authors WHERE authors.author_id = ?", + [123], + ); + + // The select builder acts as a blueprint: the JSON selection can be modified later. + $withPostCount = $b->applySelectJson( + static fn (JsonObjectBuilder $obj): JsonObjectBuilder => $obj->prop('postCount', Q\Func::count(Q::n('posts'))), + ); + + expect($withPostCount)->toRenderSql( + "SELECT JSON_OBJECT('id', authors.author_id, 'name', authors.name, 'postCount', COUNT(posts)) FROM authors WHERE authors.author_id = ?", + [123], + ); + }); + + it('selects a json object with an alias', function () { + $b = Q::selectJson( + Q\Func::jsonObject() + ->prop('id', Q::n('authors.author_id')) + ->prop('name', Q::n('authors.name')), + )->as('myjson') + ->from(Q::n('authors')) + ->where(Q::n('authors.author_id')->eq(Q::arg(123))); + + expect($b)->toRenderSql( + "SELECT JSON_OBJECT('id', authors.author_id, 'name', authors.name) AS myjson FROM authors WHERE authors.author_id = ?", + [123], + ); + }); + + it('starts an empty JSON selection and builds it up with applySelectJson', function () { + $b = Q::select() + ->from(Q::n('authors')) + ->applySelectJson( + static fn (JsonObjectBuilder $obj): JsonObjectBuilder => $obj->prop('name', Q::n('authors.name')), + ); + + expect($b)->toRenderSql("SELECT JSON_OBJECT('name', authors.name) FROM authors"); + }); + + it('keeps the JSON selection first, before other select elements', function () { + $b = Q::selectJson(Q\Func::jsonObject()->prop('name', Q::n('name'))) + ->select(Q::n('id')) + ->from(Q::n('authors')); + + expect($b)->toRenderSql("SELECT JSON_OBJECT('name', name), id FROM authors"); + }); +}); diff --git a/tests/MySQL/Q/SelectJsonShapeTest.php b/tests/MySQL/Q/SelectJsonShapeTest.php index 9fa35f8..8d2cd65 100644 --- a/tests/MySQL/Q/SelectJsonShapeTest.php +++ b/tests/MySQL/Q/SelectJsonShapeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Flowpack\QueryObjectBuilder\MySQL\Builder\FuncExp; +use Flowpack\QueryObjectBuilder\MySQL\Builder\JsonObjectBuilder; use Flowpack\QueryObjectBuilder\MySQL\Q; // JSON shapes are assembled from the constructor and aggregate functions documented at @@ -20,19 +20,17 @@ $q = Q::with('author_json')->as( Q::select(Q::n('authors.author_id')) ->select( - Q\Func::jsonObject( - Q::string('id'), Q::n('authors.author_id'), - Q::string('name'), Q::n('authors.name'), - ), + Q\Func::jsonObject() + ->prop('id', Q::n('authors.author_id')) + ->prop('name', Q::n('authors.name')), )->as('json') ->from(Q::n('authors')), ) ->select( Q::n('posts.post_id'), - Q\Func::jsonObject( - Q::string('title'), Q::n('posts.title'), - Q::string('author'), Q::n('author_json.json'), - ), + Q\Func::jsonObject() + ->prop('title', Q::n('posts.title')) + ->prop('author', Q::n('author_json.json')), ) ->from(Q::n('posts')) ->leftJoin(Q::n('author_json'))->on(Q::n('posts.author_id')->eq(Q::n('author_json.author_id'))) @@ -69,10 +67,9 @@ Q::n('u.id'), Q::n('u.name'), Q\Func::jsonArrayAgg( - Q\Func::jsonObject( - Q::string('id'), Q::n('o.id'), - Q::string('total'), Q::n('o.total'), - ), + Q\Func::jsonObject() + ->prop('id', Q::n('o.id')) + ->prop('total', Q::n('o.total')), ), )->as('orders') ->from(Q::n('users'))->as('u') @@ -100,10 +97,9 @@ Q::select( Q::coalesce( Q\Func::jsonArrayAgg( - Q\Func::jsonObject( - Q::string('id'), Q::n('c.id'), - Q::string('name'), Q::n('c.name'), - ), + Q\Func::jsonObject() + ->prop('id', Q::n('c.id')) + ->prop('name', Q::n('c.name')), ), Q\Func::jsonArray(), ), @@ -138,11 +134,10 @@ Q::n('a.author_id'), Q::coalesce( Q\Func::jsonArrayAgg( - Q\Func::jsonObject( - Q::string('ID'), Q::n('b.book_id'), - Q::string('Title'), Q::n('b.title'), - Q::string('PublicationYear'), Q::n('b.publication_year'), - ), + Q\Func::jsonObject() + ->prop('ID', Q::n('b.book_id')) + ->prop('Title', Q::n('b.title')) + ->prop('PublicationYear', Q::n('b.publication_year')), ), Q\Func::jsonArray(), ), @@ -177,10 +172,9 @@ Q::n('p.name'), Q\Func::jsonObjectAgg( Q::n('c.id'), - Q\Func::jsonObject( - Q::string('id'), Q::n('c.id'), - Q::string('name'), Q::n('c.name'), - ), + Q\Func::jsonObject() + ->prop('id', Q::n('c.id')) + ->prop('name', Q::n('c.name')), ), )->as('children_by_id') ->from(Q::n('parent'))->as('p') @@ -208,12 +202,11 @@ Q::select(Q::n('author_id')) ->select( Q::coalesce( - Q\Func::jsonArrayAgg(Q\Func::jsonObject( - Q::string('Title'), Q::n('books.title'), - Q::string('AuthorID'), Q::n('books.author_id'), - Q::string('PublicationYear'), Q::n('books.publication_year'), - Q::string('ID'), Q::n('books.book_id'), - )), + Q\Func::jsonArrayAgg(Q\Func::jsonObject() + ->prop('Title', Q::n('books.title')) + ->prop('AuthorID', Q::n('books.author_id')) + ->prop('PublicationYear', Q::n('books.publication_year')) + ->prop('ID', Q::n('books.book_id'))), Q\Func::jsonArray(), ), )->as('books') @@ -224,10 +217,9 @@ Q::select(Q::n('book_id')) ->select( Q::coalesce( - Q\Func::jsonArrayAgg(Q\Func::jsonObject( - Q::string('GenreID'), Q::n('genres.genre_id'), - Q::string('Name'), Q::n('genres.name'), - )), + Q\Func::jsonArrayAgg(Q\Func::jsonObject() + ->prop('GenreID', Q::n('genres.genre_id')) + ->prop('Name', Q::n('genres.name'))), Q\Func::jsonArray(), ), )->as('genres') @@ -236,18 +228,16 @@ ->groupBy(Q::n('book_id')), ) ->select( - Q\Func::jsonObject( - Q::string('Title'), Q::n('books.title'), - Q::string('AuthorID'), Q::n('books.author_id'), - Q::string('PublicationYear'), Q::n('books.publication_year'), - Q::string('ID'), Q::n('books.book_id'), - Q::string('Author'), Q\Func::jsonObject( - Q::string('AuthorID'), Q::n('authors.author_id'), - Q::string('Name'), Q::n('authors.name'), - Q::string('Books'), Q::n('author_books.books'), - ), - Q::string('Genres'), Q::n('book_genres.genres'), - ), + Q\Func::jsonObject() + ->prop('Title', Q::n('books.title')) + ->prop('AuthorID', Q::n('books.author_id')) + ->prop('PublicationYear', Q::n('books.publication_year')) + ->prop('ID', Q::n('books.book_id')) + ->prop('Author', Q\Func::jsonObject() + ->prop('AuthorID', Q::n('authors.author_id')) + ->prop('Name', Q::n('authors.name')) + ->prop('Books', Q::n('author_books.books'))) + ->prop('Genres', Q::n('book_genres.genres')), ) ->from(Q::n('books')) ->leftJoin(Q::n('authors'))->using('author_id') @@ -315,43 +305,39 @@ it('with correlated subselects', function () { $q = Q::select( - Q\Func::jsonObject( - Q::string('Title'), Q::n('books.title'), - Q::string('AuthorID'), Q::n('books.author_id'), - Q::string('PublicationYear'), Q::n('books.publication_year'), - Q::string('ID'), Q::n('books.book_id'), - Q::string('Author'), Q::select( - Q\Func::jsonObject( - Q::string('AuthorID'), Q::n('authors.author_id'), - Q::string('Name'), Q::n('authors.name'), - Q::string('Books'), Q::select( + Q\Func::jsonObject() + ->prop('Title', Q::n('books.title')) + ->prop('AuthorID', Q::n('books.author_id')) + ->prop('PublicationYear', Q::n('books.publication_year')) + ->prop('ID', Q::n('books.book_id')) + ->prop('Author', Q::select( + Q\Func::jsonObject() + ->prop('AuthorID', Q::n('authors.author_id')) + ->prop('Name', Q::n('authors.name')) + ->prop('Books', Q::select( Q::coalesce( - Q\Func::jsonArrayAgg(Q\Func::jsonObject( - Q::string('Title'), Q::n('books.title'), - Q::string('ID'), Q::n('books.book_id'), - )), + Q\Func::jsonArrayAgg(Q\Func::jsonObject() + ->prop('Title', Q::n('books.title')) + ->prop('ID', Q::n('books.book_id'))), Q\Func::jsonArray(), ), ) ->from(Q::n('books')) - ->where(Q::n('books.author_id')->eq(Q::n('authors.author_id'))), - ), + ->where(Q::n('books.author_id')->eq(Q::n('authors.author_id')))), ) ->from(Q::n('authors')) - ->where(Q::n('authors.author_id')->eq(Q::n('books.author_id'))), - Q::string('Genres'), Q::select( + ->where(Q::n('authors.author_id')->eq(Q::n('books.author_id')))) + ->prop('Genres', Q::select( Q::coalesce( - Q\Func::jsonArrayAgg(Q\Func::jsonObject( - Q::string('GenreID'), Q::n('genres.genre_id'), - Q::string('Name'), Q::n('genres.name'), - )), + Q\Func::jsonArrayAgg(Q\Func::jsonObject() + ->prop('GenreID', Q::n('genres.genre_id')) + ->prop('Name', Q::n('genres.name'))), Q\Func::jsonArray(), ), ) ->from(Q::n('book_genre')) ->leftJoin(Q::n('genres'))->using('genre_id') - ->where(Q::n('book_genre.book_id')->eq(Q::n('books.book_id'))), - ), + ->where(Q::n('book_genre.book_id')->eq(Q::n('books.book_id')))), ) ->from(Q::n('books')) ->where(Q::n('books.book_id')->eq(Q::arg(2))); @@ -408,12 +394,11 @@ it('without nested relations', function () { $q = Q::select( - Q\Func::jsonObject( - Q::string('Title'), Q::n('books.title'), - Q::string('AuthorID'), Q::n('books.author_id'), - Q::string('PublicationYear'), Q::n('books.publication_year'), - Q::string('ID'), Q::n('books.book_id'), - ), + Q\Func::jsonObject() + ->prop('Title', Q::n('books.title')) + ->prop('AuthorID', Q::n('books.author_id')) + ->prop('PublicationYear', Q::n('books.publication_year')) + ->prop('ID', Q::n('books.book_id')), ) ->from(Q::n('books')) ->where(Q::n('books.book_id')->eq(Q::arg(2))); @@ -434,22 +419,13 @@ SQL, [2]); }); - it('builds the JSON object conditionally in application code', function () { - // JSON_OBJECT takes a flat key/value list, so a JSON shape is assembled - // by composing the argument array before the call — the same immutable - // building the fluent API relies on. - $buildBookJson = static function (bool $includeAuthor): FuncExp { - $props = [ - Q::string('Title'), Q::n('books.title'), - Q::string('ID'), Q::n('books.book_id'), - ]; - if ($includeAuthor) { - $props[] = Q::string('AuthorID'); - $props[] = Q::n('books.author_id'); - } - - return Q\Func::jsonObject(...$props); - }; + it('builds the JSON object conditionally with propIf', function () { + // The builder assembles a shape incrementally, so an optional property + // drops out with propIf() — no hand-composed argument list needed. + $buildBookJson = static fn (bool $includeAuthor): JsonObjectBuilder => Q\Func::jsonObject() + ->prop('Title', Q::n('books.title')) + ->prop('ID', Q::n('books.book_id')) + ->propIf($includeAuthor, 'AuthorID', Q::n('books.author_id')); $with = Q::select($buildBookJson(true))->from(Q::n('books')); $without = Q::select($buildBookJson(false))->from(Q::n('books'));