Skip to content

Commit

Permalink
Merge pull request #565 from supabase/fix/forward-relationships-not-f…
Browse files Browse the repository at this point in the history
…ound

fix(types): forward relationships not found
  • Loading branch information
avallete authored Oct 28, 2024
2 parents e31b1ff + 803c861 commit ad1c533
Show file tree
Hide file tree
Showing 8 changed files with 266 additions and 8 deletions.
2 changes: 1 addition & 1 deletion src/select-query-parser/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export type ProcessEmbeddedResource<
Schema extends GenericSchema,
Relationships extends GenericRelationship[],
Field extends Ast.FieldNode,
CurrentTableOrView extends keyof TablesAndViews<Schema>
CurrentTableOrView extends keyof TablesAndViews<Schema> & string
> = ResolveRelationship<Schema, Relationships, Field, CurrentTableOrView> extends infer Resolved
? Resolved extends {
referencedTable: Pick<GenericTable, 'Row' | 'Relationships'>
Expand Down
57 changes: 53 additions & 4 deletions src/select-query-parser/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export type ResolveRelationship<
Schema extends GenericSchema,
Relationships extends GenericRelationship[],
Field extends Ast.FieldNode,
CurrentTableOrView extends keyof TablesAndViews<Schema>
CurrentTableOrView extends keyof TablesAndViews<Schema> & string
> = ResolveReverseRelationship<
Schema,
Relationships,
Expand Down Expand Up @@ -385,7 +385,7 @@ type FilterRelationships<R, TName, From> = R extends readonly (infer Rel)[]
type ResolveForwardRelationship<
Schema extends GenericSchema,
Field extends Ast.FieldNode,
CurrentTableOrView extends keyof TablesAndViews<Schema>
CurrentTableOrView extends keyof TablesAndViews<Schema> & string
> = FindFieldMatchingRelationships<
Schema,
TablesAndViews<Schema>[Field['name']]['Relationships'],
Expand Down Expand Up @@ -413,9 +413,58 @@ type ResolveForwardRelationship<
relation: FoundByMatch
direction: 'forward'
from: CurrentTableOrView
type: 'found-my-match'
type: 'found-by-match'
}
: // Forward relations can also alias other tables via tables joins relationships
// in such cases we crawl all the tables looking for a join table between our current table
// and the Field['name'] desired desitnation
FindJoinTableRelationship<
Schema,
CurrentTableOrView,
Field['name']
> extends infer FoundByJoinTable extends GenericRelationship
? {
referencedTable: TablesAndViews<Schema>[FoundByJoinTable['referencedRelation']]
relation: FoundByJoinTable & { match: 'refrel' }
direction: 'forward'
from: CurrentTableOrView
type: 'found-by-join-table'
}
: SelectQueryError<'could not find the relation'>
: SelectQueryError<`could not find the relation between ${CurrentTableOrView} and ${Field['name']}`>

/**
* Given a CurrentTableOrView, finds all join tables to this relation.
* For example, if products and categories are linked via product_categories table:
*
* @example
* Given:
* - CurrentTableView = 'products'
* - FieldName = "categories"
*
* It should return this relationship from product_categories:
* {
* foreignKeyName: "product_categories_category_id_fkey",
* columns: ["category_id"],
* isOneToOne: false,
* referencedRelation: "categories",
* referencedColumns: ["id"]
* }
*/
export type FindJoinTableRelationship<
Schema extends GenericSchema,
CurrentTableOrView extends keyof TablesAndViews<Schema> & string,
FieldName extends string
> = {
[TableName in keyof TablesAndViews<Schema>]: TablesAndViews<Schema>[TableName]['Relationships'] extends readonly (infer Rel)[]
? Rel extends { referencedRelation: CurrentTableOrView }
? TablesAndViews<Schema>[TableName]['Relationships'] extends readonly (infer OtherRel)[]
? OtherRel extends { referencedRelation: FieldName }
? OtherRel
: never
: never
: never
: never
}[keyof TablesAndViews<Schema>]

/**
* Finds a matching relationship based on the FieldNode's name and optional hint.
Expand Down
25 changes: 25 additions & 0 deletions test/db/00-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ FOREIGN KEY (parent_id)
REFERENCES public.collections(id);
COMMENT ON COLUMN public.messages.data IS 'For unstructured data and prototyping.';

-- MANY-TO-MANY RELATIONSHIP USING A JOIN TABLE

-- Create a table for products
CREATE TABLE public.products (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL,
description text,
price decimal(10, 2) NOT NULL
);

-- Create a table for categories
CREATE TABLE public.categories (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL,
description text
);

-- Create a join table for the many-to-many relationship between products and categories
CREATE TABLE public.product_categories (
product_id bigint REFERENCES public.products(id) ON DELETE CASCADE,
category_id bigint REFERENCES public.categories(id) ON DELETE CASCADE,
PRIMARY KEY (product_id, category_id)
);


-- STORED FUNCTION
CREATE FUNCTION public.get_status(name_param text)
RETURNS user_status AS $$
Expand Down
23 changes: 23 additions & 0 deletions test/db/01-dummy-data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,26 @@ VALUES
(4, 'Grandchild', 2),
(5, 'Sibling of Grandchild', 2),
(6, 'Child of Another Root', 3);

-- Insert sample products
INSERT INTO public.products (id, name, description, price)
VALUES
(1, 'Laptop', 'High-performance laptop', 999.99),
(2, 'Smartphone', 'Latest model smartphone', 699.99),
(3, 'Headphones', 'Noise-cancelling headphones', 199.99);

-- Insert sample categories
INSERT INTO public.categories (id, name, description)
VALUES
(1, 'Electronics', 'Electronic devices and gadgets'),
(2, 'Computers', 'Computer and computer accessories'),
(3, 'Audio', 'Audio equipment');

-- Insert product-category relationships
INSERT INTO public.product_categories (product_id, category_id)
VALUES
(1, 1), -- Laptop is in Electronics
(1, 2), -- Laptop is also in Computers
(2, 1), -- Smartphone is in Electronics
(3, 1), -- Headphones are in Electronics
(3, 3); -- Headphones are also in Audio
37 changes: 37 additions & 0 deletions test/relationships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ export const selectParams = {
from: 'users',
select: 'alias:missing_column.count()',
},
manyToManyWithJoinTable: {
from: 'products',
select: '*, categories(*)',
},
} as const

export const selectQueries = {
Expand Down Expand Up @@ -356,6 +360,9 @@ export const selectQueries = {
aggregateOnMissingColumnWithAlias: postgrest
.from(selectParams.aggregateOnMissingColumnWithAlias.from)
.select(selectParams.aggregateOnMissingColumnWithAlias.select),
manyToManyWithJoinTable: postgrest
.from(selectParams.manyToManyWithJoinTable.from)
.select(selectParams.manyToManyWithJoinTable.select),
} as const

test('nested query with selective fields', async () => {
Expand Down Expand Up @@ -1831,3 +1838,33 @@ test('aggregate on missing column with alias', async () => {
}
`)
})

test('many-to-many with join table', async () => {
const res = await selectQueries.manyToManyWithJoinTable.eq('id', 1).single()
expect(res).toMatchInlineSnapshot(`
Object {
"count": null,
"data": Object {
"categories": Array [
Object {
"description": "Electronic devices and gadgets",
"id": 1,
"name": "Electronics",
},
Object {
"description": "Computer and computer accessories",
"id": 2,
"name": "Computers",
},
],
"description": "High-performance laptop",
"id": 1,
"name": "Laptop",
"price": 999.99,
},
"error": null,
"status": 200,
"statusText": "OK",
}
`)
})
18 changes: 18 additions & 0 deletions test/select-query-parser/select.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,3 +742,21 @@ type Schema = Database['public']
if (error) throw error
expectType<`column 'missing_column' does not exist on 'users'.`>(data)
}

// many-to-many with join table
{
const { data } = await selectQueries.manyToManyWithJoinTable.limit(1).single()
let result: Exclude<typeof data, null>
let expected: {
id: number
name: string
description: string | null
price: number
categories: {
id: number
name: string
description: string | null
}[]
}
expectType<TypeEqual<typeof result, typeof expected>>(true)
}
43 changes: 40 additions & 3 deletions test/select-query-parser/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TypeEqual } from 'ts-expect'
import {
FindMatchingTableRelationships,
IsRelationNullable,
FindJoinTableRelationship,
} from '../../src/select-query-parser/utils'
import { Json } from '../../src/select-query-parser/types'
import { ParseQuery } from '../../src/select-query-parser/parser'
Expand Down Expand Up @@ -124,11 +125,8 @@ import { ParseQuery } from '../../src/select-query-parser/parser'
type Row = Schema['Tables'][RelationName]['Row']
type Relationships = Schema['Tables'][RelationName]['Relationships']
type ParsedQuery = ParseQuery<typeof select>
// First field of the query is username and is properly parsed
type f1 = ParsedQuery[0]
type r1 = ProcessNode<Schema, Row, RelationName, Relationships, ParsedQuery[0]>
expectType<TypeEqual<r1, { username: string }>>(true)
type f2 = ParsedQuery[1]
type r2 = ProcessNodes<Schema, Row, RelationName, Relationships, ParsedQuery>
// fail because result for messages is ({id: string} | {message: string | null })[]
expectType<
Expand Down Expand Up @@ -160,3 +158,42 @@ import { ParseQuery } from '../../src/select-query-parser/parser'
type r2 = ProcessNodes<Schema, Row, RelationName, Relationships, ParsedQuery>
expectType<r2>(expected!)
}

{
type Schema = Database['public']
type CurrentTableOrView = 'products'
type FieldName = 'categories'
type R = FindJoinTableRelationship<Schema, CurrentTableOrView, FieldName>
let expected: {
foreignKeyName: 'product_categories_category_id_fkey'
columns: ['category_id']
isOneToOne: false
referencedRelation: 'categories'
referencedColumns: ['id']
}
expectType<R>(expected!)
}

{
type Schema = Database['public']
type CurrentTableOrView = 'categories'
type FieldName = 'products'
type R = FindJoinTableRelationship<Schema, CurrentTableOrView, FieldName>
let expected: {
foreignKeyName: 'product_categories_product_id_fkey'
columns: ['product_id']
isOneToOne: false
referencedRelation: 'products'
referencedColumns: ['id']
}
expectType<R>(expected!)
}

{
type Schema = Database['public']
type CurrentTableOrView = 'categories'
type FieldName = 'missing'
type R = FindJoinTableRelationship<Schema, CurrentTableOrView, FieldName>
let expected: never
expectType<R>(expected!)
}
69 changes: 69 additions & 0 deletions test/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,75 @@ export type Database = {
}
]
}
product_categories: {
Row: {
category_id: number
product_id: number
}
Insert: {
category_id: number
product_id: number
}
Update: {
category_id?: number
product_id?: number
}
Relationships: [
{
foreignKeyName: 'product_categories_category_id_fkey'
columns: ['category_id']
isOneToOne: false
referencedRelation: 'categories'
referencedColumns: ['id']
},
{
foreignKeyName: 'product_categories_product_id_fkey'
columns: ['product_id']
isOneToOne: false
referencedRelation: 'products'
referencedColumns: ['id']
}
]
}
products: {
Row: {
description: string | null
id: number
name: string
price: number
}
Insert: {
description?: string | null
id?: number
name: string
price: number
}
Update: {
description?: string | null
id?: number
name?: string
price?: number
}
Relationships: []
}
categories: {
Row: {
description: string | null
id: number
name: string
}
Insert: {
description?: string | null
id?: number
name: string
}
Update: {
description?: string | null
id?: number
name?: string
}
Relationships: []
}
shops: {
Row: {
address: string | null
Expand Down

0 comments on commit ad1c533

Please sign in to comment.