Skip to content

Commit a4409be

Browse files
authored
fix: correctly infer nullable fields from LEFT JOIN LATERAL (#425)
1 parent 5af9dea commit a4409be

File tree

4 files changed

+141
-3
lines changed

4 files changed

+141
-3
lines changed

.changeset/bright-cows-lose.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ts-safeql/eslint-plugin": patch
3+
---
4+
5+
Fixed an issue where fields from LEFT JOIN LATERAL subqueries were incorrectly inferred as non-nullable.

packages/generate/src/ast-get-sources.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,12 @@ export function getSources({
314314
case "cte":
315315
case "subselect": {
316316
const nested = source.sources.getNestedResolvedTargetField(field);
317-
if (nested) return nested;
317+
if (nested) {
318+
return {
319+
...nested,
320+
isNotNull: nested.isNotNull && !checkIsNullableDueToRelation(source.name),
321+
};
322+
}
318323
break;
319324
}
320325
case "table": {
@@ -342,7 +347,15 @@ export function getSources({
342347
switch (source.kind) {
343348
case "subselect": {
344349
const column = source.sources.getNestedResolvedTargetField(field);
345-
if (column) return [column];
350+
if (column) {
351+
// Check if this subselect is part of a LEFT JOIN or FULL JOIN
352+
const isNullableDueToRelation = checkIsNullableDueToRelation(source.name);
353+
if (isNullableDueToRelation && column.isNotNull) {
354+
// Make the column nullable if it's from a left/full joined subselect
355+
return [{ ...column, isNotNull: false }];
356+
}
357+
return [column];
358+
}
346359
break;
347360
}
348361
default:
@@ -367,7 +380,17 @@ export function getSources({
367380
for (const source of sources.values()) {
368381
switch (source.kind) {
369382
case "subselect": {
370-
return source.sources.getColumnsByTargetField(field);
383+
const columns = source.sources.getColumnsByTargetField(field);
384+
if (columns) {
385+
// Check if this subselect is part of a LEFT JOIN or FULL JOIN
386+
const isNullableDueToRelation = checkIsNullableDueToRelation(source.name);
387+
if (isNullableDueToRelation) {
388+
// Make columns nullable if they're from a left/full joined subselect
389+
return columns.map((col) => (col.isNotNull ? { ...col, isNotNull: false } : col));
390+
}
391+
return columns;
392+
}
393+
break;
371394
}
372395

373396
default:

packages/generate/src/generate.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2350,3 +2350,96 @@ test("scalar subquery with WHERE should infer non-nullable type", async () => {
23502350
expected: [["col", { kind: "type", value: "string", type: "text" }]],
23512351
});
23522352
});
2353+
2354+
test("select from LEFT JOIN LATERAL should return nullable field", async () => {
2355+
await testQuery({
2356+
schema: `
2357+
CREATE TABLE parent_table (id INTEGER PRIMARY KEY);
2358+
CREATE TABLE child_table (
2359+
parent_id INTEGER REFERENCES parent_table(id),
2360+
status BOOLEAN NOT NULL
2361+
);
2362+
`,
2363+
query: `
2364+
SELECT
2365+
latest_child.status AS latest_status
2366+
FROM
2367+
parent_table
2368+
LEFT JOIN LATERAL (
2369+
SELECT child_table.status
2370+
FROM child_table
2371+
WHERE child_table.parent_id = parent_table.id
2372+
) latest_child ON TRUE
2373+
`,
2374+
expected: [
2375+
[
2376+
"latest_status",
2377+
{
2378+
kind: "union",
2379+
value: [
2380+
{ kind: "type", value: "boolean", type: "bool" },
2381+
{ kind: "type", value: "null", type: "null" },
2382+
],
2383+
},
2384+
],
2385+
],
2386+
});
2387+
});
2388+
2389+
test("nullable columns in regular subselect should remain nullable", async () => {
2390+
await testQuery({
2391+
schema: `
2392+
CREATE TABLE test_table (
2393+
id INTEGER PRIMARY KEY,
2394+
nullable_text TEXT,
2395+
non_null_text TEXT NOT NULL
2396+
);
2397+
`,
2398+
query: `
2399+
SELECT nullable_text, non_null_text
2400+
FROM (SELECT nullable_text, non_null_text FROM test_table) sub
2401+
`,
2402+
expected: [
2403+
[
2404+
"nullable_text",
2405+
{
2406+
kind: "union",
2407+
value: [
2408+
{ kind: "type", value: "string", type: "text" },
2409+
{ kind: "type", value: "null", type: "null" },
2410+
],
2411+
},
2412+
],
2413+
["non_null_text", { kind: "type", value: "string", type: "text" }],
2414+
],
2415+
});
2416+
});
2417+
2418+
test("nullable columns in INNER JOIN subselect should remain nullable", async () => {
2419+
await testQuery({
2420+
schema: `
2421+
CREATE TABLE table_a (id INTEGER PRIMARY KEY);
2422+
CREATE TABLE table_b (
2423+
id INTEGER PRIMARY KEY,
2424+
nullable_col TEXT
2425+
);
2426+
`,
2427+
query: `
2428+
SELECT sub.nullable_col
2429+
FROM table_a
2430+
INNER JOIN (SELECT id, nullable_col FROM table_b) sub ON sub.id = table_a.id
2431+
`,
2432+
expected: [
2433+
[
2434+
"nullable_col",
2435+
{
2436+
kind: "union",
2437+
value: [
2438+
{ kind: "type", value: "string", type: "text" },
2439+
{ kind: "type", value: "null", type: "null" },
2440+
],
2441+
},
2442+
],
2443+
],
2444+
});
2445+
});

packages/generate/src/utils/get-relations-with-joins.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,23 @@ const cases: {
113113
],
114114
],
115115
},
116+
{
117+
query: `
118+
SELECT latest.status
119+
FROM parent_table
120+
LEFT JOIN LATERAL (
121+
SELECT child_table.status
122+
FROM child_table
123+
WHERE child_table.parent_id = parent_table.id
124+
) latest ON TRUE
125+
`,
126+
expected: [
127+
[
128+
"parent_table",
129+
[{ alias: undefined, name: "latest", type: LibPgQueryAST.JoinType.JOIN_LEFT }],
130+
],
131+
],
132+
},
116133
];
117134

118135
export const getRelationsWithJoinsTE = flow(

0 commit comments

Comments
 (0)