Skip to content

Commit 26b9a70

Browse files
authored
Merge pull request #451 from PingoLee/fix/432-441-parameter-alignment
fix(#432, #441): a nested render's values follow its markers, and two projections may not share an output name
2 parents cb5847f + e4cf777 commit 26b9a70

18 files changed

Lines changed: 1196 additions & 267 deletions

.github/skills/pormg-querybuilder-internals/SKILL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ For positional backends, preserve bucket semantics and flatten order. The bucket
4141

4242
`:cte → :select → :update → :join → :where → :having`
4343

44+
**A nested render does not pick a bucket (#432).** An `Exists(...)`, a projected `Subquery(...)` or an `__@in` subquery renders inside the PARENT's clause, so its values are marked, lifted and re-emitted as one clause-ordered run at the parent's marker position (`nested_parameter_mark` / `detach_nested_run!`), with `own_contexts=true` so the inner build files its values under its own clauses first. Binding order is not text order: a build binds joins last and renders them first, which is what the buckets exist to reconcile.
45+
4446
Parameter collector model:
4547

4648
- `AbstractPormGParam`: base abstraction for all collectors
4749
- `PormGPostgresParam`: linear collector for `$1`, `$2`, ... placeholders
48-
- `PormGPositionalParam`: bucketed collector for positional `?` placeholders
50+
- `PormGSQLiteParam`: bucketed collector for positional `?` placeholders (concrete type `SQLiteParameterizedQuery`)
4951

5052
When changing parameter behavior, verify:
5153

@@ -54,7 +56,7 @@ When changing parameter behavior, verify:
5456
- parent and subquery inheritance behavior
5557
- HAVING alias promotion placement
5658
- custom join parameter routing into the join bucket
57-
- flattening through `get_final_parameters(::PormGPositionalParam)` in SQL-clause order
59+
- flattening through `get_final_parameters(::PormGSQLiteParam)` in SQL-clause order
5860

5961
Query-building context rules:
6062

@@ -235,7 +237,8 @@ When introducing a new parameterized SQL clause or changing clause order, update
235237

236238
- bucket struct fields in `parameters.jl`
237239
- `set_context!` call sites in builder modules
238-
- `get_final_parameters` flatten order
240+
- `_BUCKET_ORDER` in `parameters.jl` — the single list both `get_final_parameters` and
241+
`detach_nested_run!` (#432) read; there is no second copy to keep in sync
239242
- unit coverage in the canonical alignment tests
240243
- integration coverage if the behavior is user-visible
241244

UPGRADING.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,112 @@ _Changes merged but not yet cut into a release. A consumer dev'ing PormG at HEAD
4545
and `PormG.upgrade_guide` surfaces them by default. When the maintainer next rolls changes into a
4646
consuming app, `/pormg-cut-release` stamps every entry below with `0.5.0`, dates them, and tags it._
4747

48+
## `values()` refuses two projections that would render the same output name (#441)
49+
50+
- **Version**: Unreleased
51+
- **PormG ref**: #441; `src/querybuilder/object_manager.jl`, `src/querybuilder/build_query.jl`,
52+
`docs/src/read/values_and_joins.md`
53+
- **Severity**: **behavior change (narrow)** — two shapes that render today stop building, and one
54+
of them (`Value(...)` literals sharing a name) was working correctly rather than silently wrong.
55+
Part of the `0.5.x` pre-publish wave.
56+
57+
### What changed
58+
59+
`get_select_query` memoises each resolved projection, keyed on `_as`. A second projection under a
60+
name already taken was replaced by the first and never resolved at all — so the caller silently got
61+
one column twice instead of the two they asked for. On SQLite it was worse than a lost column: the
62+
cached field's ALREADY-RENDERED text carries its `?`, so the statement emitted one placeholder more
63+
than the driver had values for and every parameter after it bound one slot early:
64+
65+
```
66+
values("note", "h" => Exists(a), "h" => Exists(b)) + filter("note" => "TAIL")
67+
SQLite 3 placeholders, parameters ["AAA", "TAIL"]
68+
-> "TAIL" bound to the SECOND Exists; the outer WHERE never bound at all
69+
PostgreSQL $1 twice, then $2 — legal SQL, still the wrong query
70+
```
71+
72+
`values()` now refuses two projections that would render the same output name. It is refused at the
73+
call rather than repaired at render because two columns under one name are indistinguishable to
74+
everything downstream — a DataFrame column, an `order_by` alias — so no reading of the query keeps
75+
both.
76+
77+
The rule is on the **output** name, so `"*"` counts as the physical columns the database expands it
78+
to, not the declared field names. With `note` declared `db_column = "obs"`, `values("*", "obs" => x)`
79+
collides while `values("*", "note" => x)` does not.
80+
81+
**`Value(...)` duplicates are refused too**, and this is the part that removes working behavior:
82+
`values("lbl" => Value("a"), "lbl" => Value("b"))` renders both parameters correctly today, because
83+
literal projections bypass the memo entirely. It is refused anyway so the rule has no per-kind
84+
exception — a rule that holds only for some projection kinds is the sort of subtlety this defect
85+
family has repeatedly escaped through.
86+
87+
**Unchanged, and in fact newly fixed:** naming the same expression under two *different* names.
88+
`values("a" => "points", "b" => "points")` used to render `as "a"` twice and drop `b`; it now returns
89+
both columns, over a single join where a path is involved. If your code worked around that by
90+
avoiding the shape, the workaround is no longer needed.
91+
92+
Also retired: the #423 ambiguity guard in `order_by()`. It refused `order_by("x")` when `values()`
93+
projected `x` twice; `values()` now refuses that declaration, so the guard was unreachable.
94+
95+
### How to find the calls to migrate
96+
97+
Grep for `values(` calls with a repeated alias, then check `"*"` calls against the model's physical
98+
columns:
99+
100+
```bash
101+
rg -n '\.values\(' .
102+
```
103+
104+
Or programmatically — **run this against your CURRENT pin, before upgrading.** After upgrading it
105+
can never fire, because `values()` throws before you can hold an offending query. Note the `"*"`
106+
expansion: without it the check cannot see a star collision, which is the half most likely to bite:
107+
108+
```julia
109+
function duplicate_projection_names(q)
110+
m, names = q.object.model, String[]
111+
for v in q.object.values
112+
n = v.custom_as !== nothing ? v.custom_as : v._as
113+
n === nothing && continue
114+
n == "*" ? append!(names, unique(PormG.Models.field_db_column(m.fields[f], f)
115+
for f in m.field_names)) : push!(names, n)
116+
end
117+
[n for n in unique(names) if count(==(n), names) > 1]
118+
end
119+
120+
dups = duplicate_projection_names(query)
121+
isempty(dups) || @warn "values() projects a name twice" dups
122+
```
123+
124+
### Migrate your app
125+
126+
Both pairs below are real F1 columns and were executed against the `db_sl` fixture — the ✗ lines
127+
raise, the ✓ lines run.
128+
129+
```julia
130+
# ✗ BEFORE — `n` twice; the Sum was silently discarded
131+
query = M.Result.objects
132+
query.values("driverid", "n" => Count("resultid"), "n" => Sum("points"))
133+
134+
# ✓ AFTER — distinct names
135+
query = M.Result.objects
136+
query.values("driverid", "n_results" => Count("resultid"), "total_points" => Sum("points"))
137+
```
138+
139+
```julia
140+
# ✗ BEFORE — `statusid` is already one of the columns the star emits
141+
query = M.Result.objects
142+
query.values("*", "statusid" => "points")
143+
144+
# ✓ AFTER — a name the star does not already emit
145+
query = M.Result.objects
146+
query.values("*", "status_points" => "points")
147+
```
148+
149+
If a field carries a `db_column`, the star's contribution is the **physical** name. The F1 fixture's
150+
`Db_column_scratch.sku` declares `db_column = "product_sku"`, so on that model it is
151+
`values("*", "product_sku" => …)` that collides — `values("*", "sku" => …)` does not, because the
152+
star never emits `sku`.
153+
48154
## A subquery consumed by `@in` / `Subquery` / `Exists` may no longer declare its own CTE (#433)
49155

50156
- **Version**: Unreleased

docs/src/architecture.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,23 @@ characterizations worth stating plainly.
181181
- **Positional-parameter buckets (deliberate, contained)** — for SQLite the positional `?` markers
182182
are collected into per-clause buckets and flattened in SQL-clause order
183183
(`:cte → :select → :update → :join → :where → :having`). The flatten order is single-sourced in
184-
`get_final_parameters` and guarded by `test_alignment_sqlite.jl` / `test_parameters.jl`; the only
185-
cost is that *adding a new bucket* is a documented multi-site edit (see the QueryBuilder skill's
186-
maintenance checklist). Noted here so it is not mistaken for accidental coupling.
184+
`_BUCKET_ORDER` (which `get_final_parameters` and the nested-run machinery both read) and guarded by `test_alignment_sqlite.jl` / `test_parameters.jl`. Noted here
185+
so it is not mistaken for accidental coupling.
186+
187+
Two costs, not one. *Adding a new bucket* is a documented multi-site edit (see the QueryBuilder
188+
skill's maintenance checklist). The subtler one: a bucket is chosen by the builder phase that binds
189+
a value, while correctness depends on where that value's `?` is **emitted** — and those diverge
190+
whenever a fragment renders somewhere other than its own clause. Three bugs came out of that gap —
191+
#421 a relocated fragment and #432 a nested render, both SQLite-only, plus #441 a discarded
192+
projection, whose *parameter* desync was SQLite-only but which dropped the column itself on **both**
193+
backends. All three were silent. A nested render now re-emits its values as one clause-ordered run at its own marker
194+
position (`nested_parameter_mark` / `detach_nested_run!`). All four such sites in the **read**
195+
builder go through it — `Exists`, a projected `Subquery`, an `__@in` subquery, and a CTE body.
196+
197+
`deletion.jl` splices subqueries into hand-built `DELETE`/`UPDATE` clauses without it, and is
198+
correct today only by coincidence: it builds a fresh collector per statement, and a lone
199+
subquery's own text order *is* `_BUCKET_ORDER`, so the flatten happens to agree. That is a
200+
narrower claim than "contained", and deliberately so.
187201

188202
!!! note "\"Async-first\" is backend-specific"
189203
For **PostgreSQL** the async path is genuine: the pool lock is released before the round-trip and

docs/src/read/values_and_joins.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,11 +313,19 @@ query.order_by("driverid__surname") # ORDER BY "Tb_1"."surname" ASC NULLS LA
313313
sort consistent with the column the query actually returns. If you meant the underlying column,
314314
name it explicitly (`order_by("-points")`) or choose an alias that shadows nothing.
315315

316-
!!! note "Two projections may not share an alias in `order_by`"
317-
`.values("x" => "grid", "x" => "points")` followed by `order_by("x")` raises `QueryBuildError`:
318-
the sort key matches two different projections. PostgreSQL rejects an ambiguous `ORDER BY` alias
319-
and SQLite would resolve it arbitrarily, so PormG refuses it rather than diverge. Give the two
320-
projections distinct names.
316+
!!! note "Two projections may not share an output name"
317+
`.values("x" => "grid", "x" => "points")` raises `QueryBuildError` at the `values()` call — two
318+
columns under one name are indistinguishable to whatever reads the result, and on SQLite the
319+
second one's placeholders desynchronize every parameter after it. Give the two projections
320+
distinct names.
321+
322+
The rule is on the **output** name, so `"*"` counts as the physical columns the database expands
323+
it to: with `note` declared `db_column = "obs"`, `.values("*", "obs" => …)` collides while
324+
`.values("*", "note" => …)` does not.
325+
326+
Naming the same expression under two *different* names is fine and renders two columns —
327+
`.values("a" => "points", "b" => "points")` gives you both, over a single join where a path is
328+
involved.
321329

322330
---
323331

src/querybuilder/build_helpers.jl

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -828,11 +828,16 @@ function _get_select_query(v::SubqueryObject, instruc::SQLInstruction; _as::Unio
828828
# Passing the shared `parameters` makes query() treat this as a subquery: it inherits the ambient
829829
# :select bucket (set by build()) and restores the context afterward, so the inner params flatten in
830830
# :select — textually correct (the SELECT list precedes FROM/JOIN/WHERE). Correlate via outer=instruc.
831+
# #432: same nested-run reordering as `_build_exists_query` — this subquery's text sits in the
832+
# SELECT list, so everything it binds must be one clause-ordered run in the ambient bucket.
833+
nested_mark = nested_parameter_mark(instruc)
831834
inner_sql = query(handler,
832835
table_alias=instruc.table_alias,
833836
connection=instruc.connection,
834837
parameters=instruc.parameters,
835-
outer=instruc)
838+
outer=instruc,
839+
own_contexts=true)
840+
reattach_parameters!(instruc, detach_nested_run!(instruc, nested_mark))
836841
return string("(", inner_sql, ")")
837842
end
838843
function _get_select_query(q::SQLTypeF, instruc::SQLInstruction; _as::Union{Nothing,String}=nothing)
@@ -861,18 +866,25 @@ function _build_exists_query(subquery::SQLObjectHandler, instruc::SQLInstruction
861866
q.object.offset = 0
862867

863868
old_context = instruc.parameters isa PormGSQLiteParam ? instruc.parameters.current_context : nothing
869+
# #432: the inner build scatters its values across its own clause buckets while this EXISTS text is
870+
# spliced into ONE of the parent's clauses. Mark every bucket, then re-emit what it bound as one
871+
# contiguous run, clause-ordered, at this fragment's position. See `detach_nested_run!`.
872+
nested_mark = nested_parameter_mark(instruc)
864873
instruction = try
865874
build(
866875
q.object,
867876
table_alias=instruc.table_alias,
868877
connection=instruc.connection,
869878
parameters=instruc.parameters,
870-
set_contexts=false,
879+
# #432: record this subquery's OWN clause roles so `detach_nested_run!` can sort its values
880+
# into text order. The `finally` below restores the parent's ambient bucket.
881+
set_contexts=true,
871882
outer=instruc,
872883
)
873884
finally
874885
old_context !== nothing && set_context!(instruc.parameters, old_context)
875886
end
887+
reattach_parameters!(instruc, detach_nested_run!(instruc, nested_mark))
876888

877889
safe_table_name = safe_table_identifier(Models.model_table_name(q.object.model), instruction.connection)
878890
safe_alias = quote_identifier(instruction.alias, instruction.connection)
@@ -1322,7 +1334,10 @@ function _get_filter_query(v::SQLTypeOper, instruc::SQLInstruction)
13221334
_validate_membership_subquery(v)
13231335
# #433: renders an inline WITH that binds into `:cte` while its text sits in the WHERE clause.
13241336
_guard_no_nested_cte(v.values, "A membership filter (__@in / __@nin)")
1325-
placeholders = query(v.values, table_alias=instruc.table_alias, connection=instruc.connection, parameters=instruc.parameters, outer=instruc)
1337+
# #432: same nested-run reordering — the subquery renders inside this predicate's clause.
1338+
nested_mark = nested_parameter_mark(instruc)
1339+
placeholders = query(v.values, table_alias=instruc.table_alias, connection=instruc.connection, parameters=instruc.parameters, outer=instruc, own_contexts=true)
1340+
reattach_parameters!(instruc, detach_nested_run!(instruc, nested_mark))
13261341
return string(_get_filter_query(v.column, instruc), " ", v.operator, " ($placeholders)")
13271342
else
13281343
@pormg_debug false

0 commit comments

Comments
 (0)