Skip to content

Commit bd0f64b

Browse files
authored
fix jsonb operator nullability inference (#408)
1 parent ee808b4 commit bd0f64b

File tree

3 files changed

+157
-1
lines changed

3 files changed

+157
-1
lines changed

.changeset/clever-rockets-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ts-safeql/generate": patch
3+
---
4+
5+
Fixed jsonb `->>` and `#>>` operators to correctly infer nullable types when left expressions contain column references.

packages/generate/src/ast-describe.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ function getDescribedAExpr({
308308
return { value, nullable, array: false };
309309
}
310310

311+
case type.kind === "object":
312+
return { value: "jsonb", array: false, nullable: false };
313+
311314
default:
312315
return null;
313316
}
@@ -349,8 +352,23 @@ function getDescribedAExpr({
349352
return [adjust(left), operator, adjust(right)];
350353
};
351354

355+
const getNullable = () => {
356+
if (context.nonNullableColumns.has(name)) {
357+
return false;
358+
}
359+
360+
if (lnode.nullable || rnode.nullable) {
361+
return true;
362+
}
363+
364+
const operatorForcesNullable =
365+
["->>", "#>>"].includes(operator) && hasColumnReference(node.lexpr);
366+
367+
return operatorForcesNullable;
368+
};
369+
352370
const getType = (): ASTDescribedColumnType | undefined => {
353-
const nullable = !context.nonNullableColumns.has(name) && (lnode.nullable || rnode.nullable);
371+
const nullable = getNullable();
354372
const [dleft, doperator, dright] = downcast();
355373

356374
const type =
@@ -1087,6 +1105,70 @@ function concatStringNodes(nodes: LibPgQueryAST.Node[] | undefined): string {
10871105
);
10881106
}
10891107

1108+
function hasColumnReference(node: LibPgQueryAST.Node | undefined): boolean {
1109+
if (node === undefined) {
1110+
return false;
1111+
}
1112+
1113+
if (node.ColumnRef !== undefined) {
1114+
return true;
1115+
}
1116+
1117+
if (node.A_Const !== undefined) {
1118+
return false;
1119+
}
1120+
1121+
if (node.TypeCast !== undefined) {
1122+
return hasColumnReference(node.TypeCast.arg);
1123+
}
1124+
1125+
if (node.A_Expr !== undefined) {
1126+
return hasColumnReference(node.A_Expr.lexpr) || hasColumnReference(node.A_Expr.rexpr);
1127+
}
1128+
1129+
if (node.BoolExpr !== undefined) {
1130+
return (node.BoolExpr.args ?? []).some(hasColumnReference);
1131+
}
1132+
1133+
if (node.FuncCall !== undefined) {
1134+
return (node.FuncCall.args ?? []).some(hasColumnReference);
1135+
}
1136+
1137+
if (node.CoalesceExpr !== undefined) {
1138+
return node.CoalesceExpr.args.some(hasColumnReference);
1139+
}
1140+
1141+
if (node.CaseExpr !== undefined) {
1142+
if (node.CaseExpr.arg && hasColumnReference(node.CaseExpr.arg)) {
1143+
return true;
1144+
}
1145+
1146+
if (node.CaseExpr.defresult && hasColumnReference(node.CaseExpr.defresult)) {
1147+
return true;
1148+
}
1149+
1150+
return node.CaseExpr.args.some((caseWhen) => {
1151+
if (caseWhen.CaseWhen === undefined) {
1152+
return false;
1153+
}
1154+
1155+
return (
1156+
hasColumnReference(caseWhen.CaseWhen.expr) || hasColumnReference(caseWhen.CaseWhen.result)
1157+
);
1158+
});
1159+
}
1160+
1161+
if (node.A_ArrayExpr !== undefined) {
1162+
return (node.A_ArrayExpr.elements ?? []).some(hasColumnReference);
1163+
}
1164+
1165+
if (node.SubLink !== undefined) {
1166+
return true;
1167+
}
1168+
1169+
return false;
1170+
}
1171+
10901172
type GetDescribedParamsOf<T> = {
10911173
alias: string | undefined;
10921174
node: T;

packages/generate/src/generate.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2034,6 +2034,57 @@ test(`'{"a": {"b": 1}}'::jsonb ->> 'a' => string`, async () => {
20342034
});
20352035
});
20362036

2037+
test(`'{"a": {"b": 1}}'::jsonb #>> '{a,b}' => string`, async () => {
2038+
await testQuery({
2039+
query: `SELECT '{"a": {"b": 1}}'::jsonb #>> '{a,b}'`,
2040+
expected: [["?column?", { kind: "type", value: "string", type: "text" }]],
2041+
});
2042+
});
2043+
2044+
test(`jsonb subselect ->> key => string | null`, async () => {
2045+
await testQuery({
2046+
query: `SELECT (SELECT data FROM employee LIMIT 1) ->> 'myKey' as extracted_value`,
2047+
expected: [
2048+
[
2049+
"extracted_value",
2050+
{
2051+
kind: "union",
2052+
value: [
2053+
{ kind: "type", value: "string", type: "text" },
2054+
{ kind: "type", value: "null", type: "null" },
2055+
],
2056+
},
2057+
],
2058+
],
2059+
unknownColumns: ["extracted_value"],
2060+
});
2061+
});
2062+
2063+
test(`jsonb_build_object with column ->> key => string | null`, async () => {
2064+
await testQuery({
2065+
query: `SELECT jsonb_build_object('name', caregiver.last_name) ->> 'name' as extracted_value FROM caregiver`,
2066+
expected: [
2067+
[
2068+
"extracted_value",
2069+
{
2070+
kind: "union",
2071+
value: [
2072+
{ kind: "type", value: "string", type: "text" },
2073+
{ kind: "type", value: "null", type: "null" },
2074+
],
2075+
},
2076+
],
2077+
],
2078+
});
2079+
});
2080+
2081+
test(`jsonb_build_object without column ->> key => string`, async () => {
2082+
await testQuery({
2083+
query: `SELECT jsonb_build_object('name', 'value') ->> 'name'`,
2084+
expected: [["?column?", { kind: "type", value: "string", type: "text" }]],
2085+
});
2086+
});
2087+
20372088
test(`'{"a": 1, "b": 2}'::jsonb #- '{a}' => jsonb`, async () => {
20382089
await testQuery({
20392090
query: `SELECT '{"a": 1, "b": 2}'::jsonb #- '{a}'`,
@@ -2314,3 +2365,21 @@ test("varchar not like expr", async () => {
23142365
],
23152366
});
23162367
});
2368+
2369+
test("jsonb ->> operator should return string | null", async () => {
2370+
await testQuery({
2371+
query: `SELECT data->>'myKey' as extracted_value FROM employee`,
2372+
expected: [
2373+
[
2374+
"extracted_value",
2375+
{
2376+
kind: "union",
2377+
value: [
2378+
{ kind: "type", value: "string", type: "text" },
2379+
{ kind: "type", value: "null", type: "null" },
2380+
],
2381+
},
2382+
],
2383+
],
2384+
});
2385+
});

0 commit comments

Comments
 (0)