diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index ac19a52..422194e 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -19,6 +19,7 @@ "eta": "^2.0.0", "inquirer": "^8.2.7", "ora": "^5.4.1", + "pg": "^8.21.0", "pluralize": "^8.0.0", "rc": "^1.2.8", "shelljs": "^0.8.5", @@ -36,6 +37,7 @@ "@types/inquirer": "^8.2.12", "@types/mocha": "^10", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/pluralize": "^0.0.29", "@types/rc": "^1.2.1", "@types/shelljs": "^0.8.15", @@ -2394,6 +2396,18 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/pluralize": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz", @@ -13239,6 +13253,95 @@ "node": "*" } }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -13363,6 +13466,45 @@ "node": ">= 0.4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/preferred-pm": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/preferred-pm/-/preferred-pm-3.0.3.tgz", @@ -15057,6 +15199,15 @@ "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", "dev": true }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -16839,7 +16990,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "engines": { "node": ">=0.4" } diff --git a/package.json b/package.json index 67b8850..a6482d4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eta": "^2.0.0", "inquirer": "^8.2.7", "ora": "^5.4.1", + "pg": "^8.21.0", "pluralize": "^8.0.0", "rc": "^1.2.8", "shelljs": "^0.8.5", @@ -48,6 +49,7 @@ "@types/inquirer": "^8.2.12", "@types/mocha": "^10", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/pluralize": "^0.0.29", "@types/rc": "^1.2.1", "@types/shelljs": "^0.8.15", @@ -93,6 +95,9 @@ }, "mcp": { "description": "MCP server for AI coding agents" + }, + "import": { + "description": "Import an existing database into a local .apsorc schema" } }, "update": { diff --git a/src/commands/import/supabase.ts b/src/commands/import/supabase.ts new file mode 100644 index 0000000..e5266f1 --- /dev/null +++ b/src/commands/import/supabase.ts @@ -0,0 +1,245 @@ +import { Flags } from "@oclif/core"; +import * as fs from "fs"; +import * as path from "path"; +import inquirer from "inquirer"; +import BaseCommand from "../../lib/base-command"; +import { createBackup } from "../../lib/config"; +import { findConfigPath } from "../../lib/apsorc-parser"; +import { parseRelationships } from "../../lib/utils/relationships"; +import { ApsorcRelationship } from "../../lib/types/relationship"; +import { + ApsorcImportOutput, + ImportReport, + Introspector, + PgIntrospector, + findUnknownEmittedTypes, + pgToApsorc, + redactConnectionString, +} from "../../lib/import"; + +/** + * Resolve the Postgres connection string from an explicit flag or, failing + * that, the SUPABASE_DB_URL / DATABASE_URL environment variables. Returns + * undefined if none is set (the command then prompts interactively). + */ +export function resolveConnectionString( + flagValue: string | undefined, + env: NodeJS.ProcessEnv = process.env +): string | undefined { + return flagValue || env.SUPABASE_DB_URL || env.DATABASE_URL || undefined; +} + +/** + * Run the full introspect -> convert -> validate pipeline against any + * Introspector. Throws if the generated relationships fail to parse. Pure of + * file/console I/O so it can be unit-tested with a fake Introspector. + */ +export async function importFromIntrospector( + introspector: Introspector, + schema: string +): Promise<{ + apsorc: ApsorcImportOutput; + report: ImportReport; + unknownTypes: string[]; +}> { + const introspected = await introspector.introspect(schema); + const { apsorc, report } = pgToApsorc(introspected); + + // Validate by running the generated relationships through the real parser. + parseRelationships(apsorc.relationships as ApsorcRelationship[]); + + return { + apsorc, + report, + unknownTypes: findUnknownEmittedTypes(apsorc), + }; +} + +/** Render the import report as a human-readable summary (contains no secrets). */ +export function formatReport(report: ImportReport): string { + const lines: string[] = []; + lines.push( + `Imported ${report.tablesImported.length} table(s) and ${report.relationships} relationship(s).` + ); + if (report.tablesImported.length > 0) { + lines.push(` Tables: ${report.tablesImported.join(", ")}`); + } + if (report.viewsSkipped.length > 0) { + lines.push(` Skipped views: ${report.viewsSkipped.join(", ")}`); + } + if (report.systemSchemasSkipped.length > 0) { + lines.push( + ` Skipped system schemas: ${report.systemSchemasSkipped.join(", ")}` + ); + } + + const w = report.warnings; + const review: string[] = []; + if (w.compositePks.length > 0) + review.push(` - Composite primary keys (review): ${w.compositePks.join(", ")}`); + if (w.nonStandardPks.length > 0) + review.push(` - Non-"id" primary keys (kept as fields): ${w.nonStandardPks.join(", ")}`); + if (w.noPrimaryKey.length > 0) + review.push(` - Tables with no primary key: ${w.noPrimaryKey.join(", ")}`); + if (w.compositeFks.length > 0) + review.push(` - Composite foreign keys (kept as columns, no relationship): ${w.compositeFks.join(", ")}`); + if (w.fkColumnNameUnmapped.length > 0) + review.push(` - Foreign keys whose generated column may differ: ${w.fkColumnNameUnmapped.join(", ")}`); + if (w.joinTablesDetected.length > 0) + review.push(` - Join tables (could be modeled as ManyToMany): ${w.joinTablesDetected.join(", ")}`); + if (w.arraysLossy.length > 0) + review.push(` - Array columns reduced to text: ${w.arraysLossy.join(", ")}`); + if (w.typesDefaulted.length > 0) + review.push( + ` - Unknown types defaulted to text: ${w.typesDefaulted + .map((t) => `${t.column} (${t.udt})`) + .join(", ")}` + ); + if (w.defaultsDropped.length > 0) + review.push(` - Column defaults dropped: ${w.defaultsDropped.join(", ")}`); + + if (review.length > 0) { + lines.push("", "Review the following (not fully represented):", ...review); + } + return lines.join("\n"); +} + +/** + * Write the generated .apsorc to disk, backing up any existing file first. + * Returns the backup path if one was made. + */ +export function writeApsorcFile( + apsorc: ApsorcImportOutput, + outPath: string +): { backupPath?: string } { + let backupPath: string | undefined; + if (fs.existsSync(outPath)) { + backupPath = createBackup(outPath); + } + fs.writeFileSync(outPath, JSON.stringify(apsorc, null, 2), "utf-8"); + return { backupPath }; +} + +export default class ImportSupabase extends BaseCommand { + static description = + "Introspect an existing Supabase (Postgres) database and generate a local .apsorc schema. Read-only: it never writes to the source database."; + + static examples = [ + `$ apso import supabase`, + `$ apso import supabase -c "postgresql://postgres:[pw]@db.[ref].supabase.co:5432/postgres"`, + `$ SUPABASE_DB_URL="postgresql://..." apso import supabase --dry-run`, + ]; + + static flags = { + "connection-string": Flags.string({ + char: "c", + description: + "Postgres connection string. Falls back to SUPABASE_DB_URL or DATABASE_URL, then prompts.", + }), + schema: Flags.string({ + description: "Database schema to import", + default: "public", + }), + out: Flags.string({ + char: "o", + description: "Path to write the .apsorc (default: existing .apsorc or ./.apsorc)", + }), + "dry-run": Flags.boolean({ + description: "Introspect and print a summary without writing any file", + default: false, + }), + yes: Flags.boolean({ + char: "y", + description: "Skip the overwrite confirmation prompt", + default: false, + }), + }; + + /** Overridable for testing. */ + protected makeIntrospector(connectionString: string): Introspector { + return new PgIntrospector(connectionString); + } + + async run(): Promise { + const { flags } = await this.parse(ImportSupabase); + + let connectionString = resolveConnectionString(flags["connection-string"]); + if (!connectionString) { + const answer = await inquirer.prompt<{ connectionString: string }>([ + { + type: "password", + name: "connectionString", + mask: "*", + message: "Supabase Postgres connection string:", + validate: (input: string) => + input.trim().length > 0 || "A connection string is required", + }, + ]); + connectionString = answer.connectionString.trim(); + } + + try { + this.log("Connecting and introspecting schema (read-only)..."); + const introspector = this.makeIntrospector(connectionString); + const { apsorc, report, unknownTypes } = await importFromIntrospector( + introspector, + flags.schema + ); + + this.log(""); + this.log(formatReport(report)); + + if (unknownTypes.length > 0) { + this.warn( + `Some column types were not recognized and defaulted to text: ${[ + ...new Set(unknownTypes), + ].join(", ")}` + ); + } + + if (report.tablesImported.length === 0) { + this.warn( + `No base tables found in schema "${flags.schema}". Nothing to write.` + ); + return; + } + + if (flags["dry-run"]) { + this.log(""); + this.log("Dry run — no file written."); + return; + } + + const outPath = + flags.out ?? findConfigPath() ?? path.join(process.cwd(), ".apsorc"); + + if (fs.existsSync(outPath) && !flags.yes) { + const { confirm } = await inquirer.prompt<{ confirm: boolean }>([ + { + type: "confirm", + name: "confirm", + message: `Overwrite ${outPath} with the imported schema?`, + default: false, + }, + ]); + if (!confirm) { + this.log("Import cancelled."); + return; + } + } + + const { backupPath } = writeApsorcFile(apsorc, outPath); + if (backupPath) this.log(`Backup created: ${backupPath}`); + this.log(`Schema written to ${outPath}`); + this.log(""); + this.log("Next steps:"); + this.log(" - Review the .apsorc, especially any items flagged above"); + this.log(" - Run 'apso migrate' to validate the schema locally"); + this.log(" - Run 'apso generate' to scaffold code"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Defense-in-depth: never surface the connection string in an error. + this.error(redactConnectionString(message.split(connectionString).join("****"))); + } + } +} diff --git a/src/lib/import/index.ts b/src/lib/import/index.ts new file mode 100644 index 0000000..633e0c0 --- /dev/null +++ b/src/lib/import/index.ts @@ -0,0 +1,9 @@ +export * from "./types"; +export { pgToApsorc, findUnknownEmittedTypes } from "./pg-to-apsorc"; +export type { + ApsorcImportOutput, + ApsorcImportEntityOutput, + ApsorcImportFieldOutput, + ApsorcImportRelationshipOutput, +} from "./pg-to-apsorc"; +export { PgIntrospector, redactConnectionString, buildSchema, SYSTEM_SCHEMAS } from "./introspect"; diff --git a/src/lib/import/introspect.ts b/src/lib/import/introspect.ts new file mode 100644 index 0000000..ef61a58 --- /dev/null +++ b/src/lib/import/introspect.ts @@ -0,0 +1,370 @@ +/** + * Database Import — introspection layer + * + * Reads the schema of a live Postgres/Supabase database. This layer is + * deliberately thin and side-effect-light: it only issues read-only SELECTs + * against information_schema/pg_catalog and returns an `IntrospectedSchema`. + * All interpretation lives in the conversion layer (pg-to-apsorc.ts). + * + * `pg` is loaded lazily so importing this module (e.g. from the command) does + * not pull the driver until an actual connection is made. + */ + +import { + IntrospectedColumn, + IntrospectedEnum, + IntrospectedFk, + IntrospectedSchema, + IntrospectedTable, + Introspector, + OnDeleteAction, +} from "./types"; + +/** Supabase- and Postgres-managed schemas we never import from. */ +export const SYSTEM_SCHEMAS = new Set([ + "auth", + "storage", + "realtime", + "_realtime", + "vault", + "extensions", + "graphql", + "graphql_public", + "pgsodium", + "pgsodium_masks", + "pgbouncer", + "cron", + "net", + "supabase_functions", + "supabase_migrations", + "_analytics", + "information_schema", + "pg_catalog", + "pg_toast", +]); + +const CONFDELTYPE_TO_ACTION: Record = { + a: "NO ACTION", + r: "RESTRICT", + c: "CASCADE", + n: "SET NULL", + d: "SET DEFAULT", +}; + +/** Strip credentials from a connection string for safe display in errors. */ +export function redactConnectionString(input: string): string { + return input.replace(/(\/\/[^/:@]+:)[^@]*(@)/, "$1****$2"); +} + +interface QueryClient { + query(sql: string, params?: unknown[]): Promise<{ rows: any[] }>; + end(): Promise; +} + +/** + * Reads schema metadata from a Postgres database via a connection string. + * SSL is enabled by default (Supabase requires it). + */ +export class PgIntrospector implements Introspector { + private readonly connectionString: string; + + constructor(connectionString: string) { + this.connectionString = connectionString; + } + + private async connect(): Promise { + // Lazy import keeps `pg` out of the module graph until we actually connect. + const pg = await import("pg"); + const Client = (pg as any).Client ?? (pg as any).default?.Client; + const client = new Client({ + connectionString: this.connectionString, + ssl: { rejectUnauthorized: false }, + }); + await client.connect(); + return client as QueryClient; + } + + async introspect(schema: string): Promise { + let client: QueryClient | undefined; + try { + client = await this.connect(); + + const [ + tableRows, + columnRows, + pkRows, + fkRows, + uniqueRows, + indexRows, + enumRows, + schemaRows, + ] = [ + await client.query(TABLES_SQL, [schema]), + await client.query(COLUMNS_SQL, [schema]), + await client.query(PRIMARY_KEYS_SQL, [schema]), + await client.query(FOREIGN_KEYS_SQL, [schema]), + await client.query(UNIQUE_SQL, [schema]), + await client.query(INDEXES_SQL, [schema]), + await client.query(ENUMS_SQL, [schema]), + await client.query(SCHEMAS_SQL), + ]; + + return buildSchema(schema, { + tableRows: tableRows.rows, + columnRows: columnRows.rows, + pkRows: pkRows.rows, + fkRows: fkRows.rows, + uniqueRows: uniqueRows.rows, + indexRows: indexRows.rows, + enumRows: enumRows.rows, + schemaRows: schemaRows.rows, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to introspect database: ${redactConnectionString(message)}` + ); + } finally { + if (client) await client.end(); + } + } +} + +const TABLES_SQL = ` + SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = $1 AND table_type IN ('BASE TABLE', 'VIEW') + ORDER BY table_name`; + +const COLUMNS_SQL = ` + SELECT table_name, column_name, udt_name, data_type, is_nullable, + column_default, character_maximum_length, numeric_precision, + numeric_scale, ordinal_position + FROM information_schema.columns + WHERE table_schema = $1 + ORDER BY table_name, ordinal_position`; + +const PRIMARY_KEYS_SQL = ` + SELECT cl.relname AS table_name, att.attname AS column_name, ord.n AS key_index + FROM pg_constraint con + JOIN pg_class cl ON cl.oid = con.conrelid + JOIN pg_namespace ns ON ns.oid = cl.relnamespace + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ord(attnum, n) ON true + JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ord.attnum + WHERE con.contype = 'p' AND ns.nspname = $1 + ORDER BY cl.relname, ord.n`; + +const FOREIGN_KEYS_SQL = ` + SELECT con.conname, cl.relname AS table_name, att.attname AS column_name, + clf.relname AS referenced_table, attf.attname AS referenced_column, + con.confdeltype, ord.n AS key_index + FROM pg_constraint con + JOIN pg_class cl ON cl.oid = con.conrelid + JOIN pg_namespace ns ON ns.oid = cl.relnamespace + JOIN pg_class clf ON clf.oid = con.confrelid + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ord(attnum, n) ON true + JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ord.attnum + JOIN pg_attribute attf ON attf.attrelid = con.confrelid AND attf.attnum = con.confkey[ord.n] + WHERE con.contype = 'f' AND ns.nspname = $1 + ORDER BY con.conname, ord.n`; + +const UNIQUE_SQL = ` + SELECT con.conname, cl.relname AS table_name, att.attname AS column_name, ord.n AS key_index + FROM pg_constraint con + JOIN pg_class cl ON cl.oid = con.conrelid + JOIN pg_namespace ns ON ns.oid = cl.relnamespace + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ord(attnum, n) ON true + JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ord.attnum + WHERE con.contype = 'u' AND ns.nspname = $1 + ORDER BY con.conname, ord.n`; + +const INDEXES_SQL = ` + SELECT tc.relname AS table_name, ic.relname AS index_name, + a.attname AS column_name, ix.indisunique, k.n AS ord + FROM pg_index ix + JOIN pg_class ic ON ic.oid = ix.indexrelid + JOIN pg_class tc ON tc.oid = ix.indrelid + JOIN pg_namespace ns ON ns.oid = tc.relnamespace + JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true + JOIN pg_attribute a ON a.attrelid = tc.oid AND a.attnum = k.attnum + WHERE ns.nspname = $1 AND ix.indisprimary = false + ORDER BY tc.relname, ic.relname, k.n`; + +const ENUMS_SQL = ` + SELECT t.typname AS enum_name, e.enumlabel AS label + FROM pg_type t + JOIN pg_enum e ON e.enumtypid = t.oid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = $1 + ORDER BY t.typname, e.enumsortorder`; + +const SCHEMAS_SQL = `SELECT schema_name FROM information_schema.schemata`; + +interface RawRows { + tableRows: any[]; + columnRows: any[]; + pkRows: any[]; + fkRows: any[]; + uniqueRows: any[]; + indexRows: any[]; + enumRows: any[]; + schemaRows: any[]; +} + +/** + * Assemble raw query rows into an IntrospectedSchema. Exported (separate from + * the DB connection) so it can be unit-tested with fixture rows. + */ +export function buildSchema(schema: string, raw: RawRows): IntrospectedSchema { + // Enums + const enumMap = new Map(); + for (const row of raw.enumRows) { + const list = enumMap.get(row.enum_name) ?? []; + list.push(row.label); + enumMap.set(row.enum_name, list); + } + const enums: IntrospectedEnum[] = [...enumMap.entries()].map( + ([name, labels]) => ({ name, labels }) + ); + + // Tables / views + const baseTables = raw.tableRows + .filter((r) => r.table_type === "BASE TABLE") + .map((r) => r.table_name as string); + const views = raw.tableRows + .filter((r) => r.table_type === "VIEW") + .map((r) => r.table_name as string); + + // Columns grouped by table + const columnsByTable = new Map(); + for (const row of raw.columnRows) { + const isEnum = + row.data_type === "USER-DEFINED" && enumMap.has(row.udt_name); + const col: IntrospectedColumn = { + name: row.column_name, + udtName: row.udt_name, + dataType: row.data_type, + nullable: row.is_nullable === "YES", + default: row.column_default ?? null, + charMaxLength: row.character_maximum_length ?? null, + numericPrecision: row.numeric_precision ?? null, + numericScale: row.numeric_scale ?? null, + ordinal: row.ordinal_position, + isEnum, + enumTypeName: isEnum ? row.udt_name : undefined, + }; + const list = columnsByTable.get(row.table_name) ?? []; + list.push(col); + columnsByTable.set(row.table_name, list); + } + + // Primary keys + const pkByTable = new Map(); + for (const row of raw.pkRows) { + const list = pkByTable.get(row.table_name) ?? []; + list.push(row.column_name); + pkByTable.set(row.table_name, list); + } + + // Foreign keys (grouped by constraint name to handle composite keys) + const fkByConstraint = new Map< + string, + { table: string; columns: string[]; refTable: string; refColumns: string[]; confdeltype: string } + >(); + for (const row of raw.fkRows) { + const existing = fkByConstraint.get(row.conname); + if (existing) { + existing.columns.push(row.column_name); + existing.refColumns.push(row.referenced_column); + } else { + fkByConstraint.set(row.conname, { + table: row.table_name, + columns: [row.column_name], + refTable: row.referenced_table, + refColumns: [row.referenced_column], + confdeltype: row.confdeltype, + }); + } + } + const fksByTable = new Map(); + for (const fk of fkByConstraint.values()) { + const list = fksByTable.get(fk.table) ?? []; + list.push({ + columns: fk.columns, + referencedTable: fk.refTable, + referencedColumns: fk.refColumns, + onDelete: CONFDELTYPE_TO_ACTION[fk.confdeltype] ?? "NO ACTION", + }); + fksByTable.set(fk.table, list); + } + + // Unique constraints (grouped by constraint name) + const uniqueByConstraint = new Map< + string, + { table: string; columns: string[] } + >(); + for (const row of raw.uniqueRows) { + const existing = uniqueByConstraint.get(row.conname); + if (existing) existing.columns.push(row.column_name); + else + uniqueByConstraint.set(row.conname, { + table: row.table_name, + columns: [row.column_name], + }); + } + const uniquesByTable = new Map< + string, + Array<{ name: string; columns: string[] }> + >(); + for (const [name, uc] of uniqueByConstraint.entries()) { + const list = uniquesByTable.get(uc.table) ?? []; + list.push({ name, columns: uc.columns }); + uniquesByTable.set(uc.table, list); + } + + // Indexes (grouped by index name) + const indexByName = new Map< + string, + { table: string; columns: string[]; unique: boolean } + >(); + for (const row of raw.indexRows) { + const existing = indexByName.get(row.index_name); + if (existing) existing.columns.push(row.column_name); + else + indexByName.set(row.index_name, { + table: row.table_name, + columns: [row.column_name], + unique: row.indisunique, + }); + } + const indexesByTable = new Map< + string, + Array<{ name: string; columns: string[]; unique: boolean }> + >(); + for (const [name, idx] of indexByName.entries()) { + const list = indexesByTable.get(idx.table) ?? []; + list.push({ name, columns: idx.columns, unique: idx.unique }); + indexesByTable.set(idx.table, list); + } + + const tables: IntrospectedTable[] = baseTables.map((name) => ({ + name, + columns: columnsByTable.get(name) ?? [], + primaryKey: pkByTable.get(name) ?? [], + foreignKeys: fksByTable.get(name) ?? [], + uniqueConstraints: uniquesByTable.get(name) ?? [], + indexes: indexesByTable.get(name) ?? [], + })); + + const systemSchemas = raw.schemaRows + .map((r) => r.schema_name as string) + .filter((s) => SYSTEM_SCHEMAS.has(s) && s !== schema) + .sort(); + + return { + schema, + tables, + enums, + skipped: { views, systemSchemas }, + }; +} diff --git a/src/lib/import/pg-to-apsorc.ts b/src/lib/import/pg-to-apsorc.ts new file mode 100644 index 0000000..08fac0d --- /dev/null +++ b/src/lib/import/pg-to-apsorc.ts @@ -0,0 +1,468 @@ +/** + * Database Import — conversion layer + * + * Turns an `IntrospectedSchema` (read from a live Postgres/Supabase database) + * into a `.apsorc`-shaped object. This is a pure function: same input always + * yields the same output and report, with no I/O — which keeps it exhaustively + * unit-testable. + * + * The reverse type map here is the inverse of `fieldTypeToColumnType` + * (src/lib/utils/field.ts). The FK-to-relationship logic mirrors how the + * platform derives FK column names in `getRelationshipIdField` + * (src/lib/utils/relationships/parse.ts) so imported relationships round-trip. + */ + +import { camelCase } from "../utils/casing"; +import { fieldTypeToColumnType } from "../utils/field"; +import { getRelationshipIdField } from "../utils/relationships"; +import { + ImportReport, + IntrospectedColumn, + IntrospectedSchema, + IntrospectedTable, +} from "./types"; + +export interface ApsorcImportFieldOutput { + name: string; + type: string; + nullable?: boolean; + unique?: boolean; + default?: unknown; + index?: boolean; + values?: string[]; + length?: number; + precision?: number; + scale?: number; + primary?: boolean; +} + +export interface ApsorcImportEntityOutput { + name: string; + primaryKeyType?: "serial" | "uuid" | "text"; + created_at?: boolean; + updated_at?: boolean; + fields?: ApsorcImportFieldOutput[]; + uniques?: Array<{ fields: string[]; name?: string }>; + indexes?: Array<{ fields: string[]; unique?: boolean }>; +} + +export interface ApsorcImportRelationshipOutput { + from: string; + to: string; + type: "OneToMany" | "ManyToOne" | "ManyToMany" | "OneToOne"; + to_name?: string; + nullable?: boolean; + bi_directional?: boolean; + cascadeDelete?: boolean; +} + +export interface ApsorcImportOutput { + version: number; + rootFolder: string; + apiType: string; + entities: ApsorcImportEntityOutput[]; + relationships: ApsorcImportRelationshipOutput[]; +} + +/** Maps a Postgres udt_name to the closest `.apsorc` field type. */ +const udtToApsorcType: Record = { + int2: "smallint", + int4: "integer", + int8: "bigint", + float4: "real", + float8: "double", + numeric: "numeric", + money: "money", + bool: "boolean", + varchar: "varchar", + bpchar: "char", + text: "text", + uuid: "uuid", + json: "json", + jsonb: "jsonb", + date: "date", + timestamp: "timestamp", + timestamptz: "timestamptz", + time: "time", + timetz: "timetz", + bytea: "bytea", + xml: "xml", + inet: "inet", + cidr: "inet", + interval: "interval", + tsvector: "tsvector", + int4range: "int4range", + // PostGIS — pass-through (these are also keys in fieldTypeToColumnType) + point: "point", + geometry: "geometry", + geography: "geography", +}; + +const TIMESTAMP_UDTS = new Set(["timestamp", "timestamptz"]); +const AUTO_TIMESTAMP_DEFAULTS = new Set([ + "now()", + "current_timestamp", + "transaction_timestamp()", + "clock_timestamp()", +]); + +interface TypeResolution { + type: string; + /** True when the mapping loses information (arrays, unknown types). */ + lossy?: "array" | "defaulted"; +} + +/** + * Resolve a column's `.apsorc` field type from its Postgres type. Postgres + * arrays (udt_name starting with "_") collapse to `array`; user enums become + * `enum`; unknown types fall back to `text`. + */ +export function pgTypeToApsorcType(col: IntrospectedColumn): TypeResolution { + if (col.isEnum) { + return { type: "enum" }; + } + if (col.dataType === "ARRAY" || col.udtName.startsWith("_")) { + return { type: "array", lossy: "array" }; + } + const mapped = udtToApsorcType[col.udtName]; + if (mapped) { + return { type: mapped }; + } + return { type: "text", lossy: "defaulted" }; +} + +/** + * Translate a raw Postgres default expression into an `.apsorc` default value. + * Returns `{ drop: true }` when the default cannot be represented (function + * calls, sequence/uuid generators). Auto-timestamp and sequence defaults are + * dropped silently by callers that handle them structurally. + */ +export function parseDefault( + raw: string | null, + apsorcType: string +): { value?: unknown; drop?: boolean } { + if (raw === null) return {}; + + const expr = raw.trim(); + const lower = expr.toLowerCase(); + + // Sequence / generator / function defaults can't be a literal .apsorc value. + if ( + lower.startsWith("nextval(") || + lower.startsWith("gen_random_uuid(") || + lower.startsWith("uuid_generate_v4(") || + AUTO_TIMESTAMP_DEFAULTS.has(lower) + ) { + return { drop: true }; + } + + // Strip a trailing ::type cast, e.g. 'active'::text or 0::integer. + const withoutCast = expr.replace(/::[\s\w".[\]]+$/, "").trim(); + + // Quoted string literal. + if (withoutCast.startsWith("'") && withoutCast.endsWith("'")) { + const inner = withoutCast.slice(1, -1).replace(/''/g, "'"); + return { value: inner }; + } + + if (lower === "true" || lower === "false") { + return { value: lower === "true" }; + } + + if (/^-?\d+(\.\d+)?$/.test(withoutCast)) { + if (apsorcType === "numeric" || apsorcType === "decimal") { + return { value: withoutCast }; + } + return { value: Number(withoutCast) }; + } + + // Anything else (function calls, complex expressions) — drop with a warning. + return { drop: true }; +} + +/** + * Derive the relationship `to_name` so the platform regenerates a FK column + * matching the source column. Returns `undefined` when the default naming + * (`${camelCase(target)}Id`) already produces the source column. Sets + * `unmapped: true` when no name round-trips to the source column. + */ +export function deriveToName( + sourceColumn: string, + targetTable: string +): { toName?: string; unmapped?: boolean } { + // The platform always generates camelCase FK columns, while Postgres/Supabase + // columns are typically snake_case. Compare on the camelCased form so a + // snake_case source that is semantically identical isn't flagged as a mismatch. + const source = camelCase(sourceColumn); + const defaultColumn = getRelationshipIdField({ + name: targetTable, + type: "ManyToOne", + }); + if (defaultColumn === source) { + return {}; + } + + // Strip a trailing Id / _id and use the remainder as the reference name. + const base = sourceColumn.replace(/(_id|Id)$/, ""); + if (base && base !== sourceColumn) { + const candidate = camelCase(base); + const roundTrip = getRelationshipIdField({ + name: targetTable, + type: "ManyToOne", + referenceName: candidate, + }); + if (roundTrip === source) { + return { toName: candidate }; + } + } + + // Best-effort: emit a reference name but flag that the generated FK column + // may not match the source column exactly. + return { toName: camelCase(base || sourceColumn), unmapped: true }; +} + +function emptyReport(schema: IntrospectedSchema): ImportReport { + return { + tablesImported: [], + relationships: 0, + viewsSkipped: schema.skipped.views, + systemSchemasSkipped: schema.skipped.systemSchemas, + warnings: { + arraysLossy: [], + compositePks: [], + compositeFks: [], + nonStandardPks: [], + noPrimaryKey: [], + typesDefaulted: [], + fkColumnNameUnmapped: [], + defaultsDropped: [], + joinTablesDetected: [], + }, + }; +} + +function isJoinTable(table: IntrospectedTable): boolean { + const singleColumnFkNames = table.foreignKeys + .filter((fk) => fk.columns.length === 1) + .map((fk) => fk.columns[0]); + return ( + table.foreignKeys.length === 2 && + singleColumnFkNames.length === 2 && + table.primaryKey.length === 2 && + table.primaryKey.every((c) => singleColumnFkNames.includes(c)) + ); +} + +/** + * Convert an introspected Postgres schema into a `.apsorc`-shaped object plus a + * report of everything that could not be represented losslessly. + */ +export function pgToApsorc(schema: IntrospectedSchema): { + apsorc: ApsorcImportOutput; + report: ImportReport; +} { + const report = emptyReport(schema); + const enumLabels = new Map(schema.enums.map((e) => [e.name, e.labels])); + const entities: ApsorcImportEntityOutput[] = []; + const relationships: ApsorcImportRelationshipOutput[] = []; + + for (const table of schema.tables) { + report.tablesImported.push(table.name); + if (isJoinTable(table)) { + report.warnings.joinTablesDetected.push(table.name); + } + + const entity: ApsorcImportEntityOutput = { name: table.name }; + + // Columns covered by a single-column FK are materialized by the + // relationship, so they must not also be emitted as scalar fields. + const fkColumns = new Map(); + for (const fk of table.foreignKeys) { + if (fk.columns.length === 1) { + fkColumns.set(fk.columns[0], fk); + } else { + report.warnings.compositeFks.push(table.name); + } + } + + // --- Primary key handling --- + const pk = table.primaryKey; + const pkSet = new Set(pk); + let pkAsField = false; + if (pk.length === 1) { + const pkCol = table.columns.find((c) => c.name === pk[0]); + if (pk[0] === "id" && pkCol) { + const t = pgTypeToApsorcType(pkCol).type; + if (t === "uuid") entity.primaryKeyType = "uuid"; + else if (t === "text" || t === "varchar") entity.primaryKeyType = "text"; + // integer/bigint/smallint id => default serial, omit primaryKeyType. + } else { + // Single, non-"id" PK: emit it as a primary field. + pkAsField = true; + report.warnings.nonStandardPks.push(table.name); + } + } else if (pk.length > 1) { + pkAsField = true; + report.warnings.compositePks.push(table.name); + } else { + report.warnings.noPrimaryKey.push(table.name); + } + + // --- created_at / updated_at detection --- + const autoTimestamps = new Set(); + for (const name of ["created_at", "updated_at"] as const) { + const col = table.columns.find((c) => c.name === name); + if (col && TIMESTAMP_UDTS.has(col.udtName)) { + entity[name] = true; + autoTimestamps.add(name); + } + } + + // --- Fields --- + const uniqueSingle = new Set(); + const uniqueComposite: Array<{ fields: string[]; name?: string }> = []; + for (const uc of table.uniqueConstraints) { + if (uc.columns.length === 1) uniqueSingle.add(uc.columns[0]); + else uniqueComposite.push({ fields: uc.columns, name: uc.name }); + } + + const indexSingle = new Set(); + const indexComposite: Array<{ fields: string[]; unique?: boolean }> = []; + for (const idx of table.indexes) { + const cols = idx.columns; + // Skip indexes that merely back the PK or a unique constraint. + const backsPk = + cols.length === pk.length && cols.every((c) => pkSet.has(c)); + const backsUnique = table.uniqueConstraints.some( + (uc) => + uc.columns.length === cols.length && + uc.columns.every((c) => cols.includes(c)) + ); + if (backsPk || backsUnique) continue; + if (cols.length === 1) indexSingle.add(cols[0]); + else indexComposite.push({ fields: cols, unique: idx.unique }); + } + + const fields: ApsorcImportFieldOutput[] = []; + const sortedColumns = [...table.columns].sort( + (a, b) => a.ordinal - b.ordinal + ); + for (const col of sortedColumns) { + // Skip columns represented elsewhere. + if (fkColumns.has(col.name)) continue; + if (autoTimestamps.has(col.name)) continue; + if (pk.length === 1 && col.name === pk[0] && !pkAsField) continue; + + const resolved = pgTypeToApsorcType(col); + if (resolved.lossy === "array") { + report.warnings.arraysLossy.push(`${table.name}.${col.name}`); + } else if (resolved.lossy === "defaulted") { + report.warnings.typesDefaulted.push({ + column: `${table.name}.${col.name}`, + udt: col.udtName, + }); + } + + const field: ApsorcImportFieldOutput = { + name: col.name, + type: resolved.type, + }; + if (col.nullable) field.nullable = true; + if (uniqueSingle.has(col.name)) field.unique = true; + if (indexSingle.has(col.name)) field.index = true; + if (pkSet.has(col.name) && pkAsField) field.primary = true; + + if ((resolved.type === "varchar" || resolved.type === "char") && col.charMaxLength) field.length = col.charMaxLength; + if (resolved.type === "numeric" || resolved.type === "decimal") { + if (col.numericPrecision) field.precision = col.numericPrecision; + if (col.numericScale !== null) field.scale = col.numericScale; + } + + if (resolved.type === "enum") { + const labels = col.enumTypeName + ? enumLabels.get(col.enumTypeName) + : undefined; + if (labels) field.values = labels; + } + + // Defaults (skip for PK columns — those are implicit/sequence-driven). + if (!(pkSet.has(col.name) && !pkAsField)) { + const parsed = parseDefault(col.default, resolved.type); + if (parsed.drop && col.default !== null) { + report.warnings.defaultsDropped.push(`${table.name}.${col.name}`); + } else if ("value" in parsed) { + if (resolved.type === "enum") { + // Only keep an enum default that is one of the allowed labels. + if (field.values && field.values.includes(String(parsed.value))) { + field.default = parsed.value; + } else { + report.warnings.defaultsDropped.push( + `${table.name}.${col.name}` + ); + } + } else { + field.default = parsed.value; + } + } + } + + fields.push(field); + } + + if (fields.length > 0) entity.fields = fields; + if (uniqueComposite.length > 0) entity.uniques = uniqueComposite; + if (indexComposite.length > 0) entity.indexes = indexComposite; + entities.push(entity); + + // --- Foreign keys -> ManyToOne relationships --- + for (const fk of table.foreignKeys) { + if (fk.columns.length !== 1) continue; // composite FK handled above + const sourceColumn = fk.columns[0]; + const col = table.columns.find((c) => c.name === sourceColumn); + const rel: ApsorcImportRelationshipOutput = { + from: table.name, + to: fk.referencedTable, + type: "ManyToOne", + }; + const { toName, unmapped } = deriveToName(sourceColumn, fk.referencedTable); + if (toName) rel.to_name = toName; + if (unmapped) { + report.warnings.fkColumnNameUnmapped.push( + `${table.name}.${sourceColumn}` + ); + } + if (col?.nullable) rel.nullable = true; + if (fk.onDelete === "CASCADE") rel.cascadeDelete = true; + relationships.push(rel); + } + } + + report.relationships = relationships.length; + + return { + apsorc: { + version: 2, + rootFolder: "src", + apiType: "rest", + entities, + relationships, + }, + report, + }; +} + +/** + * Invariant guard: every emitted field type must be a recognized `.apsorc` + * column type, so code generation never silently falls back to varchar. + * Returns the list of any offending types (empty when valid). + */ +export function findUnknownEmittedTypes(output: ApsorcImportOutput): string[] { + const unknown: string[] = []; + for (const entity of output.entities) { + for (const field of entity.fields ?? []) { + if (field.type === "enum") continue; // enum dispatched separately + if (!(field.type in fieldTypeToColumnType)) unknown.push(field.type); + } + } + return unknown; +} diff --git a/src/lib/import/types.ts b/src/lib/import/types.ts new file mode 100644 index 0000000..25e68b6 --- /dev/null +++ b/src/lib/import/types.ts @@ -0,0 +1,127 @@ +/** + * Database Import — intermediate model + * + * These types are the contract between the introspection layer (which reads a + * live Postgres database) and the conversion layer (which turns that into a + * `.apsorc`). Keeping the introspector behind the `Introspector` interface lets + * the conversion logic and the command be unit-tested with a fake source, + * without a real database connection. + */ + +/** ON DELETE referential action of a foreign key. */ +export type OnDeleteAction = + | "CASCADE" + | "RESTRICT" + | "SET NULL" + | "NO ACTION" + | "SET DEFAULT"; + +/** A single introspected column. */ +export interface IntrospectedColumn { + name: string; + /** pg_catalog udt_name, e.g. "int4", "varchar", "_text", "timestamptz". */ + udtName: string; + /** information_schema.data_type, used to detect "USER-DEFINED" (enum) and "ARRAY". */ + dataType: string; + nullable: boolean; + /** Raw default expression, e.g. "nextval('...')", "now()", "'active'::text". */ + default: string | null; + charMaxLength: number | null; + numericPrecision: number | null; + numericScale: number | null; + ordinal: number; + /** True when the column's type is a user-defined enum. */ + isEnum: boolean; + /** Links to IntrospectedEnum.name when isEnum is true. */ + enumTypeName?: string; +} + +/** A foreign key constraint (may be composite). */ +export interface IntrospectedFk { + /** Local column(s) participating in the FK. */ + columns: string[]; + referencedTable: string; + referencedColumns: string[]; + onDelete: OnDeleteAction; +} + +/** A unique constraint (may be composite). */ +export interface IntrospectedUnique { + name: string; + columns: string[]; +} + +/** A secondary index (may be composite). */ +export interface IntrospectedIndex { + name: string; + columns: string[]; + unique: boolean; +} + +/** A user-defined enum type and its labels (in sort order). */ +export interface IntrospectedEnum { + name: string; + labels: string[]; +} + +/** A single introspected base table. */ +export interface IntrospectedTable { + name: string; + columns: IntrospectedColumn[]; + /** Ordered primary-key column names ([] if the table has no primary key). */ + primaryKey: string[]; + foreignKeys: IntrospectedFk[]; + uniqueConstraints: IntrospectedUnique[]; + indexes: IntrospectedIndex[]; +} + +/** The full result of introspecting one Postgres schema. */ +export interface IntrospectedSchema { + schema: string; + tables: IntrospectedTable[]; + enums: IntrospectedEnum[]; + /** Objects intentionally not imported, surfaced in the summary report. */ + skipped: { + views: string[]; + systemSchemas: string[]; + }; +} + +/** + * The boundary the command depends on. The real implementation + * (PgIntrospector) connects to Postgres; tests provide a fake. + */ +export interface Introspector { + introspect(schema: string): Promise; +} + +/** + * Everything the import couldn't represent losslessly, plus counts, used to + * print a summary the user can review before/after writing the `.apsorc`. + */ +export interface ImportReport { + tablesImported: string[]; + relationships: number; + viewsSkipped: string[]; + systemSchemasSkipped: string[]; + warnings: { + /** "table.column" whose Postgres array type was reduced to text. */ + arraysLossy: string[]; + /** Tables with composite primary keys (emitted as primary:true fields). */ + compositePks: string[]; + /** Tables with composite foreign keys (kept as scalar columns, no relationship). */ + compositeFks: string[]; + /** Tables whose single PK is not named "id" (emitted as a primary:true field). */ + nonStandardPks: string[]; + /** Tables with no primary key at all. */ + noPrimaryKey: string[]; + /** Columns whose type was unknown and defaulted to text. */ + typesDefaulted: Array<{ column: string; udt: string }>; + /** FK columns whose name could not be round-tripped to a relationship name. */ + fkColumnNameUnmapped: string[]; + /** "table.column" defaults that were dropped (unsupported expressions). */ + defaultsDropped: string[]; + /** Tables that look like pure join tables (candidates for manual ManyToMany). */ + joinTablesDetected: string[]; + }; +} diff --git a/test/commands/import/supabase.test.ts b/test/commands/import/supabase.test.ts new file mode 100644 index 0000000..74492da --- /dev/null +++ b/test/commands/import/supabase.test.ts @@ -0,0 +1,131 @@ +import { expect, describe, test, afterEach } from "@jest/globals"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + resolveConnectionString, + importFromIntrospector, + formatReport, + writeApsorcFile, +} from "../../../src/commands/import/supabase"; +import { + IntrospectedSchema, + Introspector, +} from "../../../src/lib/import/types"; +import { pgToApsorc } from "../../../src/lib/import/pg-to-apsorc"; + +function fakeIntrospector(s: IntrospectedSchema): Introspector { + return { introspect: async () => s }; +} + +const sampleSchema: IntrospectedSchema = { + schema: "public", + tables: [ + { + name: "User", + primaryKey: ["id"], + columns: [ + { + name: "id", + udtName: "uuid", + dataType: "base", + nullable: false, + default: null, + charMaxLength: null, + numericPrecision: null, + numericScale: null, + ordinal: 1, + isEnum: false, + }, + { + name: "email", + udtName: "varchar", + dataType: "base", + nullable: false, + default: null, + charMaxLength: 255, + numericPrecision: null, + numericScale: null, + ordinal: 2, + isEnum: false, + }, + ], + foreignKeys: [], + uniqueConstraints: [{ name: "uq_email", columns: ["email"] }], + indexes: [], + }, + ], + enums: [], + skipped: { views: ["v_active"], systemSchemas: ["auth"] }, +}; + +describe("resolveConnectionString", () => { + test("prefers the explicit flag", () => { + expect(resolveConnectionString("flag://x", { SUPABASE_DB_URL: "env://y" })).toBe( + "flag://x" + ); + }); + test("falls back to SUPABASE_DB_URL then DATABASE_URL", () => { + expect(resolveConnectionString(undefined, { SUPABASE_DB_URL: "s://a" })).toBe("s://a"); + expect(resolveConnectionString(undefined, { DATABASE_URL: "d://b" })).toBe("d://b"); + }); + test("returns undefined when nothing is set", () => { + expect(resolveConnectionString(undefined, {})).toBeUndefined(); + }); +}); + +describe("importFromIntrospector", () => { + test("produces a valid .apsorc from a fake source", async () => { + const { apsorc, report, unknownTypes } = await importFromIntrospector( + fakeIntrospector(sampleSchema), + "public" + ); + expect(apsorc.entities.map((e) => e.name)).toEqual(["User"]); + expect(apsorc.entities[0].primaryKeyType).toBe("uuid"); + expect(report.tablesImported).toEqual(["User"]); + expect(unknownTypes).toEqual([]); + }); +}); + +describe("formatReport", () => { + test("summarizes counts and skipped objects without leaking secrets", () => { + const { report } = pgToApsorc(sampleSchema); + const text = formatReport(report); + expect(text).toContain("Imported 1 table"); + expect(text).toContain("v_active"); + expect(text).toContain("auth"); + // The report is built only from schema metadata — never connection details. + expect(text).not.toContain("postgres"); + expect(text).not.toContain("@"); + }); +}); + +describe("writeApsorcFile", () => { + const tmpDirs: string[] = []; + afterEach(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); + tmpDirs.length = 0; + }); + + function tmp(): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), "apso-import-")); + tmpDirs.push(d); + return d; + } + + test("writes JSON to the target path", async () => { + const dir = tmp(); + const out = path.join(dir, ".apsorc"); + const { apsorc } = await importFromIntrospector( + fakeIntrospector(sampleSchema), + "public" + ); + const { backupPath } = writeApsorcFile(apsorc, out); + expect(backupPath).toBeUndefined(); + expect(fs.existsSync(out)).toBe(true); + // eslint-disable-next-line unicorn/prefer-json-parse-buffer -- TS types JSON.parse as string-only + const parsed = JSON.parse(fs.readFileSync(out, "utf-8")); + expect(parsed.version).toBe(2); + expect(parsed.entities[0].name).toBe("User"); + }); +}); diff --git a/test/lib/import/introspect.test.ts b/test/lib/import/introspect.test.ts new file mode 100644 index 0000000..3d5e295 --- /dev/null +++ b/test/lib/import/introspect.test.ts @@ -0,0 +1,180 @@ +/* eslint-disable camelcase -- fixtures mirror raw Postgres catalog row shapes */ +import { expect, describe, test } from "@jest/globals"; +import { + buildSchema, + redactConnectionString, +} from "../../../src/lib/import/introspect"; + +describe("redactConnectionString", () => { + test("masks the password in a connection string", () => { + const redacted = redactConnectionString( + "postgresql://postgres:s3cr3t@db.ref.supabase.co:5432/postgres" + ); + expect(redacted).toBe( + "postgresql://postgres:****@db.ref.supabase.co:5432/postgres" + ); + expect(redacted).not.toContain("s3cr3t"); + }); + + test("leaves strings without credentials unchanged", () => { + expect(redactConnectionString("no creds here")).toBe("no creds here"); + }); +}); + +describe("buildSchema", () => { + test("assembles tables, columns, PKs, FKs, uniques, indexes, and enums", () => { + const result = buildSchema("public", { + tableRows: [ + { table_name: "users", table_type: "BASE TABLE" }, + { table_name: "posts", table_type: "BASE TABLE" }, + { table_name: "active_users", table_type: "VIEW" }, + ], + columnRows: [ + { + table_name: "users", + column_name: "id", + udt_name: "uuid", + data_type: "uuid", + is_nullable: "NO", + column_default: "gen_random_uuid()", + character_maximum_length: null, + numeric_precision: null, + numeric_scale: null, + ordinal_position: 1, + }, + { + table_name: "users", + column_name: "role", + udt_name: "user_role", + data_type: "USER-DEFINED", + is_nullable: "NO", + column_default: null, + character_maximum_length: null, + numeric_precision: null, + numeric_scale: null, + ordinal_position: 2, + }, + { + table_name: "posts", + column_name: "author_id", + udt_name: "uuid", + data_type: "uuid", + is_nullable: "YES", + column_default: null, + character_maximum_length: null, + numeric_precision: null, + numeric_scale: null, + ordinal_position: 2, + }, + ], + pkRows: [{ table_name: "users", column_name: "id", key_index: 1 }], + fkRows: [ + { + conname: "posts_author_fkey", + table_name: "posts", + column_name: "author_id", + referenced_table: "users", + referenced_column: "id", + confdeltype: "c", + key_index: 1, + }, + ], + uniqueRows: [ + { + conname: "users_email_key", + table_name: "users", + column_name: "email", + key_index: 1, + }, + ], + indexRows: [ + { + table_name: "posts", + index_name: "posts_author_idx", + column_name: "author_id", + indisunique: false, + ord: 1, + }, + ], + enumRows: [ + { enum_name: "user_role", label: "admin" }, + { enum_name: "user_role", label: "member" }, + ], + schemaRows: [ + { schema_name: "public" }, + { schema_name: "auth" }, + { schema_name: "storage" }, + ], + }); + + // Views excluded from tables, surfaced in skipped. + expect(result.tables.map((t) => t.name)).toEqual(["users", "posts"]); + expect(result.skipped.views).toEqual(["active_users"]); + expect(result.skipped.systemSchemas).toEqual(["auth", "storage"]); + + // Enum detection links column -> enum type. + const users = result.tables.find((t) => t.name === "users")!; + expect(users.primaryKey).toEqual(["id"]); + const role = users.columns.find((c) => c.name === "role")!; + expect(role.isEnum).toBe(true); + expect(role.enumTypeName).toBe("user_role"); + expect(result.enums).toEqual([ + { name: "user_role", labels: ["admin", "member"] }, + ]); + + // FK with cascade + nullability. + const posts = result.tables.find((t) => t.name === "posts")!; + expect(posts.foreignKeys).toEqual([ + { + columns: ["author_id"], + referencedTable: "users", + referencedColumns: ["id"], + onDelete: "CASCADE", + }, + ]); + expect(posts.columns.find((c) => c.name === "author_id")!.nullable).toBe(true); + expect(posts.indexes).toEqual([ + { name: "posts_author_idx", columns: ["author_id"], unique: false }, + ]); + }); + + test("groups composite foreign keys by constraint name in key order", () => { + const result = buildSchema("public", { + tableRows: [{ table_name: "memberships", table_type: "BASE TABLE" }], + columnRows: [], + pkRows: [], + fkRows: [ + { + conname: "fk_pair", + table_name: "memberships", + column_name: "org_id", + referenced_table: "pairs", + referenced_column: "a", + confdeltype: "a", + key_index: 1, + }, + { + conname: "fk_pair", + table_name: "memberships", + column_name: "user_id", + referenced_table: "pairs", + referenced_column: "b", + confdeltype: "a", + key_index: 2, + }, + ], + uniqueRows: [], + indexRows: [], + enumRows: [], + schemaRows: [], + }); + expect(result.tables[0].foreignKeys).toEqual([ + { + columns: ["org_id", "user_id"], + referencedTable: "pairs", + referencedColumns: ["a", "b"], + onDelete: "NO ACTION", + }, + ]); + }); +}); diff --git a/test/lib/import/pg-to-apsorc.test.ts b/test/lib/import/pg-to-apsorc.test.ts new file mode 100644 index 0000000..b311732 --- /dev/null +++ b/test/lib/import/pg-to-apsorc.test.ts @@ -0,0 +1,548 @@ +import { expect, describe, test } from "@jest/globals"; +import { + pgToApsorc, + pgTypeToApsorcType, + parseDefault, + deriveToName, + findUnknownEmittedTypes, +} from "../../../src/lib/import/pg-to-apsorc"; +import { + IntrospectedColumn, + IntrospectedSchema, + IntrospectedTable, +} from "../../../src/lib/import/types"; +import { parseRelationships } from "../../../src/lib/utils/relationships"; +import { ApsorcRelationship } from "../../../src/lib/types/relationship"; + +// ---- fixture helpers ---- + +function col( + name: string, + udtName: string, + overrides: Partial = {} +): IntrospectedColumn { + return { + name, + udtName, + dataType: overrides.dataType ?? "base", + nullable: overrides.nullable ?? false, + default: overrides.default ?? null, + charMaxLength: overrides.charMaxLength ?? null, + numericPrecision: overrides.numericPrecision ?? null, + numericScale: overrides.numericScale ?? null, + ordinal: overrides.ordinal ?? 1, + isEnum: overrides.isEnum ?? false, + enumTypeName: overrides.enumTypeName, + }; +} + +function table( + name: string, + overrides: Partial = {} +): IntrospectedTable { + return { + name, + columns: overrides.columns ?? [], + primaryKey: overrides.primaryKey ?? [], + foreignKeys: overrides.foreignKeys ?? [], + uniqueConstraints: overrides.uniqueConstraints ?? [], + indexes: overrides.indexes ?? [], + }; +} + +function schema( + tables: IntrospectedTable[], + overrides: Partial = {} +): IntrospectedSchema { + return { + schema: "public", + tables, + enums: overrides.enums ?? [], + skipped: overrides.skipped ?? { views: [], systemSchemas: [] }, + }; +} + +function findEntity(out: ReturnType, name: string) { + return out.apsorc.entities.find((e) => e.name === name)!; +} +function findField(entity: { fields?: any[] }, name: string) { + return (entity.fields ?? []).find((f) => f.name === name); +} + +// ---- reverse type map ---- + +describe("pgTypeToApsorcType", () => { + const cases: Array<[string, string]> = [ + ["int2", "smallint"], + ["int4", "integer"], + ["int8", "bigint"], + ["float4", "real"], + ["float8", "double"], + ["numeric", "numeric"], + ["bool", "boolean"], + ["varchar", "varchar"], + ["bpchar", "char"], + ["text", "text"], + ["uuid", "uuid"], + ["json", "json"], + ["jsonb", "jsonb"], + ["date", "date"], + ["timestamp", "timestamp"], + ["timestamptz", "timestamptz"], + ["time", "time"], + ["timetz", "timetz"], + ["bytea", "bytea"], + ["inet", "inet"], + ["cidr", "inet"], + ["interval", "interval"], + ]; + test.each(cases)("maps udt %s -> %s", (udt, expected) => { + expect(pgTypeToApsorcType(col("c", udt)).type).toBe(expected); + }); + + test("array udt is lossy and maps to array", () => { + const r = pgTypeToApsorcType(col("tags", "_text", { dataType: "ARRAY" })); + expect(r.type).toBe("array"); + expect(r.lossy).toBe("array"); + }); + + test("enum column maps to enum", () => { + const r = pgTypeToApsorcType( + col("status", "order_status", { + dataType: "USER-DEFINED", + isEnum: true, + enumTypeName: "order_status", + }) + ); + expect(r.type).toBe("enum"); + }); + + test("unknown udt defaults to text and is flagged lossy", () => { + const r = pgTypeToApsorcType(col("weird", "tstzrange")); + expect(r.type).toBe("text"); + expect(r.lossy).toBe("defaulted"); + }); +}); + +// ---- defaults ---- + +describe("parseDefault", () => { + test("drops sequence/uuid/now defaults", () => { + expect(parseDefault("nextval('s'::regclass)", "integer").drop).toBe(true); + expect(parseDefault("gen_random_uuid()", "uuid").drop).toBe(true); + expect(parseDefault("now()", "timestamptz").drop).toBe(true); + }); + test("strips ::type cast and quotes for string literal", () => { + expect(parseDefault("'active'::text", "text").value).toBe("active"); + }); + test("parses booleans and numbers", () => { + expect(parseDefault("true", "boolean").value).toBe(true); + expect(parseDefault("0", "integer").value).toBe(0); + expect(parseDefault("3.50", "numeric").value).toBe("3.50"); + }); + test("drops unknown function expressions", () => { + expect(parseDefault("some_func(1)", "text").drop).toBe(true); + }); +}); + +// ---- FK reference-name matching ---- + +describe("deriveToName", () => { + test("default column name needs no to_name", () => { + expect(deriveToName("userId", "User")).toEqual({}); + }); + test("non-default column round-trips to a to_name", () => { + // target User, column authorId -> referenceName "author" yields authorId + expect(deriveToName("authorId", "User")).toEqual({ toName: "author" }); + }); + test("snake_case fk column round-trips", () => { + expect(deriveToName("author_id", "User")).toEqual({ toName: "author" }); + }); + test("column that cannot round-trip is flagged unmapped", () => { + const r = deriveToName("created_by", "User"); + expect(r.unmapped).toBe(true); + }); +}); + +// ---- primary key variants ---- + +describe("primary key handling", () => { + test("uuid id -> primaryKeyType uuid, id field omitted", () => { + const out = pgToApsorc( + schema([ + table("Account", { + primaryKey: ["id"], + columns: [col("id", "uuid"), col("name", "text")], + }), + ]) + ); + const e = findEntity(out, "Account"); + expect(e.primaryKeyType).toBe("uuid"); + expect(findField(e, "id")).toBeUndefined(); + expect(findField(e, "name")).toBeDefined(); + }); + + test("integer id -> default serial (no primaryKeyType), id omitted", () => { + const out = pgToApsorc( + schema([ + table("Widget", { + primaryKey: ["id"], + columns: [col("id", "int4"), col("label", "text")], + }), + ]) + ); + const e = findEntity(out, "Widget"); + expect(e.primaryKeyType).toBeUndefined(); + expect(findField(e, "id")).toBeUndefined(); + }); + + test("text id -> primaryKeyType text", () => { + const out = pgToApsorc( + schema([ + table("Slug", { primaryKey: ["id"], columns: [col("id", "text")] }), + ]) + ); + expect(findEntity(out, "Slug").primaryKeyType).toBe("text"); + }); + + test("non-id single PK is emitted as a primary field + warning", () => { + const out = pgToApsorc( + schema([ + table("Country", { + primaryKey: ["code"], + columns: [col("code", "varchar"), col("name", "text")], + }), + ]) + ); + const e = findEntity(out, "Country"); + expect(findField(e, "code").primary).toBe(true); + expect(out.report.warnings.nonStandardPks).toContain("Country"); + }); + + test("composite PK -> each column primary:true + warning", () => { + const out = pgToApsorc( + schema([ + table("Membership", { + primaryKey: ["orgId", "userId"], + columns: [col("orgId", "int4"), col("userId", "int4")], + }), + ]) + ); + const e = findEntity(out, "Membership"); + expect(findField(e, "orgId").primary).toBe(true); + expect(findField(e, "userId").primary).toBe(true); + expect(out.report.warnings.compositePks).toContain("Membership"); + }); + + test("no PK -> warning, all columns emitted", () => { + const out = pgToApsorc( + schema([table("Log", { columns: [col("message", "text")] })]) + ); + expect(out.report.warnings.noPrimaryKey).toContain("Log"); + expect(findField(findEntity(out, "Log"), "message")).toBeDefined(); + }); +}); + +// ---- created_at / updated_at ---- + +describe("timestamp metadata columns", () => { + test("created_at/updated_at timestamps become entity booleans and are omitted", () => { + const out = pgToApsorc( + schema([ + table("Post", { + primaryKey: ["id"], + columns: [ + col("id", "uuid"), + col("created_at", "timestamptz"), + col("updated_at", "timestamptz"), + col("title", "text"), + ], + }), + ]) + ); + const e = findEntity(out, "Post"); + expect(e.created_at).toBe(true); + expect(e.updated_at).toBe(true); + expect(findField(e, "created_at")).toBeUndefined(); + expect(findField(e, "updated_at")).toBeUndefined(); + }); + + test("created_at that is not a timestamp stays a field", () => { + const out = pgToApsorc( + schema([ + table("Weird", { + primaryKey: ["id"], + columns: [col("id", "uuid"), col("created_at", "text")], + }), + ]) + ); + const e = findEntity(out, "Weird"); + expect(e.created_at).toBeUndefined(); + expect(findField(e, "created_at")).toBeDefined(); + }); +}); + +// ---- foreign keys ---- + +describe("foreign keys", () => { + test("FK -> ManyToOne with default column name (no to_name), column omitted", () => { + const out = pgToApsorc( + schema([ + table("User", { primaryKey: ["id"], columns: [col("id", "uuid")] }), + table("Post", { + primaryKey: ["id"], + columns: [col("id", "uuid"), col("userId", "uuid", { nullable: true })], + foreignKeys: [ + { + columns: ["userId"], + referencedTable: "User", + referencedColumns: ["id"], + onDelete: "CASCADE", + }, + ], + }), + ]) + ); + const rel = out.apsorc.relationships[0]; + expect(rel).toMatchObject({ + from: "Post", + to: "User", + type: "ManyToOne", + nullable: true, + cascadeDelete: true, + }); + expect(rel.to_name).toBeUndefined(); + // FK column is materialized by the relationship, not a scalar field. + expect(findField(findEntity(out, "Post"), "userId")).toBeUndefined(); + }); + + test("non-default FK column -> to_name", () => { + const out = pgToApsorc( + schema([ + table("User", { primaryKey: ["id"], columns: [col("id", "uuid")] }), + table("Post", { + primaryKey: ["id"], + columns: [col("id", "uuid"), col("authorId", "uuid")], + foreignKeys: [ + { + columns: ["authorId"], + referencedTable: "User", + referencedColumns: ["id"], + onDelete: "NO ACTION", + }, + ], + }), + ]) + ); + expect(out.apsorc.relationships[0].to_name).toBe("author"); + }); + + test("composite FK -> columns kept, no relationship, warning", () => { + const out = pgToApsorc( + schema([ + table("Order", { + primaryKey: ["id"], + columns: [col("id", "uuid"), col("aId", "int4"), col("bId", "int4")], + foreignKeys: [ + { + columns: ["aId", "bId"], + referencedTable: "Pair", + referencedColumns: ["a", "b"], + onDelete: "NO ACTION", + }, + ], + }), + ]) + ); + expect(out.apsorc.relationships).toHaveLength(0); + expect(out.report.warnings.compositeFks).toContain("Order"); + expect(findField(findEntity(out, "Order"), "aId")).toBeDefined(); + }); +}); + +// ---- join tables ---- + +describe("join tables", () => { + test("pure join table -> entity + two ManyToOne + detected", () => { + const out = pgToApsorc( + schema([ + table("Student", { primaryKey: ["id"], columns: [col("id", "uuid")] }), + table("Course", { primaryKey: ["id"], columns: [col("id", "uuid")] }), + table("enrollment", { + primaryKey: ["studentId", "courseId"], + columns: [col("studentId", "uuid"), col("courseId", "uuid")], + foreignKeys: [ + { columns: ["studentId"], referencedTable: "Student", referencedColumns: ["id"], onDelete: "CASCADE" }, + { columns: ["courseId"], referencedTable: "Course", referencedColumns: ["id"], onDelete: "CASCADE" }, + ], + }), + ]) + ); + expect(out.report.warnings.joinTablesDetected).toContain("enrollment"); + const rels = out.apsorc.relationships.filter((r) => r.from === "enrollment"); + expect(rels).toHaveLength(2); + }); +}); + +// ---- enums ---- + +function enumSchema(defaultExpr: string | null): IntrospectedSchema { + return schema( + [ + table("Order", { + primaryKey: ["id"], + columns: [ + col("id", "uuid"), + col("status", "order_status", { + dataType: "USER-DEFINED", + isEnum: true, + enumTypeName: "order_status", + default: defaultExpr, + }), + ], + }), + ], + { enums: [{ name: "order_status", labels: ["pending", "shipped"] }] } + ); +} + +describe("enums", () => { + test("enum column carries values", () => { + const out = pgToApsorc(enumSchema(null)); + const f = findField(findEntity(out, "Order"), "status"); + expect(f.type).toBe("enum"); + expect(f.values).toEqual(["pending", "shipped"]); + }); + + test("valid enum default kept", () => { + const out = pgToApsorc(enumSchema("'pending'::order_status")); + expect(findField(findEntity(out, "Order"), "status").default).toBe("pending"); + }); + + test("invalid enum default dropped + warned", () => { + const out = pgToApsorc(enumSchema("'bogus'::order_status")); + expect(findField(findEntity(out, "Order"), "status").default).toBeUndefined(); + expect(out.report.warnings.defaultsDropped).toContain("Order.status"); + }); +}); + +// ---- unique / index ---- + +describe("unique and index", () => { + test("single-col unique/index set on field; multi-col go to entity", () => { + const out = pgToApsorc( + schema([ + table("User", { + primaryKey: ["id"], + columns: [ + col("id", "uuid"), + col("email", "varchar", { charMaxLength: 255 }), + col("a", "int4"), + col("b", "int4"), + ], + uniqueConstraints: [ + { name: "uq_email", columns: ["email"] }, + { name: "uq_ab", columns: ["a", "b"] }, + ], + indexes: [ + { name: "ix_a", columns: ["a"], unique: false }, + { name: "ix_eb", columns: ["email", "b"], unique: false }, + ], + }), + ]) + ); + const e = findEntity(out, "User"); + expect(findField(e, "email").unique).toBe(true); + expect(findField(e, "email").length).toBe(255); + expect(findField(e, "a").index).toBe(true); + expect(e.uniques).toEqual([{ fields: ["a", "b"], name: "uq_ab" }]); + expect(e.indexes).toEqual([{ fields: ["email", "b"], unique: false }]); + }); + + test("index backing a unique constraint is not duplicated", () => { + const out = pgToApsorc( + schema([ + table("User", { + primaryKey: ["id"], + columns: [col("id", "uuid"), col("email", "varchar")], + uniqueConstraints: [{ name: "uq_email", columns: ["email"] }], + indexes: [{ name: "uq_email", columns: ["email"], unique: true }], + }), + ]) + ); + const e = findEntity(out, "User"); + expect(findField(e, "email").unique).toBe(true); + expect(findField(e, "email").index).toBeUndefined(); + }); +}); + +// ---- numeric precision/scale ---- + +test("numeric carries precision and scale", () => { + const out = pgToApsorc( + schema([ + table("Money", { + primaryKey: ["id"], + columns: [ + col("id", "uuid"), + col("amount", "numeric", { numericPrecision: 12, numericScale: 4 }), + ], + }), + ]) + ); + const f = findField(findEntity(out, "Money"), "amount"); + expect(f.precision).toBe(12); + expect(f.scale).toBe(4); +}); + +// ---- invariants ---- + +describe("invariants", () => { + const broadSchema = schema( + [ + table("User", { + primaryKey: ["id"], + columns: [ + col("id", "uuid"), + col("name", "varchar", { charMaxLength: 100 }), + col("age", "int4", { nullable: true }), + col("balance", "numeric", { numericPrecision: 10, numericScale: 2 }), + col("active", "bool", { default: "true" }), + col("tags", "_text", { dataType: "ARRAY", nullable: true }), + col("created_at", "timestamptz"), + ], + }), + table("Post", { + primaryKey: ["id"], + columns: [col("id", "uuid"), col("authorId", "uuid")], + foreignKeys: [ + { columns: ["authorId"], referencedTable: "User", referencedColumns: ["id"], onDelete: "CASCADE" }, + ], + }), + ], + { skipped: { views: ["active_users"], systemSchemas: ["auth", "storage"] } } + ); + + test("every emitted field type is a recognized column type", () => { + const out = pgToApsorc(broadSchema); + expect(findUnknownEmittedTypes(out.apsorc)).toEqual([]); + }); + + test("generated relationships parse cleanly", () => { + const out = pgToApsorc(broadSchema); + expect(() => + parseRelationships(out.apsorc.relationships as ApsorcRelationship[]) + ).not.toThrow(); + }); + + test("top-level shape matches .apsorc v2", () => { + const out = pgToApsorc(broadSchema); + expect(out.apsorc).toMatchObject({ + version: 2, + rootFolder: "src", + apiType: "rest", + }); + expect(out.report.viewsSkipped).toContain("active_users"); + expect(out.report.systemSchemasSkipped).toEqual(["auth", "storage"]); + }); +});