Skip to content

Commit

Permalink
fix(select-query-parser): support !left
Browse files Browse the repository at this point in the history
* test: reproduce type error with left join

reproduction based on supabase/supabase#29086

* chore: add ts-expect-error comment

* test: add left join one to many select test

* test: add 1-1 nullable and 1-N relations tests

* wip: fix left join result type builder

* wip: found misplassed nullable

* chore: revert unwanted biome fixes

* fix: query types parser

* chore: remove unwanted styling

* chore: add comment

* fix: rollback tests

* fix: tests

* fix: tests comments

* chore: fix docker-compose ci

Chore docker-compose in ci and replace it by docker compose as recommended by:
https://github.blog/changelog/2024-04-10-github-hosted-runner-images-deprecation-notice-docker-compose-v1/

* Update test/db/01-dummy-data.sql

---------

Co-authored-by: Bobbie Soedirgo <[email protected]>
  • Loading branch information
avallete and soedirgo committed Sep 10, 2024
1 parent 9f91e72 commit c2049b2
Show file tree
Hide file tree
Showing 7 changed files with 471 additions and 2 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
"test:run": "jest --runInBand",
"test:update": "run-s db:clean db:run && jest --runInBand --updateSnapshot && run-s db:clean",
"test:types": "run-s build && tsd --files test/*.test-d.ts",
"db:clean": "cd test/db && docker-compose down --volumes",
"db:run": "cd test/db && docker-compose up --detach && wait-for-localhost 3000"
"db:clean": "cd test/db && docker compose down --volumes",
"db:run": "cd test/db && docker compose up --detach && wait-for-localhost 3000"
},
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
Expand Down
12 changes: 12 additions & 0 deletions src/select-query-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ type ConstructFieldDefinition<
? HasFKeyToFRel<Field['original'], Relationships> extends true
? Field extends { inner: true }
? Child
: Field extends { left: true }
? // TODO: This should return null only if the column is actually nullable
Child | null
: Child | null
: Child[]
: Child[]
Expand Down Expand Up @@ -350,6 +353,7 @@ type ParseIdentifier<Input extends string> = ReadLetters<Input> extends [
* - `field(nodes)`
* - `field!hint(nodes)`
* - `field!inner(nodes)`
* - `field!left(nodes)`
* - `field!hint!inner(nodes)`
* - a field without an embedded resource (see {@link ParseFieldWithoutEmbeddedResource})
*/
Expand All @@ -364,6 +368,14 @@ type ParseField<Input extends string> = Input extends ''
ParseEmbeddedResource<EatWhitespace<Remainder>>,
'Expected embedded resource after `!inner`'
>
: EatWhitespace<Remainder> extends `!left${infer Remainder}`
? ParseEmbeddedResource<EatWhitespace<Remainder>> extends [infer Fields, `${infer Remainder}`]
? // `field!left(nodes)`
[{ name: Name; original: Name; children: Fields; left: true }, EatWhitespace<Remainder>]
: CreateParserErrorIfRequired<
ParseEmbeddedResource<EatWhitespace<Remainder>>,
'Expected embedded resource after `!left`'
>
: EatWhitespace<Remainder> extends `!${infer Remainder}`
? ParseIdentifier<EatWhitespace<Remainder>> extends [infer Hint, `${infer Remainder}`]
? EatWhitespace<Remainder> extends `!inner${infer Remainder}`
Expand Down
236 changes: 236 additions & 0 deletions test/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1481,3 +1481,239 @@ test('update with no match - return=representation', async () => {
}
`)
})

test('!left join on one to one relation', async () => {
const res = await postgrest.from('channel_details').select('channels!left(id)').limit(1).single()
expect(Array.isArray(res.data?.channels)).toBe(false)
// TODO: This should not be nullable
expect(res.data?.channels?.id).not.toBeNull()
expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"channels": Object {
"id": 1,
},
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})

test('!left join on one to many relation', async () => {
const res = await postgrest.from('users').select('messages!left(username)').limit(1).single()
expect(Array.isArray(res.data?.messages)).toBe(true)
expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"messages": Array [
Object {
"username": "supabot",
},
Object {
"username": "supabot",
},
],
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})

test('!left join on one to 0-1 non-empty relation', async () => {
const res = await postgrest
.from('users')
.select('user_profiles!left(username)')
.eq('username', 'supabot')
.limit(1)
.single()
expect(Array.isArray(res.data?.user_profiles)).toBe(true)
expect(res.data?.user_profiles[0].username).not.toBeNull()
expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"user_profiles": Array [
Object {
"username": "supabot",
},
],
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})

test('!left join on zero to one with null relation', async () => {
const res = await postgrest
.from('user_profiles')
.select('*,users!left(*)')
.eq('id', 2)
.limit(1)
.single()
expect(Array.isArray(res.data?.users)).toBe(false)
expect(res.data?.users).toBeNull()

expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"id": 2,
"username": null,
"users": null,
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})

test('!left join on zero to one with valid relation', async () => {
const res = await postgrest
.from('user_profiles')
.select('*,users!left(status)')
.eq('id', 1)
.limit(1)
.single()
expect(Array.isArray(res.data?.users)).toBe(false)
// TODO: This should be nullable indeed
expect(res.data?.users?.status).not.toBeNull()

expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"id": 1,
"username": "supabot",
"users": Object {
"status": "ONLINE",
},
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})

test('!left join on zero to one empty relation', async () => {
const res = await postgrest
.from('users')
.select('user_profiles!left(username)')
.eq('username', 'dragarcia')
.limit(1)
.single()
expect(Array.isArray(res.data?.user_profiles)).toBe(true)
expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"user_profiles": Array [],
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})

test('join on 1-M relation', async () => {
// TODO: This won't raise the proper types for "first_friend_of,..." results
const res = await postgrest
.from('users')
.select(
`first_friend_of:best_friends_first_user_fkey(*),
second_friend_of:best_friends_second_user_fkey(*),
third_wheel_of:best_friends_third_wheel_fkey(*)`
)
.eq('username', 'supabot')
.limit(1)
.single()
expect(Array.isArray(res.data?.first_friend_of)).toBe(true)
expect(Array.isArray(res.data?.second_friend_of)).toBe(true)
expect(Array.isArray(res.data?.third_wheel_of)).toBe(true)
expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"first_friend_of": Array [
Object {
"first_user": "supabot",
"id": 1,
"second_user": "kiwicopple",
"third_wheel": "awailas",
},
Object {
"first_user": "supabot",
"id": 2,
"second_user": "awailas",
"third_wheel": null,
},
],
"second_friend_of": Array [],
"third_wheel_of": Array [],
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})

test('join on 1-1 relation with nullables', async () => {
const res = await postgrest
.from('best_friends')
.select(
'first_user:users!best_friends_first_user_fkey(*), second_user:users!best_friends_second_user_fkey(*), third_wheel:users!best_friends_third_wheel_fkey(*)'
)
.order('id')
.limit(1)
.single()
expect(Array.isArray(res.data?.first_user)).toBe(false)
expect(Array.isArray(res.data?.second_user)).toBe(false)
expect(Array.isArray(res.data?.third_wheel)).toBe(false)
// TODO: This should return null only if the column is actually nullable thoses are not
expect(res.data?.first_user?.username).not.toBeNull()
expect(res.data?.second_user?.username).not.toBeNull()
// TODO: This column however is nullable
expect(res.data?.third_wheel?.username).not.toBeNull()
expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"first_user": Object {
"age_range": "[1,2)",
"catchphrase": "'cat' 'fat'",
"data": null,
"status": "ONLINE",
"username": "supabot",
},
"second_user": Object {
"age_range": "[25,35)",
"catchphrase": "'bat' 'cat'",
"data": null,
"status": "OFFLINE",
"username": "kiwicopple",
},
"third_wheel": Object {
"age_range": "[25,35)",
"catchphrase": "'bat' 'rat'",
"data": null,
"status": "ONLINE",
"username": "awailas",
},
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})
18 changes: 18 additions & 0 deletions test/db/00-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ CREATE TABLE public.users (
ALTER TABLE public.users REPLICA IDENTITY FULL; -- Send "previous data" to supabase
COMMENT ON COLUMN public.users.data IS 'For unstructured data and prototyping.';


-- CREATE A ZERO-TO-ONE RELATIONSHIP (User can have profile, but not all of them do)
CREATE TABLE public.user_profiles (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username text REFERENCES users
);

-- CREATE A TABLE WITH TWO RELATIONS TO SAME DESTINATION WHICH WILL NEED HINTING
CREATE TABLE public.best_friends (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- Thoses relations should always be satisfied, never be null
first_user text REFERENCES users NOT NULL,
second_user text REFERENCES users NOT NULL,
-- This relation is nullable, it might be null
third_wheel text REFERENCES users
);


-- CHANNELS
CREATE TABLE public.channels (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
Expand Down
15 changes: 15 additions & 0 deletions test/db/01-dummy-data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,18 @@ VALUES
INSERT INTO shops(id, address, shop_geom)
VALUES
(1, '1369 Cambridge St', 'SRID=4326;POINT(-71.10044 42.373695)');

INSERT INTO public.channel_details (id, details)
VALUES
(1, 'Details for public channel'),
(2, 'Details for random channel');

INSERT INTO user_profiles (id, username)
VALUES
(1, 'supabot'),
(2, NULL);

INSERT INTO best_friends(id, first_user, second_user, third_wheel)
VALUES
(1, 'supabot', 'kiwicopple', 'awailas'),
(2, 'supabot', 'awailas', NULL);
Loading

0 comments on commit c2049b2

Please sign in to comment.