Add union() and unionAll() to QueryGenerator - #18
Conversation
Allow combining SELECT statements with UNION / UNION ALL. Operands may be another QueryGenerator (built and validated immediately, keywords intact) or a raw SQL string with params. Implemented as real methods rather than via __call because the nested generator path strips clause keywords (skipClauses), which would mangle union operands. The UNION keyword is baked into each stored piece so UNION and UNION ALL can be mixed in call order. The clause is rendered between HAVING and ORDER BY so trailing ORDER BY/LIMIT apply to the whole union and params merge in placeholder order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sctice-ifixit
left a comment
There was a problem hiding this comment.
I was worried about param ordering, but testUnionParamOrdering covers it. According to Claude: putting union between having and order in $methods means build() emits the union params in the right placeholder position, and the operand's own build() orders its params internally, so nesting works too.
One thing outside the diff that sent Claude down the wrong path: build()'s return annotation says the params come back as array<string, mixed>, which had it worried about named params colliding between the two halves of a union. As far as I can tell nothing in PopSQL uses named params (every example and test is positional ?) and build() returns a list, so it seems like that should be list<mixed>. Apparently Psalm sees this but emits it as Info rather than Error.
A QueryGenerator operand carries its own params, so any passed alongside were silently dropped. Make the mistake loud instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The return annotation claimed array<string, mixed>, suggesting named params, but nothing in PopSQL uses them and params placeholders are positional. Use array_values to make the list shape concrete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7f39b6a
Summary
Adds
union()andunionAll()operators toQueryGeneratorfor combining SELECT statements. Operands may be anotherQueryGenerator(built and validated immediately) or a raw SQL string with params.Design notes
__call, because the existing nested-generator path builds sub-queries withskipClauses: true, which strips SELECT/FROM keywords — correct for WHERE fragments, wrong for union operands that must render as complete SELECTs.UNION/UNION ALLkeyword is baked into each stored piece (glue stays"\n"), so the two can be mixed freely in call order.order()/limit()applies to the whole union result (MySQL semantics) and params merge in placeholder order.union()call time; opt out withskipValidation()on the operand.qa_req 0 -- CI has new tests
CC @sterlinghirsh @sctice-ifixit
🤖 Generated with Claude Code