@apso/cli 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/import/data.d.ts +22 -0
- package/dist/commands/import/data.js +158 -0
- package/dist/commands/import/supabase.d.ts +42 -0
- package/dist/commands/import/supabase.js +196 -0
- package/dist/lib/import/copy-data.d.ts +106 -0
- package/dist/lib/import/copy-data.js +266 -0
- package/dist/lib/import/index.d.ts +7 -0
- package/dist/lib/import/index.js +25 -0
- package/dist/lib/import/introspect.d.ts +42 -0
- package/dist/lib/import/introspect.js +327 -0
- package/dist/lib/import/pg-data.d.ts +33 -0
- package/dist/lib/import/pg-data.js +112 -0
- package/dist/lib/import/pg-to-apsorc.d.ts +104 -0
- package/dist/lib/import/pg-to-apsorc.js +399 -0
- package/dist/lib/import/types.d.ts +115 -0
- package/dist/lib/import/types.js +11 -0
- package/npm-shrinkwrap.json +153 -3
- package/oclif.manifest.json +118 -1
- package/package.json +6 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Import — pg-backed data access for the copy executor.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrappers around `pg` implementing the SourceReader / TargetWriter
|
|
5
|
+
* interfaces from copy-data.ts. `pg` is imported lazily and SSL is enabled by
|
|
6
|
+
* default (Supabase requires it). Integration-level: covered by manual/E2E
|
|
7
|
+
* runs, while the copy logic itself is unit-tested with fakes.
|
|
8
|
+
*/
|
|
9
|
+
import { SourceReader, TargetWriter } from "./copy-data";
|
|
10
|
+
/** Reads rows from the source database (read-only). */
|
|
11
|
+
export declare class PgSourceReader implements SourceReader {
|
|
12
|
+
private readonly client;
|
|
13
|
+
private readonly schema;
|
|
14
|
+
private constructor();
|
|
15
|
+
static connect(connectionString: string, schema: string): Promise<PgSourceReader>;
|
|
16
|
+
totalRows(table: string): Promise<number>;
|
|
17
|
+
readBatch(table: string, columns: string[], orderBy: string | null, offset: number, limit: number): Promise<unknown[][]>;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Writes rows into the target database within a single transaction. */
|
|
21
|
+
export declare class PgTargetWriter implements TargetWriter {
|
|
22
|
+
private readonly client;
|
|
23
|
+
private readonly schema;
|
|
24
|
+
private constructor();
|
|
25
|
+
static connect(connectionString: string, schema: string): Promise<PgTargetWriter>;
|
|
26
|
+
rowCount(table: string): Promise<number>;
|
|
27
|
+
insertBatch(table: string, columns: string[], rows: unknown[][]): Promise<void>;
|
|
28
|
+
resetSequence(table: string, column: string): Promise<void>;
|
|
29
|
+
begin(): Promise<void>;
|
|
30
|
+
commit(): Promise<void>;
|
|
31
|
+
rollback(): Promise<void>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Database Import — pg-backed data access for the copy executor.
|
|
4
|
+
*
|
|
5
|
+
* Thin wrappers around `pg` implementing the SourceReader / TargetWriter
|
|
6
|
+
* interfaces from copy-data.ts. `pg` is imported lazily and SSL is enabled by
|
|
7
|
+
* default (Supabase requires it). Integration-level: covered by manual/E2E
|
|
8
|
+
* runs, while the copy logic itself is unit-tested with fakes.
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
27
|
+
if (mod && mod.__esModule) return mod;
|
|
28
|
+
var result = {};
|
|
29
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
30
|
+
__setModuleDefault(result, mod);
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.PgTargetWriter = exports.PgSourceReader = void 0;
|
|
35
|
+
async function createClient(connectionString) {
|
|
36
|
+
var _a, _b;
|
|
37
|
+
const pg = await Promise.resolve().then(() => __importStar(require("pg")));
|
|
38
|
+
const Client = (_a = pg.Client) !== null && _a !== void 0 ? _a : (_b = pg.default) === null || _b === void 0 ? void 0 : _b.Client;
|
|
39
|
+
const client = new Client({
|
|
40
|
+
connectionString,
|
|
41
|
+
ssl: { rejectUnauthorized: false },
|
|
42
|
+
});
|
|
43
|
+
await client.connect();
|
|
44
|
+
return client;
|
|
45
|
+
}
|
|
46
|
+
function qualified(schema, table) {
|
|
47
|
+
return `"${schema}"."${table}"`;
|
|
48
|
+
}
|
|
49
|
+
/** Reads rows from the source database (read-only). */
|
|
50
|
+
class PgSourceReader {
|
|
51
|
+
constructor(client, schema) {
|
|
52
|
+
this.client = client;
|
|
53
|
+
this.schema = schema;
|
|
54
|
+
}
|
|
55
|
+
static async connect(connectionString, schema) {
|
|
56
|
+
return new PgSourceReader(await createClient(connectionString), schema);
|
|
57
|
+
}
|
|
58
|
+
async totalRows(table) {
|
|
59
|
+
const res = await this.client.query(`SELECT count(*)::int AS n FROM ${qualified(this.schema, table)}`);
|
|
60
|
+
return res.rows[0].n;
|
|
61
|
+
}
|
|
62
|
+
async readBatch(table, columns, orderBy, offset, limit) {
|
|
63
|
+
const cols = columns.map((c) => `"${c}"`).join(", ");
|
|
64
|
+
const order = orderBy ? ` ORDER BY "${orderBy}"` : "";
|
|
65
|
+
const res = await this.client.query(`SELECT ${cols} FROM ${qualified(this.schema, table)}${order} OFFSET $1 LIMIT $2`, [offset, limit]);
|
|
66
|
+
return res.rows.map((row) => columns.map((c) => row[c]));
|
|
67
|
+
}
|
|
68
|
+
async close() {
|
|
69
|
+
await this.client.end();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.PgSourceReader = PgSourceReader;
|
|
73
|
+
/** Writes rows into the target database within a single transaction. */
|
|
74
|
+
class PgTargetWriter {
|
|
75
|
+
constructor(client, schema) {
|
|
76
|
+
this.client = client;
|
|
77
|
+
this.schema = schema;
|
|
78
|
+
}
|
|
79
|
+
static async connect(connectionString, schema) {
|
|
80
|
+
return new PgTargetWriter(await createClient(connectionString), schema);
|
|
81
|
+
}
|
|
82
|
+
async rowCount(table) {
|
|
83
|
+
const res = await this.client.query(`SELECT count(*)::int AS n FROM ${qualified(this.schema, table)}`);
|
|
84
|
+
return res.rows[0].n;
|
|
85
|
+
}
|
|
86
|
+
async insertBatch(table, columns, rows) {
|
|
87
|
+
const { buildInsertSql } = await Promise.resolve().then(() => __importStar(require("./copy-data")));
|
|
88
|
+
const { text, values } = buildInsertSql(table, columns, rows, this.schema);
|
|
89
|
+
await this.client.query(text, values);
|
|
90
|
+
}
|
|
91
|
+
async resetSequence(table, column) {
|
|
92
|
+
// Advance the column's owned sequence past the max value just inserted.
|
|
93
|
+
await this.client.query(`SELECT setval(
|
|
94
|
+
pg_get_serial_sequence($1, $2),
|
|
95
|
+
(SELECT COALESCE(MAX("${column}"), 0) FROM ${qualified(this.schema, table)}),
|
|
96
|
+
true
|
|
97
|
+
)`, [`${this.schema}.${table}`, column]);
|
|
98
|
+
}
|
|
99
|
+
async begin() {
|
|
100
|
+
await this.client.query("BEGIN");
|
|
101
|
+
}
|
|
102
|
+
async commit() {
|
|
103
|
+
await this.client.query("COMMIT");
|
|
104
|
+
}
|
|
105
|
+
async rollback() {
|
|
106
|
+
await this.client.query("ROLLBACK");
|
|
107
|
+
}
|
|
108
|
+
async close() {
|
|
109
|
+
await this.client.end();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
exports.PgTargetWriter = PgTargetWriter;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Import — conversion layer
|
|
3
|
+
*
|
|
4
|
+
* Turns an `IntrospectedSchema` (read from a live Postgres/Supabase database)
|
|
5
|
+
* into a `.apsorc`-shaped object. This is a pure function: same input always
|
|
6
|
+
* yields the same output and report, with no I/O — which keeps it exhaustively
|
|
7
|
+
* unit-testable.
|
|
8
|
+
*
|
|
9
|
+
* The reverse type map here is the inverse of `fieldTypeToColumnType`
|
|
10
|
+
* (src/lib/utils/field.ts). The FK-to-relationship logic mirrors how the
|
|
11
|
+
* platform derives FK column names in `getRelationshipIdField`
|
|
12
|
+
* (src/lib/utils/relationships/parse.ts) so imported relationships round-trip.
|
|
13
|
+
*/
|
|
14
|
+
import { ImportReport, IntrospectedColumn, IntrospectedSchema } from "./types";
|
|
15
|
+
export interface ApsorcImportFieldOutput {
|
|
16
|
+
name: string;
|
|
17
|
+
type: string;
|
|
18
|
+
nullable?: boolean;
|
|
19
|
+
unique?: boolean;
|
|
20
|
+
default?: unknown;
|
|
21
|
+
index?: boolean;
|
|
22
|
+
values?: string[];
|
|
23
|
+
length?: number;
|
|
24
|
+
precision?: number;
|
|
25
|
+
scale?: number;
|
|
26
|
+
primary?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface ApsorcImportEntityOutput {
|
|
29
|
+
name: string;
|
|
30
|
+
primaryKeyType?: "serial" | "uuid" | "text";
|
|
31
|
+
created_at?: boolean;
|
|
32
|
+
updated_at?: boolean;
|
|
33
|
+
fields?: ApsorcImportFieldOutput[];
|
|
34
|
+
uniques?: Array<{
|
|
35
|
+
fields: string[];
|
|
36
|
+
name?: string;
|
|
37
|
+
}>;
|
|
38
|
+
indexes?: Array<{
|
|
39
|
+
fields: string[];
|
|
40
|
+
unique?: boolean;
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
export interface ApsorcImportRelationshipOutput {
|
|
44
|
+
from: string;
|
|
45
|
+
to: string;
|
|
46
|
+
type: "OneToMany" | "ManyToOne" | "ManyToMany" | "OneToOne";
|
|
47
|
+
to_name?: string;
|
|
48
|
+
nullable?: boolean;
|
|
49
|
+
bi_directional?: boolean;
|
|
50
|
+
cascadeDelete?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface ApsorcImportOutput {
|
|
53
|
+
version: number;
|
|
54
|
+
rootFolder: string;
|
|
55
|
+
apiType: string;
|
|
56
|
+
entities: ApsorcImportEntityOutput[];
|
|
57
|
+
relationships: ApsorcImportRelationshipOutput[];
|
|
58
|
+
}
|
|
59
|
+
interface TypeResolution {
|
|
60
|
+
type: string;
|
|
61
|
+
/** True when the mapping loses information (arrays, unknown types). */
|
|
62
|
+
lossy?: "array" | "defaulted";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a column's `.apsorc` field type from its Postgres type. Postgres
|
|
66
|
+
* arrays (udt_name starting with "_") collapse to `array`; user enums become
|
|
67
|
+
* `enum`; unknown types fall back to `text`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function pgTypeToApsorcType(col: IntrospectedColumn): TypeResolution;
|
|
70
|
+
/**
|
|
71
|
+
* Translate a raw Postgres default expression into an `.apsorc` default value.
|
|
72
|
+
* Returns `{ drop: true }` when the default cannot be represented (function
|
|
73
|
+
* calls, sequence/uuid generators). Auto-timestamp and sequence defaults are
|
|
74
|
+
* dropped silently by callers that handle them structurally.
|
|
75
|
+
*/
|
|
76
|
+
export declare function parseDefault(raw: string | null, apsorcType: string): {
|
|
77
|
+
value?: unknown;
|
|
78
|
+
drop?: boolean;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Derive the relationship `to_name` so the platform regenerates a FK column
|
|
82
|
+
* matching the source column. Returns `undefined` when the default naming
|
|
83
|
+
* (`${camelCase(target)}Id`) already produces the source column. Sets
|
|
84
|
+
* `unmapped: true` when no name round-trips to the source column.
|
|
85
|
+
*/
|
|
86
|
+
export declare function deriveToName(sourceColumn: string, targetTable: string): {
|
|
87
|
+
toName?: string;
|
|
88
|
+
unmapped?: boolean;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Convert an introspected Postgres schema into a `.apsorc`-shaped object plus a
|
|
92
|
+
* report of everything that could not be represented losslessly.
|
|
93
|
+
*/
|
|
94
|
+
export declare function pgToApsorc(schema: IntrospectedSchema): {
|
|
95
|
+
apsorc: ApsorcImportOutput;
|
|
96
|
+
report: ImportReport;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Invariant guard: every emitted field type must be a recognized `.apsorc`
|
|
100
|
+
* column type, so code generation never silently falls back to varchar.
|
|
101
|
+
* Returns the list of any offending types (empty when valid).
|
|
102
|
+
*/
|
|
103
|
+
export declare function findUnknownEmittedTypes(output: ApsorcImportOutput): string[];
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Database Import — conversion layer
|
|
4
|
+
*
|
|
5
|
+
* Turns an `IntrospectedSchema` (read from a live Postgres/Supabase database)
|
|
6
|
+
* into a `.apsorc`-shaped object. This is a pure function: same input always
|
|
7
|
+
* yields the same output and report, with no I/O — which keeps it exhaustively
|
|
8
|
+
* unit-testable.
|
|
9
|
+
*
|
|
10
|
+
* The reverse type map here is the inverse of `fieldTypeToColumnType`
|
|
11
|
+
* (src/lib/utils/field.ts). The FK-to-relationship logic mirrors how the
|
|
12
|
+
* platform derives FK column names in `getRelationshipIdField`
|
|
13
|
+
* (src/lib/utils/relationships/parse.ts) so imported relationships round-trip.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.findUnknownEmittedTypes = exports.pgToApsorc = exports.deriveToName = exports.parseDefault = exports.pgTypeToApsorcType = void 0;
|
|
17
|
+
const casing_1 = require("../utils/casing");
|
|
18
|
+
const field_1 = require("../utils/field");
|
|
19
|
+
const relationships_1 = require("../utils/relationships");
|
|
20
|
+
/** Maps a Postgres udt_name to the closest `.apsorc` field type. */
|
|
21
|
+
const udtToApsorcType = {
|
|
22
|
+
int2: "smallint",
|
|
23
|
+
int4: "integer",
|
|
24
|
+
int8: "bigint",
|
|
25
|
+
float4: "real",
|
|
26
|
+
float8: "double",
|
|
27
|
+
numeric: "numeric",
|
|
28
|
+
money: "money",
|
|
29
|
+
bool: "boolean",
|
|
30
|
+
varchar: "varchar",
|
|
31
|
+
bpchar: "char",
|
|
32
|
+
text: "text",
|
|
33
|
+
uuid: "uuid",
|
|
34
|
+
json: "json",
|
|
35
|
+
jsonb: "jsonb",
|
|
36
|
+
date: "date",
|
|
37
|
+
timestamp: "timestamp",
|
|
38
|
+
timestamptz: "timestamptz",
|
|
39
|
+
time: "time",
|
|
40
|
+
timetz: "timetz",
|
|
41
|
+
bytea: "bytea",
|
|
42
|
+
xml: "xml",
|
|
43
|
+
inet: "inet",
|
|
44
|
+
cidr: "inet",
|
|
45
|
+
interval: "interval",
|
|
46
|
+
tsvector: "tsvector",
|
|
47
|
+
int4range: "int4range",
|
|
48
|
+
// PostGIS — pass-through (these are also keys in fieldTypeToColumnType)
|
|
49
|
+
point: "point",
|
|
50
|
+
geometry: "geometry",
|
|
51
|
+
geography: "geography",
|
|
52
|
+
};
|
|
53
|
+
const TIMESTAMP_UDTS = new Set(["timestamp", "timestamptz"]);
|
|
54
|
+
const AUTO_TIMESTAMP_DEFAULTS = new Set([
|
|
55
|
+
"now()",
|
|
56
|
+
"current_timestamp",
|
|
57
|
+
"transaction_timestamp()",
|
|
58
|
+
"clock_timestamp()",
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a column's `.apsorc` field type from its Postgres type. Postgres
|
|
62
|
+
* arrays (udt_name starting with "_") collapse to `array`; user enums become
|
|
63
|
+
* `enum`; unknown types fall back to `text`.
|
|
64
|
+
*/
|
|
65
|
+
function pgTypeToApsorcType(col) {
|
|
66
|
+
if (col.isEnum) {
|
|
67
|
+
return { type: "enum" };
|
|
68
|
+
}
|
|
69
|
+
if (col.dataType === "ARRAY" || col.udtName.startsWith("_")) {
|
|
70
|
+
return { type: "array", lossy: "array" };
|
|
71
|
+
}
|
|
72
|
+
const mapped = udtToApsorcType[col.udtName];
|
|
73
|
+
if (mapped) {
|
|
74
|
+
return { type: mapped };
|
|
75
|
+
}
|
|
76
|
+
return { type: "text", lossy: "defaulted" };
|
|
77
|
+
}
|
|
78
|
+
exports.pgTypeToApsorcType = pgTypeToApsorcType;
|
|
79
|
+
/**
|
|
80
|
+
* Translate a raw Postgres default expression into an `.apsorc` default value.
|
|
81
|
+
* Returns `{ drop: true }` when the default cannot be represented (function
|
|
82
|
+
* calls, sequence/uuid generators). Auto-timestamp and sequence defaults are
|
|
83
|
+
* dropped silently by callers that handle them structurally.
|
|
84
|
+
*/
|
|
85
|
+
function parseDefault(raw, apsorcType) {
|
|
86
|
+
if (raw === null)
|
|
87
|
+
return {};
|
|
88
|
+
const expr = raw.trim();
|
|
89
|
+
const lower = expr.toLowerCase();
|
|
90
|
+
// Sequence / generator / function defaults can't be a literal .apsorc value.
|
|
91
|
+
if (lower.startsWith("nextval(") ||
|
|
92
|
+
lower.startsWith("gen_random_uuid(") ||
|
|
93
|
+
lower.startsWith("uuid_generate_v4(") ||
|
|
94
|
+
AUTO_TIMESTAMP_DEFAULTS.has(lower)) {
|
|
95
|
+
return { drop: true };
|
|
96
|
+
}
|
|
97
|
+
// Strip a trailing ::type cast, e.g. 'active'::text or 0::integer.
|
|
98
|
+
const withoutCast = expr.replace(/::[\s\w".[\]]+$/, "").trim();
|
|
99
|
+
// Quoted string literal.
|
|
100
|
+
if (withoutCast.startsWith("'") && withoutCast.endsWith("'")) {
|
|
101
|
+
const inner = withoutCast.slice(1, -1).replace(/''/g, "'");
|
|
102
|
+
return { value: inner };
|
|
103
|
+
}
|
|
104
|
+
if (lower === "true" || lower === "false") {
|
|
105
|
+
return { value: lower === "true" };
|
|
106
|
+
}
|
|
107
|
+
if (/^-?\d+(\.\d+)?$/.test(withoutCast)) {
|
|
108
|
+
if (apsorcType === "numeric" || apsorcType === "decimal") {
|
|
109
|
+
return { value: withoutCast };
|
|
110
|
+
}
|
|
111
|
+
return { value: Number(withoutCast) };
|
|
112
|
+
}
|
|
113
|
+
// Anything else (function calls, complex expressions) — drop with a warning.
|
|
114
|
+
return { drop: true };
|
|
115
|
+
}
|
|
116
|
+
exports.parseDefault = parseDefault;
|
|
117
|
+
/**
|
|
118
|
+
* Derive the relationship `to_name` so the platform regenerates a FK column
|
|
119
|
+
* matching the source column. Returns `undefined` when the default naming
|
|
120
|
+
* (`${camelCase(target)}Id`) already produces the source column. Sets
|
|
121
|
+
* `unmapped: true` when no name round-trips to the source column.
|
|
122
|
+
*/
|
|
123
|
+
function deriveToName(sourceColumn, targetTable) {
|
|
124
|
+
// The platform always generates camelCase FK columns, while Postgres/Supabase
|
|
125
|
+
// columns are typically snake_case. Compare on the camelCased form so a
|
|
126
|
+
// snake_case source that is semantically identical isn't flagged as a mismatch.
|
|
127
|
+
const source = (0, casing_1.camelCase)(sourceColumn);
|
|
128
|
+
const defaultColumn = (0, relationships_1.getRelationshipIdField)({
|
|
129
|
+
name: targetTable,
|
|
130
|
+
type: "ManyToOne",
|
|
131
|
+
});
|
|
132
|
+
if (defaultColumn === source) {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
// Strip a trailing Id / _id and use the remainder as the reference name.
|
|
136
|
+
const base = sourceColumn.replace(/(_id|Id)$/, "");
|
|
137
|
+
if (base && base !== sourceColumn) {
|
|
138
|
+
const candidate = (0, casing_1.camelCase)(base);
|
|
139
|
+
const roundTrip = (0, relationships_1.getRelationshipIdField)({
|
|
140
|
+
name: targetTable,
|
|
141
|
+
type: "ManyToOne",
|
|
142
|
+
referenceName: candidate,
|
|
143
|
+
});
|
|
144
|
+
if (roundTrip === source) {
|
|
145
|
+
return { toName: candidate };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Best-effort: emit a reference name but flag that the generated FK column
|
|
149
|
+
// may not match the source column exactly.
|
|
150
|
+
return { toName: (0, casing_1.camelCase)(base || sourceColumn), unmapped: true };
|
|
151
|
+
}
|
|
152
|
+
exports.deriveToName = deriveToName;
|
|
153
|
+
function emptyReport(schema) {
|
|
154
|
+
return {
|
|
155
|
+
tablesImported: [],
|
|
156
|
+
relationships: 0,
|
|
157
|
+
viewsSkipped: schema.skipped.views,
|
|
158
|
+
systemSchemasSkipped: schema.skipped.systemSchemas,
|
|
159
|
+
warnings: {
|
|
160
|
+
arraysLossy: [],
|
|
161
|
+
compositePks: [],
|
|
162
|
+
compositeFks: [],
|
|
163
|
+
nonStandardPks: [],
|
|
164
|
+
noPrimaryKey: [],
|
|
165
|
+
typesDefaulted: [],
|
|
166
|
+
fkColumnNameUnmapped: [],
|
|
167
|
+
defaultsDropped: [],
|
|
168
|
+
joinTablesDetected: [],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function isJoinTable(table) {
|
|
173
|
+
const singleColumnFkNames = table.foreignKeys
|
|
174
|
+
.filter((fk) => fk.columns.length === 1)
|
|
175
|
+
.map((fk) => fk.columns[0]);
|
|
176
|
+
return (table.foreignKeys.length === 2 &&
|
|
177
|
+
singleColumnFkNames.length === 2 &&
|
|
178
|
+
table.primaryKey.length === 2 &&
|
|
179
|
+
table.primaryKey.every((c) => singleColumnFkNames.includes(c)));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Convert an introspected Postgres schema into a `.apsorc`-shaped object plus a
|
|
183
|
+
* report of everything that could not be represented losslessly.
|
|
184
|
+
*/
|
|
185
|
+
function pgToApsorc(schema) {
|
|
186
|
+
const report = emptyReport(schema);
|
|
187
|
+
const enumLabels = new Map(schema.enums.map((e) => [e.name, e.labels]));
|
|
188
|
+
const entities = [];
|
|
189
|
+
const relationships = [];
|
|
190
|
+
for (const table of schema.tables) {
|
|
191
|
+
report.tablesImported.push(table.name);
|
|
192
|
+
if (isJoinTable(table)) {
|
|
193
|
+
report.warnings.joinTablesDetected.push(table.name);
|
|
194
|
+
}
|
|
195
|
+
const entity = { name: table.name };
|
|
196
|
+
// Columns covered by a single-column FK are materialized by the
|
|
197
|
+
// relationship, so they must not also be emitted as scalar fields.
|
|
198
|
+
const fkColumns = new Map();
|
|
199
|
+
for (const fk of table.foreignKeys) {
|
|
200
|
+
if (fk.columns.length === 1) {
|
|
201
|
+
fkColumns.set(fk.columns[0], fk);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
report.warnings.compositeFks.push(table.name);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// --- Primary key handling ---
|
|
208
|
+
const pk = table.primaryKey;
|
|
209
|
+
const pkSet = new Set(pk);
|
|
210
|
+
let pkAsField = false;
|
|
211
|
+
if (pk.length === 1) {
|
|
212
|
+
const pkCol = table.columns.find((c) => c.name === pk[0]);
|
|
213
|
+
if (pk[0] === "id" && pkCol) {
|
|
214
|
+
const t = pgTypeToApsorcType(pkCol).type;
|
|
215
|
+
if (t === "uuid")
|
|
216
|
+
entity.primaryKeyType = "uuid";
|
|
217
|
+
else if (t === "text" || t === "varchar")
|
|
218
|
+
entity.primaryKeyType = "text";
|
|
219
|
+
// integer/bigint/smallint id => default serial, omit primaryKeyType.
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Single, non-"id" PK: emit it as a primary field.
|
|
223
|
+
pkAsField = true;
|
|
224
|
+
report.warnings.nonStandardPks.push(table.name);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (pk.length > 1) {
|
|
228
|
+
pkAsField = true;
|
|
229
|
+
report.warnings.compositePks.push(table.name);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
report.warnings.noPrimaryKey.push(table.name);
|
|
233
|
+
}
|
|
234
|
+
// --- created_at / updated_at detection ---
|
|
235
|
+
const autoTimestamps = new Set();
|
|
236
|
+
for (const name of ["created_at", "updated_at"]) {
|
|
237
|
+
const col = table.columns.find((c) => c.name === name);
|
|
238
|
+
if (col && TIMESTAMP_UDTS.has(col.udtName)) {
|
|
239
|
+
entity[name] = true;
|
|
240
|
+
autoTimestamps.add(name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// --- Fields ---
|
|
244
|
+
const uniqueSingle = new Set();
|
|
245
|
+
const uniqueComposite = [];
|
|
246
|
+
for (const uc of table.uniqueConstraints) {
|
|
247
|
+
if (uc.columns.length === 1)
|
|
248
|
+
uniqueSingle.add(uc.columns[0]);
|
|
249
|
+
else
|
|
250
|
+
uniqueComposite.push({ fields: uc.columns, name: uc.name });
|
|
251
|
+
}
|
|
252
|
+
const indexSingle = new Set();
|
|
253
|
+
const indexComposite = [];
|
|
254
|
+
for (const idx of table.indexes) {
|
|
255
|
+
const cols = idx.columns;
|
|
256
|
+
// Skip indexes that merely back the PK or a unique constraint.
|
|
257
|
+
const backsPk = cols.length === pk.length && cols.every((c) => pkSet.has(c));
|
|
258
|
+
const backsUnique = table.uniqueConstraints.some((uc) => uc.columns.length === cols.length &&
|
|
259
|
+
uc.columns.every((c) => cols.includes(c)));
|
|
260
|
+
if (backsPk || backsUnique)
|
|
261
|
+
continue;
|
|
262
|
+
if (cols.length === 1)
|
|
263
|
+
indexSingle.add(cols[0]);
|
|
264
|
+
else
|
|
265
|
+
indexComposite.push({ fields: cols, unique: idx.unique });
|
|
266
|
+
}
|
|
267
|
+
const fields = [];
|
|
268
|
+
const sortedColumns = [...table.columns].sort((a, b) => a.ordinal - b.ordinal);
|
|
269
|
+
for (const col of sortedColumns) {
|
|
270
|
+
// Skip columns represented elsewhere.
|
|
271
|
+
if (fkColumns.has(col.name))
|
|
272
|
+
continue;
|
|
273
|
+
if (autoTimestamps.has(col.name))
|
|
274
|
+
continue;
|
|
275
|
+
if (pk.length === 1 && col.name === pk[0] && !pkAsField)
|
|
276
|
+
continue;
|
|
277
|
+
const resolved = pgTypeToApsorcType(col);
|
|
278
|
+
if (resolved.lossy === "array") {
|
|
279
|
+
report.warnings.arraysLossy.push(`${table.name}.${col.name}`);
|
|
280
|
+
}
|
|
281
|
+
else if (resolved.lossy === "defaulted") {
|
|
282
|
+
report.warnings.typesDefaulted.push({
|
|
283
|
+
column: `${table.name}.${col.name}`,
|
|
284
|
+
udt: col.udtName,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const field = {
|
|
288
|
+
name: col.name,
|
|
289
|
+
type: resolved.type,
|
|
290
|
+
};
|
|
291
|
+
if (col.nullable)
|
|
292
|
+
field.nullable = true;
|
|
293
|
+
if (uniqueSingle.has(col.name))
|
|
294
|
+
field.unique = true;
|
|
295
|
+
if (indexSingle.has(col.name))
|
|
296
|
+
field.index = true;
|
|
297
|
+
if (pkSet.has(col.name) && pkAsField)
|
|
298
|
+
field.primary = true;
|
|
299
|
+
if ((resolved.type === "varchar" || resolved.type === "char") && col.charMaxLength)
|
|
300
|
+
field.length = col.charMaxLength;
|
|
301
|
+
if (resolved.type === "numeric" || resolved.type === "decimal") {
|
|
302
|
+
if (col.numericPrecision)
|
|
303
|
+
field.precision = col.numericPrecision;
|
|
304
|
+
if (col.numericScale !== null)
|
|
305
|
+
field.scale = col.numericScale;
|
|
306
|
+
}
|
|
307
|
+
if (resolved.type === "enum") {
|
|
308
|
+
const labels = col.enumTypeName
|
|
309
|
+
? enumLabels.get(col.enumTypeName)
|
|
310
|
+
: undefined;
|
|
311
|
+
if (labels)
|
|
312
|
+
field.values = labels;
|
|
313
|
+
}
|
|
314
|
+
// Defaults (skip for PK columns — those are implicit/sequence-driven).
|
|
315
|
+
if (!(pkSet.has(col.name) && !pkAsField)) {
|
|
316
|
+
const parsed = parseDefault(col.default, resolved.type);
|
|
317
|
+
if (parsed.drop && col.default !== null) {
|
|
318
|
+
report.warnings.defaultsDropped.push(`${table.name}.${col.name}`);
|
|
319
|
+
}
|
|
320
|
+
else if ("value" in parsed) {
|
|
321
|
+
if (resolved.type === "enum") {
|
|
322
|
+
// Only keep an enum default that is one of the allowed labels.
|
|
323
|
+
if (field.values && field.values.includes(String(parsed.value))) {
|
|
324
|
+
field.default = parsed.value;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
report.warnings.defaultsDropped.push(`${table.name}.${col.name}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
field.default = parsed.value;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
fields.push(field);
|
|
336
|
+
}
|
|
337
|
+
if (fields.length > 0)
|
|
338
|
+
entity.fields = fields;
|
|
339
|
+
if (uniqueComposite.length > 0)
|
|
340
|
+
entity.uniques = uniqueComposite;
|
|
341
|
+
if (indexComposite.length > 0)
|
|
342
|
+
entity.indexes = indexComposite;
|
|
343
|
+
entities.push(entity);
|
|
344
|
+
// --- Foreign keys -> ManyToOne relationships ---
|
|
345
|
+
for (const fk of table.foreignKeys) {
|
|
346
|
+
if (fk.columns.length !== 1)
|
|
347
|
+
continue; // composite FK handled above
|
|
348
|
+
const sourceColumn = fk.columns[0];
|
|
349
|
+
const col = table.columns.find((c) => c.name === sourceColumn);
|
|
350
|
+
const rel = {
|
|
351
|
+
from: table.name,
|
|
352
|
+
to: fk.referencedTable,
|
|
353
|
+
type: "ManyToOne",
|
|
354
|
+
};
|
|
355
|
+
const { toName, unmapped } = deriveToName(sourceColumn, fk.referencedTable);
|
|
356
|
+
if (toName)
|
|
357
|
+
rel.to_name = toName;
|
|
358
|
+
if (unmapped) {
|
|
359
|
+
report.warnings.fkColumnNameUnmapped.push(`${table.name}.${sourceColumn}`);
|
|
360
|
+
}
|
|
361
|
+
if (col === null || col === void 0 ? void 0 : col.nullable)
|
|
362
|
+
rel.nullable = true;
|
|
363
|
+
if (fk.onDelete === "CASCADE")
|
|
364
|
+
rel.cascadeDelete = true;
|
|
365
|
+
relationships.push(rel);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
report.relationships = relationships.length;
|
|
369
|
+
return {
|
|
370
|
+
apsorc: {
|
|
371
|
+
version: 2,
|
|
372
|
+
rootFolder: "src",
|
|
373
|
+
apiType: "rest",
|
|
374
|
+
entities,
|
|
375
|
+
relationships,
|
|
376
|
+
},
|
|
377
|
+
report,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
exports.pgToApsorc = pgToApsorc;
|
|
381
|
+
/**
|
|
382
|
+
* Invariant guard: every emitted field type must be a recognized `.apsorc`
|
|
383
|
+
* column type, so code generation never silently falls back to varchar.
|
|
384
|
+
* Returns the list of any offending types (empty when valid).
|
|
385
|
+
*/
|
|
386
|
+
function findUnknownEmittedTypes(output) {
|
|
387
|
+
var _a;
|
|
388
|
+
const unknown = [];
|
|
389
|
+
for (const entity of output.entities) {
|
|
390
|
+
for (const field of (_a = entity.fields) !== null && _a !== void 0 ? _a : []) {
|
|
391
|
+
if (field.type === "enum")
|
|
392
|
+
continue; // enum dispatched separately
|
|
393
|
+
if (!(field.type in field_1.fieldTypeToColumnType))
|
|
394
|
+
unknown.push(field.type);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return unknown;
|
|
398
|
+
}
|
|
399
|
+
exports.findUnknownEmittedTypes = findUnknownEmittedTypes;
|