UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

194 lines (193 loc) 8.26 kB
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] }); }