Skip to content
This repository was archived by the owner on Oct 9, 2025. It is now read-only.

Commit e099a47

Browse files
committed
fix: self-referencing relation corner case
Related: #509
1 parent cd55433 commit e099a47

File tree

9 files changed

+226
-64
lines changed

9 files changed

+226
-64
lines changed

src/select-query-parser/result.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export type ProcessEmbeddedResource<
164164
> = ResolveRelationship<Schema, Relationships, Field, CurrentTableOrView> extends infer Resolved
165165
? Resolved extends {
166166
referencedTable: Pick<GenericTable, 'Row' | 'Relationships'>
167-
relation: GenericRelationship
167+
relation: GenericRelationship & { match: 'refrel' | 'col' | 'fkname' }
168168
direction: string
169169
}
170170
? ProcessEmbeddedResourceResult<Schema, Resolved, Field, CurrentTableOrView>
@@ -182,7 +182,7 @@ type ProcessEmbeddedResourceResult<
182182
Schema extends GenericSchema,
183183
Resolved extends {
184184
referencedTable: Pick<GenericTable, 'Row' | 'Relationships'>
185-
relation: GenericRelationship
185+
relation: GenericRelationship & { match: 'refrel' | 'col' | 'fkname' }
186186
direction: string
187187
},
188188
Field extends FieldNode,
@@ -207,7 +207,22 @@ type ProcessEmbeddedResourceResult<
207207
: Resolved['relation']['isOneToOne'] extends true
208208
? ProcessedChildren | null
209209
: ProcessedChildren[]
210-
: IsRelationNullable<
210+
: // If the relation is a self-reference it'll always be considered as reverse relationship
211+
Resolved['relation']['referencedRelation'] extends CurrentTableOrView
212+
? // It can either be a reverse reference via a column inclusion (eg: parent_id(*))
213+
// in such case the result will be a single object
214+
Resolved['relation']['match'] extends 'col'
215+
? IsRelationNullable<
216+
TablesAndViews<Schema>[CurrentTableOrView],
217+
Resolved['relation']
218+
> extends true
219+
? ProcessedChildren | null
220+
: ProcessedChildren
221+
: // Or it can be a reference via the reference relation (eg: collections(*))
222+
// in such case, the result will be an array of all the values (all collection with parent_id being the current id)
223+
ProcessedChildren[]
224+
: // Otherwise if it's a non self-reference reverse relationship it's a single object
225+
IsRelationNullable<
211226
TablesAndViews<Schema>[CurrentTableOrView],
212227
Resolved['relation']
213228
> extends true

src/select-query-parser/utils.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -251,20 +251,6 @@ type ResolveReverseRelationship<
251251
: false
252252
: false
253253

254-
// Utility type to find embeded relationships by all their possibles references
255-
export type FindMatchingRelationships<
256-
value extends string,
257-
Relationships extends GenericRelationship[]
258-
> = Relationships extends [infer R, ...infer Rest extends GenericRelationship[]]
259-
? R extends { foreignKeyName: value }
260-
? R
261-
: R extends { referencedRelation: value }
262-
? R
263-
: R extends { columns: [value] }
264-
? R
265-
: FindMatchingRelationships<value, Rest>
266-
: false
267-
268254
export type FindMatchingTableRelationships<
269255
Schema extends GenericSchema,
270256
Relationships extends GenericRelationship[],

test/db/00-schema.sql

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ CREATE TABLE public.messages (
5959
ALTER TABLE public.messages REPLICA IDENTITY FULL; -- Send "previous data" to supabase
6060
COMMENT ON COLUMN public.messages.data IS 'For unstructured data and prototyping.';
6161

62+
-- SELF REFERENCING TABLE
63+
CREATE TABLE public.collections (
64+
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
65+
description text,
66+
parent_id bigint
67+
);
68+
ALTER TABLE public.messages REPLICA IDENTITY FULL; -- Send "previous data" to supabase
69+
-- SELF REFERENCE via parent_id
70+
ALTER TABLE public.collections
71+
ADD CONSTRAINT collections_parent_id_fkey
72+
FOREIGN KEY (parent_id)
73+
REFERENCES public.collections(id);
74+
COMMENT ON COLUMN public.messages.data IS 'For unstructured data and prototyping.';
75+
6276
-- STORED FUNCTION
6377
CREATE FUNCTION public.get_status(name_param text)
6478
RETURNS user_status AS $$

test/db/01-dummy-data.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,12 @@ INSERT INTO best_friends(id, first_user, second_user, third_wheel)
4949
VALUES
5050
(1, 'supabot', 'kiwicopple', 'awailas'),
5151
(2, 'supabot', 'awailas', NULL);
52+
53+
INSERT INTO public.collections (id, description, parent_id)
54+
VALUES
55+
(1, 'Root Collection', NULL),
56+
(2, 'Child of Root', 1),
57+
(3, 'Another Child of Root', 1),
58+
(4, 'Grandchild', 2),
59+
(5, 'Sibling of Grandchild', 2),
60+
(6, 'Child of Another Root', 3);

test/relationships.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ export const selectParams = {
169169
from: 'channels',
170170
select: 'id, messages!channel_id!inner(id, username)',
171171
},
172+
selfReferenceRelation: {
173+
from: 'collections',
174+
select: '*, collections(*)',
175+
},
176+
selfReferenceRelationViaColumn: {
177+
from: 'collections',
178+
select: '*, parent_id(*)',
179+
},
172180
} as const
173181

174182
export const selectQueries = {
@@ -335,6 +343,12 @@ export const selectQueries = {
335343
innerJoinOnManyRelation: postgrest
336344
.from(selectParams.innerJoinOnManyRelation.from)
337345
.select(selectParams.innerJoinOnManyRelation.select),
346+
selfReferenceRelation: postgrest
347+
.from(selectParams.selfReferenceRelation.from)
348+
.select(selectParams.selfReferenceRelation.select),
349+
selfReferenceRelationViaColumn: postgrest
350+
.from(selectParams.selfReferenceRelationViaColumn.from)
351+
.select(selectParams.selfReferenceRelationViaColumn.select),
338352
} as const
339353

340354
test('nested query with selective fields', async () => {
@@ -1742,3 +1756,53 @@ test('inner join on many relation', async () => {
17421756
}
17431757
`)
17441758
})
1759+
1760+
test('self reference relation', async () => {
1761+
const res = await selectQueries.selfReferenceRelation.limit(1).single()
1762+
expect(res).toMatchInlineSnapshot(`
1763+
Object {
1764+
"count": null,
1765+
"data": Object {
1766+
"collections": Array [
1767+
Object {
1768+
"description": "Child of Root",
1769+
"id": 2,
1770+
"parent_id": 1,
1771+
},
1772+
Object {
1773+
"description": "Another Child of Root",
1774+
"id": 3,
1775+
"parent_id": 1,
1776+
},
1777+
],
1778+
"description": "Root Collection",
1779+
"id": 1,
1780+
"parent_id": null,
1781+
},
1782+
"error": null,
1783+
"status": 200,
1784+
"statusText": "OK",
1785+
}
1786+
`)
1787+
})
1788+
1789+
test('self reference relation via column', async () => {
1790+
const res = await selectQueries.selfReferenceRelationViaColumn.eq('id', 2).limit(1).single()
1791+
expect(res).toMatchInlineSnapshot(`
1792+
Object {
1793+
"count": null,
1794+
"data": Object {
1795+
"description": "Child of Root",
1796+
"id": 2,
1797+
"parent_id": Object {
1798+
"description": "Root Collection",
1799+
"id": 1,
1800+
"parent_id": null,
1801+
},
1802+
},
1803+
"error": null,
1804+
"status": 200,
1805+
"statusText": "OK",
1806+
}
1807+
`)
1808+
})

test/select-query-parser/result.test-d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,20 @@ type SelectQueryFromTableResult<
5353
}
5454
expectType<TypeEqual<typeof result, typeof expected>>(true)
5555
}
56+
57+
// Self referencing relation
58+
{
59+
const { from, select } = selectParams.selfReferenceRelation
60+
let result: SelectQueryFromTableResult<typeof from, typeof select>
61+
let expected: {
62+
id: number
63+
description: string | null
64+
parent_id: number | null
65+
collections: {
66+
id: number
67+
description: string | null
68+
parent_id: number | null
69+
}[]
70+
}
71+
expectType<TypeEqual<typeof result, typeof expected>>(true)
72+
}

test/select-query-parser/select.test-d.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,3 +701,38 @@ type Schema = Database['public']
701701
}
702702
expectType<TypeEqual<typeof result, typeof expected>>(true)
703703
}
704+
705+
// self reference relation
706+
{
707+
const { data } = await selectQueries.selfReferenceRelation.limit(1).single()
708+
let result: Exclude<typeof data, null>
709+
let expected: {
710+
id: number
711+
description: string | null
712+
parent_id: number | null
713+
collections: {
714+
id: number
715+
description: string | null
716+
parent_id: number | null
717+
}[]
718+
}
719+
expectType<TypeEqual<typeof result, typeof expected>>(true)
720+
}
721+
722+
// self reference relation via column
723+
{
724+
const { data } = await selectQueries.selfReferenceRelationViaColumn.limit(1).single()
725+
let result: Exclude<typeof data, null>
726+
let expected: {
727+
description: string | null
728+
id: number
729+
parent_id:
730+
| (number & {
731+
description: string | null
732+
id: number
733+
parent_id: number | null
734+
})
735+
| null
736+
}
737+
expectType<TypeEqual<typeof result, typeof expected>>(true)
738+
}

test/select-query-parser/types.test-d.ts

Lines changed: 29 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
import { expectType } from 'tsd'
99
import { TypeEqual } from 'ts-expect'
1010
import {
11-
FindMatchingRelationships,
1211
FindMatchingTableRelationships,
1312
IsRelationNullable,
1413
} from '../../src/select-query-parser/utils'
@@ -17,58 +16,37 @@ import { ParseQuery } from '../../src/select-query-parser/parser/parser'
1716

1817
// This test file is here to ensure some of our helpers behave as expected for ease of development
1918
// and debugging purposes
20-
// Searching for an non-existing relationship should return never
21-
{
22-
let result: FindMatchingRelationships<
23-
'test',
24-
Database['public']['Tables']['best_friends']['Relationships']
25-
>
26-
let expected: false
27-
expectType<TypeEqual<typeof result, typeof expected>>(true)
28-
}
19+
2920
// Searching for a relationship by direct foreignkey name
3021
{
31-
let result: FindMatchingRelationships<
32-
'best_friends_first_user_fkey',
33-
Database['public']['Tables']['best_friends']['Relationships']
22+
let result: FindMatchingTableRelationships<
23+
Database['public'],
24+
Database['public']['Tables']['best_friends']['Relationships'],
25+
'best_friends_first_user_fkey'
3426
>
3527
let expected: {
3628
foreignKeyName: 'best_friends_first_user_fkey'
3729
columns: ['first_user']
3830
isOneToOne: false
3931
referencedRelation: 'users'
4032
referencedColumns: ['username']
41-
}
33+
} & { match: 'fkname' }
4234
expectType<TypeEqual<typeof result, typeof expected>>(true)
4335
}
4436
// Searching for a relationship by column hoding the value reference
4537
{
46-
let result: FindMatchingRelationships<
47-
'first_user',
48-
Database['public']['Tables']['best_friends']['Relationships']
38+
let result: FindMatchingTableRelationships<
39+
Database['public'],
40+
Database['public']['Tables']['best_friends']['Relationships'],
41+
'first_user'
4942
>
5043
let expected: {
5144
foreignKeyName: 'best_friends_first_user_fkey'
5245
columns: ['first_user']
5346
isOneToOne: false
5447
referencedRelation: 'users'
5548
referencedColumns: ['username']
56-
}
57-
expectType<TypeEqual<typeof result, typeof expected>>(true)
58-
}
59-
// Will find the first matching relationship
60-
{
61-
let result: FindMatchingRelationships<
62-
'username',
63-
Database['public']['Tables']['user_profiles']['Relationships']
64-
>
65-
let expected: {
66-
foreignKeyName: 'user_profiles_username_fkey'
67-
columns: ['username']
68-
isOneToOne: false
69-
referencedRelation: 'non_updatable_view'
70-
referencedColumns: ['username']
71-
}
49+
} & { match: 'col' }
7250
expectType<TypeEqual<typeof result, typeof expected>>(true)
7351
}
7452
// should return the relation matching the "Tables" references
@@ -89,44 +67,48 @@ import { ParseQuery } from '../../src/select-query-parser/parser/parser'
8967
}
9068
// Searching for a relationship by referenced table name
9169
{
92-
let result: FindMatchingRelationships<
93-
'users',
94-
Database['public']['Tables']['messages']['Relationships']
70+
let result: FindMatchingTableRelationships<
71+
Database['public'],
72+
Database['public']['Tables']['messages']['Relationships'],
73+
'users'
9574
>
9675
let expected: {
9776
foreignKeyName: 'messages_username_fkey'
9877
columns: ['username']
9978
isOneToOne: false
10079
referencedRelation: 'users'
10180
referencedColumns: ['username']
102-
}
81+
} & { match: 'refrel' }
10382
expectType<TypeEqual<typeof result, typeof expected>>(true)
10483
}
10584
{
106-
let result: FindMatchingRelationships<
107-
'channels',
108-
Database['public']['Tables']['messages']['Relationships']
85+
let result: FindMatchingTableRelationships<
86+
Database['public'],
87+
Database['public']['Tables']['messages']['Relationships'],
88+
'channels'
10989
>
11090
let expected: {
11191
foreignKeyName: 'messages_channel_id_fkey'
11292
columns: ['channel_id']
11393
isOneToOne: false
11494
referencedRelation: 'channels'
11595
referencedColumns: ['id']
116-
}
96+
} & { match: 'refrel' }
11797
expectType<TypeEqual<typeof result, typeof expected>>(true)
11898
}
11999

120100
// IsRelationNullable
121101
{
122102
type BestFriendsTable = Database['public']['Tables']['best_friends']
123-
type NonNullableRelation = FindMatchingRelationships<
124-
'best_friends_first_user_fkey',
125-
BestFriendsTable['Relationships']
103+
type NonNullableRelation = FindMatchingTableRelationships<
104+
Database['public'],
105+
BestFriendsTable['Relationships'],
106+
'best_friends_first_user_fkey'
126107
>
127-
type NullableRelation = FindMatchingRelationships<
128-
'best_friends_third_wheel_fkey',
129-
BestFriendsTable['Relationships']
108+
type NullableRelation = FindMatchingTableRelationships<
109+
Database['public'],
110+
BestFriendsTable['Relationships'],
111+
'best_friends_third_wheel_fkey'
130112
>
131113
let nonNullableResult: IsRelationNullable<BestFriendsTable, NonNullableRelation>
132114
let nullableResult: IsRelationNullable<BestFriendsTable, NullableRelation>

0 commit comments

Comments
 (0)