shelving
Version:
Toolkit for using data in JavaScript.
208 lines (207 loc) • 9.31 kB
JavaScript
import { ArraySchema } from "../../schema/ArraySchema.js";
import { BooleanSchema } from "../../schema/BooleanSchema.js";
import { ChoiceSchema } from "../../schema/ChoiceSchema.js";
import { DataSchema } from "../../schema/DataSchema.js";
import { DateSchema } from "../../schema/DateSchema.js";
import { DateTimeSchema } from "../../schema/DateTimeSchema.js";
import { DictionarySchema } from "../../schema/DictionarySchema.js";
import { NullableSchema } from "../../schema/NullableSchema.js";
import { NumberSchema } from "../../schema/NumberSchema.js";
import { StringSchema } from "../../schema/StringSchema.js";
import { TimeSchema } from "../../schema/TimeSchema.js";
import { getSource } from "../../util/source.js";
import { SQLMigrator } from "./SQLMigrator.js";
const INT4_MAX = 2147483647;
const INT4_MIN = -2147483648;
const COMPATIBLE_NUMBER_TYPES = ["integer", "bigint", "numeric", "int", "int4", "int8"];
const COMPATIBLE_STRING_TYPES = ["character varying", "varchar", "text", "char", "character"];
/** PostgreSQL migrator using pg_catalog-style schema inspection. */
export class PostgreSQLMigrator extends SQLMigrator {
async getTables() {
const rows = await this.provider.exec `
SELECT c.relname AS ${this.provider.sqlIdentifier("name")}
FROM ${this.provider.sqlIdentifier("pg_class")} c
JOIN ${this.provider.sqlIdentifier("pg_namespace")} n ON n.oid = c.relnamespace
WHERE c.relkind = ${"r"} AND n.nspname = ${"public"}
ORDER BY c.relname
`;
return rows.map(({ name }) => name);
}
async getTable(name) {
const rows = await this.provider.exec `
SELECT
a.attname AS ${this.provider.sqlIdentifier("name")},
pg_catalog.format_type(a.atttypid, a.atttypmod) AS ${this.provider.sqlIdentifier("type")},
NOT a.attnotnull AS ${this.provider.sqlIdentifier("nullable")},
pg_get_expr(ad.adbin, ad.adrelid) AS ${this.provider.sqlIdentifier("value")},
a.attgenerated = ${"s"} AS ${this.provider.sqlIdentifier("generated")},
a.attidentity = ${"a"} AS ${this.provider.sqlIdentifier("identity")},
EXISTS (
SELECT 1
FROM ${this.provider.sqlIdentifier("pg_index")} i
WHERE i.indrelid = c.oid
AND i.indisprimary
AND a.attnum = ANY(i.indkey)
) AS ${this.provider.sqlIdentifier("primary")}
FROM ${this.provider.sqlIdentifier("pg_class")} c
JOIN ${this.provider.sqlIdentifier("pg_attribute")} a ON a.attrelid = c.oid
LEFT JOIN ${this.provider.sqlIdentifier("pg_attrdef")} ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
WHERE c.relkind = ${"r"}
AND c.relnamespace = ${"public"}::regnamespace
AND a.attnum > ${0}
AND c.relname = ${name}
AND NOT a.attisdropped
ORDER BY a.attnum
`;
if (!rows.length)
return undefined;
return {
name,
columns: Object.fromEntries(rows.map(row => [row.name, { name: row.name, statement: this.getCurrentColumnStatement(row) }])),
};
}
getCreateTableSuffix(_collection) {
return "";
}
getDataColumnDefinition() {
return `jsonb NOT NULL DEFAULT ${this.quoteString("{}")}::jsonb`;
}
getGeneratedColumnDefinition(_columnName, path, definition) {
const expression = this.getGeneratedExpression(path, definition);
return `${definition} GENERATED ALWAYS AS (${expression}) STORED`;
}
getIDColumnDefinition(collection) {
const id = collection.id;
if (id instanceof NumberSchema && id.step === 1)
return "integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY";
return "text PRIMARY KEY";
}
getAlterColumnQueries(tableName, from, to) {
const parsedFrom = this.parseColumn(from);
const parsedTo = this.parseColumn(to);
if (!parsedFrom || !parsedTo || parsedFrom.generated || parsedTo.generated)
return super.getAlterColumnQueries(tableName, from, to);
const table = this.quoteIdentifier(tableName);
const column = this.quoteIdentifier(to.name);
const migrations = [];
if (parsedFrom.type !== parsedTo.type) {
if (_areCompatibleTypes(parsedFrom.type, parsedTo.type)) {
migrations.push(`ALTER TABLE ${table} ALTER COLUMN ${column} TYPE ${parsedTo.type} USING ${column}::${parsedTo.type};`);
}
else {
return super.getAlterColumnQueries(tableName, from, to);
}
}
if (parsedFrom.nullable !== parsedTo.nullable) {
migrations.push(`ALTER TABLE ${table} ALTER COLUMN ${column} ${parsedTo.nullable ? "DROP" : "SET"} NOT NULL;`);
}
if ((parsedFrom.defaultValue ?? "NULL") !== (parsedTo.defaultValue ?? "NULL")) {
migrations.push(`ALTER TABLE ${table} ALTER COLUMN ${column} SET DEFAULT ${parsedTo.defaultValue ?? "NULL"};`);
}
return migrations.length ? migrations : super.getAlterColumnQueries(tableName, from, to);
}
definition(schema) {
const value = schema.value;
void getSource(NullableSchema, schema);
const str = getSource(StringSchema, schema);
if (str)
return "text";
const num = getSource(NumberSchema, schema);
if (num) {
const type = Number.isInteger(num.step) && Number.isInteger(num.min) && Number.isInteger(num.max)
? num.min >= INT4_MIN && num.max <= INT4_MAX
? "integer"
: "bigint"
: "numeric";
return type;
}
const bool = getSource(BooleanSchema, schema);
if (bool)
return "boolean";
const choice = getSource(ChoiceSchema, schema);
if (choice)
return "text";
const arr = getSource(ArraySchema, schema);
if (arr)
return "jsonb";
const data = getSource(DataSchema, schema);
if (data)
return "jsonb";
const dict = getSource(DictionarySchema, schema);
if (dict)
return "jsonb";
const date = getSource(DateSchema, schema);
if (date) {
const type = getSource(DateTimeSchema, schema) ? "timestamp with time zone" : getSource(TimeSchema, schema) ? "time" : "date";
return type;
}
switch (typeof value) {
case "string":
return "text";
case "number":
return Number.isInteger(value) ? "integer" : "numeric";
case "boolean":
return "boolean";
default:
return undefined;
}
}
getCurrentColumnStatement({ generated, identity, name: _name, nullable, primary, type, value }) {
if (identity && primary)
return `${type} GENERATED ALWAYS AS IDENTITY PRIMARY KEY`;
if (generated)
return `${type} GENERATED ALWAYS AS (${value ?? "NULL"}) STORED`;
return `${type}${nullable ? " NULL" : " NOT NULL"} DEFAULT ${value ?? "NULL"}`;
}
getGeneratedExpression(path, definition) {
const cast = this.getGeneratedCast(definition);
const parts = path
.replace(/^\$\./, "")
.replace(/^\$/, "")
.split(".")
.map(part => part.replaceAll(/^"|"$/g, ""));
const key = parts.map(part => part.replaceAll(`'`, `''`)).join(",");
const source = `${this.quoteIdentifier("data")} #>> ${this.quoteString(`{${key}}`)}`;
return cast ? `(${source})::${cast}` : source;
}
getGeneratedCast(definition) {
if (definition.startsWith("integer"))
return "integer";
if (definition.startsWith("bigint"))
return "bigint";
if (definition.startsWith("numeric"))
return "numeric";
if (definition.startsWith("boolean"))
return "boolean";
if (definition.startsWith("timestamp"))
return "timestamp with time zone";
if (definition.startsWith("time"))
return "time";
if (definition.startsWith("date"))
return "date";
return undefined;
}
parseColumn({ name, statement }) {
if (statement.endsWith("GENERATED ALWAYS AS IDENTITY PRIMARY KEY")) {
return { name, type: statement.replace(/\s+GENERATED ALWAYS AS IDENTITY PRIMARY KEY$/, ""), nullable: false, generated: false };
}
const generated = statement.match(/^(.+?) GENERATED ALWAYS AS \(([\s\S]+)\) STORED$/);
if (generated) {
const type = generated[1];
if (!type)
return undefined;
return { name, type, nullable: false, generated: true };
}
const normal = statement.match(/^(.+?) (NULL|NOT NULL) DEFAULT ([\s\S]+)$/);
if (!normal)
return undefined;
const [, type, nullable, defaultValue] = normal;
if (!type || !nullable || !defaultValue)
return undefined;
return { name, type, nullable: nullable === "NULL", generated: false, defaultValue };
}
}
function _areCompatibleTypes(from, to) {
return ((COMPATIBLE_NUMBER_TYPES.includes(from) && COMPATIBLE_NUMBER_TYPES.includes(to)) ||
(COMPATIBLE_STRING_TYPES.includes(from) && COMPATIBLE_STRING_TYPES.includes(to)));
}