@@ -68,9 +68,9 @@ function renderFilterCond(clause: ViewFilterClause, dialect: EmitOptions["dialec
6868 return `${ lhs } ${ op } ${ sqlLiteral ( clause . value , dialect ) } ` ;
6969}
7070
71- /** Subquery alias for the json_each re-wrap in the SQLite whole-object collect. Scoped to
72- * its own scalar subquery, so it cannot collide with a JOIN-tree alias; named distinctly
73- * anyway so it is obvious in emitted DDL where it came from. */
71+ /** Alias of the json_each table that a SQLite collect column sorts its elements over.
72+ * Scoped to its own scalar subquery, so it cannot collide with a JOIN-tree alias; named
73+ * distinctly anyway so it is obvious in emitted DDL where it came from. */
7474const JSON_EACH_ALIAS = "mo_je" ;
7575
7676/**
@@ -146,6 +146,95 @@ function renderFirst(
146146 ) ;
147147}
148148
149+ type CollectColumn = Extract < SelectColumn , { kind : "collectAgg" | "collectObjectAgg" } > ;
150+
151+ /** Alias of the grouped inner query in a two-level SQLite view. */
152+ const GROUPED_ALIAS = "mo_g" ;
153+ /** Alias of the sorted element subquery in a SQLite collect column. */
154+ const SORTED_ALIAS = "mo_s" ;
155+
156+ /**
157+ * How a SQLite collect column orders its elements. Each element is stored as a JSON array
158+ * `[key1, …, keyN, payload]`, or as the bare value when `bare` is set (a `@distinct`
159+ * collect orders by the value itself).
160+ */
161+ interface SqliteCollectLayout {
162+ readonly keys : readonly { readonly expr : string ; readonly order : string } [ ] ;
163+ readonly payload : string ;
164+ readonly bare : boolean ;
165+ /** Wrap each element in json() when re-aggregating — true for a JSON object payload. */
166+ readonly json : boolean ;
167+ }
168+
169+ function sqliteCollectLayout ( c : CollectColumn ) : SqliteCollectLayout {
170+ const col = ( name : string ) : string => `${ c . sourceAlias } .${ quoteIfNeeded ( name ) } ` ;
171+ const orderKeys = c . orderBy . map ( ( k ) => ( { expr : col ( k . column ) , order : `${ k . dir . toUpperCase ( ) } NULLS LAST` } ) ) ;
172+ if ( c . kind === "collectObjectAgg" ) {
173+ const pairs = c . members . map ( ( m ) => `'${ m . memberName } ', ${ col ( m . sourceColumn ) } ` ) . join ( ", " ) ;
174+ // Default element order is the related entity's PK; an explicit @orderBy leads with the
175+ // PK appended as a tie-break, so equal-order rows stay byte-deterministic.
176+ return {
177+ keys : [ ...orderKeys , { expr : col ( c . joinedPkColumn ) , order : "ASC" } ] ,
178+ payload : `json_object(${ pairs } )` ,
179+ bare : false ,
180+ json : true ,
181+ } ;
182+ }
183+ const src = col ( c . sourceColumn ) ;
184+ // @distinct always orders by the value; otherwise an explicit @orderBy, else value ascending.
185+ if ( c . distinct ) return { keys : [ ] , payload : src , bare : true , json : false } ;
186+ return {
187+ keys : orderKeys . length > 0 ? orderKeys : [ { expr : src , order : "ASC" } ] ,
188+ payload : src ,
189+ bare : false ,
190+ json : false ,
191+ } ;
192+ }
193+
194+ /**
195+ * SQLite collect, inner half: the grouped query aggregates the related rows UNSORTED, each
196+ * element carrying its sort keys. Ordering happens in the outer query
197+ * ({@link sqliteCollectOuter}).
198+ *
199+ * Why two levels. The ordered aggregate `json_group_array(x ORDER BY …)` needs SQLite 3.44,
200+ * and a database holding such a view will not open on anything older (Ubuntu 22.04 ships
201+ * 3.37). Before 3.44 the only way to order an aggregate's input is to aggregate over a
202+ * sorted subquery — and a subquery cannot read an aggregate of the query around it
203+ * ("misuse of aggregate function"), so the sort has to sit one query further out, reading
204+ * the grouped result as a plain column. Measured on SQLite 3.35.5, 3.37.2, 3.44 and 3.53.
205+ *
206+ * This also keeps whole-object elements as JSON objects: without an in-aggregate ORDER BY
207+ * `json_group_array(json_array(…, json_object(…)))` nests correctly, where on 3.44
208+ * `json_group_array(json_object(…) ORDER BY …)` returned an array of quoted strings.
209+ */
210+ function sqliteCollectInner ( c : CollectColumn ) : string {
211+ const layout = sqliteCollectLayout ( c ) ;
212+ const guard = `${ c . sourceAlias } .${ quoteIfNeeded ( c . joinedPkColumn ) } IS NOT NULL` ;
213+ const element = layout . bare
214+ ? `DISTINCT ${ layout . payload } `
215+ : `json_array(${ [ ...layout . keys . map ( ( k ) => k . expr ) , layout . payload ] . join ( ", " ) } )` ;
216+ return `COALESCE(json_group_array(${ element } ) FILTER (WHERE ${ guard } ), json_array())` ;
217+ }
218+
219+ /** SQLite collect, outer half: sort the grouped column's elements and re-aggregate them. */
220+ function sqliteCollectOuter ( c : CollectColumn ) : string {
221+ const layout = sqliteCollectLayout ( c ) ;
222+ const grouped = `${ GROUPED_ALIAS } .${ quoteIfNeeded ( c . dbColAlias ) } ` ;
223+ const at = ( i : number ) : string => `json_extract(${ JSON_EACH_ALIAS } .value, '$[${ i } ]')` ;
224+ const value = layout . bare ? `${ JSON_EACH_ALIAS } .value` : at ( layout . keys . length ) ;
225+ const orderBy = layout . bare
226+ ? `${ JSON_EACH_ALIAS } .value ASC`
227+ : layout . keys . map ( ( k , i ) => `${ at ( i ) } ${ k . order } ` ) . join ( ", " ) ;
228+ const element = layout . json ? `json(${ SORTED_ALIAS } .v)` : `${ SORTED_ALIAS } .v` ;
229+ return (
230+ `(SELECT json_group_array(${ element } ) FROM (SELECT ${ value } AS v FROM json_each(${ grouped } ) ${ JSON_EACH_ALIAS } ` +
231+ ` ORDER BY ${ orderBy } ) ${ SORTED_ALIAS } ) AS ${ quoteIfNeeded ( c . dbColAlias ) } `
232+ ) ;
233+ }
234+
235+ const isCollect = ( c : SelectColumn ) : c is CollectColumn =>
236+ c . kind === "collectAgg" || c . kind === "collectObjectAgg" ;
237+
149238function renderColumn ( c : SelectColumn , options : EmitOptions , baseAlias : string ) : string {
150239 const dialect = options . dialect ;
151240 const alias = quoteIfNeeded ( c . dbColAlias ) ;
@@ -200,12 +289,18 @@ function renderColumn(c: SelectColumn, options: EmitOptions, baseAlias: string):
200289 ? `ORDER BY ${ renderOrderKeys ( c . orderBy , c . sourceAlias ) } `
201290 : `ORDER BY ${ src } ASC` ;
202291 if ( dialect === "sqlite" ) {
203- return `COALESCE(json_group_array(${ distinctKw } ${ src } ${ orderClause } ) FILTER (WHERE ${ guard } ), json_array()) AS ${ alias } ` ;
292+ // Unsorted here; the outer query of the two-level SQLite view sorts it (see
293+ // sqliteCollectInner / sqliteCollectOuter).
294+ return `${ sqliteCollectInner ( c ) } AS ${ alias } ` ;
204295 }
205296 return `COALESCE(array_agg(${ distinctKw } ${ src } ${ orderClause } ) FILTER (WHERE ${ guard } ), '{}') AS ${ alias } ` ;
206297 }
207298
208299 if ( c . kind === "collectObjectAgg" ) {
300+ if ( dialect === "sqlite" ) {
301+ // SQLite: unsorted here; the outer query of the two-level view sorts it.
302+ return `${ sqliteCollectInner ( c ) } AS ${ alias } ` ;
303+ }
209304 // #335 whole-object rollup. jsonb, not json: PG's `json` has neither an equality
210305 // nor an ordering operator, so `json_agg(json_build_object(…) ORDER BY …)` does not
211306 // run — verified against a real PG 15.
@@ -224,31 +319,6 @@ function renderColumn(c: SelectColumn, options: EmitOptions, baseAlias: string):
224319 const pairs = c . members
225320 . map ( ( m ) => `'${ m . memberName } ', ${ c . sourceAlias } .${ quoteIfNeeded ( m . sourceColumn ) } ` )
226321 . join ( ", " ) ;
227- // In-aggregate ORDER BY needs SQLite >= 3.44 — not a new constraint: the scalar
228- // collect above already emits it, and D1's baseline is pinned at 3.44.0.
229- if ( dialect === "sqlite" ) {
230- // SQLite cannot do BOTH in-aggregate ORDER BY and JSON nesting in one call.
231- // Measured on SQLite 3.44.0, D1's pinned baseline:
232- //
233- // json_group_array(json_object(…)) -> nests correctly
234- // json_group_array(json_object(…) ORDER BY …) -> array of QUOTED STRINGS
235- // json_group_array(json(json_object(…)) ORDER BY …) -> array of QUOTED STRINGS
236- //
237- // The ORDER BY clause itself destroys the JSON subtype, and a json() wrapper on the
238- // argument does not survive it. Dropping ORDER BY is not an option: element order
239- // would stop being deterministic and an author's @orderBy would silently do nothing.
240- //
241- // So build the ordered array first (elements quoted), then re-wrap element by
242- // element through json_each — which iterates in ARRAY ORDER, so the ordering
243- // survives while json(value) restores each element's JSON subtype. Still a grouped
244- // LEFT JOIN: unlike origin.first this needs no correlation info, so a multi-hop
245- // @via lowers here exactly as a single-hop one does.
246- //
247- // Found by the real-engine probe in integration-tests. Emitted SQL text cannot show
248- // this, which is exactly why golden SQL is not evidence for new DDL.
249- const ordered = `COALESCE(json_group_array(json_object(${ pairs } ) ${ orderClause } ) FILTER (WHERE ${ guard } ), json_array())` ;
250- return `(SELECT json_group_array(json(${ JSON_EACH_ALIAS } .value)) FROM json_each(${ ordered } ) ${ JSON_EACH_ALIAS } ) AS ${ alias } ` ;
251- }
252322 // PG's jsonb_build_object already yields real jsonb, so jsonb_agg nests it correctly
253323 // with no wrapper — verified against a real engine, not assumed by symmetry.
254324 return `COALESCE(jsonb_agg(jsonb_build_object(${ pairs } ) ${ orderClause } ) FILTER (WHERE ${ guard } ), '[]'::jsonb) AS ${ alias } ` ;
@@ -314,10 +384,23 @@ export function emitViewDdl(spec: ViewSpec, options: EmitOptions): string {
314384 ? `\n GROUP BY ${ spec . groupBy . map ( quoteRef ) . join ( ", " ) } `
315385 : "" ;
316386
317- const body = ` SELECT
387+ const grouped = ` SELECT
318388${ cols }
319389${ fromClause } ${ joinsClause ? "\n" + joinsClause : "" } ${ whereClause } ${ groupByClause } `;
320390
391+ // SQLite orders collect elements in an outer query over the grouped one — see
392+ // sqliteCollectInner for why. Every other column passes straight through.
393+ const collects = options . dialect === "sqlite" && spec . selectSpec . columns . some ( isCollect ) ;
394+ const body = ! collects ? grouped : ` SELECT
395+ ${ spec . selectSpec . columns
396+ . map ( ( c ) => " " + ( isCollect ( c )
397+ ? sqliteCollectOuter ( c )
398+ : `${ GROUPED_ALIAS } .${ quoteIfNeeded ( c . dbColAlias ) } AS ${ quoteIfNeeded ( c . dbColAlias ) } ` ) )
399+ . join ( ",\n" ) }
400+ FROM (
401+ ${ grouped }
402+ ) ${ GROUPED_ALIAS } ` ;
403+
321404 if ( options . bodyOnly ) return body ;
322405 return `CREATE VIEW ${ quoteIfNeeded ( spec . viewName ) } AS
323406${ body } ;`;
0 commit comments