diff --git a/src/select-query-parser/result.ts b/src/select-query-parser/result.ts index 391f175c..4c6e24b8 100644 --- a/src/select-query-parser/result.ts +++ b/src/select-query-parser/result.ts @@ -198,7 +198,7 @@ export type ProcessEmbeddedResource< Schema extends GenericSchema, Relationships extends GenericRelationship[], Field extends Ast.FieldNode, - CurrentTableOrView extends keyof TablesAndViews + CurrentTableOrView extends keyof TablesAndViews & string > = ResolveRelationship extends infer Resolved ? Resolved extends { referencedTable: Pick diff --git a/src/select-query-parser/utils.ts b/src/select-query-parser/utils.ts index 804632dd..775f71d8 100644 --- a/src/select-query-parser/utils.ts +++ b/src/select-query-parser/utils.ts @@ -205,7 +205,7 @@ export type ResolveRelationship< Schema extends GenericSchema, Relationships extends GenericRelationship[], Field extends Ast.FieldNode, - CurrentTableOrView extends keyof TablesAndViews + CurrentTableOrView extends keyof TablesAndViews & string > = ResolveReverseRelationship< Schema, Relationships, @@ -385,7 +385,7 @@ type FilterRelationships = R extends readonly (infer Rel)[] type ResolveForwardRelationship< Schema extends GenericSchema, Field extends Ast.FieldNode, - CurrentTableOrView extends keyof TablesAndViews + CurrentTableOrView extends keyof TablesAndViews & string > = FindFieldMatchingRelationships< Schema, TablesAndViews[Field['name']]['Relationships'], @@ -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[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 & string, + FieldName extends string +> = { + [TableName in keyof TablesAndViews]: TablesAndViews[TableName]['Relationships'] extends readonly (infer Rel)[] + ? Rel extends { referencedRelation: CurrentTableOrView } + ? TablesAndViews[TableName]['Relationships'] extends readonly (infer OtherRel)[] + ? OtherRel extends { referencedRelation: FieldName } + ? OtherRel + : never + : never + : never + : never +}[keyof TablesAndViews] /** * Finds a matching relationship based on the FieldNode's name and optional hint. diff --git a/test/db/00-schema.sql b/test/db/00-schema.sql index c06ed0b7..07170703 100644 --- a/test/db/00-schema.sql +++ b/test/db/00-schema.sql @@ -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 $$ diff --git a/test/db/01-dummy-data.sql b/test/db/01-dummy-data.sql index dc73ecba..8a2343eb 100644 --- a/test/db/01-dummy-data.sql +++ b/test/db/01-dummy-data.sql @@ -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 diff --git a/test/relationships.ts b/test/relationships.ts index 2660b585..6b3cf7a4 100644 --- a/test/relationships.ts +++ b/test/relationships.ts @@ -181,6 +181,10 @@ export const selectParams = { from: 'users', select: 'alias:missing_column.count()', }, + manyToManyWithJoinTable: { + from: 'products', + select: '*, categories(*)', + }, } as const export const selectQueries = { @@ -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 () => { @@ -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", + } + `) +}) diff --git a/test/select-query-parser/select.test-d.ts b/test/select-query-parser/select.test-d.ts index af57519b..1db498e0 100644 --- a/test/select-query-parser/select.test-d.ts +++ b/test/select-query-parser/select.test-d.ts @@ -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 + let expected: { + id: number + name: string + description: string | null + price: number + categories: { + id: number + name: string + description: string | null + }[] + } + expectType>(true) +} diff --git a/test/select-query-parser/types.test-d.ts b/test/select-query-parser/types.test-d.ts index 9957e0e5..049630dd 100644 --- a/test/select-query-parser/types.test-d.ts +++ b/test/select-query-parser/types.test-d.ts @@ -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' @@ -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 - // First field of the query is username and is properly parsed - type f1 = ParsedQuery[0] type r1 = ProcessNode expectType>(true) - type f2 = ParsedQuery[1] type r2 = ProcessNodes // fail because result for messages is ({id: string} | {message: string | null })[] expectType< @@ -160,3 +158,42 @@ import { ParseQuery } from '../../src/select-query-parser/parser' type r2 = ProcessNodes expectType(expected!) } + +{ + type Schema = Database['public'] + type CurrentTableOrView = 'products' + type FieldName = 'categories' + type R = FindJoinTableRelationship + let expected: { + foreignKeyName: 'product_categories_category_id_fkey' + columns: ['category_id'] + isOneToOne: false + referencedRelation: 'categories' + referencedColumns: ['id'] + } + expectType(expected!) +} + +{ + type Schema = Database['public'] + type CurrentTableOrView = 'categories' + type FieldName = 'products' + type R = FindJoinTableRelationship + let expected: { + foreignKeyName: 'product_categories_product_id_fkey' + columns: ['product_id'] + isOneToOne: false + referencedRelation: 'products' + referencedColumns: ['id'] + } + expectType(expected!) +} + +{ + type Schema = Database['public'] + type CurrentTableOrView = 'categories' + type FieldName = 'missing' + type R = FindJoinTableRelationship + let expected: never + expectType(expected!) +} diff --git a/test/types.ts b/test/types.ts index 2d601877..bd26dd1e 100644 --- a/test/types.ts +++ b/test/types.ts @@ -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