diff --git a/.changeset/fromquery-select-all.md b/.changeset/fromquery-select-all.md new file mode 100644 index 0000000..58ed7f7 --- /dev/null +++ b/.changeset/fromquery-select-all.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": patch +--- + +A bare `select()` over a subquery or union (`CH.fromQuery(inner, "w").select()`, `CH.fromUnion(union, "u").select()`) selects every alias the source selects, as its type already said. It died with `QueryBuilderDefect: select() is required`, because the derived source has no column list until its own SELECT is evaluated. diff --git a/src/ch/compile.ts b/src/ch/compile.ts index f345c95..ef9cff2 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -759,7 +759,7 @@ function compileInner< const $ = makeAccessor(state) // SELECT - const selectExprs = state.selectFn ? state.selectFn($) : {} + const selectExprs = selectRecordOf(state, $) ?? {} const keys = Object.keys(selectExprs) if ( options?.selectKeys && @@ -1271,8 +1271,19 @@ export function selectedAliasesOf(query: CHQuery): ReadonlyArray< /** Evaluate a query's SELECT callback without compiling it. */ function selectExprsOf(query: CHQuery): Record | undefined { - const state = query._state - return state.selectFn ? state.selectFn(makeAccessor(state)) : undefined + return selectRecordOf(query._state, makeAccessor(query._state)) +} + +/** + * A query's SELECT list against accessor `$`: its callback's record, or for a + * bare `select()` over a subquery or union, every alias that source selects. + */ +function selectRecordOf(state: CHQueryState, $: any): Record | undefined { + if (state.selectAll) { + const source = state.fromUnion ? unionExprsOf(state.fromUnion._state.queries) : state.fromQuery ? selectExprsOf(state.fromQuery) : undefined + return source === undefined ? undefined : Object.fromEntries(Object.keys(source).map((alias) => [alias, $[alias]])) + } + return state.selectFn?.($) } /** diff --git a/src/ch/query.ts b/src/ch/query.ts index 2767211..36752b4 100644 --- a/src/ch/query.ts +++ b/src/ch/query.ts @@ -126,6 +126,12 @@ export interface CHQueryState { readonly tableAlias?: string readonly columns: ColumnDefs readonly selectFn?: ($: any) => SelectRecord + /** + * A bare `select()` over a subquery or union: every alias the source + * selects. Its columns are only known once the source's SELECT is evaluated, + * which `compile` does, so the expansion waits for it. + */ + readonly selectAll?: true readonly whereFn?: ($: any) => ReadonlyArray readonly groupByKeys: string[] /** Post-aggregation filter. Deliberately NOT consulted when deriving tenant @@ -569,11 +575,15 @@ function makeQuery< _state: state, select(...args: any[]): any { - if (args.length === 0) return makeQuery({ ...state, selectFn: selectEvery(Object.keys(state.columns)) }) + if (args.length === 0) { + return state.fromQuery !== undefined || state.fromUnion !== undefined + ? makeQuery({ ...state, selectFn: undefined, selectAll: true }) + : makeQuery({ ...state, selectFn: selectEvery(Object.keys(state.columns)) }) + } // String overload: select("Col1", "Col2") → select($ => ({ Col1: $.Col1, Col2: $.Col2 })) - if (typeof args[0] === "string") return makeQuery({ ...state, selectFn: selectEvery(args as string[]) }) + if (typeof args[0] === "string") return makeQuery({ ...state, selectFn: selectEvery(args as string[]), selectAll: undefined }) // Callback overload: select($ => ({ ... })) - return makeQuery({ ...state, selectFn: args[0] }) + return makeQuery({ ...state, selectFn: args[0], selectAll: undefined }) }, where(fn) { diff --git a/src/ch/subquery.test.ts b/src/ch/subquery.test.ts index 6e4203d..67d61fa 100644 --- a/src/ch/subquery.test.ts +++ b/src/ch/subquery.test.ts @@ -353,3 +353,21 @@ describe("CTE shadowing", () => { expect(compiled.tenantScope).toBe("cross-tenant") }) }) + +describe("bare select() over a derived source", () => { + const inner = CH.from(Events) + .select(($) => ({ trace: $.TraceId, total: CH.count(), raw: CH.untypedExpr("1") })) + .groupBy("trace") + + it("selects every alias of a subquery, typed ones decoding as before", () => { + const compiled = compileCHUnsafe(CH.fromQuery(inner, "w").select(), {}) + expect(compiled.sql).toContain("SELECT\n w.trace AS trace,\n w.total AS total,\n w.raw AS raw") + expect(compiled.sql).toContain("FROM (") + }) + + it("selects every alias of a union", () => { + const branch = CH.from(Events).select(($) => ({ trace: $.TraceId })) + const { sql } = compileCHUnsafe(CH.fromUnion(CH.unionAll(branch, branch), "u").select(), {}) + expect(sql).toContain("u.trace AS trace") + }) +}) diff --git a/tests/fuzz/known-issues.ts b/tests/fuzz/known-issues.ts index c93502c..ffb0997 100644 --- a/tests/fuzz/known-issues.ts +++ b/tests/fuzz/known-issues.ts @@ -123,13 +123,7 @@ const planIssues: ReadonlyArray> = [ target === "postgres" && [...nodes(plan)].some((node) => node.k === "value" && typeof node.v === "number" && !Number.isInteger(node.v)), }, - { - id: "fromquery-select-all", - reason: - "`CH.fromQuery(q, alias).select()` is typed as selecting every column, but a subquery source has no column " + - "list to expand, so compile dies with QueryBuilderDefect `select() is required`. `select(...names)` works.", - matches: ({ plan }) => plan.wrap === "all", - }, + ] export const knownPlanIssue = (input: PlanInput): string | undefined => firstMatch(planIssues, input)