diff --git a/package-lock.json b/package-lock.json index 092e9877..5200b488 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,9 @@ "prettier": "^2.6.2", "rimraf": "^3.0.2", "semantic-release-plugin-update-version-in-files": "^1.1.0", + "ts-expect": "^1.3.0", "ts-jest": "^28.0.3", - "tsd": "^0.24.1", + "tsd": "^0.31.2", "typedoc": "^0.22.16", "typescript": "~4.7", "wait-for-localhost-cli": "^3.0.0" @@ -1117,10 +1118,14 @@ } }, "node_modules/@tsd/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-WMFNVstwWGyDuZP2LGPRZ+kPHxZLmhO+2ormstDvnXiyoBPtW1qq9XhhrkI4NVtxgs+2ZiUTl9AG7nNIRq/uCg==", - "dev": true + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-saiCxzHRhUrRxQV2JhH580aQUZiKQUXI38FcAcikcfOomAil4G4lxT0RfrrKywoAYP/rqAdYXYmNRLppcd+hQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17" + } }, "node_modules/@types/babel__core": { "version": "7.1.19", @@ -4697,10 +4702,11 @@ } }, "node_modules/prettier": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.6.2.tgz", - "integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin-prettier.js" }, @@ -5478,6 +5484,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-expect": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-expect/-/ts-expect-1.3.0.tgz", + "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-jest": { "version": "28.0.3", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.3.tgz", @@ -5534,14 +5547,16 @@ } }, "node_modules/tsd": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.24.1.tgz", - "integrity": "sha512-sD+s81/2aM4RRhimCDttd4xpBNbUFWnoMSHk/o8kC8Ek23jljeRNWjsxFJmOmYLuLTN9swRt1b6iXfUXTcTiIA==", + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.31.2.tgz", + "integrity": "sha512-VplBAQwvYrHzVihtzXiUVXu5bGcr7uH1juQZ1lmKgkuGNGT+FechUCqmx9/zk7wibcqR2xaNEwCkDyKh+VVZnQ==", "dev": true, + "license": "MIT", "dependencies": { - "@tsd/typescript": "~4.8.3", + "@tsd/typescript": "~5.4.3", "eslint-formatter-pretty": "^4.1.0", "globby": "^11.0.1", + "jest-diff": "^29.0.3", "meow": "^9.0.0", "path-exists": "^4.0.0", "read-pkg-up": "^7.0.0" @@ -5553,6 +5568,39 @@ "node": ">=14.16" } }, + "node_modules/tsd/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/tsd/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tsd/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/tsd/node_modules/camelcase-keys": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", @@ -5579,6 +5627,16 @@ "node": ">=0.10.0" } }, + "node_modules/tsd/node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/tsd/node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5600,6 +5658,32 @@ "node": ">=8" } }, + "node_modules/tsd/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/tsd/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/tsd/node_modules/meow": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", @@ -5641,6 +5725,21 @@ "node": ">=10" } }, + "node_modules/tsd/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/tsd/node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -5650,6 +5749,13 @@ "node": ">=8" } }, + "node_modules/tsd/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/tsd/node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -6938,9 +7044,9 @@ } }, "@tsd/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-WMFNVstwWGyDuZP2LGPRZ+kPHxZLmhO+2ormstDvnXiyoBPtW1qq9XhhrkI4NVtxgs+2ZiUTl9AG7nNIRq/uCg==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-saiCxzHRhUrRxQV2JhH580aQUZiKQUXI38FcAcikcfOomAil4G4lxT0RfrrKywoAYP/rqAdYXYmNRLppcd+hQQ==", "dev": true }, "@types/babel__core": { @@ -9614,9 +9720,9 @@ } }, "prettier": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.6.2.tgz", - "integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true }, "pretty-format": { @@ -10158,6 +10264,12 @@ "integrity": "sha512-GJtWyq9InR/2HRiLZgpIKv+ufIKrVrvjQWEj7PxAXNc5dwbNJkqhAUoAGgzRmULAnoOM5EIpveYd3J2VeSAIew==", "dev": true }, + "ts-expect": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-expect/-/ts-expect-1.3.0.tgz", + "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==", + "dev": true + }, "ts-jest": { "version": "28.0.3", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.3.tgz", @@ -10183,19 +10295,41 @@ } }, "tsd": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.24.1.tgz", - "integrity": "sha512-sD+s81/2aM4RRhimCDttd4xpBNbUFWnoMSHk/o8kC8Ek23jljeRNWjsxFJmOmYLuLTN9swRt1b6iXfUXTcTiIA==", + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.31.2.tgz", + "integrity": "sha512-VplBAQwvYrHzVihtzXiUVXu5bGcr7uH1juQZ1lmKgkuGNGT+FechUCqmx9/zk7wibcqR2xaNEwCkDyKh+VVZnQ==", "dev": true, "requires": { - "@tsd/typescript": "~4.8.3", + "@tsd/typescript": "~5.4.3", "eslint-formatter-pretty": "^4.1.0", "globby": "^11.0.1", + "jest-diff": "^29.0.3", "meow": "^9.0.0", "path-exists": "^4.0.0", "read-pkg-up": "^7.0.0" }, "dependencies": { + "@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.27.8" + } + }, + "@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, "camelcase-keys": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", @@ -10213,6 +10347,12 @@ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true }, + "diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true + }, "hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -10228,6 +10368,24 @@ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true }, + "jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + } + }, + "jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true + }, "meow": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", @@ -10260,12 +10418,29 @@ "validate-npm-package-license": "^3.0.1" } }, + "pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "requires": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + } + }, "quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true }, + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", diff --git a/package.json b/package.json index fc30dd3e..dda574d6 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "test": "run-s format:check test:types db:clean db:run test:run db:clean && node test/smoke.cjs && node test/smoke.mjs", "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", + "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" }, @@ -56,8 +56,9 @@ "prettier": "^2.6.2", "rimraf": "^3.0.2", "semantic-release-plugin-update-version-in-files": "^1.1.0", + "ts-expect": "^1.3.0", "ts-jest": "^28.0.3", - "tsd": "^0.24.1", + "tsd": "^0.31.2", "typedoc": "^0.22.16", "typescript": "~4.7", "wait-for-localhost-cli": "^3.0.0" diff --git a/src/PostgrestClient.ts b/src/PostgrestClient.ts index 915ddc16..8a37b09c 100644 --- a/src/PostgrestClient.ts +++ b/src/PostgrestClient.ts @@ -140,7 +140,9 @@ export default class PostgrestClient< ? Fn['Returns'][number] : never : never, - Fn['Returns'] + Fn['Returns'], + FnName, + null > { let method: 'HEAD' | 'GET' | 'POST' const url = new URL(`${this.url}/rpc/${fn}`) diff --git a/src/PostgrestQueryBuilder.ts b/src/PostgrestQueryBuilder.ts index 8f6db3f5..44e7320a 100644 --- a/src/PostgrestQueryBuilder.ts +++ b/src/PostgrestQueryBuilder.ts @@ -1,6 +1,6 @@ import PostgrestBuilder from './PostgrestBuilder' import PostgrestFilterBuilder from './PostgrestFilterBuilder' -import { GetResult } from './select-query-parser' +import { GetResult } from './select-query-parser/result' import { Fetch, GenericSchema, GenericTable, GenericView } from './types' export default class PostgrestQueryBuilder< diff --git a/src/PostgrestTransformBuilder.ts b/src/PostgrestTransformBuilder.ts index 87b7a4fa..4791702c 100644 --- a/src/PostgrestTransformBuilder.ts +++ b/src/PostgrestTransformBuilder.ts @@ -1,5 +1,5 @@ import PostgrestBuilder from './PostgrestBuilder' -import { GetResult } from './select-query-parser' +import { GetResult } from './select-query-parser/result' import { GenericSchema } from './types' export default class PostgrestTransformBuilder< diff --git a/src/select-query-parser.ts b/src/select-query-parser.ts deleted file mode 100644 index 0c5c40f1..00000000 --- a/src/select-query-parser.ts +++ /dev/null @@ -1,706 +0,0 @@ -// Credits to @bnjmnt4n (https://www.npmjs.com/package/postgrest-query) -// See https://github.com/PostgREST/postgrest/blob/2f91853cb1de18944a4556df09e52450b881cfb3/src/PostgREST/ApiRequest/QueryParams.hs#L282-L284 - -import { GenericSchema, Prettify } from './types' - -type Whitespace = ' ' | '\n' | '\t' - -type LowerAlphabet = - | 'a' - | 'b' - | 'c' - | 'd' - | 'e' - | 'f' - | 'g' - | 'h' - | 'i' - | 'j' - | 'k' - | 'l' - | 'm' - | 'n' - | 'o' - | 'p' - | 'q' - | 'r' - | 's' - | 't' - | 'u' - | 'v' - | 'w' - | 'x' - | 'y' - | 'z' - -type Alphabet = LowerAlphabet | Uppercase - -type Digit = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0' - -type Letter = Alphabet | Digit | '_' - -type Json = string | number | boolean | null | { [key: string]: Json } | Json[] - -type SingleValuePostgreSQLTypes = - | 'bool' - | 'int2' - | 'int4' - | 'int8' - | 'float4' - | 'float8' - | 'numeric' - | 'bytea' - | 'bpchar' - | 'varchar' - | 'date' - | 'text' - | 'citext' - | 'time' - | 'timetz' - | 'timestamp' - | 'timestamptz' - | 'uuid' - | 'vector' - | 'json' - | 'jsonb' - | 'void' - | 'record' - | string - -type ArrayPostgreSQLTypes = `_${SingleValuePostgreSQLTypes}` - -type PostgreSQLTypes = SingleValuePostgreSQLTypes | ArrayPostgreSQLTypes - -type TypeScriptSingleValueTypes = T extends 'bool' - ? boolean - : T extends 'int2' | 'int4' | 'int8' | 'float4' | 'float8' | 'numeric' - ? number - : T extends - | 'bytea' - | 'bpchar' - | 'varchar' - | 'date' - | 'text' - | 'citext' - | 'time' - | 'timetz' - | 'timestamp' - | 'timestamptz' - | 'uuid' - | 'vector' - ? string - : T extends 'json' | 'jsonb' - ? Json - : T extends 'void' - ? undefined - : T extends 'record' - ? Record - : unknown - -type AggregateFunctions = 'count' | 'sum' | 'avg' | 'min' | 'max' - -type StripUnderscore = T extends `_${infer U}` ? U : T - -type TypeScriptTypes = T extends ArrayPostgreSQLTypes - ? TypeScriptSingleValueTypes>>[] - : TypeScriptSingleValueTypes - -/** - * Parser errors. - */ -type ParserError = { error: true } & Message -type GenericStringError = ParserError<'Received a generic string'> -export type SelectQueryError = { error: true } & Message - -/** - * Creates a new {@link ParserError} if the given input is not already a parser error. - */ -type CreateParserErrorIfRequired = Input extends ParserError - ? Input - : ParserError - -/** - * Trims whitespace from the left of the input. - */ -type EatWhitespace = string extends Input - ? GenericStringError - : Input extends `${Whitespace}${infer Remainder}` - ? EatWhitespace - : Input - -/** - * Returns a boolean representing whether there is a foreign key with the given name. - */ -type HasFKey = Relationships extends [infer R] - ? R extends { foreignKeyName: FKeyName } - ? true - : false - : Relationships extends [infer R, ...infer Rest] - ? HasFKey extends true - ? true - : HasFKey - : false - -/** - * Returns a boolean representing whether there the foreign key has a unique constraint. - */ -type HasUniqueFKey = Relationships extends [infer R] - ? R extends { foreignKeyName: FKeyName; isOneToOne: true } - ? true - : false - : Relationships extends [infer R, ...infer Rest] - ? HasUniqueFKey extends true - ? true - : HasUniqueFKey - : false - -/** - * Returns a boolean representing whether there is a foreign key referencing - * a given relation. - */ -type HasFKeyToFRel = Relationships extends [infer R] - ? R extends { referencedRelation: FRelName } - ? true - : false - : Relationships extends [infer R, ...infer Rest] - ? HasFKeyToFRel extends true - ? true - : HasFKeyToFRel - : false - -type HasUniqueFKeyToFRel = Relationships extends [infer R] - ? R extends { referencedRelation: FRelName; isOneToOne: true } - ? true - : false - : Relationships extends [infer R, ...infer Rest] - ? HasUniqueFKeyToFRel extends true - ? true - : HasUniqueFKeyToFRel - : false - -/** - * Constructs a type definition for a single field of an object. - * - * @param Schema Database schema. - * @param Row Type of a row in the given table. - * @param Relationships Relationships between different tables in the database. - * @param Field Single field parsed by `ParseQuery`. - */ -type ConstructFieldDefinition< - Schema extends GenericSchema, - Row extends Record, - RelationName, - Relationships, - Field -> = Field extends { star: true } - ? Row - : Field extends { spread: true; original: string; children: unknown[] } - ? GetResultHelper< - Schema, - (Schema['Tables'] & Schema['Views'])[Field['original']]['Row'], - Field['original'], - (Schema['Tables'] & Schema['Views'])[Field['original']] extends { Relationships: infer R } - ? R - : unknown, - Field['children'], - unknown - > - : Field extends { children: [] } - ? {} - : Field extends { name: string; original: string; hint: string; children: unknown[] } - ? { - [_ in Field['name']]: GetResultHelper< - Schema, - (Schema['Tables'] & Schema['Views'])[Field['original']]['Row'], - Field['original'], - (Schema['Tables'] & Schema['Views'])[Field['original']] extends { Relationships: infer R } - ? R - : unknown, - Field['children'], - unknown - > extends infer Child - ? // One-to-one relationship - referencing column(s) has unique/pkey constraint. - HasUniqueFKey< - Field['hint'], - (Schema['Tables'] & Schema['Views'])[Field['original']] extends { - Relationships: infer R - } - ? R - : unknown - > extends true - ? Field extends { inner: true } - ? Child - : Child | null - : Relationships extends unknown[] - ? HasFKey extends true - ? Field extends { inner: true } - ? Child - : Child | null - : Child[] - : Child[] - : never - } - : Field extends { name: string; original: string; children: unknown[] } - ? { - [_ in Field['name']]: GetResultHelper< - Schema, - (Schema['Tables'] & Schema['Views'])[Field['original']]['Row'], - Field['original'], - (Schema['Tables'] & Schema['Views'])[Field['original']] extends { Relationships: infer R } - ? R - : unknown, - Field['children'], - unknown - > extends infer Child - ? // One-to-one relationship - referencing column(s) has unique/pkey constraint. - HasUniqueFKeyToFRel< - RelationName, - (Schema['Tables'] & Schema['Views'])[Field['original']] extends { - Relationships: infer R - } - ? R - : unknown - > extends true - ? Field extends { inner: true } - ? Child - : Child | null - : Relationships extends unknown[] - ? HasFKeyToFRel 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[] - : never - } - : Field extends { name: string; type: infer T } - ? { [K in Field['name']]: T } - : Field extends { name: string; original: string } - ? Field['original'] extends keyof Row - ? { [K in Field['name']]: Row[Field['original']] } - : Field['original'] extends 'count' - ? { count: number } - : SelectQueryError<`Referencing missing column \`${Field['original']}\``> - : Record - -/** - * Notes: all `Parse*` types assume that their input strings have their whitespace - * removed. They return tuples of ["Return Value", "Remainder of text"] or - * a `ParserError`. - */ - -/** - * Reads a consecutive sequence of 1 or more letter, where letters are `[0-9a-zA-Z_]`. - */ -type ReadLetters = string extends Input - ? GenericStringError - : ReadLettersHelper extends [`${infer Letters}`, `${infer Remainder}`] - ? Letters extends '' - ? ParserError<`Expected letter at \`${Input}\``> - : [Letters, Remainder] - : ReadLettersHelper - -type ReadLettersHelper = string extends Input - ? GenericStringError - : Input extends `${infer L}${infer Remainder}` - ? L extends Letter - ? ReadLettersHelper - : [Acc, Input] - : [Acc, ''] - -/** - * Reads a consecutive sequence of 1 or more double-quoted letters, - * where letters are `[^"]`. - */ -type ReadQuotedLetters = string extends Input - ? GenericStringError - : Input extends `"${infer Remainder}` - ? ReadQuotedLettersHelper extends [`${infer Letters}`, `${infer Remainder}`] - ? Letters extends '' - ? ParserError<`Expected string at \`${Remainder}\``> - : [Letters, Remainder] - : ReadQuotedLettersHelper - : ParserError<`Not a double-quoted string at \`${Input}\``> - -type ReadQuotedLettersHelper = string extends Input - ? GenericStringError - : Input extends `${infer L}${infer Remainder}` - ? L extends '"' - ? [Acc, Remainder] - : ReadQuotedLettersHelper - : ParserError<`Missing closing double-quote in \`"${Acc}${Input}\``> - -/** - * Parses a (possibly double-quoted) identifier. - * Identifiers are sequences of 1 or more letters. - */ -type ParseIdentifier = ReadLetters extends [ - infer Name, - `${infer Remainder}` -] - ? [Name, `${Remainder}`] - : ReadQuotedLetters extends [infer Name, `${infer Remainder}`] - ? [Name, `${Remainder}`] - : ParserError<`No (possibly double-quoted) identifier at \`${Input}\``> - -/** - * Parses a field without preceding field renaming. - * A field is one of the following: - * - a field with an embedded resource - * - `field(nodes)` - * - `field!hint(nodes)` - * - `field!inner(nodes)` - * - `field!left(nodes)` - * - `field!hint!inner(nodes)` - * - a field without an embedded resource (see {@link ParseFieldWithoutEmbeddedResource}) - */ -type ParseField = Input extends '' - ? ParserError<'Empty string'> - : ParseIdentifier extends [infer Name, `${infer Remainder}`] - ? EatWhitespace extends `!inner${infer Remainder}` - ? ParseEmbeddedResource> extends [infer Fields, `${infer Remainder}`] - ? // `field!inner(nodes)` - [{ name: Name; original: Name; children: Fields; inner: true }, EatWhitespace] - : CreateParserErrorIfRequired< - ParseEmbeddedResource>, - 'Expected embedded resource after `!inner`' - > - : EatWhitespace extends `!left${infer Remainder}` - ? ParseEmbeddedResource> extends [infer Fields, `${infer Remainder}`] - ? // `field!left(nodes)` - [{ name: Name; original: Name; children: Fields; left: true }, EatWhitespace] - : CreateParserErrorIfRequired< - ParseEmbeddedResource>, - 'Expected embedded resource after `!left`' - > - : EatWhitespace extends `!${infer Remainder}` - ? ParseIdentifier> extends [infer Hint, `${infer Remainder}`] - ? EatWhitespace extends `!inner${infer Remainder}` - ? ParseEmbeddedResource> extends [ - infer Fields, - `${infer Remainder}` - ] - ? // `field!hint!inner(nodes)` - [ - { name: Name; original: Name; hint: Hint; children: Fields; inner: true }, - EatWhitespace - ] - : CreateParserErrorIfRequired< - ParseEmbeddedResource>, - 'Expected embedded resource after `!inner`' - > - : ParseEmbeddedResource> extends [ - infer Fields, - `${infer Remainder}` - ] - ? // `field!hint(nodes)` - [{ name: Name; original: Name; hint: Hint; children: Fields }, EatWhitespace] - : CreateParserErrorIfRequired< - ParseEmbeddedResource>, - 'Expected embedded resource after `!hint`' - > - : ParserError<'Expected identifier after `!`'> - : ParseEmbeddedResource> extends [infer Fields, `${infer Remainder}`] - ? // `field(nodes)` - [{ name: Name; original: Name; children: Fields }, EatWhitespace] - : ParseEmbeddedResource> extends ParserError - ? // Return error if start of embedded resource was detected but not found. - ParseEmbeddedResource> - : // Otherwise try to match a field without embedded resource. - ParseFieldWithoutEmbeddedResource - : ParserError<`Expected identifier at \`${Input}\``> - -/** - * Parses a field excluding embedded resources, without preceding field renaming. - * This is one of the following: - * - `field` - * - `field.aggregate()` - * - `field.aggregate()::type` - * - `field::type` - * - `field::type.aggregate()` - * - `field::type.aggregate()::type` - * - `field->json...` - * - `field->json.aggregate()` - * - `field->json.aggregate()::type` - * - `field->json::type` - * - `field->json::type.aggregate()` - * - `field->json::type.aggregate()::type` - */ -type ParseFieldWithoutEmbeddedResource = - ParseFieldWithoutEmbeddedResourceAndAggregation extends [infer Field, `${infer Remainder}`] - ? ParseFieldAggregation> extends [ - `${infer AggregateFunction}`, - `${infer Remainder}` - ] - ? ParseFieldTypeCast> extends [infer Type, `${infer Remainder}`] - ? // `field.aggregate()::type` - [ - Omit & { - name: AggregateFunction - original: AggregateFunction - type: Type - }, - EatWhitespace - ] - : ParseFieldTypeCast> extends ParserError - ? ParseFieldTypeCast> - : // `field.aggregate()` - [ - Omit & { - name: AggregateFunction - original: AggregateFunction - }, - EatWhitespace - ] - : ParseFieldAggregation> extends ParserError - ? ParseFieldAggregation> - : // `field` - [Field, EatWhitespace] - : CreateParserErrorIfRequired< - ParseFieldWithoutEmbeddedResourceAndAggregation, - `Expected field at \`${Input}\`` - > - -/** - * Parses a field excluding embedded resources or aggregation, without preceding field renaming. - * This is one of the following: - * - `field` - * - `field::type` - * - `field->json...` - * - `field->json...::type` - */ -type ParseFieldWithoutEmbeddedResourceAndAggregation = - ParseFieldWithoutEmbeddedResourceAndTypeCast extends [infer Field, `${infer Remainder}`] - ? ParseFieldTypeCast> extends [infer Type, `${infer Remainder}`] - ? // `field::type` or `field->json...::type` - [Omit & { type: Type }, EatWhitespace] - : ParseFieldTypeCast> extends ParserError - ? ParseFieldTypeCast> - : // `field` or `field->json...` - [Field, EatWhitespace] - : CreateParserErrorIfRequired< - ParseFieldWithoutEmbeddedResourceAndTypeCast, - `Expected field at \`${Input}\`` - > - -/** - * Parses a field excluding embedded resources or typecasting, without preceding field renaming. - * This is one of the following: - * - `field` - * - `field->json...` - */ -type ParseFieldWithoutEmbeddedResourceAndTypeCast = - ParseIdentifier extends [infer Name, `${infer Remainder}`] - ? ParseJsonAccessor> extends [ - infer PropertyName, - infer PropertyType, - `${infer Remainder}` - ] - ? // `field->json...` - [ - { name: PropertyName; original: PropertyName; type: PropertyType }, - EatWhitespace - ] - : // `field` - [{ name: Name; original: Name }, EatWhitespace] - : ParserError<`Expected field at \`${Input}\``> - -/** - * Parses a field typecast (`::type`), returning a tuple of ["Type", "Remainder of text"] - * or the original string input indicating that no typecast was found. - */ -type ParseFieldTypeCast = EatWhitespace extends `::${infer Remainder}` - ? ParseIdentifier> extends [`${infer CastType}`, `${infer Remainder}`] - ? // Ensure that `CastType` is a valid type. - CastType extends PostgreSQLTypes - ? [TypeScriptTypes, EatWhitespace] - : ParserError<`Invalid type for \`::\` operator \`${CastType}\``> - : ParserError<`Invalid type for \`::\` operator at \`${Remainder}\``> - : Input - -/** - * Parses a field aggregation (`.max()`), returning a tuple of ["Aggregate function", "Remainder of text"] - * or the original string input indicating that no aggregation was found. - */ -type ParseFieldAggregation = - EatWhitespace extends `.${infer Remainder}` - ? ParseIdentifier> extends [ - `${infer FunctionName}`, - `${infer Remainder}` - ] - ? // Ensure that aggregation function is valid. - FunctionName extends AggregateFunctions - ? EatWhitespace extends `()${infer Remainder}` - ? [FunctionName, EatWhitespace] - : ParserError<`Expected \`()\` after \`.\` operator \`${FunctionName}\``> - : ParserError<`Invalid type for \`.\` operator \`${FunctionName}\``> - : ParserError<`Invalid type for \`.\` operator at \`${Remainder}\``> - : Input - -/** - * Parses a node. - * A node is one of the following: - * - `*` - * - a field, as defined above - * - a renamed field, `renamed_field:field` - * - a spread field, `...field` - */ -type ParseNode = Input extends '' - ? ParserError<'Empty string'> - : // `*` - Input extends `*${infer Remainder}` - ? [{ star: true }, EatWhitespace] - : // `...field` - Input extends `...${infer Remainder}` - ? ParseField> extends [infer Field, `${infer Remainder}`] - ? Field extends { children: unknown[] } - ? [Prettify<{ spread: true } & Field>, EatWhitespace] - : ParserError<'Unable to parse spread resource'> - : ParserError<'Unable to parse spread resource'> - : ParseIdentifier extends [infer Name, `${infer Remainder}`] - ? EatWhitespace extends `::${infer _Remainder}` - ? // `field::` - // Special case to detect type-casting before renaming. - ParseField - : EatWhitespace extends `:${infer Remainder}` - ? // `renamed_field:` - ParseField> extends [infer Field, `${infer Remainder}`] - ? Field extends { name: string } - ? [Prettify & { name: Name }>, EatWhitespace] - : ParserError<`Unable to parse renamed field`> - : ParserError<`Unable to parse renamed field`> - : // Otherwise, just parse it as a field without renaming. - ParseField - : ParserError<`Expected identifier at \`${Input}\``> - -/** - * Parses a JSON property accessor of the shape `->a->b->c`. The last accessor in - * the series may convert to text by using the ->> operator instead of ->. - * - * Returns a tuple of ["Last property name", "Last property type", "Remainder of text"] - * or the original string input indicating that no opening `->` was found. - */ -type ParseJsonAccessor = Input extends `->${infer Remainder}` - ? Remainder extends `>${infer Remainder}` - ? ParseIdentifier extends [infer Name, `${infer Remainder}`] - ? [Name, string, EatWhitespace] - : ParserError<'Expected property name after `->>`'> - : ParseIdentifier extends [infer Name, `${infer Remainder}`] - ? ParseJsonAccessor extends [ - infer PropertyName, - infer PropertyType, - `${infer Remainder}` - ] - ? [PropertyName, PropertyType, EatWhitespace] - : [Name, Json, EatWhitespace] - : ParserError<'Expected property name after `->`'> - : Input - -/** - * Parses an embedded resource, which is an opening `(`, followed by a sequence of - * 0 or more nodes separated by `,`, then a closing `)`. - * - * Returns a tuple of ["Parsed fields", "Remainder of text"], an error, - * or the original string input indicating that no opening `(` was found. - */ -type ParseEmbeddedResource = Input extends `(${infer Remainder}` - ? ParseNodes> extends [infer Fields, `${infer Remainder}`] - ? EatWhitespace extends `)${infer Remainder}` - ? [Fields, EatWhitespace] - : ParserError<`Expected ")"`> - : // If no nodes were detected, check for `)` for empty embedded resources `()`. - ParseNodes> extends ParserError - ? EatWhitespace extends `)${infer Remainder}` - ? [[], EatWhitespace] - : ParseNodes> - : ParserError<'Expected embedded resource fields or `)`'> - : Input - -/** - * Parses a sequence of nodes, separated by `,`. - * - * Returns a tuple of ["Parsed fields", "Remainder of text"] or an error. - */ -type ParseNodes = string extends Input - ? GenericStringError - : ParseNodesHelper - -type ParseNodesHelper = ParseNode extends [ - infer Field, - `${infer Remainder}` -] - ? EatWhitespace extends `,${infer Remainder}` - ? ParseNodesHelper, [Field, ...Fields]> - : [[Field, ...Fields], EatWhitespace] - : ParseNode - -/** - * Parses a query. - * A query is a sequence of nodes, separated by `,`, ensuring that there is - * no remaining input after all nodes have been parsed. - * - * Returns an array of parsed nodes, or an error. - */ -type ParseQuery = string extends Query - ? GenericStringError - : ParseNodes> extends [infer Fields, `${infer Remainder}`] - ? EatWhitespace extends '' - ? Fields - : ParserError<`Unexpected input: ${Remainder}`> - : ParseNodes> - -type GetResultHelper< - Schema extends GenericSchema, - Row extends Record, - RelationName, - Relationships, - Fields extends unknown[], - Acc -> = Fields extends [infer R] - ? ConstructFieldDefinition extends SelectQueryError< - infer E - > - ? SelectQueryError - : GetResultHelper< - Schema, - Row, - RelationName, - Relationships, - [], - ConstructFieldDefinition & Acc - > - : Fields extends [infer R, ...infer Rest] - ? ConstructFieldDefinition extends SelectQueryError< - infer E - > - ? SelectQueryError - : GetResultHelper< - Schema, - Row, - RelationName, - Relationships, - Rest, - ConstructFieldDefinition & Acc - > - : Prettify - -/** - * Constructs a type definition for an object based on a given PostgREST query. - * - * @param Schema Database schema. - * @param Row Type of a row in the given table. - * @param Relationships Relationships between different tables in the database. - * @param Query Select query string literal to parse. - */ -export type GetResult< - Schema extends GenericSchema, - Row extends Record, - RelationName, - Relationships, - Query extends string -> = ParseQuery extends unknown[] - ? GetResultHelper, unknown> - : ParseQuery diff --git a/src/select-query-parser/parser.ts b/src/select-query-parser/parser.ts new file mode 100644 index 00000000..77a57e30 --- /dev/null +++ b/src/select-query-parser/parser.ts @@ -0,0 +1,452 @@ +// Credits to @bnjmnt4n (https://www.npmjs.com/package/postgrest-query) +// See https://github.com/PostgREST/postgrest/blob/2f91853cb1de18944a4556df09e52450b881cfb3/src/PostgREST/ApiRequest/QueryParams.hs#L282-L284 + +import { SimplifyDeep } from '../types' + +/** + * Parses a query. + * A query is a sequence of nodes, separated by `,`, ensuring that there is + * no remaining input after all nodes have been parsed. + * + * Returns an array of parsed nodes, or an error. + */ +export type ParseQuery = string extends Query + ? GenericStringError + : ParseNodes> extends [infer Nodes extends Ast.Node[], `${infer Remainder}`] + ? EatWhitespace extends '' + ? SimplifyDeep + : ParserError<`Unexpected input: ${Remainder}`> + : ParseNodes> + +/** + * Notes: all `Parse*` types assume that their input strings have their whitespace + * removed. They return tuples of ["Return Value", "Remainder of text"] or + * a `ParserError`. + */ + +/** + * Parses a sequence of nodes, separated by `,`. + * + * Returns a tuple of ["Parsed fields", "Remainder of text"] or an error. + */ +type ParseNodes = string extends Input + ? GenericStringError + : ParseNodesHelper + +type ParseNodesHelper = ParseNode extends [ + infer Node extends Ast.Node, + `${infer Remainder}` +] + ? EatWhitespace extends `,${infer Remainder}` + ? ParseNodesHelper, [...Nodes, Node]> + : [[...Nodes, Node], EatWhitespace] + : ParseNode + +/** + * Parses a node. + * A node is one of the following: + * - `*` + * - a field, as defined above + * - a renamed field, `renamed_field:field` + * - a spread field, `...field` + */ +type ParseNode = Input extends '' + ? ParserError<'Empty string'> + : // `*` + Input extends `*${infer Remainder}` + ? [Ast.StarNode, EatWhitespace] + : // `...field` + Input extends `...${infer Remainder}` + ? ParseField> extends [ + infer TargetField extends Ast.FieldNode, + `${infer Remainder}` + ] + ? [{ type: 'spread'; target: TargetField }, EatWhitespace] + : ParserError<`Unable to parse spread resource at \`${Input}\``> + : ParseIdentifier extends [infer NameOrAlias, `${infer Remainder}`] + ? EatWhitespace extends `::${infer _}` + ? // It's a type cast and not an alias, so treat it as part of the field. + ParseField + : EatWhitespace extends `:${infer Remainder}` + ? // `alias:` + ParseField> extends [ + infer Field extends Ast.FieldNode, + `${infer Remainder}` + ] + ? [Omit & { alias: NameOrAlias }, EatWhitespace] + : ParserError<`Unable to parse renamed field at \`${Input}\``> + : // Otherwise, just parse it as a field without alias. + ParseField + : ParserError<`Expected identifier at \`${Input}\``> + +/** + * Parses a field without preceding alias. + * A field is one of the following: + * - a top-level `count` field: https://docs.postgrest.org/en/v12/references/api/aggregate_functions.html#the-case-of-count + * - a field with an embedded resource + * - `field(nodes)` + * - `field!hint(nodes)` + * - `field!inner(nodes)` + * - `field!left(nodes)` + * - `field!hint!inner(nodes)` + * - `field!hint!left(nodes)` + * - a field without an embedded resource (see {@link ParseNonEmbeddedResourceField}) + */ +type ParseField = Input extends '' + ? ParserError<'Empty string'> + : ParseIdentifier extends [infer Name, `${infer Remainder}`] + ? Name extends 'count' + ? ParseCountField + : Remainder extends `!inner${infer Remainder}` + ? ParseEmbeddedResource> extends [ + infer Children extends Ast.Node[], + `${infer Remainder}` + ] + ? // `field!inner(nodes)` + [{ type: 'field'; name: Name; innerJoin: true; children: Children }, Remainder] + : CreateParserErrorIfRequired< + ParseEmbeddedResource>, + `Expected embedded resource after "!inner" at \`${Remainder}\`` + > + : EatWhitespace extends `!left${infer Remainder}` + ? ParseEmbeddedResource> extends [ + infer Children extends Ast.Node[], + `${infer Remainder}` + ] + ? // `field!left(nodes)` + // !left is a noise word - treat it the same way as a non-`!inner`. + [{ type: 'field'; name: Name; children: Children }, EatWhitespace] + : CreateParserErrorIfRequired< + ParseEmbeddedResource>, + `Expected embedded resource after "!left" at \`${EatWhitespace}\`` + > + : EatWhitespace extends `!${infer Remainder}` + ? ParseIdentifier> extends [infer Hint, `${infer Remainder}`] + ? EatWhitespace extends `!inner${infer Remainder}` + ? ParseEmbeddedResource> extends [ + infer Children extends Ast.Node[], + `${infer Remainder}` + ] + ? // `field!hint!inner(nodes)` + [ + { type: 'field'; name: Name; hint: Hint; innerJoin: true; children: Children }, + EatWhitespace + ] + : ParseEmbeddedResource> + : ParseEmbeddedResource> extends [ + infer Children extends Ast.Node[], + `${infer Remainder}` + ] + ? // `field!hint(nodes)` + [{ type: 'field'; name: Name; hint: Hint; children: Children }, EatWhitespace] + : ParseEmbeddedResource> + : ParserError<`Expected identifier after "!" at \`${EatWhitespace}\``> + : EatWhitespace extends `(${infer _}` + ? ParseEmbeddedResource> extends [ + infer Children extends Ast.Node[], + `${infer Remainder}` + ] + ? // `field(nodes)` + [{ type: 'field'; name: Name; children: Children }, EatWhitespace] + : // Return error if start of embedded resource was detected but not found. + ParseEmbeddedResource> + : // Otherwise it's a non-embedded resource field. + ParseNonEmbeddedResourceField + : ParserError<`Expected identifier at \`${Input}\``> + +type ParseCountField = ParseIdentifier extends [ + 'count', + `${infer Remainder}` +] + ? ( + EatWhitespace extends `()${infer Remainder_}` + ? EatWhitespace + : EatWhitespace + ) extends `${infer Remainder}` + ? Remainder extends `::${infer _}` + ? ParseFieldTypeCast extends [infer CastType, `${infer Remainder}`] + ? [ + { type: 'field'; name: 'count'; aggregateFunction: 'count'; castType: CastType }, + Remainder + ] + : ParseFieldTypeCast + : [{ type: 'field'; name: 'count'; aggregateFunction: 'count' }, Remainder] + : never + : ParserError<`Expected "count" at \`${Input}\``> + +/** + * Parses an embedded resource, which is an opening `(`, followed by a sequence of + * 0 or more nodes separated by `,`, then a closing `)`. + * + * Returns a tuple of ["Parsed fields", "Remainder of text"], an error, + * or the original string input indicating that no opening `(` was found. + */ +type ParseEmbeddedResource = Input extends `(${infer Remainder}` + ? EatWhitespace extends `)${infer Remainder}` + ? [[], EatWhitespace] + : ParseNodes> extends [ + infer Nodes extends Ast.Node[], + `${infer Remainder}` + ] + ? EatWhitespace extends `)${infer Remainder}` + ? [Nodes, EatWhitespace] + : ParserError<`Expected ")" at \`${EatWhitespace}\``> + : ParseNodes> + : ParserError<`Expected "(" at \`${Input}\``> + +/** + * Parses a field excluding embedded resources, without preceding field renaming. + * This is one of the following: + * - `field` + * - `field.aggregate()` + * - `field.aggregate()::type` + * - `field::type` + * - `field::type.aggregate()` + * - `field::type.aggregate()::type` + * - `field->json...` + * - `field->json.aggregate()` + * - `field->json.aggregate()::type` + * - `field->json::type` + * - `field->json::type.aggregate()` + * - `field->json::type.aggregate()::type` + */ +type ParseNonEmbeddedResourceField = ParseIdentifier extends [ + infer Name, + `${infer Remainder}` +] + ? // Parse optional JSON path. + ( + Remainder extends `->${infer _}` + ? ParseJsonAccessor extends [ + infer PropertyName, + infer PropertyType, + `${infer Remainder}` + ] + ? [{ type: 'field'; name: Name; alias: PropertyName; castType: PropertyType }, Remainder] + : ParseJsonAccessor + : [{ type: 'field'; name: Name }, Remainder] + ) extends infer Parsed + ? Parsed extends [infer Field, `${infer Remainder}`] + ? // Parse optional typecast or aggregate function input typecast. + ( + Remainder extends `::${infer _}` + ? ParseFieldTypeCast extends [infer CastType, `${infer Remainder}`] + ? [Omit & { castType: CastType }, Remainder] + : ParseFieldTypeCast + : [Field, Remainder] + ) extends infer Parsed + ? Parsed extends [infer Field, `${infer Remainder}`] + ? // Parse optional aggregate function. + Remainder extends `.${infer _}` + ? ParseFieldAggregation extends [ + infer AggregateFunction, + `${infer Remainder}` + ] + ? // Parse optional aggregate function output typecast. + Remainder extends `::${infer _}` + ? ParseFieldTypeCast extends [infer CastType, `${infer Remainder}`] + ? [ + Omit & { + aggregateFunction: AggregateFunction + castType: CastType + }, + Remainder + ] + : ParseFieldTypeCast + : [Field & { aggregateFunction: AggregateFunction }, Remainder] + : ParseFieldAggregation + : [Field, Remainder] + : Parsed + : never + : Parsed + : never + : ParserError<`Expected identifier at \`${Input}\``> + +/** + * Parses a JSON property accessor of the shape `->a->b->c`. The last accessor in + * the series may convert to text by using the ->> operator instead of ->. + * + * Returns a tuple of ["Last property name", "Last property type", "Remainder of text"] + */ +type ParseJsonAccessor = Input extends `->${infer Remainder}` + ? Remainder extends `>${infer Remainder}` + ? ParseIdentifier extends [infer Name, `${infer Remainder}`] + ? [Name, 'text', EatWhitespace] + : ParserError<'Expected property name after `->>`'> + : ParseIdentifier extends [infer Name, `${infer Remainder}`] + ? ParseJsonAccessor extends [ + infer PropertyName, + infer PropertyType, + `${infer Remainder}` + ] + ? [PropertyName, PropertyType, EatWhitespace] + : [Name, 'json', EatWhitespace] + : ParserError<'Expected property name after `->`'> + : ParserError<'Expected ->'> + +/** + * Parses a field typecast (`::type`), returning a tuple of ["Type", "Remainder of text"]. + */ +type ParseFieldTypeCast = EatWhitespace extends `::${infer Remainder}` + ? ParseIdentifier> extends [`${infer CastType}`, `${infer Remainder}`] + ? [CastType, EatWhitespace] + : ParserError<`Invalid type for \`::\` operator at \`${Remainder}\``> + : ParserError<'Expected ::'> + +/** + * Parses a field aggregation (`.max()`), returning a tuple of ["Aggregate function", "Remainder of text"] + */ +type ParseFieldAggregation = + EatWhitespace extends `.${infer Remainder}` + ? ParseIdentifier> extends [ + `${infer FunctionName}`, + `${infer Remainder}` + ] + ? // Ensure that aggregation function is valid. + FunctionName extends Token.AggregateFunction + ? EatWhitespace extends `()${infer Remainder}` + ? [FunctionName, EatWhitespace] + : ParserError<`Expected \`()\` after \`.\` operator \`${FunctionName}\``> + : ParserError<`Invalid type for \`.\` operator \`${FunctionName}\``> + : ParserError<`Invalid type for \`.\` operator at \`${Remainder}\``> + : ParserError<'Expected .'> + +/** + * Parses a (possibly double-quoted) identifier. + * Identifiers are sequences of 1 or more letters. + */ +type ParseIdentifier = ParseLetters extends [ + infer Name, + `${infer Remainder}` +] + ? [Name, EatWhitespace] + : ParseQuotedLetters extends [infer Name, `${infer Remainder}`] + ? [Name, EatWhitespace] + : ParserError<`No (possibly double-quoted) identifier at \`${Input}\``> + +/** + * Parse a consecutive sequence of 1 or more letter, where letters are `[0-9a-zA-Z_]`. + */ +type ParseLetters = string extends Input + ? GenericStringError + : ParseLettersHelper extends [`${infer Letters}`, `${infer Remainder}`] + ? Letters extends '' + ? ParserError<`Expected letter at \`${Input}\``> + : [Letters, Remainder] + : ParseLettersHelper + +type ParseLettersHelper = string extends Input + ? GenericStringError + : Input extends `${infer L}${infer Remainder}` + ? L extends Token.Letter + ? ParseLettersHelper + : [Acc, Input] + : [Acc, ''] + +/** + * Parse a consecutive sequence of 1 or more double-quoted letters, + * where letters are `[^"]`. + */ +type ParseQuotedLetters = string extends Input + ? GenericStringError + : Input extends `"${infer Remainder}` + ? ParseQuotedLettersHelper extends [`${infer Letters}`, `${infer Remainder}`] + ? Letters extends '' + ? ParserError<`Expected string at \`${Remainder}\``> + : [Letters, Remainder] + : ParseQuotedLettersHelper + : ParserError<`Not a double-quoted string at \`${Input}\``> + +type ParseQuotedLettersHelper = string extends Input + ? GenericStringError + : Input extends `${infer L}${infer Remainder}` + ? L extends '"' + ? [Acc, Remainder] + : ParseQuotedLettersHelper + : ParserError<`Missing closing double-quote in \`"${Acc}${Input}\``> + +/** + * Trims whitespace from the left of the input. + */ +type EatWhitespace = string extends Input + ? GenericStringError + : Input extends `${Token.Whitespace}${infer Remainder}` + ? EatWhitespace + : Input + +/** + * Creates a new {@link ParserError} if the given input is not already a parser error. + */ +type CreateParserErrorIfRequired = Input extends ParserError + ? Input + : ParserError + +/** + * Parser errors. + */ +export type ParserError = { error: true } & Message +type GenericStringError = ParserError<'Received a generic string'> + +export namespace Ast { + export type Node = FieldNode | StarNode | SpreadNode + + export type FieldNode = { + type: 'field' + name: string + alias?: string + hint?: string + innerJoin?: true + castType?: string + aggregateFunction?: Token.AggregateFunction + children?: Node[] + } + + export type StarNode = { + type: 'star' + } + + export type SpreadNode = { + type: 'spread' + target: FieldNode & { children: Node[] } + } +} + +namespace Token { + export type Whitespace = ' ' | '\n' | '\t' + + type LowerAlphabet = + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' + + type Alphabet = LowerAlphabet | Uppercase + + type Digit = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0' + + export type Letter = Alphabet | Digit | '_' + + export type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max' +} diff --git a/src/select-query-parser/result.ts b/src/select-query-parser/result.ts new file mode 100644 index 00000000..1742df74 --- /dev/null +++ b/src/select-query-parser/result.ts @@ -0,0 +1,304 @@ +import { GenericTable } from '../types' +import { ContainsNull, GenericRelationship, PostgreSQLTypes } from './types' +import { Ast, ParseQuery } from './parser' +import { + AggregateFunctions, + ExtractFirstProperty, + GenericSchema, + IsNonEmptyArray, + Prettify, + TablesAndViews, + TypeScriptTypes, +} from './types' +import { + CheckDuplicateEmbededReference, + GetFieldNodeResultName, + IsRelationNullable, + ResolveRelationship, + SelectQueryError, +} from './utils' + +/** + * Main entry point for constructing the result type of a PostgREST query. + * + * @param Schema - Database schema. + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current table or view. + * @param Relationships - Relationships of the current table. + * @param Query - The select query string literal to parse. + */ +export type GetResult< + Schema extends GenericSchema, + Row extends Record, + RelationName, + Relationships, + Query extends string +> = Relationships extends null // For .rpc calls the passed relationships will be null in that case, the result will always be the function return type + ? ParseQuery extends infer ParsedQuery extends Ast.Node[] + ? RPCCallNodes + : Row + : ParseQuery extends infer ParsedQuery + ? ParsedQuery extends Ast.Node[] + ? RelationName extends string + ? Relationships extends GenericRelationship[] + ? ProcessNodes + : SelectQueryError<'Invalid Relationships cannot infer result type'> + : SelectQueryError<'Invalid RelationName cannot infer result type'> + : ParsedQuery + : never + +/** + * Processes a single Node from a select chained after a rpc call + * + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current rpc function + * @param NodeType - The Node to process. + */ +export type ProcessRPCNode< + Row extends Record, + RelationName extends string, + NodeType extends Ast.Node +> = NodeType extends Ast.StarNode // If the selection is * + ? Row + : NodeType extends Ast.FieldNode + ? ProcessSimpleField + : SelectQueryError<'Unsupported node type.'> +/** + * Process select call that can be chained after an rpc call + */ +export type RPCCallNodes< + Nodes extends Ast.Node[], + RelationName extends string, + Row extends Record, + Acc extends Record = {} // Acc is now an object +> = Nodes extends [infer FirstNode extends Ast.Node, ...infer RestNodes extends Ast.Node[]] + ? ProcessRPCNode extends infer FieldResult + ? FieldResult extends Record + ? RPCCallNodes + : FieldResult extends SelectQueryError + ? SelectQueryError + : SelectQueryError<'Could not retrieve a valid record or error value'> + : SelectQueryError<'Processing node failed.'> + : Prettify + +/** + * Recursively processes an array of Nodes and accumulates the resulting TypeScript type. + * + * @param Schema - Database schema. + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current table or view. + * @param Relationships - Relationships of the current table. + * @param Nodes - An array of AST nodes to process. + * @param Acc - Accumulator for the constructed type. + */ +export type ProcessNodes< + Schema extends GenericSchema, + Row extends Record, + RelationName extends string, + Relationships extends GenericRelationship[], + Nodes extends Ast.Node[], + Acc extends Record = {} // Acc is now an object +> = CheckDuplicateEmbededReference extends false + ? Nodes extends [infer FirstNode extends Ast.Node, ...infer RestNodes extends Ast.Node[]] + ? ProcessNode extends infer FieldResult + ? FieldResult extends Record + ? ProcessNodes + : FieldResult extends SelectQueryError + ? SelectQueryError + : SelectQueryError<'Could not retrieve a valid record or error value'> + : SelectQueryError<'Processing node failed.'> + : Prettify + : Prettify> + +/** + * Processes a single Node and returns the resulting TypeScript type. + * + * @param Schema - Database schema. + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current table or view. + * @param Relationships - Relationships of the current table. + * @param NodeType - The Node to process. + */ +export type ProcessNode< + Schema extends GenericSchema, + Row extends Record, + RelationName extends string, + Relationships extends GenericRelationship[], + NodeType extends Ast.Node +> = NodeType extends Ast.StarNode // If the selection is * + ? Row + : NodeType extends Ast.SpreadNode // If the selection is a ...spread + ? ProcessSpreadNode + : NodeType extends Ast.FieldNode + ? ProcessFieldNode + : SelectQueryError<'Unsupported node type.'> + +/** + * Processes a FieldNode and returns the resulting TypeScript type. + * + * @param Schema - Database schema. + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current table or view. + * @param Relationships - Relationships of the current table. + * @param Field - The FieldNode to process. + */ +type ProcessFieldNode< + Schema extends GenericSchema, + Row extends Record, + RelationName extends string, + Relationships extends GenericRelationship[], + Field extends Ast.FieldNode +> = Field['children'] extends [] + ? {} + : IsNonEmptyArray extends true // Has embedded resource? + ? ProcessEmbeddedResource + : ProcessSimpleField + +/** + * Processes a simple field (without embedded resources). + * + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current table or view. + * @param Field - The FieldNode to process. + */ +type ProcessSimpleField< + Row extends Record, + RelationName extends string, + Field extends Ast.FieldNode +> = Field['name'] extends keyof Row | 'count' + ? Field['aggregateFunction'] extends AggregateFunctions + ? { + // An aggregate function will always override the column name id.sum() will become sum + // except if it has been aliased + [K in GetFieldNodeResultName]: Field['castType'] extends PostgreSQLTypes + ? TypeScriptTypes + : number + } + : { + // Aliases override the property name in the result + [K in GetFieldNodeResultName]: Field['castType'] extends PostgreSQLTypes // We apply the detected casted as the result type + ? TypeScriptTypes + : Row[Field['name']] + } + : SelectQueryError<`column '${Field['name']}' does not exist on '${RelationName}'.`> + +/** + * Processes an embedded resource (relation). + * + * @param Schema - Database schema. + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current table or view. + * @param Relationships - Relationships of the current table. + * @param Field - The FieldNode to process. + */ +export type ProcessEmbeddedResource< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + Field extends Ast.FieldNode, + CurrentTableOrView extends keyof TablesAndViews & string +> = ResolveRelationship extends infer Resolved + ? Resolved extends { + referencedTable: Pick + relation: GenericRelationship & { match: 'refrel' | 'col' | 'fkname' } + direction: string + } + ? ProcessEmbeddedResourceResult + : // Otherwise the Resolved is a SelectQueryError return it + { [K in GetFieldNodeResultName]: Resolved } + : { + [K in GetFieldNodeResultName]: SelectQueryError<'Failed to resolve relationship.'> & + string + } + +/** + * Helper type to process the result of an embedded resource. + */ +type ProcessEmbeddedResourceResult< + Schema extends GenericSchema, + Resolved extends { + referencedTable: Pick + relation: GenericRelationship & { match: 'refrel' | 'col' | 'fkname' } + direction: string + }, + Field extends Ast.FieldNode, + CurrentTableOrView extends keyof TablesAndViews +> = ProcessNodes< + Schema, + Resolved['referencedTable']['Row'], + Field['name'], + Resolved['referencedTable']['Relationships'], + Field['children'] extends undefined + ? [] + : Exclude extends Ast.Node[] + ? Exclude + : [] +> extends infer ProcessedChildren + ? { + [K in GetFieldNodeResultName]: Resolved['direction'] extends 'forward' + ? Field extends { innerJoin: true } + ? Resolved['relation']['isOneToOne'] extends true + ? ProcessedChildren + : ProcessedChildren[] + : Resolved['relation']['isOneToOne'] extends true + ? ProcessedChildren | null + : ProcessedChildren[] + : // If the relation is a self-reference it'll always be considered as reverse relationship + Resolved['relation']['referencedRelation'] extends CurrentTableOrView + ? // It can either be a reverse reference via a column inclusion (eg: parent_id(*)) + // in such case the result will be a single object + Resolved['relation']['match'] extends 'col' + ? IsRelationNullable< + TablesAndViews[CurrentTableOrView], + Resolved['relation'] + > extends true + ? ProcessedChildren | null + : ProcessedChildren + : // Or it can be a reference via the reference relation (eg: collections(*)) + // in such case, the result will be an array of all the values (all collection with parent_id being the current id) + ProcessedChildren[] + : // Otherwise if it's a non self-reference reverse relationship it's a single object + IsRelationNullable< + TablesAndViews[CurrentTableOrView], + Resolved['relation'] + > extends true + ? ProcessedChildren | null + : ProcessedChildren + } + : { + [K in GetFieldNodeResultName]: SelectQueryError<'Failed to process embedded resource nodes.'> & + string + } + +/** + * Processes a SpreadNode by processing its target node. + * + * @param Schema - Database schema. + * @param Row - The type of a row in the current table. + * @param RelationName - The name of the current table or view. + * @param Relationships - Relationships of the current table. + * @param Spread - The SpreadNode to process. + */ +type ProcessSpreadNode< + Schema extends GenericSchema, + Row extends Record, + RelationName extends string, + Relationships extends GenericRelationship[], + Spread extends Ast.SpreadNode +> = ProcessNode extends infer Result + ? Result extends SelectQueryError + ? SelectQueryError + : ExtractFirstProperty extends unknown[] + ? { + [K in Spread['target']['name']]: SelectQueryError<`"${RelationName}" and "${Spread['target']['name']}" do not form a many-to-one or one-to-one relationship spread not possible`> + } + : ProcessSpreadNodeResult + : never + +/** + * Helper type to process the result of a spread node. + */ +type ProcessSpreadNodeResult = ExtractFirstProperty extends infer SpreadedObject + ? ContainsNull extends true + ? Exclude<{ [K in keyof SpreadedObject]: SpreadedObject[K] | null }, null> + : Exclude<{ [K in keyof SpreadedObject]: SpreadedObject[K] }, null> + : SelectQueryError<'An error occurred spreading the object'> diff --git a/src/select-query-parser/types.ts b/src/select-query-parser/types.ts new file mode 100644 index 00000000..597ee0a9 --- /dev/null +++ b/src/select-query-parser/types.ts @@ -0,0 +1,115 @@ +import type { GenericRelationship, GenericSchema, GenericTable, Prettify } from '../types' + +export type { GenericRelationship, GenericSchema, GenericTable, Prettify } + +export type AggregateWithoutColumnFunctions = 'count' + +export type AggregateWithColumnFunctions = + | 'sum' + | 'avg' + | 'min' + | 'max' + | AggregateWithoutColumnFunctions + +export type AggregateFunctions = AggregateWithColumnFunctions + +export type Json = + | string + | number + | boolean + | null + | { + [key: string]: Json | undefined + } + | Json[] + +type PostgresSQLNumberTypes = 'int2' | 'int4' | 'int8' | 'float4' | 'float8' | 'numeric' + +type PostgresSQLStringTypes = + | 'bytea' + | 'bpchar' + | 'varchar' + | 'date' + | 'text' + | 'citext' + | 'time' + | 'timetz' + | 'timestamp' + | 'timestamptz' + | 'uuid' + | 'vector' + +type SingleValuePostgreSQLTypes = + | PostgresSQLNumberTypes + | PostgresSQLStringTypes + | 'bool' + | 'json' + | 'jsonb' + | 'void' + | 'record' + | string + +type ArrayPostgreSQLTypes = `_${SingleValuePostgreSQLTypes}` + +type TypeScriptSingleValueTypes = T extends 'bool' + ? boolean + : T extends PostgresSQLNumberTypes + ? number + : T extends PostgresSQLStringTypes + ? string + : T extends 'json' | 'jsonb' + ? Json + : T extends 'void' + ? undefined + : T extends 'record' + ? Record + : unknown + +type StripUnderscore = T extends `_${infer U}` ? U : T + +// Represents all possible PostgreSQL types, including array types, allow for custom types with 'string' in union +export type PostgreSQLTypes = SingleValuePostgreSQLTypes | ArrayPostgreSQLTypes + +// Helper type to convert PostgreSQL types to their TypeScript equivalents +export type TypeScriptTypes = T extends ArrayPostgreSQLTypes + ? TypeScriptSingleValueTypes>>[] + : TypeScriptSingleValueTypes + +// Utility types for working with unions +export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I +) => void + ? I + : never + +export type LastOf = UnionToIntersection T : never> extends () => infer R + ? R + : never + +export type Push = [...T, V] + +// Converts a union type to a tuple type +export type UnionToTuple, N = [T] extends [never] ? true : false> = N extends true + ? [] + : Push>, L> + +export type UnionToArray = UnionToTuple + +// Extracts the type of the first property in an object type +export type ExtractFirstProperty = T extends { [K in keyof T]: infer U } ? U : never + +// Type predicates +export type ContainsNull = null extends T ? true : false + +export type IsNonEmptyArray = Exclude extends readonly [unknown, ...unknown[]] + ? true + : false + +// Types for working with database schemas +export type TablesAndViews = Schema['Tables'] & + Exclude + +export type GetTableRelationships< + Schema extends GenericSchema, + Tname extends string +> = TablesAndViews[Tname] extends { Relationships: infer R } ? R : false diff --git a/src/select-query-parser/utils.ts b/src/select-query-parser/utils.ts new file mode 100644 index 00000000..b869229d --- /dev/null +++ b/src/select-query-parser/utils.ts @@ -0,0 +1,495 @@ +import { Ast } from './parser' +import { + AggregateFunctions, + ContainsNull, + GenericRelationship, + GenericSchema, + GenericTable, + IsNonEmptyArray, + TablesAndViews, + UnionToArray, +} from './types' + +export type SelectQueryError = { error: true } & Message + +export type GetFieldNodeResultName = Field['alias'] extends string + ? Field['alias'] + : Field['aggregateFunction'] extends AggregateFunctions + ? Field['aggregateFunction'] + : Field['name'] + +type FilterRelationNodes = UnionToArray< + { + [K in keyof Nodes]: Nodes[K] extends Ast.SpreadNode + ? Nodes[K]['target'] + : Nodes[K] extends Ast.FieldNode + ? IsNonEmptyArray extends true + ? Nodes[K] + : never + : never + }[number] +> + +type ResolveRelationships< + Schema extends GenericSchema, + RelationName extends string, + Relationships extends GenericRelationship[], + Nodes extends Ast.FieldNode[] +> = UnionToArray<{ + [K in keyof Nodes]: ResolveRelationship< + Schema, + Relationships, + Nodes[K], + RelationName + > extends infer Relation + ? Relation extends { + relation: { + referencedRelation: any + foreignKeyName: any + match: any + } + from: any + } + ? { + referencedTable: Relation['relation']['referencedRelation'] + fkName: Relation['relation']['foreignKeyName'] + from: Relation['from'] + match: Relation['relation']['match'] + fieldName: GetFieldNodeResultName + } + : never + : never +}>[0] + +/** + * Checks if a relation is implicitly referenced twice, requiring disambiguation + */ +type IsDoubleReference = T extends { + referencedTable: infer RT + fieldName: infer FN + match: infer M extends 'col' | 'refrel' +} + ? U extends { referencedTable: RT; fieldName: FN; match: M } + ? true + : false + : false + +/** + * Compares one element with all other elements in the array to find duplicates + */ +type CheckDuplicates = Arr extends [infer Head, ...infer Tail] + ? IsDoubleReference extends true + ? Head | CheckDuplicates // Return the Head if duplicate + : CheckDuplicates // Otherwise, continue checking + : never + +/** + * Iterates over the elements of the array to find duplicates + */ +type FindDuplicates = Arr extends [infer Head, ...infer Tail] + ? CheckDuplicates | FindDuplicates + : never + +export type CheckDuplicateEmbededReference< + Schema extends GenericSchema, + RelationName extends string, + Relationships extends GenericRelationship[], + Nodes extends Ast.Node[] +> = FilterRelationNodes extends infer RelationsNodes extends Ast.FieldNode[] + ? ResolveRelationships< + Schema, + RelationName, + Relationships, + RelationsNodes + > extends infer ResolvedRels + ? ResolvedRels extends unknown[] + ? FindDuplicates extends infer Duplicates + ? Duplicates extends never + ? false + : Duplicates extends { fieldName: infer FieldName extends string } + ? { + [K in FieldName]: SelectQueryError<`table "${RelationName}" specified more than once use hinting for desambiguation`> + } + : false + : false + : false + : false + : false + +/** + * Returns a boolean representing whether there is a foreign key referencing + * a given relation. + */ +type HasFKeyToFRel = Relationships extends [infer R] + ? R extends { referencedRelation: FRelName } + ? true + : false + : Relationships extends [infer R, ...infer Rest] + ? HasFKeyToFRel extends true + ? true + : HasFKeyToFRel + : false +/** + * Checks if there is more than one relation to a given foreign relation name in the Relationships. + */ +type HasMultipleFKeysToFRel = Relationships extends [ + infer R, + ...infer Rest +] + ? R extends { referencedRelation: FRelName } + ? HasFKeyToFRel extends true + ? true + : HasMultipleFKeysToFRel + : HasMultipleFKeysToFRel + : false + +type CheckRelationshipError< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + CurrentTableOrView extends keyof TablesAndViews & string, + FoundRelation +> = FoundRelation extends SelectQueryError + ? FoundRelation + : // If the relation is a reverse relation with no hint (matching by name) + FoundRelation extends { + relation: { + referencedRelation: infer RelatedRelationName extends string + name: string + } + direction: 'reverse' + } + ? // We check if there is possible confusion with other relations with this table + HasMultipleFKeysToFRel extends true + ? // If there is, postgrest will fail at runtime, and require desambiguation via hinting + SelectQueryError<`Could not embed because more than one relationship was found for '${RelatedRelationName}' and '${CurrentTableOrView}' you need to hint the column with ${RelatedRelationName}! ?`> + : FoundRelation + : // Same check for forward relationships, but we must gather the relationships from the found relation + FoundRelation extends { + relation: { + referencedRelation: infer RelatedRelationName extends string + name: string + } + direction: 'forward' + from: infer From extends keyof TablesAndViews & string + } + ? HasMultipleFKeysToFRel< + RelatedRelationName, + TablesAndViews[From]['Relationships'] + > extends true + ? SelectQueryError<`Could not embed because more than one relationship was found for '${From}' and '${RelatedRelationName}' you need to hint the column with ${From}! ?`> + : FoundRelation + : FoundRelation + +/** + * Resolves relationships for embedded resources and retrieves the referenced Table + */ +export type ResolveRelationship< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + Field extends Ast.FieldNode, + CurrentTableOrView extends keyof TablesAndViews & string +> = ResolveReverseRelationship< + Schema, + Relationships, + Field, + CurrentTableOrView +> extends infer ReverseRelationship + ? ReverseRelationship extends false + ? CheckRelationshipError< + Schema, + Relationships, + CurrentTableOrView, + ResolveForwardRelationship + > + : CheckRelationshipError + : never + +/** + * Resolves reverse relationships (from children to parent) + */ +type ResolveReverseRelationship< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + Field extends Ast.FieldNode, + CurrentTableOrView extends keyof TablesAndViews & string +> = FindFieldMatchingRelationships extends infer FoundRelation + ? FoundRelation extends never + ? false + : FoundRelation extends { referencedRelation: infer RelatedRelationName extends string } + ? RelatedRelationName extends keyof TablesAndViews + ? // If the relation was found via hinting we just return it without any more checks + FoundRelation extends { hint: string } + ? { + referencedTable: TablesAndViews[RelatedRelationName] + relation: FoundRelation + direction: 'reverse' + from: CurrentTableOrView + } + : // If the relation was found via implicit relation naming, we must ensure there is no conflicting matches + HasMultipleFKeysToFRel extends true + ? SelectQueryError<`Could not embed because more than one relationship was found for '${RelatedRelationName}' and '${CurrentTableOrView}' you need to hint the column with ${RelatedRelationName}! ?`> + : { + referencedTable: TablesAndViews[RelatedRelationName] + relation: FoundRelation + direction: 'reverse' + from: CurrentTableOrView + } + : SelectQueryError<`Relation '${RelatedRelationName}' not found in schema.`> + : false + : false + +export type FindMatchingTableRelationships< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + value extends string +> = Relationships extends [infer R, ...infer Rest extends GenericRelationship[]] + ? R extends { referencedRelation: infer ReferencedRelation } + ? ReferencedRelation extends keyof Schema['Tables'] + ? R extends { foreignKeyName: value } + ? R & { match: 'fkname' } + : R extends { referencedRelation: value } + ? R & { match: 'refrel' } + : R extends { columns: [value] } + ? R & { match: 'col' } + : FindMatchingTableRelationships + : FindMatchingTableRelationships + : false + : false + +export type FindMatchingViewRelationships< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + value extends string +> = Relationships extends [infer R, ...infer Rest extends GenericRelationship[]] + ? R extends { referencedRelation: infer ReferencedRelation } + ? ReferencedRelation extends keyof Schema['Views'] + ? R extends { foreignKeyName: value } + ? R & { match: 'fkname' } + : R extends { referencedRelation: value } + ? R & { match: 'refrel' } + : R extends { columns: [value] } + ? R & { match: 'col' } + : FindMatchingViewRelationships + : FindMatchingViewRelationships + : false + : false + +export type FindMatchingHintTableRelationships< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + hint extends string, + name extends string +> = Relationships extends [infer R, ...infer Rest extends GenericRelationship[]] + ? R extends { referencedRelation: infer ReferencedRelation } + ? ReferencedRelation extends name + ? R extends { foreignKeyName: hint } + ? R & { match: 'fkname' } + : R extends { referencedRelation: hint } + ? R & { match: 'refrel' } + : R extends { columns: [hint] } + ? R & { match: 'col' } + : FindMatchingHintTableRelationships + : FindMatchingHintTableRelationships + : false + : false + +export type FindMatchingHintViewRelationships< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + hint extends string, + name extends string +> = Relationships extends [infer R, ...infer Rest extends GenericRelationship[]] + ? R extends { referencedRelation: infer ReferencedRelation } + ? ReferencedRelation extends name + ? R extends { foreignKeyName: hint } + ? R & { match: 'fkname' } + : R extends { referencedRelation: hint } + ? R & { match: 'refrel' } + : R extends { columns: [hint] } + ? R & { match: 'col' } + : FindMatchingHintViewRelationships + : FindMatchingHintViewRelationships + : false + : false + +type IsColumnsNullable< + Table extends Pick, + Columns extends (keyof Table['Row'])[] +> = Columns extends [infer Column, ...infer Rest] + ? Column extends keyof Table['Row'] + ? ContainsNull extends true + ? true + : IsColumnsNullable + : false + : false + +// Check weither or not a 1-1 relation is nullable by checking against the type of the columns +export type IsRelationNullable< + Table extends GenericTable, + Relation extends GenericRelationship +> = IsColumnsNullable + +type TableForwardRelationships< + Schema extends GenericSchema, + TName +> = TName extends keyof TablesAndViews + ? UnionToArray< + RecursivelyFindRelationships> + > extends infer R extends (GenericRelationship & { from: keyof TablesAndViews })[] + ? R + : [] + : [] + +type RecursivelyFindRelationships< + Schema extends GenericSchema, + TName, + Keys extends keyof TablesAndViews +> = Keys extends infer K + ? K extends keyof TablesAndViews + ? FilterRelationships[K]['Relationships'], TName, K> extends never + ? RecursivelyFindRelationships> + : + | FilterRelationships[K]['Relationships'], TName, K> + | RecursivelyFindRelationships> + : false + : false + +type FilterRelationships = R extends readonly (infer Rel)[] + ? Rel extends { referencedRelation: TName } + ? Rel & { from: From } + : never + : never + +// Find a relationship from the parent to the childrens +type ResolveForwardRelationship< + Schema extends GenericSchema, + Field extends Ast.FieldNode, + CurrentTableOrView extends keyof TablesAndViews & string +> = FindFieldMatchingRelationships< + Schema, + TablesAndViews[Field['name']]['Relationships'], + Ast.FieldNode & { name: CurrentTableOrView; hint: Field['hint'] } +> extends infer FoundByName extends GenericRelationship + ? { + referencedTable: TablesAndViews[Field['name']] + relation: FoundByName + direction: 'forward' + from: Field['name'] + type: 'found-by-name' + } + : // The Field['name'] can sometimes be a reference to the related foreign key + // In that case, we can't use the Field['name'] to get back the relations, instead, we will find all relations pointing + // to our current table or view, and search if we can find a match in it + FindFieldMatchingRelationships< + Schema, + TableForwardRelationships, + Field + > extends infer FoundByMatch extends GenericRelationship & { + from: keyof TablesAndViews + } + ? { + referencedTable: TablesAndViews[FoundByMatch['from']] + relation: FoundByMatch + direction: 'forward' + from: CurrentTableOrView + 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 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. + */ +export type FindFieldMatchingRelationships< + Schema extends GenericSchema, + Relationships extends GenericRelationship[], + Field extends Ast.FieldNode +> = Field extends { hint: infer Hint extends string } + ? FindMatchingHintTableRelationships< + Schema, + Relationships, + Hint, + Field['name'] + > extends infer TableRelationViaHint extends GenericRelationship + ? TableRelationViaHint & { + branch: 'found-in-table-via-hint' + hint: Field['hint'] + } + : FindMatchingHintViewRelationships< + Schema, + Relationships, + Hint, + Field['name'] + > extends infer TableViewViaHint extends GenericRelationship + ? TableViewViaHint & { + branch: 'found-in-view-via-hint' + hint: Field['hint'] + } + : SelectQueryError<'Failed to find matching relation via hint'> + : FindMatchingTableRelationships< + Schema, + Relationships, + Field['name'] + > extends infer TableRelationViaName extends GenericRelationship + ? TableRelationViaName & { + branch: 'found-in-table-via-name' + name: Field['name'] + } + : FindMatchingViewRelationships< + Schema, + Relationships, + Field['name'] + > extends infer ViewRelationViaName extends GenericRelationship + ? ViewRelationViaName & { + branch: 'found-in-view-via-name' + name: Field['name'] + } + : SelectQueryError<'Failed to find matching relation via name'> diff --git a/src/types.ts b/src/types.ts index 76e4f6cb..fd1378ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,20 +29,31 @@ export type PostgrestSingleResponse = PostgrestResponseSuccess | Postgrest export type PostgrestMaybeSingleResponse = PostgrestSingleResponse export type PostgrestResponse = PostgrestSingleResponse +export type GenericRelationship = { + foreignKeyName: string + columns: string[] + isOneToOne?: boolean + referencedRelation: string + referencedColumns: string[] +} + export type GenericTable = { Row: Record Insert: Record Update: Record + Relationships: GenericRelationship[] } export type GenericUpdatableView = { Row: Record Insert: Record Update: Record + Relationships: GenericRelationship[] } export type GenericNonUpdatableView = { Row: Record + Relationships: GenericRelationship[] } export type GenericView = GenericUpdatableView | GenericNonUpdatableView @@ -60,3 +71,21 @@ export type GenericSchema = { // https://twitter.com/mattpocockuk/status/1622730173446557697 export type Prettify = { [K in keyof T]: T[K] } & {} +// https://github.com/sindresorhus/type-fest +export type SimplifyDeep = ConditionalSimplifyDeep< + Type, + ExcludeType | NonRecursiveType | Set | Map, + object +> +type ConditionalSimplifyDeep< + Type, + ExcludeType = never, + IncludeType = unknown +> = Type extends ExcludeType + ? Type + : Type extends IncludeType + ? { [TypeKey in keyof Type]: ConditionalSimplifyDeep } + : Type +type NonRecursiveType = BuiltIns | Function | (new (...arguments_: any[]) => unknown) +type BuiltIns = Primitive | void | Date | RegExp +type Primitive = null | undefined | string | number | boolean | symbol | bigint diff --git a/test/db/00-schema.sql b/test/db/00-schema.sql index 21d49a7e..07170703 100644 --- a/test/db/00-schema.sql +++ b/test/db/00-schema.sql @@ -59,6 +59,45 @@ CREATE TABLE public.messages ( ALTER TABLE public.messages REPLICA IDENTITY FULL; -- Send "previous data" to supabase COMMENT ON COLUMN public.messages.data IS 'For unstructured data and prototyping.'; +-- SELF REFERENCING TABLE +CREATE TABLE public.collections ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + description text, + parent_id bigint +); +ALTER TABLE public.messages REPLICA IDENTITY FULL; -- Send "previous data" to supabase +-- SELF REFERENCE via parent_id +ALTER TABLE public.collections +ADD CONSTRAINT collections_parent_id_fkey +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 8026b16d..8a2343eb 100644 --- a/test/db/01-dummy-data.sql +++ b/test/db/01-dummy-data.sql @@ -49,3 +49,35 @@ INSERT INTO best_friends(id, first_user, second_user, third_wheel) VALUES (1, 'supabot', 'kiwicopple', 'awailas'), (2, 'supabot', 'awailas', NULL); + +INSERT INTO public.collections (id, description, parent_id) +VALUES + (1, 'Root Collection', NULL), + (2, 'Child of Root', 1), + (3, 'Another Child of Root', 1), + (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/index.test-d.ts b/test/index.test-d.ts index ffe2112a..e64dabcc 100644 --- a/test/index.test-d.ts +++ b/test/index.test-d.ts @@ -1,6 +1,5 @@ import { expectError, expectType } from 'tsd' -import { PostgrestClient, PostgrestSingleResponse } from '../src/index' -import { SelectQueryError } from '../src/select-query-parser' +import { PostgrestClient } from '../src/index' import { Prettify } from '../src/types' import { Database, Json } from './types' @@ -80,22 +79,13 @@ const postgrest = new PostgrestClient(REST_URL) ) } -// embedded resource with no fields -{ - const { data, error } = await postgrest.from('messages').select('message, users()').single() - if (error) { - throw new Error(error.message) - } - expectType<{ message: string | null }>(data) -} - // `count` in embedded resource { const { data, error } = await postgrest.from('messages').select('message, users(count)').single() if (error) { throw new Error(error.message) } - expectType<{ message: string | null; users: { count: number } | null }>(data) + expectType<{ message: string | null; users: { count: number } }>(data) } // json accessor in select query @@ -109,7 +99,7 @@ const postgrest = new PostgrestClient(REST_URL) } // getting this w/o the cast, not sure why: // Parameter type Json is declared too wide for argument type Json - expectType(data.bar as Json) + expectType(data.bar) expectType(data.baz) } @@ -122,62 +112,6 @@ const postgrest = new PostgrestClient(REST_URL) expectType<'ONLINE' | 'OFFLINE'>(data) } -// many-to-one relationship -{ - const { data: message, error } = await postgrest.from('messages').select('user:users(*)').single() - if (error) { - throw new Error(error.message) - } - expectType(message.user) -} - -// !inner relationship -{ - const { data: message, error } = await postgrest - .from('messages') - .select('channels!inner(*, channel_details!inner(*))') - .single() - if (error) { - throw new Error(error.message) - } - type ExpectedType = Prettify< - Database['public']['Tables']['channels']['Row'] & { - channel_details: Database['public']['Tables']['channel_details']['Row'] - } - > - - expectType(message.channels) -} - -// one-to-many relationship -{ - const { data: user, error } = await postgrest.from('users').select('messages(*)').single() - if (error) { - throw new Error(error.message) - } - expectType(user.messages) -} - -// referencing missing column -{ - const res = await postgrest.from('users').select('username, dat') - expectType[]>>(res) -} - -// one-to-one relationship -{ - const { data: channels, error } = await postgrest - .from('channels') - .select('channel_details(*)') - .single() - if (error) { - throw new Error(error.message) - } - expectType( - channels.channel_details - ) -} - // PostgrestBuilder's children retains class when using inherited methods { const x = postgrest.from('channels').select() @@ -186,83 +120,3 @@ const postgrest = new PostgrestClient(REST_URL) expectType(y) expectType(z) } - -// !left oneToOne -{ - const { data: oneToOne, error } = await postgrest - .from('channel_details') - .select('channels!left(*)') - .single() - - if (error) { - throw new Error(error.message) - } - - // TODO: this should never be nullable - expectType(oneToOne.channels) -} - -// !left oneToMany -{ - const { data: oneToMany, error } = await postgrest - .from('users') - .select('messages!left(*)') - .single() - - if (error) { - throw new Error(error.message) - } - - expectType>(oneToMany.messages) -} - -// !left zeroToOne -{ - const { data: zeroToOne, error } = await postgrest - .from('user_profiles') - .select('users!left(*)') - .single() - - if (error) { - throw new Error(error.message) - } - - expectType(zeroToOne.users) -} - -// join over a 1-1 relation with both nullables and non-nullables fields -{ - const { data: bestFriends, error } = 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(*)' - ) - .single() - - if (error) { - throw new Error(error.message) - } - - // TODO: Those two fields shouldn't be nullables - expectType(bestFriends.first_user) - expectType(bestFriends.second_user) - // The third wheel should be nullable - expectType(bestFriends.third_wheel) -} -// join over a 1-M relation with both nullables and non-nullables fields -{ - const { data: users, error } = 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(*)` - ) - .single() - - if (error) { - throw new Error(error.message) - } - // TODO: type properly the result for this kind of queries - expectType>(users.first_friend_of) -} diff --git a/test/index.test.ts b/test/index.test.ts index 6a18f85d..a7f2ffc6 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3,3 +3,4 @@ import './relationships' import './filters' import './resource-embedding' import './transforms' +import './rpc' diff --git a/test/relationships.ts b/test/relationships.ts index 4d8b612d..6b3cf7a4 100644 --- a/test/relationships.ts +++ b/test/relationships.ts @@ -2,7 +2,7 @@ import { PostgrestClient } from '../src/index' import { Database } from './types' const REST_URL = 'http://localhost:3000' -const postgrest = new PostgrestClient(REST_URL) +export const postgrest = new PostgrestClient(REST_URL) const userColumn: 'catchphrase' | 'username' = 'username' @@ -165,6 +165,26 @@ export const selectParams = { select: 'message, users.count(), casted_message:message::int4, casted_count:users.count()::text', }, + innerJoinOnManyRelation: { + from: 'channels', + select: 'id, messages!channel_id!inner(id, username)', + }, + selfReferenceRelation: { + from: 'collections', + select: '*, collections(*)', + }, + selfReferenceRelationViaColumn: { + from: 'collections', + select: '*, parent_id(*)', + }, + aggregateOnMissingColumnWithAlias: { + from: 'users', + select: 'alias:missing_column.count()', + }, + manyToManyWithJoinTable: { + from: 'products', + select: '*, categories(*)', + }, } as const export const selectQueries = { @@ -328,6 +348,21 @@ export const selectQueries = { typecastingAndAggregate: postgrest .from(selectParams.typecastingAndAggregate.from) .select(selectParams.typecastingAndAggregate.select), + innerJoinOnManyRelation: postgrest + .from(selectParams.innerJoinOnManyRelation.from) + .select(selectParams.innerJoinOnManyRelation.select), + selfReferenceRelation: postgrest + .from(selectParams.selfReferenceRelation.from) + .select(selectParams.selfReferenceRelation.select), + selfReferenceRelationViaColumn: postgrest + .from(selectParams.selfReferenceRelationViaColumn.from) + .select(selectParams.selfReferenceRelationViaColumn.select), + 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 () => { @@ -1714,3 +1749,122 @@ test('typecasting and aggregate', async () => { } `) }) + +test('inner join on many relation', async () => { + const res = await selectQueries.innerJoinOnManyRelation.limit(1).single() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Object { + "id": 1, + "messages": Array [ + Object { + "id": 1, + "username": "supabot", + }, + ], + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('self reference relation', async () => { + const res = await selectQueries.selfReferenceRelation.limit(1).single() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Object { + "collections": Array [ + Object { + "description": "Child of Root", + "id": 2, + "parent_id": 1, + }, + Object { + "description": "Another Child of Root", + "id": 3, + "parent_id": 1, + }, + ], + "description": "Root Collection", + "id": 1, + "parent_id": null, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('self reference relation via column', async () => { + const res = await selectQueries.selfReferenceRelationViaColumn.eq('id', 2).limit(1).single() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Object { + "description": "Child of Root", + "id": 2, + "parent_id": Object { + "description": "Root Collection", + "id": 1, + "parent_id": null, + }, + }, + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('aggregate on missing column with alias', async () => { + const res = await selectQueries.aggregateOnMissingColumnWithAlias.eq('id', 1).limit(1).single() + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": null, + "error": Object { + "code": "42703", + "details": null, + "hint": null, + "message": "column users.missing_column does not exist", + }, + "status": 400, + "statusText": "Bad Request", + } + `) +}) + +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/rpc.ts b/test/rpc.ts new file mode 100644 index 00000000..8e1e263f --- /dev/null +++ b/test/rpc.ts @@ -0,0 +1,158 @@ +import { PostgrestClient } from '../src/index' +import { Database } from './types' + +const REST_URL = 'http://localhost:3000' +export const postgrest = new PostgrestClient(REST_URL) + +export const RPC_NAME = 'get_username_and_status' + +export const selectParams = { + noParams: undefined, + starSelect: '*', + fieldSelect: 'username', + fieldsSelect: 'username, status', + fieldAliasing: 'name:username', + fieldCasting: 'status::text', + fieldAggregate: 'username.count(), status', +} as const + +export const selectQueries = { + noParams: postgrest.rpc(RPC_NAME, { name_param: 'supabot' }).select(selectParams.noParams), + starSelect: postgrest.rpc(RPC_NAME, { name_param: 'supabot' }).select(selectParams.starSelect), + fieldSelect: postgrest.rpc(RPC_NAME, { name_param: 'supabot' }).select(selectParams.fieldSelect), + fieldsSelect: postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldsSelect), + fieldAliasing: postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldAliasing), + fieldCasting: postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldCasting), + fieldAggregate: postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldAggregate), +} as const + +test('RPC call with no params', async () => { + const res = await selectQueries.noParams + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "status": "ONLINE", + "username": "supabot", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('RPC call with star select', async () => { + const res = await selectQueries.starSelect + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "status": "ONLINE", + "username": "supabot", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('RPC call with single field select', async () => { + const res = await selectQueries.fieldSelect + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "username": "supabot", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('RPC call with multiple fields select', async () => { + const res = await selectQueries.fieldsSelect + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "status": "ONLINE", + "username": "supabot", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('RPC call with field aliasing', async () => { + const res = await selectQueries.fieldAliasing + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "name": "supabot", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('RPC call with field casting', async () => { + const res = await selectQueries.fieldCasting + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "status": "ONLINE", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) + +test('RPC call with field aggregate', async () => { + const res = await selectQueries.fieldAggregate + expect(res).toMatchInlineSnapshot(` + Object { + "count": null, + "data": Array [ + Object { + "count": 1, + "status": "ONLINE", + }, + ], + "error": null, + "status": 200, + "statusText": "OK", + } + `) +}) diff --git a/test/select-query-parser/parser.test-d.ts b/test/select-query-parser/parser.test-d.ts new file mode 100644 index 00000000..8c7291ac --- /dev/null +++ b/test/select-query-parser/parser.test-d.ts @@ -0,0 +1,616 @@ +import { expectType } from 'tsd' +import type { ParseQuery, ParserError } from '../../src/select-query-parser/parser' +import { selectParams } from '../relationships' + +// This test file is here to ensure some of our perser behave as expected +// it's useful to track down if the result type of a query is invalid becase of bad parsing +// or because of invalid matching against the final database type +// Basic select with multiple fields +{ + expectType>([ + { type: 'field', name: 'username' }, + { type: 'field', name: 'email' }, + { type: 'field', name: 'created_at' }, + ]) +} + +// Select with star +{ + expectType>([{ type: 'star' }]) +} + +{ + expectType>([{ type: 'field', name: 'username' }, { type: 'star' }]) +} + +// Select with renamed field +{ + expectType>([ + { type: 'field', name: 'username', alias: 'display_name' }, + ]) +} + +// Select with embedded resource +{ + expectType>([ + { + type: 'field', + name: 'posts', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'title' }, + { type: 'field', name: 'content' }, + ], + }, + ]) +} + +// Select with nested embedded resources +{ + expectType>([ + { + type: 'field', + name: 'posts', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'title' }, + { + type: 'field', + name: 'author', + children: [ + { type: 'field', name: 'name' }, + { type: 'field', name: 'email' }, + ], + }, + ], + }, + ]) +} + +// Select with aggregation +{ + expectType>([ + { + type: 'field', + name: 'posts', + children: [{ type: 'field', name: 'count', aggregateFunction: 'count' }], + }, + ]) +} + +// Select with JSON accessor +{ + expectTypepreferences->theme'>>([ + { type: 'field', name: 'data', alias: 'theme', castType: 'json' }, + ]) +} + +// Select with JSON accessor and text conversion +{ + expectTypepreferences->>theme'>>([ + { type: 'field', name: 'data', alias: 'theme', castType: 'text' }, + ]) +} + +// Select with spread +{ + expectType>([ + { + type: 'field', + name: 'username', + }, + { + type: 'spread', + target: { + type: 'field', + name: 'posts', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'title' }, + ], + }, + }, + ]) +} +{ + expectType>([ + { + type: 'spread', + target: { + type: 'field', + name: 'users', + children: [ + { type: 'field', name: 'first_name' }, + { type: 'field', name: 'last_name' }, + ], + }, + }, + ]) +} + +// Select with inner join +{ + expectType>([ + { + type: 'field', + name: 'posts', + innerJoin: true, + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'title' }, + ], + }, + ]) +} + +// Select with left join +{ + expectType>([ + { + type: 'field', + name: 'posts', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'title' }, + ], + }, + ]) +} + +// Select with rename and hint +{ + expectType>([ + { + type: 'field', + name: 'users', + alias: 'author', + hint: 'user_id', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'name' }, + ], + }, + ]) +} + +// Complex select combining multiple features +{ + expectType< + ParseQuery<'id, username, posts!left(id, title, comments(id, content)), profile->settings->>theme::text'> + >([ + { type: 'field', name: 'id' }, + { type: 'field', name: 'username' }, + { + type: 'field', + name: 'posts', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'title' }, + { + type: 'field', + name: 'comments', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'content' }, + ], + }, + ], + }, + { type: 'field', name: 'profile', alias: 'theme', castType: 'text' }, + ]) +} +{ + type t = ParseQuery<'id, posts(count), comments(sum:id.sum())'> + type aggFunction = t[1]['children'][0]['aggregateFunction'] + type isCount = aggFunction extends 'count' ? true : false + expectType(true) + expectType>([ + { type: 'field', name: 'id' }, + { + type: 'field', + name: 'posts', + children: [{ type: 'field', name: 'count', aggregateFunction: 'count' }], + }, + { + type: 'field', + name: 'comments', + children: [{ type: 'field', alias: 'sum', name: 'id', aggregateFunction: 'sum' }], + }, + ]) +} +{ + expectType>([ + { type: 'field', name: 'id' }, + { + type: 'field', + name: 'posts', + children: [{ type: 'field', name: 'count', aggregateFunction: 'count' }], + }, + { + type: 'field', + name: 'comments', + children: [{ type: 'field', name: 'id', aggregateFunction: 'sum' }], + }, + ]) +} +{ + expectType>([ + { type: 'field', name: 'id' }, + { + type: 'field', + name: 'posts', + children: [{ type: 'field', name: 'id', aggregateFunction: 'count' }], + }, + ]) +} +{ + expectType>([ + { type: 'field', name: 'id' }, + { + type: 'field', + name: 'posts', + children: [{ type: 'field', name: 'id', alias: 'aliased', aggregateFunction: 'count' }], + }, + ]) +} +{ + expectType>([ + { type: 'field', name: 'id' }, + { + type: 'field', + name: 'posts', + children: [{ type: 'field', name: 'count', aggregateFunction: 'count' }], + }, + ]) +} +{ + type t = ParseQuery<'id, posts(renamed_count:count())'> + type aggFunction = t[1]['children'][0]['aggregateFunction'] + type isCount = aggFunction extends 'count' ? true : false + expectType(true) + expectType>([ + { type: 'field', name: 'id' }, + { + type: 'field', + name: 'posts', + children: [ + { type: 'field', alias: 'renamed_count', name: 'count', aggregateFunction: 'count' }, + ], + }, + ]) +} +{ + type t = ParseQuery<'username, messages(channels(channel_count:count()))'> + type aggFunction = t[1]['children'][0]['children'][0]['aggregateFunction'] + type isCount = aggFunction extends 'count' ? true : false + expectType(true) + expectType>([ + { type: 'field', name: 'username' }, + { + type: 'field', + name: 'messages', + children: [ + { + type: 'field', + name: 'channels', + children: [ + { + type: 'field', + alias: 'channel_count', + name: 'count', + aggregateFunction: 'count', + }, + ], + }, + ], + }, + ]) +} +// Other than count aggregation function without column name +// should be a field like any other +{ + expectType>([ + { type: 'field', name: 'posts', children: [{ type: 'field', name: 'sum' }] }, + ]) +} +// Should be considered embeded with parenthesis +{ + expectType>([ + { + type: 'field', + name: 'posts', + children: [{ type: 'field', name: 'sum', children: [] as [] }], + }, + ]) +} + +// Select with nested JSON accessors +{ + expectTypepreferences->theme->color'>>([ + { type: 'field', name: 'data', alias: 'color', castType: 'json' }, + ]) +} + +// Select with multiple spreads +{ + expectType>([ + { type: 'field', name: 'id' }, + { + type: 'spread', + target: { + type: 'field', + name: 'profile', + children: [ + { type: 'field', name: 'name' }, + { type: 'field', name: 'email' }, + ], + }, + }, + { + type: 'spread', + target: { + type: 'field', + name: 'settings', + children: [ + { type: 'field', name: 'theme' }, + { type: 'field', name: 'language' }, + ], + }, + }, + ]) +} + +// Select with multiple hints +{ + expectType>([ + { + type: 'field', + alias: 'author', + name: 'users', + hint: 'user_id', + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'name' }, + ], + }, + { + type: 'field', + name: 'posts', + hint: 'post_id', + children: [ + { type: 'field', name: 'title' }, + { type: 'field', name: 'content' }, + ], + }, + ]) +} + +// Select with combination of inner and left joins +{ + expectType>([ + { + type: 'field', + name: 'users', + innerJoin: true, + children: [ + { type: 'field', name: 'id' }, + { type: 'field', name: 'name' }, + ], + }, + { + type: 'field', + name: 'posts', + children: [ + { type: 'field', name: 'title' }, + { type: 'field', name: 'content' }, + ], + }, + ]) +} + +// Select with quoted identifiers +{ + expectType>([ + { type: 'field', name: 'complex name', alias: 'user name' }, + { + type: 'field', + alias: 'post-title', + name: 'posts', + children: [{ type: 'field', name: 'content-body' }], + }, + ]) +} + +// Select with nested aggregations and type castings +{ + expectType>([ + { + type: 'field', + name: 'users', + children: [ + { type: 'field', name: 'id' }, + { + type: 'field', + name: 'posts', + children: [ + { type: 'field', name: 'count', castType: 'int', aggregateFunction: 'count' }, + { + type: 'field', + alias: 'avg_likes', + name: 'likes', + castType: 'float', + aggregateFunction: 'avg', + }, + ], + }, + ], + }, + ]) +} + +// Invalid type cast +{ + expectType>([ + { + type: 'field', + name: 'id', + castType: 'invalid_type', + }, + ]) +} + +// Select with multiple type castings +{ + expectTypeage::int'>>([ + { type: 'field', name: 'id', castType: 'text' }, + { type: 'field', name: 'created_at', castType: 'date' }, + { type: 'field', name: 'data', alias: 'age', castType: 'int' }, + ]) +} + +// Select with type casting +{ + expectType>([ + { type: 'field', name: 'id', castType: 'text' }, + { type: 'field', name: 'created_at', castType: 'date' }, + { type: 'field', name: 'other', castType: 'int' }, + ]) +} + +// select JSON accessor +{ + expect>([ + { type: 'field', name: 'data', alias: 'bar', castType: 'json' }, + { type: 'field', name: 'data', alias: 'baz', castType: 'text' }, + ]) +} + +// embed resource with no fields +{ + expect>([ + { type: 'field', name: 'message' }, + { type: 'field', name: 'users', children: [] }, + ]) +} + +// many-to-one join +{ + expectType>([ + { type: 'field', name: 'message' }, + { + type: 'field', + name: 'channels', + children: [{ type: 'field', name: 'slug' }], + }, + ]) +} + +// many-to-one join with inner +{ + expectType>([ + { type: 'field', name: 'message' }, + { + type: 'field', + name: 'channels', + innerJoin: true, + children: [{ type: 'field', name: 'slug' }], + }, + ]) +} + +// many-to-one join with not null +{ + expectType>([ + { type: 'field', name: 'message' }, + { + type: 'field', + name: 'channels', + children: [{ type: 'field', name: 'slug' }], + }, + ]) +} + +// many-to-one join with inner and not null +{ + expectType>([ + { type: 'field', name: 'message' }, + { + type: 'field', + name: 'channels', + innerJoin: true, + children: [{ type: 'field', name: 'slug' }], + }, + ]) +} + +// ParserError test cases +// Empty string +{ + expectType>(0 as any as ParserError<'Empty string'>) +} + +// Unexpected input at the end +{ + expectType>( + 0 as any as ParserError<'Unexpected input: unexpected_input'> + ) +} + +// Missing closing parenthesis +{ + expectType>(0 as any as ParserError<'Expected ")" at ``'>) +} + +// Incomplete JSON accessor +{ + expectType'>>(0 as any as ParserError<'Expected property name after `->`'>) +} + +// Invalid hint (missing identifier after !) +{ + expectType>( + 0 as any as ParserError<'Expected identifier after "!" at `(id, name)`'> + ) +} + +// Invalid spread (missing field after ...) +{ + expectType>( + 0 as any as ParserError<'Unable to parse spread resource at `...::`'> + ) +} + +// Invalid rename (missing field after :) +{ + expectType>( + 0 as any as ParserError<'Unable to parse renamed field at `new_name:`'> + ) +} + +// Incomplete quoted identifier +{ + expectType>( + 0 as any as ParserError<'Expected identifier at `"incomplete`'> + ) +} + +// Invalid combination of inner and left join +{ + expectType>( + 0 as any as ParserError<'Expected "(" at `!left(id, name)`'> + ) +} + +// Missing opening parenthesis after aggregate function +{ + expectType>( + 0 as any as ParserError<'Expected `()` after `.` operator `avg`'> + ) +} + +// Invalid nested JSON accessor +{ + expectTypepreferences->->theme'>>( + 0 as any as ParserError<'Unexpected input: ->->theme'> + ) +} diff --git a/test/select-query-parser/result.test-d.ts b/test/select-query-parser/result.test-d.ts new file mode 100644 index 00000000..38dcccae --- /dev/null +++ b/test/select-query-parser/result.test-d.ts @@ -0,0 +1,72 @@ +import { Database, Json } from '../types' +import { selectParams } from '../relationships' +import { GetResult } from '../../src/select-query-parser/result' +import { expectType } from 'tsd' +import { TypeEqual } from 'ts-expect' + +type SelectQueryFromTableResult< + TableName extends keyof Database['public']['Tables'], + Q extends string +> = GetResult< + Database['public'], + Database['public']['Tables'][TableName]['Row'], + TableName, + Database['public']['Tables'][TableName]['Relationships'], + Q +> + +// This test file is here to help develop, debug and maintain the GetResult +// type inference, it can be useful to identify weither a type error come from the +// result inference or functions chaining down the line in the client (.filter(), ...) + +// nested query with selective fields +{ + const { from, select } = selectParams.nestedQueryWithSelectiveFields + let result: SelectQueryFromTableResult + let expected: { + username: string + messages: { + id: number + message: string | null + }[] + } + expectType>(true) +} + +// select JSON accessor +{ + const { from, select } = selectParams.selectJsonAccessor + let result: SelectQueryFromTableResult + let expected: { + bar: Json + baz: string + } + expectType>(true) +} + +// embed resource with no fields +{ + const { from, select } = selectParams.selectEmbedRessourceWithNoFields + let result: SelectQueryFromTableResult + let expected: { + message: string | null + } + expectType>(true) +} + +// Self referencing relation +{ + const { from, select } = selectParams.selfReferenceRelation + let result: SelectQueryFromTableResult + let expected: { + id: number + description: string | null + parent_id: number | null + collections: { + id: number + description: string | null + parent_id: number | null + }[] + } + expectType>(true) +} diff --git a/test/select-query-parser/rpc.test-d.ts b/test/select-query-parser/rpc.test-d.ts new file mode 100644 index 00000000..858434bf --- /dev/null +++ b/test/select-query-parser/rpc.test-d.ts @@ -0,0 +1,74 @@ +import { postgrest, selectParams, RPC_NAME } from '../rpc' +import { Database } from '../types' +import { expectType } from 'tsd' +import { TypeEqual } from 'ts-expect' + +// RPC call with no params +{ + const { data } = await postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.noParams) + let result: Exclude + let expected: Database['public']['Functions'][typeof RPC_NAME]['Returns'] + expectType>(true) +} + +// RPC call with star select +{ + const { data } = await postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.starSelect) + let result: Exclude + let expected: Database['public']['Functions'][typeof RPC_NAME]['Returns'] + expectType>(true) +} + +// RPC call with single field select +{ + const { data } = await postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldSelect) + let result: Exclude + let expected: { username: string }[] + expectType>(true) +} + +// RPC call with multiple fields select +{ + const { data } = await postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldsSelect) + let result: Exclude + let expected: Database['public']['Functions'][typeof RPC_NAME]['Returns'] + expectType>(true) +} + +// RPC call with field aliasing +{ + const { data } = await postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldAliasing) + let result: Exclude + let expected: { name: string }[] + expectType>(true) +} + +// RPC call with field casting +{ + const { data } = await postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldCasting) + let result: Exclude + let expected: { status: string }[] + expectType>(true) +} + +// RPC call with field aggregate +{ + const { data } = await postgrest + .rpc(RPC_NAME, { name_param: 'supabot' }) + .select(selectParams.fieldAggregate) + let result: Exclude + let expected: { count: number; status: 'ONLINE' | 'OFFLINE' }[] + expectType>(true) +} diff --git a/test/select-query-parser/select.test-d.ts b/test/select-query-parser/select.test-d.ts new file mode 100644 index 00000000..af2fd540 --- /dev/null +++ b/test/select-query-parser/select.test-d.ts @@ -0,0 +1,763 @@ +import { expectType } from 'tsd' +import { TypeEqual } from 'ts-expect' +import { Json } from '../../src/select-query-parser/types' +import { SelectQueryError } from '../../src/select-query-parser/utils' +import { Prettify } from '../../src/types' +import { Database } from '../types' +import { selectQueries } from '../relationships' + +// This test file is here to ensure that for a query against a specfic datatabase +// our type inference for the result is correct and matching postgrest behavior at runtime +// it'll test the actual type inference AND query chaining (limit(), single(), ...) +// IMPORTANT: It shoudl be kept in sync with "test/relationships.ts" which test the actual runtime +// behavior of those queries using the same parameters + +type Schema = Database['public'] + +// nested query with selective fields +{ + const { data } = await selectQueries.nestedQueryWithSelectiveFields.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: { + id: number + message: string | null + }[] + } + expectType>(true) +} + +// nested query with multiple levels and selective fields +{ + const { data } = await selectQueries.nestedQueryWithMultipleLevelsAndSelectiveFields + .limit(1) + .single() + let result: Exclude + let expected: { + messages: Array<{ + id: number + message: string | null + channels: { + id: number + slug: string | null + } + }> + username: string + } + expectType>(true) +} + +// query with multiple one-to-many relationships +{ + const { data } = await selectQueries.queryWithMultipleOneToManySelectives.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array> + user_profiles: Array> + } + expectType>(true) +} + +// many-to-one relationship +{ + const { data } = await selectQueries.manyToOne.limit(1).single() + let result: Exclude + let expected: { + user: Database['public']['Tables']['users']['Row'] + } + expectType>(true) +} + +// !inner relationship +{ + const { data } = await selectQueries.inner.limit(1).single() + let result: Exclude + type ExpectedType = Prettify< + Database['public']['Tables']['channels']['Row'] & { + channel_details: Database['public']['Tables']['channel_details']['Row'] + } + > + let expected: { + channels: ExpectedType + } + expectType>(true) +} + +// one-to-many relationship +{ + const { data } = await selectQueries.oneToMany.limit(1).single() + let result: Exclude + let expected: { + messages: Database['public']['Tables']['messages']['Row'][] + } + expectType>(true) +} + +// one-to-many relationship with selective columns +{ + const { data } = await selectQueries.oneToManySelective.limit(1).single() + let result: Exclude + let expected: { + messages: Array> + } + expectType>(true) +} + +// one-to-one relationship +{ + const { data } = await selectQueries.oneToOne.limit(1).single() + let result: Exclude + let expected: { + channel_details: Database['public']['Tables']['channel_details']['Row'] | null + } + expectType>(true) +} + +// !left oneToOne +{ + const { data } = await selectQueries.leftOneToOne.limit(1).single() + let result: Exclude + let expected: { + channels: Database['public']['Tables']['channels']['Row'] + } + expectType>(true) +} + +// !left oneToMany +{ + const { data } = await selectQueries.leftOneToMany.limit(1).single() + let result: Exclude + let expected: { + messages: Array + } + expectType>(true) +} + +// !left zeroToOne +{ + const { data } = await selectQueries.leftZeroToOne.limit(1).single() + let result: Exclude + let expected: { + users: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// join over a 1-1 relation with both nullables and non-nullables fields using foreign key name for hinting +{ + const { data } = await selectQueries.joinOneToOneWithFkHint.limit(1).single() + let result: Exclude + let expected: { + first_user: Database['public']['Tables']['users']['Row'] + second_user: Database['public']['Tables']['users']['Row'] + third_wheel: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// join over a 1-M relation with both nullables and non-nullables fields using foreign key name for hinting +{ + const { data } = await selectQueries.joinOneToManyWithFkHint.limit(1).single() + let result: Exclude + let expected: { + first_friend_of: Array + second_friend_of: Array + third_wheel_of: Array + } + expectType>(true) +} + +// join on 1-M relation +{ + const { data } = await selectQueries.joinOneToManyUsersWithFkHint.limit(1).single() + let result: Exclude + let expected: { + first_friend_of: Array + second_friend_of: Array + third_wheel_of: Array + } + expectType>(true) +} + +// join on 1-1 relation with nullables +{ + const { data } = await selectQueries.joinOneToOneWithNullablesFkHint.limit(1).single() + let result: Exclude + let expected: { + first_user: Database['public']['Tables']['users']['Row'] + second_user: Database['public']['Tables']['users']['Row'] + third_wheel: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// join over a 1-1 relation with both nullables and non-nullables fields with no hinting +{ + const { data } = await selectQueries.joinOneToOneWithNullablesNoHint.limit(1).single() + let result: Exclude + let expected: { + first_user: SelectQueryError<"Could not embed because more than one relationship was found for 'users' and 'best_friends' you need to hint the column with users! ?"> + second_user: SelectQueryError<"Could not embed because more than one relationship was found for 'users' and 'best_friends' you need to hint the column with users! ?"> + third_wheel: SelectQueryError<"Could not embed because more than one relationship was found for 'users' and 'best_friends' you need to hint the column with users! ?"> + } + expectType>(true) +} + +// join over a 1-1 relation with both nullablesand non-nullables fields with column name hinting +{ + const { data } = await selectQueries.joinOneToOneWithNullablesColumnHint.limit(1).single() + let result: Exclude + let expected: { + first_user: Database['public']['Tables']['users']['Row'] + second_user: Database['public']['Tables']['users']['Row'] + third_wheel: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// join over a 1-M relation with both nullables and non-nullables fields with no hinting +{ + const { data } = await selectQueries.joinOneToManyWithNullablesNoHint.limit(1).single() + let result: Exclude + let expected: { + first_friend_of: SelectQueryError<"Could not embed because more than one relationship was found for 'best_friends' and 'users' you need to hint the column with best_friends! ?"> + second_friend_of: SelectQueryError<"Could not embed because more than one relationship was found for 'best_friends' and 'users' you need to hint the column with best_friends! ?"> + third_wheel_of: SelectQueryError<"Could not embed because more than one relationship was found for 'best_friends' and 'users' you need to hint the column with best_friends! ?"> + } + expectType>(true) +} + +// join over a 1-M relation with both nullables and non-nullables fields using column name for hinting +{ + const { data } = await selectQueries.joinOneToManyWithNullablesColumnHint.limit(1).single() + let result: Exclude + let expected: { + first_friend_of: Array + second_friend_of: Array + third_wheel_of: Array + } + expectType>(true) +} + +// join over a 1-M relation with both nullables and non-nullables fields using column name hinting on nested relation +{ + const { data } = await selectQueries.joinOneToManyWithNullablesColumnHintOnNestedRelation + .limit(1) + .single() + let result: Exclude + type ExpectedType = Prettify< + Database['public']['Tables']['best_friends']['Row'] & { + first_user: string & Database['public']['Tables']['users']['Row'] + } + > + let expected: { + first_friend_of: ExpectedType[] + second_friend_of: Array + third_wheel_of: Array + } + expectType>(true) +} + +// join over a 1-M relation with both nullables and non-nullables fields using no hinting on nested relation +{ + const { data } = await selectQueries.joinOneToManyWithNullablesNoHintOnNestedRelation + .limit(1) + .single() + let result: Exclude + let expected: { + first_friend_of: Array<{ + id: number + second_user: string + third_wheel: string | null + first_user: SelectQueryError<"Could not embed because more than one relationship was found for 'users' and 'best_friends' you need to hint the column with users! ?"> + }> + second_friend_of: Array + third_wheel_of: Array + } + expectType>(true) +} + +// !left join on one to 0-1 non-empty relation +{ + const { data } = await selectQueries.leftOneToOneUsers.limit(1).single() + let result: Exclude + let expected: { + user_profiles: Array> + } + expectType>(true) +} + +// join on one to 0-1 non-empty relation via column name +{ + const { data } = await selectQueries.oneToOneUsersColumnName.limit(1).single() + let result: Exclude + let expected: { + user_profiles: Array> + } + expectType>(true) +} + +// !left join on zero to one with null relation +{ + const { data } = await selectQueries.leftZeroToOneUserProfiles.limit(1).single() + let result: Exclude + let expected: { + id: number + username: string | null + users: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// !left join on zero to one with valid relation +{ + const { data } = await selectQueries.leftZeroToOneUserProfilesWithNullables.limit(1).single() + let result: Exclude + let expected: { + id: number + username: string | null + users: Pick | null + } + expectType>(true) +} + +// !left join on zero to one empty relation +{ + const { data } = await selectQueries.leftOneToOneUsers.limit(1).single() + let result: Exclude + let expected: { + user_profiles: Array> + } + expectType>(true) +} + +// join on 1-M relation with selective fk hinting +{ + const { data } = await selectQueries.joinOneToManyUsersWithFkHintSelective.limit(1).single() + let result: Exclude + let expected: { + first_friend_of: Array> + second_friend_of: Array + third_wheel_of: Array + } + expectType>(true) +} + +// join select via column +{ + const { data } = await selectQueries.joinSelectViaColumn.limit(1).single() + let result: Exclude + let expected: { + username: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// join select via column selective +{ + const { data } = await selectQueries.joinSelectViaColumnSelective.limit(1).single() + let result: Exclude + let expected: { + username: { + status: Schema['Enums']['user_status'] | null + } | null + } + expectType>(true) +} + +// join select via column and alias +{ + const { data } = await selectQueries.joinSelectViaColumnAndAlias.limit(1).single() + let result: Exclude + let expected: { + user: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// join select via unique table relationship +{ + const { data } = await selectQueries.joinSelectViaUniqueTableRelationship.limit(1).single() + let result: Exclude + let expected: { + users: Database['public']['Tables']['users']['Row'] | null + } + expectType>(true) +} + +// join select via view name relationship +{ + const { data } = await selectQueries.joinSelectViaViewNameRelationship.limit(1).single() + let result: Exclude + let expected: { + updatable_view: Database['public']['Views']['updatable_view']['Row'] | null + } + expectType>(true) +} + +// join select via column with string templating +{ + const { data } = await selectQueries.selectionWithStringTemplating.limit(1).single() + let result: Exclude + let expected: { + status: Schema['Enums']['user_status'] | null + username: string + } + expectType>(true) +} + +// select with aggregate count function +{ + const { data } = await selectQueries.selectWithAggregateCountFunction.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + count: number + }> + } + expectType>(true) +} + +// select with aggregate count on a column function +{ + const { data } = await selectQueries.selectWithAggregateCountOnAColumnFunction.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + count: number + }> + } + expectType>(true) +} + +// select with aggregate sum function without column should error +{ + const { data } = await selectQueries.selectWithAggregateSumFunctionWithoutColumn.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: SelectQueryError<"column 'sum' does not exist on 'messages'.">[] + } + expectType>(true) +} + +// select with aggregate count function and alias +{ + const { data } = await selectQueries.selectWithAggregateCountFunctionAndAlias.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + message_count: number + }> + } + expectType>(true) +} + +// select with aggregate nested count function +{ + const { data } = await selectQueries.selectWithAggregateNestedCountFunction.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + channels: { + count: number + } + }> + } + expectType>(true) +} + +// select with aggregate nested count function and alias +{ + const { data } = await selectQueries.selectWithAggregateNestedCountFunctionAndAlias + .limit(1) + .single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + channels: { + channel_count: number + } + }> + } + expectType>(true) +} + +// select with aggregate count and spread +{ + const { data } = await selectQueries.selectWithAggregateCountAndSpread.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + channels: { + count: number + details: string | null + } + }> + } + expectType>(true) +} + +// select with aggregate sum function +{ + const { data } = await selectQueries.selectWithAggregateSumFunction.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + sum: number + }> + } + expectType>(true) +} + +// select with aggregate aliased sum function +{ + const { data } = await selectQueries.selectWithAggregateAliasedSumFunction.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + sum_id: number + }> + } + expectType>(true) +} + +// select with aggregate sum function on nested relation +{ + const { data } = await selectQueries.selectWithAggregateSumFunctionOnNestedRelation + .limit(1) + .single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + channels: { + sum: number + } + }> + } + expectType>(true) +} + +// select with aggregate sum and spread +{ + const { data } = await selectQueries.selectWithAggregateSumAndSpread.limit(1).single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + channels: { + sum: number + details: string | null + } + }> + } + expectType>(true) +} + +// select with aggregate sum and spread on nested relation +{ + const { data } = await selectQueries.selectWithAggregateSumAndSpreadOnNestedRelation + .limit(1) + .single() + let result: Exclude + let expected: { + username: string + messages: Array<{ + channels: { + sum: number + details_sum: number | null + details: string | null + } + }> + } + expectType>(true) +} + +// select with spread on nested relation +{ + const { data } = await selectQueries.selectWithSpreadOnNestedRelation.limit(1).single() + let result: Exclude + let expected: { + id: number + channels: { + id: number + details_id: number | null + details: string | null + } + } + expectType>(true) +} + +// select with type casting query +{ + const { data } = await selectQueries.typeCastingQuery.limit(1).single() + let result: Exclude + let expected: { + id: string + } + expectType>(true) +} + +// join with column hinting +{ + const { data } = await selectQueries.joinSelectViaColumnHint.limit(1).single() + let result: Exclude + let expected: { + users: { + age_range: unknown | null + catchphrase: unknown | null + data: Json | null + status: Database['public']['Enums']['user_status'] | null + username: string + } + } + expectType>(true) +} + +// join with same dest twice column hinting +{ + const { data } = await selectQueries.joinSelectViaColumnHintTwice.limit(1).single() + let result: Exclude + let expected: { + users: SelectQueryError<'table "best_friends" specified more than once use hinting for desambiguation'> + } + expectType>(true) +} + +// join with same dest twice column hinting +{ + const { data } = await selectQueries.selectSpreadOnManyRelation.limit(1).single() + let result: Exclude + let expected: { + id: number + messages: SelectQueryError<'"channels" and "messages" do not form a many-to-one or one-to-one relationship spread not possible'> + } + expectType>(true) +} + +// multiple times the same column in selection +{ + const { data } = await selectQueries.selectWithDuplicatesFields.limit(1).single() + let result: Exclude + let expected: { + id: number + } + expectType>(true) +} + +// embed resource with no fields +{ + const { data } = await selectQueries.selectEmbedRessourceWithNoFields.limit(1).single() + let result: Exclude + let expected: { + message: string | null + } + expectType>(true) +} + +// select JSON accessor +{ + const { data } = await selectQueries.selectJsonAccessor + .limit(1) + .filter('username', 'eq', 'jsonuser') + .single() + let result: Exclude + let expected: { + bar: Json + baz: string + } + expectType>(true) +} + +// typecasting and aggregate +{ + const { data } = await selectQueries.typecastingAndAggregate.limit(1).single() + let result: Exclude + let expected: SelectQueryError<`column 'users' does not exist on 'messages'.`> + expectType>(true) +} + +// inner join on many relation +{ + const { data } = await selectQueries.innerJoinOnManyRelation.limit(1).single() + let result: Exclude + let expected: { + id: number + messages: { + id: number + username: string + }[] + } + expectType>(true) +} + +// self reference relation +{ + const { data } = await selectQueries.selfReferenceRelation.limit(1).single() + let result: Exclude + let expected: { + id: number + description: string | null + parent_id: number | null + collections: { + id: number + description: string | null + parent_id: number | null + }[] + } + expectType>(true) +} + +// self reference relation via column +{ + const { data } = await selectQueries.selfReferenceRelationViaColumn.limit(1).single() + let result: Exclude + let expected: { + description: string | null + id: number + parent_id: + | (number & { + description: string | null + id: number + parent_id: number | null + }) + | null + } + expectType>(true) +} + +// aggregate on missing column with alias +{ + const { data, error } = await selectQueries.aggregateOnMissingColumnWithAlias.limit(1).single() + if (error) throw error + expectType>(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 new file mode 100644 index 00000000..049630dd --- /dev/null +++ b/test/select-query-parser/types.test-d.ts @@ -0,0 +1,199 @@ +import { Database } from '../types' +import { selectParams } from '../relationships' +import { + ProcessEmbeddedResource, + ProcessNode, + ProcessNodes, +} from '../../src/select-query-parser/result' +import { expectType } from 'tsd' +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' + +// This test file is here to ensure some of our helpers behave as expected for ease of development +// and debugging purposes + +// Searching for a relationship by direct foreignkey name +{ + let result: FindMatchingTableRelationships< + Database['public'], + Database['public']['Tables']['best_friends']['Relationships'], + 'best_friends_first_user_fkey' + > + let expected: { + foreignKeyName: 'best_friends_first_user_fkey' + columns: ['first_user'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['username'] + } & { match: 'fkname' } + expectType>(true) +} +// Searching for a relationship by column hoding the value reference +{ + let result: FindMatchingTableRelationships< + Database['public'], + Database['public']['Tables']['best_friends']['Relationships'], + 'first_user' + > + let expected: { + foreignKeyName: 'best_friends_first_user_fkey' + columns: ['first_user'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['username'] + } & { match: 'col' } + expectType>(true) +} +// should return the relation matching the "Tables" references +{ + let result: FindMatchingTableRelationships< + Database['public'], + Database['public']['Tables']['user_profiles']['Relationships'], + 'username' + > + let expected: { + foreignKeyName: 'user_profiles_username_fkey' + columns: ['username'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['username'] + } & { match: 'col' } + expectType>(true) +} +// Searching for a relationship by referenced table name +{ + let result: FindMatchingTableRelationships< + Database['public'], + Database['public']['Tables']['messages']['Relationships'], + 'users' + > + let expected: { + foreignKeyName: 'messages_username_fkey' + columns: ['username'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['username'] + } & { match: 'refrel' } + expectType>(true) +} +{ + let result: FindMatchingTableRelationships< + Database['public'], + Database['public']['Tables']['messages']['Relationships'], + 'channels' + > + let expected: { + foreignKeyName: 'messages_channel_id_fkey' + columns: ['channel_id'] + isOneToOne: false + referencedRelation: 'channels' + referencedColumns: ['id'] + } & { match: 'refrel' } + expectType>(true) +} + +// IsRelationNullable +{ + type BestFriendsTable = Database['public']['Tables']['best_friends'] + type NonNullableRelation = FindMatchingTableRelationships< + Database['public'], + BestFriendsTable['Relationships'], + 'best_friends_first_user_fkey' + > + type NullableRelation = FindMatchingTableRelationships< + Database['public'], + BestFriendsTable['Relationships'], + 'best_friends_third_wheel_fkey' + > + let nonNullableResult: IsRelationNullable + let nullableResult: IsRelationNullable + expectType(false) + expectType(true) +} + +// Test nodes relations crawling utils +{ + const { from, select } = selectParams.nestedQueryWithSelectiveFields + type Schema = Database['public'] + type RelationName = typeof from + type Row = Schema['Tables'][RelationName]['Row'] + type Relationships = Schema['Tables'][RelationName]['Relationships'] + type ParsedQuery = ParseQuery + type r1 = ProcessNode + expectType>(true) + type r2 = ProcessNodes + // fail because result for messages is ({id: string} | {message: string | null })[] + expectType< + TypeEqual + >(true) + type f3 = ParsedQuery[1] + type r3 = ProcessEmbeddedResource + expectType>(true) +} +// Select from the column holding the relation (0-1 relation) +{ + const { from, select } = selectParams.joinSelectViaColumn + type Schema = Database['public'] + type RelationName = typeof from + type Row = Schema['Tables'][RelationName]['Row'] + type Relationships = Schema['Tables'][RelationName]['Relationships'] + type ParsedQuery = ParseQuery + type r1 = ProcessNode + let expected: { + username: { + age_range: unknown | null + catchphrase: unknown | null + data: Json | null + status: Database['public']['Enums']['user_status'] | null + username: string + } | null + } + expectType(expected!) + 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 deed0fa9..bd26dd1e 100644 --- a/test/types.ts +++ b/test/types.ts @@ -65,6 +65,20 @@ export type Database = { third_wheel?: string | null } Relationships: [ + { + foreignKeyName: 'best_friends_first_user_fkey' + columns: ['first_user'] + isOneToOne: false + referencedRelation: 'non_updatable_view' + referencedColumns: ['username'] + }, + { + foreignKeyName: 'best_friends_first_user_fkey' + columns: ['first_user'] + isOneToOne: false + referencedRelation: 'updatable_view' + referencedColumns: ['username'] + }, { foreignKeyName: 'best_friends_first_user_fkey' columns: ['first_user'] @@ -157,6 +171,32 @@ export type Database = { } Relationships: [] } + collections: { + Row: { + description: string | null + id: number + parent_id: number | null + } + Insert: { + description?: string | null + id?: number + parent_id?: number | null + } + Update: { + description?: string | null + id?: number + parent_id?: number | null + } + Relationships: [ + { + foreignKeyName: 'collections_parent_id_fkey' + columns: ['parent_id'] + isOneToOne: false + referencedRelation: 'collections' + referencedColumns: ['id'] + } + ] + } messages: { Row: { channel_id: number @@ -210,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