diff --git a/src/backend/dataSource.js b/src/backend/dataSource.js index 9abac03..af9a99a 100644 --- a/src/backend/dataSource.js +++ b/src/backend/dataSource.js @@ -18,6 +18,74 @@ export function asyncRow(obj, columns) { return { columns, cells, resolved: obj } } +/** + * Reads a column value from a row, preferring the pre-materialized `resolved` + * object for exposed columns and falling back to the lazy cell closure. When + * present, `resolved` is expected to hold a value for every column in + * `row.columns`; it may also carry hidden source fields not visible to SQL. + * Returns a value or a Promise; callers await either. + * + * @param {AsyncRow} row + * @param {string} key + * @returns {SqlPrimitive | Promise} + */ +export function readCell(row, key) { + if (row.resolved != null && row.columns.includes(key)) return row.resolved[key] + return row.cells[key]() +} + +/** + * Whether a row can read the given key. `resolved` only exposes row.columns; + * hidden expression caches may live in cells without appearing in row.columns. + * + * @param {AsyncRow} row + * @param {string} key + * @returns {boolean} + */ +export function hasCell(row, key) { + return row.resolved != null && row.columns.includes(key) || + Object.prototype.hasOwnProperty.call(row.cells, key) +} + +/** + * Returns a lazy cell closure for a column, resolve-aware: reuses the row's own + * closure when present, otherwise synthesizes one from `resolved` (lean buffered + * rows drop their closures to save memory). Returns undefined when the row + * exposes neither, so callers can detect an unknown column. + * + * @param {AsyncRow} row + * @param {string} key + * @returns {(() => Promise) | undefined} + */ +export function cellThunk(row, key) { + const cell = row.cells[key] + if (cell) return cell + if (row.resolved != null && row.columns.includes(key)) { + const { [key]: value } = row.resolved + return () => Promise.resolve(value) + } + return undefined +} + +/** + * Resolve-aware view of a row's cells as a complete map. Lean buffered rows + * (values in `resolved`, closures dropped to save memory) get their closures + * rebuilt from `resolved`, with any hidden non-column cells preserved. Rows + * without `resolved` return their own map unchanged so lazy or cached closures + * still apply. May return the row's own map, so callers that mutate must copy. + * + * @param {AsyncRow} row + * @returns {AsyncCells} + */ +export function rowCells(row) { + if (row.resolved == null) return row.cells + const { cells } = asyncRow(row.resolved, row.columns) + for (const key in row.cells) { + if (!(key in cells)) cells[key] = row.cells[key] + } + return cells +} + /** * Creates an async memory-backed data source from an array of plain objects * @@ -105,7 +173,7 @@ export function cachedDataSource(source) { /** @type {AsyncCells} */ const cells = {} for (const key of row.columns) { - const cell = row.cells[key] + const cell = cellThunk(row, key) cells[key] = () => { let value = rowCache.get(key) if (!value) { diff --git a/src/execute/aggregates.js b/src/execute/aggregates.js index 5a92793..effaba9 100644 --- a/src/execute/aggregates.js +++ b/src/execute/aggregates.js @@ -1,3 +1,4 @@ +import { cellThunk, rowCells } from '../backend/dataSource.js' import { derivedAlias } from '../expression/alias.js' import { evaluateExpr } from '../expression/evaluate.js' import { executePlan, selectColumnNames } from './execute.js' @@ -37,7 +38,9 @@ function projectAggregateColumns(selectColumns, group, context) { const dotIndex = key.indexOf('.') const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key columns.push(outputKey) - cells[outputKey] = firstRow.cells[key] + // Lean buffered rows (see executeHashAggregate) carry `resolved` but no + // cell closures; cellThunk reads from `resolved` when present. + cells[outputKey] = cellThunk(firstRow, key) } } } else { @@ -65,9 +68,11 @@ function projectAggregateColumns(selectColumns, group, context) { */ function aggregateContextRow(group, aggregateRow) { const baseRow = group[0] ?? { columns: [], cells: {} } + // Lean buffered rows carry `resolved` but no cell closures; rowCells rebuilds + // base-column cells from `resolved` for this one row per group (O(groups)). return { columns: [...baseRow.columns, ...aggregateRow.columns], - cells: { ...baseRow.cells, ...aggregateRow.cells }, + cells: { ...rowCells(baseRow), ...aggregateRow.cells }, } } @@ -84,7 +89,11 @@ export function executeHashAggregate(plan, context) { columns: selectColumnNames(plan.columns, child.columns), maxRows: child.maxRows, async *rows() { - // Collect all rows + // Collect all rows. GROUP BY buffers its whole input; for already + // materialized rows (`resolved` present) keep only the plain object and + // drop the O(columns) per-column cell closures, so the buffer holds row + // data, not N sets of closures. Aggregates and group keys read these lean + // rows straight from `resolved`. Rows without `resolved` are kept as-is. /** @type {AsyncRow[]} */ const allRows = [] let collectCount = 0 @@ -93,7 +102,7 @@ export function executeHashAggregate(plan, context) { await yieldToEventLoop() if (context.signal?.aborted) return } - allRows.push(row) + allRows.push(row.resolved ? { columns: row.columns, cells: {}, resolved: row.resolved } : row) } context.signal?.throwIfAborted() @@ -143,9 +152,13 @@ export function executeHashAggregate(plan, context) { /** @type {{ row: AsyncRow, rows: AsyncRow[], outputRow: AsyncRow }[]} */ const aggregateRows = [] + // The context row (base columns + aggregate aliases) is only needed for + // HAVING and grouped ORDER BY; skip building it otherwise so a plain + // GROUP BY doesn't retain O(groups) extra rows. + const needContextRow = Boolean(plan.having) || Boolean(plan.orderBy?.length) for (const group of groups.values()) { const asyncRow = projectAggregateColumns(plan.columns, group, context) - const contextRow = aggregateContextRow(group, asyncRow) + const contextRow = needContextRow ? aggregateContextRow(group, asyncRow) : asyncRow // Apply HAVING filter if (plan.having) { @@ -222,7 +235,7 @@ export function executeScalarAggregate(plan, context) { /** @type {AsyncRow} */ const havingRow = { columns: [...baseRow.columns, ...asyncRow.columns], - cells: { ...baseRow.cells, ...asyncRow.cells }, + cells: { ...rowCells(baseRow), ...asyncRow.cells }, } const passes = await evaluateExpr({ node: plan.having, diff --git a/src/execute/execute.js b/src/execute/execute.js index 7546d70..5f76277 100644 --- a/src/execute/execute.js +++ b/src/execute/execute.js @@ -1,4 +1,4 @@ -import { memorySource } from '../backend/dataSource.js' +import { cellThunk, hasCell, memorySource } from '../backend/dataSource.js' import { derivedAlias } from '../expression/alias.js' import { evaluateExpr } from '../expression/evaluate.js' import { parseSql } from '../parse/parse.js' @@ -531,13 +531,14 @@ function executeProject(plan, context) { /** @type {AsyncCells} */ const cells = {} - // Only safe to propagate resolved when every output column comes from - // the star branch. Derived expressions evaluate lazily and can't be - // pre-materialized here, and a partial resolved would make - // collect()/downstream identifier fast paths read undefined. + // Only safe to propagate resolved when every output column is actually + // pre-materialized here. Derived expressions evaluate lazily, and a + // partial resolved would make collect()/downstream identifier fast + // paths read undefined. const source = resolveable ? row.resolved : undefined /** @type {Record | undefined} */ const resolved = source ? {} : undefined + let rowResolveable = Boolean(source) let colIdx = 0 for (const col of plan.columns) { @@ -547,7 +548,7 @@ function executeProject(plan, context) { if (prefix && !key.startsWith(prefix)) continue const dotIndex = key.indexOf('.') const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key - cells[outputKey] = row.cells[key] + cells[outputKey] = cellThunk(row, key) if (resolved && source) resolved[outputKey] = source[key] colIdx++ } @@ -562,10 +563,15 @@ function executeProject(plan, context) { const id = col.expr const sourceName = id.prefix ? `${id.prefix}.${id.name}` : id.name const alias = columns[colIdx++] - if (sourceName in row.cells) { - cells[alias] = row.cells[sourceName] - if (resolved && source) resolved[alias] = source[sourceName] + if (hasCell(row, sourceName)) { + cells[alias] = cellThunk(row, sourceName) + // Only stay resolveable if the value is actually present in the + // source's resolved object; a cells-only key (e.g. a cached sort + // key) would otherwise propagate `undefined` into resolved. + if (resolved && source && sourceName in source) resolved[alias] = source[sourceName] + else rowResolveable = false } else { + rowResolveable = false const { expr } = col cells[alias] = () => evaluateExpr({ node: expr, @@ -575,6 +581,7 @@ function executeProject(plan, context) { }) } } else { + rowResolveable = false const alias = columns[colIdx++] cells[alias] = () => evaluateExpr({ node: col.expr, @@ -585,7 +592,7 @@ function executeProject(plan, context) { } } - yield { columns, cells, resolved } + yield { columns, cells, resolved: rowResolveable ? resolved : undefined } } }, } diff --git a/src/execute/join.js b/src/execute/join.js index b029ae0..97942de 100644 --- a/src/execute/join.js +++ b/src/execute/join.js @@ -1,3 +1,4 @@ +import { cellThunk, rowCells } from '../backend/dataSource.js' import { evaluateExpr } from '../expression/evaluate.js' import { keyify, maxBounds } from './utils.js' import { executePlan } from './execute.js' @@ -314,12 +315,16 @@ export function executeHashJoin(plan, context) { */ function mergeOuterRows(outerRow, leftRow, leftTable) { const columns = [...outerRow.columns] + // The enclosing outer row may be a lean buffered row (empty cells, values in + // `resolved`) when the correlated subquery runs under an outer ORDER BY or + // GROUP BY. rowCells rehydrates from `resolved` so outer columns stay readable; + // copy it since we mutate below. /** @type {AsyncCells} */ - const cells = { ...outerRow.cells } - for (const [key, cell] of Object.entries(leftRow.cells)) { + const cells = { ...rowCells(outerRow) } + for (const key of leftRow.columns) { const alias = key.includes('.') ? key : `${leftTable}.${key}` if (!(alias in cells)) columns.push(alias) - cells[alias] = cell + cells[alias] = cellThunk(leftRow, key) } return { columns, cells } } @@ -370,17 +375,17 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) { const cells = {} // Add left table columns with prefix - for (const [key, cell] of Object.entries(leftRow.cells)) { + for (const key of leftRow.columns) { const alias = key.includes('.') ? key : `${leftTable}.${key}` columns.push(alias) - cells[alias] = cell + cells[alias] = cellThunk(leftRow, key) } // Add right table columns with prefix - for (const [key, cell] of Object.entries(rightRow.cells)) { + for (const key of rightRow.columns) { const alias = key.includes('.') ? key : `${rightTable}.${key}` columns.push(alias) - cells[alias] = cell + cells[alias] = cellThunk(rightRow, key) } return { columns, cells } diff --git a/src/execute/sort.js b/src/execute/sort.js index 078b153..db0ffc9 100644 --- a/src/execute/sort.js +++ b/src/execute/sort.js @@ -1,3 +1,4 @@ +import { rowCells } from '../backend/dataSource.js' import { derivedAlias } from '../expression/alias.js' import { evaluateExpr } from '../expression/evaluate.js' import { executePlan } from './execute.js' @@ -73,8 +74,13 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue const idx = chunk[i] const value = values[i] evaluatedValues[idx][orderByIdx] = value - if (cacheValues && !(alias in entries[idx].row.cells)) { - entries[idx].row.cells[alias] = () => Promise.resolve(value) + // Cache the evaluated sort key back onto the row so the output + // projection can reuse it instead of recomputing (e.g. an ORDER BY UDF + // also referenced in SELECT). Skip real columns (read directly) and + // rows with no cells map. + const entryRow = entries[idx].row + if (cacheValues && entryRow.cells && !entryRow.columns.includes(alias) && !(alias in entryRow.cells)) { + entryRow.cells[alias] = () => Promise.resolve(value) } } start += chunk.length @@ -129,12 +135,22 @@ export function executeSort(plan, context) { numRows: child.numRows, maxRows: child.maxRows, async *rows() { - // Buffer all rows + // ORDER BY must buffer its whole input before it can emit. For rows that + // are already fully materialized (`resolved` present), keep only the plain + // object plus a fresh empty cells map, dropping the O(columns) per-column + // cell closures: the buffer then holds row data, not N sets of closures. + // The empty cells map still lets the sort cache derived sort keys. Rows + // without `resolved` (e.g. derived expressions) are kept as-is so their + // lazy cells still work. /** @type {AsyncRow[]} */ const rows = [] for await (const row of child.rows()) { if (context.signal?.aborted) return - rows.push(row) + if (row.resolved) { + rows.push({ columns: row.columns, cells: {}, resolved: row.resolved }) + } else { + rows.push(row) + } } const sortedRows = await sortEntriesByTerms({ @@ -144,9 +160,17 @@ export function executeSort(plan, context) { cacheValues: true, }) - // Yield sorted rows + // Rebuild full cell closures for lean rows only at emit time, one row at a + // time, so downstream consumers get the normal cells interface without the + // buffer ever holding N sets of closures. A buffered row is lean exactly + // when it carries `resolved` (non-materialized rows are kept as-is above). + // Carry over any cached derived sort-key cells added during the sort. for (const { row } of sortedRows) { - yield row + if (!row.resolved) { + yield row + continue + } + yield { columns: row.columns, cells: rowCells(row), resolved: row.resolved } } }, } diff --git a/src/execute/utils.js b/src/execute/utils.js index da0597e..8f74e6b 100644 --- a/src/execute/utils.js +++ b/src/execute/utils.js @@ -1,3 +1,5 @@ +import { readCell } from '../backend/dataSource.js' + /** * @import { AsyncRow, OrderByItem, QueryResults, SqlPrimitive } from '../types.js' */ @@ -80,7 +82,7 @@ export async function collect(results) { } return Promise.all(rows.map(async asyncRow => { - const values = await Promise.all(asyncRow.columns.map(k => asyncRow.cells[k]())) + const values = await Promise.all(asyncRow.columns.map(k => readCell(asyncRow, k))) /** @type {Record} */ const item = {} for (let i = 0; i < asyncRow.columns.length; i++) { @@ -185,6 +187,6 @@ export function keyify(...values) { * @returns {Promise} */ export function stableRowKey(row) { - return Promise.all(row.columns.map(k => row.cells[k]())) + return Promise.all(row.columns.map(k => readCell(row, k))) .then(values => keyify(...values)) } diff --git a/src/execute/window.js b/src/execute/window.js index e88c45f..475e6f3 100644 --- a/src/execute/window.js +++ b/src/execute/window.js @@ -1,3 +1,4 @@ +import { rowCells } from '../backend/dataSource.js' import { evaluateExpr } from '../expression/evaluate.js' import { executePlan } from './execute.js' import { compareForTerm, keyify } from './utils.js' @@ -44,7 +45,7 @@ export function executeWindow(plan, context) { await yieldToEventLoop() if (context.signal?.aborted) return } - const cells = { ...row.cells } + const cells = { ...rowCells(row) } for (const w of plan.windows) { const value = i cells[w.alias] = () => Promise.resolve(value) @@ -91,7 +92,7 @@ export function executeWindow(plan, context) { if (context.signal?.aborted) return } const row = rows[i] - const cells = { ...row.cells } + const cells = { ...rowCells(row) } for (let w = 0; w < plan.windows.length; w++) { const { alias } = plan.windows[w] const value = windowValues[w][i] diff --git a/src/expression/evaluate.js b/src/expression/evaluate.js index 1d43d36..d16c729 100644 --- a/src/expression/evaluate.js +++ b/src/expression/evaluate.js @@ -1,3 +1,4 @@ +import { hasCell, readCell } from '../backend/dataSource.js' import { executeStatement } from '../execute/execute.js' import { isPlainObject, keyify, sqlEquals, stringify } from '../execute/utils.js' import { yieldToEventLoop } from '../execute/yield.js' @@ -71,13 +72,13 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) { // Try qualified name first (e.g. 'users.id') if (node.prefix) { const qualified = node.prefix + '.' + node.name - if (qualified in row.cells) { - return row.cells[qualified]() + if (hasCell(row, qualified)) { + return readCell(row, qualified) } const prefix = node.prefix + '.' const prefixedColumns = row.columns.filter(col => col.startsWith(prefix)) if (prefixedColumns.length === 1) { - const value = await row.cells[prefixedColumns[0]]() + const value = await readCell(row, prefixedColumns[0]) if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(value, node.name)) { return value[node.name] } @@ -88,37 +89,37 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) { const suffix = '.' + node.prefix const baseColumns = row.columns.filter(col => col === node.prefix || col.endsWith(suffix)) if (baseColumns.length === 1) { - const value = await row.cells[baseColumns[0]]() + const value = await readCell(row, baseColumns[0]) if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(value, node.name)) { return value[node.name] } } // Check outer row for correlated subquery references - if (context.outerRow && context.outerAliases?.has(node.prefix) && node.name in context.outerRow.cells) { - return context.outerRow.cells[node.name]() + if (context.outerRow && context.outerAliases?.has(node.prefix) && hasCell(context.outerRow, node.name)) { + return readCell(context.outerRow, node.name) } // Standalone `FROM UNNEST(...) AS alias` row has a single bare column; // `alias.field` should struct-access that column's element. if (context.scope?.includes(node.prefix) && row.columns.length === 1) { - const value = await row.cells[row.columns[0]]() + const value = await readCell(row, row.columns[0]) if (isPlainObject(value) && Object.prototype.hasOwnProperty.call(value, node.name)) { return value[node.name] } } // Fall back to just the column part - if (node.name in row.cells) { - return row.cells[node.name]() + if (hasCell(row, node.name)) { + return readCell(row, node.name) } } else { // Try exact match first - if (node.name in row.cells) { - return row.cells[node.name]() + if (hasCell(row, node.name)) { + return readCell(row, node.name) } // For unqualified names, search for a matching prefixed column (e.g. 'id' to 'a.id') const suffix = '.' + node.name const match = row.columns.find(col => col.endsWith(suffix)) if (match) { - return row.cells[match]() + return readCell(row, match) } } // Unknown identifier @@ -140,7 +141,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) { const { value } = await gen.next() gen.return(undefined) if (!value) return null - return value.cells[value.columns[0]]() + return readCell(value, value.columns[0]) } // Unary operators @@ -188,8 +189,8 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) { // with the expression's derived alias. if (!rows) { const alias = derivedAlias(node) - if (alias in row.cells && !row.columns.includes(alias)) { - return row.cells[alias]() + if (!row.columns.includes(alias) && hasCell(row, alias)) { + return readCell(row, alias) } } @@ -200,7 +201,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) { // This is only allowed if same aggregate was in the SELECT list const alias = derivedAlias(node) if (row.columns.includes(alias)) { - return row.cells[alias]() + return readCell(row, alias) } else { throw new ExecutionError({ message: `Aggregate function ${funcName} is not available in this context`, @@ -731,7 +732,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) { await yieldToEventLoop() context.signal?.throwIfAborted() } - const value = await resRow.cells[resRow.columns[0]]() + const value = await readCell(resRow, resRow.columns[0]) if (sqlEquals(exprVal, value)) return true } return false diff --git a/src/types.d.ts b/src/types.d.ts index 5dea865..3fe0da1 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -62,8 +62,9 @@ export interface ExecuteContext { export interface AsyncRow { columns: string[] cells: AsyncCells - // Optional pre-materialized row values keyed by output column name. - // When present, consumers can skip the AsyncCell Promise roundtrip. + // Optional pre-materialized row values. When present, this must contain every + // key in columns so consumers can skip the AsyncCell Promise roundtrip. + // It may also contain backing-source fields that are not visible to SQL. resolved?: Record } export type AsyncCells = Record diff --git a/test/execute/execute.aggregate.test.js b/test/execute/execute.aggregate.test.js index 65aae8a..0be1154 100644 --- a/test/execute/execute.aggregate.test.js +++ b/test/execute/execute.aggregate.test.js @@ -94,6 +94,52 @@ describe('executeSql', () => { ]) }) + it('should preserve lazy derived-table cells through GROUP BY', async () => { + const result = await collect(executeSql({ + tables: { + t: [ + { obj: { k: 'z' } }, + { obj: { k: 'a' } }, + ], + }, + query: 'SELECT * FROM (SELECT obj.k AS k, obj FROM t) GROUP BY obj', + })) + expect(result).toEqual([ + { k: 'z', obj: { k: 'z' } }, + { k: 'a', obj: { k: 'a' } }, + ]) + }) + + it('should aggregate a correlated scalar subquery with nested lateral UNNEST after GROUP BY', async () => { + const outers = [ + { id: 1, arr: [10, 20] }, + { id: 2, arr: [30] }, + ] + const t = [ + { k: 1 }, + { k: 2 }, + ] + const result = await collect(executeSql({ + tables: { outers, t }, + query: ` + SELECT + id, + SUM(( + SELECT COUNT(*) + FROM t + JOIN UNNEST(o.arr) AS u(x) ON TRUE + )) AS nested_total + FROM outers AS o + GROUP BY id, arr + ORDER BY id + `, + })) + expect(result).toEqual([ + { id: 1, nested_total: 4 }, + { id: 2, nested_total: 2 }, + ]) + }) + it('should throw for COUNTIF with wrong argument count', () => { expect(() => executeSql({ tables: { users }, diff --git a/test/execute/execute.orderby.test.js b/test/execute/execute.orderby.test.js index 4f4d2c2..92577b4 100644 --- a/test/execute/execute.orderby.test.js +++ b/test/execute/execute.orderby.test.js @@ -135,6 +135,35 @@ describe('ORDER BY', () => { expect(result.map(r => r.name)).toEqual(['Bob', 'Diana', 'Alice', 'Eve', 'Charlie']) }) + it('should sort by a correlated scalar subquery with nested lateral UNNEST', async () => { + const outers = [ + { id: 1, arr: [10, 20] }, + { id: 2, arr: [30] }, + { id: 3, arr: [] }, + ] + const t = [ + { k: 1 }, + { k: 2 }, + ] + const result = await collect(executeSql({ + tables: { outers, t }, + query: ` + SELECT id, arr + FROM outers AS o + ORDER BY ( + SELECT COUNT(*) + FROM t + JOIN UNNEST(o.arr) AS u(x) ON TRUE + ), id + `, + })) + expect(result).toEqual([ + { id: 3, arr: [] }, + { id: 2, arr: [30] }, + { id: 1, arr: [10, 20] }, + ]) + }) + it('should sort by SELECT alias', async () => { const result = await collect(executeSql({ tables: { users }, query: 'SELECT id AS user_id, name FROM users ORDER BY user_id DESC' })) // Expected order by id DESC: 5, 4, 3, 2, 1 diff --git a/test/execute/execute.strings.test.js b/test/execute/execute.strings.test.js index 350c5ae..81d4d87 100644 --- a/test/execute/execute.strings.test.js +++ b/test/execute/execute.strings.test.js @@ -35,6 +35,14 @@ describe('string functions', () => { expect(result[0].upper_city).toBe('NYC') }) + it('should not use hidden resolved fields as cached expression aliases', async () => { + const result = await collect(executeSql({ + tables: { t: [{ name: 'a', upper_name: 'WRONG' }] }, + query: 'SELECT UPPER(name) FROM t', + })) + expect(result).toEqual([{ upper_name: 'A' }]) + }) + it('should handle mixed case input', async () => { const result = await collect(executeSql({ tables: { users }, @@ -60,6 +68,22 @@ describe('string functions', () => { expect(result[0].upper_name).toBe('ALICE') expect(result[result.length - 1].upper_name).toBe('DIANA') }) + + it('should preserve lazy derived-table cells through ORDER BY', async () => { + const result = await collect(executeSql({ + tables: { + t: [ + { obj: { k: 'z' }, a: 2 }, + { obj: { k: 'a' }, a: 1 }, + ], + }, + query: 'SELECT UPPER(k), obj FROM (SELECT obj.k AS k, a, obj FROM t) ORDER BY a', + })) + expect(result).toEqual([ + { upper_k: 'A', obj: { k: 'a' } }, + { upper_k: 'Z', obj: { k: 'z' } }, + ]) + }) }) describe('LOWER', () => {