shelving
Version:
Toolkit for using data in JavaScript.
194 lines (193 loc) • 8.26 kB
JavaScript
import { UnimplementedError } from "../../error/UnimplementedError.js";
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 { DictionarySchema } from "../../schema/DictionarySchema.js";
import { NumberSchema } from "../../schema/NumberSchema.js";
import { StringSchema } from "../../schema/StringSchema.js";
import { ThroughSchema } from "../../schema/ThroughSchema.js";
import { DBMigrator } from "./DBMigrator.js";
/** Shared SQL migration logic based on schema diffing. */
export class SQLMigrator extends DBMigrator {
async migrate(...collections) {
for (const migration of await this.getMigrations(...collections))
await this.provider.exec(_getTemplateStrings(migration));
}
async getMigrations(...collections) {
const tables = await this.getTables();
const existing = new Set(tables);
const migrations = [];
for (const collection of collections) {
const name = collection.name;
const table = existing.has(name) ? await this.getTable(name) : undefined;
migrations.push(...this.getTableMigrations(collection, table));
}
return migrations;
}
getCreateTableQuery(collection) {
const suffix = this.getCreateTableSuffix(collection);
return `CREATE TABLE ${this.quoteIdentifier(collection.name)} (\n${this.getCreateTableColumns(collection)
.map(column => ` ${this.getTableColumnDefinition(column)}`)
.join(",\n")}\n)${suffix};`;
}
getCreateTableColumns(collection) {
return [this.getIDColumn(collection), this.getDataColumn(), ...this.getGeneratedTableColumns(collection)];
}
getGeneratedTableColumns(collection) {
return this.getGeneratedColumns(collection).map(column => this.getGeneratedColumn(column, collection));
}
getColumnMigrations(tableName, from, to) {
if (to) {
if (!from)
return [this.getAddColumnQuery(tableName, to)];
if (!this.isSameColumn(from, to))
return this.getAlterColumnQueries(tableName, from, to);
return [];
}
if (!from)
return [];
return [this.getDropColumnQuery(tableName, from.name)];
}
getAlterColumnQueries(tableName, from, to) {
return [this.getDropColumnQuery(tableName, from.name), this.getAddColumnQuery(tableName, to)];
}
getTableColumnDefinition({ name, statement }) {
return `${this.quoteIdentifier(name)} ${statement}`;
}
getAddColumnQuery(tableName, column) {
if (column.name === "id")
throw new UnimplementedError(`Cannot add primary key column to existing table "${tableName}"`);
return `ALTER TABLE ${this.quoteIdentifier(tableName)} ADD COLUMN ${this.getTableColumnDefinition(column)};`;
}
getDropColumnQuery(tableName, columnName) {
if (columnName === "id")
throw new UnimplementedError(`Cannot drop primary key column from existing table "${tableName}"`);
return `ALTER TABLE ${this.quoteIdentifier(tableName)} DROP COLUMN ${this.quoteIdentifier(columnName)};`;
}
getTableMigrations(collection, table) {
if (!table)
return [this.getCreateTableQuery(collection)];
const desired = this.getCreateTableColumns(collection);
const wanted = new Map(desired.map(column => [column.name, column]));
const migrations = [];
for (const column of desired)
migrations.push(...this.getColumnMigrations(table.name, table.columns[column.name], column));
for (const column of Object.values(table.columns)) {
if (!wanted.has(column.name))
migrations.push(...this.getColumnMigrations(table.name, column, undefined));
}
return migrations;
}
getIDColumn(collection) {
return { name: "id", statement: this.getIDColumnDefinition(collection) };
}
getDataColumn() {
return { name: "data", statement: this.getDataColumnDefinition() };
}
getGeneratedColumn({ column, key, path }, collection) {
const schema = _getColumnSchema(collection, key);
const definition = this.definition(schema);
if (!definition)
throw new UnimplementedError(`Cannot generate SQL column for "${key}"`, { received: schema });
return { name: column, statement: this.getGeneratedColumnDefinition(column, path, definition) };
}
isSameColumn(from, to) {
return _normaliseSQL(from.statement) === _normaliseSQL(to.statement);
}
quoteIdentifier(value) {
return `"${value.replaceAll(`"`, `""`)}"`;
}
quoteString(value) {
return `'${value.replaceAll(`'`, `''`)}'`;
}
getGeneratedColumns(collection) {
return _getColumns(collection.props, this.getColumnName.bind(this), this.getJSONPath.bind(this));
}
getColumnName(key) {
return key.replaceAll(".", "__");
}
getJSONPath(key) {
return `$${key
.split(".")
.map(part => `.${JSON.stringify(part)}`)
.join("")}`;
}
definition(schema) {
const unwrapped = _unwrapSchema(schema);
if (unwrapped instanceof DataSchema || unwrapped instanceof ArraySchema || unwrapped instanceof DictionarySchema)
return undefined;
if (unwrapped instanceof NumberSchema)
return unwrapped.step === 1 ? "INTEGER" : "REAL";
if (unwrapped instanceof BooleanSchema)
return "INTEGER";
if (unwrapped instanceof StringSchema || unwrapped instanceof ChoiceSchema || unwrapped instanceof DateSchema)
return "TEXT";
switch (typeof unwrapped.value) {
case "boolean":
return "INTEGER";
case "number":
return Number.isInteger(unwrapped.value) ? "INTEGER" : "REAL";
case "string":
return "TEXT";
default:
return undefined;
}
}
}
function _getColumnSchema(collection, key) {
let schema = collection;
for (const part of key.split(".")) {
const current = _unwrapSchema(schema);
if (!(current instanceof DataSchema))
throw new UnimplementedError(`Cannot resolve schema path "${key}"`);
const next = current.props[part];
if (!next)
throw new UnimplementedError(`Cannot resolve schema path "${key}"`);
schema = next;
}
return schema;
}
function _getColumns(props, getColumnName, getJSONPath, prefix = "") {
const columns = [];
for (const [key, schema] of Object.entries(props)) {
const nextKey = prefix ? `${prefix}.${key}` : key;
const unwrapped = _unwrapSchema(schema);
if (unwrapped instanceof DataSchema)
columns.push(..._getColumns(unwrapped.props, getColumnName, getJSONPath, nextKey));
else if (_isColumnSchema(unwrapped))
columns.push({ column: getColumnName(nextKey), key: nextKey, path: getJSONPath(nextKey) });
}
return columns;
}
function _isColumnSchema(schema) {
const unwrapped = _unwrapSchema(schema);
if (unwrapped instanceof DataSchema || unwrapped instanceof ArraySchema || unwrapped instanceof DictionarySchema)
return false;
if (unwrapped instanceof BooleanSchema ||
unwrapped instanceof ChoiceSchema ||
unwrapped instanceof DateSchema ||
unwrapped instanceof NumberSchema)
return true;
switch (typeof unwrapped.value) {
case "boolean":
case "number":
case "string":
return true;
default:
return false;
}
}
function _unwrapSchema(schema) {
let current = schema;
while (current instanceof ThroughSchema)
current = current.source;
return current;
}
function _normaliseSQL(value) {
return value.replaceAll(/\s+/g, " ").trim();
}
function _getTemplateStrings(value) {
return Object.assign([value], { raw: [value] });
}