diff --git a/.changeset/postgrest-typegen-arktype-validation.md b/.changeset/postgrest-typegen-arktype-validation.md new file mode 100644 index 000000000..b18f22d9d --- /dev/null +++ b/.changeset/postgrest-typegen-arktype-validation.md @@ -0,0 +1,5 @@ +--- +"@supabase/postgrest-typegen": minor +--- + +Back `GeneratorMetadata` with an ArkType schema as the single source of truth (each `Postgres*` type is now derived via `.infer`), and add an opt-in `parseGeneratorMetadata(data)` runtime validator plus the exported `generatorMetadataSchema`. This lets integrators producing `GeneratorMetadata` through a custom/injected introspection adapter validate the result at runtime instead of blindly casting. Validation is intentionally not baked into `introspect()`. The inferred types remain structurally identical to the previous interfaces (pinned by a compile-time equivalence test), so generator output is unchanged. diff --git a/.changeset/postgrest-typegen-drop-table-relationships.md b/.changeset/postgrest-typegen-drop-table-relationships.md new file mode 100644 index 000000000..ec1167607 --- /dev/null +++ b/.changeset/postgrest-typegen-drop-table-relationships.md @@ -0,0 +1,9 @@ +--- +"@supabase/postgrest-typegen": minor +--- + +Drop the unused per-table `relationships` and `primary_keys` fields from `PostgresTable` / `GeneratorMetadata.tables[]`, and remove the corresponding `pg_constraint` JSON-array join and primary-key subquery from the tables introspection SQL. + +None of the language generators ever read these per-table fields — TypeScript relationship output is built from the top-level `GeneratorMetadata.relationships` (the PostgREST-shaped `PostgresRelationship`), and Go/Python/Swift emit no relationship metadata at all. Removing them makes `introspect()`'s tables query markedly cheaper (the expensive constraint join is gone) without changing any generator output (byte-parity preserved). The `PostgresRelationshipOld` and `PostgresPrimaryKey` types are removed accordingly. + +This trims the `GeneratorMetadata` contract; consumers that produce metadata through a custom adapter no longer need to populate those table fields. postgres-meta's `/tables` REST endpoint is unaffected — it uses its own table types, not this package's. diff --git a/.changeset/postgrest-typegen-initial.md b/.changeset/postgrest-typegen-initial.md new file mode 100644 index 000000000..e79f382c3 --- /dev/null +++ b/.changeset/postgrest-typegen-initial.md @@ -0,0 +1,5 @@ +--- +"@supabase/postgrest-typegen": minor +--- + +Initial alpha of `@supabase/postgrest-typegen`: type generation for PostgREST extracted from postgres-meta. Provides a hard split between introspection (`introspect(db)` → `GeneratorMetadata`) and pure generation (`generateTypescript`/`generateGo`/`generatePython`/`generateSwift`), with `GeneratorMetadata` as the pluggable contract. Output is byte-identical to postgres-meta's embedded templates. diff --git a/.changeset/postgrest-typegen-null-has-default.md b/.changeset/postgrest-typegen-null-has-default.md new file mode 100644 index 000000000..63d1d0e13 --- /dev/null +++ b/.changeset/postgrest-typegen-null-has-default.md @@ -0,0 +1,5 @@ +--- +"@supabase/postgrest-typegen": patch +--- + +Accept `null` `has_default` for OUT/TABLE function args in `parseGeneratorMetadata`. The introspection SQL sizes `arg_has_defaults` from the input-arg count (`pronargs`) while `arg_modes`/`arg_types` include output args, so `introspect()` legitimately emits `has_default: null` for the OUT columns of RETURNS TABLE / OUT-arg functions. The opt-in validator previously typed this field as a plain `boolean` and rejected such valid introspector output. The field is now `boolean | null`; generator output is unaffected (the generators already treat `has_default` truthily, so `null` and `false` behave identically). diff --git a/.changeset/postgrest-typegen-tables-order.md b/.changeset/postgrest-typegen-tables-order.md new file mode 100644 index 000000000..4ed2b45f7 --- /dev/null +++ b/.changeset/postgrest-typegen-tables-order.md @@ -0,0 +1,5 @@ +--- +"@supabase/postgrest-typegen": minor +--- + +Add `sortGeneratorMetadata`, a generator-agnostic pass that deterministically orders every `GeneratorMetadata` collection by a stable, **semantic** key (schema + name + signature; oid only as a final tie-breaker). The Go/Python/Swift generators emit objects in metadata order, so their output was sensitive to however the producer ordered its rows — the SQL introspector returns rows in environment-dependent heap order. Sorting by semantic keys (rather than oid, which differs across equivalent databases created in a different order) makes codegen byte-stable for the same logical schema regardless of the producer or environment. Callers apply `sortGeneratorMetadata` after introspection and before generation; the generators document that they expect pre-sorted input. Generator content is unchanged — only ordering is now canonical. diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 1d7afe79d..0bce1857d 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -23,6 +23,12 @@ outputs: pg-topo-package-json: description: Whether pg-topo package.json changed value: ${{ steps.filter.outputs.pg-topo-package-json }} + postgrest-typegen: + description: Whether postgrest-typegen changed + value: ${{ steps.filter.outputs.postgrest-typegen }} + postgrest-typegen-package-json: + description: Whether postgrest-typegen package.json changed + value: ${{ steps.filter.outputs.postgrest-typegen-package-json }} root: description: Whether root config changed value: ${{ steps.filter.outputs.root }} @@ -50,6 +56,10 @@ runs: - 'packages/pg-topo/**' pg-topo-package-json: - 'packages/pg-topo/package.json' + postgrest-typegen: + - 'packages/postgrest-typegen/**' + postgrest-typegen-package-json: + - 'packages/postgrest-typegen/package.json' root: - 'package.json' - 'bunfig.toml' @@ -75,6 +85,8 @@ runs: - "!packages/pg-delta/CHANGELOG.md" - "!packages/pg-topo/package.json" - "!packages/pg-topo/CHANGELOG.md" + - "!packages/postgrest-typegen/package.json" + - "!packages/postgrest-typegen/CHANGELOG.md" - id: release-pr-content shell: bash diff --git a/.github/agents/pg-toolbelt.md b/.github/agents/pg-toolbelt.md index 9b869dd08..839b03f48 100644 --- a/.github/agents/pg-toolbelt.md +++ b/.github/agents/pg-toolbelt.md @@ -20,6 +20,7 @@ Bun-based monorepo containing PostgreSQL tooling packages. - **packages/pg-delta** (`@supabase/pg-delta`): PostgreSQL schema diff and migration tool. Compares two live databases and generates DDL migration scripts. - **packages/pg-topo** (`@supabase/pg-topo`): Topological sorting for SQL DDL statements. Pure library that accepts SQL content strings, extracts dependencies, and produces a deterministic execution order. Includes an optional filesystem adapter for discovering/reading `.sql` files. +- **packages/postgrest-typegen** (`@supabase/postgrest-typegen`): Type generation for PostgREST from a PostgreSQL schema (the engine behind `supabase gen types`, extracted from postgres-meta). Hard split between introspection (`introspect(db)` → `GeneratorMetadata`) and pure generation (`generateTypescript`/`generateGo`/`generatePython`/`generateSwift`); `GeneratorMetadata` is the pluggable contract. `GeneratorMetadata`/`Postgres*` types are derived from ArkType schemas in `src/types.ts` (single source of truth; a compile-time equivalence test pins the inferred types to the frozen interface contract), and `parseGeneratorMetadata` is an opt-in runtime validator — never baked into `introspect()`. Output must stay byte-identical to postgres-meta's templates (prettier pinned exact to 3.5.3) until parity is released. ## Quick Reference @@ -165,6 +166,7 @@ The `Lint Pull Request` CI check (see `.github/workflows/lint-pull-request.yml`) - GitHub Actions with `dorny/paths-filter` detects which packages changed - Only affected packages are tested - pg-delta integration tests are sharded across 15 runners x 3 PG versions +- pg-topo and postgrest-typegen each have a single `*-tests` job (Docker required); postgrest-typegen's integration/parity tests spin up a PostgreSQL container via testcontainers - Changesets automate releases on merge to main When changing shard count or PG versions, update all of these locations: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 377e5ea17..acf559be8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,6 +38,8 @@ jobs: pg-delta-package-json: ${{ github.event_name == 'merge_group' && 'true' || steps.changes.outputs.pg-delta-package-json }} pg-topo: ${{ github.event_name == 'merge_group' && 'true' || steps.changes.outputs.pg-topo }} pg-topo-package-json: ${{ github.event_name == 'merge_group' && 'true' || steps.changes.outputs.pg-topo-package-json }} + postgrest-typegen: ${{ github.event_name == 'merge_group' && 'true' || steps.changes.outputs.postgrest-typegen }} + postgrest-typegen-package-json: ${{ github.event_name == 'merge_group' && 'true' || steps.changes.outputs.postgrest-typegen-package-json }} root: ${{ github.event_name == 'merge_group' && 'true' || steps.changes.outputs.root }} # 'true' only when the PR is authored by the changesets release bot AND # every changed file is in the release-PR whitelist (CHANGELOGs, @@ -115,6 +117,9 @@ jobs: - name: Check types (pg-topo) if: needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true' run: bun run --filter '@supabase/pg-topo' check-types + - name: Check types (postgrest-typegen) + if: needs.detect-changes.outputs.postgrest-typegen == 'true' || needs.detect-changes.outputs.root == 'true' + run: bun run --filter '@supabase/postgrest-typegen' check-types format-and-lint: if: >- @@ -149,6 +154,9 @@ jobs: - name: Knip (pg-topo) if: needs.detect-changes.outputs.pg-topo == 'true' || needs.detect-changes.outputs.root == 'true' run: bun run --filter '@supabase/pg-topo' knip + - name: Knip (postgrest-typegen) + if: needs.detect-changes.outputs.postgrest-typegen == 'true' || needs.detect-changes.outputs.root == 'true' + run: bun run --filter '@supabase/postgrest-typegen' knip pg-delta-unit: if: >- @@ -443,12 +451,13 @@ jobs: coverage: if: >- !cancelled() && - (needs.pg-delta-unit.result != 'skipped' || needs.pg-delta-integration.result != 'skipped' || needs.pg-topo-tests.result != 'skipped') + (needs.pg-delta-unit.result != 'skipped' || needs.pg-delta-integration.result != 'skipped' || needs.pg-topo-tests.result != 'skipped' || needs.postgrest-typegen-tests.result != 'skipped') continue-on-error: true needs: - pg-delta-unit - pg-delta-integration - pg-topo-tests + - postgrest-typegen-tests name: "Coverage report" runs-on: ubuntu-latest steps: @@ -541,6 +550,35 @@ jobs: retention-days: 1 include-hidden-files: true + postgrest-typegen-tests: + if: >- + needs.detect-changes.outputs.is-release-pr != 'true' && + (github.event_name != 'pull_request' || github.event.pull_request.draft == false) && + (needs.detect-changes.outputs.postgrest-typegen == 'true' || needs.detect-changes.outputs.root == 'true') + needs: detect-changes + name: "postgrest-typegen: Tests" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup + uses: ./.github/actions/setup + - name: Run tests + working-directory: packages/postgrest-typegen + env: + BUN_COVERAGE: "1" + NYC_OUTPUT_DIR: ${{ github.workspace }}/packages/postgrest-typegen/.nyc_output + run: bun run test + - name: Upload coverage + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: coverage-postgrest-typegen + path: packages/postgrest-typegen/.nyc_output/ + retention-days: 1 + include-hidden-files: true + deno2-library-e2e: if: >- needs.detect-changes.outputs.is-release-pr != 'true' && @@ -575,6 +613,7 @@ jobs: - pg-delta-integration-pg15-compat - pg-delta-integration-pg17-compat - pg-topo-tests + - postgrest-typegen-tests - deno2-library-e2e if: always() && !cancelled() && needs.detect-changes.outputs.is-release-pr != 'true' name: Release preview @@ -587,4 +626,4 @@ jobs: - name: Build all packages run: bun run build - name: Release preview - run: bunx pkg-pr-new publish ./packages/pg-delta ./packages/pg-topo + run: bunx pkg-pr-new publish ./packages/pg-delta ./packages/pg-topo ./packages/postgrest-typegen diff --git a/bun.lock b/bun.lock index e2e4ba6fd..c48942fb0 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/pg-delta": { "name": "@supabase/pg-delta", - "version": "1.0.0-alpha.25", + "version": "1.0.0-alpha.30", "bin": { "pgdelta": "./dist/cli/bin/cli.js", }, @@ -64,7 +64,7 @@ }, "packages/pg-topo": { "name": "@supabase/pg-topo", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.2", "dependencies": { "@pgsql/traverse": "^17.2.4", "plpgsql-parser": "^0.5.4", @@ -78,11 +78,38 @@ "typescript": "^5.9.3", }, }, + "packages/postgrest-typegen": { + "name": "@supabase/postgrest-typegen", + "version": "1.0.0-alpha.1", + "dependencies": { + "arktype": "2.2.1", + "pg-format": "1.0.4", + "prettier": "3.5.3", + }, + "devDependencies": { + "@supabase/bun-istanbul-coverage": "workspace:*", + "@testcontainers/postgresql": "^11.11.0", + "@tsconfig/node-ts": "^23.6.2", + "@tsconfig/node24": "^24.0.3", + "@types/bun": "^1.3.9", + "@types/node": "^24.10.4", + "@types/pg": "^8.11.10", + "@types/pg-format": "^1.0.1", + "knip": "^5.75.2", + "pg": "^8.17.2", + "testcontainers": "^11.11.0", + "typescript": "^5.9.3", + }, + }, }, "overrides": { "cpu-features": "file:./.stubs/cpu-features", }, "packages": { + "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], + + "@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], @@ -415,6 +442,8 @@ "@supabase/pg-topo": ["@supabase/pg-topo@workspace:packages/pg-topo"], + "@supabase/postgrest-typegen": ["@supabase/postgrest-typegen@workspace:packages/postgrest-typegen"], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], "@testcontainers/postgresql": ["@testcontainers/postgresql@11.14.0", "", { "dependencies": { "testcontainers": "^11.14.0" } }, "sha512-wYbJn8GRTj8qfqzfVubxioYWlHJU/ImIjuzPwyy9C5Qfo6g3GLduPZAj+BifvqTZjgT3gd4gFVLCPhBji7dc1w=="], @@ -451,6 +480,8 @@ "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + "@types/pg-format": ["@types/pg-format@1.0.5", "", {}, "sha512-i+oEEJEC+1I3XAhgqtVp45Faj8MBbV0Aoq4rHsHD7avgLjyDkaWKObd514g0Q/DOUkdxU0P4CQ0iq2KR4SoJcw=="], + "@types/picomatch": ["@types/picomatch@4.0.3", "", {}, "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ=="], "@types/responselike": ["@types/responselike@1.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA=="], @@ -529,6 +560,10 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "arkregex": ["arkregex@0.0.6", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-9mvuMKQuibfWhBrsNYhsKhNb6k9oEHoAJ/FvDiqe8h+E9Siwe0/cro1WVOGgpajXQ9ZHd24yCOf2k35Q/QqUQw=="], + + "arktype": ["arktype@2.2.1", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.6" } }, "sha512-CWPJxNoSxrS+NYGB3ufwc/blFonESEW5vBQyYPVS0rf4STu8VWoAWfKJSl5vVVm56h4yxpwbODeYwy6XFKvojA=="], + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], @@ -1123,6 +1158,8 @@ "pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="], + "pg-format": ["pg-format@1.0.4", "", {}, "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], @@ -1163,7 +1200,7 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], @@ -1451,8 +1488,12 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "@changesets/parse/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], "@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/package.json b/package.json index 190922ca4..3f786a68d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:pg-topo": "bun run --filter '@supabase/pg-topo' test", "verdaccio:start": "verdaccio --config ./verdaccio/config.yaml", "pg-delta:publish-local": "bun scripts/verdaccio-publish-pg-delta.ts", + "postgrest-typegen:publish-local": "bun scripts/verdaccio-publish-postgrest-typegen.ts", "version": "changeset version" }, "devDependencies": { diff --git a/packages/postgrest-typegen/CLAUDE.md b/packages/postgrest-typegen/CLAUDE.md new file mode 100644 index 000000000..a5c26372b --- /dev/null +++ b/packages/postgrest-typegen/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md -- @supabase/postgrest-typegen + +## What This Package Does + +Type generation for PostgREST from a PostgreSQL schema. Introspects a database +into a normalized `GeneratorMetadata` shape, then renders language types +(TypeScript, Go, Python, Swift) from it. This is the engine extracted from +postgres-meta (the one behind `supabase gen types`), repackaged as a small, +driver-agnostic library. + +## Architecture + +Hard split between **introspection** and **generation**: + +- `src/introspection/` -- `introspect(db, opts) => GeneratorMetadata`. Takes a + structural `Queryable` (`pg.Pool`/`pg.Client` satisfy it; postgres-meta + injects its forked-pg pool). Runs SQL builders ported from postgres-meta. +- `src/generation/` -- `generateTypescript` / `generateGo` / `generatePython` / + `generateSwift`. Pure functions: `GeneratorMetadata` in, source string out. + No database access. +- `src/types.ts` -- `GeneratorMetadata` + `Postgres*` types. This is the public, + pluggable contract: any source that can produce `GeneratorMetadata` can feed + the generators. **ArkType is the single source of truth here**: each shape is + an ArkType schema and the exported type is `typeof schema.infer`. The arrays + on `GeneratorMetadata` use `.omit("columns")` to mirror `Omit<…, "columns">`. + A compile-time equivalence test (`test/validation/validate.test.ts`) pins the + inferred types to the frozen interface contract so they can't silently drift. + `parseGeneratorMetadata(data)` is an **opt-in** runtime validator (throws on + mismatch) — it is intentionally NOT called inside `introspect()`; integrators + with a custom producer wrap the result themselves. + +## Subpath Exports + +- `.` -- everything +- `./introspection` -- `introspect`, `Queryable` +- `./generation` -- the `generateX` functions + +The `bun` condition serves TypeScript source directly; `import`/`require` serve +compiled JS from `dist/`. + +## Commands + +```bash +bun run build # tsc --project tsconfig.build.json (emits dist/) +bun run check-types # tsc --noEmit +bun run test # bun:test (Docker required for integration/parity tests) +bun run format-and-lint # oxfmt + oxlint check +bun run knip # unused-code/deps check +``` + +## Byte-Parity Constraint + +This package must produce **byte-identical** output to postgres-meta's +templates until parity is validated and released. Two consequences: + +- `prettier` is pinned **exact** to `3.5.3` (the version postgres-meta's + lockfile resolves). Do not bump it during the port. +- Don't "improve" template strings or SQL during the port. Byte parity first; + behavior-changing cleanups (e.g. oxfmt instead of prettier) come later. + +`int8` columns: stock `pg` returns them as strings while postgres-meta installs +a global int8 type parser. `src/introspection/normalize.ts` coerces known +numeric id fields after each query so output is identical under any driver. + +**Deterministic ordering:** the Go/Python/Swift generators emit objects in +`GeneratorMetadata` order (only TypeScript sorts internally), so output is +sensitive to however the producer ordered its collections. Do NOT rely on +introspection query order (heap/aggregate-plan order is environment- and +data-dependent — the parity test can pass locally while postgres-meta's +order-sensitive snapshots break in CI). Stability is enforced by a single +generator-agnostic pass, `sortGeneratorMetadata` (`src/sort.ts`): callers apply +it to the metadata *after* introspection and *before* any `generate*` call. The +generators document that they expect pre-sorted input; introspection queries +carry no `ORDER BY`. Sort keys are **semantic** (schema + name + signature), +never oid — equivalent databases assign different oids, so an oid sort would +still churn output across environments. + +**TypeScript is the source of truth for ordering.** `sortGeneratorMetadata` +replicates the sorts `generateTypescript` used to apply internally, so all four +generators now consume the single pass and TypeScript output stays +byte-identical. The only sorts that remain inside generators are ones the +single-collection pass cannot express: cross-collection *merge* sorts +(TypeScript and Swift merge tables + foreign tables, and views + materialized +views, into one per-schema group then sort by name) and TypeScript's +overload-resolution sorts. Don't reintroduce per-collection sorting in a +generator — extend `sortGeneratorMetadata` instead. + +## Test Patterns + +- Generation unit tests: pure, no Docker, fixture-builder + per-language inline + snapshots (`src/` and `test/generation/`). +- Introspection/parity tests: `bun:test` + testcontainers PostgreSQL. diff --git a/packages/postgrest-typegen/LICENSE b/packages/postgrest-typegen/LICENSE new file mode 100644 index 000000000..ce68b1457 --- /dev/null +++ b/packages/postgrest-typegen/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Supabase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/postgrest-typegen/README.md b/packages/postgrest-typegen/README.md new file mode 100644 index 000000000..005004afe --- /dev/null +++ b/packages/postgrest-typegen/README.md @@ -0,0 +1,91 @@ +# @supabase/postgrest-typegen + +Type generation for [PostgREST](https://postgrest.org) from a PostgreSQL +schema. This is the type-generation engine behind `supabase gen types`, +extracted from [postgres-meta](https://github.com/supabase/postgres-meta) into a +small, driver-agnostic library. + +> **Status:** alpha. The public API is settling as generators and introspection +> are ported. See the [pg-toolbelt](https://github.com/supabase/pg-toolbelt) +> repo for progress. + +## Design + +There is a hard split between **introspection** (database → metadata) and +**generation** (metadata → string): + +```ts +import { introspect } from "@supabase/postgrest-typegen/introspection"; +import { + generateTypescript, + sortGeneratorMetadata, +} from "@supabase/postgrest-typegen/generation"; + +// Any `pg.Pool` / `pg.Client` (or compatible driver) works here. +const metadata = await introspect(pool, { includedSchemas: ["public"] }); +// Canonically sort before generating (see "Stable ordering" below). +const types = await generateTypescript(sortGeneratorMetadata(metadata), { + postgrestVersion: "12", +}); +``` + +`GeneratorMetadata` is the pluggable contract: the SQL introspector is the +default producer, but any source that can produce that shape can feed the +generators. + +### Stable ordering (`sortGeneratorMetadata`) + +The Go/Python/Swift generators emit tables, views, and materialized views in +`GeneratorMetadata` order, so their output depends on how the producer ordered +its collections (a SQL introspector returns rows in environment-dependent heap +order). `sortGeneratorMetadata` is a pure pass that canonically sorts every +collection; **apply it after introspection and before any `generate*` call** so +output is deterministic regardless of the producer. Generators expect +pre-sorted input and do not re-sort it themselves. + +### Runtime validation (opt-in) + +`GeneratorMetadata` is backed by an [ArkType](https://arktype.io) schema, so a +result coming from a custom/injected producer can be validated at runtime +rather than blindly cast. `introspect()` does **not** validate — wrap its +result yourself when you want the guarantee: + +```ts +import { parseGeneratorMetadata, generatorMetadataSchema } from "@supabase/postgrest-typegen"; + +// Throws a TypeError with a readable summary if the shape is wrong. +const metadata = parseGeneratorMetadata(await someCustomIntrospector(db)); + +// Or use the raw schema directly for custom flows. +const out = generatorMetadataSchema(unknownInput); +``` + +### Generators + +```ts +import { + generateTypescript, // async (uses prettier) + generateGo, + generatePython, + generateSwift, +} from "@supabase/postgrest-typegen/generation"; +``` + +| Function | Options | +| -------------------- | ----------------------------------------------------------------------- | +| `generateTypescript` | `{ detectOneToOneRelationships?, postgrestVersion?, defaultSchema? }` | +| `generateGo` | — | +| `generatePython` | — | +| `generateSwift` | `{ accessControl?: 'internal' \| 'public' \| 'private' \| 'package' }` | + +## Installation + +```bash +npm install @supabase/postgrest-typegen +# pg is a peer of your application, not bundled here +npm install pg +``` + +## License + +MIT diff --git a/packages/postgrest-typegen/bunfig.toml b/packages/postgrest-typegen/bunfig.toml new file mode 100644 index 000000000..56dd2bab2 --- /dev/null +++ b/packages/postgrest-typegen/bunfig.toml @@ -0,0 +1,7 @@ +# KNOWN BUG: Bun ignores [test] settings in bunfig.toml (oven-sh/bun#17664, oven-sh/bun#7789). +# All test config is applied via CLI flags in scripts/run-tests.ts instead. +# Kept here for documentation / when the bug is fixed. + +[test] +preload = ["./test/global-setup.ts"] +timeout = 15000 diff --git a/packages/postgrest-typegen/knip.json b/packages/postgrest-typegen/knip.json new file mode 100644 index 000000000..4d23f829b --- /dev/null +++ b/packages/postgrest-typegen/knip.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": ["test/global-setup.ts", "test/**/*.test.ts"], + "ignoreBinaries": ["oxfmt", "oxlint"] +} diff --git a/packages/postgrest-typegen/package.json b/packages/postgrest-typegen/package.json new file mode 100644 index 000000000..270360185 --- /dev/null +++ b/packages/postgrest-typegen/package.json @@ -0,0 +1,85 @@ +{ + "name": "@supabase/postgrest-typegen", + "version": "1.0.0-alpha.1", + "description": "Type generation for PostgREST from PostgreSQL schemas", + "keywords": [ + "codegen", + "pg", + "postgres", + "postgrest", + "typegen", + "types" + ], + "homepage": "https://github.com/supabase/pg-toolbelt", + "bugs": "https://github.com/supabase/pg-toolbelt/issues", + "license": "MIT", + "author": "Supabase", + "repository": { + "type": "git", + "url": "https://github.com/supabase/pg-toolbelt.git", + "directory": "packages/postgrest-typegen" + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "bun": "./src/index.ts", + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./introspection": { + "bun": "./src/introspection/index.ts", + "import": "./dist/introspection/index.js", + "require": "./dist/introspection/index.js", + "types": "./dist/introspection/index.d.ts", + "default": "./dist/introspection/index.js" + }, + "./generation": { + "bun": "./src/generation/index.ts", + "import": "./dist/generation/index.js", + "require": "./dist/generation/index.js", + "types": "./dist/generation/index.d.ts", + "default": "./dist/generation/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "check-types": "tsc --noEmit", + "format-and-lint": "oxfmt --check . && oxlint --deny-warnings", + "format-and-lint:fix": "oxfmt . && oxlint --fix", + "knip": "knip", + "test": "bun scripts/run-tests.ts test/" + }, + "dependencies": { + "arktype": "2.2.1", + "pg-format": "1.0.4", + "prettier": "3.5.3" + }, + "devDependencies": { + "@supabase/bun-istanbul-coverage": "workspace:*", + "@testcontainers/postgresql": "^11.11.0", + "@tsconfig/node-ts": "^23.6.2", + "@tsconfig/node24": "^24.0.3", + "@types/bun": "^1.3.9", + "@types/node": "^24.10.4", + "@types/pg": "^8.11.10", + "@types/pg-format": "^1.0.1", + "knip": "^5.75.2", + "pg": "^8.17.2", + "testcontainers": "^11.11.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/postgrest-typegen/scripts/run-tests.ts b/packages/postgrest-typegen/scripts/run-tests.ts new file mode 100644 index 000000000..7320d2426 --- /dev/null +++ b/packages/postgrest-typegen/scripts/run-tests.ts @@ -0,0 +1,38 @@ +/** + * Test runner that resolves global-setup and test paths from this script's + * location, so `bun run test` works correctly whether invoked from the + * package directory or from the monorepo root (e.g. via `bun run --filter '*' test`). + */ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkgRoot = join(import.meta.dir, ".."); +const globalSetup = join(pkgRoot, "test", "global-setup.ts"); +const args = process.argv.slice(2); + +const coveragePreload = fileURLToPath( + import.meta.resolve("@supabase/bun-istanbul-coverage/preload"), +); +const coverageArgs = + process.env.BUN_COVERAGE === "1" ? ["--preload", coveragePreload] : []; + +const proc = Bun.spawn({ + cmd: [ + "bun", + "test", + ...coverageArgs, + "--preload", + globalSetup, + "--timeout", + "15000", + "--concurrent", + "--max-concurrency", + "8", + ...args, + ], + cwd: pkgRoot, + stdio: ["inherit", "inherit", "inherit"], +}); + +const exitCode = await proc.exited; +process.exit(exitCode); diff --git a/packages/postgrest-typegen/src/generation/constants.ts b/packages/postgrest-typegen/src/generation/constants.ts new file mode 100644 index 000000000..a2999abb3 --- /dev/null +++ b/packages/postgrest-typegen/src/generation/constants.ts @@ -0,0 +1,11 @@ +/** + * Type-generation constants ported verbatim from + * `postgres-meta/src/server/constants.ts`. Only the typegen-relevant values + * live here; server/runtime constants stay in postgres-meta. + * + * Consumed by the TypeScript generator's function-argument handling. + */ + +// json/jsonb/text types +export const VALID_UNNAMED_FUNCTION_ARG_TYPES = new Set([114, 3802, 25]); +export const VALID_FUNCTION_ARGS_MODE = new Set(["in", "inout", "variadic"]); diff --git a/packages/postgrest-typegen/src/generation/go.ts b/packages/postgrest-typegen/src/generation/go.ts new file mode 100644 index 000000000..fd02289d8 --- /dev/null +++ b/packages/postgrest-typegen/src/generation/go.ts @@ -0,0 +1,356 @@ +import type { + GeneratorMetadata, + PostgresColumn, + PostgresMaterializedView, + PostgresSchema, + PostgresTable, + PostgresType, + PostgresView, +} from "../types.ts"; + +type Operation = "Select" | "Insert" | "Update"; + +/** + * Emits tables/views/materialized views in `GeneratorMetadata` order. Pass + * input pre-sorted with `sortGeneratorMetadata` for deterministic output. + */ +export const generateGo = ({ + schemas, + tables, + views, + materializedViews, + columns, + types, +}: GeneratorMetadata): string => { + const columnsByTableId = columns.reduce( + (acc, curr) => { + acc[curr.table_id] ??= []; + acc[curr.table_id].push(curr); + return acc; + }, + {} as Record, + ); + + const compositeTypes = types.filter((type) => type.attributes.length > 0); + + let output = ` +package database + +${tables + .filter((table) => schemas.some((schema) => schema.name === table.schema)) + .flatMap((table) => + generateTableStructsForOperations( + schemas.find((schema) => schema.name === table.schema)!, + table, + columnsByTableId[table.id], + types, + ["Select", "Insert", "Update"], + ), + ) + .join("\n\n")} + +${views + .filter((view) => schemas.some((schema) => schema.name === view.schema)) + .flatMap((view) => + generateTableStructsForOperations( + schemas.find((schema) => schema.name === view.schema)!, + view, + columnsByTableId[view.id], + types, + ["Select"], + ), + ) + .join("\n\n")} + +${materializedViews + .filter((materializedView) => + schemas.some((schema) => schema.name === materializedView.schema), + ) + .flatMap((materializedView) => + generateTableStructsForOperations( + schemas.find((schema) => schema.name === materializedView.schema)!, + materializedView, + columnsByTableId[materializedView.id], + types, + ["Select"], + ), + ) + .join("\n\n")} + +${compositeTypes + .filter((compositeType) => + schemas.some((schema) => schema.name === compositeType.schema), + ) + .map((compositeType) => + generateCompositeTypeStruct( + schemas.find((schema) => schema.name === compositeType.schema)!, + compositeType, + types, + ), + ) + .join("\n\n")} +`.trim(); + + return output; +}; + +/** + * Converts a Postgres name to PascalCase. + * + * @example + * ```ts + * formatForGoTypeName('pokedex') // Pokedex + * formatForGoTypeName('pokemon_center') // PokemonCenter + * formatForGoTypeName('victory-road') // VictoryRoad + * formatForGoTypeName('pokemon league') // PokemonLeague + * ``` + */ +function formatForGoTypeName(name: string): string { + return name + .split(/[^a-zA-Z0-9]/) + .map((word) => { + if (word) { + return `${word[0].toUpperCase()}${word.slice(1)}`; + } else { + return ""; + } + }) + .join(""); +} + +function generateTableStruct( + schema: PostgresSchema, + table: PostgresTable | PostgresView | PostgresMaterializedView, + columns: PostgresColumn[] | undefined, + types: PostgresType[], + operation: Operation, +): string { + // Storing columns as a tuple of [formattedName, type, name] rather than creating the string + // representation of the line allows us to pre-format the entries. Go formats + // struct fields to be aligned, e.g.: + // ```go + // type Pokemon struct { + // id int `json:"id"` + // name string `json:"name"` + // } + const columnEntries: [string, string, string][] = + columns?.map((column) => { + let nullable: boolean; + if (operation === "Insert") { + nullable = + column.is_nullable || + column.is_identity || + column.is_generated || + !!column.default_value; + } else if (operation === "Update") { + nullable = true; + } else { + nullable = column.is_nullable; + } + return [ + formatForGoTypeName(column.name), + pgTypeToGoType(column.format, nullable, types), + column.name, + ]; + }) ?? []; + + const [maxFormattedNameLength, maxTypeLength] = columnEntries.reduce( + ([maxFormattedName, maxType], [formattedName, type]) => { + return [ + Math.max(maxFormattedName, formattedName.length), + Math.max(maxType, type.length), + ]; + }, + [0, 0], + ); + + // Pad the formatted name and type to align the struct fields, then join + // create the final string representation of the struct fields. + const formattedColumnEntries = columnEntries.map( + ([formattedName, type, name]) => { + return ` ${formattedName.padEnd(maxFormattedNameLength)} ${type.padEnd( + maxTypeLength, + )} \`json:"${name}"\``; + }, + ); + + return ` +type ${formatForGoTypeName(schema.name)}${formatForGoTypeName(table.name)}${operation} struct { +${formattedColumnEntries.join("\n")} +} +`.trim(); +} + +function generateTableStructsForOperations( + schema: PostgresSchema, + table: PostgresTable | PostgresView | PostgresMaterializedView, + columns: PostgresColumn[] | undefined, + types: PostgresType[], + operations: Operation[], +): string[] { + return operations.map((operation) => + generateTableStruct(schema, table, columns, types, operation), + ); +} + +function generateCompositeTypeStruct( + schema: PostgresSchema, + type: PostgresType, + types: PostgresType[], +): string { + // Use the type_id of the attributes to find the types of the attributes + const typeWithRetrievedAttributes = { + ...type, + attributes: type.attributes.map((attribute) => { + const type = types.find((type) => type.id === attribute.type_id); + return { + ...attribute, + type, + }; + }), + }; + const attributeEntries: [string, string, string][] = + typeWithRetrievedAttributes.attributes.map((attribute) => [ + formatForGoTypeName(attribute.name), + pgTypeToGoType(attribute.type!.format, false), + attribute.name, + ]); + + const [maxFormattedNameLength, maxTypeLength] = attributeEntries.reduce( + ([maxFormattedName, maxType], [formattedName, type]) => { + return [ + Math.max(maxFormattedName, formattedName.length), + Math.max(maxType, type.length), + ]; + }, + [0, 0], + ); + + // Pad the formatted name and type to align the struct fields, then join + // create the final string representation of the struct fields. + const formattedAttributeEntries = attributeEntries.map( + ([formattedName, type, name]) => { + return ` ${formattedName.padEnd(maxFormattedNameLength)} ${type.padEnd( + maxTypeLength, + )} \`json:"${name}"\``; + }, + ); + + return ` +type ${formatForGoTypeName(schema.name)}${formatForGoTypeName(type.name)} struct { +${formattedAttributeEntries.join("\n")} +} +`.trim(); +} + +// Note: the type map uses `interface{ } `, not `any`, to remain compatible with +// older versions of Go. +const GO_TYPE_MAP = { + // Bool + bool: "bool", + + // Numbers + int2: "int16", + int4: "int32", + int8: "int64", + float4: "float32", + float8: "float64", + numeric: "float64", + + // Strings + bytea: "[]byte", + bpchar: "string", + varchar: "string", + date: "string", + text: "string", + citext: "string", + time: "string", + timetz: "string", + timestamp: "string", + timestamptz: "string", + interval: "string", + uuid: "string", + vector: "string", + + // JSON + json: "interface{}", + jsonb: "interface{}", + + // Range + int4range: "string", + int4multirange: "string", + int8range: "string", + int8multirange: "string", + numrange: "string", + nummultirange: "string", + tsrange: "string", + tsmultirange: "string", + tstzrange: "string", + tstzmultirange: "string", + daterange: "string", + datemultirange: "string", + + // Misc + void: "interface{}", + record: "map[string]interface{}", +} as const; + +type GoType = (typeof GO_TYPE_MAP)[keyof typeof GO_TYPE_MAP]; + +const GO_NULLABLE_TYPE_MAP: Record = { + string: "*string", + bool: "*bool", + int16: "*int16", + int32: "*int32", + int64: "*int64", + float32: "*float32", + float64: "*float64", + "[]byte": "[]byte", + "interface{}": "interface{}", + "map[string]interface{}": "map[string]interface{}", +}; + +function pgTypeToGoType( + pgType: string, + nullable: boolean, + types: PostgresType[] = [], +): string { + let goType: GoType | undefined = undefined; + if (pgType in GO_TYPE_MAP) { + goType = GO_TYPE_MAP[pgType as keyof typeof GO_TYPE_MAP]; + } + + // Enums + const enumType = types.find( + (type) => type.name === pgType && type.enums.length > 0, + ); + if (enumType) { + goType = "string"; + } + + if (goType) { + if (nullable) { + return GO_NULLABLE_TYPE_MAP[goType]; + } + return goType; + } + + // Composite types + const compositeType = types.find( + (type) => type.name === pgType && type.attributes.length > 0, + ); + if (compositeType) { + // TODO: generate composite types + // return formatForGoTypeName(pgType) + return "map[string]interface{}"; + } + + // Arrays + if (pgType.startsWith("_")) { + const innerType = pgTypeToGoType(pgType.slice(1), nullable, types); + return `[]${innerType} `; + } + + // Fallback + return "interface{}"; +} diff --git a/packages/postgrest-typegen/src/generation/index.ts b/packages/postgrest-typegen/src/generation/index.ts new file mode 100644 index 000000000..c34a65fec --- /dev/null +++ b/packages/postgrest-typegen/src/generation/index.ts @@ -0,0 +1,13 @@ +export { sortGeneratorMetadata } from "../sort.ts"; +export { generateGo } from "./go.ts"; +export { generatePython } from "./python.ts"; +export { + generateTypescript, + type GenerateTypescriptOptions, + pgTypeToTsType, +} from "./typescript.ts"; +export { + type AccessControl, + generateSwift, + type GenerateSwiftOptions, +} from "./swift.ts"; diff --git a/packages/postgrest-typegen/src/generation/python.ts b/packages/postgrest-typegen/src/generation/python.ts new file mode 100644 index 000000000..8114e6317 --- /dev/null +++ b/packages/postgrest-typegen/src/generation/python.ts @@ -0,0 +1,461 @@ +import type { + GeneratorMetadata, + PostgresColumn, + PostgresMaterializedView, + PostgresSchema, + PostgresTable, + PostgresType, + PostgresView, +} from "../types.ts"; + +/** + * Emits tables/views/materialized views in `GeneratorMetadata` order. Pass + * input pre-sorted with `sortGeneratorMetadata` for deterministic output. + */ +export const generatePython = ({ + schemas, + tables, + views, + materializedViews, + columns, + types, +}: GeneratorMetadata): string => { + const ctx = new PythonContext(types, columns, schemas); + // Used for efficient lookup of types by schema name + const schemasNames = new Set(schemas.map((schema) => schema.name)); + const py_tables = tables.flatMap((table) => { + const py_class_and_methods = ctx.tableToClass(table); + return py_class_and_methods; + }); + const composite_types = types + // We always include system schemas, so we need to filter out types that are not in the included schemas + .filter( + (type) => type.attributes.length > 0 && schemasNames.has(type.schema), + ) + .map((type) => ctx.typeToClass(type)); + const py_views = views.map((view) => ctx.viewToClass(view)); + const py_matviews = materializedViews.map((matview) => + ctx.matViewToClass(matview), + ); + + let output = ` +from __future__ import annotations + +import datetime +import uuid +from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, +) + +from pydantic import BaseModel, Field, Json + +${concatLines(Object.values(ctx.user_enums))} + +${concatLines(py_tables)} + +${concatLines(py_views)} + +${concatLines(py_matviews)} + +${concatLines(composite_types)} + +`.trim(); + + return output; +}; + +interface Serializable { + serialize(): string; +} + +class PythonContext { + types: { [k: string]: PostgresType }; + user_enums: { [k: string]: PythonEnum }; + columns: Record; + schemas: { [k: string]: PostgresSchema }; + + constructor( + types: PostgresType[], + columns: PostgresColumn[], + schemas: PostgresSchema[], + ) { + this.schemas = Object.fromEntries( + schemas.map((schema) => [schema.name, schema]), + ); + this.types = Object.fromEntries(types.map((type) => [type.name, type])); + this.columns = columns.reduce( + (acc, curr) => { + acc[curr.table_id] ??= []; + acc[curr.table_id].push(curr); + return acc; + }, + {} as Record, + ); + this.user_enums = Object.fromEntries( + types + .filter((type) => type.enums.length > 0) + .map((type) => [type.name, new PythonEnum(type)]), + ); + } + + resolveTypeName(name: string): string { + if (name in this.user_enums) { + return this.user_enums[name].name; + } + if (name in PY_TYPE_MAP) { + return PY_TYPE_MAP[name]; + } + if (name in this.types) { + const type = this.types[name]; + const schema = type!.schema; + return `${formatForPyClassName(schema)}${formatForPyClassName(type.name)}`; + } + return "Any"; + } + + parsePgType(pg_type: string): PythonType { + if (pg_type.startsWith("_")) { + const inner_str = pg_type.slice(1); + const inner = this.parsePgType(inner_str); + return new PythonListType(inner); + } else { + const type_name = this.resolveTypeName(pg_type); + return new PythonSimpleType(type_name); + } + } + + typeToClass(type: PostgresType): PythonBaseModel { + const types = Object.values(this.types); + const attributes = type.attributes.map((attribute) => { + const type = types.find((type) => type.id === attribute.type_id); + return { + ...attribute, + type, + }; + }); + const attributeEntries: PythonBaseModelAttr[] = attributes.map( + (attribute) => { + const type = this.parsePgType(attribute.type!.name); + return new PythonBaseModelAttr(attribute.name, type, false); + }, + ); + + const schema = this.schemas[type.schema]; + return new PythonBaseModel(type.name, schema, attributeEntries); + } + + columnsToClassAttrs(table_id: number): PythonBaseModelAttr[] { + const attrs = this.columns[table_id] ?? []; + return attrs.map((col) => { + const type = this.parsePgType(col.format); + return new PythonBaseModelAttr(col.name, type, col.is_nullable); + }); + } + + columnsToDictAttrs( + table_id: number, + not_required: boolean, + ): PythonTypedDictAttr[] { + const attrs = this.columns[table_id] ?? []; + return attrs.map((col) => { + const type = this.parsePgType(col.format); + return new PythonTypedDictAttr( + col.name, + type, + col.is_nullable, + not_required || + col.is_nullable || + col.is_identity || + col.default_value !== null, + ); + }); + } + + tableToClass( + table: PostgresTable, + ): [PythonBaseModel, PythonTypedDict, PythonTypedDict] { + const schema = this.schemas[table.schema]; + const select = new PythonBaseModel( + table.name, + schema, + this.columnsToClassAttrs(table.id), + ); + const insert = new PythonTypedDict( + table.name, + "Insert", + schema, + this.columnsToDictAttrs(table.id, false), + ); + const update = new PythonTypedDict( + table.name, + "Update", + schema, + this.columnsToDictAttrs(table.id, true), + ); + return [select, insert, update]; + } + + viewToClass(view: PostgresView): PythonBaseModel { + const attributes = this.columnsToClassAttrs(view.id); + return new PythonBaseModel( + view.name, + this.schemas[view.schema], + attributes, + ); + } + + matViewToClass(matview: PostgresMaterializedView): PythonBaseModel { + const attributes = this.columnsToClassAttrs(matview.id); + return new PythonBaseModel( + matview.name, + this.schemas[matview.schema], + attributes, + ); + } +} + +class PythonEnum implements Serializable { + name: string; + variants: string[]; + constructor(type: PostgresType) { + this.name = `${formatForPyClassName(type.schema)}${formatForPyClassName(type.name)}`; + this.variants = type.enums; + } + serialize(): string { + const variants = this.variants.map((item) => `"${item}"`).join(", "); + return `${this.name}: TypeAlias = Literal[${variants}]`; + } +} + +type PythonType = PythonListType | PythonSimpleType; + +class PythonSimpleType implements Serializable { + name: string; + constructor(name: string) { + this.name = name; + } + serialize(): string { + return this.name; + } +} + +class PythonListType implements Serializable { + inner: PythonType; + constructor(inner: PythonType) { + this.inner = inner; + } + serialize(): string { + return `List[${this.inner.serialize()}]`; + } +} + +class PythonBaseModelAttr implements Serializable { + name: string; + pg_name: string; + py_type: PythonType; + nullable: boolean; + + constructor(name: string, py_type: PythonType, nullable: boolean) { + this.name = formatForPyAttributeName(name); + this.pg_name = name; + this.py_type = py_type; + this.nullable = nullable; + } + + serialize(): string { + const py_type = this.nullable + ? `Optional[${this.py_type.serialize()}]` + : this.py_type.serialize(); + return ` ${this.name}: ${py_type} = Field(alias="${this.pg_name}")`; + } +} + +class PythonBaseModel implements Serializable { + name: string; + table_name: string; + schema: PostgresSchema; + class_attributes: PythonBaseModelAttr[]; + + constructor( + name: string, + schema: PostgresSchema, + class_attributes: PythonBaseModelAttr[], + ) { + this.schema = schema; + this.class_attributes = class_attributes; + this.table_name = name; + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; + } + serialize(): string { + const attributes = + this.class_attributes.length > 0 + ? this.class_attributes.map((attr) => attr.serialize()).join("\n") + : " pass"; + return `class ${this.name}(BaseModel):\n${attributes}`; + } +} + +class PythonTypedDictAttr implements Serializable { + name: string; + pg_name: string; + py_type: PythonType; + nullable: boolean; + not_required: boolean; + + constructor( + name: string, + py_type: PythonType, + nullable: boolean, + required: boolean, + ) { + this.name = formatForPyAttributeName(name); + this.pg_name = name; + this.py_type = py_type; + this.nullable = nullable; + this.not_required = required; + } + + serialize(): string { + const py_type = this.nullable + ? `Optional[${this.py_type.serialize()}]` + : this.py_type.serialize(); + const annotation = `Annotated[${py_type}, Field(alias="${this.pg_name}")]`; + const rhs = this.not_required ? `NotRequired[${annotation}]` : annotation; + return ` ${this.name}: ${rhs}`; + } +} + +class PythonTypedDict implements Serializable { + name: string; + table_name: string; + parent_class: string; + schema: PostgresSchema; + dict_attributes: PythonTypedDictAttr[]; + operation: "Insert" | "Update"; + + constructor( + name: string, + operation: "Insert" | "Update", + schema: PostgresSchema, + dict_attributes: PythonTypedDictAttr[], + parent_class: string = "BaseModel", + ) { + this.schema = schema; + this.dict_attributes = dict_attributes; + this.table_name = name; + this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}`; + this.parent_class = parent_class; + this.operation = operation; + } + serialize(): string { + const attributes = + this.dict_attributes.length > 0 + ? this.dict_attributes.map((attr) => attr.serialize()).join("\n") + : " pass"; + return `class ${this.name}${this.operation}(TypedDict):\n${attributes}`; + } +} + +function concatLines(items: Serializable[]): string { + return items.map((item) => item.serialize()).join("\n\n"); +} + +const PY_TYPE_MAP: Record = { + // Bool + bool: "bool", + + // Numbers + int2: "int", + int4: "int", + int8: "int", + float4: "float", + float8: "float", + numeric: "float", + + // Strings + bytea: "bytes", + bpchar: "str", + varchar: "str", + string: "str", + date: "datetime.date", + text: "str", + citext: "str", + time: "datetime.time", + timetz: "datetime.time", + timestamp: "datetime.datetime", + timestamptz: "datetime.datetime", + uuid: "uuid.UUID", + vector: "list[Any]", + interval: "str", + + // JSON + json: "Json[Any]", + jsonb: "Json[Any]", + + // Range types (can be adjusted to more complex types if needed) + int4range: "str", + int4multirange: "str", + int8range: "str", + int8multirange: "str", + numrange: "str", + nummultirange: "str", + tsrange: "str", + tsmultirange: "str", + tstzrange: "str", + tstzmultirange: "str", + daterange: "str", + datemultirange: "str", + + // Miscellaneous types + void: "None", + record: "dict[str, Any]", +} as const; + +/** + * Converts a Postgres name to PascalCase. + * + * @example + * ```ts + * formatForPyTypeName('pokedex') // Pokedex + * formatForPyTypeName('pokemon_center') // PokemonCenter + * formatForPyTypeName('victory-road') // VictoryRoad + * formatForPyTypeName('pokemon league') // PokemonLeague + * ``` + */ + +function formatForPyClassName(name: string): string { + return name + .split(/[^a-zA-Z0-9]/) + .map((word) => { + if (word) { + return `${word[0].toUpperCase()}${word.slice(1)}`; + } else { + return ""; + } + }) + .join(""); +} +/** + * Converts a Postgres name to snake_case. + * + * @example + * ```ts + * formatForPyTypeName('Pokedex') // pokedex + * formatForPyTypeName('PokemonCenter') // pokemon_enter + * formatForPyTypeName('victory-road') // victory_road + * formatForPyTypeName('pokemon league') // pokemon_league + * ``` + */ +function formatForPyAttributeName(name: string): string { + return name + .split(/[^a-zA-Z0-9]+/) // Split on non-alphanumeric characters (like spaces, dashes, etc.) + .map((word) => word.toLowerCase()) // Convert each word to lowercase + .join("_"); // Join with underscores +} diff --git a/packages/postgrest-typegen/src/generation/swift.ts b/packages/postgrest-typegen/src/generation/swift.ts new file mode 100644 index 000000000..5528418db --- /dev/null +++ b/packages/postgrest-typegen/src/generation/swift.ts @@ -0,0 +1,481 @@ +import type { + GeneratorMetadata, + PostgresColumn, + PostgresForeignTable, + PostgresMaterializedView, + PostgresTable, + PostgresType, + PostgresView, +} from "../types.ts"; + +type Operation = "Select" | "Insert" | "Update"; +export type AccessControl = "internal" | "public" | "private" | "package"; + +export interface GenerateSwiftOptions { + /** Swift access-control level applied to generated declarations. Default `'internal'`. */ + accessControl?: AccessControl; +} + +type SwiftGeneratorOptions = { + accessControl: AccessControl; +}; + +type SwiftEnumCase = { + formattedName: string; + rawValue: string; +}; + +type SwiftEnum = { + formattedEnumName: string; + protocolConformances: string[]; + cases: SwiftEnumCase[]; +}; + +type SwiftAttribute = { + formattedAttributeName: string; + formattedType: string; + rawName: string; + isIdentity: boolean; +}; + +type SwiftStruct = { + formattedStructName: string; + protocolConformances: string[]; + attributes: SwiftAttribute[]; + codingKeysEnum: SwiftEnum | undefined; +}; + +function formatForSwiftSchemaName(schema: string): string { + return `${formatForSwiftTypeName(schema)}Schema`; +} + +function pgEnumToSwiftEnum(pgEnum: PostgresType): SwiftEnum { + return { + formattedEnumName: formatForSwiftTypeName(pgEnum.name), + protocolConformances: ["String", "Codable", "Hashable", "Sendable"], + cases: pgEnum.enums.map((case_) => { + return { + formattedName: formatForSwiftPropertyName(case_), + rawValue: case_, + }; + }), + }; +} + +function pgTypeToSwiftStruct( + table: + | PostgresTable + | PostgresForeignTable + | PostgresView + | PostgresMaterializedView, + columns: PostgresColumn[] | undefined, + operation: Operation, + { + types, + views, + tables, + }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] }, +): SwiftStruct { + const columnEntries: SwiftAttribute[] = + columns?.map((column) => { + let nullable: boolean; + + if (operation === "Insert") { + nullable = + column.is_nullable || + column.is_identity || + column.is_generated || + !!column.default_value; + } else if (operation === "Update") { + nullable = true; + } else { + nullable = column.is_nullable; + } + + return { + rawName: column.name, + formattedAttributeName: formatForSwiftPropertyName(column.name), + formattedType: pgTypeToSwiftType(column.format, nullable, { + types, + views, + tables, + }), + isIdentity: column.is_identity, + }; + }) ?? []; + + return { + formattedStructName: `${formatForSwiftTypeName(table.name)}${operation}`, + attributes: columnEntries, + protocolConformances: ["Codable", "Hashable", "Sendable"], + codingKeysEnum: generateCodingKeysEnumFromAttributes(columnEntries), + }; +} + +function generateCodingKeysEnumFromAttributes( + attributes: SwiftAttribute[], +): SwiftEnum | undefined { + return attributes.length > 0 + ? { + formattedEnumName: "CodingKeys", + protocolConformances: ["String", "CodingKey"], + cases: attributes.map((attribute) => { + return { + formattedName: attribute.formattedAttributeName, + rawValue: attribute.rawName, + }; + }), + } + : undefined; +} + +function pgCompositeTypeToSwiftStruct( + type: PostgresType, + { + types, + views, + tables, + }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] }, +): SwiftStruct { + const typeWithRetrievedAttributes = { + ...type, + attributes: type.attributes.map((attribute) => { + const type = types.find((type) => type.id === attribute.type_id); + return { + ...attribute, + type, + }; + }), + }; + + const attributeEntries: SwiftAttribute[] = + typeWithRetrievedAttributes.attributes.map((attribute) => { + return { + formattedAttributeName: formatForSwiftTypeName(attribute.name), + formattedType: pgTypeToSwiftType(attribute.type!.format, false, { + types, + views, + tables, + }), + rawName: attribute.name, + isIdentity: false, + }; + }); + + return { + formattedStructName: formatForSwiftTypeName(type.name), + attributes: attributeEntries, + protocolConformances: ["Codable", "Hashable", "Sendable"], + codingKeysEnum: generateCodingKeysEnumFromAttributes(attributeEntries), + }; +} + +function generateProtocolConformances(protocols: string[]): string { + return protocols.length === 0 ? "" : `: ${protocols.join(", ")}`; +} + +function generateEnum( + enum_: SwiftEnum, + { accessControl, level }: SwiftGeneratorOptions & { level: number }, +): string[] { + return [ + `${ident(level)}${accessControl} enum ${enum_.formattedEnumName}${generateProtocolConformances( + enum_.protocolConformances, + )} {`, + ...enum_.cases.map( + (case_) => + `${ident(level + 1)}case ${case_.formattedName} = "${case_.rawValue}"`, + ), + `${ident(level)}}`, + ]; +} + +function generateStruct( + struct: SwiftStruct, + { accessControl, level }: SwiftGeneratorOptions & { level: number }, +): string[] { + const identity = struct.attributes.find((column) => column.isIdentity); + + let protocolConformances = struct.protocolConformances; + if (identity) { + protocolConformances.push("Identifiable"); + } + + let output = [ + `${ident(level)}${accessControl} struct ${struct.formattedStructName}${generateProtocolConformances( + struct.protocolConformances, + )} {`, + ]; + + if (identity && identity.formattedAttributeName !== "id") { + output.push( + `${ident(level + 1)}${accessControl} var id: ${identity.formattedType} { ${identity.formattedAttributeName} }`, + ); + } + + output.push( + ...struct.attributes.map( + (attribute) => + `${ident(level + 1)}${accessControl} let ${attribute.formattedAttributeName}: ${attribute.formattedType}`, + ), + ); + + if (struct.codingKeysEnum) { + output.push( + ...generateEnum(struct.codingKeysEnum, { + accessControl, + level: level + 1, + }), + ); + } + + output.push(`${ident(level)}}`); + + return output; +} + +/** + * Emits tables/views/materialized views in `GeneratorMetadata` order. Pass + * input pre-sorted with `sortGeneratorMetadata` for deterministic output. + */ +export const generateSwift = ( + metadata: GeneratorMetadata, + opts: GenerateSwiftOptions = {}, +): string => { + const { + schemas, + tables, + foreignTables, + views, + materializedViews, + columns, + types, + } = metadata; + const { accessControl = "internal" } = opts; + const columnsByTableId = Object.fromEntries( + [...tables, ...foreignTables, ...views, ...materializedViews].map((t) => [ + t.id, + [], + ]), + ); + + columns + .filter((c) => c.table_id in columnsByTableId) + .forEach((c) => columnsByTableId[c.table_id].push(c)); + + let output = [ + "import Foundation", + "import Supabase", + "", + ...schemas.flatMap((schema) => { + const schemaTables = [...tables, ...foreignTables] + .filter((table) => table.schema === schema.name) + .sort(({ name: a }, { name: b }) => a.localeCompare(b)); + + const schemaViews = [...views, ...materializedViews] + .filter((table) => table.schema === schema.name) + .sort(({ name: a }, { name: b }) => a.localeCompare(b)); + + const schemaEnums = types.filter( + (type) => type.schema === schema.name && type.enums.length > 0, + ); + + const schemaCompositeTypes = types.filter( + (type) => type.schema === schema.name && type.attributes.length > 0, + ); + + return [ + `${accessControl} enum ${formatForSwiftSchemaName(schema.name)} {`, + ...schemaEnums.flatMap((enum_) => + generateEnum(pgEnumToSwiftEnum(enum_), { accessControl, level: 1 }), + ), + ...schemaTables.flatMap((table) => + (["Select", "Insert", "Update"] as Operation[]) + .map((operation) => + pgTypeToSwiftStruct( + table, + columnsByTableId[table.id], + operation, + { + types, + views, + tables, + }, + ), + ) + .flatMap((struct) => + generateStruct(struct, { accessControl, level: 1 }), + ), + ), + ...schemaViews.flatMap((view) => + generateStruct( + pgTypeToSwiftStruct(view, columnsByTableId[view.id], "Select", { + types, + views, + tables, + }), + { accessControl, level: 1 }, + ), + ), + ...schemaCompositeTypes.flatMap((type) => + generateStruct( + pgCompositeTypeToSwiftStruct(type, { types, views, tables }), + { + accessControl, + level: 1, + }, + ), + ), + "}", + ]; + }), + ]; + + return output.join("\n"); +}; + +// TODO: Make this more robust. Currently doesn't handle range types - returns them as string. +const pgTypeToSwiftType = ( + pgType: string, + nullable: boolean, + { + types, + views, + tables, + }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] }, +): string => { + let swiftType: string; + + if (pgType === "bool") { + swiftType = "Bool"; + } else if (pgType === "int2") { + swiftType = "Int16"; + } else if (pgType === "int4") { + swiftType = "Int32"; + } else if (pgType === "int8") { + swiftType = "Int64"; + } else if (pgType === "float4") { + swiftType = "Float"; + } else if (pgType === "float8") { + swiftType = "Double"; + } else if (["numeric", "decimal"].includes(pgType)) { + swiftType = "Decimal"; + } else if (pgType === "uuid") { + swiftType = "UUID"; + } else if ( + [ + "bytea", + "bpchar", + "varchar", + "date", + "text", + "citext", + "time", + "timetz", + "timestamp", + "timestamptz", + "interval", + "vector", + ].includes(pgType) + ) { + swiftType = "String"; + } else if (["json", "jsonb"].includes(pgType)) { + swiftType = "AnyJSON"; + } else if (pgType === "void") { + swiftType = "Void"; + } else if (pgType === "record") { + swiftType = "JSONObject"; + } else if (pgType.startsWith("_")) { + swiftType = `[${pgTypeToSwiftType(pgType.substring(1), false, { types, views, tables })}]`; + } else { + const enumType = types.find( + (type) => type.name === pgType && type.enums.length > 0, + ); + + const compositeTypes = [...types, ...views, ...tables].find( + (type) => type.name === pgType, + ); + + if (enumType) { + swiftType = `${formatForSwiftTypeName(enumType.name)}`; + } else if (compositeTypes) { + // Append a `Select` to the composite type, as that is how is named in the generated struct. + swiftType = `${formatForSwiftTypeName(compositeTypes.name)}Select`; + } else { + swiftType = "AnyJSON"; + } + } + + return `${swiftType}${nullable ? "?" : ""}`; +}; + +function ident( + level: number, + options: { width: number } = { width: 2 }, +): string { + return " ".repeat(level * options.width); +} + +/** + * Converts a Postgres name to PascalCase. + * + * @example + * ```ts + * formatForSwiftTypeName('pokedex') // Pokedex + * formatForSwiftTypeName('pokemon_center') // PokemonCenter + * formatForSwiftTypeName('victory-road') // VictoryRoad + * formatForSwiftTypeName('pokemon league') // PokemonLeague + * formatForSwiftTypeName('_key_id_context') // _KeyIdContext + * ``` + */ +function formatForSwiftTypeName(name: string): string { + // Preserve the initial underscore if it exists + let prefix = ""; + if (name.startsWith("_")) { + prefix = "_"; + name = name.slice(1); // Remove the initial underscore for processing + } + + return ( + prefix + + name + .split(/[^a-zA-Z0-9]+/) + .map((word) => { + if (word) { + return `${word[0].toUpperCase()}${word.slice(1)}`; + } else { + return ""; + } + }) + .join("") + ); +} + +const SWIFT_KEYWORDS = ["in", "default", "case"]; + +/** + * Converts a Postgres name to pascalCase. + * + * @example + * ```ts + * formatForSwiftTypeName('pokedex') // pokedex + * formatForSwiftTypeName('pokemon_center') // pokemonCenter + * formatForSwiftTypeName('victory-road') // victoryRoad + * formatForSwiftTypeName('pokemon league') // pokemonLeague + * ``` + */ +function formatForSwiftPropertyName(name: string): string { + const propertyName = name + .split(/[^a-zA-Z0-9]/) + .map((word, index) => { + const lowerWord = word.toLowerCase(); + return index !== 0 + ? lowerWord.charAt(0).toUpperCase() + lowerWord.slice(1) + : lowerWord; + }) + .join(""); + + return SWIFT_KEYWORDS.includes(propertyName) + ? `\`${propertyName}\`` + : propertyName; +} diff --git a/packages/postgrest-typegen/src/generation/typescript.ts b/packages/postgrest-typegen/src/generation/typescript.ts new file mode 100644 index 000000000..17b8f666c --- /dev/null +++ b/packages/postgrest-typegen/src/generation/typescript.ts @@ -0,0 +1,1107 @@ +// oxlint-disable typescript/restrict-template-expressions -- this is a verbatim port of +// postgres-meta's template, which intentionally interpolates arrays/values into template +// literals (relying on Array#toString) to build the output. Preserving that is required for +// byte-parity, and the offending expressions sit inside template literals where a targeted +// disable comment would corrupt the emitted string. +import prettier from "prettier"; +import type { + GeneratorMetadata, + PostgresColumn, + PostgresFunction, + PostgresSchema, + PostgresTable, + PostgresType, + PostgresView, +} from "../types.ts"; +import { + VALID_FUNCTION_ARGS_MODE, + VALID_UNNAMED_FUNCTION_ARG_TYPES, +} from "./constants.ts"; + +export interface GenerateTypescriptOptions { + /** + * Emit `isOneToOne` on each relationship entry. Mirrors postgres-meta's + * `detect_one_to_one_relationships` query flag. Default `false`. + */ + detectOneToOneRelationships?: boolean; + /** + * When set, emit `__InternalSupabase.PostgrestVersion` so `createClient` + * can be instantiated with the right options automatically. + */ + postgrestVersion?: string; + /** + * Schema treated as the default for the generated `Tables`/`Enums`/etc. + * helper types. Replaces postgres-meta's `GENERATE_TYPES_DEFAULT_SCHEMA` + * env read. Default `'public'`. + */ + defaultSchema?: string; +} + +type TsRelationship = Pick< + GeneratorMetadata["relationships"][number], + | "foreign_key_name" + | "columns" + | "is_one_to_one" + | "referenced_relation" + | "referenced_columns" +>; + +/** + * Sorts every collection internally, so it is order-insensitive — but pass + * input pre-sorted with `sortGeneratorMetadata` to stay consistent with the + * other generators (whose output depends on `GeneratorMetadata` order). + */ +export const generateTypescript = async ( + metadata: GeneratorMetadata, + opts: GenerateTypescriptOptions = {}, +): Promise => { + const { + schemas, + tables, + foreignTables, + views, + materializedViews, + columns, + relationships, + functions, + types, + } = metadata; + const { + detectOneToOneRelationships = false, + postgrestVersion, + defaultSchema = "public", + } = opts; + // Ordering of all collections (incl. relationships, columns, args below) is + // provided by `sortGeneratorMetadata`; this generator no longer re-sorts. + const introspectionBySchema = Object.fromEntries<{ + tables: { + table: Pick; + relationships: TsRelationship[]; + }[]; + views: { + view: PostgresView; + relationships: TsRelationship[]; + }[]; + functions: { fn: PostgresFunction; inArgs: PostgresFunction["args"] }[]; + enums: PostgresType[]; + compositeTypes: PostgresType[]; + }>( + schemas.map((s) => [ + s.name, + { tables: [], views: [], functions: [], enums: [], compositeTypes: [] }, + ]), + ); + const columnsByTableId: Record = {}; + const tablesNamesByTableId: Record = {}; + const relationTypeByIds = new Map(); + // group types by id for quicker lookup + const typesById = new Map(); + const tablesLike = [ + ...tables, + ...foreignTables, + ...views, + ...materializedViews, + ]; + + for (const tableLike of tablesLike) { + columnsByTableId[tableLike.id] = []; + tablesNamesByTableId[tableLike.id] = tableLike.name; + } + for (const column of columns) { + if (column.table_id in columnsByTableId) { + columnsByTableId[column.table_id].push(column); + } + } + + for (const type of types) { + typesById.set(type.id, type); + // Save all the types that are relation types for quicker lookup + if (type.type_relation_id) { + relationTypeByIds.set(type.id, type); + } + if (type.schema in introspectionBySchema) { + if (type.enums.length > 0) { + introspectionBySchema[type.schema].enums.push(type); + } + if (type.attributes.length > 0) { + introspectionBySchema[type.schema].compositeTypes.push(type); + } + } + } + + function getRelationships( + object: { schema: string; name: string }, + relationships: GeneratorMetadata["relationships"], + ): Pick< + GeneratorMetadata["relationships"][number], + | "foreign_key_name" + | "columns" + | "is_one_to_one" + | "referenced_relation" + | "referenced_columns" + >[] { + return relationships.filter( + (relationship) => + relationship.schema === object.schema && + relationship.referenced_schema === object.schema && + relationship.relation === object.name, + ); + } + + function generateRelationshiptTsDefinition( + relationship: TsRelationship, + ): string { + return `{ + foreignKeyName: ${JSON.stringify(relationship.foreign_key_name)} + columns: ${JSON.stringify(relationship.columns)}${ + detectOneToOneRelationships + ? `\nisOneToOne: ${relationship.is_one_to_one}` + : "" + } + referencedRelation: ${JSON.stringify(relationship.referenced_relation)} + referencedColumns: ${JSON.stringify(relationship.referenced_columns)} + }`; + } + + for (const table of tables) { + if (table.schema in introspectionBySchema) { + introspectionBySchema[table.schema].tables.push({ + table, + relationships: getRelationships(table, relationships), + }); + } + } + for (const table of foreignTables) { + if (table.schema in introspectionBySchema) { + introspectionBySchema[table.schema].tables.push({ + table, + relationships: getRelationships(table, relationships), + }); + } + } + for (const view of views) { + if (view.schema in introspectionBySchema) { + introspectionBySchema[view.schema].views.push({ + view, + relationships: getRelationships(view, relationships), + }); + } + } + for (const materializedView of materializedViews) { + if (materializedView.schema in introspectionBySchema) { + introspectionBySchema[materializedView.schema].views.push({ + view: { + ...materializedView, + is_updatable: false, + }, + relationships: getRelationships(materializedView, relationships), + }); + } + } + // Helper function to get table/view name from relation id + const getTableNameFromRelationId = ( + relationId: number | null, + returnTypeId: number | null, + ): string | null => { + if (!relationId) return null; + + if (tablesNamesByTableId[relationId]) + return tablesNamesByTableId[relationId]; + // if it's a composite type we use the type name as relation name to allow sub-selecting fields of the composite type + const reltype = returnTypeId ? relationTypeByIds.get(returnTypeId) : null; + return reltype ? reltype.name : null; + }; + + for (const func of functions) { + if (func.schema in introspectionBySchema) { + // Get all input args (in, inout, variadic modes) + const inArgs = func.args.filter(({ mode }) => + VALID_FUNCTION_ARGS_MODE.has(mode), + ); + + if ( + // Case 1: Function has no parameters + inArgs.length === 0 || + // Case 2: All input args are named + !inArgs.some(({ name }) => name === "") || + // Case 3: All unnamed args have default values AND are valid types + inArgs.every((arg) => { + if (arg.name === "") { + return ( + arg.has_default && + VALID_UNNAMED_FUNCTION_ARG_TYPES.has(arg.type_id) + ); + } + return true; + }) || + // Case 4: Single unnamed parameter of valid type (json, jsonb, text) + // Exclude all functions definitions that have only one single argument unnamed argument that isn't + // a json/jsonb/text as it won't be considered by PostgREST + (inArgs.length === 1 && + inArgs[0].name === "" && + (VALID_UNNAMED_FUNCTION_ARG_TYPES.has(inArgs[0].type_id) || + // OR if the function have a single unnamed args which is another table (embeded function) + (relationTypeByIds.get(inArgs[0].type_id) && + getTableNameFromRelationId( + func.return_type_relation_id, + func.return_type_id, + )) || + // OR if the function takes a table row but doesn't qualify as embedded (for error reporting) + (relationTypeByIds.get(inArgs[0].type_id) && + !getTableNameFromRelationId( + func.return_type_relation_id, + func.return_type_id, + )))) + ) { + introspectionBySchema[func.schema].functions.push({ fn: func, inArgs }); + } + } + } + // These per-schema groups MERGE multiple metadata collections — `.tables` + // combines tables + foreign tables, `.views` combines views + materialized + // views — so the cross-collection name sort can't be expressed by the + // single-collection `sortGeneratorMetadata` pass and stays here. + for (const schema in introspectionBySchema) { + introspectionBySchema[schema].tables.sort((a, b) => + a.table.name.localeCompare(b.table.name), + ); + introspectionBySchema[schema].views.sort((a, b) => + a.view.name.localeCompare(b.view.name), + ); + introspectionBySchema[schema].functions.sort((a, b) => + a.fn.name.localeCompare(b.fn.name), + ); + introspectionBySchema[schema].enums.sort((a, b) => + a.name.localeCompare(b.name), + ); + introspectionBySchema[schema].compositeTypes.sort((a, b) => + a.name.localeCompare(b.name), + ); + } + + const getFunctionTsReturnType = ( + fn: PostgresFunction, + returnType: string, + ) => { + // Determine if this function should have SetofOptions + let setofOptionsInfo = ""; + + const returnTableName = getTableNameFromRelationId( + fn.return_type_relation_id, + fn.return_type_id, + ); + const returnsSetOfTable = + fn.is_set_returning_function && fn.return_type_relation_id !== null; + const returnsMultipleRows = fn.prorows !== null && fn.prorows > 1; + // Case 1: if the function returns a table, we need to add SetofOptions to allow selecting sub fields of the table + // Those can be used in rpc to select sub fields of a table + if (returnTableName) { + setofOptionsInfo = `SetofOptions: { + from: "*" + to: ${JSON.stringify(returnTableName)} + isOneToOne: ${Boolean(!returnsMultipleRows)} + isSetofReturn: ${fn.is_set_returning_function} + }`; + } + // Case 2: if the function has a single table argument, we need to add SetofOptions to allow selecting sub fields of the table + // and set the right "from" and "to" values to allow selecting from a table row + if (fn.args.length === 1) { + const relationType = relationTypeByIds.get(fn.args[0].type_id); + + // Only add SetofOptions for functions with table arguments (embedded functions) + // or specific functions that RETURNS table-name + if (relationType) { + const sourceTable = relationType.format; + // Case 1: Standard embedded function with proper setof detection + if (returnsSetOfTable && returnTableName) { + setofOptionsInfo = `SetofOptions: { + from: ${JSON.stringify(sourceTable)} + to: ${JSON.stringify(returnTableName)} + isOneToOne: ${Boolean(!returnsMultipleRows)} + isSetofReturn: true + }`; + } // Case 2: Handle RETURNS table-name those are always a one to one relationship + else if (returnTableName && !returnsSetOfTable) { + const targetTable = returnTableName; + setofOptionsInfo = `SetofOptions: { + from: ${JSON.stringify(sourceTable)} + to: ${JSON.stringify(targetTable)} + isOneToOne: true + isSetofReturn: false + }`; + } + } + } + + return `${returnType}${fn.is_set_returning_function && returnsMultipleRows ? "[]" : ""} + ${setofOptionsInfo ? `${setofOptionsInfo}` : ""}`; + }; + + const getFunctionReturnType = ( + schema: PostgresSchema, + fn: PostgresFunction, + ): string => { + // Case 1: `returns table`. + const tableArgs = fn.args.filter(({ mode }) => mode === "table"); + if (tableArgs.length > 0) { + const argsNameAndType = tableArgs.map(({ name, type_id }) => { + const type = typesById.get(type_id); + let tsType = "unknown"; + if (type) { + tsType = pgTypeToTsType(schema, type.name, { + types, + schemas, + tables, + views, + }); + } + return { name, type: tsType }; + }); + + return `{ + ${argsNameAndType.map(({ name, type }) => `${JSON.stringify(name)}: ${type}`)} + }`; + } + + // Case 2: returns a relation's row type. + const relation = + introspectionBySchema[schema.name]?.tables.find( + ({ table: { id } }) => id === fn.return_type_relation_id, + )?.table || + introspectionBySchema[schema.name]?.views.find( + ({ view: { id } }) => id === fn.return_type_relation_id, + )?.view; + if (relation) { + return `{ + ${columnsByTableId[relation.id] + .map((column) => + generateColumnTsDefinition( + schema, + { + name: column.name, + format: column.format, + is_nullable: column.is_nullable, + is_optional: false, + }, + { + types, + schemas, + tables, + views, + }, + ), + ) + .join(",\n")} + }`; + } + + // Case 3: returns base/array/composite/enum type. + const type = typesById.get(fn.return_type_id); + if (type) { + return pgTypeToTsType(schema, type.name, { + types, + schemas, + tables, + views, + }); + } + + return "unknown"; + }; + // Special error case for functions that take table row but don't qualify as embedded functions + const hasTableRowError = ( + fn: PostgresFunction, + inArgs: PostgresFunction["args"], + ) => { + if ( + inArgs.length === 1 && + inArgs[0].name === "" && + relationTypeByIds.get(inArgs[0].type_id) && + !getTableNameFromRelationId(fn.return_type_relation_id, fn.return_type_id) + ) { + return true; + } + return false; + }; + + // Check for generic conflict cases that need error reporting + const getConflictError = ( + schema: PostgresSchema, + fns: Array<{ fn: PostgresFunction; inArgs: PostgresFunction["args"] }>, + fn: PostgresFunction, + inArgs: PostgresFunction["args"], + ) => { + // If there is a single function definition, there is no conflict + if (fns.length <= 1) return null; + + // Generic conflict detection patterns + // Pattern 1: No-args vs default-args conflicts + if (inArgs.length === 0) { + const conflictingFns = fns.filter( + ({ fn: otherFn, inArgs: otherInArgs }) => { + if (otherFn === fn) return false; + return ( + otherInArgs.length === 1 && + otherInArgs[0].name === "" && + otherInArgs[0].has_default + ); + }, + ); + + if (conflictingFns.length > 0) { + const conflictingFn = conflictingFns[0]; + const returnTypeName = + typesById.get(conflictingFn.fn.return_type_id)?.name || "unknown"; + return `Could not choose the best candidate function between: ${schema.name}.${fn.name}(), ${schema.name}.${fn.name}( => ${returnTypeName}). Try renaming the parameters or the function itself in the database so function overloading can be resolved`; + } + } + + // Pattern 2: Same parameter name but different types (unresolvable overloads) + if (inArgs.length === 1 && inArgs[0].name !== "") { + const conflictingFns = fns.filter( + ({ fn: otherFn, inArgs: otherInArgs }) => { + if (otherFn === fn) return false; + return ( + otherInArgs.length === 1 && + otherInArgs[0].name === inArgs[0].name && + otherInArgs[0].type_id !== inArgs[0].type_id + ); + }, + ); + + if (conflictingFns.length > 0) { + const allConflictingFunctions = [{ fn, inArgs }, ...conflictingFns]; + const conflictList = allConflictingFunctions + .sort((a, b) => { + const aArgs = a.inArgs; + const bArgs = b.inArgs; + return (aArgs[0]?.type_id || 0) - (bArgs[0]?.type_id || 0); + }) + .map((f) => { + const args = f.inArgs; + return `${schema.name}.${fn.name}(${args + .map( + (a) => + `${a.name || ""} => ${typesById.get(a.type_id)?.name || "unknown"}`, + ) + .join(", ")})`; + }) + .join(", "); + + return `Could not choose the best candidate function between: ${conflictList}. Try renaming the parameters or the function itself in the database so function overloading can be resolved`; + } + } + + return null; + }; + + const getFunctionSignatures = ( + schema: PostgresSchema, + fns: Array<{ fn: PostgresFunction; inArgs: PostgresFunction["args"] }>, + ) => { + return fns + .map(({ fn, inArgs }) => { + let argsType = "never"; + let returnType = getFunctionReturnType(schema, fn); + + // Check for specific error cases + const conflictError = getConflictError(schema, fns, fn, inArgs); + if (conflictError) { + if (inArgs.length > 0) { + const argsNameAndType = inArgs.map( + ({ name, type_id, has_default }) => { + const type = typesById.get(type_id); + let tsType = "unknown"; + if (type) { + tsType = pgTypeToTsType(schema, type.name, { + types, + schemas, + tables, + views, + }); + } + return { name, type: tsType, has_default }; + }, + ); + argsType = `{ ${argsNameAndType.map( + ({ name, type, has_default }) => + `${JSON.stringify(name)}${has_default ? "?" : ""}: ${type}`, + )} }`; + } + returnType = `{ error: true } & ${JSON.stringify(conflictError)}`; + } else if (hasTableRowError(fn, inArgs)) { + // Special case for computed fields returning scalars functions + if (inArgs.length > 0) { + const argsNameAndType = inArgs.map( + ({ name, type_id, has_default }) => { + const type = typesById.get(type_id); + let tsType = "unknown"; + if (type) { + tsType = pgTypeToTsType(schema, type.name, { + types, + schemas, + tables, + views, + }); + } + return { name, type: tsType, has_default }; + }, + ); + argsType = `{ ${argsNameAndType.map( + ({ name, type, has_default }) => + `${JSON.stringify(name)}${has_default ? "?" : ""}: ${type}`, + )} }`; + } + returnType = `{ error: true } & ${JSON.stringify( + `the function ${schema.name}.${fn.name} with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache`, + )}`; + } else if (inArgs.length > 0) { + const argsNameAndType = inArgs.map( + ({ name, type_id, has_default }) => { + const type = typesById.get(type_id); + let tsType = "unknown"; + if (type) { + tsType = pgTypeToTsType(schema, type.name, { + types, + schemas, + tables, + views, + }); + } + return { name, type: tsType, has_default }; + }, + ); + argsType = `{ ${argsNameAndType.map( + ({ name, type, has_default }) => + `${JSON.stringify(name)}${has_default ? "?" : ""}: ${type}`, + )} }`; + } + + return `{ Args: ${argsType}; Returns: ${getFunctionTsReturnType(fn, returnType)} }`; + }) + .join(" |\n"); + }; + + const internal_supabase_schema = postgrestVersion + ? `// Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: '${postgrestVersion}' + }` + : ""; + + function generateNullableUnionTsType(tsType: string, isNullable: boolean) { + // Only add the null union if the type is not unknown as unknown already includes null + if (tsType === "unknown" || tsType === "any" || !isNullable) { + return tsType; + } + return `${tsType} | null`; + } + + function generateColumnTsDefinition( + schema: PostgresSchema, + column: { + name: string; + format: string; + is_nullable: boolean; + is_optional: boolean; + }, + context: { + types: PostgresType[]; + schemas: PostgresSchema[]; + tables: PostgresTable[]; + views: PostgresView[]; + }, + ) { + return `${JSON.stringify(column.name)}${column.is_optional ? "?" : ""}: ${generateNullableUnionTsType( + pgTypeToTsType(schema, column.format, context), + column.is_nullable, + )}`; + } + + let output = ` +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] + +export type Database = { + ${internal_supabase_schema} + ${schemas.map((schema) => { + const { + tables: schemaTables, + views: schemaViews, + functions: schemaFunctions, + enums: schemaEnums, + compositeTypes: schemaCompositeTypes, + } = introspectionBySchema[schema.name]; + return `${JSON.stringify(schema.name)}: { + Tables: { + ${ + schemaTables.length === 0 + ? "[_ in never]: never" + : schemaTables.map( + ({ + table, + relationships, + }) => `${JSON.stringify(table.name)}: { + Row: { + ${[ + ...columnsByTableId[table.id].map((column) => + generateColumnTsDefinition( + schema, + { + name: column.name, + format: column.format, + is_nullable: column.is_nullable, + is_optional: false, + }, + { types, schemas, tables, views }, + ), + ), + ...schemaFunctions + .filter(({ fn }) => fn.argument_types === table.name) + .map(({ fn }) => { + return `${JSON.stringify(fn.name)}: ${generateNullableUnionTsType( + getFunctionReturnType(schema, fn), + true, + )}`; + }), + ]} + } + Insert: { + ${columnsByTableId[table.id].map((column) => { + if (column.identity_generation === "ALWAYS") { + return `${JSON.stringify(column.name)}?: never`; + } + return generateColumnTsDefinition( + schema, + { + name: column.name, + format: column.format, + is_nullable: column.is_nullable, + is_optional: + column.is_nullable || + column.is_identity || + column.default_value !== null, + }, + { types, schemas, tables, views }, + ); + })} + } + Update: { + ${columnsByTableId[table.id].map((column) => { + if (column.identity_generation === "ALWAYS") { + return `${JSON.stringify(column.name)}?: never`; + } + + return generateColumnTsDefinition( + schema, + { + name: column.name, + format: column.format, + is_nullable: column.is_nullable, + is_optional: true, + }, + { types, schemas, tables, views }, + ); + })} + } + Relationships: [ + ${relationships.map(generateRelationshiptTsDefinition)} + ] + }`, + ) + } + } + Views: { + ${ + schemaViews.length === 0 + ? "[_ in never]: never" + : schemaViews.map( + ({ + view, + relationships, + }) => `${JSON.stringify(view.name)}: { + Row: { + ${[ + ...columnsByTableId[view.id].map((column) => + generateColumnTsDefinition( + schema, + { + name: column.name, + format: column.format, + is_nullable: column.is_nullable, + is_optional: false, + }, + { types, schemas, tables, views }, + ), + ), + ...schemaFunctions + .filter(({ fn }) => fn.argument_types === view.name) + .map( + ({ fn }) => + `${JSON.stringify(fn.name)}: ${generateNullableUnionTsType( + getFunctionReturnType(schema, fn), + true, + )}`, + ), + ]} + } + ${ + view.is_updatable + ? `Insert: { + ${columnsByTableId[view.id].map((column) => { + if (!column.is_updatable) { + return `${JSON.stringify(column.name)}?: never`; + } + return generateColumnTsDefinition( + schema, + { + name: column.name, + format: column.format, + is_nullable: true, + is_optional: true, + }, + { types, schemas, tables, views }, + ); + })} + } + Update: { + ${columnsByTableId[view.id].map((column) => { + if (!column.is_updatable) { + return `${JSON.stringify(column.name)}?: never`; + } + return generateColumnTsDefinition( + schema, + { + name: column.name, + format: column.format, + is_nullable: true, + is_optional: true, + }, + { types, schemas, tables, views }, + ); + })} + } + ` + : "" + }Relationships: [ + ${relationships.map(generateRelationshiptTsDefinition)} + ] + }`, + ) + } + } + Functions: { + ${(() => { + if (schemaFunctions.length === 0) { + return "[_ in never]: never"; + } + const schemaFunctionsGroupedByName = schemaFunctions.reduce( + (acc, curr) => { + acc[curr.fn.name] ??= []; + acc[curr.fn.name].push(curr); + return acc; + }, + {} as Record, + ); + for (const fnName in schemaFunctionsGroupedByName) { + schemaFunctionsGroupedByName[fnName].sort( + (a, b) => + a.fn.argument_types.localeCompare(b.fn.argument_types) || + a.fn.return_type.localeCompare(b.fn.return_type), + ); + } + + return Object.entries(schemaFunctionsGroupedByName) + .map(([fnName, fns]) => { + const functionSignatures = getFunctionSignatures(schema, fns); + return `${JSON.stringify(fnName)}:\n${functionSignatures}`; + }) + .join(",\n"); + })()} + } + Enums: { + ${ + schemaEnums.length === 0 + ? "[_ in never]: never" + : schemaEnums.map( + (enum_) => + `${JSON.stringify(enum_.name)}: ${enum_.enums + .map((variant) => JSON.stringify(variant)) + .join("|")}`, + ) + } + } + CompositeTypes: { + ${ + schemaCompositeTypes.length === 0 + ? "[_ in never]: never" + : schemaCompositeTypes.map( + ({ name, attributes }) => + `${JSON.stringify(name)}: { + ${attributes.map(({ name, type_id }) => { + const type = typesById.get(type_id); + let tsType = "unknown"; + if (type) { + tsType = `${generateNullableUnionTsType( + pgTypeToTsType(schema, type.name, { + types, + schemas, + tables, + views, + }), + true, + )}`; + } + return `${JSON.stringify(name)}: ${tsType}`; + })} + }`, + ) + } + } + }`; + })} +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never +> = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never +> = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never +> = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never +> = DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never +> = PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + ${schemas.map((schema) => { + const schemaEnums = introspectionBySchema[schema.name].enums; + return `${JSON.stringify(schema.name)}: { + Enums: { + ${schemaEnums.map( + (enum_) => + `${JSON.stringify(enum_.name)}: [${enum_.enums + .map((variant) => JSON.stringify(variant)) + .join(", ")}]`, + )} + } + }`; + })} +} as const +`; + + output = await prettier.format(output, { + parser: "typescript", + semi: false, + }); + return output; +}; + +// TODO: Make this more robust. Currently doesn't handle range types - returns them as unknown. +export const pgTypeToTsType = ( + schema: PostgresSchema, + pgType: string, + { + types, + schemas, + tables, + views, + }: { + types: PostgresType[]; + schemas: PostgresSchema[]; + tables: PostgresTable[]; + views: PostgresView[]; + }, +): string => { + if (pgType === "bool") { + return "boolean"; + } else if ( + ["int2", "int4", "int8", "float4", "float8", "numeric"].includes(pgType) + ) { + return "number"; + } else if ( + [ + "bytea", + "bpchar", + "varchar", + "date", + "text", + "citext", + "time", + "timetz", + "timestamp", + "timestamptz", + "uuid", + "vector", + "interval", + ].includes(pgType) + ) { + return "string"; + } else if (["json", "jsonb"].includes(pgType)) { + return "Json"; + } else if (pgType === "void") { + return "undefined"; + } else if (pgType === "record") { + return "Record"; + } else if (pgType.startsWith("_")) { + return `(${pgTypeToTsType(schema, pgType.substring(1), { + types, + schemas, + tables, + views, + })})[]`; + } else { + const enumTypes = types.filter( + (type) => type.name === pgType && type.enums.length > 0, + ); + if (enumTypes.length > 0) { + const enumType = + enumTypes.find((type) => type.schema === schema.name) || enumTypes[0]; + if (schemas.some(({ name }) => name === enumType.schema)) { + return `Database[${JSON.stringify(enumType.schema)}]['Enums'][${JSON.stringify( + enumType.name, + )}]`; + } + return enumType.enums.map((variant) => JSON.stringify(variant)).join("|"); + } + + const compositeTypes = types.filter( + (type) => type.name === pgType && type.attributes.length > 0, + ); + if (compositeTypes.length > 0) { + const compositeType = + compositeTypes.find((type) => type.schema === schema.name) || + compositeTypes[0]; + if (schemas.some(({ name }) => name === compositeType.schema)) { + return `Database[${JSON.stringify( + compositeType.schema, + )}]['CompositeTypes'][${JSON.stringify(compositeType.name)}]`; + } + return "unknown"; + } + + const tableRowTypes = tables.filter((table) => table.name === pgType); + if (tableRowTypes.length > 0) { + const tableRowType = + tableRowTypes.find((type) => type.schema === schema.name) || + tableRowTypes[0]; + if (schemas.some(({ name }) => name === tableRowType.schema)) { + return `Database[${JSON.stringify(tableRowType.schema)}]['Tables'][${JSON.stringify( + tableRowType.name, + )}]['Row']`; + } + return "unknown"; + } + + const viewRowTypes = views.filter((view) => view.name === pgType); + if (viewRowTypes.length > 0) { + const viewRowType = + viewRowTypes.find((type) => type.schema === schema.name) || + viewRowTypes[0]; + if (schemas.some(({ name }) => name === viewRowType.schema)) { + return `Database[${JSON.stringify(viewRowType.schema)}]['Views'][${JSON.stringify( + viewRowType.name, + )}]['Row']`; + } + return "unknown"; + } + + return "unknown"; + } +}; diff --git a/packages/postgrest-typegen/src/index.ts b/packages/postgrest-typegen/src/index.ts new file mode 100644 index 000000000..10397e76d --- /dev/null +++ b/packages/postgrest-typegen/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types.ts"; +export * from "./sort.ts"; +export * from "./introspection/index.ts"; +export * from "./generation/index.ts"; diff --git a/packages/postgrest-typegen/src/introspection/index.ts b/packages/postgrest-typegen/src/introspection/index.ts new file mode 100644 index 000000000..423c26e41 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/index.ts @@ -0,0 +1,115 @@ +import type { GeneratorMetadata } from "../types.ts"; +import { normalizeRows } from "./normalize.ts"; +import { listRelationships } from "./relationships.ts"; +import { COLUMNS_SQL } from "./sql/columns.sql.ts"; +import { FOREIGN_TABLES_SQL } from "./sql/foreign_tables.sql.ts"; +import { FUNCTIONS_SQL } from "./sql/functions.sql.ts"; +import { DEFAULT_SYSTEM_SCHEMAS, filterByList } from "./sql/helpers.ts"; +import { MATERIALIZED_VIEWS_SQL } from "./sql/materialized_views.sql.ts"; +import { SCHEMAS_SQL } from "./sql/schemas.sql.ts"; +import { TABLES_SQL } from "./sql/table.sql.ts"; +import { TYPES_SQL } from "./sql/types.sql.ts"; +import { VIEWS_SQL } from "./sql/views.sql.ts"; + +/** + * Minimal structural database interface. `pg.Pool` / `pg.Client` satisfy it, + * as do Bun and other drivers. postgres-meta injects its forked-pg pool here, + * keeping its own error handling on its side of the boundary. Errors throw; + * callers adapt. + */ +export interface Queryable { + query(sql: string): Promise<{ rows: any[] }>; +} + +export interface IntrospectOptions { + includedSchemas?: string[]; + excludedSchemas?: string[]; +} + +/** + * Introspect a database into the `GeneratorMetadata` contract. + * + * Port of `getGeneratorMetadata` from `postgres-meta/src/lib/generators.ts`. + * Instead of going through the `PostgresMeta*` manager classes, it calls the + * ported SQL builders directly with the single option combination the + * generator path uses, then applies the int8 `normalize` coercion so output is + * identical regardless of driver. Connection lifecycle is the caller's + * responsibility — unlike `getGeneratorMetadata`, this does not end the pool. + */ +export async function introspect( + db: Queryable, + opts: IntrospectOptions = {}, +): Promise { + const includedSchemas = opts.includedSchemas ?? []; + const excludedSchemas = opts.excludedSchemas ?? []; + // Managers receive `undefined` (not an empty array) when no filter is set. + const included = includedSchemas.length > 0 ? includedSchemas : undefined; + const excluded = excludedSchemas.length > 0 ? excludedSchemas : undefined; + + // Most queries exclude the system schemas by default; foreign tables and + // materialized views do not (matching the respective manager `list()` calls). + const systemExcludingFilter = filterByList( + included, + excluded, + DEFAULT_SYSTEM_SCHEMAS, + ); + const plainFilter = filterByList(included, excluded); + + const queryRows = async (sql: string) => + normalizeRows((await db.query(sql)).rows); + + const schemas = await queryRows( + SCHEMAS_SQL({ + includeSystemSchemas: false, + nameFilter: systemExcludingFilter, + }), + ); + const tables = await queryRows( + TABLES_SQL({ schemaFilter: systemExcludingFilter }), + ); + const foreignTables = await queryRows( + FOREIGN_TABLES_SQL({ schemaFilter: plainFilter }), + ); + const views = await queryRows( + VIEWS_SQL({ schemaFilter: systemExcludingFilter }), + ); + const materializedViews = await queryRows( + MATERIALIZED_VIEWS_SQL({ schemaFilter: plainFilter }), + ); + const columns = await queryRows( + COLUMNS_SQL({ schemaFilter: systemExcludingFilter }), + ); + const relationships = await listRelationships(db, { + includedSchemas: included, + excludedSchemas: excluded, + }); + const functions = await queryRows( + FUNCTIONS_SQL({ schemaFilter: systemExcludingFilter }), + ); + // types: includeSystemSchemas true (no schema filter), table + array types included. + const types = await queryRows( + TYPES_SQL({ + schemaFilter: "", + includeTableTypes: true, + includeArrayTypes: true, + }), + ); + + return { + schemas: schemas.filter( + ({ name }) => + !excludedSchemas.includes(name) && + (includedSchemas.length === 0 || includedSchemas.includes(name)), + ), + tables, + foreignTables, + views, + materializedViews, + columns, + relationships, + functions: functions.filter( + ({ return_type }) => !["trigger", "event_trigger"].includes(return_type), + ), + types, + } as GeneratorMetadata; +} diff --git a/packages/postgrest-typegen/src/introspection/normalize.ts b/packages/postgrest-typegen/src/introspection/normalize.ts new file mode 100644 index 000000000..e3289e82c --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/normalize.ts @@ -0,0 +1,71 @@ +/** + * int8 → number coercion replicating postgres-meta's global int8 type parser. + * + * postgres-meta installs `pg.types.setTypeParser(INT8, ...)` in + * `src/lib/db.ts`, so every `bigint`/`int8` column comes back as a JS number + * when it fits in a safe integer, and as the raw string otherwise. Stock `pg` + * (and other drivers) instead return int8 as a string. To keep + * `GeneratorMetadata` byte-identical regardless of driver, we re-apply that + * exact coercion to the known int8 top-level fields after each query. + * + * Only top-level int8 columns need this: int8 values embedded inside `jsonb` + * columns (e.g. `args[].type_id`, composite type attributes) + * are decoded by the JSON parser as numbers in every driver, matching + * postgres-meta. Composite text ids like a column's `"."` are not + * safe integers, so the `Number.isSafeInteger` guard leaves them untouched — + * exactly as postgres-meta's parser does, since that column is `text`, not + * int8. + */ + +/** + * Top-level result columns that are `int8` across the typegen introspection + * queries (schemas/tables/foreign-tables/views/materialized-views/columns/ + * functions/types). Field names are unique enough across these queries that a + * name-based set faithfully reproduces the OID-based global parser. + */ +const INT8_FIELDS = [ + "id", + "table_id", + "type_relation_id", + "return_type_id", + "return_type_relation_id", + "bytes", + "live_rows_estimate", + "dead_rows_estimate", +] as const; + +/** + * Coerce a single value the way postgres-meta's `setTypeParser(INT8, ...)` + * does: parse to a number when it is a safe integer, otherwise keep the raw + * string. Non-string values (already-numbers, `null`) pass through unchanged. + */ +export function coerceInt8Value(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + const asNumber = Number(value); + if (Number.isSafeInteger(asNumber)) { + return asNumber; + } + return value; +} + +/** + * Apply {@link coerceInt8Value} to the known int8 fields of a single row, + * mutating and returning it. + */ +export function normalizeRow>(row: T): T { + for (const field of INT8_FIELDS) { + if (field in row) { + (row as Record)[field] = coerceInt8Value(row[field]); + } + } + return row; +} + +/** Apply {@link normalizeRow} to every row of a query result. */ +export function normalizeRows>( + rows: T[], +): T[] { + return rows.map(normalizeRow); +} diff --git a/packages/postgrest-typegen/src/introspection/relationships.ts b/packages/postgrest-typegen/src/introspection/relationships.ts new file mode 100644 index 000000000..9d4b1ac8e --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/relationships.ts @@ -0,0 +1,173 @@ +import type { PostgresRelationship } from "../types.ts"; +import type { IntrospectOptions, Queryable } from "./index.ts"; +import { DEFAULT_SYSTEM_SCHEMAS, filterByList } from "./sql/helpers.ts"; +import { TABLE_RELATIONSHIPS_SQL } from "./sql/table_relationships.sql.ts"; +import { VIEWS_KEY_DEPENDENCIES_SQL } from "./sql/views_key_dependencies.sql.ts"; + +/** + * Port of `postgres-meta/src/lib/PostgresMetaRelationships.ts`. + * + * The relationships used for type generation come in two parts: + * 1. table↔table m2o/o2o relationships, read directly from `pg_constraint` + * (`TABLE_RELATIONSHIPS_SQL`); and + * 2. relationships that involve a view on either side, which PostgREST derives + * by mapping a base table's foreign key columns onto the view columns that + * select them (`VIEWS_KEY_DEPENDENCIES_SQL` + the cartesian-product + * expansion below). This second part is pure TypeScript, not SQL — ported + * verbatim from upstream. + * + * Adapted from: + * https://github.com/PostgREST/postgrest/blob/f9f0f79fa914ac00c11fbf7f4c558e14821e67e2/src/PostgREST/SchemaCache.hs#L392 + */ + +type ColDep = { + table_column: string; + view_columns: string[]; +}; + +export type ViewKeyDependency = { + table_schema: string; + table_name: string; + view_schema: string; + view_name: string; + constraint_name: string; + constraint_type: "f" | "f_ref" | "p" | "p_ref"; + column_dependencies: ColDep[]; +}; + +/** + * Expand the table↔table relationships into the view↔table / table↔view / + * view↔view relationships implied by the view key dependencies. Pure function + * (no database) so the cartesian-product expansion can be unit-tested directly. + * + * Ported verbatim from `PostgresMetaRelationships.list()` (the body of the + * `allViewM2oAndO2oRelationships` block). + */ +export function expandViewRelationships( + allTableM2oAndO2oRelationships: PostgresRelationship[], + viewsKeyDependencies: ViewKeyDependency[], +): PostgresRelationship[] { + return allTableM2oAndO2oRelationships.flatMap((r) => { + const expandKeyDepCols = ( + colDeps: ColDep[], + ): { tableColumns: string[]; viewColumns: string[] }[] => { + const tableColumns = colDeps.map(({ table_column }) => table_column); + // https://gist.github.com/ssippe/1f92625532eef28be6974f898efb23ef?permalink_comment_id=3474581#gistcomment-3474581 + const cartesianProduct = (allEntries: T[][]): T[][] => { + return allEntries.reduce( + (results, entries) => + results + .map((result) => entries.map((entry) => result.concat(entry))) + .reduce((subResults, result) => subResults.concat(result), []), + [[]], + ); + }; + const viewColumnsPermutations = cartesianProduct( + colDeps.map((cd) => cd.view_columns), + ); + return viewColumnsPermutations.map((viewColumns) => ({ + tableColumns, + viewColumns, + })); + }; + + const viewToTableKeyDeps = viewsKeyDependencies.filter( + (vkd) => + vkd.table_schema === r.schema && + vkd.table_name === r.relation && + vkd.constraint_name === r.foreign_key_name && + vkd.constraint_type === "f", + ); + const tableToViewKeyDeps = viewsKeyDependencies.filter( + (vkd) => + vkd.table_schema === r.referenced_schema && + vkd.table_name === r.referenced_relation && + vkd.constraint_name === r.foreign_key_name && + vkd.constraint_type === "f_ref", + ); + + const viewToTableRelationships = viewToTableKeyDeps.flatMap((vtkd) => + expandKeyDepCols(vtkd.column_dependencies).map(({ viewColumns }) => ({ + foreign_key_name: r.foreign_key_name, + schema: vtkd.view_schema, + relation: vtkd.view_name, + columns: viewColumns, + is_one_to_one: r.is_one_to_one, + referenced_schema: r.referenced_schema, + referenced_relation: r.referenced_relation, + referenced_columns: r.referenced_columns, + })), + ); + + const tableToViewRelationships = tableToViewKeyDeps.flatMap((tvkd) => + expandKeyDepCols(tvkd.column_dependencies).map(({ viewColumns }) => ({ + foreign_key_name: r.foreign_key_name, + schema: r.schema, + relation: r.relation, + columns: r.columns, + is_one_to_one: r.is_one_to_one, + referenced_schema: tvkd.view_schema, + referenced_relation: tvkd.view_name, + referenced_columns: viewColumns, + })), + ); + + const viewToViewRelationships = viewToTableKeyDeps.flatMap((vtkd) => + expandKeyDepCols(vtkd.column_dependencies).flatMap(({ viewColumns }) => + tableToViewKeyDeps.flatMap((tvkd) => + expandKeyDepCols(tvkd.column_dependencies).map( + ({ viewColumns: referencedViewColumns }) => ({ + foreign_key_name: r.foreign_key_name, + schema: vtkd.view_schema, + relation: vtkd.view_name, + columns: viewColumns, + is_one_to_one: r.is_one_to_one, + referenced_schema: tvkd.view_schema, + referenced_relation: tvkd.view_name, + referenced_columns: referencedViewColumns, + }), + ), + ), + ), + ); + + return [ + ...viewToTableRelationships, + ...tableToViewRelationships, + ...viewToViewRelationships, + ]; + }); +} + +/** + * List all relationships (table↔table plus the view-derived ones) for the + * given schema filter. Mirrors `PostgresMetaRelationships.list()` with the + * generator path's defaults (`includeSystemSchemas: false`). Errors from the + * injected `Queryable` propagate by throwing — callers adapt. + */ +export async function listRelationships( + db: Queryable, + { includedSchemas, excludedSchemas }: IntrospectOptions = {}, +): Promise { + const schemaFilter = filterByList( + includedSchemas, + excludedSchemas, + DEFAULT_SYSTEM_SCHEMAS, + ); + + const { rows: allTableM2oAndO2oRelationships } = await db.query( + TABLE_RELATIONSHIPS_SQL({ schemaFilter }), + ); + const { rows: viewsKeyDependencies } = await db.query( + VIEWS_KEY_DEPENDENCIES_SQL({ schemaFilter }), + ); + + const allViewM2oAndO2oRelationships = expandViewRelationships( + allTableM2oAndO2oRelationships as PostgresRelationship[], + viewsKeyDependencies as ViewKeyDependency[], + ); + + return (allTableM2oAndO2oRelationships as PostgresRelationship[]).concat( + allViewM2oAndO2oRelationships, + ); +} diff --git a/packages/postgrest-typegen/src/introspection/sql/columns.sql.ts b/packages/postgrest-typegen/src/introspection/sql/columns.sql.ts new file mode 100644 index 000000000..723498b31 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/columns.sql.ts @@ -0,0 +1,130 @@ +import { literal } from "pg-format"; +import type { SQLQueryPropsWithSchemaFilter } from "./common.ts"; + +export const COLUMNS_SQL = ( + props: SQLQueryPropsWithSchemaFilter & { + tableIdFilter?: string; + tableIdentifierFilter?: string; + columnNameFilter?: string; + idsFilter?: string; + }, +) => /* SQL */ ` +-- Adapted from information_schema.columns + +SELECT + c.oid :: int8 AS table_id, + nc.nspname AS schema, + c.relname AS table, + (c.oid || '.' || a.attnum) AS id, + a.attnum AS ordinal_position, + a.attname AS name, + CASE + WHEN a.atthasdef THEN pg_get_expr(ad.adbin, ad.adrelid) + ELSE NULL + END AS default_value, + CASE + WHEN t.typtype = 'd' THEN CASE + WHEN bt.typelem <> 0 :: oid + AND bt.typlen = -1 THEN 'ARRAY' + WHEN nbt.nspname = 'pg_catalog' THEN format_type(t.typbasetype, NULL) + ELSE 'USER-DEFINED' + END + ELSE CASE + WHEN t.typelem <> 0 :: oid + AND t.typlen = -1 THEN 'ARRAY' + WHEN nt.nspname = 'pg_catalog' THEN format_type(a.atttypid, NULL) + ELSE 'USER-DEFINED' + END + END AS data_type, + COALESCE(bt.typname, t.typname) AS format, + a.attidentity IN ('a', 'd') AS is_identity, + CASE + a.attidentity + WHEN 'a' THEN 'ALWAYS' + WHEN 'd' THEN 'BY DEFAULT' + ELSE NULL + END AS identity_generation, + a.attgenerated IN ('s') AS is_generated, + NOT ( + a.attnotnull + OR t.typtype = 'd' AND t.typnotnull + ) AS is_nullable, + ( + c.relkind IN ('r', 'p') + OR c.relkind IN ('v', 'f') AND pg_column_is_updatable(c.oid, a.attnum, FALSE) + ) AS is_updatable, + uniques.table_id IS NOT NULL AS is_unique, + check_constraints.definition AS "check", + array_to_json( + array( + SELECT + enumlabel + FROM + pg_catalog.pg_enum enums + WHERE + enums.enumtypid = coalesce(bt.oid, t.oid) + OR enums.enumtypid = coalesce(bt.typelem, t.typelem) + ORDER BY + enums.enumsortorder + ) + ) AS enums, + col_description(c.oid, a.attnum) AS comment +FROM + pg_attribute a + LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid + AND a.attnum = ad.adnum + JOIN ( + pg_class c + JOIN pg_namespace nc ON c.relnamespace = nc.oid + ) ON a.attrelid = c.oid + JOIN ( + pg_type t + JOIN pg_namespace nt ON t.typnamespace = nt.oid + ) ON a.atttypid = t.oid + LEFT JOIN ( + pg_type bt + JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid + ) ON t.typtype = 'd' + AND t.typbasetype = bt.oid + LEFT JOIN ( + SELECT DISTINCT ON (table_id, ordinal_position) + conrelid AS table_id, + conkey[1] AS ordinal_position + FROM pg_catalog.pg_constraint + WHERE contype = 'u' AND cardinality(conkey) = 1 + ) AS uniques ON uniques.table_id = c.oid AND uniques.ordinal_position = a.attnum + LEFT JOIN ( + -- We only select the first column check + SELECT DISTINCT ON (table_id, ordinal_position) + conrelid AS table_id, + conkey[1] AS ordinal_position, + substring( + pg_get_constraintdef(pg_constraint.oid, true), + 8, + length(pg_get_constraintdef(pg_constraint.oid, true)) - 8 + ) AS "definition" + FROM pg_constraint + WHERE contype = 'c' AND cardinality(conkey) = 1 + ORDER BY table_id, ordinal_position, oid asc + ) AS check_constraints ON check_constraints.table_id = c.oid AND check_constraints.ordinal_position = a.attnum +WHERE + ${props.schemaFilter ? `nc.nspname ${props.schemaFilter} AND` : ""} + ${props.idsFilter ? `(c.oid || '.' || a.attnum) ${props.idsFilter} AND` : ""} + ${props.columnNameFilter ? `(c.relname || '.' || a.attname) ${props.columnNameFilter} AND` : ""} + ${props.tableIdFilter ? `c.oid ${props.tableIdFilter} AND` : ""} + ${props.tableIdentifierFilter ? `nc.nspname || '.' || c.relname ${props.tableIdentifierFilter} AND` : ""} + NOT pg_is_other_temp_schema(nc.oid) + AND a.attnum > 0 + AND NOT a.attisdropped + AND (c.relkind IN ('r', 'v', 'm', 'f', 'p')) + AND ( + pg_has_role(c.relowner, 'USAGE') + OR has_column_privilege( + c.oid, + a.attnum, + 'SELECT, INSERT, UPDATE, REFERENCES' + ) + ) +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/common.ts b/packages/postgrest-typegen/src/introspection/sql/common.ts new file mode 100644 index 000000000..7802eca61 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/common.ts @@ -0,0 +1,18 @@ +/** + * Shared prop shapes for the SQL builders, ported from + * `postgres-meta/src/lib/sql/common.ts`. Only the variants used by the + * typegen introspection path are kept. + */ +export type SQLQueryProps = { + limit?: number; + offset?: number; +}; + +export type SQLQueryPropsWithSchemaFilter = SQLQueryProps & { + schemaFilter?: string; +}; + +export type SQLQueryPropsWithSchemaFilterAndIdsFilter = SQLQueryProps & { + schemaFilter?: string; + idsFilter?: string; +}; diff --git a/packages/postgrest-typegen/src/introspection/sql/foreign_tables.sql.ts b/packages/postgrest-typegen/src/introspection/sql/foreign_tables.sql.ts new file mode 100644 index 000000000..d398f33d9 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/foreign_tables.sql.ts @@ -0,0 +1,26 @@ +import { literal } from "pg-format"; +import type { SQLQueryProps } from "./common.ts"; + +export const FOREIGN_TABLES_SQL = ( + props: SQLQueryProps & { + schemaFilter?: string; + idsFilter?: string; + tableIdentifierFilter?: string; + }, +) => /* SQL */ ` +SELECT + c.oid :: int8 AS id, + n.nspname AS schema, + c.relname AS name, + obj_description(c.oid) AS comment +FROM + pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE + ${props.schemaFilter ? `n.nspname ${props.schemaFilter} AND` : ""} + ${props.idsFilter ? `c.oid ${props.idsFilter} AND` : ""} + ${props.tableIdentifierFilter ? `(n.nspname || '.' || c.relname) ${props.tableIdentifierFilter} AND` : ""} + c.relkind = 'f' +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/functions.sql.ts b/packages/postgrest-typegen/src/introspection/sql/functions.sql.ts new file mode 100644 index 000000000..2bc959255 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/functions.sql.ts @@ -0,0 +1,159 @@ +// oxlint-disable typescript/restrict-template-expressions -- verbatim port of postgres-meta's +// builder, which interpolates the `args` string[] into the SQL (relying on Array#toString). The +// expression sits inside a template literal where a targeted disable comment would corrupt the SQL. +import { literal } from "pg-format"; +import type { SQLQueryPropsWithSchemaFilterAndIdsFilter } from "./common.ts"; + +export const FUNCTIONS_SQL = ( + props: SQLQueryPropsWithSchemaFilterAndIdsFilter & { + nameFilter?: string; + args?: string[]; + }, +) => /* SQL */ ` +-- CTE with sane arg_modes, arg_names, and arg_types. +-- All three are always of the same length. +-- All three include all args, including OUT and TABLE args. +with functions as ( + select + p.*, + -- proargmodes is null when all arg modes are IN + coalesce( + p.proargmodes, + array_fill('i'::text, array[cardinality(coalesce(p.proallargtypes, p.proargtypes))]) + ) as arg_modes, + -- proargnames is null when all args are unnamed + coalesce( + p.proargnames, + array_fill(''::text, array[cardinality(coalesce(p.proallargtypes, p.proargtypes))]) + ) as arg_names, + -- proallargtypes is null when all arg modes are IN + coalesce(p.proallargtypes, p.proargtypes) as arg_types, + array_cat( + array_fill(false, array[pronargs - pronargdefaults]), + array_fill(true, array[pronargdefaults])) as arg_has_defaults + from + pg_proc as p + ${props.schemaFilter ? `join pg_namespace n on p.pronamespace = n.oid` : ""} + where + ${props.schemaFilter ? `n.nspname ${props.schemaFilter} AND` : ""} + ${props.idsFilter ? `p.oid ${props.idsFilter} AND` : ""} + ${props.nameFilter ? `p.proname ${props.nameFilter} AND` : ""} + ${ + props.args === undefined + ? "" + : props.args.length > 0 + ? `p.proargtypes::text = ${ + props.args.length + ? `( + SELECT STRING_AGG(type_oid::text, ' ') FROM ( + SELECT ( + split_args.arr[ + array_length( + split_args.arr, + 1 + ) + ]::regtype::oid + ) AS type_oid FROM ( + SELECT STRING_TO_ARRAY( + UNNEST( + ARRAY[${props.args}] + ), + ' ' + ) AS arr + ) AS split_args + ) args + )` + : "''" + } AND` + : "" + } + p.prokind = 'f' +) +select + f.oid::int8 as id, + n.nspname as schema, + f.proname as name, + l.lanname as language, + case + when l.lanname = 'internal' then '' + else f.prosrc + end as definition, + case + when l.lanname = 'internal' then f.prosrc + else pg_get_functiondef(f.oid) + end as complete_statement, + coalesce(f_args.args, '[]') as args, + pg_get_function_arguments(f.oid) as argument_types, + pg_get_function_identity_arguments(f.oid) as identity_argument_types, + f.prorettype::int8 as return_type_id, + pg_get_function_result(f.oid) as return_type, + nullif(rt.typrelid::int8, 0) as return_type_relation_id, + f.proretset as is_set_returning_function, + case + when f.proretset then nullif(f.prorows, 0) + else null + end as prorows, + case + when f.provolatile = 'i' then 'IMMUTABLE' + when f.provolatile = 's' then 'STABLE' + when f.provolatile = 'v' then 'VOLATILE' + end as behavior, + f.prosecdef as security_definer, + f_config.config_params as config_params +from + functions f + left join pg_namespace n on f.pronamespace = n.oid + left join pg_language l on f.prolang = l.oid + left join pg_type rt on rt.oid = f.prorettype + left join ( + select + oid, + jsonb_object_agg(param, value) filter (where param is not null) as config_params + from + ( + select + oid, + (string_to_array(unnest(proconfig), '='))[1] as param, + (string_to_array(unnest(proconfig), '='))[2] as value + from + functions + ) as t + group by + oid + ) f_config on f_config.oid = f.oid + left join ( + select + oid, + jsonb_agg(jsonb_build_object( + 'mode', t2.mode, + 'name', name, + 'type_id', type_id, + 'has_default', has_default + )) as args + from + ( + select + oid, + unnest(arg_modes) as mode, + unnest(arg_names) as name, + unnest(arg_types)::int8 as type_id, + unnest(arg_has_defaults) as has_default + from + functions + ) as t1, + lateral ( + select + case + when t1.mode = 'i' then 'in' + when t1.mode = 'o' then 'out' + when t1.mode = 'b' then 'inout' + when t1.mode = 'v' then 'variadic' + else 'table' + end as mode + ) as t2 + group by + t1.oid + ) f_args on f_args.oid = f.oid +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/helpers.ts b/packages/postgrest-typegen/src/introspection/sql/helpers.ts new file mode 100644 index 000000000..44f289b10 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/helpers.ts @@ -0,0 +1,33 @@ +import { literal } from "pg-format"; + +/** + * Schemas that postgres-meta excludes by default when `includeSystemSchemas` + * is false. Ported from `postgres-meta/src/lib/constants.ts`. + */ +export const DEFAULT_SYSTEM_SCHEMAS = [ + "information_schema", + "pg_catalog", + "pg_toast", +]; + +/** + * Build a SQL `IN (...)` / `NOT IN (...)` fragment for an include/exclude list, + * ported verbatim from `postgres-meta/src/lib/helpers.ts`. Values are escaped + * with pg-format's `literal`. Returns an empty string when there is nothing to + * filter, so callers interpolate it conditionally. + */ +export const filterByList = ( + include?: (string | number)[], + exclude?: (string | number)[], + defaultExclude?: (string | number)[], +) => { + if (defaultExclude) { + exclude = defaultExclude.concat(exclude ?? []); + } + if (include?.length) { + return `IN (${include.map(literal).join(",")})`; + } else if (exclude?.length) { + return `NOT IN (${exclude.map(literal).join(",")})`; + } + return ""; +}; diff --git a/packages/postgrest-typegen/src/introspection/sql/materialized_views.sql.ts b/packages/postgrest-typegen/src/introspection/sql/materialized_views.sql.ts new file mode 100644 index 000000000..a99ede82c --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/materialized_views.sql.ts @@ -0,0 +1,25 @@ +import { literal } from "pg-format"; +import type { SQLQueryPropsWithSchemaFilterAndIdsFilter } from "./common.ts"; + +export const MATERIALIZED_VIEWS_SQL = ( + props: SQLQueryPropsWithSchemaFilterAndIdsFilter & { + materializedViewIdentifierFilter?: string; + }, +) => /* SQL */ ` +select + c.oid::int8 as id, + n.nspname as schema, + c.relname as name, + c.relispopulated as is_populated, + obj_description(c.oid) as comment +from + pg_class c + join pg_namespace n on n.oid = c.relnamespace +where + ${props.schemaFilter ? `n.nspname ${props.schemaFilter} AND` : ""} + ${props.idsFilter ? `c.oid ${props.idsFilter} AND` : ""} + ${props.materializedViewIdentifierFilter ? `(n.nspname || '.' || c.relname) ${props.materializedViewIdentifierFilter} AND` : ""} + c.relkind = 'm' +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/schemas.sql.ts b/packages/postgrest-typegen/src/introspection/sql/schemas.sql.ts new file mode 100644 index 000000000..951e17cc4 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/schemas.sql.ts @@ -0,0 +1,32 @@ +import { literal } from "pg-format"; +import type { SQLQueryProps } from "./common.ts"; + +export const SCHEMAS_SQL = ( + props: SQLQueryProps & { + nameFilter?: string; + idsFilter?: string; + includeSystemSchemas?: boolean; + }, +) => /* SQL */ ` +-- Adapted from information_schema.schemata +select + n.oid::int8 as id, + n.nspname as name, + u.rolname as owner +from + pg_namespace n, + pg_roles u +where + n.nspowner = u.oid + ${props.idsFilter ? `and n.oid ${props.idsFilter}` : ""} + ${props.nameFilter ? `and n.nspname ${props.nameFilter}` : ""} + ${!props.includeSystemSchemas ? `and not pg_catalog.starts_with(n.nspname, 'pg_')` : ""} + and ( + pg_has_role(n.nspowner, 'USAGE') + or has_schema_privilege(n.oid, 'CREATE, USAGE') + ) + and not pg_catalog.starts_with(n.nspname, 'pg_temp_') + and not pg_catalog.starts_with(n.nspname, 'pg_toast_temp_') +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/table.sql.ts b/packages/postgrest-typegen/src/introspection/sql/table.sql.ts new file mode 100644 index 000000000..be116b712 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/table.sql.ts @@ -0,0 +1,47 @@ +import { literal } from "pg-format"; +import type { SQLQueryPropsWithSchemaFilterAndIdsFilter } from "./common.ts"; + +export const TABLES_SQL = ( + props: SQLQueryPropsWithSchemaFilterAndIdsFilter & { + tableIdentifierFilter?: string; + }, +) => /* SQL */ ` +SELECT + c.oid :: int8 AS id, + nc.nspname AS schema, + c.relname AS name, + c.relrowsecurity AS rls_enabled, + c.relforcerowsecurity AS rls_forced, + CASE + WHEN c.relreplident = 'd' THEN 'DEFAULT' + WHEN c.relreplident = 'i' THEN 'INDEX' + WHEN c.relreplident = 'f' THEN 'FULL' + ELSE 'NOTHING' + END AS replica_identity, + pg_total_relation_size(format('%I.%I', nc.nspname, c.relname)) :: int8 AS bytes, + pg_size_pretty( + pg_total_relation_size(format('%I.%I', nc.nspname, c.relname)) + ) AS size, + pg_stat_get_live_tuples(c.oid) AS live_rows_estimate, + pg_stat_get_dead_tuples(c.oid) AS dead_rows_estimate, + obj_description(c.oid) AS comment +FROM + pg_namespace nc + JOIN pg_class c ON nc.oid = c.relnamespace +WHERE + ${props.schemaFilter ? `nc.nspname ${props.schemaFilter} AND` : ""} + ${props.idsFilter ? `c.oid ${props.idsFilter} AND` : ""} + ${props.tableIdentifierFilter ? `nc.nspname || '.' || c.relname ${props.tableIdentifierFilter} AND` : ""} + c.relkind IN ('r', 'p') + AND NOT pg_is_other_temp_schema(nc.oid) + AND ( + pg_has_role(c.relowner, 'USAGE') + OR has_table_privilege( + c.oid, + 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER' + ) + OR has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') + ) +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/table_relationships.sql.ts b/packages/postgrest-typegen/src/introspection/sql/table_relationships.sql.ts new file mode 100644 index 000000000..5ace2ea79 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/table_relationships.sql.ts @@ -0,0 +1,53 @@ +import type { SQLQueryPropsWithSchemaFilter } from "./common.ts"; + +export const TABLE_RELATIONSHIPS_SQL = ( + props: SQLQueryPropsWithSchemaFilter, +) => /* SQL */ ` +-- Adapted from +-- https://github.com/PostgREST/postgrest/blob/f9f0f79fa914ac00c11fbf7f4c558e14821e67e2/src/PostgREST/SchemaCache.hs#L722 +WITH +pks_uniques_cols AS ( + SELECT + connamespace, + conrelid, + jsonb_agg(column_info.cols) as cols + FROM pg_constraint + JOIN lateral ( + SELECT array_agg(cols.attname order by cols.attnum) as cols + FROM ( select unnest(conkey) as col) _ + JOIN pg_attribute cols on cols.attrelid = conrelid and cols.attnum = col + ) column_info ON TRUE + WHERE + contype IN ('p', 'u') and + connamespace::regnamespace::text <> 'pg_catalog' + ${props.schemaFilter ? `and connamespace::regnamespace::text ${props.schemaFilter}` : ""} + GROUP BY connamespace, conrelid +) +SELECT + traint.conname AS foreign_key_name, + ns1.nspname AS schema, + tab.relname AS relation, + column_info.cols AS columns, + ns2.nspname AS referenced_schema, + other.relname AS referenced_relation, + column_info.refs AS referenced_columns, + (column_info.cols IN (SELECT * FROM jsonb_array_elements(pks_uqs.cols))) AS is_one_to_one +FROM pg_constraint traint +JOIN LATERAL ( + SELECT + jsonb_agg(cols.attname order by ord) AS cols, + jsonb_agg(refs.attname order by ord) AS refs + FROM unnest(traint.conkey, traint.confkey) WITH ORDINALITY AS _(col, ref, ord) + JOIN pg_attribute cols ON cols.attrelid = traint.conrelid AND cols.attnum = col + JOIN pg_attribute refs ON refs.attrelid = traint.confrelid AND refs.attnum = ref + WHERE ${props.schemaFilter ? `traint.connamespace::regnamespace::text ${props.schemaFilter}` : "true"} +) AS column_info ON TRUE +JOIN pg_namespace ns1 ON ns1.oid = traint.connamespace +JOIN pg_class tab ON tab.oid = traint.conrelid +JOIN pg_class other ON other.oid = traint.confrelid +JOIN pg_namespace ns2 ON ns2.oid = other.relnamespace +LEFT JOIN pks_uniques_cols pks_uqs ON pks_uqs.connamespace = traint.connamespace AND pks_uqs.conrelid = traint.conrelid +WHERE traint.contype = 'f' +AND traint.conparentid = 0 +${props.schemaFilter ? `and ns1.nspname ${props.schemaFilter}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/types.sql.ts b/packages/postgrest-typegen/src/introspection/sql/types.sql.ts new file mode 100644 index 000000000..90263cbd2 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/types.sql.ts @@ -0,0 +1,74 @@ +import { literal } from "pg-format"; +import type { SQLQueryPropsWithSchemaFilterAndIdsFilter } from "./common.ts"; + +export const TYPES_SQL = ( + props: SQLQueryPropsWithSchemaFilterAndIdsFilter & { + includeTableTypes?: boolean; + includeArrayTypes?: boolean; + }, +) => /* SQL */ ` +select + t.oid::int8 as id, + t.typname as name, + n.nspname as schema, + format_type (t.oid, null) as format, + coalesce(t_enums.enums, '[]') as enums, + coalesce(t_attributes.attributes, '[]') as attributes, + obj_description (t.oid, 'pg_type') as comment, + nullif(t.typrelid::int8, 0) as type_relation_id +from + pg_type t + left join pg_namespace n on n.oid = t.typnamespace + left join ( + select + enumtypid, + jsonb_agg(enumlabel order by enumsortorder) as enums + from + pg_enum + group by + enumtypid + ) as t_enums on t_enums.enumtypid = t.oid + left join ( + select + oid, + jsonb_agg( + jsonb_build_object('name', a.attname, 'type_id', a.atttypid::int8) + order by a.attnum asc + ) as attributes + from + pg_class c + join pg_attribute a on a.attrelid = c.oid + where + c.relkind = 'c' and not a.attisdropped + group by + c.oid + ) as t_attributes on t_attributes.oid = t.typrelid + where + ( + t.typrelid = 0 + or ( + select + c.relkind ${props.includeTableTypes ? `in ('c', 'r', 'v', 'm', 'p')` : `= 'c'`} + from + pg_class c + where + c.oid = t.typrelid + ) + ) + ${ + !props.includeArrayTypes + ? `and not exists ( + select + from + pg_type el + where + el.oid = t.typelem + and el.typarray = t.oid + )` + : "" + } + ${props.schemaFilter ? `and n.nspname ${props.schemaFilter}` : ""} + ${props.idsFilter ? `and t.oid ${props.idsFilter}` : ""} +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/views.sql.ts b/packages/postgrest-typegen/src/introspection/sql/views.sql.ts new file mode 100644 index 000000000..0861b1235 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/views.sql.ts @@ -0,0 +1,26 @@ +import { literal } from "pg-format"; +import type { SQLQueryPropsWithSchemaFilterAndIdsFilter } from "./common.ts"; + +export const VIEWS_SQL = ( + props: SQLQueryPropsWithSchemaFilterAndIdsFilter & { + viewIdentifierFilter?: string; + }, +) => /* SQL */ ` +SELECT + c.oid :: int8 AS id, + n.nspname AS schema, + c.relname AS name, + -- See definition of information_schema.views + (pg_relation_is_updatable(c.oid, false) & 20) = 20 AS is_updatable, + obj_description(c.oid) AS comment +FROM + pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE + ${props.schemaFilter ? `n.nspname ${props.schemaFilter} AND` : ""} + ${props.idsFilter ? `c.oid ${props.idsFilter} AND` : ""} + ${props.viewIdentifierFilter ? `(n.nspname || '.' || c.relname) ${props.viewIdentifierFilter} AND` : ""} + c.relkind = 'v' +${props.limit ? `limit ${literal(props.limit)}` : ""} +${props.offset ? `offset ${literal(props.offset)}` : ""} +`; diff --git a/packages/postgrest-typegen/src/introspection/sql/views_key_dependencies.sql.ts b/packages/postgrest-typegen/src/introspection/sql/views_key_dependencies.sql.ts new file mode 100644 index 000000000..3aa1ca6a1 --- /dev/null +++ b/packages/postgrest-typegen/src/introspection/sql/views_key_dependencies.sql.ts @@ -0,0 +1,199 @@ +import type { SQLQueryPropsWithSchemaFilter } from "./common.ts"; + +export const VIEWS_KEY_DEPENDENCIES_SQL = ( + props: SQLQueryPropsWithSchemaFilter, +) => /* SQL */ ` +-- Adapted from +-- https://github.com/PostgREST/postgrest/blob/f9f0f79fa914ac00c11fbf7f4c558e14821e67e2/src/PostgREST/SchemaCache.hs#L820 +with recursive +pks_fks as ( + -- pk + fk referencing col + select + contype::text as contype, + conname, + array_length(conkey, 1) as ncol, + conrelid as resorigtbl, + col as resorigcol, + ord + from pg_constraint + left join lateral unnest(conkey) with ordinality as _(col, ord) on true + where contype IN ('p', 'f') + union + -- fk referenced col + select + concat(contype, '_ref') as contype, + conname, + array_length(confkey, 1) as ncol, + confrelid, + col, + ord + from pg_constraint + left join lateral unnest(confkey) with ordinality as _(col, ord) on true + where contype='f' + ${props.schemaFilter ? `and connamespace::regnamespace::text ${props.schemaFilter}` : ""} +), +views as ( + select + c.oid as view_id, + n.nspname as view_schema, + c.relname as view_name, + r.ev_action as view_definition + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + join pg_rewrite r on r.ev_class = c.oid + where c.relkind in ('v', 'm') + ${props.schemaFilter ? `and n.nspname ${props.schemaFilter}` : ""} +), +transform_json as ( + select + view_id, view_schema, view_name, + -- the following formatting is without indentation on purpose + -- to allow simple diffs, with less whitespace noise + replace( + replace( + replace( + replace( + replace( + replace( + replace( + regexp_replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + view_definition::text, + -- This conversion to json is heavily optimized for performance. + -- The general idea is to use as few regexp_replace() calls as possible. + -- Simple replace() is a lot faster, so we jump through some hoops + -- to be able to use regexp_replace() only once. + -- This has been tested against a huge schema with 250+ different views. + -- The unit tests do NOT reflect all possible inputs. Be careful when changing this! + -- ----------------------------------------------- + -- pattern | replacement | flags + -- ----------------------------------------------- + -- <> in pg_node_tree is the same as null in JSON, but due to very poor performance of json_typeof + -- we need to make this an empty array here to prevent json_array_elements from throwing an error + -- when the targetList is null. + -- We'll need to put it first, to make the node protection below work for node lists that start with + -- null: (<> ..., too. This is the case for coldefexprs, when the first column does not have a default value. + '<>' , '()' + -- , is not part of the pg_node_tree format, but used in the regex. + -- This removes all , that might be part of column names. + ), ',' , '' + -- The same applies for { and }, although those are used a lot in pg_node_tree. + -- We remove the escaped ones, which might be part of column names again. + ), E'\\\\{' , '' + ), E'\\\\}' , '' + -- The fields we need are formatted as json manually to protect them from the regex. + ), ' :targetList ' , ',"targetList":' + ), ' :resno ' , ',"resno":' + ), ' :resorigtbl ' , ',"resorigtbl":' + ), ' :resorigcol ' , ',"resorigcol":' + -- Make the regex also match the node type, e.g. \`{QUERY ...\`, to remove it in one pass. + ), '{' , '{ :' + -- Protect node lists, which start with \`({\` or \`((\` from the greedy regex. + -- The extra \`{\` is removed again later. + ), '((' , '{((' + ), '({' , '{({' + -- This regex removes all unused fields to avoid the need to format all of them correctly. + -- This leads to a smaller json result as well. + -- Removal stops at \`,\` for used fields (see above) and \`}\` for the end of the current node. + -- Nesting can't be parsed correctly with a regex, so we stop at \`{\` as well and + -- add an empty key for the followig node. + ), ' :[^}{,]+' , ',"":' , 'g' + -- For performance, the regex also added those empty keys when hitting a \`,\` or \`}\`. + -- Those are removed next. + ), ',"":}' , '}' + ), ',"":,' , ',' + -- This reverses the "node list protection" from above. + ), '{(' , '(' + -- Every key above has been added with a \`,\` so far. The first key in an object doesn't need it. + ), '{,' , '{' + -- pg_node_tree has \`()\` around lists, but JSON uses \`[]\` + ), '(' , '[' + ), ')' , ']' + -- pg_node_tree has \` \` between list items, but JSON uses \`,\` + ), ' ' , ',' + )::json as view_definition + from views +), +target_entries as( + select + view_id, view_schema, view_name, + json_array_elements(view_definition->0->'targetList') as entry + from transform_json +), +results as( + select + view_id, view_schema, view_name, + (entry->>'resno')::int as view_column, + (entry->>'resorigtbl')::oid as resorigtbl, + (entry->>'resorigcol')::int as resorigcol + from target_entries +), +-- CYCLE detection according to PG docs: https://www.postgresql.org/docs/current/queries-with.html#QUERIES-WITH-CYCLE +-- Can be replaced with CYCLE clause once PG v13 is EOL. +recursion(view_id, view_schema, view_name, view_column, resorigtbl, resorigcol, is_cycle, path) as( + select + r.*, + false, + ARRAY[resorigtbl] + from results r + where ${props.schemaFilter ? `view_schema ${props.schemaFilter}` : "true"} + union all + select + view.view_id, + view.view_schema, + view.view_name, + view.view_column, + tab.resorigtbl, + tab.resorigcol, + tab.resorigtbl = ANY(path), + path || tab.resorigtbl + from recursion view + join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column + where not is_cycle +), +repeated_references as( + select + view_id, + view_schema, + view_name, + resorigtbl, + resorigcol, + array_agg(attname) as view_columns + from recursion + join pg_attribute vcol on vcol.attrelid = view_id and vcol.attnum = view_column + group by + view_id, + view_schema, + view_name, + resorigtbl, + resorigcol +) +select + sch.nspname as table_schema, + tbl.relname as table_name, + rep.view_schema, + rep.view_name, + pks_fks.conname as constraint_name, + pks_fks.contype as constraint_type, + jsonb_agg( + jsonb_build_object('table_column', col.attname, 'view_columns', view_columns) order by pks_fks.ord + ) as column_dependencies +from repeated_references rep +join pks_fks using (resorigtbl, resorigcol) +join pg_class tbl on tbl.oid = rep.resorigtbl +join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rep.resorigcol +join pg_namespace sch on sch.oid = tbl.relnamespace +group by sch.nspname, tbl.relname, rep.view_schema, rep.view_name, pks_fks.conname, pks_fks.contype, pks_fks.ncol +-- make sure we only return key for which all columns are referenced in the view - no partial PKs or FKs +having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1) +`; diff --git a/packages/postgrest-typegen/src/sort.ts b/packages/postgrest-typegen/src/sort.ts new file mode 100644 index 000000000..a31aac540 --- /dev/null +++ b/packages/postgrest-typegen/src/sort.ts @@ -0,0 +1,93 @@ +/** + * Canonical ordering pass for {@link GeneratorMetadata}. + * + * The language generators consume the metadata collections in array order. + * This pass is the single, generator-agnostic stabilizer: it returns a new + * `GeneratorMetadata` whose every collection is ordered so that any producer + * (the bundled SQL `introspect()` or a custom injected adapter) yields + * byte-stable codegen. Generators document that they expect input pre-sorted + * with this function; ordering is intentionally NOT enforced inside + * `introspect()` so the concern lives in one place. + * + * **The TypeScript generator is the source of truth for ordering.** The keys + * below replicate the sorts that `generateTypescript` historically applied + * internally (schemas/tables/views/functions/types/columns/relationships by + * name; function args by name), so that generator can drop its own sorting and + * stay byte-identical. The other generators then inherit the same canonical + * order for free. + * + * Keys are **semantic** (schema + name + signature), never oid: equivalent + * databases assign different oids, so an oid sort would still churn output + * across environments. `id` is only a final tie-breaker for a total order. + * + * Composite type `attributes` and enum values are left untouched — their order + * is semantically meaningful (struct field / enum-label order) and must be + * preserved. Function `args` are sorted by name because PostgREST RPC args are + * addressed by name (the generated type is an object), matching TypeScript. + */ +import type { GeneratorMetadata, PostgresRelationship } from "./types.ts"; + +// Relations (tables/views/materialized views/foreign tables) and types share a +// (schema, name) identity within a database; `id` breaks any residual tie. +const bySchemaName = ( + a: { schema: string; name: string; id: number }, + b: { schema: string; name: string; id: number }, +): number => + a.schema.localeCompare(b.schema) || + a.name.localeCompare(b.name) || + a.id - b.id; + +// Mirrors the TypeScript generator's historical `relationships.sort`. +const byRelationship = ( + a: PostgresRelationship, + b: PostgresRelationship, +): number => + a.foreign_key_name.localeCompare(b.foreign_key_name) || + a.referenced_relation.localeCompare(b.referenced_relation) || + JSON.stringify(a.referenced_columns).localeCompare( + JSON.stringify(b.referenced_columns), + ); + +/** + * Return a new {@link GeneratorMetadata} with every collection ordered by a + * stable, total, semantic key (matching the TypeScript generator's ordering). + * Pure (does not mutate the input) and idempotent. Apply this after + * introspection and before any `generate*` call. + */ +export function sortGeneratorMetadata( + metadata: GeneratorMetadata, +): GeneratorMetadata { + return { + schemas: [...metadata.schemas].sort( + (a, b) => a.name.localeCompare(b.name) || a.id - b.id, + ), + tables: [...metadata.tables].sort(bySchemaName), + foreignTables: [...metadata.foreignTables].sort(bySchemaName), + views: [...metadata.views].sort(bySchemaName), + materializedViews: [...metadata.materializedViews].sort(bySchemaName), + // Group columns by their table's (schema, name); name-order within a table + // (matches the TypeScript generator's per-table `columns.sort(by name)`). + columns: [...metadata.columns].sort( + (a, b) => + a.schema.localeCompare(b.schema) || + a.table.localeCompare(b.table) || + a.name.localeCompare(b.name), + ), + relationships: [...metadata.relationships].sort(byRelationship), + // Functions can overload, so the signature is part of the identity. Args + // are addressed by name in generated RPC types, so sort them by name too. + functions: [...metadata.functions] + .sort( + (a, b) => + a.schema.localeCompare(b.schema) || + a.name.localeCompare(b.name) || + a.identity_argument_types.localeCompare(b.identity_argument_types) || + a.id - b.id, + ) + .map((fn) => ({ + ...fn, + args: [...fn.args].sort((a, b) => a.name.localeCompare(b.name)), + })), + types: [...metadata.types].sort(bySchemaName), + }; +} diff --git a/packages/postgrest-typegen/src/types.ts b/packages/postgrest-typegen/src/types.ts new file mode 100644 index 000000000..ee78173bc --- /dev/null +++ b/packages/postgrest-typegen/src/types.ts @@ -0,0 +1,190 @@ +/** + * Public metadata contract for type generation. + * + * ArkType is the single source of truth: each `Postgres*` schema below is a + * runtime validator, and the corresponding exported type is derived from it via + * `.infer`. The shapes are kept identical to postgres-meta's `src/lib/types.ts` + * so that postgres-meta can consume this package as a drop-in replacement for + * its embedded templates (a compile-time equivalence test pins this). + * + * `GeneratorMetadata` is the pluggable contract: the SQL introspector is the + * default producer, but any source able to produce this shape can feed the + * language generators. Because the producer may be an injected adapter, + * `parseGeneratorMetadata` is offered so integrators can validate a result at + * runtime instead of blindly casting it. Validation is deliberately *not* baked + * into `introspect()` — it is an opt-in helper. + */ +import { type } from "arktype"; + +const postgresColumnSchema = type({ + table_id: "number", + schema: "string", + table: "string", + /** `.` */ + id: "string", + ordinal_position: "number", + name: "string", + default_value: "unknown", + data_type: "string", + format: "string", + is_identity: "boolean", + identity_generation: "'ALWAYS' | 'BY DEFAULT' | null", + is_generated: "boolean", + is_nullable: "boolean", + is_updatable: "boolean", + is_unique: "boolean", + enums: "string[]", + check: "string | null", + comment: "string | null", +}); +export type PostgresColumn = typeof postgresColumnSchema.infer; + +const postgresForeignTableSchema = type({ + id: "number", + schema: "string", + name: "string", + comment: "string | null", + "columns?": postgresColumnSchema.array(), +}); +export type PostgresForeignTable = typeof postgresForeignTableSchema.infer; + +const postgresFunctionSchema = type({ + id: "number", + schema: "string", + name: "string", + language: "string", + definition: "string", + complete_statement: "string", + args: type({ + mode: "'in' | 'out' | 'inout' | 'variadic' | 'table'", + name: "string", + type_id: "number", + // `introspect()` emits `null` for the OUT columns of RETURNS TABLE / + // OUT-arg functions: the introspection SQL sizes `arg_has_defaults` from + // the input-arg count while `arg_modes`/`arg_types` include output args, + // so the trailing rows are padded with NULL. postgres-meta types this as a + // plain boolean, but the opt-in validator must accept the real output. + has_default: "boolean | null", + }).array(), + argument_types: "string", + identity_argument_types: "string", + return_type_id: "number", + return_type: "string", + return_type_relation_id: "number | null", + is_set_returning_function: "boolean", + prorows: "number | null", + behavior: "'IMMUTABLE' | 'STABLE' | 'VOLATILE'", + security_definer: "boolean", + config_params: type({ "[string]": "string" }).or("null"), +}); +export type PostgresFunction = typeof postgresFunctionSchema.infer; + +const postgresMaterializedViewSchema = type({ + id: "number", + schema: "string", + name: "string", + is_populated: "boolean", + comment: "string | null", + "columns?": postgresColumnSchema.array(), +}); +export type PostgresMaterializedView = + typeof postgresMaterializedViewSchema.infer; + +const postgresRelationshipSchema = type({ + foreign_key_name: "string", + schema: "string", + relation: "string", + columns: "string[]", + is_one_to_one: "boolean", + referenced_schema: "string", + referenced_relation: "string", + referenced_columns: "string[]", +}); +export type PostgresRelationship = typeof postgresRelationshipSchema.infer; + +const postgresSchemaSchema = type({ + id: "number", + name: "string", + owner: "string", +}); +export type PostgresSchema = typeof postgresSchemaSchema.infer; + +const postgresTableSchema = type({ + id: "number", + schema: "string", + name: "string", + rls_enabled: "boolean", + rls_forced: "boolean", + replica_identity: "'DEFAULT' | 'INDEX' | 'FULL' | 'NOTHING'", + bytes: "number", + size: "string", + live_rows_estimate: "number", + dead_rows_estimate: "number", + comment: "string | null", + "columns?": postgresColumnSchema.array(), +}); +export type PostgresTable = typeof postgresTableSchema.infer; + +const postgresTypeSchema = type({ + id: "number", + name: "string", + schema: "string", + format: "string", + enums: "string[]", + attributes: type({ name: "string", type_id: "number" }).array(), + comment: "string | null", + type_relation_id: "number | null", +}); +export type PostgresType = typeof postgresTypeSchema.infer; + +const postgresViewSchema = type({ + id: "number", + schema: "string", + name: "string", + is_updatable: "boolean", + comment: "string | null", + "columns?": postgresColumnSchema.array(), +}); +export type PostgresView = typeof postgresViewSchema.infer; + +/** + * The complete set of introspected metadata required to generate types. + * Produced by `introspect()` (the default SQL-based producer) or any other + * source able to satisfy this shape. The table-like collections drop the + * optional `columns` field (mirroring `Omit<…, "columns">`); columns are + * carried separately on `columns`. + */ +export const generatorMetadataSchema = type({ + schemas: postgresSchemaSchema.array(), + tables: postgresTableSchema.omit("columns").array(), + foreignTables: postgresForeignTableSchema.omit("columns").array(), + views: postgresViewSchema.omit("columns").array(), + materializedViews: postgresMaterializedViewSchema.omit("columns").array(), + columns: postgresColumnSchema.array(), + relationships: postgresRelationshipSchema.array(), + functions: postgresFunctionSchema.array(), + types: postgresTypeSchema.array(), +}); +export type GeneratorMetadata = typeof generatorMetadataSchema.infer; + +/** + * Validate an unknown value against the {@link generatorMetadataSchema} and + * return it typed as {@link GeneratorMetadata}. Throws a `TypeError` with + * ArkType's human-readable summary if validation fails. + * + * Offered as an opt-in helper so any integrator producing `GeneratorMetadata` + * through an injected adapter can verify the result at runtime instead of + * casting. `introspect()` does not call this — wrap its result yourself when + * you want the guarantee: + * + * ```ts + * const metadata = parseGeneratorMetadata(await introspect(db)); + * ``` + */ +export function parseGeneratorMetadata(data: unknown): GeneratorMetadata { + const out = generatorMetadataSchema(data); + if (out instanceof type.errors) { + throw new TypeError(`Invalid GeneratorMetadata: ${out.summary}`); + } + return out; +} diff --git a/packages/postgrest-typegen/test/generation/fixtures.ts b/packages/postgrest-typegen/test/generation/fixtures.ts new file mode 100644 index 000000000..6ab6939bd --- /dev/null +++ b/packages/postgrest-typegen/test/generation/fixtures.ts @@ -0,0 +1,189 @@ +/** + * Fixture builders for generation unit tests, ported from the pattern in + * `postgres-meta/test/server/templates/go.test.ts` and generalized so the Go + * and Python (and later TypeScript/Swift) generators can share one set of + * inputs. + * + * These produce the `GeneratorMetadata` contract directly — no database — so + * generation can be exercised as a pure function with inline snapshots. + */ +import type { + GeneratorMetadata, + PostgresColumn, + PostgresFunction, + PostgresMaterializedView, + PostgresSchema, + PostgresTable, + PostgresType, + PostgresView, +} from "../../src/types.ts"; + +const baseSchema: PostgresSchema = { + id: 1, + name: "public", + owner: "postgres", +}; + +export const baseTable = ( + overrides: Partial> = {}, +): Omit => ({ + id: 1, + schema: "public", + name: "tickets", + rls_enabled: false, + rls_forced: false, + replica_identity: "DEFAULT", + bytes: 0, + size: "0 bytes", + live_rows_estimate: 0, + dead_rows_estimate: 0, + comment: null, + ...overrides, +}); + +export const baseView = ( + overrides: Partial> = {}, +): Omit => ({ + id: 1, + schema: "public", + name: "tickets_view", + is_updatable: false, + comment: null, + ...overrides, +}); + +export const baseMaterializedView = ( + overrides: Partial> = {}, +): Omit => ({ + id: 1, + schema: "public", + name: "tickets_matview", + is_populated: true, + comment: null, + ...overrides, +}); + +export const userStatusEnum: PostgresType = { + id: 100, + name: "user_status", + schema: "public", + format: "user_status", + enums: ["ACTIVE", "INACTIVE"], + attributes: [], + comment: null, + type_relation_id: null, +}; + +/** + * A composite type `address` with two text attributes. `type_id` 25 is the + * Postgres OID for `text`, matched by the builtin text fixture below so the + * generators can resolve attribute types. + */ +export const addressCompositeType: PostgresType = { + id: 200, + name: "address", + schema: "public", + format: "address", + enums: [], + attributes: [ + { name: "street", type_id: 25 }, + { name: "city", type_id: 25 }, + ], + comment: null, + type_relation_id: 200, +}; + +/** Minimal builtin `text` type so composite attribute lookups resolve. */ +export const textType: PostgresType = { + id: 25, + name: "text", + schema: "pg_catalog", + format: "text", + enums: [], + attributes: [], + comment: null, + type_relation_id: null, +}; + +export const baseColumn = ( + overrides: Partial, +): PostgresColumn => + ({ + table_id: 1, + schema: "public", + table: "tickets", + id: "1.1", + ordinal_position: 1, + name: "col", + default_value: null, + data_type: "text", + format: "text", + is_identity: false, + identity_generation: null, + is_generated: false, + is_nullable: false, + is_updatable: true, + is_unique: false, + enums: [], + check: null, + comment: null, + ...overrides, + }) as PostgresColumn; + +export const baseRelationship = ( + overrides: Partial = {}, +): GeneratorMetadata["relationships"][number] => ({ + foreign_key_name: "tickets_owner_id_fkey", + schema: "public", + relation: "tickets", + columns: ["owner_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users", + referenced_columns: ["id"], + ...overrides, +}); + +export const baseFunction = ( + overrides: Partial = {}, +): PostgresFunction => + ({ + id: 300, + schema: "public", + name: "get_status", + language: "sql", + definition: "select 1", + complete_statement: "CREATE FUNCTION ...", + args: [], + argument_types: "", + identity_argument_types: "", + return_type_id: 23, + return_type: "integer", + return_type_relation_id: null, + is_set_returning_function: false, + prorows: 0, + behavior: "STABLE", + security_definer: false, + config_params: null, + ...overrides, + }) as PostgresFunction; + +/** + * Build a complete `GeneratorMetadata` from partial pieces. Defaults to a + * single `public` schema with the `user_status` enum and `text` builtin + * registered so most fixtures resolve without extra wiring. + */ +export const buildMetadata = ( + overrides: Partial = {}, +): GeneratorMetadata => ({ + schemas: [baseSchema], + tables: [], + foreignTables: [], + views: [], + materializedViews: [], + columns: [], + relationships: [], + functions: [], + types: [userStatusEnum, textType], + ...overrides, +}); diff --git a/packages/postgrest-typegen/test/generation/go.test.ts b/packages/postgrest-typegen/test/generation/go.test.ts new file mode 100644 index 000000000..42ec058b6 --- /dev/null +++ b/packages/postgrest-typegen/test/generation/go.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test"; + +import { generateGo as rawGenerateGo } from "../../src/generation/go.ts"; +import { sortGeneratorMetadata } from "../../src/sort.ts"; +import { + addressCompositeType, + baseColumn, + baseMaterializedView, + baseTable, + baseView, + buildMetadata, + textType, + userStatusEnum, +} from "./fixtures.ts"; + +// Generators expect pre-sorted metadata (the caller applies the canonical sort +// pass); mirror that here so fixture construction order doesn't matter. +const generateGo = (metadata: Parameters[0]) => + rawGenerateGo(sortGeneratorMetadata(metadata)); + +describe("go typegen", () => { + test("table with nullability, identity, generated and default columns", () => { + const result = generateGo( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "id", + format: "int8", + is_identity: true, + ordinal_position: 1, + }), + baseColumn({ + name: "status", + format: "user_status", + is_nullable: true, + ordinal_position: 2, + }), + baseColumn({ name: "label", format: "text", ordinal_position: 3 }), + baseColumn({ + name: "computed", + format: "text", + is_generated: true, + ordinal_position: 4, + }), + baseColumn({ + name: "with_default", + format: "int4", + default_value: "0", + ordinal_position: 5, + }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "package database + + type PublicTicketsSelect struct { + Computed string \`json:"computed"\` + Id int64 \`json:"id"\` + Label string \`json:"label"\` + Status *string \`json:"status"\` + WithDefault int32 \`json:"with_default"\` + } + + type PublicTicketsInsert struct { + Computed *string \`json:"computed"\` + Id *int64 \`json:"id"\` + Label string \`json:"label"\` + Status *string \`json:"status"\` + WithDefault *int32 \`json:"with_default"\` + } + + type PublicTicketsUpdate struct { + Computed *string \`json:"computed"\` + Id *int64 \`json:"id"\` + Label *string \`json:"label"\` + Status *string \`json:"status"\` + WithDefault *int32 \`json:"with_default"\` + }" + `); + }); + + test("views, materialized views and foreign tables", () => { + const result = generateGo( + buildMetadata({ + tables: [baseTable({ id: 1, name: "tickets" })], + views: [baseView({ id: 2, name: "tickets_view" })], + materializedViews: [ + baseMaterializedView({ id: 3, name: "tickets_mv" }), + ], + foreignTables: [ + { id: 4, schema: "public", name: "tickets_ft", comment: null }, + ], + columns: [ + baseColumn({ table_id: 1, name: "a", format: "text" }), + baseColumn({ + table_id: 2, + name: "b", + format: "int4", + is_nullable: true, + }), + baseColumn({ table_id: 3, name: "c", format: "bool" }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "package database + + type PublicTicketsSelect struct { + A string \`json:"a"\` + } + + type PublicTicketsInsert struct { + A string \`json:"a"\` + } + + type PublicTicketsUpdate struct { + A *string \`json:"a"\` + } + + type PublicTicketsViewSelect struct { + B *int32 \`json:"b"\` + } + + type PublicTicketsMvSelect struct { + C bool \`json:"c"\` + }" + `); + }); + + test("composite type struct", () => { + const result = generateGo( + buildMetadata({ + types: [userStatusEnum, textType, addressCompositeType], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "package database + + + + + + + + type PublicAddress struct { + Street string \`json:"street"\` + City string \`json:"city"\` + }" + `); + }); +}); + +describe("go typegen pgTypeToGoType array fallback", () => { + test("non-nullable array of enum resolves to []string, not []interface{}", () => { + const result = generateGo( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "tags", + format: "_user_status", + is_nullable: false, + }), + ], + }), + ); + + expect(result).toMatch(/Tags\s+\[]string\b/); + expect(result).not.toMatch(/Tags\s+\[]interface\{\}/); + }); + + test("nullable array of enum resolves to []*string, not []interface{}", () => { + const result = generateGo( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "tags", + format: "_user_status", + is_nullable: true, + }), + ], + }), + ); + + expect(result).toMatch(/Tags\s+\[]\*string\b/); + expect(result).not.toMatch(/Tags\s+\[]interface\{\}/); + }); + + test("plain text array still resolves to []string", () => { + const result = generateGo( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ name: "tags", format: "_text", is_nullable: false }), + ], + }), + ); + + expect(result).toMatch(/Tags\s+\[]string\b/); + }); +}); diff --git a/packages/postgrest-typegen/test/generation/python.test.ts b/packages/postgrest-typegen/test/generation/python.test.ts new file mode 100644 index 000000000..1d717a41d --- /dev/null +++ b/packages/postgrest-typegen/test/generation/python.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from "bun:test"; + +import { generatePython as rawGeneratePython } from "../../src/generation/python.ts"; +import { sortGeneratorMetadata } from "../../src/sort.ts"; +import { + addressCompositeType, + baseColumn, + baseMaterializedView, + baseTable, + baseView, + buildMetadata, + textType, + userStatusEnum, +} from "./fixtures.ts"; + +// Generators expect pre-sorted metadata (the caller applies the canonical sort +// pass); mirror that here so fixture construction order doesn't matter. +const generatePython = (metadata: Parameters[0]) => + rawGeneratePython(sortGeneratorMetadata(metadata)); + +describe("python typegen", () => { + test("table with nullability, identity, generated and default columns", () => { + const result = generatePython( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "id", + format: "int8", + is_identity: true, + ordinal_position: 1, + }), + baseColumn({ + name: "status", + format: "user_status", + is_nullable: true, + ordinal_position: 2, + }), + baseColumn({ name: "label", format: "text", ordinal_position: 3 }), + baseColumn({ + name: "computed", + format: "text", + is_generated: true, + ordinal_position: 4, + }), + baseColumn({ + name: "with_default", + format: "int4", + default_value: "0", + ordinal_position: 5, + }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "from __future__ import annotations + + import datetime + import uuid + from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, + ) + + from pydantic import BaseModel, Field, Json + + PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] + + class PublicTickets(BaseModel): + computed: str = Field(alias="computed") + id: int = Field(alias="id") + label: str = Field(alias="label") + status: Optional[PublicUserStatus] = Field(alias="status") + with_default: int = Field(alias="with_default") + + class PublicTicketsInsert(TypedDict): + computed: Annotated[str, Field(alias="computed")] + id: NotRequired[Annotated[int, Field(alias="id")]] + label: Annotated[str, Field(alias="label")] + status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] + with_default: NotRequired[Annotated[int, Field(alias="with_default")]] + + class PublicTicketsUpdate(TypedDict): + computed: NotRequired[Annotated[str, Field(alias="computed")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + label: NotRequired[Annotated[str, Field(alias="label")]] + status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] + with_default: NotRequired[Annotated[int, Field(alias="with_default")]]" + `); + }); + + test("views and materialized views", () => { + const result = generatePython( + buildMetadata({ + tables: [baseTable({ id: 1, name: "tickets" })], + views: [baseView({ id: 2, name: "tickets_view" })], + materializedViews: [ + baseMaterializedView({ id: 3, name: "tickets_mv" }), + ], + columns: [ + baseColumn({ table_id: 1, name: "a", format: "text" }), + baseColumn({ + table_id: 2, + name: "b", + format: "int4", + is_nullable: true, + }), + baseColumn({ table_id: 3, name: "c", format: "bool" }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "from __future__ import annotations + + import datetime + import uuid + from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, + ) + + from pydantic import BaseModel, Field, Json + + PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] + + class PublicTickets(BaseModel): + a: str = Field(alias="a") + + class PublicTicketsInsert(TypedDict): + a: Annotated[str, Field(alias="a")] + + class PublicTicketsUpdate(TypedDict): + a: NotRequired[Annotated[str, Field(alias="a")]] + + class PublicTicketsView(BaseModel): + b: Optional[int] = Field(alias="b") + + class PublicTicketsMv(BaseModel): + c: bool = Field(alias="c")" + `); + }); + + test("enum alias and composite type", () => { + const result = generatePython( + buildMetadata({ + types: [userStatusEnum, textType, addressCompositeType], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "from __future__ import annotations + + import datetime + import uuid + from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, + ) + + from pydantic import BaseModel, Field, Json + + PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] + + + + + + + + class PublicAddress(BaseModel): + street: str = Field(alias="street") + city: str = Field(alias="city")" + `); + }); + + test("array column resolves to List[...] and multi-word names are normalized", () => { + const result = generatePython( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "tags", + format: "_user_status", + is_nullable: false, + }), + baseColumn({ name: "names", format: "_text", is_nullable: true }), + baseColumn({ name: "victory road", format: "text" }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "from __future__ import annotations + + import datetime + import uuid + from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, + ) + + from pydantic import BaseModel, Field, Json + + PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] + + class PublicTickets(BaseModel): + names: Optional[List[str]] = Field(alias="names") + tags: List[PublicUserStatus] = Field(alias="tags") + victory_road: str = Field(alias="victory road") + + class PublicTicketsInsert(TypedDict): + names: NotRequired[Annotated[Optional[List[str]], Field(alias="names")]] + tags: Annotated[List[PublicUserStatus], Field(alias="tags")] + victory_road: Annotated[str, Field(alias="victory road")] + + class PublicTicketsUpdate(TypedDict): + names: NotRequired[Annotated[Optional[List[str]], Field(alias="names")]] + tags: NotRequired[Annotated[List[PublicUserStatus], Field(alias="tags")]] + victory_road: NotRequired[Annotated[str, Field(alias="victory road")]]" + `); + }); +}); diff --git a/packages/postgrest-typegen/test/generation/swift.test.ts b/packages/postgrest-typegen/test/generation/swift.test.ts new file mode 100644 index 000000000..a0567c5b9 --- /dev/null +++ b/packages/postgrest-typegen/test/generation/swift.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from "bun:test"; + +import { generateSwift as rawGenerateSwift } from "../../src/generation/swift.ts"; +import { sortGeneratorMetadata } from "../../src/sort.ts"; +import { + addressCompositeType, + baseColumn, + baseTable, + baseView, + buildMetadata, + textType, + userStatusEnum, +} from "./fixtures.ts"; + +// Generators expect pre-sorted metadata (the caller applies the canonical sort +// pass); mirror that here so fixture construction order doesn't matter. +const generateSwift = ( + metadata: Parameters[0], + opts?: Parameters[1], +) => rawGenerateSwift(sortGeneratorMetadata(metadata), opts); + +describe("swift typegen", () => { + test("enum, struct operations, identity and CodingKeys", () => { + const result = generateSwift( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "id", + format: "int8", + is_identity: true, + ordinal_position: 1, + }), + baseColumn({ + name: "status", + format: "user_status", + is_nullable: true, + ordinal_position: 2, + }), + baseColumn({ name: "label", format: "text", ordinal_position: 3 }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "import Foundation + import Supabase + + internal enum PublicSchema { + internal enum UserStatus: String, Codable, Hashable, Sendable { + case active = "ACTIVE" + case inactive = "INACTIVE" + } + internal struct TicketsSelect: Codable, Hashable, Sendable, Identifiable { + internal let id: Int64 + internal let label: String + internal let status: UserStatus? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case label = "label" + case status = "status" + } + } + internal struct TicketsInsert: Codable, Hashable, Sendable, Identifiable { + internal let id: Int64? + internal let label: String + internal let status: UserStatus? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case label = "label" + case status = "status" + } + } + internal struct TicketsUpdate: Codable, Hashable, Sendable, Identifiable { + internal let id: Int64? + internal let label: String? + internal let status: UserStatus? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case label = "label" + case status = "status" + } + } + }" + `); + }); + + test("views and composite types", () => { + const result = generateSwift( + buildMetadata({ + types: [userStatusEnum, textType, addressCompositeType], + views: [baseView({ id: 2, name: "tickets_view" })], + columns: [ + baseColumn({ + table_id: 2, + name: "b", + format: "int4", + is_nullable: true, + }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "import Foundation + import Supabase + + internal enum PublicSchema { + internal enum UserStatus: String, Codable, Hashable, Sendable { + case active = "ACTIVE" + case inactive = "INACTIVE" + } + internal struct TicketsViewSelect: Codable, Hashable, Sendable { + internal let b: Int32? + internal enum CodingKeys: String, CodingKey { + case b = "b" + } + } + internal struct Address: Codable, Hashable, Sendable { + internal let Street: String + internal let City: String + internal enum CodingKeys: String, CodingKey { + case Street = "street" + case City = "city" + } + } + }" + `); + }); + + test("accessControl: public", () => { + const result = generateSwift( + buildMetadata({ + tables: [baseTable()], + columns: [baseColumn({ name: "id", format: "int8" })], + }), + { accessControl: "public" }, + ); + + expect(result).toMatchInlineSnapshot(` + "import Foundation + import Supabase + + public enum PublicSchema { + public enum UserStatus: String, Codable, Hashable, Sendable { + case active = "ACTIVE" + case inactive = "INACTIVE" + } + public struct TicketsSelect: Codable, Hashable, Sendable { + public let id: Int64 + public enum CodingKeys: String, CodingKey { + case id = "id" + } + } + public struct TicketsInsert: Codable, Hashable, Sendable { + public let id: Int64 + public enum CodingKeys: String, CodingKey { + case id = "id" + } + } + public struct TicketsUpdate: Codable, Hashable, Sendable { + public let id: Int64? + public enum CodingKeys: String, CodingKey { + case id = "id" + } + } + }" + `); + }); + + test("array and uuid types", () => { + const result = generateSwift( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ name: "id", format: "uuid", ordinal_position: 1 }), + baseColumn({ + name: "tags", + format: "_text", + is_nullable: true, + ordinal_position: 2, + }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "import Foundation + import Supabase + + internal enum PublicSchema { + internal enum UserStatus: String, Codable, Hashable, Sendable { + case active = "ACTIVE" + case inactive = "INACTIVE" + } + internal struct TicketsSelect: Codable, Hashable, Sendable { + internal let id: UUID + internal let tags: [String]? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case tags = "tags" + } + } + internal struct TicketsInsert: Codable, Hashable, Sendable { + internal let id: UUID + internal let tags: [String]? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case tags = "tags" + } + } + internal struct TicketsUpdate: Codable, Hashable, Sendable { + internal let id: UUID? + internal let tags: [String]? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case tags = "tags" + } + } + }" + `); + }); +}); diff --git a/packages/postgrest-typegen/test/generation/typescript.test.ts b/packages/postgrest-typegen/test/generation/typescript.test.ts new file mode 100644 index 000000000..39619efc6 --- /dev/null +++ b/packages/postgrest-typegen/test/generation/typescript.test.ts @@ -0,0 +1,1168 @@ +import { describe, expect, test } from "bun:test"; + +import { generateTypescript as rawGenerateTypescript } from "../../src/generation/typescript.ts"; +import { sortGeneratorMetadata } from "../../src/sort.ts"; +import { + baseColumn, + baseFunction, + baseRelationship, + baseTable, + buildMetadata, + userStatusEnum, +} from "./fixtures.ts"; + +// Generators expect pre-sorted metadata (the caller applies the canonical sort +// pass); mirror that here so fixture construction order doesn't matter. +const generateTypescript = ( + metadata: Parameters[0], + opts?: Parameters[1], +) => rawGenerateTypescript(sortGeneratorMetadata(metadata), opts); + +describe("typescript typegen", () => { + test("table Row/Insert/Update with enum, identity ALWAYS, default and nullable", async () => { + const result = await generateTypescript( + buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "id", + format: "int8", + is_identity: true, + identity_generation: "ALWAYS", + ordinal_position: 1, + }), + baseColumn({ + name: "status", + format: "user_status", + is_nullable: true, + ordinal_position: 2, + }), + baseColumn({ name: "label", format: "text", ordinal_position: 3 }), + baseColumn({ + name: "score", + format: "int4", + default_value: "0", + ordinal_position: 4, + }), + baseColumn({ + name: "meta", + format: "jsonb", + is_nullable: true, + ordinal_position: 5, + }), + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + + export type Database = { + public: { + Tables: { + tickets: { + Row: { + id: number + label: string + meta: Json | null + score: number + status: Database["public"]["Enums"]["user_status"] | null + } + Insert: { + id?: never + label: string + meta?: Json | null + score?: number + status?: Database["public"]["Enums"]["user_status"] | null + } + Update: { + id?: never + label?: string + meta?: Json | null + score?: number + status?: Database["public"]["Enums"]["user_status"] | null + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + user_status: "ACTIVE" | "INACTIVE" + } + CompositeTypes: { + [_ in never]: never + } + } + } + + type DatabaseWithoutInternals = Omit + + type DefaultSchema = DatabaseWithoutInternals[Extract] + + export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + + export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + + export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + + export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, + > = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + + export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, + > = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + + export const Constants = { + public: { + Enums: { + user_status: ["ACTIVE", "INACTIVE"], + }, + }, + } as const + " + `); + }); + + test("relationships without detectOneToOneRelationships omit isOneToOne", async () => { + const result = await generateTypescript( + buildMetadata({ + tables: [baseTable({ id: 1, name: "tickets" })], + columns: [ + baseColumn({ table_id: 1, name: "owner_id", format: "int8" }), + ], + relationships: [baseRelationship()], + }), + ); + + expect(result).not.toContain("isOneToOne"); + expect(result).toMatchInlineSnapshot(` + "export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + + export type Database = { + public: { + Tables: { + tickets: { + Row: { + owner_id: number + } + Insert: { + owner_id: number + } + Update: { + owner_id?: number + } + Relationships: [ + { + foreignKeyName: "tickets_owner_id_fkey" + columns: ["owner_id"] + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + user_status: "ACTIVE" | "INACTIVE" + } + CompositeTypes: { + [_ in never]: never + } + } + } + + type DatabaseWithoutInternals = Omit + + type DefaultSchema = DatabaseWithoutInternals[Extract] + + export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + + export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + + export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + + export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, + > = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + + export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, + > = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + + export const Constants = { + public: { + Enums: { + user_status: ["ACTIVE", "INACTIVE"], + }, + }, + } as const + " + `); + }); + + test("relationships with detectOneToOneRelationships include isOneToOne", async () => { + const result = await generateTypescript( + buildMetadata({ + tables: [baseTable({ id: 1, name: "tickets" })], + columns: [ + baseColumn({ table_id: 1, name: "owner_id", format: "int8" }), + ], + relationships: [baseRelationship({ is_one_to_one: true })], + }), + { detectOneToOneRelationships: true }, + ); + + expect(result).toContain("isOneToOne: true"); + expect(result).toMatchInlineSnapshot(` + "export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + + export type Database = { + public: { + Tables: { + tickets: { + Row: { + owner_id: number + } + Insert: { + owner_id: number + } + Update: { + owner_id?: number + } + Relationships: [ + { + foreignKeyName: "tickets_owner_id_fkey" + columns: ["owner_id"] + isOneToOne: true + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + user_status: "ACTIVE" | "INACTIVE" + } + CompositeTypes: { + [_ in never]: never + } + } + } + + type DatabaseWithoutInternals = Omit + + type DefaultSchema = DatabaseWithoutInternals[Extract] + + export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + + export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + + export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + + export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, + > = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + + export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, + > = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + + export const Constants = { + public: { + Enums: { + user_status: ["ACTIVE", "INACTIVE"], + }, + }, + } as const + " + `); + }); + + test("postgrestVersion emits __InternalSupabase.PostgrestVersion", async () => { + const result = await generateTypescript( + buildMetadata({ + tables: [baseTable()], + columns: [baseColumn({ name: "id", format: "int8" })], + }), + { postgrestVersion: "12" }, + ); + + expect(result).toContain('PostgrestVersion: "12"'); + expect(result).toMatchInlineSnapshot(` + "export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + + export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "12" + } + public: { + Tables: { + tickets: { + Row: { + id: number + } + Insert: { + id: number + } + Update: { + id?: number + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + user_status: "ACTIVE" | "INACTIVE" + } + CompositeTypes: { + [_ in never]: never + } + } + } + + type DatabaseWithoutInternals = Omit + + type DefaultSchema = DatabaseWithoutInternals[Extract] + + export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + + export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + + export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + + export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, + > = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + + export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, + > = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + + export const Constants = { + public: { + Enums: { + user_status: ["ACTIVE", "INACTIVE"], + }, + }, + } as const + " + `); + }); + + test("function signature with args and scalar return", async () => { + const result = await generateTypescript( + buildMetadata({ + functions: [ + baseFunction({ + name: "add", + args: [ + { mode: "in", name: "a", type_id: 23, has_default: false }, + { mode: "in", name: "b", type_id: 23, has_default: true }, + ], + argument_types: "a integer, b integer", + return_type_id: 23, + return_type: "integer", + }), + ], + types: [ + userStatusEnum, + { + id: 23, + name: "int4", + schema: "pg_catalog", + format: "int4", + enums: [], + attributes: [], + comment: null, + type_relation_id: null, + }, + ], + }), + ); + + expect(result).toMatchInlineSnapshot(` + "export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + + export type Database = { + public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + add: { Args: { a: number; b?: number }; Returns: number } + } + Enums: { + user_status: "ACTIVE" | "INACTIVE" + } + CompositeTypes: { + [_ in never]: never + } + } + } + + type DatabaseWithoutInternals = Omit + + type DefaultSchema = DatabaseWithoutInternals[Extract] + + export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + + export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + + export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + + export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, + > = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + + export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, + > = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + + export const Constants = { + public: { + Enums: { + user_status: ["ACTIVE", "INACTIVE"], + }, + }, + } as const + " + `); + }); + + test("defaultSchema option targets a non-public schema", async () => { + const result = await generateTypescript( + buildMetadata({ + schemas: [{ id: 2, name: "api", owner: "postgres" }], + tables: [baseTable({ id: 1, schema: "api", name: "widgets" })], + columns: [ + baseColumn({ + table_id: 1, + schema: "api", + name: "id", + format: "int8", + }), + ], + types: [], + }), + { defaultSchema: "api" }, + ); + + expect(result).toContain('Extract'); + expect(result).toMatchInlineSnapshot(` + "export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + + export type Database = { + api: { + Tables: { + widgets: { + Row: { + id: number + } + Insert: { + id: number + } + Update: { + id?: number + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } + } + + type DatabaseWithoutInternals = Omit + + type DefaultSchema = DatabaseWithoutInternals[Extract] + + export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + + export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + + export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, + > = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + + export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, + > = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + + export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, + > = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + + export const Constants = { + api: { + Enums: {}, + }, + } as const + " + `); + }); +}); diff --git a/packages/postgrest-typegen/test/global-setup.ts b/packages/postgrest-typegen/test/global-setup.ts new file mode 100644 index 000000000..0cb1a9003 --- /dev/null +++ b/packages/postgrest-typegen/test/global-setup.ts @@ -0,0 +1,6 @@ +/** + * Global test setup. Integration/parity tests (added in later phases) use + * testcontainers to spin up PostgreSQL and will prewarm the image here. + * Currently a no-op while only pure unit tests exist. + */ +export {}; diff --git a/packages/postgrest-typegen/test/introspection/fixtures/00-init.sql b/packages/postgrest-typegen/test/introspection/fixtures/00-init.sql new file mode 100644 index 000000000..c30e1f4a4 --- /dev/null +++ b/packages/postgrest-typegen/test/introspection/fixtures/00-init.sql @@ -0,0 +1,503 @@ + + +-- Tables for testing + +CREATE TYPE public.user_status AS ENUM ('ACTIVE', 'INACTIVE'); +CREATE TYPE composite_type_with_array_attribute AS (my_text_array text[]); + +CREATE TABLE public.users ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text, + status user_status DEFAULT 'ACTIVE', + decimal numeric, + user_uuid uuid DEFAULT gen_random_uuid() +); +INSERT INTO + public.users (name) +VALUES + ('Joe Bloggs'), + ('Jane Doe'); + +CREATE TABLE public.todos ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + details text, + "user-id" bigint REFERENCES users NOT NULL +); + +INSERT INTO + public.todos (details, "user-id") +VALUES + ('Star the repo', 1), + ('Watch the releases', 2); + + +CREATE FUNCTION add(integer, integer) RETURNS integer + AS 'select $1 + $2;' + LANGUAGE SQL + IMMUTABLE + RETURNS NULL ON NULL INPUT; + +create table public.users_audit ( + id BIGINT generated by DEFAULT as identity, + created_at timestamptz DEFAULT now(), + user_id bigint, + previous_value jsonb +); + +create function public.audit_action() +returns trigger as $$ +begin + insert into public.users_audit (user_id, previous_value) + values (old.id, row_to_json(old)); + + return new; +end; +$$ language plpgsql; + +CREATE VIEW todos_view AS SELECT * FROM public.todos; +-- For testing typegen on view-to-view relationships +create view users_view as select * from public.users; +-- Create a more complex view for testing +CREATE VIEW user_todos_summary_view AS +SELECT + u.id as user_id, + u.name as user_name, + u.status as user_status, + COUNT(t.id) as todo_count, + array_agg(t.details) FILTER (WHERE t.details IS NOT NULL) as todo_details +FROM public.users u +LEFT JOIN public.todos t ON t."user-id" = u.id +GROUP BY u.id, u.name, u.status; + +create materialized view todos_matview as select * from public.todos; + +create function public.blurb(public.todos) returns text as +$$ +select substring($1.details, 1, 3); +$$ language sql stable; + +create function public.blurb_varchar(public.todos) returns character varying as +$$ +select substring($1.details, 1, 3); +$$ language sql stable; + +create function public.blurb_varchar(public.todos_view) returns character varying as +$$ +select substring($1.details, 1, 3); +$$ language sql stable; + +create function public.details_length(public.todos) returns integer as +$$ +select length($1.details); +$$ language sql stable; + +create function public.details_is_long(public.todos) returns boolean as +$$ +select $1.details_length > 20; +$$ language sql stable; + +create function public.details_words(public.todos) returns text[] as +$$ +select string_to_array($1.details, ' '); +$$ language sql stable; + +create extension postgres_fdw; +create server foreign_server foreign data wrapper postgres_fdw options (host 'localhost', port '5432', dbname 'postgres'); +create user mapping for postgres server foreign_server options (user 'postgres', password 'postgres'); +create foreign table foreign_table ( + id int8 not null, + name text, + status user_status +) server foreign_server options (schema_name 'public', table_name 'users'); + +create or replace function public.function_returning_row() +returns public.users +language sql +stable +as $$ + select * from public.users limit 1; +$$; + +create or replace function public.function_returning_single_row(todos public.todos) +returns public.users +language sql +stable +as $$ + select * from public.users limit 1; +$$; + + +create or replace function public.function_returning_set_of_rows() +returns setof public.users +language sql +stable +as $$ + select * from public.users; +$$; + +create or replace function public.function_returning_table() +returns table (id int, name text) +language sql +stable +as $$ + select id, name from public.users; +$$; + +create or replace function public.function_returning_table_with_args(user_id int) +returns table (id int, name text) +language sql +stable +as $$ + select id, name from public.users WHERE id = user_id; +$$; + + +create or replace function public.polymorphic_function(text) returns void language sql as ''; +create or replace function public.polymorphic_function(bool) returns void language sql as ''; + +create table user_details ( + user_id int8 references users(id) primary key, + details text +); + +create view a_view as select id from users; + +create table empty(); + +create table table_with_other_tables_row_type ( + col1 user_details, + col2 a_view +); + +create table table_with_primary_key_other_than_id ( + other_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text +); + +create type composite_type_with_record_attribute as ( + todo todos +); + +create view users_view_with_multiple_refs_to_users as +WITH initial_user AS ( + SELECT + u.id as initial_id, + u.name as initial_name + FROM users u + where u.id = 1 +), +second_user AS ( + SELECT + u.id as second_id, + u.name as second_name + FROM users u + where u.id = 2 +) +SELECT * from initial_user iu +cross join second_user su; + +CREATE OR REPLACE FUNCTION public.get_user_audit_setof_single_row(user_row users) +RETURNS SETOF users_audit +LANGUAGE SQL STABLE +ROWS 1 +AS $$ + SELECT * FROM public.users_audit WHERE user_id = user_row.id; +$$; + +CREATE OR REPLACE FUNCTION public.get_todos_by_matview(todos_matview) +RETURNS SETOF todos ROWS 1 +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos LIMIT 1; +$$; + +CREATE OR REPLACE FUNCTION public.search_todos_by_details(search_details text) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE details ilike search_details; +$$; + +CREATE OR REPLACE FUNCTION public.get_todos_setof_rows(user_row users) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE "user-id" = user_row.id; +$$; + +CREATE OR REPLACE FUNCTION public.get_todos_setof_rows(todo_row todos) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE "user-id" = todo_row."user-id"; +$$; + +-- SETOF composite_type - Returns multiple rows of a custom composite type +CREATE OR REPLACE FUNCTION public.get_composite_type_data() +RETURNS SETOF composite_type_with_array_attribute +LANGUAGE SQL STABLE +AS $$ + SELECT ROW(ARRAY['hello', 'world']::text[])::composite_type_with_array_attribute + UNION ALL + SELECT ROW(ARRAY['foo', 'bar']::text[])::composite_type_with_array_attribute; +$$; + +-- SETOF record - Returns multiple rows with structure defined in the function +CREATE OR REPLACE FUNCTION public.get_user_summary() +RETURNS SETOF record +LANGUAGE SQL STABLE +AS $$ + SELECT u.id, name, count(t.id) as todo_count + FROM public.users u + LEFT JOIN public.todos t ON t."user-id" = u.id + GROUP BY u.id, u.name; +$$; + +-- SETOF scalar_type - Returns multiple values of a basic type +CREATE OR REPLACE FUNCTION public.get_user_ids() +RETURNS SETOF bigint +LANGUAGE SQL STABLE +AS $$ + SELECT id FROM public.users; +$$; + + +-- Function returning view using scalar as input +CREATE OR REPLACE FUNCTION public.get_single_user_summary_from_view(search_user_id bigint) +RETURNS SETOF user_todos_summary_view +LANGUAGE SQL STABLE +ROWS 1 +AS $$ + SELECT * FROM user_todos_summary_view WHERE user_id = search_user_id; +$$; +-- Function returning view using table row as input +CREATE OR REPLACE FUNCTION public.get_single_user_summary_from_view(user_row users) +RETURNS SETOF user_todos_summary_view +LANGUAGE SQL STABLE +ROWS 1 +AS $$ + SELECT * FROM user_todos_summary_view WHERE user_id = user_row.id; +$$; +-- Function returning view using another view row as input +CREATE OR REPLACE FUNCTION public.get_single_user_summary_from_view(userview_row users_view) +RETURNS SETOF user_todos_summary_view +LANGUAGE SQL STABLE +ROWS 1 +AS $$ + SELECT * FROM user_todos_summary_view WHERE user_id = userview_row.id; +$$; + + +-- Function returning view using scalar as input +CREATE OR REPLACE FUNCTION public.get_todos_from_user(search_user_id bigint) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM todos WHERE "user-id" = search_user_id; +$$; +-- Function returning view using table row as input +CREATE OR REPLACE FUNCTION public.get_todos_from_user(user_row users) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM todos WHERE "user-id" = user_row.id; +$$; +-- Function returning view using another view row as input +CREATE OR REPLACE FUNCTION public.get_todos_from_user(userview_row users_view) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM todos WHERE "user-id" = userview_row.id; +$$; + +-- Valid postgresql function override but that produce an unresolvable postgrest function call +create function postgrest_unresolvable_function() returns void language sql as ''; +create function postgrest_unresolvable_function(a text) returns int language sql as 'select 1'; +create function postgrest_unresolvable_function(a int) returns text language sql as $$ + SELECT 'toto' +$$; +-- Valid postgresql function override with differents returns types depending of different arguments +create function postgrest_resolvable_with_override_function() returns void language sql as ''; +create function postgrest_resolvable_with_override_function(a text) returns int language sql as 'select 1'; +create function postgrest_resolvable_with_override_function(b int) returns text language sql as $$ + SELECT 'toto' +$$; +-- Function overrides returning setof tables +create function postgrest_resolvable_with_override_function(user_id bigint) returns setof users language sql stable as $$ + SELECT * FROM users WHERE id = user_id; +$$; +create function postgrest_resolvable_with_override_function(todo_id bigint, completed boolean) returns setof todos language sql stable as $$ + SELECT * FROM todos WHERE id = todo_id AND completed = completed; +$$; +-- Function override taking a table as argument and returning a setof +create function postgrest_resolvable_with_override_function(user_row users) returns setof todos language sql stable as $$ + SELECT * FROM todos WHERE "user-id" = user_row.id; +$$; + +create or replace function public.polymorphic_function_with_different_return(bool) returns int language sql as 'SELECT 1'; +create or replace function public.polymorphic_function_with_different_return(int) returns int language sql as 'SELECT 2'; +create or replace function public.polymorphic_function_with_different_return(text) returns text language sql as $$ SELECT 'foo' $$; + +create or replace function public.polymorphic_function_with_no_params_or_unnamed() returns int language sql as 'SELECT 1'; +create or replace function public.polymorphic_function_with_no_params_or_unnamed(bool) returns int language sql as 'SELECT 2'; +create or replace function public.polymorphic_function_with_no_params_or_unnamed(text) returns text language sql as $$ SELECT 'foo' $$; +-- Function with a single unnamed params that isn't a json/jsonb/text should never appears in the type gen as it won't be in postgrest schema +create or replace function public.polymorphic_function_with_unnamed_integer(int) returns int language sql as 'SELECT 1'; +create or replace function public.polymorphic_function_with_unnamed_json(json) returns int language sql as 'SELECT 1'; +create or replace function public.polymorphic_function_with_unnamed_jsonb(jsonb) returns int language sql as 'SELECT 1'; +create or replace function public.polymorphic_function_with_unnamed_text(text) returns int language sql as 'SELECT 1'; + +-- Functions with unnamed parameters that have default values +create or replace function public.polymorphic_function_with_unnamed_default() returns int language sql as 'SELECT 1'; +create or replace function public.polymorphic_function_with_unnamed_default(int default 42) returns int language sql as 'SELECT 2'; +create or replace function public.polymorphic_function_with_unnamed_default(text default 'default') returns text language sql as $$ SELECT 'foo' $$; + +-- Functions with unnamed parameters that have default values and multiple overloads +create or replace function public.polymorphic_function_with_unnamed_default_overload() returns int language sql as 'SELECT 1'; +create or replace function public.polymorphic_function_with_unnamed_default_overload(int default 42) returns int language sql as 'SELECT 2'; +create or replace function public.polymorphic_function_with_unnamed_default_overload(text default 'default') returns text language sql as $$ SELECT 'foo' $$; +create or replace function public.polymorphic_function_with_unnamed_default_overload(bool default true) returns int language sql as 'SELECT 3'; + +-- Test function with unnamed row parameter returning setof +CREATE OR REPLACE FUNCTION public.test_unnamed_row_setof(todos) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE "user-id" = $1."user-id"; +$$; + +CREATE OR REPLACE FUNCTION public.test_unnamed_row_setof(users) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE "user-id" = $1."id"; +$$; + + +CREATE OR REPLACE FUNCTION public.test_unnamed_row_setof(user_id bigint) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE "user-id" = user_id; +$$; + +-- Test function with unnamed row parameter returning scalar +CREATE OR REPLACE FUNCTION public.test_unnamed_row_scalar(todos) +RETURNS integer +LANGUAGE SQL STABLE +AS $$ + SELECT COUNT(*) FROM public.todos WHERE "user-id" = $1."user-id"; +$$; + +-- Test function with unnamed view row parameter +CREATE OR REPLACE FUNCTION public.test_unnamed_view_row(todos_view) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE "user-id" = $1."user-id"; +$$; + +-- Test function with multiple unnamed row parameters +CREATE OR REPLACE FUNCTION public.test_unnamed_multiple_rows(users, todos) +RETURNS SETOF todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos + WHERE "user-id" = $1.id + AND id = $2.id; +$$; + +-- Test function with unnamed row parameter returning composite +CREATE OR REPLACE FUNCTION public.test_unnamed_row_composite(users) +RETURNS composite_type_with_array_attribute +LANGUAGE SQL STABLE +AS $$ + SELECT ROW(ARRAY[$1.name])::composite_type_with_array_attribute; +$$; + +-- Function that returns a single element +CREATE OR REPLACE FUNCTION public.function_using_table_returns(user_row users) +RETURNS todos +LANGUAGE SQL STABLE +AS $$ + SELECT * FROM public.todos WHERE todos."user-id" = user_row.id LIMIT 1; +$$; + +CREATE OR REPLACE FUNCTION public.function_using_setof_rows_one(user_row users) +RETURNS SETOF todos +LANGUAGE SQL STABLE +ROWS 1 +AS $$ + SELECT * FROM public.todos WHERE todos."user-id" = user_row.id LIMIT 1; +$$; + +-- Function that return the created_ago computed field +CREATE OR REPLACE FUNCTION "public"."created_ago" ("public"."users_audit") RETURNS numeric LANGUAGE "sql" +SET + "search_path" TO '' AS $_$ + SELECT ROUND(EXTRACT(EPOCH FROM (NOW() - $1.created_at))); +$_$; + +-- Create a partitioned table for testing computed fields on partitioned tables +CREATE TABLE public.events ( + id bigint generated by default as identity, + created_at timestamptz default now(), + event_type text, + data jsonb, + primary key (id, created_at) +) partition by range (created_at); + +-- Create partitions for the events table +CREATE TABLE public.events_2024 PARTITION OF public.events +FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + +CREATE TABLE public.events_2025 PARTITION OF public.events +FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); + +-- Insert some test data +INSERT INTO public.events (created_at, event_type, data) +VALUES + ('2024-06-15', 'login', '{"user": "alice"}'), + ('2025-03-20', 'logout', '{"user": "bob"}'); + +-- Function that returns computed field for partitioned table +CREATE OR REPLACE FUNCTION "public"."days_since_event" ("public"."events") RETURNS numeric LANGUAGE "sql" +SET + "search_path" TO '' AS $_$ + SELECT ROUND(EXTRACT(EPOCH FROM (NOW() - $1.created_at)) / 86400); +$_$; + +-- Table with interval columns for testing interval type (nullable and not nullable) +CREATE TABLE public.interval_test ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + duration_required interval NOT NULL, + duration_optional interval +); + +-- Insert test data with interval values +INSERT INTO public.interval_test (duration_required, duration_optional) +VALUES + ('1 day 2 hours 30 minutes', '3 days 5 hours'), + ('1 week', NULL), + ('2 hours 15 minutes', '45 minutes'); + +-- Function that takes interval parameter and returns interval +CREATE OR REPLACE FUNCTION public.add_interval_to_duration( + base_duration interval, + additional_interval interval +) +RETURNS interval +LANGUAGE SQL +STABLE +AS $$ + SELECT base_duration + additional_interval; +$$; + +-- Function that takes a table row with interval and returns interval +CREATE OR REPLACE FUNCTION public.double_duration(interval_test_row public.interval_test) +RETURNS interval +LANGUAGE SQL +STABLE +AS $$ + SELECT interval_test_row.duration_required * 2; +$$; \ No newline at end of file diff --git a/packages/postgrest-typegen/test/introspection/fixtures/01-memes.sql b/packages/postgrest-typegen/test/introspection/fixtures/01-memes.sql new file mode 100644 index 000000000..d4909b7ab --- /dev/null +++ b/packages/postgrest-typegen/test/introspection/fixtures/01-memes.sql @@ -0,0 +1,2433 @@ + + +CREATE TABLE public.category ( + id serial NOT NULL PRIMARY KEY, + name text NOT NULL +); + +-- Fake policies +grant select, update(name) +on category +to postgres; + +create policy categories_update_policy +on category for update +to postgres +using(current_setting('my.username') IN (name)); + +INSERT INTO public.category (id, name) VALUES +(1, 'Funny'), +(2, 'Weird'), +(3, 'Dumb'), +(4, 'Cute'), +(5, 'Interesting'); + +CREATE TYPE meme_status AS ENUM ('new', 'old', 'retired'); + +CREATE TABLE public.memes ( + id serial NOT NULL PRIMARY KEY, + name text NOT NULL, + category INTEGER REFERENCES category(id), + metadata jsonb, + created_at TIMESTAMP NOT NULL, + status meme_status DEFAULT 'old' +); + +INSERT INTO public.memes (name, category, created_at) VALUES +('NO. Rage Face', 5, NOW()), +('"Not Bad" Obama Face', 5, NOW()), +('You Know I Had to Do It to Em', 5, NOW()), +('Rage Comics', 1, NOW()), +('Okay Guy', 4, NOW()), +('Derp', 2, NOW()), +('Jessi Slaughter', 2, NOW()), +('Brazzers', 3, NOW()), +('Dat Boi', 1, NOW()), +('Bad Luck Brian', 4, NOW()), +('Lemon Party', 1, NOW()), +('Philosoraptor', 4, NOW()), +('Fuck Yea', 3, NOW()), +('Harambe the Gorilla', 4, NOW()), +('Press F to Pay Respects', 1, NOW()), +('GamerGate', 4, NOW()), +('60''s Spider-Man', 1, NOW()), +('MonkaS', 2, NOW()), +('Dick Butt', 3, NOW()), +('Rage Guy (FFFFFUUUUUUUU-)', 4, NOW()), +('Blue Whale Challenge', 5, NOW()), +('Ricardo Milos', 3, NOW()), +('[10] Guy', 1, NOW()), +('Wat', 3, NOW()), +('Big Chungus', 1, NOW()), +('Haters Gonna Hate', 3, NOW()), +('Do You Even Lift?', 1, NOW()), +('Kappa', 5, NOW()), +('Sad Keanu', 5, NOW()), +('"You Are Already Dead" / "Omae Wa Mou Shindeiru"', 1, NOW()), +('Mega Milk / Titty Monster', 4, NOW()), +('Annoying Facebook Girl', 3, NOW()), +('Raise Your Dongers', 3, NOW()), +('Successful Black Man', 5, NOW()), +('Bear Grylls / Better Drink My Own Piss', 4, NOW()), +('Fight Club: 5/7 Movie', 5, NOW()), +('друг', 3, NOW()), +('Reaction Images', 2, NOW()), +('I Hope Senpai Will Notice Me', 1, NOW()), +('Cereal Guy', 4, NOW()), +('Serbia Strong / Remove Kebab', 3, NOW()), +('High Expectations Asian Father', 4, NOW()), +('PTSD Clarinet Boy', 2, NOW()), +('Homophobic Seal', 4, NOW()), +('Fus Ro Dah', 4, NOW()), +('Zalgo', 3, NOW()), +('Aww Yea Guy', 2, NOW()), +('Copypasta', 5, NOW()), +('Futurama Fry / Not Sure If', 2, NOW()), +('I Sexually Identify as an Attack Helicopter', 1, NOW()), +('Are You Serious Face / Seriously?', 4, NOW()), +('One Does Not Simply Walk into Mordor', 4, NOW()), +('Gangnam Style', 1, NOW()), +('Cool Story, Bro', 2, NOW()), +('True Story', 2, NOW()), +('Sweet Brown / Ain''t Nobody Got Time for That', 4, NOW()), +('Darude - Sandstorm', 4, NOW()), +('Creepypasta', 2, NOW()), +('Fuck Her Right in the Pussy / FHRITP', 2, NOW()), +('Based God', 3, NOW()), +('All the Things', 4, NOW()), +('Kek', 1, NOW()), +('Meatspin', 5, NOW()), +('Waifu', 3, NOW()), +('Belle Delphine', 5, NOW()), +('Facepalm', 5, NOW()), +('If You Know What I Mean', 4, NOW()), +('Bye Felicia', 3, NOW()), +('Emergence (Metamorphosis)', 3, NOW()), +('Hide The Pain Harold', 2, NOW()), +('Rule 63', 3, NOW()), +('I Know That Feel Bro', 2, NOW()), +('Cash Me Ousside / Howbow Dah', 2, NOW()), +('Deal With It', 3, NOW()), +('Momo Challenge', 1, NOW()), +('Socially Awkward Penguin', 2, NOW()), +('Nope! Chuck Testa', 3, NOW()), +('To Be Fair, You Have To Have a Very High IQ to Understand Rick and Morty', 5, NOW()), +('First World Problems', 2, NOW()), +('Fap', 4, NOW()), +('That Really Rustled My Jimmies', 1, NOW()), +('Moon Moon', 4, NOW()), +('The Most Interesting Man in the World', 5, NOW()), +('Dank Memes', 5, NOW()), +('Challenge Accepted', 3, NOW()), +('Xzibit Yo Dawg', 4, NOW()), +('Ayy LMAO', 5, NOW()), +('Grumpy Cat', 1, NOW()), +('Nyan Cat', 2, NOW()), +('''Dat Ass', 5, NOW()), +('Boxxy', 3, NOW()), +('2 Girls 1 Cup', 2, NOW()), +('Mr. Hands', 1, NOW()), +('Joseph Ducreux / Archaic Rap', 1, NOW()), +('Harlem Shake', 1, NOW()), +('Oh Crap / OMG Rage Face', 5, NOW()), +('Shrek Is Love, Shrek Is Life', 3, NOW()), +('Delete System32', 1, NOW()), +('Musically Oblivious 8th Grader', 2, NOW()), +('2/10 Would Not Bang', 5, NOW()), +('Insanity Wolf', 2, NOW()), +('Mocking SpongeBob', 4, NOW()), +('Success Kid / I Hate Sandcastles', 2, NOW()), +('JoJo''s Bizarre Adventure', 3, NOW()), +('The Tragedy of Darth Plagueis The Wise', 2, NOW()), +('What People Think I Do / What I Really Do', 2, NOW()), +('Rebecca Black - Friday', 3, NOW()), +('Tide POD Challenge', 2, NOW()), +('Look At All The Fucks I Give', 2, NOW()), +('U WOT M8', 4, NOW()), +('Tubgirl', 3, NOW()), +('Smile.jpg', 2, NOW()), +('Dafuq', 3, NOW()), +('Yaranaika? (やらないか)', 2, NOW()), +('The Fappening / Celebgate', 3, NOW()), +('That''s Racist!', 4, NOW()), +('Genius', 5, NOW()), +('I See What You Did There', 5, NOW()), +('Roll Safe', 3, NOW()), +('Rape Sloth', 3, NOW()), +('PewDiePie', 4, NOW()), +('Weird Flex But Ok', 3, NOW()), +('My Body Is Ready', 1, NOW()), +('Billy Herrington / Gachimuchi', 2, NOW()), +('Don''t Talk To Me Or My Son Ever Again', 2, NOW()), +('Disaster Girl', 3, NOW()), +('Cheeki Breeki', 1, NOW()), +('That Escalated Quickly', 5, NOW()), +('Son, I Am Disappoint', 5, NOW()), +('Nyannyancosplay / Hit or Miss', 3, NOW()), +('Jet Fuel Can''t Melt Steel Beams', 4, NOW()), +('Your Argument Is Invalid', 5, NOW()), +('B Button Emoji 🅱', 5, NOW()), +('Chubby Bubbles Girl', 3, NOW()), +('Miley Cyrus Sex Tape Facebook Scams', 1, NOW()), +('Homestuck', 2, NOW()), +('This Is So Sad Alexa Play Despacito', 3, NOW()), +('Majora''s Mask Creepypasta (BEN DROWNED)', 1, NOW()), +('Sweet Jesus Face', 2, NOW()), +('Come At Me Bro', 5, NOW()), +('Inglip', 5, NOW()), +('Swag', 2, NOW()), +('I Have No Idea What I''m Doing', 1, NOW()), +('Shut Up And Take My Money!', 2, NOW()), +('Neil deGrasse Tyson Reaction', 2, NOW()), +('"Y U NO" Guy', 4, NOW()), +('Rule 34', 5, NOW()), +('Overly Attached Girlfriend', 2, NOW()), +('Scumbag Steve', 2, NOW()), +('I Took an Arrow in the Knee', 1, NOW()), +('Rules of the Internet', 2, NOW()), +('You Don''t Say?', 1, NOW()), +('The Mandela Effect', 4, NOW()), +('Woman Yelling at a Cat', 2, NOW()), +('Good Guy Greg', 2, NOW()), +('ಠ_ಠ Look of Disapproval', 1, NOW()), +('Pepe the Frog', 5, NOW()), +('Ridiculously Photogenic Guy', 3, NOW()), +('Ancient Aliens', 1, NOW()), +('Has Anyone Really Been Far Even as Decided to Use Even Go Want to do Look More Like?', 5, NOW()), +('Slender Man', 2, NOW()), +('Doge', 1, NOW()), +('( ͡° ͜ʖ ͡°) / Lenny Face', 1, NOW()), +('Navy Seal Copypasta', 2, NOW()), +('Forever Alone', 4, NOW()), +('Zerg Rush', 2, NOW()), +('Trollface', 3, NOW()), +('My Little Pony: Friendship is Magic', 4, NOW()), +('Me Gusta', 5, NOW()), +('Do A Barrel Roll', 5, NOW()), +('Flipping Tables / (╯°□°)╯︵ ┻━┻', 2, NOW()), +('Ermahgerd', 4, NOW()), +('Loss', 2, NOW()), +('Dolan', 4, NOW()), +('Ugandan Knuckles', 4, NOW()), +('Yao Ming Face / Bitch Please', 1, NOW()), +('Duck Face', 3, NOW()), +('Boku no Pico', 2, NOW()), +('Senor Chang''s "Ha, Gay!"', 2, NOW()), +('Rickroll', 5, NOW()), +('Bowsette', 3, NOW()), +('Poker Face', 3, NOW()), +('And Not a Single Fuck Was Given That Day', 3, NOW()), +('Twitch Plays Pokemon', 3, NOW()), +('Desu', 3, NOW()), +('What Are Those?', 3, NOW()), +('TheLegend27', 5, NOW()), +('Karen', 5, NOW()), +('Keep Calm and Carry On', 5, NOW()), +('Katy t3h PeNgU1N oF d00m', 2, NOW()), +('Oh God Why', 2, NOW()), +('Polandball', 4, NOW()), +('Ahegaokin - アヘ顔菌', 4, NOW()), +('Slowpoke', 2, NOW()), +('Stonks', 5, NOW()), +('Instagram Quote Rebuttals / Hipster Edits', 3, NOW()), +('Shock Sites', 1, NOW()), +('Shoop Da Whoop', 4, NOW()), +('I Can Count to Potato', 2, NOW()), +('Spaghetti Stories', 3, NOW()), +('Advice Dog', 5, NOW()), +('420 Blaze It', 5, NOW()), +('James Doakes'' "Surprise Motherfucker"', 1, NOW()), +('Business Cat', 3, NOW()), +('Trolling', 5, NOW()), +('Side Eyeing Chloe', 5, NOW()), +('Tendies Stories', 3, NOW()), +('What Is This I Don''t Even', 2, NOW()), +('I''m Twelve Years Old and What is This?', 2, NOW()), +('This Is Fine', 3, NOW()), +('The Narwhal Bacons at Midnight', 4, NOW()), +('Mother of God', 2, NOW()), +('''Murica', 1, NOW()), +('I Came', 4, NOW()), +('ME!ME!ME!', 5, NOW()), +('Draw Me Like One of Your French Girls', 1, NOW()), +('Virgin vs. Chad', 2, NOW()), +('Ahegao', 5, NOW()), +('Are You A Wizard', 5, NOW()), +('Feels', 1, NOW()), +('Pokémon', 5, NOW()), +('Strutting Leo', 4, NOW()), +('Troll Science / Troll Physics', 5, NOW()), +('Sanic Hegehog', 4, NOW()), +('Kekistan', 2, NOW()), +('Mom''s Spaghetti', 2, NOW()), +('I Lied', 2, NOW()), +('Freddie Mercury Rage Pose', 2, NOW()), +('Conspiracy Keanu', 1, NOW()), +('Five Nights at Freddy''s', 1, NOW()), +('Indestructible Nokia 3310', 4, NOW()), +('Distracted Boyfriend', 4, NOW()), +('Piper Perri Surrounded', 3, NOW()), +('Shadman / Shadbase', 1, NOW()), +('LOL Guy', 4, NOW()), +('It''s A Trap!', 1, NOW()), +('Staredad', 3, NOW()), +('Feels Good Man', 3, NOW()), +('ZONE-sama', 1, NOW()), +('Me and the Boys', 4, NOW()), +('Trap', 1, NOW()), +('Confused Black Girl', 5, NOW()), +('WE WUZ KINGS', 4, NOW()), +('Ryan Gosling', 4, NOW()), +('Notices Bulge / OwO What''s This?', 5, NOW()), +('???? PROFIT!!!!', 4, NOW()), +('30-Year-Old Boomer', 4, NOW()), +('Galaxy Brain', 4, NOW()), +('Idiot Nerd Girl', 1, NOW()), +('Pool''s Closed', 4, NOW()), +('Kony 2012', 2, NOW()), +('Video 1444', 1, NOW()), +('Pepega', 3, NOW()), +('Huahuehuahue', 1, NOW()), +('O RLY?', 2, NOW()), +('Feel Like a Sir', 2, NOW()), +('Absolute Unit', 3, NOW()), +('An Hero', 5, NOW()), +('SOON', 1, NOW()), +('Stahp', 5, NOW()), +('Weeaboo', 3, NOW()), +('The Cake Is a Lie', 2, NOW()), +('Lord Marquaad E', 5, NOW()), +('"Miracles" / Fucking Magnets, How Do They Work?', 2, NOW()), +('THEN WHO WAS PHONE?', 2, NOW()), +('Work-Safe Porn', 1, NOW()), +('Logan Paul''s Suicide Forest Video', 2, NOW()), +('Yes, This is Dog', 1, NOW()), +('Chris-Chan / CWC / Christian Weston Chandler', 4, NOW()), +('REEEEEEE', 5, NOW()), +('Honey Badger', 3, NOW()), +('Tree Fiddy', 2, NOW()), +('Greentext Stories', 3, NOW()), +('Minecraft Creeper', 1, NOW()), +('Surprised Pikachu', 1, NOW()), +('Big Smoke''s Order', 3, NOW()), +('College Freshman', 4, NOW()), +('All Your Base Are Belong to Us', 1, NOW()), +('Like A Boss', 3, NOW()), +('Jeff the Killer', 1, NOW()), +('I Herd U Liek Mudkips', 1, NOW()), +('Thicc', 4, NOW()), +('Hover Hand', 3, NOW()), +('The Bongcheon-Dong Ghost', 2, NOW()), +('Lavender Town Syndrome Creepypasta', 2, NOW()), +('Foul Bachelor Frog', 1, NOW()), +('John is Kill', 1, NOW()), +('College Liberal', 2, NOW()), +('Impossibru', 4, NOW()), +('I Accidentally', 2, NOW()), +('U MAD?', 4, NOW()), +('It''s Over 9000!', 5, NOW()), +('Ligma', 5, NOW()), +('Newfags Can''t Triforce', 3, NOW()), +('He Protec but He Also Attac', 1, NOW()), +('Feels Bad Man / Sad Frog', 2, NOW()), +('Are You Fucking Kidding Me?', 5, NOW()), +('Leeroy Jenkins', 2, NOW()), +('I Don''t Want to Live on This Planet Anymore', 2, NOW()), +('Courage Wolf', 1, NOW()), +('Cinnamon Challenge', 5, NOW()), +('But That''s None of My Business', 1, NOW()), +('Goatse', 3, NOW()), +('4chan', 1, NOW()), +('241543903 / Heads In Freezers', 1, NOW()), +('Half-Life 3 Confirmed', 4, NOW()), +('Antoine Dodson / Bed Intruder', 5, NOW()), +('9GAG', 4, NOW()), +('Bobs and Vegana', 1, NOW()), +('The Circle Game', 2, NOW()), +('Is This a Pigeon?', 3, NOW()), +('Good Luck, I''m Behind 7 Proxies', 3, NOW()), +('FAIL / Epic Fail', 2, NOW()), +('Hey Girls, Did You Know...', 5, NOW()), +('Virgin-Killing Sweater', 4, NOW()), +('NPC Wojak', 3, NOW()), +('Deez Nuts', 4, NOW()), +('Handsome Face', 1, NOW()), +('Reaction Guys / Gaijin 4Koma', 2, NOW()), +('In Soviet Russia...', 5, NOW()), +('Scumbag Stacy', 1, NOW()), +('Moth Lamp', 1, NOW()), +('OK Symbol 👌', 1, NOW()), +('Cuck', 1, NOW()), +('Lazy College Senior', 1, NOW()), +('T-Pose', 3, NOW()), +('Filthy Frank', 3, NOW()), +('My Brain is Full of Fuck', 1, NOW()), +('Third World Success', 1, NOW()), +('Rain Drop Drop Top', 3, NOW()), +('Scarlett Johansson Leaked Nudes', 3, NOW()), +('Hugh Mungus', 3, NOW()), +('Hentai Quotes', 4, NOW()), +('Trigger', 2, NOW()), +('Technoviking', 5, NOW()), +('YOLO', 5, NOW()), +('They Don''t Think It Be Like It Is But It Do', 5, NOW()), +('I Came Out to Have a Good Time and I''m Honestly Feeling So Attacked Right Now', 1, NOW()), +('Condescending Wonka / Creepy Wonka', 1, NOW()), +('Trash Doves', 2, NOW()), +('Wojak / Feels Guy', 1, NOW()), +('Electric Boogaloo', 3, NOW()), +('*Tips Fedora*', 3, NOW()), +('I''m Already Tracer', 4, NOW()), +('Epic Sax Guy', 2, NOW()), +('Yes - Roundabout / To Be Continued', 2, NOW()), +('How Is Babby Formed?', 4, NOW()), +('POMF =3', 5, NOW()), +('Some Men Just Want to Watch the World Burn', 4, NOW()), +('Minecraft', 1, NOW()), +('Honey Boo Boo Child', 3, NOW()), +('I Dunno LOL ¯\(°_o)/¯', 2, NOW()), +('Spider-Man Pointing at Spider-Man', 4, NOW()), +('Zyzz', 4, NOW()), +('Wood Sitting on a Bed', 2, NOW()), +('X, X Everywhere', 3, NOW()), +('Clown Pepe / Honk Honk / Clown World', 2, NOW()), +('Computer Reaction Faces', 2, NOW()), +('Git Gud', 1, NOW()), +('He-Man Sings / HEYYEYAAEYAAAEYAEYAA', 5, NOW()), +('Ultra Instinct Shaggy', 2, NOW()), +('Za Warudo / WRYYYYY', 5, NOW()), +('Ruined Childhood', 1, NOW()), +('fsjal', 5, NOW()), +('Giovanna Plowman / Tampon Girl', 3, NOW()), +('Steven Crowder''s "Change My Mind" Campus Sign', 4, NOW()), +('Tumblr', 5, NOW()), +('Jesus is a Jerk', 5, NOW()), +('Wombo Combo', 2, NOW()), +('Yee', 5, NOW()), +('This Kills The Crab', 4, NOW()), +('BME Pain Olympics', 1, NOW()), +('Crying Cat', 2, NOW()), +('Trypophobia', 2, NOW()), +('Brent Rambo', 3, NOW()), +('Tenso', 5, NOW()), +('Duwang', 3, NOW()), +('Johny Johny Yes Papa', 1, NOW()), +('Thot', 3, NOW()), +('Butthurt Dweller / Gordo Granudo', 3, NOW()), +('Didn''t Read LOL', 4, NOW()), +('Russian Sleep Experiment', 3, NOW()), +('Trigglypuff', 4, NOW()), +('Puking Rainbows', 3, NOW()), +('Coldsteel The Hedgeheg', 3, NOW()), +('Moon Man', 3, NOW()), +('Wingboner & Clopping', 2, NOW()), +('Sharkeisha Fight Video', 2, NOW()), +('In This Moment I Am Euphoric', 1, NOW()), +('This Isn''t Even My Final Form', 4, NOW()), +('Dashcon', 3, NOW()), +('Vivian James', 4, NOW()), +('Squidward''s Suicide', 2, NOW()), +('Hail Hydra', 5, NOW()), +('Mormon Porn / Bubble Porn', 4, NOW()), +('Vengeance Dad', 4, NOW()), +('Everybody Walk the Dinosaur', 1, NOW()), +('Puts On Sunglasses / YEEEEAAAHHH', 5, NOW()), +('Casually Pepper Spray Everything Cop', 3, NOW()), +('The Dab', 1, NOW()), +('Carl!', 3, NOW()), +('You Must Construct Additional Pylons!', 2, NOW()), +('Expand Dong', 4, NOW()), +('1 Guy 1 Jar', 2, NOW()), +('PROTIP', 1, NOW()), +('Longcat', 5, NOW()), +('Thanks, Obama!', 2, NOW()), +('Shooting Stars', 4, NOW()), +('Ash Pedreiro / Dat Ash', 2, NOW()), +('Awesome Face / Epic Smiley', 1, NOW()), +('My Immortal / The Worst Fanfiction Ever', 2, NOW()), +('Almost Politically Correct Redneck', 3, NOW()), +('Overly Manly Man', 2, NOW()), +('Natalia Poklonskaya', 3, NOW()), +('Coomer', 5, NOW()), +('CSI 4 Pane Comics', 2, NOW()), +('Skull Trumpet / Doot Doot', 5, NOW()), +('SpongeGar / Primitive Sponge / Caveman Spongebob', 3, NOW()), +('Confused Nick Young', 1, NOW()), +('Gamer Joker / Gamers Rise Up / We Live in a Society', 1, NOW()), +('Prepare Your Anus', 3, NOW()), +('Creepy Chan (Allison Harvard)', 1, NOW()), +('Nothing To Do Here / Jet Pack Guy', 3, NOW()), +('Pretty Cool Guy', 2, NOW()), +('Zoë Quinn', 3, NOW()), +('Mr. Bones'' Wild Ride', 1, NOW()), +('Hitler''s "Downfall" Parodies', 5, NOW()), +('Divide By Zero', 1, NOW()), +('48÷2(9+3) = ?', 3, NOW()), +('Baneposting', 2, NOW()), +('The Grifter', 5, NOW()), +('Unexpected John Cena / And His Name is John Cena', 2, NOW()), +('Check Your Privilege', 2, NOW()), +('It Is Wednesday My Dudes', 3, NOW()), +('We Live In a Society Social Media Reactions', 5, NOW()), +('Friendship Ended With Mudasir', 3, NOW()), +('Neckbeard', 5, NOW()), +('tl;dr', 4, NOW()), +('Trololo Guy', 2, NOW()), +('Hentai Woody / 変態ウッディー', 2, NOW()), +('Cupcakes', 3, NOW()), +('OP is a Faggot', 3, NOW()), +('Cats', 3, NOW()), +('Super Smash Brothers', 4, NOW()), +('Earth-chan', 1, NOW()), +('Derpy Hooves', 3, NOW()), +('Unhelpful High School Teacher', 5, NOW()), +('Nicolas Cage', 1, NOW()), +('Dubs Guy / “Check ’Em”', 5, NOW()), +('Burger King Foot Lettuce', 5, NOW()), +('Brian Peppers', 2, NOW()), +('Arthur''s Fist', 1, NOW()), +('Horse Head Mask', 2, NOW()), +('Deus Vult', 3, NOW()), +('Smudge the Cat', 2, NOW()), +('You Know Nothing, Jon Snow', 3, NOW()), +('The Rake', 3, NOW()), +('Herobrine', 3, NOW()), +('Salt Bae', 3, NOW()), +('Horrifying House-guest / Shadowlurker', 1, NOW()), +('Caught Me Sleeping / Bae Caught Me Slippin', 4, NOW()), +('Math Lady / Confused Lady', 4, NOW()), +('KEKW', 4, NOW()), +('Shitposting', 2, NOW()), +('Warlizard Gaming Forum', 4, NOW()), +('It Was Me, Dio!', 3, NOW()), +('Blue Waffle', 5, NOW()), +('Memes', 1, NOW()), +('Super S Stussy', 5, NOW()), +('Reddit', 4, NOW()), +('Guile''s Theme Goes with Everything', 5, NOW()), +('Alternate Universe', 4, NOW()), +('Yoshi Committed Tax Fraud', 1, NOW()), +('Somebody Toucha My Spaghet', 1, NOW()), +('Bone Hurting Juice', 5, NOW()), +('Why Not Both? / Why Don''t We Have Both?', 4, NOW()), +('Nope.avi', 1, NOW()), +('Steven Universe', 3, NOW()), +('Cave Johnson / Combustible Lemons', 5, NOW()), +('Kanye Interrupts / Imma Let You Finish', 5, NOW()), +('*Record Scratch* *Freeze Frame*', 4, NOW()), +('Old Gregg', 3, NOW()), +('I, For One, Welcome Our New Insect Overlords', 2, NOW()), +('Goodnight Sweet Prince', 2, NOW()), +('Padoru', 4, NOW()), +('"Anime Was a Mistake"', 2, NOW()), +('>Shadman', 1, NOW()), +('Finger Boxes', 4, NOW()), +('Feeling Cute, Might Delete Later', 2, NOW()), +('Peachette / Super Crown', 4, NOW()), +('Imminent Ned / Brace Yourselves, Winter is Coming', 5, NOW()), +('Trollestia / Molestia / Tyrant Celestia', 5, NOW()), +('Seems Legit / Sounds Legit', 3, NOW()), +('Potato Jesus', 3, NOW()), +('*Teleports Behind You* Nothing Personal, Kid', 3, NOW()), +('McKayla is Not Impressed', 5, NOW()), +('Now Kiss!', 4, NOW()), +('Donde Esta La Biblioteca / Spanish Rap', 2, NOW()), +('Candle Cove', 2, NOW()), +('The Glorious PC Gaming Master Race', 1, NOW()), +('The Dress / What Color Is This Dress?', 2, NOW()), +('Waffles? Don''t You Mean Carrots?', 4, NOW()), +('Karate Kyle', 4, NOW()), +('Spoderman / Spodermen', 5, NOW()), +('Steamed Hams', 2, NOW()), +('Too Much Water', 1, NOW()), +('Slaps Roof of Car', 3, NOW()), +('Bitches Love Smiley Faces', 4, NOW()), +('Phteven / Tuna the Dog', 1, NOW()), +('Sudden Clarity Clarence', 5, NOW()), +('The Backrooms', 5, NOW()), +('Mereana Mordegard Glesgorv', 1, NOW()), +('Dear Sister Parodies / "Mmm Whatcha'' Say"', 5, NOW()), +('Tourette''s Guy', 1, NOW()), +('Begone, Thot', 1, NOW()), +('A Cat Is Fine Too', 4, NOW()), +('There Are No Girls on the Internet', 2, NOW()), +('Go Home, You Are Drunk', 1, NOW()), +('Pawn Stars', 1, NOW()), +('Spoopy', 1, NOW()), +('Gotta Go Fast', 1, NOW()), +('Kill Yourself', 1, NOW()), +('Banana For Scale', 4, NOW()), +('My Parents Are Dead / Batman Slapping Robin', 1, NOW()), +('uwu', 1, NOW()), +('Gary Oak', 1, NOW()), +('The Main Difference Between Europe and USA', 3, NOW()), +('9 + 10 = 21', 4, NOW()), +('Shit Was So Cash', 4, NOW()), +('Name Puns', 4, NOW()), +('Wonderwall', 3, NOW()), +('Pokeparents / Pokedads', 4, NOW()), +('THE GAME', 3, NOW()), +('Chad Thundercock', 2, NOW()), +('Eyebrows on Fleek', 1, NOW()), +('Weegee', 2, NOW()), +('Montage Parodies', 1, NOW()), +('Actual Advice Mallard', 1, NOW()), +('Inception', 2, NOW()), +('You''re Doing It Wrong', 3, NOW()), +('This Looks Shopped', 2, NOW()), +('Advice Animals', 3, NOW()), +('He Will Not Divide Us', 3, NOW()), +('Close Enough', 1, NOW()), +('"This Is the Ideal Male Body"', 4, NOW()), +('Battletoads Pre-order', 5, NOW()), +('Boardroom Suggestion', 2, NOW()), +('Cocaine Bear', 3, NOW()), +('Carl the Cuck and AIDS Skrillex', 5, NOW()), +('Oh, You', 4, NOW()), +('Epic Beard Man', 2, NOW()), +('Everything Went Better Than Expected', 2, NOW()), +('Ah Shit, Here We Go Again', 4, NOW()), +('Limes Guy / Why Can''t I Hold All These Limes?', 1, NOW()), +('"Mitochondria is the Powerhouse of the Cell"', 2, NOW()), +('Normie', 3, NOW()), +('Alt + F4', 5, NOW()), +('Download More RAM', 3, NOW()), +('Confused Travolta', 2, NOW()), +('Troll Quotes', 4, NOW()), +('Nigel Thornberry Remixes', 1, NOW()), +('Hipster Mermaid / Hipster Ariel', 4, NOW()), +('My Name Is Jeff', 2, NOW()), +('Daily Dose / Piccolo Dick', 4, NOW()), +('X Grab My Y', 3, NOW()), +('Cory in the House', 5, NOW()), +('Doomer', 3, NOW()), +('Zangief Kid', 4, NOW()), +('Descriptive Noise', 1, NOW()), +('Jebaited', 4, NOW()), +('How Can She Slap?', 5, NOW()), +('Cracking Open a Cold One With the Boys', 4, NOW()), +('Release The Kraken!', 2, NOW()), +('What Has Been Seen Cannot Be Unseen', 1, NOW()), +('Nobody:', 4, NOW()), +('Yeah Science, Bitch', 4, NOW()), +('Diabeetus', 4, NOW()), +('Cake Farts', 3, NOW()), +('Ted Cruz Zodiac Killer', 2, NOW()), +('Anti-Joke Chicken', 2, NOW()), +('spritecranberry.net', 5, NOW()), +('Pacha Edits / When The Sun Hits That Ridge Just Right', 1, NOW()), +('Sonic.exe', 1, NOW()), +('That''s What She Said', 4, NOW()), +('Bee Movie Script / According To All Known Laws Of Aviation', 1, NOW()), +('JonTron', 2, NOW()), +('Girls Laughing', 3, NOW()), +('YouTube Poop / YTP', 5, NOW()), +('It''s Something', 3, NOW()), +('U Jelly?', 2, NOW()), +('Trolldad', 4, NOW()), +('Gabe Newell', 5, NOW()), +('Wololo', 1, NOW()), +('Stefán Karl Stefánsson / Robbie Rotten', 3, NOW()), +('Dragon Dildos', 5, NOW()), +('PINGAS', 4, NOW()), +('Oh Stop It, You', 2, NOW()), +('This Ain''t It, Chief', 4, NOW()), +('Who''s Getting the Best Head?', 4, NOW()), +('Feels Good', 1, NOW()), +('Sam Hyde', 3, NOW()), +('What in Tarnation', 1, NOW()), +('This Nigga Eating Beans', 1, NOW()), +('We Are Number One', 2, NOW()), +('Happy Merchant', 1, NOW()), +('Netflix and Chill', 1, NOW()), +('Right In Front Of My Salad', 4, NOW()), +('You Keep Using That Word, I Do Not Think It Means What You Think It Means', 1, NOW()), +('Om Nom Nom Nom', 4, NOW()), +('The Rock Driving', 1, NOW()), +('RIP in Peace', 3, NOW()), +('SCP Foundation', 1, NOW()), +('Hipster Kitty', 4, NOW()), +('What''s All This Racket? / Mirada Fija', 1, NOW()), +('BORN TO DIE / WORLD IS A FUCK / Kill Em All 1989 / I am trash man / 410,757,864,530 DEAD COPS', 3, NOW()), +('Bepis', 2, NOW()), +('Why Do Slavs Squat? / Slav Squat', 3, NOW()), +('Ken Bone', 4, NOW()), +('Team Fortress 2', 5, NOW()), +('Butthurt', 5, NOW()), +('It''s Free Real Estate', 5, NOW()), +('Tits or GTFO', 2, NOW()), +('Cult of Kek', 3, NOW()), +('Gabe the Dog / Bork Remixes', 2, NOW()), +('Forced to Drink Milk', 4, NOW()), +('Meme Man', 3, NOW()), +('Interior Semiotics', 2, NOW()), +('YouTube', 5, NOW()), +('Rekt', 1, NOW()), +('I Put on My Robe and Wizard Hat', 1, NOW()), +('Thai Political Crisis Breakup', 3, NOW()), +('Furries', 5, NOW()), +('The Wadsworth Constant', 3, NOW()), +('Bogdanoff Twins', 1, NOW()), +('Do Want / Do Not Want', 4, NOW()), +('Brendan Fraser''s Alimony / Just Fuck My Shit Up', 5, NOW()), +('Actual Cannibal Shia LaBeouf', 5, NOW()), +('Ah, I See You''re a Man of Culture As Well', 5, NOW()), +('RWBY', 1, NOW()), +('It''s Dangerous to Go Alone! Take This', 3, NOW()), +('Double Rainbow', 2, NOW()), +('You Just Activated My Trap Card!', 3, NOW()), +('Oh Fuck Yeah Spread It', 5, NOW()), +('Adalia Rose', 3, NOW()), +('Dark Souls', 4, NOW()), +('Guys Literally Only Want One Thing And It''s Fucking Disgusting', 4, NOW()), +('Zuckerberg Note Pass', 4, NOW()), +('Asians in the Library', 4, NOW()), +('Conceited Reaction', 4, NOW()), +('Undertale', 2, NOW()), +('Ben Swolo', 3, NOW()), +('This Is Why We Can''t Have Nice Things', 3, NOW()), +('Bee Movie', 2, NOW()), +('Ted the Caver', 2, NOW()), +('Give Her The Dick', 5, NOW()), +('Why Are We Still Here? Just To Suffer?', 3, NOW()), +('Boruto''s Dad', 2, NOW()), +('Suh Dude', 5, NOW()), +('The Casting Couch', 4, NOW()), +('Soy Boy', 1, NOW()), +('Deep Fried Memes', 4, NOW()), +('They Told Me I Could Be Anything I Wanted', 3, NOW()), +('Wew Lad', 1, NOW()), +('Put Your Finger Here', 2, NOW()), +('Gaben', 3, NOW()), +('Internet Husband', 5, NOW()), +('Did He Died?', 4, NOW()), +('Rare Pepe', 3, NOW()), +('The Song of My People!', 4, NOW()), +('Ladies and Gentlemen, We Got Him', 5, NOW()), +('Justin Bieber', 2, NOW()), +('SO HARDCORE', 5, NOW()), +('Shit Tyrone, Get It Together', 1, NOW()), +('Chonk / Oh Lawd He Comin''', 5, NOW()), +('Le Monke / Uh Oh Stinky', 2, NOW()), +('Costanza.jpg / George Costanza Reaction Face', 1, NOW()), +('"I Saw Flying Lotus in a Grocery Store..." Copypasta', 2, NOW()), +('Does Bruno Mars Is Gay?', 4, NOW()), +('Daquan', 5, NOW()), +('Pokémon Creepy Black', 5, NOW()), +('Ebola-chan', 2, NOW()), +('Katawa Shoujo', 4, NOW()), +('Candlejack', 1, NOW()), +('They''re Good Dogs Brent', 5, NOW()), +('Kreygasm', 5, NOW()), +('Three Wolf Moon', 3, NOW()), +('Dating Site Murderer', 2, NOW()), +('Supa Hot Fire', 1, NOW()), +('Full Retard', 4, NOW()), +('"It''s Gonna Be May"', 1, NOW()), +('Futurama Zoidberg / Why Not Zoidberg?', 1, NOW()), +('Yeet', 3, NOW()), +('Skeleton War', 5, NOW()), +('Gonna Cry? Gonna Piss Your Pants Maybe?', 2, NOW()), +('I''m Here To Kick Ass And Chew Bubblegum', 4, NOW()), +('Edgy', 4, NOW()), +('2Spooky', 1, NOW()), +('Dindu Nuffin', 3, NOW()), +('No Fap September / No Fap Months', 3, NOW()), +('Aesthetic', 2, NOW()), +('Make Me a Sandwich', 5, NOW()), +('Chemistry Cat', 4, NOW()), +('Ainsley Harriott', 3, NOW()), +('Soy Boy Face', 5, NOW()), +('I Like Turtles', 3, NOW()), +('Dog Fort', 1, NOW()), +('SpongeBob SquarePants', 4, NOW()), +('JoJo''s Pose', 3, NOW()), +('Crying Michael Jordan', 2, NOW()), +('Doom Paul / It''s Happening', 3, NOW()), +('PogChamp', 2, NOW()), +('hunter2', 4, NOW()), +('Oh Fuck, Put It Back In', 3, NOW()), +('Whomst', 1, NOW()), +('This Man (Ever Dream This Man)', 1, NOW()), +('Agar.io', 1, NOW()), +('The Undertaker Threw Mankind Off Hell in a Cell', 2, NOW()), +('Suicide Mouse', 5, NOW()), +('People Die If They Are Killed', 3, NOW()), +('I Should Buy a Boat Cat', 3, NOW()), +('Amber Lamps', 2, NOW()), +('My Faggot Dog', 4, NOW()), +('Keemstar', 4, NOW()), +('Fukouna Shoujo 03', 1, NOW()), +('Damn Daniel', 1, NOW()), +('Overwatch', 5, NOW()), +('Dub the Dew', 3, NOW()), +('Kill It With Fire', 1, NOW()), +('Instructions Unclear', 5, NOW()), +('Black Guy on the Phone', 4, NOW()), +('Adventure Time', 4, NOW()), +('Sheltered College Freshman', 1, NOW()), +('Didney Worl', 4, NOW()), +('Bruh', 3, NOW()), +('It''s So Fucking Big', 4, NOW()), +('8chan / 8kun', 2, NOW()), +('iPhone Whale', 1, NOW()), +('This Is Sparta!', 5, NOW()), +('My Name Is Yoshikage Kira', 2, NOW()), +('Is This a JoJo Reference?', 3, NOW()), +('Aww Yiss', 1, NOW()), +('Succ', 2, NOW()), +('Persian Cat Room Guardian', 1, NOW()), +('Ight Imma Head Out', 3, NOW()), +('GachiGASM / GachiBASS', 3, NOW()), +('Emoticons', 5, NOW()), +('Cats Wanting Fruit Loops', 4, NOW()), +('Eggplant Emoji 🍆', 5, NOW()), +('Dummy Thicc', 5, NOW()), +('You''ve Been Gnomed', 3, NOW()), +('Drake The Type Of...', 1, NOW()), +('Ramirez, Do Everything!', 1, NOW()), +('ROFLcopter', 1, NOW()), +('Florida Man', 4, NOW()), +('Fap Guy', 4, NOW()), +('LOLWUT', 5, NOW()), +('Understandable, Have a Nice Day', 3, NOW()), +('Crab Rave', 5, NOW()), +('What You Think You Look Like vs. What You Actually Look Like', 5, NOW()), +('OK Boomer', 5, NOW()), +('Pokéfusion / Pokémon Fusion', 3, NOW()), +('INB4', 4, NOW()), +('Emo Dad', 1, NOW()), +('FrankerZ', 2, NOW()), +('Godwin''s Law', 3, NOW()), +('Hello Darkness, My Old Friend', 2, NOW()), +('[Intensifies]', 1, NOW()), +('Snowclone', 1, NOW()), +('3 Guys 1 Hammer', 1, NOW()), +('MULTI-TRACK DRIFTING', 1, NOW()), +('Impact', 3, NOW()), +('Brother, May I Have Some Oats', 2, NOW()), +('Menacing / ゴゴゴゴ', 2, NOW()), +('Demotivational Posters', 4, NOW()), +('I Have The Weirdest Boner', 5, NOW()), +('You Had One Job', 4, NOW()), +('Doki Doki Literature Club', 2, NOW()), +('Oh? You''re Approaching Me? / JoJo Approach', 3, NOW()), +('Harp Darp / Herp Derp', 3, NOW()), +('Shut Down Everything', 5, NOW()), +('Designated Shitting Streets / Poo in the Loo', 3, NOW()), +('Woll Smoth', 3, NOW()), +('First Day on the Internet Kid', 4, NOW()), +('Wheaton''s Law', 5, NOW()), +('Fedora Shaming', 1, NOW()), +('I Crave That Mineral', 3, NOW()), +('I''m Rick Harrison and This Is My Pawn Shop', 3, NOW()), +('Obedece a la morsa / Obey the walrus', 3, NOW()), +('Confession Bear', 3, NOW()), +('Roof Koreans', 1, NOW()), +('Brother Sharp (犀利哥)', 3, NOW()), +('Grammar Nazi', 2, NOW()), +('Delet This', 4, NOW()), +('I Must Go', 2, NOW()), +('Pokepuns', 4, NOW()), +('Vaporwave', 4, NOW()), +('Tony Kornheiser''s "Why"', 4, NOW()), +('My Face When (MFW) / That Face When (TFW)', 1, NOW()), +('Cringeworthy', 4, NOW()), +('Pepperidge Farm Remembers', 2, NOW()), +('Solaire of Astora', 2, NOW()), +('Kyubey', 5, NOW()), +('Nigga Stole My Bike', 1, NOW()), +('HNNNNNNG', 3, NOW()), +('Coffin Dance / Dancing Pallbearers', 4, NOW()), +('It''s Goofy Time!', 2, NOW()), +('Let''s Get This Bread', 3, NOW()), +('Derpina', 1, NOW()), +('fortniteburger.net', 1, NOW()), +('Crying Laughing Emoji 😂', 4, NOW()), +('Loli-chan', 2, NOW()), +('Hipster Barista', 1, NOW()), +('Friend Zone', 2, NOW()), +('Marauder Shields', 3, NOW()), +('Falcon Punch', 2, NOW()), +('"Tunak Tunak Tun" Dance', 2, NOW()), +('The Illuminati', 1, NOW()), +('You Gonna Get Raped', 5, NOW()), +('Destroy Dick December', 4, NOW()), +('I Will Survive', 1, NOW()), +('Increasingly Verbose Memes', 4, NOW()), +('Gnome Child', 1, NOW()), +('Thanos Car', 2, NOW()), +('I Didn''t Choose The Thug Life, The Thug Life Chose Me', 2, NOW()), +('Exploitables', 3, NOW()), +('Autonomous Sensory Meridian Response (ASMR)', 5, NOW()), +('Luke, I am Your Father', 1, NOW()), +('Gold Membership Trolling', 1, NOW()), +('Matrix Morpheus', 2, NOW()), +('They See Me Rollin''', 3, NOW()), +('Anime / Manga', 5, NOW()), +('Cthulhu', 4, NOW()), +('YTMND', 1, NOW()), +('Face Swap', 1, NOW()), +('I''m The Goddamn Batman', 3, NOW()), +('The Barber', 3, NOW()), +('Hackerman', 4, NOW()), +('Pepe Silvia', 3, NOW()), +('Cole Sprouse''s Tumblr Experiment', 3, NOW()), +('Gremlin D.Va', 4, NOW()), +('Mariah Mallad (Momokun)', 1, NOW()), +('Best Cry Ever', 3, NOW()), +('Shrek', 3, NOW()), +('Goth GF', 4, NOW()), +('Rose / randytaylor69', 4, NOW()), +('League of Legends', 3, NOW()), +('Women Logic', 5, NOW()), +('Queensland Rail Etiquette Posters', 4, NOW()), +('/r9k/', 4, NOW()), +('Do It Faggot', 5, NOW()), +('When I''m Bored', 1, NOW()), +('Dimitri Finds Out', 3, NOW()), +('Disintegration Effect / I Don''t Feel So Good', 2, NOW()), +('Give Pikachu a Face', 4, NOW()), +('Sonic the Hedgehog', 1, NOW()), +('Mesothelioma Ad Copypasta', 4, NOW()), +('2014 Tumblr-4chan Raids', 5, NOW()), +('Amy''s Baking Company PR Scandal', 1, NOW()), +('Donald Trump', 5, NOW()), +('NO U', 1, NOW()), +('Sonichu', 5, NOW()), +('QUALITY', 2, NOW()), +('Lyra Plushie', 3, NOW()), +('*breath in* Boi', 4, NOW()), +('Doggo', 2, NOW()), +('Everything Changed When The Fire Nation Attacked', 1, NOW()), +('Big Man Tyrone', 1, NOW()), +('Autistic Screeching', 4, NOW()), +('Cleganebowl', 4, NOW()), +('When You See it...', 1, NOW()), +('Unflattering Beyonce', 3, NOW()), +('Apply Cold Water To That Burn', 4, NOW()), +('COMBO BREAKER', 4, NOW()), +('Put Shoe on Head', 5, NOW()), +('It Will Be Fun, They Said', 3, NOW()), +('Trumpet Boy', 3, NOW()), +('Blinking White Guy', 2, NOW()), +('TR-8R the Stormtrooper', 2, NOW()), +('QWOP', 3, NOW()), +('Touhou Project (東方Project)', 3, NOW()), +('Anti-Zombie Fortress', 4, NOW()), +('This is Bob', 2, NOW()), +('Planking', 3, NOW()), +('Poot Lovato', 4, NOW()), +('Giga Pudding', 4, NOW()), +('I''m Ethan Bradberry', 4, NOW()), +('Pun Dog', 4, NOW()), +('We''re a Culture, Not a Costume', 4, NOW()), +('Rape Face', 1, NOW()), +('Racists On 4chan', 4, NOW()), +('Ceiling Cat', 5, NOW()), +('"How Do You Do, Fellow Kids?"', 4, NOW()), +('Boonk Gang', 5, NOW()), +('Henlo', 4, NOW()), +('Commit Sudoku', 5, NOW()), +('iDubbbz', 3, NOW()), +('Chocolate Bird', 4, NOW()), +('The Room', 1, NOW()), +('Surprised Patrick', 1, NOW()), +('If It Fits I Sits', 2, NOW()), +('Big Red', 5, NOW()), +('Gentlemen', 4, NOW()), +('Send Nudes', 4, NOW()), +('Pootis', 5, NOW()), +('Dipper Goes To Taco Bell', 2, NOW()), +('Splatoon', 4, NOW()), +('Nuclear Gandhi', 1, NOW()), +('P-P-P-Powerbook!', 5, NOW()), +('+1', 2, NOW()), +('Ameno', 5, NOW()), +('Schrute Facts', 2, NOW()), +('Encyclopedia Dramatica', 5, NOW()), +('Esther Verkest''s Help Sign', 3, NOW()), +('Starter Packs', 1, NOW()), +('Twitter', 1, NOW()), +('Smoke Weed Everyday', 5, NOW()), +('Groyper', 5, NOW()), +('Money Printer Go Brrr', 2, NOW()), +('Thug Life', 2, NOW()), +('Mass Effect 3 Endings Reception', 2, NOW()), +('Pornhub Community Intro', 1, NOW()), +('Fuck The Police', 2, NOW()), +('If Young Metro Don''t Trust You', 4, NOW()), +('Push It Somewhere Else Patrick', 4, NOW()), +('Special Feeling / 特別な気分', 5, NOW()), +('Ed, Edd n Eddy', 3, NOW()), +('Pusheen', 2, NOW()), +('Yaoi Hands', 4, NOW()), +('U.N. Owen Was Her?', 5, NOW()), +('You Are A Pirate', 1, NOW()), +('How Do I Shot Web?', 3, NOW()), +('GiantDad', 1, NOW()), +('Am I a Joke To You?', 4, NOW()), +('Annoyed Picard', 5, NOW()), +('Ellen Baker (New Horizon)', 4, NOW()), +('You So Precious When You Smile', 1, NOW()), +('Cat Breading', 1, NOW()), +('LulzSec Hacks', 5, NOW()), +('Obunga', 3, NOW()), +('Surprise Buttsecks', 1, NOW()), +('Okay, This Is Epic', 1, NOW()), +('Am I the Only One Around Here', 5, NOW()), +('Stop Right There, Criminal Scum', 3, NOW()), +('Buenos Dias, Mandy', 5, NOW()), +('Paul Christoforo Ocean Marketing Emails', 2, NOW()), +('If You Watch X Backwards, It''s About Y', 4, NOW()), +('E3 Sony 2006 / Giant Enemy Crab', 4, NOW()), +('Storm Area 51', 4, NOW()), +('Yare Yare Daze', 4, NOW()), +('This Could Be Us But You Playing', 2, NOW()), +('Paranoid Parrot', 3, NOW()), +('Die Cis Scum', 3, NOW()), +('Evil Kermit', 2, NOW()), +('Breaking Bad Comics', 5, NOW()), +('Nobody Expects The Spanish Inquisition', 3, NOW()), +('WTF Is This Shit!?', 4, NOW()), +('Google Ultron', 3, NOW()), +('Just According to Keikaku', 2, NOW()), +('Unlimited Blade Works', 5, NOW()), +('Bad Joke Eel', 3, NOW()), +('Anonymous', 3, NOW()), +('Bronyspeak', 5, NOW()), +('[Help!] The Girl I Like Won’t Respond to My Emails (´・ω・`)', 2, NOW()), +('Twitch Emotes', 5, NOW()), +('Checkmate, Atheists', 5, NOW()), +('Cursed Image', 4, NOW()), +('Nailed It', 3, NOW()), +('Peanut Butter Jelly Time', 2, NOW()), +('4chumblr', 3, NOW()), +('Reply Girls', 5, NOW()), +('Man''s Not Hot', 2, NOW()), +('The Rent is Too Damn High / Jimmy McMillan', 3, NOW()), +('Turn To Page 394', 5, NOW()), +('Social Justice Warrior', 1, NOW()), +('A Wild X Appears! / Wild X Appeared!', 4, NOW()), +('Bimbofication', 4, NOW()), +('Brainlet', 1, NOW()), +('Bad Apple!!', 2, NOW()), +('Comic Sans', 3, NOW()), +('Chris Hansen', 2, NOW()), +('Bonsai Kittens', 1, NOW()), +('The Tails Doll', 1, NOW()), +('Hatsune Miku / Vocaloid', 2, NOW()), +('This Is a Flammenwerfer, It Werfs Flammen', 1, NOW()), +('You Reposted in the Wrong Neighborhood', 2, NOW()), +('Chuck Norris Facts', 5, NOW()), +('ManningFace', 3, NOW()), +('Alignment Charts', 5, NOW()), +('Fire Emblem', 3, NOW()), +('Excuse Me What the Fuck', 4, NOW()), +('Body Inflation', 4, NOW()), +('Nice Girl Bike', 2, NOW()), +('LOLcats', 4, NOW()), +('The Ass Was Fat / Arthur Sees A Fat Ass', 2, NOW()), +('Handsome Squidward / Squidward Falling', 2, NOW()), +('Technologically Impaired Duck', 3, NOW()), +('Johnny Sins', 5, NOW()), +('Mind = Blown', 4, NOW()), +('Mission Report: December 16th, 1991', 2, NOW()), +('Ben Garrison', 2, NOW()), +('Pee Is Stored in the Balls', 1, NOW()), +('Cigar Guy', 3, NOW()), +('Queen of England Death Predictions', 4, NOW()), +('IKEA Monkey', 4, NOW()), +('Long Neck Reaction Guy', 4, NOW()), +('Dril', 1, NOW()), +('HowToBasic', 1, NOW()), +('Lie Down / Try Not To Cry / Cry A Lot', 3, NOW()), +('We''re Fucking Cock Destroyers', 2, NOW()), +('Pepehands', 5, NOW()), +('Amanda Todd''s Death', 3, NOW()), +('Every Day I''m Shufflin', 4, NOW()), +('I Love You 3000', 5, NOW()), +('Super Smash Brothers Ultimate', 1, NOW()), +('Smug Frog', 1, NOW()), +('GigaChad', 4, NOW()), +('Despacito 2', 1, NOW()), +('You Dense Motherfucker', 5, NOW()), +('Crash Bandicoot "Woah"', 1, NOW()), +('Time Traveling Hipster', 2, NOW()), +('Respect Women', 3, NOW()), +('American Chopper Argument', 3, NOW()), +('"Milhouse Is Not A Meme"', 3, NOW()), +('Why The Fuck You Lyin''', 1, NOW()), +('Goosh Goosh', 1, NOW()), +('My Bike Got Stolen Recently', 2, NOW()), +('I''m in That Weird Part of YouTube', 2, NOW()), +('GiIvaSunner / SiIvaGunner', 2, NOW()), +('Oh No, It''s Retarded', 2, NOW()), +('Everyday We Stray Further From God''s Light', 1, NOW()), +('Bel-Air (Fresh Prince)', 4, NOW()), +('Attack on Titan / Shingeki No Kyojin', 5, NOW()), +('Avatar: The Last Airbender / The Legend of Korra', 5, NOW()), +('A.I.Channel / Ai Kizuna', 3, NOW()), +('Gondola', 5, NOW()), +('Brad''s Wife', 4, NOW()), +('What Is the Airspeed Velocity of an Unladen Swallow?', 3, NOW()), +('Diglett Underground', 5, NOW()), +('Nigga, You Gay', 3, NOW()), +('Stop Girl', 3, NOW()), +('Bongo Cat', 4, NOW()), +('Ken-Sama', 1, NOW()), +('Etika', 2, NOW()), +('Attractive Convict', 1, NOW()), +('Bronies', 4, NOW()), +('Shia LaBeouf''s Intense Motivational Speech / Just Do It', 2, NOW()), +('Top 10 Anime List Parodies', 2, NOW()), +('I Told You About Stairs', 3, NOW()), +('30-Year-Old Virgin Wizard', 4, NOW()), +('You Tried', 5, NOW()), +('Bitch I Might Be', 1, NOW()), +('Special Delivery Instructions', 1, NOW()), +('Naked Banana', 5, NOW()), +('Raptor Jesus', 2, NOW()), +('Youngster Joey', 1, NOW()), +('What You See vs. What She Sees', 1, NOW()), +('LOL Jesus', 2, NOW()), +('Do It For Her', 4, NOW()), +('Sad Panda', 2, NOW()), +('Gingers Do Have Souls!', 3, NOW()), +('Hand Me the Aux Cord', 5, NOW()), +('Apu Apustaja', 1, NOW()), +('You Just Have to Say That You''re Fine', 1, NOW()), +('The Absolute Madman', 3, NOW()), +('Sea-Lioning', 1, NOW()), +('Pony Reactions', 4, NOW()), +('Nut Button', 2, NOW()), +('Video Game Logic', 4, NOW()), +('Biggie Cheese', 5, NOW()), +('Pakalu Papito', 3, NOW()), +('Describe Yourself in 3 Fictional Characters', 5, NOW()), +('Call Me Maybe', 5, NOW()), +('A Winner Is You', 3, NOW()), +('Pics or It Didn''t Happen', 4, NOW()), +('Get Down (Geddan)', 4, NOW()), +('Poggers', 3, NOW()), +('Real Nigga Hours', 5, NOW()), +('Laughing Tom Cruise', 1, NOW()), +('Vibe Check', 1, NOW()), +('Amerimutt / Le 56% Face', 5, NOW()), +('Good Girl Gina', 2, NOW()), +('Ant-Man Will Defeat Thanos by Crawling Up His Butt and Expanding', 2, NOW()), +('James Franco “First Time?”', 4, NOW()), +('Shadilay', 2, NOW()), +('Snek', 4, NOW()), +('Big Nigga', 4, NOW()), +('I''m in Me Mum''s Car, Broom Broom', 5, NOW()), +('Yamcha''s Death Pose', 4, NOW()), +('Amanda Cummings'' Death', 4, NOW()), +('Circle Jerk', 5, NOW()), +('DJ P0N-3 / Vinyl Scratch', 4, NOW()), +('Luna Game / The End is Neigh', 4, NOW()), +('Kill la Kill', 4, NOW()), +('Luigi''s Death Stare', 3, NOW()), +('Anti-Masturbation Cross', 2, NOW()), +('I Regret Nothing', 2, NOW()), +('Brazilian Fart Porn', 3, NOW()), +('You Must Be New Here', 5, NOW()), +('Fuck Me, Right?', 1, NOW()), +('Friendzone Johnny', 2, NOW()), +('Fukkireta (吹っ切れた)', 3, NOW()), +('Birthday Dog', 2, NOW()), +('You Vs. The Guy She Told You Not to Worry About', 3, NOW()), +('Rage Quit', 2, NOW()), +('Differenze Linguistiche', 4, NOW()), +('Wake Me Up Inside (Can''t Wake Up)', 2, NOW()), +('I Studied the Blade', 5, NOW()), +('Crazy Frog Brothers', 5, NOW()), +('Inappropriate Timing Spongebob Banner', 2, NOW()), +('Despacito', 2, NOW()), +('Amazing Horse', 5, NOW()), +('Hipster Glasses', 1, NOW()), +('You Either Die A Hero, Or You Live Long Enough To See Yourself Become The Villain', 1, NOW()), +('Eat Hot Chip and Lie', 3, NOW()), +('Steve Buscemeyes', 4, NOW()), +('I''m an Anteater!!!', 2, NOW()), +('Vape Nation', 3, NOW()), +('Friend Zone Fiona', 1, NOW()), +('Starecat / Grafics Cat', 4, NOW()), +('Ralph Pootawn', 5, NOW()), +('What is Love?', 5, NOW()), +('Wolf Girl With You', 5, NOW()), +('Ayaya', 5, NOW()), +('Learn to Code', 2, NOW()), +('My Little Pony Character Fandom', 2, NOW()), +('Dancing Spider-Man', 5, NOW()), +('Why Wub Woo / Dashface', 2, NOW()), +('America: Fuck Yeah!', 1, NOW()), +('Meanwhile in...', 3, NOW()), +('It''s Super Effective!', 5, NOW()), +('Cummies', 3, NOW()), +('White People Dancing / LOL White People', 5, NOW()), +('Just As Planned', 5, NOW()), +('Absolutely Disgusting', 5, NOW()), +('Big Enough', 4, NOW()), +('Bro Fist', 1, NOW()), +('Vladimir Putin', 4, NOW()), +('5ever', 4, NOW()), +('It Was At This Moment He Knew... He Fucked Up', 1, NOW()), +('Sweating Towel Guy', 3, NOW()), +('You''re Mom Gay', 5, NOW()), +('Moonbase Alpha Text to Speech', 4, NOW()), +('Nutted But She Still Sucking', 4, NOW()), +('Dis Gon B Gud', 2, NOW()), +('Linda Glocke / I Will Destroy ISIS', 2, NOW()), +('He Will Never Have a Girlfriend', 3, NOW()), +('Ocean Man', 3, NOW()), +('Family Guy Effect', 5, NOW()), +('Owling', 3, NOW()), +('Bitch I''m Fabulous', 1, NOW()), +('DSPGaming', 3, NOW()), +('Savage Patrick', 2, NOW()), +('Awoo~', 2, NOW()), +('Stock Photo Clichés', 4, NOW()), +('Cookie Clicker', 3, NOW()), +('LUL', 1, NOW()), +('Howard the Alien / Money Longer Alien', 5, NOW()), +('Image Macros', 5, NOW()), +('Griefing', 5, NOW()), +('I Did It for the Lulz', 1, NOW()), +('Yiff', 4, NOW()), +('Change Da World... My Final Message', 4, NOW()), +('Interior Crocodile Alligator', 5, NOW()), +('That Wasn''t Very Cash Money of You', 2, NOW()), +('Facebook', 3, NOW()), +('Streisand Effect', 1, NOW()), +('Professor Badass', 2, NOW()), +('Unpopular Opinion Puffin', 5, NOW()), +('Get To The Choppa', 4, NOW()), +('Zamii070 Harassment Controversy', 2, NOW()), +('Salty', 1, NOW()), +('BadBoy2 / I''m 18, Do I Have Potential?', 5, NOW()), +('No Items, Fox Only, Final Destination', 2, NOW()), +('Super Mario', 5, NOW()), +('Improvise. Adapt. Overcome', 3, NOW()), +('Shaggy''s Power', 2, NOW()), +('Game Grumps', 2, NOW()), +('I Drink Your Milkshake!', 3, NOW()), +('I Don''t Like Sand', 3, NOW()), +('Diretide', 3, NOW()), +('360 No Scope', 1, NOW()), +('Facebook Cartoon Profile Picture Week', 3, NOW()), +('Cuteness Overload', 1, NOW()), +('Instagram', 2, NOW()), +('That''s How Mafia Works', 3, NOW()), +('Boneless Pizza', 4, NOW()), +('Milkshake Duck', 3, NOW()), +('Misunderstood D-Bag', 3, NOW()), +('Magikarp Guy', 1, NOW()), +('Content Aware Scaling', 4, NOW()), +('0.5x A Presses / But First We Need to Talk About Parallel Universes', 2, NOW()), +('Spanish Laughing Guy / "El Risitas" Interview Parodies', 5, NOW()), +('Untoons', 5, NOW()), +('/pol/', 3, NOW()), +('Katya Lischina', 2, NOW()), +('Polybius', 3, NOW()), +('Bold Move Cotton', 1, NOW()), +('Super Cool Ski Instructor', 1, NOW()), +('Gijinka / Moe Anthropomorphism', 5, NOW()), +('Hey Beter', 4, NOW()), +('Bait / This is Bait', 4, NOW()), +('Nickelback', 2, NOW()), +('Sitting Lyra', 1, NOW()), +('Keyboard Cat', 4, NOW()), +('It''s Not Lupus', 4, NOW()), +('Row Row Fight the Powah', 1, NOW()), +('Monster Musume / Daily Life with Monster Girl', 5, NOW()), +('Naruto Run', 1, NOW()), +('Blyat / Cyka Blyat', 4, NOW()), +('How About No?', 1, NOW()), +('Le Toucan', 2, NOW()), +('Cats Can Have a Little Salami', 1, NOW()), +('You''ve Heard of the Elf on the Shelf...', 5, NOW()), +('They''re Taking the Hobbits to Isengard', 3, NOW()), +('Recorded with a Potato', 4, NOW()), +('Best Gore', 1, NOW()), +('Supercut', 5, NOW()), +('That Would Be Great', 2, NOW()), +('Legal Loli', 3, NOW()), +('What is a man?', 3, NOW()), +('Oh Long Johnson', 2, NOW()), +('So Long, Gay Bowser', 3, NOW()), +('Sweet Apple Massacre (My Little Pony Fanfiction)', 5, NOW()), +('Nico Nico Nii', 5, NOW()), +('Awkward Moment Seal', 4, NOW()), +('Straight Outta Somewhere / #StraightOutta', 3, NOW()), +('The Alot', 2, NOW()), +('Jasmine Masters "And I Oop"', 3, NOW()), +('In Ur Base', 4, NOW()), +('4chan Party Van', 3, NOW()), +('Grandma Finds the Internet', 2, NOW()), +('Super Bowl XLIX Halftime "Left Shark"', 2, NOW()), +('S[he] Be[lie]ve[d] (Sbeve)', 5, NOW()), +('The Picard Song', 5, NOW()), +('What The Fuck Am I Reading?', 3, NOW()), +('Plastic Love', 4, NOW()), +('Celebrity Pokemon Evolutions', 2, NOW()), +('BBQ Becky', 3, NOW()), +('"You Could Stop at Five or Six Stores"', 4, NOW()), +('Allahu Akbar', 4, NOW()), +('Zelda''s Response', 5, NOW()), +('Slide Into Your DMs', 2, NOW()), +('Astronaut Sloth', 5, NOW()), +('Nice Boat.', 4, NOW()), +('Unsettled Tom', 2, NOW()), +('Oh No Baby! What Is You Doin???', 5, NOW()), +('Bunchie', 5, NOW()), +('Winnie The Pooh''s Home Run Derby', 1, NOW()), +('What Are You Doing, Step Bro?', 2, NOW()), +('All Right, Gentlemen!', 5, NOW()), +('Boar Vessel', 2, NOW()), +('Happy Cat', 3, NOW()), +('NOMA - Brain Power', 4, NOW()), +('Fuck Logic', 3, NOW()), +('Don''t Worry, He Knows', 5, NOW()), +('Here in My Garage', 1, NOW()), +('I''m Literally the Guy in the Pic', 4, NOW()), +('Dinkleberg', 4, NOW()), +('Brushie Brushie Brushie', 2, NOW()), +('OK So Basically I''m Monky', 1, NOW()), +('Just Kidding... Unless?', 1, NOW()), +('Bench Tails', 1, NOW()), +('Make Your Own Album Cover', 2, NOW()), +('Woody Harrelson Reddit AMA', 1, NOW()), +('Strong Black Woman Who Don''t Need No Man', 3, NOW()), +('Dihydrogen Monoxide Hoax', 3, NOW()), +('Clever Girl', 5, NOW()), +('The D', 1, NOW()), +('Globglogabgalab', 2, NOW()), +('WTF BOOM!', 1, NOW()), +('Sheltering Suburban Mom', 3, NOW()), +('Fakemon', 4, NOW()), +('Race Guy', 2, NOW()), +('Gadsden Flag / Don''t Tread On Me', 1, NOW()), +('Spider-Man Ass Slap', 3, NOW()), +('Adolf Hitler', 2, NOW()), +('You Win the Internet!', 2, NOW()), +('I''m Watching You', 1, NOW()), +('Dick Flattening', 2, NOW()), +('Dankey Kang', 3, NOW()), +('What Year Is It?', 2, NOW()), +('Ara Ara', 5, NOW()), +('L.A. Noire "Doubt" / Press X To Doubt', 2, NOW()), +('How to Wear a Men''s Shirt', 3, NOW()), +('Gravity Falls', 3, NOW()), +('Pop Team Epic', 2, NOW()), +('Interior Monologue Captioning', 5, NOW()), +('I Showed You My Dick Please Respond', 2, NOW()), +('Dragons Having Sex with Cars', 5, NOW()), +('Baldi''s Basics in Education and Learning', 2, NOW()), +('I Was Only Pretending To Be Retarded', 4, NOW()), +('Creepy Villager', 3, NOW()), +('Annoying Childhood Friend', 4, NOW()), +('Manly Tears', 4, NOW()), +('>Implying (Implying Implications)', 1, NOW()), +('Thinking Face Emoji 🤔', 3, NOW()), +('Corey Worthington''s Party', 4, NOW()), +('Bone Apple Tea', 3, NOW()), +('R+L=J', 5, NOW()), +('Still a Better Love Story than Twilight', 5, NOW()), +('That''s the Evilest Thing I Can Imagine', 1, NOW()), +('Hipster', 2, NOW()), +('Smugleaf', 5, NOW()), +('NSFW', 4, NOW()), +('Sarah Jessica Parker Looks Like a Horse', 5, NOW()), +('Rasta Science Teacher', 1, NOW()), +('Ken M', 4, NOW()), +('Tiger Mom', 3, NOW()), +('Magibon', 1, NOW()), +('Top Gun Hat', 2, NOW()), +('Party Hard', 1, NOW()), +('Did You Just Assume My Gender?', 4, NOW()), +('Lord Tachanka', 1, NOW()), +('Activated Almonds', 4, NOW()), +('Fifty Shades of Grey', 3, NOW()), +('New Guy', 5, NOW()), +('Bro, I''m Straight Up Not Having a Good Time', 2, NOW()), +('Charlie Sheen Rant / #tigerblood', 3, NOW()), +('Cracky-chan', 4, NOW()), +('"You And Me" Parodies', 5, NOW()), +('The YouTube Sexual Abuse Scandal', 5, NOW()), +('That Post Gave Me Cancer', 3, NOW()), +('Assuming Control', 2, NOW()), +('Get A Brain Morans', 5, NOW()), +('I''m Baby', 4, NOW()), +('Trust Nobody, Not Even Yourself', 4, NOW()), +('How Italians Do Things', 4, NOW()), +('I''ve Seen Enough Hentai To Know Where This Is Going', 3, NOW()), +('Nazi Pepe Controversy', 3, NOW()), +('Feeling Cute Challenge', 5, NOW()), +('Shiny Pidgey', 2, NOW()), +('Yes Chad', 1, NOW()), +('Cheeky Nando''s', 2, NOW()), +('BonbiBonkers', 1, NOW()), +('It''s Okay to Be White', 3, NOW()), +('Portal 2 Space Personality Core', 1, NOW()), +('Lex Luthor Took Forty Cakes', 3, NOW()), +('Video Games Appeal to the Male Fantasy', 3, NOW()), +('Spooky Scary Skeletons', 4, NOW()), +('Greg Paul Sex Tape Leak', 5, NOW()), +('Mary Sue', 1, NOW()), +('The Last Page of the Internet', 1, NOW()), +('BLACKED', 2, NOW()), +('Gohan Blanco', 3, NOW()), +('Piracy, It''s a Crime', 4, NOW()), +('What I Watched / What I Expected / What I Got', 1, NOW()), +('You Get Nothing! You Lose! Good Day, Sir!', 2, NOW()), +('Demopan', 4, NOW()), +('-fag (Suffix)', 1, NOW()), +('Surprise, Bitch', 1, NOW()), +('Girugamesh', 5, NOW()), +('Don''t Look at Her', 1, NOW()), +('Who Created Kirby?', 3, NOW()), +('White Knight', 5, NOW()), +('Medieval Tapestry Edits', 5, NOW()), +('Loud Nigra', 5, NOW()), +('Lonk', 4, NOW()), +('Caramelldansen', 5, NOW()), +('No Nut November', 2, NOW()), +('Shipping', 4, NOW()), +('Todd Howard', 4, NOW()), +('Chocolate Rain', 4, NOW()), +('Where Is Your God Now?', 4, NOW()), +('Fuckboy', 5, NOW()), +('Desiree Jennings Dystonia Hoax', 5, NOW()), +('Welcome To The Salty Spitoon. How Tough Are Ya?', 3, NOW()), +('Well Yes, But Actually No', 1, NOW()), +('Lame Pun Coon', 5, NOW()), +('I Cri Evrytiem', 1, NOW()), +('Then Perish', 3, NOW()), +('I Got 99 Problems But a Bitch Ain''t One', 1, NOW()), +('/b/', 2, NOW()), +('DOOM: Repercussions of Evil', 2, NOW()), +('Zoomer', 4, NOW()), +('Wrong Lyrics Christina', 5, NOW()), +('Brittany Venti', 3, NOW()), +('You See Ivan...', 5, NOW()), +('Ponify', 5, NOW()), +('Unidan', 3, NOW()), +('Red Shirt Guy', 1, NOW()), +('How to Break Your Thumb Ligament', 5, NOW()), +('Ding Fries Are Done', 5, NOW()), +('Stu Making Chocolate Pudding At 4 AM', 3, NOW()), +('Thank You Kanye, Very Cool!', 1, NOW()), +('Lonely Computer Guy / Net Noob', 3, NOW()), +('Game of Thrones', 2, NOW()), +('YES! YES!', 5, NOW()), +('Graphic Design Is My Passion', 2, NOW()), +('It''s High Noon', 5, NOW()), +('Bikini Bridge', 5, NOW()), +('Single-Serving Site', 1, NOW()), +('Gavin', 4, NOW()), +('Meme Magic', 2, NOW()), +('2019 Tokyo Yandere Stabbing', 3, NOW()), +('Budd Dwyer Suicide Video', 3, NOW()), +('ProJared Cheating Scandal', 1, NOW()), +('Urban Dictionary', 2, NOW()), +('Bill O''Reilly Rant', 1, NOW()), +('Christopher Poole / moot', 3, NOW()), +('Polite Cat', 2, NOW()), +('The Woah', 2, NOW()), +('Trump Is Playing 4D Chess', 4, NOW()), +('Whatcha Thinkin Bout?', 1, NOW()), +('Don''t Hug Me I''m Scared', 1, NOW()), +('Can You Please Photoshop The Sun Between My Fingers?', 1, NOW()), +('Dakimakura / Body Pillow', 3, NOW()), +('Domo', 2, NOW()), +('But I Poop From There', 4, NOW()), +('Smug Wendy''s', 3, NOW()), +('Grape-kun', 5, NOW()), +('Monster Girls', 1, NOW()), +('Angry Pepe', 2, NOW()), +('Final Boss of the Internet', 2, NOW()), +('Brock Obama', 1, NOW()), +('Star Wars', 4, NOW()), +('GIF', 1, NOW()), +('Fuck Yeah Seaking', 1, NOW()), +('Lenin Cat', 2, NOW()), +('Pornhub', 1, NOW()), +('Kowalski', 4, NOW()), +('Tumblr Nose', 5, NOW()), +('Big Dick Energy', 5, NOW()), +('It Really Do Be Like That Sometimes', 3, NOW()), +('Erratas', 3, NOW()), +('Honk Honk / Chen Edits', 2, NOW()), +('The More You Know', 2, NOW()), +('Anita Sarkeesian', 3, NOW()), +('Spy Crab', 1, NOW()), +('e621', 5, NOW()), +('Super Smash Bros Character Predictions', 4, NOW()), +('9/11 Tourist Guy', 4, NOW()), +('Angelina Jolie''s Leg', 5, NOW()), +('Y Tho', 2, NOW()), +('My Hero Academia', 2, NOW()), +('Do She Got a Booty? (She Do)', 3, NOW()), +('Doctor Whooves / Time Turner', 1, NOW()), +('Meth, Not Even Once', 5, NOW()), +('Stoner Dog', 4, NOW()), +('Don''t Judge Challenge', 2, NOW()), +('Red Leader Standing By', 5, NOW()), +('That Pool', 2, NOW()), +('Spiders Georg', 4, NOW()), +('Topkek', 3, NOW()), +('Lying Down Game', 2, NOW()), +('Dancing Baby', 1, NOW()), +('Renai Circulation', 4, NOW()), +('Mia Khalifa Death Threats', 3, NOW()), +('Clippy', 2, NOW()), +('Some Of You Guys Are Alright', 5, NOW()), +('Sonic Original Characters', 5, NOW()), +('Eli Porter', 2, NOW()), +('asdfmovie', 2, NOW()), +('Benis', 5, NOW()), +('Horse-Sized Duck', 2, NOW()), +('Hmm Today I Will', 2, NOW()), +('SourPls', 3, NOW()), +('Don''t Blink "The Weeping Angels"', 2, NOW()), +('Original vs. Un-Tumblrized', 5, NOW()), +('Yotsuba Koiwai / 404 Girl', 2, NOW()), +('He Needs Some Milk', 2, NOW()), +('Babby', 5, NOW()), +('Happy Keanu', 4, NOW()), +('Gordon Ramsay', 1, NOW()), +('Cock and Ball Torture', 5, NOW()), +('Pregnancy Announcement', 2, NOW()), +('That Damn Smile', 4, NOW()), +('What 4chan Cries Out During Sex', 1, NOW()), +('I''m sorry, I can''t hear you over the sound of how awesome I am', 5, NOW()), +('Hunger Games Simulator', 5, NOW()), +('Christ-chan', 3, NOW()), +('Look at This Dude / Flipagram Roast Videos', 2, NOW()), +('Everyone Loses Their Minds', 5, NOW()), +('Nevada-Tan', 1, NOW()), +('Beautiful Cinnamon Roll Too Good For This World, Too Pure', 4, NOW()), +('I Can''t Believe You''ve Done This!', 4, NOW()), +('Barack Obama', 4, NOW()), +('Your Music''s Bad and You Should Feel Bad', 1, NOW()), +('Excuse Me Sir, Do You Have a Moment to Talk About Jesus Christ?', 3, NOW()), +('Swole', 2, NOW()), +('Glen Coco', 2, NOW()), +('Bread Helmet Man', 1, NOW()), +('Lick Icons', 3, NOW()), +('She Was His Queen...', 5, NOW()), +('Skinny Legend', 1, NOW()), +('Sage', 5, NOW()), +('You Have My Sword, And My Bow, And My Axe', 1, NOW()), +('Keyhole Turtleneck', 4, NOW()), +('Hitler Did Nothing Wrong', 3, NOW()), +('God I Wish That Were Me', 1, NOW()), +('Prancing Cera', 3, NOW()), +('Vince McMahon Reaction', 2, NOW()), +('Protect IP Act / Stop Online Piracy Act (PIPA / SOPA)', 2, NOW()), +('Channel Awesome Implosion / #ChangeTheChannel', 4, NOW()), +('Penis Goes Where/Cock goes where', 4, NOW()), +('Robot Unicorn Attack / Harmony Harmony', 5, NOW()), +('The "!1" Phenomenon', 5, NOW()), +('Fish4Hoes', 2, NOW()), +('King Crimson (JoJo)', 1, NOW()), +('Mirai Nikki Yandere Face', 3, NOW()), +('Le', 4, NOW()), +('MEMRI TV', 3, NOW()), +('Olympics or Gay Porn?', 5, NOW()), +('Faces of Marijuana', 1, NOW()), +('orz', 4, NOW()), +('I''m Going To Build My Own Theme Park With Blackjack and Hookers', 3, NOW()), +('#SlaneGirl', 3, NOW()), +('Curb Your Enthusiasm Theme Remixes', 2, NOW()), +('You Shall Not Pass!!!', 4, NOW()), +('NOT THE BEES!', 2, NOW()), +('You Cheated Not Only the Game, But Yourself', 4, NOW()), +('Perfectly Timed Photos', 4, NOW()), +('Filthy Casual', 1, NOW()), +('Warhammer 40,000', 1, NOW()), +('I Can''t Even', 1, NOW()), +('Angry German Kid / Keyboard Crasher', 1, NOW()), +('Updog', 4, NOW()), +('Ryan Gosling Won''t Eat His Cereal', 5, NOW()), +('Trainers Hate Him', 1, NOW()), +('IDDQD', 1, NOW()), +('Unnecessary Censorship', 4, NOW()), +('Marge Krumping', 3, NOW()), +('Cr1TiKaL', 4, NOW()), +('You On Kazoo', 4, NOW()), +('Black Twitter', 2, NOW()), +('I Watch It For The Plot', 2, NOW()), +('Actual Sexual Advice Girl', 1, NOW()), +('The FitnessGram Pacer Test', 1, NOW()), +('Dogs', 5, NOW()), +('Wii Fit Girl', 4, NOW()), +('Oh God What Have I Done?', 3, NOW()), +('Get Out Frog / Frogout / Me Obrigue', 5, NOW()), +('The Great Subscriber War / Subscribe to PewDiePie', 4, NOW()), +('Swiggity Swag', 4, NOW()), +('Slow Clap', 2, NOW()), +('Old Man Yells at Cloud', 4, NOW()), +('Wait, That''s Illegal', 1, NOW()), +('The World According To...', 5, NOW()), +('Hurr Durr', 4, NOW()), +('Bianca Devins'' Murder', 3, NOW()), +('Alberto Barbosa', 5, NOW()), +('420', 3, NOW()), +('Senior College Student', 1, NOW()), +('Annoyed Bird', 1, NOW()), +('Emojipasta', 5, NOW()), +('And It''s Gone', 1, NOW()), +('Toto''s "Africa"', 4, NOW()), +('Ctrl+Alt+Del', 2, NOW()), +('"Speak to the Manager" Haircut', 4, NOW()), +('iDubbbz''s "I''m Gay"', 1, NOW()), +('Laundry Room Viking', 5, NOW()), +('Glowing Eyes', 4, NOW()), +('Baby Godfather', 4, NOW()), +('Guess I''ll Die', 5, NOW()), +('Epic Boobs Girl', 3, NOW()), +('Polish Jerry', 2, NOW()), +('Pokémon Sun and Moon', 1, NOW()), +('Crazy Girlfriend Praying Mantis', 3, NOW()), +('I''m At Soup / Soup Store', 4, NOW()), +('It''s More Likely Than You Think', 1, NOW()), +('Disney''s Frozen Whitewashing Controversy', 2, NOW()), +('Guido Jesus', 4, NOW()), +('Fuck Yo Couch', 3, NOW()), +('Weber Cooks', 3, NOW()), +('People Who Thank the Bus Driver', 3, NOW()), +('Jesusland', 3, NOW()), +('Nigerian Scams', 2, NOW()), +('But Can It Run Crysis?', 1, NOW()), +('Kumamon', 3, NOW()), +('Immature High Schoolers', 4, NOW()), +('Jam It In! / You Lost Me', 3, NOW()), +('You''re A Wizard, Harry!', 4, NOW()), +('Serious Cat', 2, NOW()), +('Fake History', 5, NOW()), +('"S" Stands For? / Smile Sweet Sister Sadistic Suprise Service', 5, NOW()), +('The Holders', 1, NOW()), +('The Human Centipede', 3, NOW()), +('Tabby', 5, NOW()), +('LazyTown', 1, NOW()), +('Days Without Sex', 4, NOW()), +('Safety Not Guaranteed', 2, NOW()), +('Sweetie Belle Derelle', 1, NOW()), +('Pony Re-Imaginings', 2, NOW()), +('Forever Resentful Mother', 1, NOW()), +('Trying to Hold a Fart Next to a Cute Girl in Class', 2, NOW()), +('Enderman', 2, NOW()), +('Billie Eilish''s 18th Birthday', 4, NOW()), +('Hentai Haven', 4, NOW()), +('Creepy Katara', 4, NOW()), +('Ben Shapiro DESTROYS Liberals', 1, NOW()), +('Leekspin / Loituma Girl', 5, NOW()), +('Advice God', 3, NOW()), +('Flutterrage / Flutterbitch', 5, NOW()), +('Saturdays Are For The Boys', 4, NOW()), +('Logan Paul', 2, NOW()), +('Endless Eight / Kyon-kun Denwa!', 4, NOW()), +('Vic Mignogna Sexual Harassment Allegations', 2, NOW()), +('It''s Time to Stop', 4, NOW()), +('Nelson the Bull Terrier / Walter', 5, NOW()), +('GET', 4, NOW()), +('Doesn''t Matter, Had Sex', 5, NOW()), +('Rotten.com', 4, NOW()), +('Van-sama / Fercussion', 3, NOW()), +('I AM ERROR', 3, NOW()), +('Let''s Go Bowling', 3, NOW()), +('Sleep Tight Pupper', 4, NOW()), +('69', 3, NOW()), +('Behind The Meme', 2, NOW()), +('Tobuscus', 2, NOW()), +('My Wife''s Son', 1, NOW()), +('Alinity', 5, NOW()), +('Nightcore', 1, NOW()), +('Hampster Dance', 4, NOW()), +('Gee Bill! How Come Your Mom Lets You Eat Two Wieners?', 5, NOW()), +('Tuxedo Winnie the Pooh', 3, NOW()), +('ExHentai / SadPanda', 4, NOW()), +('Skeptical Baby', 5, NOW()), +('Batman', 4, NOW()), +('Food Network Recipe Reviews', 1, NOW()), +('Sosig', 2, NOW()), +('ISIS-chan', 2, NOW()), +('Sminem', 3, NOW()), +('The Amazing Atheist', 4, NOW()), +('Needs More Cowbell', 5, NOW()), +('Mic Drop', 4, NOW()), +('Original Character Do Not Steal', 5, NOW()), +('John Cena', 1, NOW()), +('Monkey Puppet', 1, NOW()), +('Eddy Wally''s "Wow"', 3, NOW()), +('Hi, My Name Is Reggie', 1, NOW()), +('Based Stickman', 5, NOW()), +('Pew Pew', 3, NOW()), +('Ayuwoki', 1, NOW()), +('Drakeposting', 3, NOW()), +('Occupy Wall Street', 5, NOW()), +('Caturday', 3, NOW()), +('A Midsummer Night''s Lewd Dream / 真夏の夜の淫夢', 4, NOW()), +('You''re Gonna Have a Bad Time', 4, NOW()), +('Wii Fit Trainer', 2, NOW()), +('Minor Mistake Marvin', 2, NOW()), +('Facebomb', 3, NOW()), +('Twerking', 5, NOW()), +('NEDM', 5, NOW()), +('CathyMay15', 2, NOW()), +('Involuntary Celibacy / Incel', 2, NOW()), +('Hungry Kim Jong-un', 5, NOW()), +('Gay Test', 1, NOW()), +('Patrick Bateman With an Axe', 3, NOW()), +('Keanu Reeves', 4, NOW()), +('I Never Asked For This', 4, NOW()), +('Don''t Ask Who Joe Is / Joe Mama', 5, NOW()), +('The Weighted Companion Cube', 4, NOW()), +('Tom Preston', 1, NOW()), +('Layers of Irony', 2, NOW()), +('Nuke It From Orbit', 3, NOW()), +('How About I Slap Your Shit', 5, NOW()), +('Three Seashells', 5, NOW()), +('Cumbox', 5, NOW()), +('Snails Sleep 3', 3, NOW()), +('And In That Moment I Swear We Were Infinite', 4, NOW()), +('Depression Dog', 3, NOW()), +('They Had Us In the First Half', 3, NOW()), +('Delete This Nephew', 3, NOW()), +('Wise Confucius', 1, NOW()), +('Pancake Bunny', 4, NOW()), +('No God, Please No!', 2, NOW()), +('One-Punch Man', 4, NOW()), +('Oprah''s "You Get a Car"', 1, NOW()), +('Sleeping Shaq', 4, NOW()), +('Going to the Store', 2, NOW()), +('TheReportOfTheWeek', 3, NOW()), +('Welcome to The Internet', 3, NOW()), +('Photo Fads', 4, NOW()), +('Crossed-Out Pride Flag Emoji Combination', 5, NOW()), +('When U Mom Com Home And Make Hte Spagheti', 4, NOW()), +('I Too Like to Live Dangerously', 3, NOW()), +('ProjektMelody', 2, NOW()), +('Me Me Big Boy', 1, NOW()), +('Flat Is Justice / Delicious Flat Chest', 3, NOW()), +('Epic Handshake', 3, NOW()), +('1337 speak', 3, NOW()), +('Epic Fail Guy', 4, NOW()), +('We Did It Reddit!', 5, NOW()), +('Why Would You Say Something So Controversial Yet So Brave?', 2, NOW()), +('Fallout', 3, NOW()), +('4chan Word Filter', 4, NOW()), +('Oh Joy Sex Toy''s "Cuck" Comic', 3, NOW()), +('Why So Serious?', 3, NOW()), +('Ordinary Muslim Man', 4, NOW()), +('You Are NOT The Father!', 5, NOW()), +('Snape Kills Dumbledore', 2, NOW()), +('Pokemon Lost Silver', 3, NOW()), +('The Legend of Zelda', 5, NOW()), +('OMG Cat', 4, NOW()), +('Kanye West', 2, NOW()), +('Be Strong For Mother', 1, NOW()), +('Argentina Is White', 1, NOW()), +('But Our Princess is in Another Castle!', 5, NOW()), +('Oh God How Did This Get Here I Am Not Good With Computer', 4, NOW()), +('Nope', 3, NOW()), +('Shit People Say', 1, NOW()), +('What Cleopatra May Have Looked Like', 5, NOW()), +('Numa Numa', 2, NOW()), +('Hold My Beer', 4, NOW()), +('Stand Cries / ORA ORA ORA / MUDA MUDA MUDA', 5, NOW()), +('In The Way Guy', 5, NOW()), +('High Impact Sexual Violence', 2, NOW()), +('Ram Ranch', 5, NOW()), +('Short Tyler1', 2, NOW()), +('Featuring Dante From The Devil May Cry Series', 3, NOW()), +('Limecat', 3, NOW()), +('Ultimate Insult Man', 4, NOW()), +('Indonesian Reporting Commission Facebook Takedowns', 5, NOW()), +('Pink Shirt Guy', 4, NOW()), +('Foul Bachelorette Frog', 5, NOW()), +('Wat Do?', 4, NOW()), +('Kim Jong-un', 5, NOW()), +('Internet Tough Guy', 3, NOW()), +('"i lik the bred"', 3, NOW()), +('Scumbag Hat', 3, NOW()), +('Sans', 2, NOW()), +('Don''t Look at Them Ricky!', 3, NOW()), +('John Lennon The Absolute Madman', 4, NOW()), +('Nic Cage as Everyone', 2, NOW()), +('Inappropriate Timing Bill Clinton', 4, NOW()), +('Doxing', 1, NOW()), +('Salad Fingers', 5, NOW()), +('HABEEB IT', 3, NOW()), +('Privilege Denying Dude', 4, NOW()), +('Fat Yoshi', 1, NOW()), +('Amouranth', 3, NOW()), +('h3h3productions', 2, NOW()), +('Doctor Who', 4, NOW()), +('Desk Flip', 5, NOW()), +('Massimo D''Alema', 1, NOW()), +('Running Butthole Challenge', 3, NOW()), +('I Throw My Hands Up In The Air Sometimes Saying Ayo', 4, NOW()), +('Roses Are Red, Violets Are Blue', 1, NOW()), +('Jesus Take The Wheel', 5, NOW()), +('Over-Educated Problems', 1, NOW()), +('I Can Be Your Angle Or Yuor Devil', 5, NOW()), +('But It''s Honest Work', 1, NOW()), +('Kyle', 5, NOW()), +('Cicada Block', 4, NOW()), +('& Knuckles', 2, NOW()), +('Has Science Gone Too Far?', 1, NOW()), +('And Then I Said', 1, NOW()), +('Calm Your Tits', 1, NOW()), +('30-50 Feral Hogs', 1, NOW()), +('PS3 Has No Games', 5, NOW()), +('I''m The Juggernaut, Bitch!', 2, NOW()), +('Anime Tiddies', 1, NOW()), +('Gordon Ramsay''s Lamb Sauce', 3, NOW()), +('Pokémon Sword and Shield', 5, NOW()), +('England Is My City', 3, NOW()), +('Nanomachines, Son', 3, NOW()), +('Noob Tube', 4, NOW()), +('Mah Nigga', 1, NOW()), +('Extra Thicc', 1, NOW()), +('Who Would Win?', 5, NOW()), +('Pony Cum Jar Project', 5, NOW()), +('Crabcore', 5, NOW()), +('Horse Armor', 4, NOW()), +('Fortnite', 2, NOW()), +('Maru the Cat', 4, NOW()), +('Shamefur Dispray', 3, NOW()), +('Free Helicopter Rides', 2, NOW()), +('Skipping Leg Day', 5, NOW()), +('Star Wars Kid', 4, NOW()), +('Fully Automated Luxury Gay Space Communism', 1, NOW()), +('Tentaquil', 1, NOW()), +('Dancing Stormtrooper', 5, NOW()), +('Bazinga', 1, NOW()), +('Horny Samus', 3, NOW()), +('Dinner with Waifu / Otaku Dates', 5, NOW()), +('Professor Oak', 3, NOW()), +('Wow Queen, You''re So Beautiful', 3, NOW()), +('Free Candy Van', 3, NOW()), +('Shit That Siri Says', 5, NOW()), +('Scottish Pokémon Trainer', 2, NOW()), +('So I Got That Goin'' For Me, Which is Nice', 3, NOW()), +('The Old Reddit Switch-a-roo', 4, NOW()), +('Who Is She? (Vine)', 2, NOW()), +('Evil Duolingo Owl', 1, NOW()), +('Duane!', 1, NOW()), +('Sweet Home Alabama', 1, NOW()), +('Overconfident Alcoholic', 2, NOW()), +('I Made You a Cookie, But I Eated It', 2, NOW()), +('Panty and Stocking', 5, NOW()), +('Kyle Punches Drywall', 1, NOW()), +('How People View Me After I Say I''m...', 4, NOW()), +('I''ll Take a Potato Chip and EAT IT!', 3, NOW()), +('Gaston', 3, NOW()), +('A Small Loan of a Million Dollars', 4, NOW()), +('Deepfakes', 2, NOW()), +('Infomercial Fails', 5, NOW()), +('Mafia City', 4, NOW()), +('Monster Girl Quest', 2, NOW()), +('"They Ask You How You Are and You Say That You''re Fine"', 5, NOW()), +('Internet Fight', 1, NOW()), +('Ha Ha Ha, No', 5, NOW()), +('Woomy', 5, NOW()), +('I Swear On Me Mum', 2, NOW()), +('MAD', 5, NOW()), +('Leonardo DiCaprio''s Oscar', 1, NOW()), +('You Have Died of Dysentery', 1, NOW()), +('At First I Was Like...', 2, NOW()), +('"Come On, It''s 2015" / Current Year', 4, NOW()), +('NONONONO Cat', 4, NOW()), +('Starbucks Drake Hands', 4, NOW()), +('Surreal Memes', 4, NOW()), +('Waluigi', 2, NOW()), +('Staring Hamster', 5, NOW()), +('I find your lack of faith disturbing', 5, NOW()), +('Basic Bitch', 1, NOW()), +('Forced Meme', 4, NOW()), +('Pleb', 5, NOW()), +('Internet Is Leaking', 4, NOW()), +('DJ Khaled', 2, NOW()), +('Hypnotoad', 1, NOW()), +('Who''s That Pokémon?', 5, NOW()), +('They Did Surgery On a Grape', 5, NOW()), +('Berserk', 5, NOW()), +('That''s a Penis', 3, NOW()), +('Sturgeon Face', 2, NOW()), +('The Almighty Loaf', 4, NOW()), +('Bitch Lasagna', 3, NOW()), +('On All Levels Except Physical, I am a Wolf', 2, NOW()), +('Autism', 3, NOW()), +('Otaku Test', 3, NOW()), +('Cosplay', 4, NOW()), +('Onii-chan', 3, NOW()), +('Goose on Fire / Fire Duck', 2, NOW()), +('Uvuvwevwevwe', 4, NOW()), +('Creepy Garfield', 1, NOW()), +('Flying Lawnmower', 4, NOW()), +('Hey Its Me Ur Brother', 2, NOW()), +('Engrish', 3, NOW()), +('Grimdark', 2, NOW()), +('TriHard', 3, NOW()), +('Bart, Get Out! I''m Piss!', 1, NOW()), +('Imagefap Trolling', 1, NOW()), +('Dramatic Chipmunk', 1, NOW()), +('Captain Hydra / Captain America "Hail Hydra" Edits', 5, NOW()), +('Just Waiting For A Mate', 1, NOW()), +('That''s The Joke', 4, NOW()), +('Glenn Beck Rape & Murder Hoax', 2, NOW()), +('I Can''t Fap to This', 4, NOW()), +('VTEC just kicked in, yo!', 5, NOW()), +('Scene Wolf', 3, NOW()), +('Javert Reaction GIFs', 5, NOW()), +('Social Justice Blogging', 3, NOW()), +('September 11th, 2001 Attacks', 3, NOW()), +('Well Meme''d', 5, NOW()), +('Say Sike Right Now', 1, NOW()), +('Club Penguin', 1, NOW()), +('The End of the World', 3, NOW()), +('Crack Kid', 5, NOW()), +('A Potato Flew Around My Room', 1, NOW()), +('Oh My God, Karen, You Can’t Just Ask Someone Why They''re White', 4, NOW()), +('McNuggies', 5, NOW()), +('Tsundere', 3, NOW()), +('Nyanyanyanyanyanyanya!', 5, NOW()), +('I Whip My Hair Back and Forth', 4, NOW()), +('Steve Jobs vs. Bill Gates', 5, NOW()), +('One Finger Selfie Challenge', 5, NOW()), +('The Scroll of Truth', 1, NOW()), +('Big Bill Hell''s', 5, NOW()), +('No, This Is Patrick', 1, NOW()), +('4chan Drinking Game Cards', 3, NOW()), +('You Are Now Breathing Manually', 3, NOW()), +('Daily Struggle', 4, NOW()), +('Fluffy Ponies', 3, NOW()), +('Lolrus', 1, NOW()), +('Captain Obvious', 3, NOW()), +('Birds with Arms', 5, NOW()), +('Vore', 2, NOW()), +('1 Lunatic 1 Ice Pick', 3, NOW()), +('Bait and Switch Videos / Pictures', 5, NOW()), +('Sopa de Macaco / Uma Delicia', 1, NOW()), +('Priority Peter', 1, NOW()), +('Oblivious Suburban Mom', 4, NOW()), +('My Disappointment Is Immeasurable', 3, NOW()), +('Sweet Jesus, Pooh! That''s Not Honey!', 5, NOW()), +('Get In The Fucking Robot Shinji', 4, NOW()), +('Name a More Iconic Duo', 5, NOW()), +('Albert Einstein Copypasta', 3, NOW()), +('Big, If True', 4, NOW()), +('Madara Uchiha Copypasta', 1, NOW()), +('Watermelon Headshot', 2, NOW()), +('It''s Just a Prank', 1, NOW()), +('Your Ecards / Someecards.com', 2, NOW()), +('Mitt Romney', 2, NOW()), +('Fluttercry / Characters Watching Tv', 1, NOW()), +('Yaass', 2, NOW()), +('Roller Coaster Chess', 2, NOW()), +('Beep Beep Lettuce', 4, NOW()), +('Mike Inel / Manyakis', 5, NOW()), +('The Worst Trade Deal', 2, NOW()), +('TikTok', 3, NOW()), +('Be Like Bill', 5, NOW()), +('Just Little Things', 4, NOW()), +('"Pen Pineapple Apple Pen"', 2, NOW()), +('That Is Mahogany', 5, NOW()), +('The Hacker Known as 4Chan', 3, NOW()), +('Confused Mr. Krabs', 5, NOW()), +('Gratata', 1, NOW()), +('Steve Rambo Videos / "Oh Shit, I''m Sorry"', 4, NOW()), +('Megami Tensei - Persona', 1, NOW()), +('Mikey Wilson (Middle Finger kid)', 4, NOW()), +('Epstein Didn''t Kill Himself', 3, NOW()), +('Lonelygirl15', 5, NOW()), +('Even Speedwagon Is Afraid!', 2, NOW()), +('Picardía', 5, NOW()), +('Net Neutrality', 3, NOW()), +('Somebody Once Told Me', 5, NOW()), +('The Situation Room', 4, NOW()), +('Dancing Hot Dog Snapchat Filter', 5, NOW()), +('Operation Darknet', 3, NOW()), +('Kill Me', 2, NOW()), +('Who Killed Hannibal?', 4, NOW()), +('Espurr''s Stare', 1, NOW()), +('Slaps Chicken at 3275.95 MPH', 2, NOW()), +('Ebin', 5, NOW()), +('I Want to Cum Inside Rainbow Dash', 5, NOW()), +('Look At Me, I''m The Captain Now', 4, NOW()), +('*Hits Blunt*', 1, NOW()), +('Sweden Yes', 5, NOW()), +('Sauce', 2, NOW()), +('Darth Vader''s Noooooooooooo!', 2, NOW()), +('Oh, Worm?', 5, NOW()), +('The Flying Spaghetti Monster', 2, NOW()), +('Shit Just Got Real', 5, NOW()), +('This Is Snek', 1, NOW()), +('Baton Roue', 2, NOW()), +('How To Draw an Owl', 2, NOW()), +('All Star', 3, NOW()), +('Dark Knight 4 Pane', 1, NOW()), +('Star vs. the Forces of Evil', 1, NOW()), +('Just Go On The Internet and Tell Lies', 3, NOW()), +('7 Grand Dad', 1, NOW()), +('Lurk Moar', 2, NOW()), +('Fake and Gay', 1, NOW()), +('Kitler', 4, NOW()), +('VideoGameDunkey', 3, NOW()), +('Operation Payback', 1, NOW()), +('Oh Baby, a Triple!', 4, NOW()), +('Gar', 2, NOW()), +('DansGame', 3, NOW()), +('Osama Bin Laden''s Death', 4, NOW()), +('Shit, I''m Late For School', 1, NOW()), +('Hello My Future Girlfriend', 3, NOW()), +('France Is Bacon', 5, NOW()), +('Get In The Bag, Nebby', 2, NOW()), +('Scout Face', 5, NOW()), +('Dedotated Wam', 1, NOW()), +('Perfection', 1, NOW()), +('Corona-chan', 3, NOW()), +('Turn 360 Degrees and Walk Away', 4, NOW()), +('Weird Twitter', 3, NOW()), +('You Came to the Wrong Neighborhood', 1, NOW()), +('Smol', 3, NOW()), +('Auto-Tune', 3, NOW()), +('Press X to Jason', 3, NOW()), +('Jesus', 3, NOW()), +('Rip and Tear', 3, NOW()), +('Something Awful', 2, NOW()), +('That Awkward Moment', 4, NOW()), +('Iridocyclitis', 3, NOW()), +('Stawp it, Rahn!', 5, NOW()), +('Snu-Snu', 5, NOW()), +('Despite Being Only 13 Percent of the Population', 4, NOW()), +('I''m About to End This Man''s Whole Career', 1, NOW()), +('Sheeeit', 3, NOW()), +('No Way Fag', 3, NOW()), +('Coaxed Into a Snafu', 2, NOW()), +('Pajama Kid', 4, NOW()), +('Netorare', 1, NOW()), +('Kai the Hatchet-Wielding Hitchhiker', 1, NOW()), +('Internet Explorer', 3, NOW()), +('Stop Bullying Comics', 2, NOW()), +('Listen Here You Little Shit', 1, NOW()), +('I Hope You Step on a LEGO', 4, NOW()), +('Google Search Suggestions', 1, NOW()), +('Solo Jazz Pattern', 4, NOW()), +('Kevin Durant MVP Speech', 5, NOW()), +('ISIS / Daesh', 3, NOW()), +('Ooh Mister Darcy', 1, NOW()), +('Hey Man You See That Guy Over There', 3, NOW()), +('Furry Scale', 5, NOW()), +('I''m OK With This', 1, NOW()), +('Y''all Got Anymore of...', 4, NOW()), +('Taiwan #1', 5, NOW()), +('#CuttingForBieber', 3, NOW()), +('Paul Blart: Mall Cop', 5, NOW()), +('Mishapocalypse', 4, NOW()), +('XD', 4, NOW()), +('I Have The High Ground', 2, NOW()), +('Jesus', 4, NOW()), +('Cheezburger', 2, NOW()), +('Heart-Shaped Boob Challenge', 3, NOW()), +('Rush B', 3, NOW()), +('I Wish I Was At Home', 3, NOW()), +('Pineapple on Pizza Debate', 5, NOW()), +('MaximilianMus "Oh Yeah Yeah"', 2, NOW()), +('Walt Jr. Loves Breakfast', 4, NOW()), +('I Am The Table', 3, NOW()), +('Awaken, My Masters', 4, NOW()), +('Pizza is a Vegetable', 4, NOW()), +('Noot Noot', 1, NOW()), +('Swearing on a Christian Server', 5, NOW()), +('Karma Is a Bitch', 3, NOW()), +('Lemme Smash', 2, NOW()), +('Splat Tim', 5, NOW()), +('We Need To Go Deeper', 3, NOW()), +('eHarmony Video Bio', 3, NOW()), +('Thinking With Portals', 1, NOW()), +('Misinformationalized / You Hear About Video Games?', 5, NOW()), +('DURR PLANT', 2, NOW()), +('First', 3, NOW()), +('Dragon Ball', 1, NOW()), +('Me, An Intellectual', 5, NOW()), +('What If Zelda Was A Girl?', 4, NOW()), +('The Sneaky Hat', 4, NOW()), +('Pretty Girls, Ugly Faces', 2, NOW()), +('It''s Simple, We Kill The Batman', 5, NOW()), +('Another One', 5, NOW()), +('Trivago Guy', 5, NOW()), +('Gru''s Plan', 5, NOW()), +('Y U Do Dis?', 4, NOW()), +('Beyblades eBay Auction', 2, NOW()), +('This is someone dying while having an MRI scan.', 2, NOW()), +('We Can''t Stop Here, This is Bat Country', 5, NOW()), +('@Wendys', 4, NOW()), +('Please Disconnect The Bluetooth Speaker', 3, NOW()), +('UNITINU / UNITIИU', 5, NOW()), +('Spengbab', 5, NOW()), +('Put Some Respeck on My Name', 3, NOW()), +('The Ratio', 4, NOW()), +('Electronic Entertainment Expo (E3)', 5, NOW()), +('Bush Did 9/11', 4, NOW()), +('Michelle Jenneke''s Warm Up Dance', 5, NOW()), +('How-Old.net', 3, NOW()), +('The Simpsons', 2, NOW()), +('No Homo', 2, NOW()), +('PFFFTTTCHH', 1, NOW()), +('Ugly Children Lawsuit Hoax', 4, NOW()), +('Facebook University Meme Pages', 3, NOW()), +('Eccentric Witness Lady / Backin'' Up', 1, NOW()), +('Fake Pokémon Battles', 4, NOW()), +('Sherlock', 2, NOW()), +('MySpace Angles', 5, NOW()), +('The Floor is Lava / Hot Lava Game', 2, NOW()), +('You Laugh, You Lose', 4, NOW()), +('Rawr XD', 4, NOW()), +('Taylor Swift', 4, NOW()), +('David Kalac''s 4chan Murder Confession', 5, NOW()), +('Half-Life', 2, NOW()), +('Greater Internet Fuckwad Theory', 4, NOW()), +('Angry Dog Noises', 4, NOW()), +('I''m Gonna Do What''s Called a Pro Gamer Move', 2, NOW()), +('Internet Death Hoaxes', 4, NOW()), +('Ali-A''s Intro', 5, NOW()), +('¿Quieres?', 1, NOW()), +('Cooking by the Book', 5, NOW()), +('Online Pornography', 4, NOW()), +('Lil Bub', 4, NOW()), +('Are Ya Winning, Son?', 2, NOW()), +('I Want to Believe', 2, NOW()), +('Most People Rejected His Message', 3, NOW()), +('Hoenn Confirmed', 1, NOW()), +('Figwit', 4, NOW()), +('Turn Up the Volume', 5, NOW()), +('"Copy That"', 5, NOW()), +('Welcome to Chili''s', 3, NOW()), +('niconico', 1, NOW()), +('I Didn''t Get No Sleep ''Cause Of Ya''ll', 5, NOW()), +('Stannis the Mannis', 1, NOW()), +('Stupid Sexy Flanders', 4, NOW()), +('Drunk Baby', 3, NOW()), +('Into The Trash It Goes / Opinion Discarded', 5, NOW()), +('Binders Full of Women', 4, NOW()), +('Steam Sales', 1, NOW()), +('Antonio Banderas'' Laptop Reaction', 3, NOW()), +('LOL', 1, NOW()), +('With Great Power Comes Great Responsibility', 3, NOW()), +('NYC Cardstand Earthcam Trolling / "I''ll Be There in 30 Minutes"', 1, NOW()), +('"Bitch I Look Like Goku"', 2, NOW()), +('He Does It for Free', 5, NOW()), +('Jazz Music Stops', 5, NOW()), +('>tfw no gf', 1, NOW()), +('Internet Slang', 4, NOW()), +('Wolverine Crush', 3, NOW()), +('Nyanners', 2, NOW()), +('Like Skyrim With Guns', 5, NOW()), +('Wheelchair Drake', 3, NOW()), +('WataMote / It''s Not My Fault That I''m Not Popular', 2, NOW()), +('Abandon Thread', 4, NOW()), +('Arthur', 4, NOW()), +('Cupcake Dog', 1, NOW()), +('Free Bleeding', 2, NOW()), +('Have You Ever Been So Angry That You...', 1, NOW()), +('BRODYQUEST', 1, NOW()), +('Koichi Pose', 3, NOW()), +('They Took Our Jobs!', 3, NOW()), +('Brotherman Bill', 2, NOW()), +('Trayvon Martin''s Death', 3, NOW()), +('Brazilian Slutwalk Flasher', 3, NOW()), +('Gekyume''s Circumcision', 1, NOW()), +('The Pumpkin Dance', 4, NOW()), +('Chadwardenn', 1, NOW()), +('Breaking Bad', 1, NOW()), +('Emily Faked Cancer', 5, NOW()), +('Ear Rape', 2, NOW()), +('Dubstep', 1, NOW()), +('Snickers "Hungry?" Commercials', 4, NOW()), +('RCDart', 2, NOW()), +('Me Explaining to My Mom', 3, NOW()), +('Homer Simpson Backs Into Bushes', 1, NOW()), +('Mr. Worldwide', 4, NOW()), +('Website Anthropomorphism', 4, NOW()), +('Mehmet My Son', 5, NOW()), +('Mods Are Asleep', 2, NOW()), +('Modern Problems Require Modern Solutions', 5, NOW()), +('The Enigma of Amigara Fault / This Is My Hole', 3, NOW()), +('Duckroll', 1, NOW()), +('Woke', 3, NOW()), +('Imgur', 1, NOW()), +('Webcomics', 2, NOW()), +('Country Girls Make Do', 3, NOW()), +('Vinesauce', 5, NOW()), +('Lolicon', 5, NOW()), +('Simp', 3, NOW()), +('I Forced a Bot', 2, NOW()), +('Michael Rosen', 5, NOW()), +('Choo Choo Motherfucker', 2, NOW()), +('Ran Off Da Plug Twice', 2, NOW()), +('Waiting for OP', 5, NOW()), +('The Nuzlocke Challenge', 2, NOW()), +('Painis Cupcake (Penis Cupcake)', 4, NOW()), +('Kim Jong Il Looking At Things', 2, NOW()), +('Plebcomics', 3, NOW()), +('Impossible is Nothing', 1, NOW()), +('Beanos', 3, NOW()), +('I;m Thinking About Thos Beans', 3, NOW()), +('Over 9000 Penises', 1, NOW()), +('Childhood Enhanced', 3, NOW()), +('When Did This Become Hotter Than This', 1, NOW()), +('Objection!', 2, NOW()), +('Disgustang', 4, NOW()), +('Noice', 2, NOW()), +('LeafyIsHere', 1, NOW()), +('Ironic Memes', 4, NOW()), +('Cool Dog', 5, NOW()), +('Totally Looks Like / Separated At Birth', 3, NOW()), +('Vomit-chan', 3, NOW()), +('Pokémon GO', 4, NOW()), +('Dick Neck', 3, NOW()), +('Stop It Son, You Are Doing Me A Frighten', 3, NOW()), +('The Nutshack', 1, NOW()), +('G.I. Joe PSAs', 2, NOW()), +('Drake', 5, NOW()), +('Bernie Sanders', 2, NOW()), +('True Capitalist Radio', 1, NOW()), +('I''m Commander Shepard', 3, NOW()), +('Captain Hindsight', 4, NOW()), +('DeviantArt', 3, NOW()), +('Salil Sawarim / Saleel al-Sawarim', 2, NOW()), +('More Dakka', 2, NOW()), +('Voiceoverpete', 2, NOW()), +('Redneck Randal', 5, NOW()), +('Horse_ebooks', 3, NOW()), +('Let Me In', 5, NOW()), +('FunnyJunk', 2, NOW()), +('Check Out My Mixtape', 1, NOW()), +('MOAR', 3, NOW()), +('I''ve Made a Huge Mistake', 3, NOW()), +('Nyoro~n', 1, NOW()), +('Because of Parkinson''s', 4, NOW()), +('Outstanding Move / Maravillosa Jugada', 2, NOW()), +('Poe''s Law', 5, NOW()), +('Cinemagraphs', 4, NOW()), +('Ideal GF', 1, NOW()), +('Kamina Glasses', 3, NOW()), +('Hold My Flower', 5, NOW()), +('We Irritating', 1, NOW()), +('Fermi Paradox', 3, NOW()), +('Follow The Damn Train, CJ!', 5, NOW()), +('Sonic For Real Justice', 5, NOW()), +('Mike Wazowski-Sulley Face Swap', 4, NOW()), +('Tails Gets Trolled', 5, NOW()), +('Floating Chinese Government Officials', 2, NOW()), +('Bitches Don''t Know', 1, NOW()), +('Skidaddle Skidoodle Your Dick Is Now a Noodle', 5, NOW()), +('7-day No Fap Challenge', 2, NOW()), +('Floral Shoppe (フローラルの専門店)', 3, NOW()), +('200% Mad', 4, NOW()), +('"Why Is the FBI Here?"', 5, NOW()), +('Smitty Werbenjagermanjensen', 5, NOW()), +('The Warriors Blew a 3-1 Lead', 5, NOW()), +('I Lived Bitch', 5, NOW()), +('Moon''s Haunted', 5, NOW()), +('I Will Now Buy Your Game', 5, NOW()), +('The Miz Girl', 2, NOW()), +('Triple Parentheses / (((Echo)))', 1, NOW()), +('Gag Names', 1, NOW()), +('Vibrating GIFs', 2, NOW()), +('Back to the Future Day', 2, NOW()), +('Never Say No to Panda', 1, NOW()), +('Carlos', 2, NOW()), +('Do You Think This Is a Game?', 4, NOW()), +('Masahiro Sakurai', 1, NOW()), +('Down With Cis', 3, NOW()), +('Inhaling Seagull', 3, NOW()), +('We''ll bang, OK?', 2, NOW()), +('Meme Economy', 5, NOW()), +('Come to Brazil', 4, NOW()), +('The Internet Is For Porn', 4, NOW()), +('The Walking Dead', 1, NOW()), +('Lana Del Rey', 5, NOW()), +('Stoner Comics / Tree Comics', 1, NOW()), +('Me IRL', 1, NOW()), +('Garry''s Mod', 2, NOW()), +('Cockmongler', 4, NOW()), +('Story Time Jesus', 3, NOW()), +('YouTube Comment Memes', 4, NOW()), +('Twilight Sparkle Alicorn Controversy', 3, NOW()), +('Miss Kobayashi''s Dragon Maid', 3, NOW()), +('Sakurafish / Every Day Until You Like It', 1, NOW()), +('Condom Challenge', 5, NOW()), +('CD Emotes', 3, NOW()), +('Gardevoir', 5, NOW()), +('I''m You, But Stronger', 1, NOW()), +('Scumbag Brain', 3, NOW()), +('#NotMyRodrick', 2, NOW()), +('Taxmaster', 4, NOW()), +('They''re Eating Her!', 3, NOW()), +('Leave Britney Alone', 2, NOW()), +('Exodia the Forbidden One', 5, NOW()), +('This is delicious cake', 2, NOW()), +('Ice Cream Cone Guy', 2, NOW()), +('Vagineer', 1, NOW()), +('Casey Anthony Trial', 5, NOW()), +('OMG RUN Guy / Tampon Head Rage Face', 1, NOW()), +('Sco Pa Tu Manaa', 1, NOW()), +('My Brand', 5, NOW()), +('Meme of the Month Calendars', 3, NOW()), +('A Man Has Fallen Into the River in Lego City', 3, NOW()), +('Jaden Smith', 5, NOW()), +('McDonald''s Mulan Szechuan Sauce', 5, NOW()), +('Neon Genesis Evangelion', 5, NOW()), +('Dog Poo Girl', 2, NOW()), +('I Want My Hat Back', 1, NOW()), +('Sakuya''s breast padding', 5, NOW()), +('Throwback Thursday', 3, NOW()), +('Megalovania', 5, NOW()), +('They''re The Same Picture', 3, NOW()), +('They Played Us Like A Damn Fiddle!', 4, NOW()), +('YouTube Automatic Caption FAIL', 2, NOW()), +('Nice Meme', 2, NOW()), +('Dawn of the Final Day', 3, NOW()), +('The Legend of Zelda Timeline Theories', 1, NOW()), +('Nicki Minaj', 4, NOW()), +('Elf on the Shelf', 5, NOW()), +('Anthony Fantano', 2, NOW()), +('But Can You Do This?', 1, NOW()), +('Sexually Oblivious Female', 2, NOW()), +('George Takei Calls Out Anti-Gay School Board Member', 3, NOW()), +('Sky King / Richard Russell', 1, NOW()), +('Miss Me With That Gay Shit', 2, NOW()), +('Donglegate / Adria Richards', 2, NOW()), +('Spirit Pokémon', 5, NOW()), +('Roblox', 4, NOW()), +('Headless Mami', 2, NOW()), +('You Forgot Poland', 1, NOW()), +('Bottom Text', 5, NOW()), +('Vsauce', 2, NOW()), +('I Have Drawn You', 3, NOW()), +('What''s Wrong Big Boy?', 1, NOW()), +('Milo Yiannopoulos', 3, NOW()), +('Krispy Kreme / Froggy Fresh', 1, NOW()), +('I Fear No Man', 5, NOW()), +('Bitches Be Like', 5, NOW()), +('I''m Really Feeling It', 5, NOW()), +('Monkey Haircut', 1, NOW()), +('Why Are You Booing Me? I''m Right', 3, NOW()), +('Cancer', 4, NOW()), +('University of Maryland Sorority E-Mail Rant', 5, NOW()), +('Moe', 4, NOW()), +('Human Bean', 4, NOW()), +('Goblin Slayer', 5, NOW()), +('Evil Toddler', 2, NOW()), +('Can I Be Drawn Better?', 4, NOW()), +('The Gendo Pose', 3, NOW()), +('I Hope Mom and Dad Don''t Find Out', 2, NOW()), +('Pot of Greed', 4, NOW()), +('WHARRGARBL / Sprinkler Dog', 3, NOW()), +('Alt-right', 4, NOW()), +('Tebowing', 4, NOW()), +('Ted Williams / The Golden Voice', 1, NOW()), +('McDonald''s "Ran Ran Ru" Commercial', 1, NOW()), +('Open Eye Crying Laughing Emoji', 2, NOW()), +('Subway Sandwich Porn', 3, NOW()), +('Weaponized Autism', 2, NOW()), +('3DPD', 5, NOW()), +('Dr DisRespect', 3, NOW()), +('That''s just like, your opinion, man', 5, NOW()), +('Surprised Kitty', 2, NOW()), +('Happy Gary', 3, NOW()), +('Paula Deen Riding Things', 1, NOW()), +('Hot Dog Legs', 2, NOW()), +('Holy Shit It''s a Dinosaur!', 1, NOW()), +('Grape Lady', 4, NOW()), +('Fistful of Yen', 5, NOW()), +('Squart Guy', 1, NOW()), +('Sir, This Is an Arby''s', 4, NOW()), +('Folgers "Brother and Sister" Commercial', 4, NOW()), +('Who Killed Captain Alex?', 2, NOW()), +('fgsfds', 1, NOW()), +('Are You Not Entertained?', 5, NOW()), +('Shitlord', 3, NOW()), +('Cone-ing', 3, NOW()), +('Yamero', 5, NOW()), +('Single Topic Blogs', 3, NOW()), +('Angery', 3, NOW()), +('Squidward Fad', 2, NOW()), +('Enjoy Your AIDS', 1, NOW()), +('Vancouver Riot Kiss', 3, NOW()), +('Who''s Awesome? You''re Awesome! / Sos Groso, Sabelo!', 3, NOW()), +('Oh God! I Can See Forever!', 3, NOW()), +('Crossguard Lightsaber', 1, NOW()), +('Cyanide and Happiness', 3, NOW()), +('When Mama Isn''t Home', 4, NOW()), +('Cheems', 3, NOW()), +('Joe Biden', 3, NOW()), +('A Weapon to Surpass Metal Gear', 1, NOW()), +('Markiplier', 4, NOW()), +('Have You Ever Had A Dream Like This?', 2, NOW()), +('2 Guys 1 Fish', 2, NOW()), +('The Floor Is...', 5, NOW()), +('Shitty Charmander', 5, NOW()), +('Tonight''s Big Loser', 4, NOW()), +('Scary Maze Game', 5, NOW()), +('Lewd', 3, NOW()), +('"Millennials Are Killing..."', 4, NOW()), +('Beta Uprising', 1, NOW()), +('Don''t Tase Me Bro!', 4, NOW()), +('Pickup Line Panda', 3, NOW()), +('Chinese Cartoons', 4, NOW()), +('Lauren Phillips Lifting Alice Merchesi', 5, NOW()), +('World of Warcraft', 4, NOW()), +('Team Siren', 2, NOW()), +('Long Boy', 4, NOW()), +('How''d It Get Burned?', 5, NOW()), +('Covfefe', 2, NOW()), +('MissingNo.', 5, NOW()), +('Deadass', 5, NOW()), +('Damn Nature, You Scary!', 4, NOW()), +('Attack on Titan Opening Credits Parodies', 3, NOW()), +('Soraya Montenegro', 1, NOW()), +('Friendship Arsenal', 4, NOW()), +('Once-ler', 1, NOW()), +('Bertstrips', 2, NOW()), +('Mimikyu', 4, NOW()), +('Wanna Sprite Cranberry', 3, NOW()), +('The Emoji Movie', 1, NOW()), +('This Is So Sad', 1, NOW()), +('Vidya', 3, NOW()), +('Red Equal Sign', 2, NOW()), +('Mini Keanu Reeves', 1, NOW()), +('xkcd', 5, NOW()), +('What If We Kissed In', 5, NOW()), +('Bully Hunters', 1, NOW()), +('50 Cent Drive By', 3, NOW()), +('That Fucking Cat', 2, NOW()), +('Stop Trying to Make Fetch Happen', 1, NOW()), +('Internet Hate Machine', 1, NOW()), +('Dammit Jim, I''m a Doctor, Not a X', 5, NOW()), +('Andy'' Sixx''s Log of Shit', 5, NOW()), +('Danbooru', 3, NOW()), +('Fuck This Gay Earth', 3, NOW()), +('The Great Toilet Paper Debate', 3, NOW()), +('The Goggles Do Nothing!', 3, NOW()), +('Miss Teen USA South Carolina', 1, NOW()), +('Rappin'' for Jesus', 3, NOW()), +('Ridley is Too Big', 2, NOW()), +('Curb Your Enthusiasm', 1, NOW()), +('Billie Eilish''s Body Reveal Tour Video', 1, NOW()), +('MOM HOLY FUCK', 2, NOW()), +('Lemon Stealing Whores', 2, NOW()), +('Anarcho-Capitalism', 4, NOW()), +('Nickelback''s "Photograph"', 5, NOW()), +('Squidward Dab', 5, NOW()), +('Yesn''t', 3, NOW()), +('Grand Theft Auto', 1, NOW()), +('Drinking Out of Cups', 4, NOW()), +('Sonic the Hedgehog (2020 Film)', 4, NOW()), +('Art Student Owl', 4, NOW()); diff --git a/packages/postgrest-typegen/test/introspection/introspect.test.ts b/packages/postgrest-typegen/test/introspection/introspect.test.ts new file mode 100644 index 000000000..2b9e84a57 --- /dev/null +++ b/packages/postgrest-typegen/test/introspection/introspect.test.ts @@ -0,0 +1,225 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + PostgreSqlContainer, + type StartedPostgreSqlContainer, +} from "@testcontainers/postgresql"; +import { Pool } from "pg"; +import { Wait } from "testcontainers"; + +import { introspect } from "../../src/introspection/index.ts"; +import { parseGeneratorMetadata } from "../../src/types.ts"; +import type { GeneratorMetadata } from "../../src/types.ts"; + +/** + * Integration coverage for `introspect()` against a real Postgres container, + * using the same `00-init.sql` / `01-memes.sql` fixtures as postgres-meta. + * + * `pg.Pool` is passed directly as the `Queryable` — it satisfies the structural + * interface and returns int8 columns as strings, exercising `normalize.ts`. + */ +const FIXTURE_DIR = join(import.meta.dir, "fixtures"); +const byName = (a: { name: string }, b: { name: string }) => + a.name.localeCompare(b.name); + +let container: StartedPostgreSqlContainer; +let pool: Pool; +let full: GeneratorMetadata; +let onlyPublic: GeneratorMetadata; +let excludingPublic: GeneratorMetadata; + +beforeAll(async () => { + // The fixtures reference the `postgres` superuser role, so match it (the + // testcontainers default is `test`). + container = await new PostgreSqlContainer("postgres:15-alpine") + .withUsername("postgres") + .withPassword("postgres") + .withDatabase("postgres") + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(120_000) + .start(); + pool = new Pool({ connectionString: container.getConnectionUri() }); + await pool.query(readFileSync(join(FIXTURE_DIR, "00-init.sql"), "utf8")); + await pool.query(readFileSync(join(FIXTURE_DIR, "01-memes.sql"), "utf8")); + + full = await introspect(pool); + onlyPublic = await introspect(pool, { includedSchemas: ["public"] }); + excludingPublic = await introspect(pool, { excludedSchemas: ["public"] }); +}, 180_000); + +afterAll(async () => { + await pool?.end(); + await container?.stop(); +}); + +const findTable = (m: GeneratorMetadata, name: string) => + m.tables.find((t) => t.schema === "public" && t.name === name); + +describe("introspect (integration)", () => { + test("returns the public schema and excludes system schemas", () => { + const names = full.schemas.map((s) => s.name); + expect(names).toContain("public"); + expect(names).not.toContain("pg_catalog"); + expect(names).not.toContain("information_schema"); + }); + + test("normalizes int8 ids to numbers while leaving composite column ids as strings", () => { + const users = findTable(full, "users"); + expect(users).toBeDefined(); + expect(typeof users!.id).toBe("number"); + + const idColumn = full.columns.find( + (c) => c.table_id === users!.id && c.name === "id", + ); + expect(idColumn).toBeDefined(); + // table_id is int8 → number; the column's composite id stays a "." string + expect(typeof idColumn!.table_id).toBe("number"); + expect(typeof idColumn!.id).toBe("string"); + expect(idColumn!.id).toContain("."); + }); + + test("users columns introspect with expected formats/flags", () => { + const users = findTable(full, "users")!; + const cols = full.columns + .filter((c) => c.table_id === users.id) + .sort(byName) + .map((c) => ({ + name: c.name, + format: c.format, + is_nullable: c.is_nullable, + is_identity: c.is_identity, + })); + + expect(cols).toMatchInlineSnapshot(` + [ + { + "format": "numeric", + "is_identity": false, + "is_nullable": true, + "name": "decimal", + }, + { + "format": "int8", + "is_identity": true, + "is_nullable": false, + "name": "id", + }, + { + "format": "text", + "is_identity": false, + "is_nullable": true, + "name": "name", + }, + { + "format": "user_status", + "is_identity": false, + "is_nullable": true, + "name": "status", + }, + { + "format": "uuid", + "is_identity": false, + "is_nullable": true, + "name": "user_uuid", + }, + ] + `); + }); + + test("excludes trigger and event_trigger functions", () => { + expect(full.functions.some((f) => f.return_type === "trigger")).toBe(false); + expect(full.functions.some((f) => f.return_type === "event_trigger")).toBe( + false, + ); + // the plain `add(integer, integer)` SQL function from 00-init survives + expect( + full.functions.some((f) => f.schema === "public" && f.name === "add"), + ).toBe(true); + }); + + test("includes composite and enum types", () => { + expect( + full.types.some( + (t) => + t.schema === "public" && + t.name === "user_status" && + t.enums.length > 0, + ), + ).toBe(true); + expect( + full.types.some( + (t) => + t.name === "composite_type_with_array_attribute" && + t.attributes.length > 0, + ), + ).toBe(true); + }); + + describe("relationships", () => { + const has = ( + m: GeneratorMetadata, + relation: string, + referenced: string, + ): boolean => + m.relationships.some( + (r) => + r.schema === "public" && + r.relation === relation && + r.referenced_schema === "public" && + r.referenced_relation === referenced, + ); + + test("table→table foreign key (todos → users)", () => { + expect(has(full, "todos", "users")).toBe(true); + }); + + test("view→table expansion (todos_view → users)", () => { + expect(has(full, "todos_view", "users")).toBe(true); + }); + + test("table→view expansion (todos → users_view)", () => { + expect(has(full, "todos", "users_view")).toBe(true); + }); + + test("view→view expansion (todos_view → users_view)", () => { + expect(has(full, "todos_view", "users_view")).toBe(true); + }); + }); + + describe("parseGeneratorMetadata round-trips real introspection output", () => { + test("RETURNS TABLE functions emit null has_default for their OUT columns", () => { + const fn = full.functions.find( + (f) => f.schema === "public" && f.name === "function_returning_table", + ); + expect(fn).toBeDefined(); + const tableArgs = fn!.args.filter((a) => a.mode === "table"); + expect(tableArgs.length).toBeGreaterThan(0); + expect(tableArgs.every((a) => a.has_default === null)).toBe(true); + }); + + test("validates without throwing", () => { + expect(() => parseGeneratorMetadata(full)).not.toThrow(); + }); + }); + + describe("schema filters", () => { + test("includedSchemas restricts to the named schema", () => { + expect(onlyPublic.schemas.every((s) => s.name === "public")).toBe(true); + expect(findTable(onlyPublic, "users")).toBeDefined(); + }); + + test("excludedSchemas drops the named schema's objects", () => { + expect(excludingPublic.schemas.some((s) => s.name === "public")).toBe( + false, + ); + expect(excludingPublic.tables.every((t) => t.schema !== "public")).toBe( + true, + ); + expect(excludingPublic.columns.every((c) => c.schema !== "public")).toBe( + true, + ); + }); + }); +}); diff --git a/packages/postgrest-typegen/test/introspection/normalize.test.ts b/packages/postgrest-typegen/test/introspection/normalize.test.ts new file mode 100644 index 000000000..ef8638c1f --- /dev/null +++ b/packages/postgrest-typegen/test/introspection/normalize.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; + +import { + coerceInt8Value, + normalizeRow, + normalizeRows, +} from "../../src/introspection/normalize.ts"; + +/** + * Reference implementation of postgres-meta's `setTypeParser(INT8, ...)` from + * `src/lib/db.ts`, used to assert byte-for-byte equivalence. + */ +function dbTsInt8Parser(x: string): string | number { + const asNumber = Number(x); + if (Number.isSafeInteger(asNumber)) { + return asNumber; + } else { + return x; + } +} + +describe("coerceInt8Value", () => { + test("matches db.ts int8 parser for representative values", () => { + const samples = [ + "0", + "1", + "16385", + "9007199254740991", // Number.MAX_SAFE_INTEGER + "9007199254740993", // beyond safe integer → stays string + "123456789012345678901234567890", + ]; + for (const s of samples) { + expect(coerceInt8Value(s)).toBe(dbTsInt8Parser(s)); + } + }); + + test("passes through non-string values unchanged", () => { + expect(coerceInt8Value(42)).toBe(42); + expect(coerceInt8Value(null)).toBe(null); + expect(coerceInt8Value(undefined)).toBe(undefined); + }); + + test("leaves composite text ids (e.g. column id) untouched", () => { + // Columns expose id as `.` (text, not int8). It is not a safe + // integer, so it must survive coercion exactly as postgres-meta leaves it. + expect(coerceInt8Value("16385.1")).toBe("16385.1"); + expect(coerceInt8Value("16385.10")).toBe("16385.10"); + }); + + test("large unsafe integer string is preserved verbatim", () => { + expect(coerceInt8Value("9223372036854775807")).toBe("9223372036854775807"); + }); +}); + +describe("normalizeRow", () => { + test("coerces known int8 fields and leaves others alone", () => { + const row: Record = { + id: "16385", + table_id: "16385", + type_relation_id: "16390", + return_type_id: "23", + return_type_relation_id: null, + bytes: "8192", + live_rows_estimate: "100", + dead_rows_estimate: "0", + name: "tickets", + schema: "public", + // not in the int8 set — must remain a string even though numeric + argument_types: "12345", + }; + + expect(normalizeRow({ ...row })).toEqual({ + id: 16385, + table_id: 16385, + type_relation_id: 16390, + return_type_id: 23, + return_type_relation_id: null, + bytes: 8192, + live_rows_estimate: 100, + dead_rows_estimate: 0, + name: "tickets", + schema: "public", + argument_types: "12345", + }); + }); + + test("preserves a column row's composite text id", () => { + const row: Record = { + table_id: "16385", + id: "16385.2", + name: "status", + }; + expect(normalizeRow(row)).toEqual({ + table_id: 16385, + id: "16385.2", + name: "status", + }); + }); +}); + +describe("normalizeRows", () => { + test("normalizes every row", () => { + const rows: Record[] = [ + { id: "1" }, + { id: "2" }, + { id: "9007199254740993" }, + ]; + expect(normalizeRows(rows)).toEqual([ + { id: 1 }, + { id: 2 }, + { id: "9007199254740993" }, + ]); + }); +}); diff --git a/packages/postgrest-typegen/test/introspection/relationships.test.ts b/packages/postgrest-typegen/test/introspection/relationships.test.ts new file mode 100644 index 000000000..9ebfd7a15 --- /dev/null +++ b/packages/postgrest-typegen/test/introspection/relationships.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test"; + +import type { PostgresRelationship } from "../../src/types.ts"; +import { + expandViewRelationships, + listRelationships, + type ViewKeyDependency, +} from "../../src/introspection/relationships.ts"; + +const postsAuthorFk: PostgresRelationship = { + foreign_key_name: "posts_author_id_fkey", + schema: "public", + relation: "posts", + columns: ["author_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users", + referenced_columns: ["id"], +}; + +const viewToTableDep: ViewKeyDependency = { + table_schema: "public", + table_name: "posts", + view_schema: "public", + view_name: "posts_view", + constraint_name: "posts_author_id_fkey", + constraint_type: "f", + column_dependencies: [ + { table_column: "author_id", view_columns: ["author_id"] }, + ], +}; + +const tableToViewDep: ViewKeyDependency = { + table_schema: "public", + table_name: "users", + view_schema: "public", + view_name: "users_view", + constraint_name: "posts_author_id_fkey", + constraint_type: "f_ref", + column_dependencies: [{ table_column: "id", view_columns: ["id"] }], +}; + +describe("expandViewRelationships", () => { + test("view→table expansion (constraint_type 'f')", () => { + const result = expandViewRelationships([postsAuthorFk], [viewToTableDep]); + + expect(result).toEqual([ + { + foreign_key_name: "posts_author_id_fkey", + schema: "public", + relation: "posts_view", + columns: ["author_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users", + referenced_columns: ["id"], + }, + ]); + }); + + test("table→view expansion (constraint_type 'f_ref')", () => { + const result = expandViewRelationships([postsAuthorFk], [tableToViewDep]); + + expect(result).toEqual([ + { + foreign_key_name: "posts_author_id_fkey", + schema: "public", + relation: "posts", + columns: ["author_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users_view", + referenced_columns: ["id"], + }, + ]); + }); + + test("view→view expansion combines both dependency kinds", () => { + const result = expandViewRelationships( + [postsAuthorFk], + [viewToTableDep, tableToViewDep], + ); + + // view→table, table→view, then view→view + expect(result).toEqual([ + { + foreign_key_name: "posts_author_id_fkey", + schema: "public", + relation: "posts_view", + columns: ["author_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users", + referenced_columns: ["id"], + }, + { + foreign_key_name: "posts_author_id_fkey", + schema: "public", + relation: "posts", + columns: ["author_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users_view", + referenced_columns: ["id"], + }, + { + foreign_key_name: "posts_author_id_fkey", + schema: "public", + relation: "posts_view", + columns: ["author_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users_view", + referenced_columns: ["id"], + }, + ]); + }); + + test("cartesian product over multiple view columns and composite keys", () => { + const compositeFk: PostgresRelationship = { + foreign_key_name: "memberships_org_fkey", + schema: "public", + relation: "memberships", + columns: ["org_id", "user_id"], + is_one_to_one: true, + referenced_schema: "public", + referenced_relation: "orgs", + referenced_columns: ["org_id", "owner_id"], + }; + const compositeDep: ViewKeyDependency = { + table_schema: "public", + table_name: "memberships", + view_schema: "public", + view_name: "memberships_view", + constraint_name: "memberships_org_fkey", + constraint_type: "f", + column_dependencies: [ + { table_column: "org_id", view_columns: ["org_id", "organization_id"] }, + { table_column: "user_id", view_columns: ["user_id", "member_id"] }, + ], + }; + + const result = expandViewRelationships([compositeFk], [compositeDep]); + + // 2 x 2 cartesian product of the view column permutations + expect(result.map((r) => r.columns)).toEqual([ + ["org_id", "user_id"], + ["org_id", "member_id"], + ["organization_id", "user_id"], + ["organization_id", "member_id"], + ]); + expect( + result.every( + (r) => r.relation === "memberships_view" && r.is_one_to_one === true, + ), + ).toBe(true); + }); + + test("returns nothing when no view depends on the relationship", () => { + expect(expandViewRelationships([postsAuthorFk], [])).toEqual([]); + }); +}); + +describe("listRelationships", () => { + test("concatenates table relationships with view-derived ones", async () => { + const db = { + query: async (sql: string) => ({ + rows: sql.includes("pks_uniques_cols") + ? [postsAuthorFk] + : [viewToTableDep], + }), + }; + + const result = await listRelationships(db, { includedSchemas: ["public"] }); + + expect(result).toEqual([ + postsAuthorFk, + { + foreign_key_name: "posts_author_id_fkey", + schema: "public", + relation: "posts_view", + columns: ["author_id"], + is_one_to_one: false, + referenced_schema: "public", + referenced_relation: "users", + referenced_columns: ["id"], + }, + ]); + }); +}); diff --git a/packages/postgrest-typegen/test/introspection/sql.test.ts b/packages/postgrest-typegen/test/introspection/sql.test.ts new file mode 100644 index 000000000..e6b65e364 --- /dev/null +++ b/packages/postgrest-typegen/test/introspection/sql.test.ts @@ -0,0 +1,770 @@ +import { describe, expect, test } from "bun:test"; + +import { + DEFAULT_SYSTEM_SCHEMAS, + filterByList, +} from "../../src/introspection/sql/helpers.ts"; +import { SCHEMAS_SQL } from "../../src/introspection/sql/schemas.sql.ts"; +import { TABLES_SQL } from "../../src/introspection/sql/table.sql.ts"; +import { FOREIGN_TABLES_SQL } from "../../src/introspection/sql/foreign_tables.sql.ts"; +import { VIEWS_SQL } from "../../src/introspection/sql/views.sql.ts"; +import { MATERIALIZED_VIEWS_SQL } from "../../src/introspection/sql/materialized_views.sql.ts"; +import { COLUMNS_SQL } from "../../src/introspection/sql/columns.sql.ts"; +import { FUNCTIONS_SQL } from "../../src/introspection/sql/functions.sql.ts"; +import { TYPES_SQL } from "../../src/introspection/sql/types.sql.ts"; +import { TABLE_RELATIONSHIPS_SQL } from "../../src/introspection/sql/table_relationships.sql.ts"; +import { VIEWS_KEY_DEPENDENCIES_SQL } from "../../src/introspection/sql/views_key_dependencies.sql.ts"; + +/** + * These tests pin the exact SQL the generator/introspection path builds. The + * builders are ported verbatim from postgres-meta and parameterized; here we + * exercise the single option combination `introspect()` (PGMETA-110) will use: + * + * - schemas/tables/views/columns/functions/relationships: system schemas + * excluded (`includeSystemSchemas: false`) + * - foreign tables / materialized views: no default system-schema exclusion + * - types: includeTableTypes + includeArrayTypes true, no schema filter + * (includeSystemSchemas: true) + * + * Snapshots double as a guard that the ported SQL stays byte-stable. + */ +describe("filterByList", () => { + test("produces a NOT IN clause for the default system schemas", () => { + expect( + filterByList(undefined, undefined, DEFAULT_SYSTEM_SCHEMAS), + ).toMatchInlineSnapshot( + `"NOT IN ('information_schema','pg_catalog','pg_toast')"`, + ); + }); + + test("produces an IN clause for included schemas", () => { + expect( + filterByList(["public", "api"], undefined, DEFAULT_SYSTEM_SCHEMAS), + ).toMatchInlineSnapshot(`"IN ('public','api')"`); + }); + + test("returns empty string when nothing to filter", () => { + expect(filterByList(undefined, undefined, undefined)).toBe(""); + }); +}); + +// The schema filter the generator path computes for the system-schema-excluding queries. +const schemaFilter = filterByList(undefined, undefined, DEFAULT_SYSTEM_SCHEMAS); + +describe("introspection SQL builders (generator-path option combination)", () => { + test("SCHEMAS_SQL", () => { + expect( + SCHEMAS_SQL({ includeSystemSchemas: false, nameFilter: schemaFilter }), + ).toMatchInlineSnapshot(` + " + -- Adapted from information_schema.schemata + select + n.oid::int8 as id, + n.nspname as name, + u.rolname as owner + from + pg_namespace n, + pg_roles u + where + n.nspowner = u.oid + + and n.nspname NOT IN ('information_schema','pg_catalog','pg_toast') + and not pg_catalog.starts_with(n.nspname, 'pg_') + and ( + pg_has_role(n.nspowner, 'USAGE') + or has_schema_privilege(n.oid, 'CREATE, USAGE') + ) + and not pg_catalog.starts_with(n.nspname, 'pg_temp_') + and not pg_catalog.starts_with(n.nspname, 'pg_toast_temp_') + + + " + `); + }); + + test("TABLES_SQL", () => { + expect(TABLES_SQL({ schemaFilter })).toMatchInlineSnapshot(` + " + SELECT + c.oid :: int8 AS id, + nc.nspname AS schema, + c.relname AS name, + c.relrowsecurity AS rls_enabled, + c.relforcerowsecurity AS rls_forced, + CASE + WHEN c.relreplident = 'd' THEN 'DEFAULT' + WHEN c.relreplident = 'i' THEN 'INDEX' + WHEN c.relreplident = 'f' THEN 'FULL' + ELSE 'NOTHING' + END AS replica_identity, + pg_total_relation_size(format('%I.%I', nc.nspname, c.relname)) :: int8 AS bytes, + pg_size_pretty( + pg_total_relation_size(format('%I.%I', nc.nspname, c.relname)) + ) AS size, + pg_stat_get_live_tuples(c.oid) AS live_rows_estimate, + pg_stat_get_dead_tuples(c.oid) AS dead_rows_estimate, + obj_description(c.oid) AS comment + FROM + pg_namespace nc + JOIN pg_class c ON nc.oid = c.relnamespace + WHERE + nc.nspname NOT IN ('information_schema','pg_catalog','pg_toast') AND + + + c.relkind IN ('r', 'p') + AND NOT pg_is_other_temp_schema(nc.oid) + AND ( + pg_has_role(c.relowner, 'USAGE') + OR has_table_privilege( + c.oid, + 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER' + ) + OR has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') + ) + + + " + `); + }); + + test("FOREIGN_TABLES_SQL", () => { + // foreign tables use filterByList(included, excluded) with no default exclude + expect(FOREIGN_TABLES_SQL({ schemaFilter: "" })).toMatchInlineSnapshot(` + " + SELECT + c.oid :: int8 AS id, + n.nspname AS schema, + c.relname AS name, + obj_description(c.oid) AS comment + FROM + pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE + + + + c.relkind = 'f' + + + " + `); + }); + + test("VIEWS_SQL", () => { + expect(VIEWS_SQL({ schemaFilter })).toMatchInlineSnapshot(` + " + SELECT + c.oid :: int8 AS id, + n.nspname AS schema, + c.relname AS name, + -- See definition of information_schema.views + (pg_relation_is_updatable(c.oid, false) & 20) = 20 AS is_updatable, + obj_description(c.oid) AS comment + FROM + pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE + n.nspname NOT IN ('information_schema','pg_catalog','pg_toast') AND + + + c.relkind = 'v' + + + " + `); + }); + + test("MATERIALIZED_VIEWS_SQL", () => { + // materialized views use filterByList(included, excluded, undefined) + expect(MATERIALIZED_VIEWS_SQL({ schemaFilter: "" })).toMatchInlineSnapshot(` + " + select + c.oid::int8 as id, + n.nspname as schema, + c.relname as name, + c.relispopulated as is_populated, + obj_description(c.oid) as comment + from + pg_class c + join pg_namespace n on n.oid = c.relnamespace + where + + + + c.relkind = 'm' + + + " + `); + }); + + test("COLUMNS_SQL", () => { + expect(COLUMNS_SQL({ schemaFilter })).toMatchInlineSnapshot(` + " + -- Adapted from information_schema.columns + + SELECT + c.oid :: int8 AS table_id, + nc.nspname AS schema, + c.relname AS table, + (c.oid || '.' || a.attnum) AS id, + a.attnum AS ordinal_position, + a.attname AS name, + CASE + WHEN a.atthasdef THEN pg_get_expr(ad.adbin, ad.adrelid) + ELSE NULL + END AS default_value, + CASE + WHEN t.typtype = 'd' THEN CASE + WHEN bt.typelem <> 0 :: oid + AND bt.typlen = -1 THEN 'ARRAY' + WHEN nbt.nspname = 'pg_catalog' THEN format_type(t.typbasetype, NULL) + ELSE 'USER-DEFINED' + END + ELSE CASE + WHEN t.typelem <> 0 :: oid + AND t.typlen = -1 THEN 'ARRAY' + WHEN nt.nspname = 'pg_catalog' THEN format_type(a.atttypid, NULL) + ELSE 'USER-DEFINED' + END + END AS data_type, + COALESCE(bt.typname, t.typname) AS format, + a.attidentity IN ('a', 'd') AS is_identity, + CASE + a.attidentity + WHEN 'a' THEN 'ALWAYS' + WHEN 'd' THEN 'BY DEFAULT' + ELSE NULL + END AS identity_generation, + a.attgenerated IN ('s') AS is_generated, + NOT ( + a.attnotnull + OR t.typtype = 'd' AND t.typnotnull + ) AS is_nullable, + ( + c.relkind IN ('r', 'p') + OR c.relkind IN ('v', 'f') AND pg_column_is_updatable(c.oid, a.attnum, FALSE) + ) AS is_updatable, + uniques.table_id IS NOT NULL AS is_unique, + check_constraints.definition AS "check", + array_to_json( + array( + SELECT + enumlabel + FROM + pg_catalog.pg_enum enums + WHERE + enums.enumtypid = coalesce(bt.oid, t.oid) + OR enums.enumtypid = coalesce(bt.typelem, t.typelem) + ORDER BY + enums.enumsortorder + ) + ) AS enums, + col_description(c.oid, a.attnum) AS comment + FROM + pg_attribute a + LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid + AND a.attnum = ad.adnum + JOIN ( + pg_class c + JOIN pg_namespace nc ON c.relnamespace = nc.oid + ) ON a.attrelid = c.oid + JOIN ( + pg_type t + JOIN pg_namespace nt ON t.typnamespace = nt.oid + ) ON a.atttypid = t.oid + LEFT JOIN ( + pg_type bt + JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid + ) ON t.typtype = 'd' + AND t.typbasetype = bt.oid + LEFT JOIN ( + SELECT DISTINCT ON (table_id, ordinal_position) + conrelid AS table_id, + conkey[1] AS ordinal_position + FROM pg_catalog.pg_constraint + WHERE contype = 'u' AND cardinality(conkey) = 1 + ) AS uniques ON uniques.table_id = c.oid AND uniques.ordinal_position = a.attnum + LEFT JOIN ( + -- We only select the first column check + SELECT DISTINCT ON (table_id, ordinal_position) + conrelid AS table_id, + conkey[1] AS ordinal_position, + substring( + pg_get_constraintdef(pg_constraint.oid, true), + 8, + length(pg_get_constraintdef(pg_constraint.oid, true)) - 8 + ) AS "definition" + FROM pg_constraint + WHERE contype = 'c' AND cardinality(conkey) = 1 + ORDER BY table_id, ordinal_position, oid asc + ) AS check_constraints ON check_constraints.table_id = c.oid AND check_constraints.ordinal_position = a.attnum + WHERE + nc.nspname NOT IN ('information_schema','pg_catalog','pg_toast') AND + + + + + NOT pg_is_other_temp_schema(nc.oid) + AND a.attnum > 0 + AND NOT a.attisdropped + AND (c.relkind IN ('r', 'v', 'm', 'f', 'p')) + AND ( + pg_has_role(c.relowner, 'USAGE') + OR has_column_privilege( + c.oid, + a.attnum, + 'SELECT, INSERT, UPDATE, REFERENCES' + ) + ) + + + " + `); + }); + + test("FUNCTIONS_SQL", () => { + expect(FUNCTIONS_SQL({ schemaFilter })).toMatchInlineSnapshot(` + " + -- CTE with sane arg_modes, arg_names, and arg_types. + -- All three are always of the same length. + -- All three include all args, including OUT and TABLE args. + with functions as ( + select + p.*, + -- proargmodes is null when all arg modes are IN + coalesce( + p.proargmodes, + array_fill('i'::text, array[cardinality(coalesce(p.proallargtypes, p.proargtypes))]) + ) as arg_modes, + -- proargnames is null when all args are unnamed + coalesce( + p.proargnames, + array_fill(''::text, array[cardinality(coalesce(p.proallargtypes, p.proargtypes))]) + ) as arg_names, + -- proallargtypes is null when all arg modes are IN + coalesce(p.proallargtypes, p.proargtypes) as arg_types, + array_cat( + array_fill(false, array[pronargs - pronargdefaults]), + array_fill(true, array[pronargdefaults])) as arg_has_defaults + from + pg_proc as p + join pg_namespace n on p.pronamespace = n.oid + where + n.nspname NOT IN ('information_schema','pg_catalog','pg_toast') AND + + + + p.prokind = 'f' + ) + select + f.oid::int8 as id, + n.nspname as schema, + f.proname as name, + l.lanname as language, + case + when l.lanname = 'internal' then '' + else f.prosrc + end as definition, + case + when l.lanname = 'internal' then f.prosrc + else pg_get_functiondef(f.oid) + end as complete_statement, + coalesce(f_args.args, '[]') as args, + pg_get_function_arguments(f.oid) as argument_types, + pg_get_function_identity_arguments(f.oid) as identity_argument_types, + f.prorettype::int8 as return_type_id, + pg_get_function_result(f.oid) as return_type, + nullif(rt.typrelid::int8, 0) as return_type_relation_id, + f.proretset as is_set_returning_function, + case + when f.proretset then nullif(f.prorows, 0) + else null + end as prorows, + case + when f.provolatile = 'i' then 'IMMUTABLE' + when f.provolatile = 's' then 'STABLE' + when f.provolatile = 'v' then 'VOLATILE' + end as behavior, + f.prosecdef as security_definer, + f_config.config_params as config_params + from + functions f + left join pg_namespace n on f.pronamespace = n.oid + left join pg_language l on f.prolang = l.oid + left join pg_type rt on rt.oid = f.prorettype + left join ( + select + oid, + jsonb_object_agg(param, value) filter (where param is not null) as config_params + from + ( + select + oid, + (string_to_array(unnest(proconfig), '='))[1] as param, + (string_to_array(unnest(proconfig), '='))[2] as value + from + functions + ) as t + group by + oid + ) f_config on f_config.oid = f.oid + left join ( + select + oid, + jsonb_agg(jsonb_build_object( + 'mode', t2.mode, + 'name', name, + 'type_id', type_id, + 'has_default', has_default + )) as args + from + ( + select + oid, + unnest(arg_modes) as mode, + unnest(arg_names) as name, + unnest(arg_types)::int8 as type_id, + unnest(arg_has_defaults) as has_default + from + functions + ) as t1, + lateral ( + select + case + when t1.mode = 'i' then 'in' + when t1.mode = 'o' then 'out' + when t1.mode = 'b' then 'inout' + when t1.mode = 'v' then 'variadic' + else 'table' + end as mode + ) as t2 + group by + t1.oid + ) f_args on f_args.oid = f.oid + + + " + `); + }); + + test("TYPES_SQL", () => { + expect( + TYPES_SQL({ + schemaFilter: "", + includeTableTypes: true, + includeArrayTypes: true, + }), + ).toMatchInlineSnapshot(` + " + select + t.oid::int8 as id, + t.typname as name, + n.nspname as schema, + format_type (t.oid, null) as format, + coalesce(t_enums.enums, '[]') as enums, + coalesce(t_attributes.attributes, '[]') as attributes, + obj_description (t.oid, 'pg_type') as comment, + nullif(t.typrelid::int8, 0) as type_relation_id + from + pg_type t + left join pg_namespace n on n.oid = t.typnamespace + left join ( + select + enumtypid, + jsonb_agg(enumlabel order by enumsortorder) as enums + from + pg_enum + group by + enumtypid + ) as t_enums on t_enums.enumtypid = t.oid + left join ( + select + oid, + jsonb_agg( + jsonb_build_object('name', a.attname, 'type_id', a.atttypid::int8) + order by a.attnum asc + ) as attributes + from + pg_class c + join pg_attribute a on a.attrelid = c.oid + where + c.relkind = 'c' and not a.attisdropped + group by + c.oid + ) as t_attributes on t_attributes.oid = t.typrelid + where + ( + t.typrelid = 0 + or ( + select + c.relkind in ('c', 'r', 'v', 'm', 'p') + from + pg_class c + where + c.oid = t.typrelid + ) + ) + + + + + + " + `); + }); + + test("TABLE_RELATIONSHIPS_SQL", () => { + expect(TABLE_RELATIONSHIPS_SQL({ schemaFilter })).toMatchInlineSnapshot(` + " + -- Adapted from + -- https://github.com/PostgREST/postgrest/blob/f9f0f79fa914ac00c11fbf7f4c558e14821e67e2/src/PostgREST/SchemaCache.hs#L722 + WITH + pks_uniques_cols AS ( + SELECT + connamespace, + conrelid, + jsonb_agg(column_info.cols) as cols + FROM pg_constraint + JOIN lateral ( + SELECT array_agg(cols.attname order by cols.attnum) as cols + FROM ( select unnest(conkey) as col) _ + JOIN pg_attribute cols on cols.attrelid = conrelid and cols.attnum = col + ) column_info ON TRUE + WHERE + contype IN ('p', 'u') and + connamespace::regnamespace::text <> 'pg_catalog' + and connamespace::regnamespace::text NOT IN ('information_schema','pg_catalog','pg_toast') + GROUP BY connamespace, conrelid + ) + SELECT + traint.conname AS foreign_key_name, + ns1.nspname AS schema, + tab.relname AS relation, + column_info.cols AS columns, + ns2.nspname AS referenced_schema, + other.relname AS referenced_relation, + column_info.refs AS referenced_columns, + (column_info.cols IN (SELECT * FROM jsonb_array_elements(pks_uqs.cols))) AS is_one_to_one + FROM pg_constraint traint + JOIN LATERAL ( + SELECT + jsonb_agg(cols.attname order by ord) AS cols, + jsonb_agg(refs.attname order by ord) AS refs + FROM unnest(traint.conkey, traint.confkey) WITH ORDINALITY AS _(col, ref, ord) + JOIN pg_attribute cols ON cols.attrelid = traint.conrelid AND cols.attnum = col + JOIN pg_attribute refs ON refs.attrelid = traint.confrelid AND refs.attnum = ref + WHERE traint.connamespace::regnamespace::text NOT IN ('information_schema','pg_catalog','pg_toast') + ) AS column_info ON TRUE + JOIN pg_namespace ns1 ON ns1.oid = traint.connamespace + JOIN pg_class tab ON tab.oid = traint.conrelid + JOIN pg_class other ON other.oid = traint.confrelid + JOIN pg_namespace ns2 ON ns2.oid = other.relnamespace + LEFT JOIN pks_uniques_cols pks_uqs ON pks_uqs.connamespace = traint.connamespace AND pks_uqs.conrelid = traint.conrelid + WHERE traint.contype = 'f' + AND traint.conparentid = 0 + and ns1.nspname NOT IN ('information_schema','pg_catalog','pg_toast') + " + `); + }); + + test("VIEWS_KEY_DEPENDENCIES_SQL", () => { + expect(VIEWS_KEY_DEPENDENCIES_SQL({ schemaFilter })).toMatchInlineSnapshot(` + " + -- Adapted from + -- https://github.com/PostgREST/postgrest/blob/f9f0f79fa914ac00c11fbf7f4c558e14821e67e2/src/PostgREST/SchemaCache.hs#L820 + with recursive + pks_fks as ( + -- pk + fk referencing col + select + contype::text as contype, + conname, + array_length(conkey, 1) as ncol, + conrelid as resorigtbl, + col as resorigcol, + ord + from pg_constraint + left join lateral unnest(conkey) with ordinality as _(col, ord) on true + where contype IN ('p', 'f') + union + -- fk referenced col + select + concat(contype, '_ref') as contype, + conname, + array_length(confkey, 1) as ncol, + confrelid, + col, + ord + from pg_constraint + left join lateral unnest(confkey) with ordinality as _(col, ord) on true + where contype='f' + and connamespace::regnamespace::text NOT IN ('information_schema','pg_catalog','pg_toast') + ), + views as ( + select + c.oid as view_id, + n.nspname as view_schema, + c.relname as view_name, + r.ev_action as view_definition + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + join pg_rewrite r on r.ev_class = c.oid + where c.relkind in ('v', 'm') + and n.nspname NOT IN ('information_schema','pg_catalog','pg_toast') + ), + transform_json as ( + select + view_id, view_schema, view_name, + -- the following formatting is without indentation on purpose + -- to allow simple diffs, with less whitespace noise + replace( + replace( + replace( + replace( + replace( + replace( + replace( + regexp_replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + replace( + view_definition::text, + -- This conversion to json is heavily optimized for performance. + -- The general idea is to use as few regexp_replace() calls as possible. + -- Simple replace() is a lot faster, so we jump through some hoops + -- to be able to use regexp_replace() only once. + -- This has been tested against a huge schema with 250+ different views. + -- The unit tests do NOT reflect all possible inputs. Be careful when changing this! + -- ----------------------------------------------- + -- pattern | replacement | flags + -- ----------------------------------------------- + -- <> in pg_node_tree is the same as null in JSON, but due to very poor performance of json_typeof + -- we need to make this an empty array here to prevent json_array_elements from throwing an error + -- when the targetList is null. + -- We'll need to put it first, to make the node protection below work for node lists that start with + -- null: (<> ..., too. This is the case for coldefexprs, when the first column does not have a default value. + '<>' , '()' + -- , is not part of the pg_node_tree format, but used in the regex. + -- This removes all , that might be part of column names. + ), ',' , '' + -- The same applies for { and }, although those are used a lot in pg_node_tree. + -- We remove the escaped ones, which might be part of column names again. + ), E'\\\\{' , '' + ), E'\\\\}' , '' + -- The fields we need are formatted as json manually to protect them from the regex. + ), ' :targetList ' , ',"targetList":' + ), ' :resno ' , ',"resno":' + ), ' :resorigtbl ' , ',"resorigtbl":' + ), ' :resorigcol ' , ',"resorigcol":' + -- Make the regex also match the node type, e.g. \`{QUERY ...\`, to remove it in one pass. + ), '{' , '{ :' + -- Protect node lists, which start with \`({\` or \`((\` from the greedy regex. + -- The extra \`{\` is removed again later. + ), '((' , '{((' + ), '({' , '{({' + -- This regex removes all unused fields to avoid the need to format all of them correctly. + -- This leads to a smaller json result as well. + -- Removal stops at \`,\` for used fields (see above) and \`}\` for the end of the current node. + -- Nesting can't be parsed correctly with a regex, so we stop at \`{\` as well and + -- add an empty key for the followig node. + ), ' :[^}{,]+' , ',"":' , 'g' + -- For performance, the regex also added those empty keys when hitting a \`,\` or \`}\`. + -- Those are removed next. + ), ',"":}' , '}' + ), ',"":,' , ',' + -- This reverses the "node list protection" from above. + ), '{(' , '(' + -- Every key above has been added with a \`,\` so far. The first key in an object doesn't need it. + ), '{,' , '{' + -- pg_node_tree has \`()\` around lists, but JSON uses \`[]\` + ), '(' , '[' + ), ')' , ']' + -- pg_node_tree has \` \` between list items, but JSON uses \`,\` + ), ' ' , ',' + )::json as view_definition + from views + ), + target_entries as( + select + view_id, view_schema, view_name, + json_array_elements(view_definition->0->'targetList') as entry + from transform_json + ), + results as( + select + view_id, view_schema, view_name, + (entry->>'resno')::int as view_column, + (entry->>'resorigtbl')::oid as resorigtbl, + (entry->>'resorigcol')::int as resorigcol + from target_entries + ), + -- CYCLE detection according to PG docs: https://www.postgresql.org/docs/current/queries-with.html#QUERIES-WITH-CYCLE + -- Can be replaced with CYCLE clause once PG v13 is EOL. + recursion(view_id, view_schema, view_name, view_column, resorigtbl, resorigcol, is_cycle, path) as( + select + r.*, + false, + ARRAY[resorigtbl] + from results r + where view_schema NOT IN ('information_schema','pg_catalog','pg_toast') + union all + select + view.view_id, + view.view_schema, + view.view_name, + view.view_column, + tab.resorigtbl, + tab.resorigcol, + tab.resorigtbl = ANY(path), + path || tab.resorigtbl + from recursion view + join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column + where not is_cycle + ), + repeated_references as( + select + view_id, + view_schema, + view_name, + resorigtbl, + resorigcol, + array_agg(attname) as view_columns + from recursion + join pg_attribute vcol on vcol.attrelid = view_id and vcol.attnum = view_column + group by + view_id, + view_schema, + view_name, + resorigtbl, + resorigcol + ) + select + sch.nspname as table_schema, + tbl.relname as table_name, + rep.view_schema, + rep.view_name, + pks_fks.conname as constraint_name, + pks_fks.contype as constraint_type, + jsonb_agg( + jsonb_build_object('table_column', col.attname, 'view_columns', view_columns) order by pks_fks.ord + ) as column_dependencies + from repeated_references rep + join pks_fks using (resorigtbl, resorigcol) + join pg_class tbl on tbl.oid = rep.resorigtbl + join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rep.resorigcol + join pg_namespace sch on sch.oid = tbl.relnamespace + group by sch.nspname, tbl.relname, rep.view_schema, rep.view_name, pks_fks.conname, pks_fks.contype, pks_fks.ncol + -- make sure we only return key for which all columns are referenced in the view - no partial PKs or FKs + having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1) + " + `); + }); +}); diff --git a/packages/postgrest-typegen/test/parity/expected/go.txt b/packages/postgrest-typegen/test/parity/expected/go.txt new file mode 100644 index 000000000..916bdbc69 --- /dev/null +++ b/packages/postgrest-typegen/test/parity/expected/go.txt @@ -0,0 +1,291 @@ +package database + +type PublicCategorySelect struct { + Id int32 `json:"id"` + Name string `json:"name"` +} + +type PublicCategoryInsert struct { + Id *int32 `json:"id"` + Name string `json:"name"` +} + +type PublicCategoryUpdate struct { + Id *int32 `json:"id"` + Name *string `json:"name"` +} + +type PublicEmptySelect struct { + +} + +type PublicEmptyInsert struct { + +} + +type PublicEmptyUpdate struct { + +} + +type PublicEventsSelect struct { + CreatedAt string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id int64 `json:"id"` +} + +type PublicEventsInsert struct { + CreatedAt *string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id *int64 `json:"id"` +} + +type PublicEventsUpdate struct { + CreatedAt *string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id *int64 `json:"id"` +} + +type PublicEvents2024Select struct { + CreatedAt string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id int64 `json:"id"` +} + +type PublicEvents2024Insert struct { + CreatedAt *string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id int64 `json:"id"` +} + +type PublicEvents2024Update struct { + CreatedAt *string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id *int64 `json:"id"` +} + +type PublicEvents2025Select struct { + CreatedAt string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id int64 `json:"id"` +} + +type PublicEvents2025Insert struct { + CreatedAt *string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id int64 `json:"id"` +} + +type PublicEvents2025Update struct { + CreatedAt *string `json:"created_at"` + Data interface{} `json:"data"` + EventType *string `json:"event_type"` + Id *int64 `json:"id"` +} + +type PublicIntervalTestSelect struct { + DurationOptional *string `json:"duration_optional"` + DurationRequired string `json:"duration_required"` + Id int64 `json:"id"` +} + +type PublicIntervalTestInsert struct { + DurationOptional *string `json:"duration_optional"` + DurationRequired string `json:"duration_required"` + Id *int64 `json:"id"` +} + +type PublicIntervalTestUpdate struct { + DurationOptional *string `json:"duration_optional"` + DurationRequired *string `json:"duration_required"` + Id *int64 `json:"id"` +} + +type PublicMemesSelect struct { + Category *int32 `json:"category"` + CreatedAt string `json:"created_at"` + Id int32 `json:"id"` + Metadata interface{} `json:"metadata"` + Name string `json:"name"` + Status *string `json:"status"` +} + +type PublicMemesInsert struct { + Category *int32 `json:"category"` + CreatedAt string `json:"created_at"` + Id *int32 `json:"id"` + Metadata interface{} `json:"metadata"` + Name string `json:"name"` + Status *string `json:"status"` +} + +type PublicMemesUpdate struct { + Category *int32 `json:"category"` + CreatedAt *string `json:"created_at"` + Id *int32 `json:"id"` + Metadata interface{} `json:"metadata"` + Name *string `json:"name"` + Status *string `json:"status"` +} + +type PublicTableWithOtherTablesRowTypeSelect struct { + Col1 interface{} `json:"col1"` + Col2 interface{} `json:"col2"` +} + +type PublicTableWithOtherTablesRowTypeInsert struct { + Col1 interface{} `json:"col1"` + Col2 interface{} `json:"col2"` +} + +type PublicTableWithOtherTablesRowTypeUpdate struct { + Col1 interface{} `json:"col1"` + Col2 interface{} `json:"col2"` +} + +type PublicTableWithPrimaryKeyOtherThanIdSelect struct { + Name *string `json:"name"` + OtherId int64 `json:"other_id"` +} + +type PublicTableWithPrimaryKeyOtherThanIdInsert struct { + Name *string `json:"name"` + OtherId *int64 `json:"other_id"` +} + +type PublicTableWithPrimaryKeyOtherThanIdUpdate struct { + Name *string `json:"name"` + OtherId *int64 `json:"other_id"` +} + +type PublicTodosSelect struct { + Details *string `json:"details"` + Id int64 `json:"id"` + UserId int64 `json:"user-id"` +} + +type PublicTodosInsert struct { + Details *string `json:"details"` + Id *int64 `json:"id"` + UserId int64 `json:"user-id"` +} + +type PublicTodosUpdate struct { + Details *string `json:"details"` + Id *int64 `json:"id"` + UserId *int64 `json:"user-id"` +} + +type PublicUserDetailsSelect struct { + Details *string `json:"details"` + UserId int64 `json:"user_id"` +} + +type PublicUserDetailsInsert struct { + Details *string `json:"details"` + UserId int64 `json:"user_id"` +} + +type PublicUserDetailsUpdate struct { + Details *string `json:"details"` + UserId *int64 `json:"user_id"` +} + +type PublicUsersSelect struct { + Decimal *float64 `json:"decimal"` + Id int64 `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + UserUuid *string `json:"user_uuid"` +} + +type PublicUsersInsert struct { + Decimal *float64 `json:"decimal"` + Id *int64 `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + UserUuid *string `json:"user_uuid"` +} + +type PublicUsersUpdate struct { + Decimal *float64 `json:"decimal"` + Id *int64 `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + UserUuid *string `json:"user_uuid"` +} + +type PublicUsersAuditSelect struct { + CreatedAt *string `json:"created_at"` + Id int64 `json:"id"` + PreviousValue interface{} `json:"previous_value"` + UserId *int64 `json:"user_id"` +} + +type PublicUsersAuditInsert struct { + CreatedAt *string `json:"created_at"` + Id *int64 `json:"id"` + PreviousValue interface{} `json:"previous_value"` + UserId *int64 `json:"user_id"` +} + +type PublicUsersAuditUpdate struct { + CreatedAt *string `json:"created_at"` + Id *int64 `json:"id"` + PreviousValue interface{} `json:"previous_value"` + UserId *int64 `json:"user_id"` +} + +type PublicAViewSelect struct { + Id *int64 `json:"id"` +} + +type PublicTodosViewSelect struct { + Details *string `json:"details"` + Id *int64 `json:"id"` + UserId *int64 `json:"user-id"` +} + +type PublicUserTodosSummaryViewSelect struct { + TodoCount *int64 `json:"todo_count"` + TodoDetails []*string `json:"todo_details"` + UserId *int64 `json:"user_id"` + UserName *string `json:"user_name"` + UserStatus *string `json:"user_status"` +} + +type PublicUsersViewSelect struct { + Decimal *float64 `json:"decimal"` + Id *int64 `json:"id"` + Name *string `json:"name"` + Status *string `json:"status"` + UserUuid *string `json:"user_uuid"` +} + +type PublicUsersViewWithMultipleRefsToUsersSelect struct { + InitialId *int64 `json:"initial_id"` + InitialName *string `json:"initial_name"` + SecondId *int64 `json:"second_id"` + SecondName *string `json:"second_name"` +} + +type PublicTodosMatviewSelect struct { + Details *string `json:"details"` + Id *int64 `json:"id"` + UserId *int64 `json:"user-id"` +} + +type PublicCompositeTypeWithArrayAttribute struct { + MyTextArray interface{} `json:"my_text_array"` +} + +type PublicCompositeTypeWithRecordAttribute struct { + Todo interface{} `json:"todo"` +} diff --git a/packages/postgrest-typegen/test/parity/expected/python.txt b/packages/postgrest-typegen/test/parity/expected/python.txt new file mode 100644 index 000000000..716adeb09 --- /dev/null +++ b/packages/postgrest-typegen/test/parity/expected/python.txt @@ -0,0 +1,263 @@ +from __future__ import annotations + +import datetime +import uuid +from typing import ( + Annotated, + Any, + List, + Literal, + NotRequired, + Optional, + TypeAlias, + TypedDict, +) + +from pydantic import BaseModel, Field, Json + +PublicMemeStatus: TypeAlias = Literal["new", "old", "retired"] + +PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] + +class PublicCategory(BaseModel): + id: int = Field(alias="id") + name: str = Field(alias="name") + +class PublicCategoryInsert(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + name: Annotated[str, Field(alias="name")] + +class PublicCategoryUpdate(TypedDict): + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + +class PublicEmpty(BaseModel): + pass + +class PublicEmptyInsert(TypedDict): + pass + +class PublicEmptyUpdate(TypedDict): + pass + +class PublicEvents(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEventsInsert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEventsUpdate(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEvents2024(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEvents2024Insert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] + id: Annotated[int, Field(alias="id")] + +class PublicEvents2024Update(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicEvents2025(BaseModel): + created_at: datetime.datetime = Field(alias="created_at") + data: Optional[Json[Any]] = Field(alias="data") + event_type: Optional[str] = Field(alias="event_type") + id: int = Field(alias="id") + +class PublicEvents2025Insert(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] + id: Annotated[int, Field(alias="id")] + +class PublicEvents2025Update(TypedDict): + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + data: NotRequired[Annotated[Optional[Json[Any]], Field(alias="data")]] + event_type: NotRequired[Annotated[Optional[str], Field(alias="event_type")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicIntervalTest(BaseModel): + duration_optional: Optional[str] = Field(alias="duration_optional") + duration_required: str = Field(alias="duration_required") + id: int = Field(alias="id") + +class PublicIntervalTestInsert(TypedDict): + duration_optional: NotRequired[Annotated[Optional[str], Field(alias="duration_optional")]] + duration_required: Annotated[str, Field(alias="duration_required")] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicIntervalTestUpdate(TypedDict): + duration_optional: NotRequired[Annotated[Optional[str], Field(alias="duration_optional")]] + duration_required: NotRequired[Annotated[str, Field(alias="duration_required")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + +class PublicMemes(BaseModel): + category: Optional[int] = Field(alias="category") + created_at: datetime.datetime = Field(alias="created_at") + id: int = Field(alias="id") + metadata: Optional[Json[Any]] = Field(alias="metadata") + name: str = Field(alias="name") + status: Optional[PublicMemeStatus] = Field(alias="status") + +class PublicMemesInsert(TypedDict): + category: NotRequired[Annotated[Optional[int], Field(alias="category")]] + created_at: Annotated[datetime.datetime, Field(alias="created_at")] + id: NotRequired[Annotated[int, Field(alias="id")]] + metadata: NotRequired[Annotated[Optional[Json[Any]], Field(alias="metadata")]] + name: Annotated[str, Field(alias="name")] + status: NotRequired[Annotated[Optional[PublicMemeStatus], Field(alias="status")]] + +class PublicMemesUpdate(TypedDict): + category: NotRequired[Annotated[Optional[int], Field(alias="category")]] + created_at: NotRequired[Annotated[datetime.datetime, Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + metadata: NotRequired[Annotated[Optional[Json[Any]], Field(alias="metadata")]] + name: NotRequired[Annotated[str, Field(alias="name")]] + status: NotRequired[Annotated[Optional[PublicMemeStatus], Field(alias="status")]] + +class PublicTableWithOtherTablesRowType(BaseModel): + col1: Optional[PublicUserDetails] = Field(alias="col1") + col2: Optional[PublicAView] = Field(alias="col2") + +class PublicTableWithOtherTablesRowTypeInsert(TypedDict): + col1: NotRequired[Annotated[Optional[PublicUserDetails], Field(alias="col1")]] + col2: NotRequired[Annotated[Optional[PublicAView], Field(alias="col2")]] + +class PublicTableWithOtherTablesRowTypeUpdate(TypedDict): + col1: NotRequired[Annotated[Optional[PublicUserDetails], Field(alias="col1")]] + col2: NotRequired[Annotated[Optional[PublicAView], Field(alias="col2")]] + +class PublicTableWithPrimaryKeyOtherThanId(BaseModel): + name: Optional[str] = Field(alias="name") + other_id: int = Field(alias="other_id") + +class PublicTableWithPrimaryKeyOtherThanIdInsert(TypedDict): + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + +class PublicTableWithPrimaryKeyOtherThanIdUpdate(TypedDict): + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + +class PublicTodos(BaseModel): + details: Optional[str] = Field(alias="details") + id: int = Field(alias="id") + user_id: int = Field(alias="user-id") + +class PublicTodosInsert(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: Annotated[int, Field(alias="user-id")] + +class PublicTodosUpdate(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: NotRequired[Annotated[int, Field(alias="user-id")]] + +class PublicUserDetails(BaseModel): + details: Optional[str] = Field(alias="details") + user_id: int = Field(alias="user_id") + +class PublicUserDetailsInsert(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + user_id: Annotated[int, Field(alias="user_id")] + +class PublicUserDetailsUpdate(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + +class PublicUsers(BaseModel): + decimal: Optional[float] = Field(alias="decimal") + id: int = Field(alias="id") + name: Optional[str] = Field(alias="name") + status: Optional[PublicUserStatus] = Field(alias="status") + user_uuid: Optional[uuid.UUID] = Field(alias="user_uuid") + +class PublicUsersInsert(TypedDict): + decimal: NotRequired[Annotated[Optional[float], Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] + user_uuid: NotRequired[Annotated[Optional[uuid.UUID], Field(alias="user_uuid")]] + +class PublicUsersUpdate(TypedDict): + decimal: NotRequired[Annotated[Optional[float], Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] + user_uuid: NotRequired[Annotated[Optional[uuid.UUID], Field(alias="user_uuid")]] + +class PublicUsersAudit(BaseModel): + created_at: Optional[datetime.datetime] = Field(alias="created_at") + id: int = Field(alias="id") + previous_value: Optional[Json[Any]] = Field(alias="previous_value") + user_id: Optional[int] = Field(alias="user_id") + +class PublicUsersAuditInsert(TypedDict): + created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] + +class PublicUsersAuditUpdate(TypedDict): + created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] + +class PublicAView(BaseModel): + id: Optional[int] = Field(alias="id") + +class PublicTodosView(BaseModel): + details: Optional[str] = Field(alias="details") + id: Optional[int] = Field(alias="id") + user_id: Optional[int] = Field(alias="user-id") + +class PublicUserTodosSummaryView(BaseModel): + todo_count: Optional[int] = Field(alias="todo_count") + todo_details: Optional[List[str]] = Field(alias="todo_details") + user_id: Optional[int] = Field(alias="user_id") + user_name: Optional[str] = Field(alias="user_name") + user_status: Optional[PublicUserStatus] = Field(alias="user_status") + +class PublicUsersView(BaseModel): + decimal: Optional[float] = Field(alias="decimal") + id: Optional[int] = Field(alias="id") + name: Optional[str] = Field(alias="name") + status: Optional[PublicUserStatus] = Field(alias="status") + user_uuid: Optional[uuid.UUID] = Field(alias="user_uuid") + +class PublicUsersViewWithMultipleRefsToUsers(BaseModel): + initial_id: Optional[int] = Field(alias="initial_id") + initial_name: Optional[str] = Field(alias="initial_name") + second_id: Optional[int] = Field(alias="second_id") + second_name: Optional[str] = Field(alias="second_name") + +class PublicTodosMatview(BaseModel): + details: Optional[str] = Field(alias="details") + id: Optional[int] = Field(alias="id") + user_id: Optional[int] = Field(alias="user-id") + +class PublicCompositeTypeWithArrayAttribute(BaseModel): + my_text_array: List[str] = Field(alias="my_text_array") + +class PublicCompositeTypeWithRecordAttribute(BaseModel): + todo: PublicTodos = Field(alias="todo") diff --git a/packages/postgrest-typegen/test/parity/expected/swift.txt b/packages/postgrest-typegen/test/parity/expected/swift.txt new file mode 100644 index 000000000..84d283ad7 --- /dev/null +++ b/packages/postgrest-typegen/test/parity/expected/swift.txt @@ -0,0 +1,521 @@ +import Foundation +import Supabase + +internal enum PublicSchema { + internal enum MemeStatus: String, Codable, Hashable, Sendable { + case new = "new" + case old = "old" + case retired = "retired" + } + internal enum UserStatus: String, Codable, Hashable, Sendable { + case active = "ACTIVE" + case inactive = "INACTIVE" + } + internal struct CategorySelect: Codable, Hashable, Sendable { + internal let id: Int32 + internal let name: String + internal enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + } + } + internal struct CategoryInsert: Codable, Hashable, Sendable { + internal let id: Int32? + internal let name: String + internal enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + } + } + internal struct CategoryUpdate: Codable, Hashable, Sendable { + internal let id: Int32? + internal let name: String? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + } + } + internal struct EmptySelect: Codable, Hashable, Sendable { + } + internal struct EmptyInsert: Codable, Hashable, Sendable { + } + internal struct EmptyUpdate: Codable, Hashable, Sendable { + } + internal struct EventsSelect: Codable, Hashable, Sendable, Identifiable { + internal let createdAt: String + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64 + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct EventsInsert: Codable, Hashable, Sendable, Identifiable { + internal let createdAt: String? + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64? + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct EventsUpdate: Codable, Hashable, Sendable, Identifiable { + internal let createdAt: String? + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64? + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct Events2024Select: Codable, Hashable, Sendable { + internal let createdAt: String + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64 + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct Events2024Insert: Codable, Hashable, Sendable { + internal let createdAt: String? + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64 + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct Events2024Update: Codable, Hashable, Sendable { + internal let createdAt: String? + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64? + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct Events2025Select: Codable, Hashable, Sendable { + internal let createdAt: String + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64 + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct Events2025Insert: Codable, Hashable, Sendable { + internal let createdAt: String? + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64 + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct Events2025Update: Codable, Hashable, Sendable { + internal let createdAt: String? + internal let data: AnyJSON? + internal let eventType: String? + internal let id: Int64? + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case data = "data" + case eventType = "event_type" + case id = "id" + } + } + internal struct ForeignTableSelect: Codable, Hashable, Sendable { + internal let id: Int64 + internal let name: String? + internal let status: UserStatus? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + case status = "status" + } + } + internal struct ForeignTableInsert: Codable, Hashable, Sendable { + internal let id: Int64 + internal let name: String? + internal let status: UserStatus? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + case status = "status" + } + } + internal struct ForeignTableUpdate: Codable, Hashable, Sendable { + internal let id: Int64? + internal let name: String? + internal let status: UserStatus? + internal enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + case status = "status" + } + } + internal struct IntervalTestSelect: Codable, Hashable, Sendable, Identifiable { + internal let durationOptional: String? + internal let durationRequired: String + internal let id: Int64 + internal enum CodingKeys: String, CodingKey { + case durationOptional = "duration_optional" + case durationRequired = "duration_required" + case id = "id" + } + } + internal struct IntervalTestInsert: Codable, Hashable, Sendable, Identifiable { + internal let durationOptional: String? + internal let durationRequired: String + internal let id: Int64? + internal enum CodingKeys: String, CodingKey { + case durationOptional = "duration_optional" + case durationRequired = "duration_required" + case id = "id" + } + } + internal struct IntervalTestUpdate: Codable, Hashable, Sendable, Identifiable { + internal let durationOptional: String? + internal let durationRequired: String? + internal let id: Int64? + internal enum CodingKeys: String, CodingKey { + case durationOptional = "duration_optional" + case durationRequired = "duration_required" + case id = "id" + } + } + internal struct MemesSelect: Codable, Hashable, Sendable { + internal let category: Int32? + internal let createdAt: String + internal let id: Int32 + internal let metadata: AnyJSON? + internal let name: String + internal let status: MemeStatus? + internal enum CodingKeys: String, CodingKey { + case category = "category" + case createdAt = "created_at" + case id = "id" + case metadata = "metadata" + case name = "name" + case status = "status" + } + } + internal struct MemesInsert: Codable, Hashable, Sendable { + internal let category: Int32? + internal let createdAt: String + internal let id: Int32? + internal let metadata: AnyJSON? + internal let name: String + internal let status: MemeStatus? + internal enum CodingKeys: String, CodingKey { + case category = "category" + case createdAt = "created_at" + case id = "id" + case metadata = "metadata" + case name = "name" + case status = "status" + } + } + internal struct MemesUpdate: Codable, Hashable, Sendable { + internal let category: Int32? + internal let createdAt: String? + internal let id: Int32? + internal let metadata: AnyJSON? + internal let name: String? + internal let status: MemeStatus? + internal enum CodingKeys: String, CodingKey { + case category = "category" + case createdAt = "created_at" + case id = "id" + case metadata = "metadata" + case name = "name" + case status = "status" + } + } + internal struct TableWithOtherTablesRowTypeSelect: Codable, Hashable, Sendable { + internal let col1: UserDetailsSelect? + internal let col2: AViewSelect? + internal enum CodingKeys: String, CodingKey { + case col1 = "col1" + case col2 = "col2" + } + } + internal struct TableWithOtherTablesRowTypeInsert: Codable, Hashable, Sendable { + internal let col1: UserDetailsSelect? + internal let col2: AViewSelect? + internal enum CodingKeys: String, CodingKey { + case col1 = "col1" + case col2 = "col2" + } + } + internal struct TableWithOtherTablesRowTypeUpdate: Codable, Hashable, Sendable { + internal let col1: UserDetailsSelect? + internal let col2: AViewSelect? + internal enum CodingKeys: String, CodingKey { + case col1 = "col1" + case col2 = "col2" + } + } + internal struct TableWithPrimaryKeyOtherThanIdSelect: Codable, Hashable, Sendable, Identifiable { + internal var id: Int64 { otherId } + internal let name: String? + internal let otherId: Int64 + internal enum CodingKeys: String, CodingKey { + case name = "name" + case otherId = "other_id" + } + } + internal struct TableWithPrimaryKeyOtherThanIdInsert: Codable, Hashable, Sendable, Identifiable { + internal var id: Int64? { otherId } + internal let name: String? + internal let otherId: Int64? + internal enum CodingKeys: String, CodingKey { + case name = "name" + case otherId = "other_id" + } + } + internal struct TableWithPrimaryKeyOtherThanIdUpdate: Codable, Hashable, Sendable, Identifiable { + internal var id: Int64? { otherId } + internal let name: String? + internal let otherId: Int64? + internal enum CodingKeys: String, CodingKey { + case name = "name" + case otherId = "other_id" + } + } + internal struct TodosSelect: Codable, Hashable, Sendable, Identifiable { + internal let details: String? + internal let id: Int64 + internal let userId: Int64 + internal enum CodingKeys: String, CodingKey { + case details = "details" + case id = "id" + case userId = "user-id" + } + } + internal struct TodosInsert: Codable, Hashable, Sendable, Identifiable { + internal let details: String? + internal let id: Int64? + internal let userId: Int64 + internal enum CodingKeys: String, CodingKey { + case details = "details" + case id = "id" + case userId = "user-id" + } + } + internal struct TodosUpdate: Codable, Hashable, Sendable, Identifiable { + internal let details: String? + internal let id: Int64? + internal let userId: Int64? + internal enum CodingKeys: String, CodingKey { + case details = "details" + case id = "id" + case userId = "user-id" + } + } + internal struct UserDetailsSelect: Codable, Hashable, Sendable { + internal let details: String? + internal let userId: Int64 + internal enum CodingKeys: String, CodingKey { + case details = "details" + case userId = "user_id" + } + } + internal struct UserDetailsInsert: Codable, Hashable, Sendable { + internal let details: String? + internal let userId: Int64 + internal enum CodingKeys: String, CodingKey { + case details = "details" + case userId = "user_id" + } + } + internal struct UserDetailsUpdate: Codable, Hashable, Sendable { + internal let details: String? + internal let userId: Int64? + internal enum CodingKeys: String, CodingKey { + case details = "details" + case userId = "user_id" + } + } + internal struct UsersSelect: Codable, Hashable, Sendable, Identifiable { + internal let decimal: Decimal? + internal let id: Int64 + internal let name: String? + internal let status: UserStatus? + internal let userUuid: UUID? + internal enum CodingKeys: String, CodingKey { + case decimal = "decimal" + case id = "id" + case name = "name" + case status = "status" + case userUuid = "user_uuid" + } + } + internal struct UsersInsert: Codable, Hashable, Sendable, Identifiable { + internal let decimal: Decimal? + internal let id: Int64? + internal let name: String? + internal let status: UserStatus? + internal let userUuid: UUID? + internal enum CodingKeys: String, CodingKey { + case decimal = "decimal" + case id = "id" + case name = "name" + case status = "status" + case userUuid = "user_uuid" + } + } + internal struct UsersUpdate: Codable, Hashable, Sendable, Identifiable { + internal let decimal: Decimal? + internal let id: Int64? + internal let name: String? + internal let status: UserStatus? + internal let userUuid: UUID? + internal enum CodingKeys: String, CodingKey { + case decimal = "decimal" + case id = "id" + case name = "name" + case status = "status" + case userUuid = "user_uuid" + } + } + internal struct UsersAuditSelect: Codable, Hashable, Sendable, Identifiable { + internal let createdAt: String? + internal let id: Int64 + internal let previousValue: AnyJSON? + internal let userId: Int64? + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case id = "id" + case previousValue = "previous_value" + case userId = "user_id" + } + } + internal struct UsersAuditInsert: Codable, Hashable, Sendable, Identifiable { + internal let createdAt: String? + internal let id: Int64? + internal let previousValue: AnyJSON? + internal let userId: Int64? + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case id = "id" + case previousValue = "previous_value" + case userId = "user_id" + } + } + internal struct UsersAuditUpdate: Codable, Hashable, Sendable, Identifiable { + internal let createdAt: String? + internal let id: Int64? + internal let previousValue: AnyJSON? + internal let userId: Int64? + internal enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case id = "id" + case previousValue = "previous_value" + case userId = "user_id" + } + } + internal struct AViewSelect: Codable, Hashable, Sendable { + internal let id: Int64? + internal enum CodingKeys: String, CodingKey { + case id = "id" + } + } + internal struct TodosMatviewSelect: Codable, Hashable, Sendable { + internal let details: String? + internal let id: Int64? + internal let userId: Int64? + internal enum CodingKeys: String, CodingKey { + case details = "details" + case id = "id" + case userId = "user-id" + } + } + internal struct TodosViewSelect: Codable, Hashable, Sendable { + internal let details: String? + internal let id: Int64? + internal let userId: Int64? + internal enum CodingKeys: String, CodingKey { + case details = "details" + case id = "id" + case userId = "user-id" + } + } + internal struct UserTodosSummaryViewSelect: Codable, Hashable, Sendable { + internal let todoCount: Int64? + internal let todoDetails: [String]? + internal let userId: Int64? + internal let userName: String? + internal let userStatus: UserStatus? + internal enum CodingKeys: String, CodingKey { + case todoCount = "todo_count" + case todoDetails = "todo_details" + case userId = "user_id" + case userName = "user_name" + case userStatus = "user_status" + } + } + internal struct UsersViewSelect: Codable, Hashable, Sendable { + internal let decimal: Decimal? + internal let id: Int64? + internal let name: String? + internal let status: UserStatus? + internal let userUuid: UUID? + internal enum CodingKeys: String, CodingKey { + case decimal = "decimal" + case id = "id" + case name = "name" + case status = "status" + case userUuid = "user_uuid" + } + } + internal struct UsersViewWithMultipleRefsToUsersSelect: Codable, Hashable, Sendable { + internal let initialId: Int64? + internal let initialName: String? + internal let secondId: Int64? + internal let secondName: String? + internal enum CodingKeys: String, CodingKey { + case initialId = "initial_id" + case initialName = "initial_name" + case secondId = "second_id" + case secondName = "second_name" + } + } + internal struct CompositeTypeWithArrayAttribute: Codable, Hashable, Sendable { + internal let MyTextArray: AnyJSON + internal enum CodingKeys: String, CodingKey { + case MyTextArray = "my_text_array" + } + } + internal struct CompositeTypeWithRecordAttribute: Codable, Hashable, Sendable { + internal let Todo: TodosSelect + internal enum CodingKeys: String, CodingKey { + case Todo = "todo" + } + } +} diff --git a/packages/postgrest-typegen/test/parity/expected/typescript.txt b/packages/postgrest-typegen/test/parity/expected/typescript.txt new file mode 100644 index 000000000..00170a8f5 --- /dev/null +++ b/packages/postgrest-typegen/test/parity/expected/typescript.txt @@ -0,0 +1,1187 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + public: { + Tables: { + category: { + Row: { + id: number + name: string + } + Insert: { + id?: number + name: string + } + Update: { + id?: number + name?: string + } + Relationships: [] + } + empty: { + Row: {} + Insert: {} + Update: {} + Relationships: [] + } + events: { + Row: { + created_at: string + data: Json | null + event_type: string | null + id: number + days_since_event: number | null + } + Insert: { + created_at?: string + data?: Json | null + event_type?: string | null + id?: number + } + Update: { + created_at?: string + data?: Json | null + event_type?: string | null + id?: number + } + Relationships: [] + } + events_2024: { + Row: { + created_at: string + data: Json | null + event_type: string | null + id: number + } + Insert: { + created_at?: string + data?: Json | null + event_type?: string | null + id: number + } + Update: { + created_at?: string + data?: Json | null + event_type?: string | null + id?: number + } + Relationships: [] + } + events_2025: { + Row: { + created_at: string + data: Json | null + event_type: string | null + id: number + } + Insert: { + created_at?: string + data?: Json | null + event_type?: string | null + id: number + } + Update: { + created_at?: string + data?: Json | null + event_type?: string | null + id?: number + } + Relationships: [] + } + foreign_table: { + Row: { + id: number + name: string | null + status: Database["public"]["Enums"]["user_status"] | null + } + Insert: { + id: number + name?: string | null + status?: Database["public"]["Enums"]["user_status"] | null + } + Update: { + id?: number + name?: string | null + status?: Database["public"]["Enums"]["user_status"] | null + } + Relationships: [] + } + interval_test: { + Row: { + duration_optional: string | null + duration_required: string + id: number + } + Insert: { + duration_optional?: string | null + duration_required: string + id?: number + } + Update: { + duration_optional?: string | null + duration_required?: string + id?: number + } + Relationships: [] + } + memes: { + Row: { + category: number | null + created_at: string + id: number + metadata: Json | null + name: string + status: Database["public"]["Enums"]["meme_status"] | null + } + Insert: { + category?: number | null + created_at: string + id?: number + metadata?: Json | null + name: string + status?: Database["public"]["Enums"]["meme_status"] | null + } + Update: { + category?: number | null + created_at?: string + id?: number + metadata?: Json | null + name?: string + status?: Database["public"]["Enums"]["meme_status"] | null + } + Relationships: [ + { + foreignKeyName: "memes_category_fkey" + columns: ["category"] + referencedRelation: "category" + referencedColumns: ["id"] + }, + ] + } + table_with_other_tables_row_type: { + Row: { + col1: Database["public"]["Tables"]["user_details"]["Row"] | null + col2: Database["public"]["Views"]["a_view"]["Row"] | null + } + Insert: { + col1?: Database["public"]["Tables"]["user_details"]["Row"] | null + col2?: Database["public"]["Views"]["a_view"]["Row"] | null + } + Update: { + col1?: Database["public"]["Tables"]["user_details"]["Row"] | null + col2?: Database["public"]["Views"]["a_view"]["Row"] | null + } + Relationships: [] + } + table_with_primary_key_other_than_id: { + Row: { + name: string | null + other_id: number + } + Insert: { + name?: string | null + other_id?: number + } + Update: { + name?: string | null + other_id?: number + } + Relationships: [] + } + todos: { + Row: { + details: string | null + id: number + "user-id": number + blurb: string | null + blurb_varchar: string | null + details_is_long: boolean | null + details_length: number | null + details_words: string[] | null + test_unnamed_row_scalar: number | null + test_unnamed_row_setof: { + details: string | null + id: number + "user-id": number + } | null + } + Insert: { + details?: string | null + id?: number + "user-id": number + } + Update: { + details?: string | null + id?: number + "user-id"?: number + } + Relationships: [ + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "a_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "user_todos_summary_view" + referencedColumns: ["user_id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["initial_id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["second_id"] + }, + ] + } + user_details: { + Row: { + details: string | null + user_id: number + } + Insert: { + details?: string | null + user_id: number + } + Update: { + details?: string | null + user_id?: number + } + Relationships: [ + { + foreignKeyName: "user_details_user_id_fkey" + columns: ["user_id"] + referencedRelation: "a_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "user_details_user_id_fkey" + columns: ["user_id"] + referencedRelation: "user_todos_summary_view" + referencedColumns: ["user_id"] + }, + { + foreignKeyName: "user_details_user_id_fkey" + columns: ["user_id"] + referencedRelation: "users" + referencedColumns: ["id"] + }, + { + foreignKeyName: "user_details_user_id_fkey" + columns: ["user_id"] + referencedRelation: "users_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "user_details_user_id_fkey" + columns: ["user_id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["initial_id"] + }, + { + foreignKeyName: "user_details_user_id_fkey" + columns: ["user_id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["second_id"] + }, + ] + } + users: { + Row: { + decimal: number | null + id: number + name: string | null + status: Database["public"]["Enums"]["user_status"] | null + user_uuid: string | null + test_unnamed_row_composite: + | Database["public"]["CompositeTypes"]["composite_type_with_array_attribute"] + | null + test_unnamed_row_setof: { + details: string | null + id: number + "user-id": number + } | null + } + Insert: { + decimal?: number | null + id?: number + name?: string | null + status?: Database["public"]["Enums"]["user_status"] | null + user_uuid?: string | null + } + Update: { + decimal?: number | null + id?: number + name?: string | null + status?: Database["public"]["Enums"]["user_status"] | null + user_uuid?: string | null + } + Relationships: [] + } + users_audit: { + Row: { + created_at: string | null + id: number + previous_value: Json | null + user_id: number | null + created_ago: number | null + } + Insert: { + created_at?: string | null + id?: number + previous_value?: Json | null + user_id?: number | null + } + Update: { + created_at?: string | null + id?: number + previous_value?: Json | null + user_id?: number | null + } + Relationships: [] + } + } + Views: { + a_view: { + Row: { + id: number | null + } + Insert: { + id?: number | null + } + Update: { + id?: number | null + } + Relationships: [] + } + todos_matview: { + Row: { + details: string | null + id: number | null + "user-id": number | null + get_todos_by_matview: { + details: string | null + id: number + "user-id": number + } | null + } + Relationships: [ + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "a_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "user_todos_summary_view" + referencedColumns: ["user_id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["initial_id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["second_id"] + }, + ] + } + todos_view: { + Row: { + details: string | null + id: number | null + "user-id": number | null + blurb_varchar: string | null + test_unnamed_view_row: { + details: string | null + id: number + "user-id": number + } | null + } + Insert: { + details?: string | null + id?: number | null + "user-id"?: number | null + } + Update: { + details?: string | null + id?: number | null + "user-id"?: number | null + } + Relationships: [ + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "a_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "user_todos_summary_view" + referencedColumns: ["user_id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view" + referencedColumns: ["id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["initial_id"] + }, + { + foreignKeyName: "todos_user-id_fkey" + columns: ["user-id"] + referencedRelation: "users_view_with_multiple_refs_to_users" + referencedColumns: ["second_id"] + }, + ] + } + user_todos_summary_view: { + Row: { + todo_count: number | null + todo_details: string[] | null + user_id: number | null + user_name: string | null + user_status: Database["public"]["Enums"]["user_status"] | null + } + Relationships: [] + } + users_view: { + Row: { + decimal: number | null + id: number | null + name: string | null + status: Database["public"]["Enums"]["user_status"] | null + user_uuid: string | null + } + Insert: { + decimal?: number | null + id?: number | null + name?: string | null + status?: Database["public"]["Enums"]["user_status"] | null + user_uuid?: string | null + } + Update: { + decimal?: number | null + id?: number | null + name?: string | null + status?: Database["public"]["Enums"]["user_status"] | null + user_uuid?: string | null + } + Relationships: [] + } + users_view_with_multiple_refs_to_users: { + Row: { + initial_id: number | null + initial_name: string | null + second_id: number | null + second_name: string | null + } + Relationships: [] + } + } + Functions: { + add_interval_to_duration: { + Args: { additional_interval: string; base_duration: string } + Returns: string + } + blurb: { + Args: { "": Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + error: true + } & "the function public.blurb with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + blurb_varchar: + | { + Args: { "": Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + error: true + } & "the function public.blurb_varchar with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + | { + Args: { "": Database["public"]["Views"]["todos_view"]["Row"] } + Returns: { + error: true + } & "the function public.blurb_varchar with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + created_ago: { + Args: { "": Database["public"]["Tables"]["users_audit"]["Row"] } + Returns: { + error: true + } & "the function public.created_ago with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + days_since_event: { + Args: { "": Database["public"]["Tables"]["events"]["Row"] } + Returns: { + error: true + } & "the function public.days_since_event with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + details_is_long: { + Args: { "": Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + error: true + } & "the function public.details_is_long with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + details_length: { + Args: { "": Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + error: true + } & "the function public.details_length with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + details_words: { + Args: { "": Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + error: true + } & "the function public.details_words with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + double_duration: { + Args: { + interval_test_row: Database["public"]["Tables"]["interval_test"]["Row"] + } + Returns: string + } + function_returning_row: { + Args: never + Returns: { + decimal: number | null + id: number + name: string | null + status: Database["public"]["Enums"]["user_status"] | null + user_uuid: string | null + } + SetofOptions: { + from: "*" + to: "users" + isOneToOne: true + isSetofReturn: false + } + } + function_returning_set_of_rows: { + Args: never + Returns: { + decimal: number | null + id: number + name: string | null + status: Database["public"]["Enums"]["user_status"] | null + user_uuid: string | null + }[] + SetofOptions: { + from: "*" + to: "users" + isOneToOne: false + isSetofReturn: true + } + } + function_returning_single_row: { + Args: { todos: Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + decimal: number | null + id: number + name: string | null + status: Database["public"]["Enums"]["user_status"] | null + user_uuid: string | null + } + SetofOptions: { + from: "todos" + to: "users" + isOneToOne: true + isSetofReturn: false + } + } + function_returning_table: { + Args: never + Returns: { + id: number + name: string + }[] + } + function_returning_table_with_args: { + Args: { user_id: number } + Returns: { + id: number + name: string + }[] + } + function_using_setof_rows_one: { + Args: { user_row: Database["public"]["Tables"]["users"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + } + SetofOptions: { + from: "users" + to: "todos" + isOneToOne: true + isSetofReturn: true + } + } + function_using_table_returns: { + Args: { user_row: Database["public"]["Tables"]["users"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + } + SetofOptions: { + from: "users" + to: "todos" + isOneToOne: true + isSetofReturn: false + } + } + get_composite_type_data: { + Args: never + Returns: Database["public"]["CompositeTypes"]["composite_type_with_array_attribute"][] + SetofOptions: { + from: "*" + to: "composite_type_with_array_attribute" + isOneToOne: false + isSetofReturn: true + } + } + get_single_user_summary_from_view: + | { + Args: { search_user_id: number } + Returns: { + todo_count: number | null + todo_details: string[] | null + user_id: number | null + user_name: string | null + user_status: Database["public"]["Enums"]["user_status"] | null + } + SetofOptions: { + from: "*" + to: "user_todos_summary_view" + isOneToOne: true + isSetofReturn: true + } + } + | { + Args: { user_row: Database["public"]["Tables"]["users"]["Row"] } + Returns: { + todo_count: number | null + todo_details: string[] | null + user_id: number | null + user_name: string | null + user_status: Database["public"]["Enums"]["user_status"] | null + } + SetofOptions: { + from: "users" + to: "user_todos_summary_view" + isOneToOne: true + isSetofReturn: true + } + } + | { + Args: { + userview_row: Database["public"]["Views"]["users_view"]["Row"] + } + Returns: { + todo_count: number | null + todo_details: string[] | null + user_id: number | null + user_name: string | null + user_status: Database["public"]["Enums"]["user_status"] | null + } + SetofOptions: { + from: "users_view" + to: "user_todos_summary_view" + isOneToOne: true + isSetofReturn: true + } + } + get_todos_by_matview: { + Args: { "": unknown } + Returns: { + details: string | null + id: number + "user-id": number + } + SetofOptions: { + from: "todos_matview" + to: "todos" + isOneToOne: true + isSetofReturn: true + } + } + get_todos_from_user: + | { + Args: { search_user_id: number } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "*" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + | { + Args: { user_row: Database["public"]["Tables"]["users"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "users" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + | { + Args: { + userview_row: Database["public"]["Views"]["users_view"]["Row"] + } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "users_view" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + get_todos_setof_rows: + | { + Args: { todo_row: Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "todos" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + | { + Args: { user_row: Database["public"]["Tables"]["users"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "users" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + get_user_audit_setof_single_row: { + Args: { user_row: Database["public"]["Tables"]["users"]["Row"] } + Returns: { + created_at: string | null + id: number + previous_value: Json | null + user_id: number | null + } + SetofOptions: { + from: "users" + to: "users_audit" + isOneToOne: true + isSetofReturn: true + } + } + get_user_ids: { Args: never; Returns: number[] } + get_user_summary: { Args: never; Returns: Record[] } + polymorphic_function: { Args: { "": string }; Returns: undefined } + polymorphic_function_with_different_return: { + Args: { "": string } + Returns: string + } + polymorphic_function_with_no_params_or_unnamed: + | { Args: never; Returns: number } + | { Args: { "": string }; Returns: string } + polymorphic_function_with_unnamed_default: + | { + Args: never + Returns: { + error: true + } & "Could not choose the best candidate function between: public.polymorphic_function_with_unnamed_default(), public.polymorphic_function_with_unnamed_default( => text). Try renaming the parameters or the function itself in the database so function overloading can be resolved" + } + | { Args: { ""?: string }; Returns: string } + polymorphic_function_with_unnamed_default_overload: + | { + Args: never + Returns: { + error: true + } & "Could not choose the best candidate function between: public.polymorphic_function_with_unnamed_default_overload(), public.polymorphic_function_with_unnamed_default_overload( => text). Try renaming the parameters or the function itself in the database so function overloading can be resolved" + } + | { Args: { ""?: string }; Returns: string } + polymorphic_function_with_unnamed_json: { + Args: { "": Json } + Returns: number + } + polymorphic_function_with_unnamed_jsonb: { + Args: { "": Json } + Returns: number + } + polymorphic_function_with_unnamed_text: { + Args: { "": string } + Returns: number + } + postgres_fdw_disconnect: { Args: { "": string }; Returns: boolean } + postgres_fdw_disconnect_all: { Args: never; Returns: boolean } + postgres_fdw_get_connections: { + Args: never + Returns: Record[] + } + postgres_fdw_handler: { Args: never; Returns: unknown } + postgrest_resolvable_with_override_function: + | { Args: never; Returns: undefined } + | { Args: { a: string }; Returns: number } + | { Args: { b: number }; Returns: string } + | { + Args: { completed: boolean; todo_id: number } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "*" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + | { + Args: { user_id: number } + Returns: { + decimal: number | null + id: number + name: string | null + status: Database["public"]["Enums"]["user_status"] | null + user_uuid: string | null + }[] + SetofOptions: { + from: "*" + to: "users" + isOneToOne: false + isSetofReturn: true + } + } + | { + Args: { user_row: Database["public"]["Tables"]["users"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "users" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + postgrest_unresolvable_function: + | { Args: never; Returns: undefined } + | { + Args: { a: number } + Returns: { + error: true + } & "Could not choose the best candidate function between: public.postgrest_unresolvable_function(a => int4), public.postgrest_unresolvable_function(a => text). Try renaming the parameters or the function itself in the database so function overloading can be resolved" + } + | { + Args: { a: string } + Returns: { + error: true + } & "Could not choose the best candidate function between: public.postgrest_unresolvable_function(a => int4), public.postgrest_unresolvable_function(a => text). Try renaming the parameters or the function itself in the database so function overloading can be resolved" + } + search_todos_by_details: { + Args: { search_details: string } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "*" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + test_unnamed_row_composite: { + Args: { "": Database["public"]["Tables"]["users"]["Row"] } + Returns: Database["public"]["CompositeTypes"]["composite_type_with_array_attribute"] + SetofOptions: { + from: "users" + to: "composite_type_with_array_attribute" + isOneToOne: true + isSetofReturn: false + } + } + test_unnamed_row_scalar: { + Args: { "": Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + error: true + } & "the function public.test_unnamed_row_scalar with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + test_unnamed_row_setof: + | { + Args: { "": Database["public"]["Tables"]["todos"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "todos" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + | { + Args: { user_id: number } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "*" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + | { + Args: { "": Database["public"]["Tables"]["users"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "users" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + test_unnamed_view_row: { + Args: { "": Database["public"]["Views"]["todos_view"]["Row"] } + Returns: { + details: string | null + id: number + "user-id": number + }[] + SetofOptions: { + from: "todos_view" + to: "todos" + isOneToOne: false + isSetofReturn: true + } + } + } + Enums: { + meme_status: "new" | "old" | "retired" + user_status: "ACTIVE" | "INACTIVE" + } + CompositeTypes: { + composite_type_with_array_attribute: { + my_text_array: string[] | null + } + composite_type_with_record_attribute: { + todo: Database["public"]["Tables"]["todos"]["Row"] | null + } + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: { + meme_status: ["new", "old", "retired"], + user_status: ["ACTIVE", "INACTIVE"], + }, + }, +} as const + diff --git a/packages/postgrest-typegen/test/parity/parity.test.ts b/packages/postgrest-typegen/test/parity/parity.test.ts new file mode 100644 index 000000000..f271b19f4 --- /dev/null +++ b/packages/postgrest-typegen/test/parity/parity.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + PostgreSqlContainer, + type StartedPostgreSqlContainer, +} from "@testcontainers/postgresql"; +import { Pool } from "pg"; +import { Wait } from "testcontainers"; + +import { + generateGo, + generatePython, + generateSwift, + generateTypescript, + sortGeneratorMetadata, +} from "../../src/generation/index.ts"; +import { introspect } from "../../src/introspection/index.ts"; +import type { GeneratorMetadata } from "../../src/types.ts"; + +/** + * End-to-end parity gate: introspect the shared postgres-meta fixture DB, run + * all four generators with their default options, and assert the output matches + * the committed golden files in `expected/`. + * + * The metadata is passed through `sortGeneratorMetadata` first (as every + * consumer must), so the golden files reflect the canonical, deterministic + * ordering rather than the database's heap order. Content is byte-identical to + * postgres-meta's CLI output; only the ordering of order-sensitive collections + * (Go/Python/Swift emit tables/views in metadata order) is canonicalized — and + * postgres-meta applies the same sort pass, so the two stay in lockstep. + * postgres-meta's CLI prints with `console.log`, which appends exactly one + * trailing newline to the generator's return value — hence the `+ "\n"` below. + * + * If a generator change is intended, regenerate the golden files from this + * package and review the diff. + */ +const FIXTURE_DIR = join(import.meta.dir, "..", "introspection", "fixtures"); +const EXPECTED_DIR = join(import.meta.dir, "expected"); +const golden = (name: string) => readFileSync(join(EXPECTED_DIR, name), "utf8"); + +let container: StartedPostgreSqlContainer; +let pool: Pool; +let metadata: GeneratorMetadata; + +beforeAll(async () => { + container = await new PostgreSqlContainer("postgres:15-alpine") + .withUsername("postgres") + .withPassword("postgres") + .withDatabase("postgres") + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(120_000) + .start(); + pool = new Pool({ connectionString: container.getConnectionUri() }); + await pool.query(readFileSync(join(FIXTURE_DIR, "00-init.sql"), "utf8")); + await pool.query(readFileSync(join(FIXTURE_DIR, "01-memes.sql"), "utf8")); + metadata = sortGeneratorMetadata(await introspect(pool)); +}, 180_000); + +afterAll(async () => { + await pool?.end(); + await container?.stop(); +}); + +describe("generator parity vs postgres-meta CLI", () => { + test("typescript", async () => { + expect((await generateTypescript(metadata)) + "\n").toBe( + golden("typescript.txt"), + ); + }); + + test("go", () => { + expect(generateGo(metadata) + "\n").toBe(golden("go.txt")); + }); + + test("python", () => { + expect(generatePython(metadata) + "\n").toBe(golden("python.txt")); + }); + + test("swift", () => { + expect(generateSwift(metadata) + "\n").toBe(golden("swift.txt")); + }); +}); diff --git a/packages/postgrest-typegen/test/smoke.test.ts b/packages/postgrest-typegen/test/smoke.test.ts new file mode 100644 index 000000000..2b6e74dc0 --- /dev/null +++ b/packages/postgrest-typegen/test/smoke.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; + +import * as pkg from "../src/index.ts"; +import { + generateGo, + generatePython, + generateSwift, + generateTypescript, +} from "../src/generation/index.ts"; +import { introspect } from "../src/introspection/index.ts"; + +/** + * Surface smoke test. It pins the public API shape so the subpath exports and + * barrel stay wired up. All four generators (PGMETA-106/107) and `introspect` + * (PGMETA-108/109/110) are implemented; the focused behavior lives in + * `test/generation/` and `test/introspection/`. + */ +describe("public API surface", () => { + test("barrel re-exports introspection and generation entry points", () => { + expect(typeof pkg.introspect).toBe("function"); + expect(typeof pkg.generateTypescript).toBe("function"); + expect(typeof pkg.generateGo).toBe("function"); + expect(typeof pkg.generatePython).toBe("function"); + expect(typeof pkg.generateSwift).toBe("function"); + }); + + test("introspect assembles an empty GeneratorMetadata from empty results", async () => { + const metadata = await introspect({ query: async () => ({ rows: [] }) }); + expect(Object.keys(metadata).sort()).toEqual([ + "columns", + "foreignTables", + "functions", + "materializedViews", + "relationships", + "schemas", + "tables", + "types", + "views", + ]); + expect(metadata.tables).toEqual([]); + expect(metadata.relationships).toEqual([]); + }); + + const emptyMetadata = { + schemas: [], + tables: [], + foreignTables: [], + views: [], + materializedViews: [], + columns: [], + relationships: [], + functions: [], + types: [], + }; + + test("implemented generators produce output for empty metadata", async () => { + expect(generateGo(emptyMetadata)).toContain("package database"); + expect(generatePython(emptyMetadata)).toContain( + "from pydantic import BaseModel", + ); + expect(generateSwift(emptyMetadata)).toContain("import Foundation"); + expect(await generateTypescript(emptyMetadata)).toContain( + "export type Database", + ); + }); +}); diff --git a/packages/postgrest-typegen/test/sort.test.ts b/packages/postgrest-typegen/test/sort.test.ts new file mode 100644 index 000000000..6e667a9e1 --- /dev/null +++ b/packages/postgrest-typegen/test/sort.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; + +import { sortGeneratorMetadata } from "../src/sort.ts"; +import type { PostgresType } from "../src/types.ts"; +import { + addressCompositeType, + baseColumn, + baseFunction, + baseRelationship, + baseTable, + baseView, + buildMetadata, + userStatusEnum, +} from "./generation/fixtures.ts"; + +describe("sortGeneratorMetadata", () => { + test("orders by semantic keys (schema/name), NOT by oid", () => { + // ids deliberately disagree with names so the assertions prove the sort is + // name-based — an oid sort would yield a different order, and oids are not + // stable across equivalent databases. + const shuffled = buildMetadata({ + tables: [ + baseTable({ id: 3, name: "a" }), + baseTable({ id: 1, name: "b" }), + baseTable({ id: 2, name: "c" }), + ], + views: [ + baseView({ id: 9, name: "v_a" }), + baseView({ id: 5, name: "v_b" }), + ], + functions: [ + baseFunction({ id: 20, name: "f_a" }), + baseFunction({ id: 10, name: "f_b" }), + ], + columns: [ + baseColumn({ table_id: 1, table: "b", ordinal_position: 1, name: "x" }), + baseColumn({ table_id: 3, table: "a", ordinal_position: 2, name: "y" }), + baseColumn({ table_id: 3, table: "a", ordinal_position: 1, name: "z" }), + ], + }); + + const result = sortGeneratorMetadata(shuffled); + expect(result.tables.map((t) => t.name)).toEqual(["a", "b", "c"]); + expect(result.tables.map((t) => t.id)).toEqual([3, 1, 2]); + expect(result.views.map((v) => v.name)).toEqual(["v_a", "v_b"]); + expect(result.functions.map((f) => f.name)).toEqual(["f_a", "f_b"]); + // Columns grouped by (schema, table), name-ordered within a table + // (mirrors the TypeScript generator's per-table column sort). + expect(result.columns.map((c) => [c.table, c.name])).toEqual([ + ["a", "y"], + ["a", "z"], + ["b", "x"], + ]); + }); + + test("disambiguates overloaded functions by signature", () => { + const result = sortGeneratorMetadata( + buildMetadata({ + functions: [ + baseFunction({ id: 2, name: "f", identity_argument_types: "text" }), + baseFunction({ + id: 1, + name: "f", + identity_argument_types: "integer", + }), + ], + }), + ); + expect(result.functions.map((f) => f.identity_argument_types)).toEqual([ + "integer", + "text", + ]); + }); + + test("is idempotent", () => { + const sorted = sortGeneratorMetadata( + buildMetadata({ + tables: [ + baseTable({ id: 2, name: "a" }), + baseTable({ id: 1, name: "b" }), + ], + relationships: [baseRelationship()], + }), + ); + expect(sortGeneratorMetadata(sorted)).toEqual(sorted); + }); + + test("does not mutate the input", () => { + const input = buildMetadata({ + tables: [ + baseTable({ id: 1, name: "b" }), + baseTable({ id: 2, name: "a" }), + ], + }); + const before = input.tables.map((t) => t.name); + sortGeneratorMetadata(input); + expect(input.tables.map((t) => t.name)).toEqual(before); + }); + + test("sorts function args by name (TS-aligned) but preserves enum values and composite attributes", () => { + const args = [ + { mode: "in" as const, name: "z", type_id: 23, has_default: false }, + { mode: "in" as const, name: "a", type_id: 23, has_default: false }, + ]; + const reversedEnum: PostgresType = { + ...userStatusEnum, + enums: ["INACTIVE", "ACTIVE"], + }; + const result = sortGeneratorMetadata( + buildMetadata({ + functions: [baseFunction({ id: 1, name: "f", args })], + // addressCompositeType.attributes are [street, city] — non-alphabetical. + types: [reversedEnum, addressCompositeType], + }), + ); + // RPC args are addressed by name, so the generated type is order-insensitive + // and we sort them (matches TypeScript). + expect(result.functions[0].args.map((a) => a.name)).toEqual(["a", "z"]); + // Enum values and composite attribute order are semantic → left untouched. + const e = result.types.find((t) => t.name === userStatusEnum.name)!; + expect(e.enums).toEqual(["INACTIVE", "ACTIVE"]); + const c = result.types.find((t) => t.name === addressCompositeType.name)!; + expect(c.attributes.map((a) => a.name)).toEqual(["street", "city"]); + }); +}); diff --git a/packages/postgrest-typegen/test/validation/validate.test.ts b/packages/postgrest-typegen/test/validation/validate.test.ts new file mode 100644 index 000000000..3f01d8397 --- /dev/null +++ b/packages/postgrest-typegen/test/validation/validate.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from "bun:test"; + +import { + generatorMetadataSchema, + type GeneratorMetadata, + parseGeneratorMetadata, + type PostgresColumn, + type PostgresForeignTable, + type PostgresFunction, + type PostgresMaterializedView, + type PostgresRelationship, + type PostgresSchema, + type PostgresTable, + type PostgresType, + type PostgresView, +} from "../../src/types.ts"; +import { + addressCompositeType, + baseColumn, + baseFunction, + baseRelationship, + baseTable, + buildMetadata, + textType, + userStatusEnum, +} from "../generation/fixtures.ts"; + +/** + * Compile-time equivalence: the ArkType-derived types must stay structurally + * identical to the frozen public contract (the original hand-written + * interfaces, copied verbatim below). If ArkType's `.infer` drifts — e.g. an + * optional key becomes `| undefined`, or a union changes — `assertEquals` + * fails to compile. This is what lets ArkType be the single source of truth + * without silently changing postgres-meta's consumed shapes. + */ +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +const assertEquals = <_Expected extends true>(): void => {}; + +// --- Frozen copies of the original interfaces (the contract to preserve) --- + +interface ExpectedPostgresColumn { + table_id: number; + schema: string; + table: string; + id: string; + ordinal_position: number; + name: string; + default_value: unknown; + data_type: string; + format: string; + is_identity: boolean; + identity_generation: "ALWAYS" | "BY DEFAULT" | null; + is_generated: boolean; + is_nullable: boolean; + is_updatable: boolean; + is_unique: boolean; + enums: string[]; + check: string | null; + comment: string | null; +} + +interface ExpectedPostgresForeignTable { + id: number; + schema: string; + name: string; + comment: string | null; + columns?: ExpectedPostgresColumn[]; +} + +interface ExpectedPostgresFunction { + id: number; + schema: string; + name: string; + language: string; + definition: string; + complete_statement: string; + args: { + mode: "in" | "out" | "inout" | "variadic" | "table"; + name: string; + type_id: number; + has_default: boolean | null; + }[]; + argument_types: string; + identity_argument_types: string; + return_type_id: number; + return_type: string; + return_type_relation_id: number | null; + is_set_returning_function: boolean; + prorows: number | null; + behavior: "IMMUTABLE" | "STABLE" | "VOLATILE"; + security_definer: boolean; + config_params: Record | null; +} + +interface ExpectedPostgresMaterializedView { + id: number; + schema: string; + name: string; + is_populated: boolean; + comment: string | null; + columns?: ExpectedPostgresColumn[]; +} + +interface ExpectedPostgresRelationship { + foreign_key_name: string; + schema: string; + relation: string; + columns: string[]; + is_one_to_one: boolean; + referenced_schema: string; + referenced_relation: string; + referenced_columns: string[]; +} + +interface ExpectedPostgresSchema { + id: number; + name: string; + owner: string; +} + +interface ExpectedPostgresTable { + id: number; + schema: string; + name: string; + rls_enabled: boolean; + rls_forced: boolean; + replica_identity: "DEFAULT" | "INDEX" | "FULL" | "NOTHING"; + bytes: number; + size: string; + live_rows_estimate: number; + dead_rows_estimate: number; + comment: string | null; + columns?: ExpectedPostgresColumn[]; +} + +interface ExpectedPostgresType { + id: number; + name: string; + schema: string; + format: string; + enums: string[]; + attributes: { name: string; type_id: number }[]; + comment: string | null; + type_relation_id: number | null; +} + +interface ExpectedPostgresView { + id: number; + schema: string; + name: string; + is_updatable: boolean; + comment: string | null; + columns?: ExpectedPostgresColumn[]; +} + +interface ExpectedGeneratorMetadata { + schemas: ExpectedPostgresSchema[]; + tables: Omit[]; + foreignTables: Omit[]; + views: Omit[]; + materializedViews: Omit[]; + columns: ExpectedPostgresColumn[]; + relationships: ExpectedPostgresRelationship[]; + functions: ExpectedPostgresFunction[]; + types: ExpectedPostgresType[]; +} + +assertEquals>(); +assertEquals>(); +assertEquals>(); +assertEquals< + Equals +>(); +assertEquals>(); +assertEquals>(); +assertEquals>(); +assertEquals>(); +assertEquals>(); +assertEquals>(); + +describe("parseGeneratorMetadata", () => { + const validMetadata: GeneratorMetadata = buildMetadata({ + tables: [baseTable()], + columns: [ + baseColumn({ + name: "id", + format: "int8", + is_identity: true, + ordinal_position: 1, + }), + baseColumn({ + name: "status", + format: "user_status", + is_nullable: true, + ordinal_position: 2, + }), + ], + relationships: [baseRelationship()], + functions: [ + baseFunction({ + name: "add", + args: [{ mode: "in", name: "a", type_id: 23, has_default: false }], + }), + ], + types: [userStatusEnum, textType, addressCompositeType], + }); + + test("returns the value unchanged when it satisfies the contract", () => { + expect(parseGeneratorMetadata(validMetadata)).toEqual(validMetadata); + }); + + test("accepts a fully empty metadata", () => { + const empty: GeneratorMetadata = { + schemas: [], + tables: [], + foreignTables: [], + views: [], + materializedViews: [], + columns: [], + relationships: [], + functions: [], + types: [], + }; + expect(parseGeneratorMetadata(empty)).toEqual(empty); + }); + + test("rejects a non-object", () => { + expect(() => parseGeneratorMetadata(null)).toThrow(TypeError); + expect(() => parseGeneratorMetadata("nope")).toThrow( + "Invalid GeneratorMetadata", + ); + }); + + test("rejects a missing top-level collection", () => { + const { types: _dropped, ...missingTypes } = validMetadata; + expect(() => parseGeneratorMetadata(missingTypes)).toThrow(/types/); + }); + + test("rejects a column with a wrong field type", () => { + const bad = { + ...validMetadata, + columns: [{ ...validMetadata.columns[0], is_nullable: "yes" }], + }; + expect(() => parseGeneratorMetadata(bad)).toThrow(TypeError); + }); + + test("rejects an invalid enum value (identity_generation)", () => { + const bad = { + ...validMetadata, + columns: [ + { ...validMetadata.columns[0], identity_generation: "SOMETIMES" }, + ], + }; + expect(() => parseGeneratorMetadata(bad)).toThrow(TypeError); + }); + + test("rejects a relationship missing referenced_columns", () => { + const { referenced_columns: _dropped, ...badRel } = + validMetadata.relationships[0]; + const bad = { ...validMetadata, relationships: [badRel] }; + expect(() => parseGeneratorMetadata(bad)).toThrow(TypeError); + }); + + test("accepts functions whose OUT/TABLE args have null has_default", () => { + // `introspect()` emits `has_default: null` for the OUT columns of + // RETURNS TABLE / OUT-arg functions: the introspection SQL sizes + // `arg_has_defaults` from the input-arg count (`pronargs`) while + // `arg_modes`/`arg_types` include the output args, so the trailing rows + // get NULL. The validator must accept that real introspector output. + const withNullHasDefault = buildMetadata({ + functions: [ + baseFunction({ + name: "function_returning_table", + args: [ + { mode: "in", name: "user_id", type_id: 23, has_default: false }, + { + mode: "table", + name: "id", + type_id: 23, + has_default: null as unknown as boolean, + }, + ], + }), + ], + }); + expect(() => parseGeneratorMetadata(withNullHasDefault)).not.toThrow(); + }); +}); + +describe("generatorMetadataSchema", () => { + test("is exported for advanced/custom validation flows", () => { + expect(typeof generatorMetadataSchema).toBe("function"); + }); +}); diff --git a/packages/postgrest-typegen/tsconfig.build.json b/packages/postgrest-typegen/tsconfig.build.json new file mode 100644 index 000000000..9cdcae7e0 --- /dev/null +++ b/packages/postgrest-typegen/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": [ + "@tsconfig/node24/tsconfig.json", + "@tsconfig/node-ts/tsconfig.json" + ], + "compilerOptions": { + "outDir": "./dist", + "declaration": true + }, + "rootDir": "./src", + "include": ["./src"], + "exclude": ["**/*.test.ts"] +} diff --git a/packages/postgrest-typegen/tsconfig.json b/packages/postgrest-typegen/tsconfig.json new file mode 100644 index 000000000..ebdcd8bdd --- /dev/null +++ b/packages/postgrest-typegen/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": [ + "@tsconfig/node24/tsconfig.json", + "@tsconfig/node-ts/tsconfig.json" + ], + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "test", "scripts"] +} diff --git a/scripts/verdaccio-publish-postgrest-typegen.ts b/scripts/verdaccio-publish-postgrest-typegen.ts new file mode 100644 index 000000000..503bdafc6 --- /dev/null +++ b/scripts/verdaccio-publish-postgrest-typegen.ts @@ -0,0 +1,143 @@ +/** + * Build `@supabase/postgrest-typegen` and publish it to the locally-running + * Verdaccio with a fresh `0.0.0-local.` version, then restore the + * working-copy version so the repo stays clean. Pair with + * `bun run verdaccio:start`. + * + * This is the Phase 2 byte-parity loop (PGMETA-112): publish locally, point + * postgres-meta at the local registry, and run its typegen snapshot suite + * before anything is published to npm. + * + * Usage: + * bun run verdaccio:start # in one shell + * bun run postgrest-typegen:publish-local + * bun run postgrest-typegen:publish-local --registry=http://localhost:4873/ + * + * Then, in postgres-meta (do NOT commit the .npmrc override): + * echo '@supabase:registry=http://localhost:4873/' >> .npmrc + * npm install @supabase/postgrest-typegen@ + */ +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dir, ".."); +const packageRoot = join(repoRoot, "packages", "postgrest-typegen"); +const packageJsonPath = join(packageRoot, "package.json"); +const defaultRegistry = "http://localhost:4873/"; + +function log(msg: string) { + console.log(`\n=== ${msg} ===`); +} + +// Throw rather than `process.exit(1)`: `process.exit` terminates synchronously +// without unwinding `try`/`finally`, so calling it from inside `main()`'s `try` +// block would skip the package.json version restore and leave the working copy +// bumped. The top-level `main().catch(...)` logs and exits non-zero for us. +function fail(msg: string): never { + throw new Error(msg); +} + +function parseRegistry(): string { + for (const arg of process.argv.slice(2)) { + if (arg.startsWith("--registry=")) { + return arg.slice("--registry=".length); + } + if (arg === "--help" || arg === "-h") { + console.log( + "Usage: bun run postgrest-typegen:publish-local [--registry=]", + ); + process.exit(0); + } + fail(`Unknown argument: ${arg}`); + } + return defaultRegistry; +} + +// npm refuses to POST a publish without an auth token configured for the +// target registry, even when the registry would accept anonymous. Derive the +// per-registry config key (`//[:][]/:_authToken`) from the +// URL so we can pass `--=` to `npm publish`. Verdaccio with +// `publish: $all` ignores the token value. +function authTokenFlag(registry: string): string { + const stripped = registry.replace(/^https?:/i, "").replace(/\/+$/, ""); + return `--${stripped}/:_authToken=local-anon-token`; +} + +async function run(cmd: string[], cwd: string): Promise { + const proc = Bun.spawn(cmd, { cwd, stdout: "inherit", stderr: "inherit" }); + return proc.exited; +} + +interface PkgJson { + name: string; + version: string; + [key: string]: unknown; +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as PkgJson; +} + +async function writeJson(path: string, value: PkgJson): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function main(): Promise { + const registry = parseRegistry(); + + const pkg = await readJson(packageJsonPath); + const originalVersion = pkg.version; + const localVersion = `0.0.0-local.${Math.floor(Date.now() / 1000)}`; + + log( + `Publishing ${pkg.name} as ${localVersion} (working copy will be restored to ${originalVersion})`, + ); + + pkg.version = localVersion; + await writeJson(packageJsonPath, pkg); + + try { + log("Building postgrest-typegen"); + const buildExit = await run( + ["bun", "run", "--filter", "@supabase/postgrest-typegen", "build"], + repoRoot, + ); + if (buildExit !== 0) fail("postgrest-typegen build failed"); + + log(`Publishing to ${registry}`); + // Use `npm publish` (not `bun publish`) because Verdaccio handles npm's + // protocol most reliably for anonymous local registries. + const publishExit = await run( + [ + "npm", + "publish", + "--registry", + registry, + "--tag", + "local", + "--access", + "public", + authTokenFlag(registry), + ], + packageRoot, + ); + if (publishExit !== 0) fail("npm publish failed"); + } finally { + // Always restore the working-copy version, even on failure. + const restored = await readJson(packageJsonPath); + restored.version = originalVersion; + await writeJson(packageJsonPath, restored); + } + + log(`Published version: ${localVersion}`); + console.log( + "\nTo consume from postgres-meta (do NOT commit the .npmrc override):\n" + + ` echo '@supabase:registry=${registry}' >> .npmrc\n` + + ` npm install @supabase/postgrest-typegen@${localVersion}`, + ); +} + +main().catch((err: unknown) => { + console.error(err); + process.exit(1); +});