Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slimy-walls-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ts-safeql/generate": minor
---

Added support for `unnest` function in type inference. This allows correct typing when expanding arrays into rows, including support for multidimensional arrays and enums.
45 changes: 45 additions & 0 deletions packages/generate/src/ast-describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,11 +800,56 @@ function getDescribedFuncCall(
return getDescribedJsonAggFunCall(params);
case functionName === "array_agg":
return getDescribedArrayAggFunCall(params);
case functionName === "unnest":
return getDescribedUnnestFunCall(params);
default:
return getDescribedFuncCallByPgFn(params);
}
}

function getDescribedUnnestFunCall({
alias,
context,
node,
}: GetDescribedParamsOf<LibPgQueryAST.FuncCall>): ASTDescribedColumn[] {
const functionName = node.funcname.at(-1)?.String?.sval ?? "";
const name = alias ?? functionName;

const firstArg = node.args?.at(0);
if (firstArg === undefined) {
return [{ name, type: context.toTypeScriptType({ name: "unknown" }) }];
Comment on lines +818 to +820

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Infer columns for all UNNEST arguments

getDescribedUnnestFunCall only inspects node.args?.at(0) and builds a single column from that one argument, so calls like SELECT * FROM unnest(arr1, arr2) or unnest(... ) WITH ORDINALITY will drop the additional returned columns in the inferred schema. That produces an incorrect column count and types for valid UNNEST usages involving multiple arrays, which will make the generated typings diverge from the actual query results.

Useful? React with 👍 / 👎.

}

const described = getDescribedNode({ alias: undefined, node: firstArg, context }).at(0);
if (described === undefined) {
return [{ name, type: context.toTypeScriptType({ name: "unknown" }) }];
}

function unwrap(type: ASTDescribedColumnType): ASTDescribedColumnType {
if (type.kind === "array") {
return unwrap(type.value);
}

if (type.kind === "union") {
const hasArray = type.value.some((t) => t.kind === "array");

const unwrapped = type.value
.filter((t) => !hasArray || !(t.kind === "type" && t.value === "null"))
.map(unwrap);

return mergeDescribedColumnTypes(unwrapped);
}

if (type.kind === "type" && type.type.startsWith("_")) {
return { ...type, type: type.type.slice(1) };
}

return type;
}

return [{ name, type: unwrap(described.type) }];
}

function getDescribedFuncCallByPgFn({
alias,
context,
Expand Down
75 changes: 75 additions & 0 deletions packages/generate/src/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2508,3 +2508,78 @@ test("select array of enums", async () => {
],
});
});

test("select unnest(array_text_column)", async () => {
await testQuery({
query: `SELECT unnest(array_text_column) as unnested_text FROM all_types`,
expected: [["unnested_text", { kind: "type", value: "string", type: "text" }]],
});
});

test("select unnest(array_enum_column)", async () => {
await testQuery({
schema: `
CREATE TYPE my_enum AS ENUM ('A', 'B', 'C');
CREATE TABLE test_unnest_enum (col my_enum[] NOT NULL);
`,
query: `SELECT unnest(col) as unnested_enum FROM test_unnest_enum`,
expected: [
[
"unnested_enum",
{
kind: "union",
value: [
{ kind: "type", value: "'A'", type: "my_enum" },
{ kind: "type", value: "'B'", type: "my_enum" },
{ kind: "type", value: "'C'", type: "my_enum" },
],
},
],
],
});
});

test("select unnest(multidimensional_array)", async () => {
await testQuery({
query: `SELECT unnest(ARRAY[[1,2],[3,4]]) as unnested_int`,
expected: [
[
"unnested_int",
{
kind: "union",
value: [
{ kind: "literal", value: "1", base: { kind: "type", value: "number", type: "int4" } },
{ kind: "literal", value: "2", base: { kind: "type", value: "number", type: "int4" } },
{ kind: "literal", value: "3", base: { kind: "type", value: "number", type: "int4" } },
{ kind: "literal", value: "4", base: { kind: "type", value: "number", type: "int4" } },
],
},
],
],
});
});

test("select unnest(nullable_array_column)", async () => {
await testQuery({
query: `SELECT unnest(nullable_date_arr) as unnested_date FROM test_date_column`,
expected: [["unnested_date", { kind: "type", value: "Date", type: "date" }]],
});
});

test("select unnest(array_with_nulls)", async () => {
await testQuery({
query: `SELECT unnest(ARRAY[1, NULL]) as unnested_int`,
expected: [
[
"unnested_int",
{
kind: "union",
value: [
{ kind: "literal", value: "1", base: { kind: "type", value: "number", type: "int4" } },
{ kind: "type", value: "null", type: "null" },
],
},
],
],
});
});