UNPKG

rake-db

Version:
1,600 lines (1,590 loc) 188 kB
'use strict'; var orchidCore = require('orchid-core'); var pqb = require('pqb'); var path = require('path'); var node_url = require('node:url'); var fs = require('fs/promises'); require('url'); require('node:path'); const getFirstWordAndRest = (input) => { const i = input.search(/(?=[A-Z])|[-_ ]/); if (i !== -1) { const restStart = input[i] === "-" || input[i] === "_" || input[i] === " " ? i + 1 : i; const rest = input.slice(restStart); return [input.slice(0, i), rest[0].toLowerCase() + rest.slice(1)]; } else { return [input]; } }; const getTextAfterRegExp = (input, regex, length) => { let i = input.search(regex); if (i === -1) return; if (input[i] === "-" || input[i] === "_" || input[i] === " ") i++; i += length; const start = input[i] == "-" || input[i] === "_" || input[i] === " " ? i + 1 : i; const text = input.slice(start); return text[0].toLowerCase() + text.slice(1); }; const getTextAfterTo = (input) => { return getTextAfterRegExp(input, /(To|-to|_to| to)[A-Z-_ ]/, 2); }; const getTextAfterFrom = (input) => { return getTextAfterRegExp(input, /(From|-from|_from| from)[A-Z-_ ]/, 4); }; const joinColumns = (columns) => { return columns.map((column) => `"${column}"`).join(", "); }; const quoteWithSchema = ({ schema, name }) => quoteTable(schema, name); const quoteTable = (schema, table) => schema ? `"${schema}"."${table}"` : `"${table}"`; const getSchemaAndTableFromName = (name) => { const i = name.indexOf("."); return i !== -1 ? [name.slice(0, i), name.slice(i + 1)] : [void 0, name]; }; const quoteNameFromString = (string) => { return quoteTable(...getSchemaAndTableFromName(string)); }; const quoteCustomType = (s) => { const [schema, type] = getSchemaAndTableFromName(s); return schema ? '"' + schema + '".' + type : type; }; const quoteSchemaTable = (arg) => { return orchidCore.singleQuote(concatSchemaAndName(arg)); }; const concatSchemaAndName = ({ schema, name }) => { return schema ? `${schema}.${name}` : name; }; const makePopulateEnumQuery = (item) => { const [schema, name] = getSchemaAndTableFromName(item.enumName); return { text: `SELECT unnest(enum_range(NULL::${quoteTable(schema, name)}))::text`, then(result) { item.options.push(...result.rows.map(([value]) => value)); } }; }; const begin = { text: "BEGIN" }; const transaction = (adapter, fn) => { return adapter.transaction(begin, fn); }; const queryLock = (trx) => trx.query(`SELECT pg_advisory_xact_lock('${RAKE_DB_LOCK_KEY}')`); let currentChanges = []; const clearChanges = () => { currentChanges = []; }; const getCurrentChanges = () => currentChanges; const pushChange = (change) => currentChanges.push(change); const versionToString = (config, version) => config.migrationId === "timestamp" ? `${version}` : `${version}`.padStart(config.migrationId.serial, "0"); const columnTypeToSql = (item) => { return item.data.isOfCustomType ? item instanceof pqb.DomainColumn ? quoteNameFromString(item.dataType) : quoteCustomType(item.toSQL()) : item.toSQL(); }; const getColumnName = (item, key, snakeCase) => { return item.data.name || (snakeCase ? orchidCore.toSnakeCase(key) : key); }; const columnToSql = (name, item, values, hasMultiplePrimaryKeys, snakeCase) => { const line = [`"${name}" ${columnTypeToSql(item)}`]; if (item.data.compression) { line.push(`COMPRESSION ${item.data.compression}`); } if (item.data.collate) { line.push(`COLLATE ${quoteNameFromString(item.data.collate)}`); } if (item.data.identity) { line.push(identityToSql(item.data.identity)); } else if (item.data.generated) { line.push( `GENERATED ALWAYS AS (${item.data.generated.toSQL({ values, snakeCase })}) STORED` ); } if (item.data.primaryKey && !hasMultiplePrimaryKeys) { if (item.data.primaryKey !== true) { line.push(`CONSTRAINT "${item.data.primaryKey}"`); } line.push("PRIMARY KEY"); } else if (!item.data.isNullable) { line.push("NOT NULL"); } if (item.data.checks) { line.push( item.data.checks.map( (check) => (check.name ? `CONSTRAINT "${check.name}" ` : "") + checkToSql(check.sql, values) ).join(", ") ); } const def = encodeColumnDefault(item.data.default, values, item); if (def !== null) line.push(`DEFAULT ${def}`); const { foreignKeys } = item.data; if (foreignKeys) { for (const foreignKey of foreignKeys) { if (foreignKey.options?.name) { line.push(`CONSTRAINT "${foreignKey.options?.name}"`); } line.push( referencesToSql( { columns: [name], ...foreignKey }, snakeCase ) ); } } return line.join(" "); }; const encodeColumnDefault = (def, values, column) => { if (def !== void 0 && def !== null && typeof def !== "function") { if (orchidCore.isRawSQL(def)) { return def.toSQL({ values }); } else { return pqb.escapeForMigration( column instanceof pqb.ArrayColumn && Array.isArray(def) ? "{" + (column.data.item.data.encode ? def.map((x) => column.data.item.data.encode(x)) : def).join(",") + "}" : column?.data.encode ? column.data.encode(def) : def ); } } return null; }; const identityToSql = (identity) => { const options = sequenceOptionsToSql(identity); return `GENERATED ${identity.always ? "ALWAYS" : "BY DEFAULT"} AS IDENTITY${options ? ` (${options})` : ""}`; }; const sequenceOptionsToSql = (item) => { const line = []; if (item.dataType) line.push(`AS ${item.dataType}`); if (item.increment !== void 0) line.push(`INCREMENT BY ${item.increment}`); if (item.min !== void 0) line.push(`MINVALUE ${item.min}`); if (item.max !== void 0) line.push(`MAXVALUE ${item.max}`); if (item.start !== void 0) line.push(`START WITH ${item.start}`); if (item.cache !== void 0) line.push(`CACHE ${item.cache}`); if (item.cycle) line.push(`CYCLE`); if (item.ownedBy) { const [schema, table] = getSchemaAndTableFromName(item.ownedBy); line.push(`OWNED BY ${quoteTable(schema, table)}`); } return line.join(" "); }; const addColumnIndex = (indexes, name, item) => { if (item.data.indexes) { indexes.push( ...item.data.indexes.map((index) => ({ columns: [{ ...index.options, column: name }], ...index })) ); } }; const addColumnExclude = (excludes, name, item) => { if (item.data.excludes) { excludes.push( ...item.data.excludes.map(({ with: w, ...exclude }) => ({ columns: [{ ...exclude.options, column: name, with: w }], ...exclude })) ); } }; const addColumnComment = (comments, name, item) => { if (item.data.comment) { comments.push({ column: name, comment: item.data.comment }); } }; const getForeignKeyTable = (fnOrTable) => { if (typeof fnOrTable === "string") { return getSchemaAndTableFromName(fnOrTable); } const item = new (fnOrTable())(); return [item.schema, item.table]; }; const getConstraintName = (table, constraint, snakeCase) => { if (constraint.references) { let { columns } = constraint.references; if (snakeCase) { columns = columns.map(orchidCore.toSnakeCase); } return makeConstraintName(table, columns, "fkey"); } if (constraint.check) return `${table}_check`; if (constraint.identity) return `${table}_identity`; return `${table}_constraint`; }; const constraintToSql = ({ name }, up, constraint, values, snakeCase) => { const constraintName = constraint.name || getConstraintName(name, constraint, snakeCase); if (!up) { const { dropMode } = constraint; return `CONSTRAINT "${constraintName}"${dropMode ? ` ${dropMode}` : ""}`; } const sql = [`CONSTRAINT "${constraintName}"`]; if (constraint.references) { sql.push(foreignKeyToSql(constraint.references, snakeCase)); } if (constraint.check) { sql.push(checkToSql(constraint.check, values)); } return sql.join(" "); }; const checkToSql = (check, values) => { return `CHECK (${check.toSQL({ values })})`; }; const foreignKeyToSql = (item, snakeCase) => { return `FOREIGN KEY (${joinColumns( snakeCase ? item.columns.map(orchidCore.toSnakeCase) : item.columns )}) ${referencesToSql(item, snakeCase)}`; }; const referencesToSql = (references, snakeCase) => { const [schema, table] = getForeignKeyTable(references.fnOrTable); const sql = [ `REFERENCES ${quoteTable(schema, table)}(${joinColumns( snakeCase ? references.foreignColumns.map(orchidCore.toSnakeCase) : references.foreignColumns )})` ]; const { options } = references; if (options?.match) { sql.push(`MATCH ${options?.match.toUpperCase()}`); } if (options?.onDelete) { sql.push(`ON DELETE ${options?.onDelete.toUpperCase()}`); } if (options?.onUpdate) { sql.push(`ON UPDATE ${options?.onUpdate.toUpperCase()}`); } return sql.join(" "); }; const MAX_CONSTRAINT_NAME_LEN = 63; const makeConstraintName = (table, columns, suffix) => { const long = `${table}_${columns.join("_")}_${suffix}`; if (long.length <= MAX_CONSTRAINT_NAME_LEN) return long; for (let partLen = 3; partLen > 0; partLen--) { const shorter = `${orchidCore.toCamelCase( orchidCore.toSnakeCase(table).split("_").map((p) => p.slice(0, partLen)).join("_") )}_${columns.map( (c) => orchidCore.toCamelCase( c.split("_").map((p) => p.slice(0, partLen)).join("_") ) ).join("_")}_${suffix}`; if (shorter.length <= MAX_CONSTRAINT_NAME_LEN) return shorter; } const short = `${table}_${columns.length}columns_${suffix}`; if (short.length <= MAX_CONSTRAINT_NAME_LEN) return short; for (let partLen = 3; partLen > 0; partLen--) { const short2 = `${orchidCore.toCamelCase( orchidCore.toSnakeCase(table).split("_").map((p) => p.slice(0, partLen)).join("_") )}_${columns.length}columns_${suffix}`; if (short2.length <= MAX_CONSTRAINT_NAME_LEN) return short2; } return `long_ass_table_${suffix}`; }; const getIndexOrExcludeName = (table, columns, suffix) => makeConstraintName( table, columns.map( (it) => "column" in it ? it.column : "expression" ), suffix ); const getIndexName = (table, columns) => getIndexOrExcludeName(table, columns, "idx"); const getExcludeName = (table, columns) => getIndexOrExcludeName(table, columns, "exclude"); const indexesToQuery = (up, { schema, name: tableName }, indexes, snakeCase, language) => { return indexes.map((index) => { const { options } = index; const { columns, include, name } = getIndexOrExcludeMainOptions( tableName, index, getIndexName, snakeCase ); if (!up) { return { text: `DROP INDEX "${name}"${options.dropMode ? ` ${options.dropMode}` : ""}` }; } const values = []; const sql = ["CREATE"]; if (options.unique) { sql.push("UNIQUE"); } sql.push(`INDEX "${name}" ON ${quoteTable(schema, tableName)}`); const u = options.using || options.tsVector && "GIN"; if (u) { sql.push(`USING ${u}`); } const lang = options.tsVector && options.languageColumn ? `"${options.languageColumn}"` : options.language ? `'${options.language}'` : `'${language || "english"}'`; let hasWeight = options.tsVector && columns.some((column) => !!column.weight); const columnsSql = columns.map((column) => { let sql2 = [ "expression" in column ? `(${column.expression})` : `"${column.column}"`, column.collate && `COLLATE ${quoteNameFromString(column.collate)}`, column.opclass, column.order ].filter((x) => !!x).join(" "); if (hasWeight) { sql2 = `to_tsvector(${lang}, coalesce(${sql2}, ''))`; if (column.weight) { hasWeight = true; sql2 = `setweight(${sql2}, '${column.weight}')`; } } return sql2; }); let columnList; if (hasWeight) { columnList = `(${columnsSql.join(" || ")})`; } else if (options.tsVector) { columnList = `to_tsvector(${lang}, ${columnsSql.join(" || ' ' || ")})`; } else { columnList = columnsSql.join(", "); } sql.push(`(${columnList})`); if (include && include.length) { sql.push( `INCLUDE (${include.map((column) => `"${column}"`).join(", ")})` ); } if (options.nullsNotDistinct) { sql.push(`NULLS NOT DISTINCT`); } if (options.with) { sql.push(`WITH (${options.with})`); } if (options.tablespace) { sql.push(`TABLESPACE ${options.tablespace}`); } if (options.where) { sql.push( `WHERE ${orchidCore.isRawSQL(options.where) ? options.where.toSQL({ values }) : options.where}` ); } return { text: sql.join(" "), values }; }); }; const excludesToQuery = (up, { schema, name: tableName }, excludes, snakeCase) => { return excludes.map((exclude) => { const { options } = exclude; const { columns, include, name } = getIndexOrExcludeMainOptions( tableName, exclude, getExcludeName, snakeCase ); if (!up) { return { text: `ALTER TABLE ${quoteTable( schema, tableName )} DROP CONSTRAINT "${name}"${options.dropMode ? ` ${options.dropMode}` : ""}` }; } const columnList = columns.map( (column) => [ "expression" in column ? `(${column.expression})` : `"${column.column}"`, column.collate && `COLLATE ${quoteNameFromString(column.collate)}`, column.opclass, column.order, `WITH ${column.with}` ].filter((x) => !!x).join(" ") ).join(", "); const values = []; const text = [ `ALTER TABLE ${quoteTable( schema, tableName )} ADD CONSTRAINT "${name}" EXCLUDE`, options.using && `USING ${options.using}`, `(${columnList})`, include?.length && `INCLUDE (${include.map((column) => `"${column}"`).join(", ")})`, options.with && `WITH (${options.with})`, options.tablespace && `USING INDEX TABLESPACE ${options.tablespace}`, options.where && `WHERE ${orchidCore.isRawSQL(options.where) ? options.where.toSQL({ values }) : options.where}` ].filter((x) => !!x).join(" "); return { text, values }; }); }; const getIndexOrExcludeMainOptions = (tableName, item, getName, snakeCase) => { let include = item.options.include ? orchidCore.toArray(item.options.include) : void 0; let { columns } = item; if (snakeCase) { columns = columns.map( (c) => "column" in c ? { ...c, column: orchidCore.toSnakeCase(c.column) } : c ); if (include) include = include.map(orchidCore.toSnakeCase); } return { columns, include, name: item.name || getName(tableName, columns) }; }; const commentsToQuery = (schemaTable, comments) => { return comments.map(({ column, comment }) => ({ text: `COMMENT ON COLUMN ${quoteWithSchema( schemaTable )}."${column}" IS ${pqb.escapeForMigration(comment)}`, values: [] })); }; const primaryKeyToSql = (primaryKey) => { return `${primaryKey.name ? `CONSTRAINT "${primaryKey.name}" ` : ""}PRIMARY KEY (${joinColumns(primaryKey.columns)})`; }; const interpolateSqlValues = ({ text, values }) => { return values?.length ? text.replace(/\$(\d+)/g, (_, n) => { const i = +n - 1; return pqb.escapeForMigration(values[i]); }) : text; }; const nameColumnChecks = (table, column, checks) => checks.map((check, i) => ({ ...check, name: check.name || `${table}_${column}_check${i === 0 ? "" : i}` })); const cmpRawSql = (a, b) => { const values = []; const aSql = a.makeSQL({ values }); const aValues = JSON.stringify(values); values.length = 0; const bSql = b.makeSQL({ values }); const bValues = JSON.stringify(values); return aSql === bSql && aValues === bValues; }; const tableMethods = { enum(name) { return new pqb.EnumColumn( pqb.defaultSchemaConfig, name, [], void 0 ); } }; class RakeDbError extends Error { } class NoPrimaryKey extends RakeDbError { } const createTable = async (migration, up, tableName, first, second, third) => { let options; let fn; let dataFn; if (typeof first === "object") { options = first; fn = second; dataFn = third; } else { options = orchidCore.emptyObject; fn = first; dataFn = second; } const snakeCase = "snakeCase" in options ? options.snakeCase : migration.options.snakeCase; const language = "language" in options ? options.language : migration.options.language; const types = Object.assign( Object.create(migration.columnTypes), tableMethods ); types[orchidCore.snakeCaseKey] = snakeCase; let shape; let tableData; if (fn) { shape = pqb.getColumnTypes( types, fn, migration.options.baseTable?.nowSQL, language ); tableData = pqb.parseTableData(dataFn); tableData.constraints?.forEach((x, i) => { if (x.name || !x.check) return; x.name = `${tableName}_check${i === 0 ? "" : i}`; }); } else { shape = tableData = orchidCore.emptyObject; } const ast = makeAst$2( up, tableName, shape, tableData, options, migration.options.noPrimaryKey ); fn && validatePrimaryKey(ast); const queries = astToQueries$1(ast, snakeCase, language); for (const { then, ...query } of queries) { const result = await migration.adapter.arrays(interpolateSqlValues(query)); then?.(result); } let table; return { get table() { return table ?? (table = migration( tableName, shape, void 0, { noPrimaryKey: options.noPrimaryKey ? "ignore" : void 0, snakeCase } )); } }; }; const makeAst$2 = (up, tableName, shape, tableData, options, noPrimaryKey) => { const shapePKeys = []; for (const key in shape) { const column = shape[key]; if (column.data.primaryKey) { shapePKeys.push(key); } } const { primaryKey } = tableData; const [schema, table] = getSchemaAndTableFromName(tableName); return { type: "table", action: up ? "create" : "drop", schema, name: table, shape, ...tableData, primaryKey: shapePKeys.length <= 1 ? primaryKey : primaryKey ? { ...primaryKey, columns: [.../* @__PURE__ */ new Set([...shapePKeys, ...primaryKey.columns])] } : { columns: shapePKeys }, ...options, noPrimaryKey: options.noPrimaryKey ? "ignore" : noPrimaryKey || "error" }; }; const validatePrimaryKey = (ast) => { if (ast.noPrimaryKey !== "ignore") { let hasPrimaryKey = !!ast.primaryKey?.columns?.length; if (!hasPrimaryKey) { for (const key in ast.shape) { if (ast.shape[key].data.primaryKey) { hasPrimaryKey = true; break; } } } if (!hasPrimaryKey) { const error = new NoPrimaryKey( `Table ${ast.name} has no primary key. You can suppress this error by setting { noPrimaryKey: true } after a table name.` ); if (ast.noPrimaryKey === "error") { throw error; } else { console.warn(error.message); } } } }; const astToQueries$1 = (ast, snakeCase, language) => { const queries = []; const { shape } = ast; for (const key in shape) { const item = shape[key]; if (!(item instanceof pqb.EnumColumn)) continue; queries.push(makePopulateEnumQuery(item)); } if (ast.action === "drop") { queries.push({ text: `DROP TABLE${ast.dropIfExists ? " IF EXISTS" : ""} ${quoteWithSchema(ast)}${ast.dropMode ? ` ${ast.dropMode}` : ""}` }); return queries; } const lines = []; const values = []; const indexes = []; const excludes = []; const comments = []; for (const key in shape) { const item = shape[key]; const name = getColumnName(item, key, snakeCase); addColumnIndex(indexes, name, item); addColumnExclude(excludes, name, item); addColumnComment(comments, name, item); lines.push( ` ${columnToSql(name, item, values, !!ast.primaryKey, snakeCase)}` ); } if (ast.primaryKey) { lines.push( ` ${primaryKeyToSql({ name: ast.primaryKey.name, columns: ast.primaryKey.columns.map( (key) => getColumnName(shape[key], key, snakeCase) ) })}` ); } ast.constraints?.forEach((item) => { lines.push( ` ${constraintToSql( ast, true, { ...item, references: item.references ? { ...item.references, columns: item.references.columns.map( (column) => getColumnName(shape[column], column, snakeCase) ) } : void 0 }, values, snakeCase )}` ); }); pushIndexesOrExcludesFromAst(indexes, ast.indexes, shape, snakeCase); pushIndexesOrExcludesFromAst(excludes, ast.excludes, shape, snakeCase); queries.push( { text: `CREATE TABLE${ast.createIfNotExists ? " IF NOT EXISTS" : ""} ${quoteWithSchema(ast)} (${lines.join(",")} )`, values }, ...indexesToQuery(true, ast, indexes, snakeCase, language), ...excludesToQuery(true, ast, excludes, snakeCase), ...commentsToQuery(ast, comments) ); if (ast.comment) { queries.push({ text: `COMMENT ON TABLE ${quoteWithSchema(ast)} IS ${pqb.escapeString( ast.comment )}` }); } return queries; }; const pushIndexesOrExcludesFromAst = (arr, inAst, shape, snakeCase) => { arr.push( ...inAst?.map((x) => ({ ...x, columns: x.columns.map((item) => ({ ...item, ..."column" in item ? { column: getColumnName(shape[item.column], item.column, snakeCase) } : {} })) })) || [] ); }; const newChangeTableData = () => ({ add: {}, drop: {} }); let changeTableData = newChangeTableData(); const resetChangeTableData = () => { changeTableData = newChangeTableData(); }; const addOrDropChanges = []; function add(item, options) { orchidCore.consumeColumnName(); setName(this, item); if (item instanceof pqb.ColumnType) { const result = addOrDrop("add", item, options); if (result.type === "change") return result; addOrDropChanges.push(result); return addOrDropChanges.length - 1; } for (const key in item) { if (item[key] instanceof orchidCore.ColumnTypeBase) { const result = {}; for (const key2 in item) { result[key2] = { type: "add", item: item[key2], dropMode: options?.dropMode }; } return result; } pqb.parseTableDataInput(changeTableData.add, item); break; } return void 0; } const drop = function(item, options) { orchidCore.consumeColumnName(); setName(this, item); if (item instanceof pqb.ColumnType) { const result = addOrDrop("drop", item, options); if (result.type === "change") return result; addOrDropChanges.push(result); return addOrDropChanges.length - 1; } for (const key in item) { if (item[key] instanceof orchidCore.ColumnTypeBase) { const result = {}; for (const key2 in item) { result[key2] = { type: "drop", item: item[key2], dropMode: options?.dropMode }; } return result; } pqb.parseTableDataInput(changeTableData.drop, item); break; } return void 0; }; const addOrDrop = (type, item, options) => { if (item instanceof pqb.UnknownColumn) { const empty = columnTypeToColumnChange({ type: "change", to: {} }); const add2 = columnTypeToColumnChange({ type: "change", to: { checks: item.data.checks } }); return { type: "change", from: type === "add" ? empty : add2, to: type === "add" ? add2 : empty, ...options }; } return { type, item, dropMode: options?.dropMode }; }; const columnTypeToColumnChange = (item, name) => { if (item instanceof pqb.ColumnType) { let column = item; const foreignKeys = column.data.foreignKeys; if (foreignKeys?.some((it) => "fn" in it)) { throw new Error("Callback in foreignKey is not allowed in migration"); } return { column, type: column.toSQL(), nullable: column.data.isNullable, ...column.data, primaryKey: column.data.primaryKey === void 0 ? void 0 : true, foreignKeys }; } return item.to; }; const nameKey = Symbol("name"); const setName = (self, item) => { var _a, _b; const name = self[nameKey]; if (!name) return; if ("column" in item && item.column instanceof pqb.ColumnType) { (_a = item.column.data).name ?? (_a.name = name); } else if (item instanceof pqb.ColumnType) { (_b = item.data).name ?? (_b.name = name); } else { item.name ?? (item.name = name); } }; const tableChangeMethods = { ...tableMethods, ...pqb.tableDataMethods, name(name) { orchidCore.setCurrentColumnName(name); const types = Object.create(this); types[nameKey] = name; return types; }, add, drop, change(from, to, using) { orchidCore.consumeColumnName(); const f = columnTypeToColumnChange(from); const t = columnTypeToColumnChange(to); setName(this, f); setName(this, t); return { type: "change", // eslint-disable-next-line @typescript-eslint/no-explicit-any name: this[nameKey], from: f, to: t, using }; }, default(value) { return { type: "change", to: { default: value } }; }, nullable() { return { type: "change", to: { nullable: true } }; }, nonNullable() { return { type: "change", to: { nullable: false } }; }, comment(comment) { return { type: "change", to: { comment } }; }, /** * Rename a column: * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * await db.changeTable('table', (t) => ({ * oldColumnName: t.rename('newColumnName'), * })); * }); * ``` * * Note that the renaming `ALTER TABLE` is executed before the rest of alterations, * so if you're also adding a new constraint on this column inside the same `changeTable`, * refer to it with a new name. * * @param name */ rename(name) { return { type: "rename", name }; } }; const changeTable = async (migration, up, tableName, options, fn) => { const snakeCase = "snakeCase" in options ? options.snakeCase : migration.options.snakeCase; const language = "language" in options ? options.language : migration.options.language; orchidCore.setDefaultLanguage(language); resetChangeTableData(); const tableChanger = Object.create( migration.columnTypes ); Object.assign(tableChanger, tableChangeMethods); tableChanger[orchidCore.snakeCaseKey] = snakeCase; addOrDropChanges.length = 0; const changeData = fn?.(tableChanger) || {}; const ast = makeAst$1(up, tableName, changeData, changeTableData, options); const queries = astToQueries(ast, snakeCase, language); for (const query of queries) { const result = await migration.adapter.arrays(interpolateSqlValues(query)); query.then?.(result); } }; const makeAst$1 = (up, name, changeData, changeTableData2, options) => { const { comment } = options; const shape = {}; const consumedChanges = {}; for (const key in changeData) { let item = changeData[key]; if (typeof item === "number") { consumedChanges[item] = true; item = addOrDropChanges[item]; } else if (item instanceof pqb.ColumnType) { item = addOrDrop("add", item); } if ("type" in item) { if (up) { shape[key] = item; } else { if (item.type === "rename") { shape[item.name] = { ...item, name: key }; } else { shape[key] = item.type === "add" ? { ...item, type: "drop" } : item.type === "drop" ? { ...item, type: "add" } : item.type === "change" ? { ...item, from: item.to, to: item.from, using: item.using && { usingUp: item.using.usingDown, usingDown: item.using.usingUp } } : item; } } } } for (let i = 0; i < addOrDropChanges.length; i++) { if (consumedChanges[i]) continue; const change = addOrDropChanges[i]; const name2 = change.item.data.name; if (!name2) { throw new Error(`Column in ...t.${change.type}() must have a name`); } const arr = shape[name2] ? orchidCore.toArray(shape[name2]) : []; arr[up ? "push" : "unshift"]( up ? change : { ...change, type: change.type === "add" ? "drop" : "add" } ); shape[name2] = arr; } const [schema, table] = getSchemaAndTableFromName(name); return { type: "changeTable", schema, name: table, comment: comment ? up ? Array.isArray(comment) ? comment[1] : comment : Array.isArray(comment) ? comment[0] : null : void 0, shape, ...up ? changeTableData2 : { add: changeTableData2.drop, drop: changeTableData2.add } }; }; const astToQueries = (ast, snakeCase, language) => { const queries = []; if (ast.comment !== void 0) { queries.push({ text: `COMMENT ON TABLE ${quoteWithSchema(ast)} IS ${ast.comment === null ? "NULL" : pqb.escapeString( typeof ast.comment === "string" ? ast.comment : ast.comment[1] )}` }); } const addPrimaryKeys = { columns: [] }; const dropPrimaryKeys = { columns: [] }; for (const key in ast.shape) { const item = ast.shape[key]; if (Array.isArray(item)) { for (const it of item) { handlePrerequisitesForTableItem( key, it, queries, addPrimaryKeys, dropPrimaryKeys, snakeCase ); } } else { handlePrerequisitesForTableItem( key, item, queries, addPrimaryKeys, dropPrimaryKeys, snakeCase ); } } if (ast.add.primaryKey) { addPrimaryKeys.name = ast.add.primaryKey.name; const { columns } = ast.add.primaryKey; addPrimaryKeys.columns.push( ...snakeCase ? columns.map(orchidCore.toSnakeCase) : columns ); } if (ast.drop.primaryKey) { dropPrimaryKeys.name = ast.drop.primaryKey.name; const { columns } = ast.drop.primaryKey; dropPrimaryKeys.columns.push( ...snakeCase ? columns.map(orchidCore.toSnakeCase) : columns ); } const alterTable = []; const renameItems = []; const values = []; const addIndexes = ast.add.indexes ?? []; const dropIndexes = ast.drop.indexes ?? []; const addExcludes = ast.add.excludes ?? []; const dropExcludes = ast.drop.excludes ?? []; const addConstraints = ast.add.constraints ?? []; const dropConstraints = ast.drop.constraints ?? []; const comments = []; for (const key in ast.shape) { const item = ast.shape[key]; if (Array.isArray(item)) { for (const it of item) { handleTableItemChange( key, it, ast, alterTable, renameItems, values, addPrimaryKeys, addIndexes, dropIndexes, addExcludes, dropExcludes, addConstraints, dropConstraints, comments, snakeCase ); } } else { handleTableItemChange( key, item, ast, alterTable, renameItems, values, addPrimaryKeys, addIndexes, dropIndexes, addExcludes, dropExcludes, addConstraints, dropConstraints, comments, snakeCase ); } } const prependAlterTable = []; if (ast.drop.primaryKey || dropPrimaryKeys.change || dropPrimaryKeys.columns.length > 1) { const name = dropPrimaryKeys.name || `${ast.name}_pkey`; prependAlterTable.push(`DROP CONSTRAINT "${name}"`); } prependAlterTable.push( ...dropConstraints.map( (foreignKey) => ` DROP ${constraintToSql(ast, false, foreignKey, values, snakeCase)}` ) ); alterTable.unshift(...prependAlterTable); if (ast.add.primaryKey || addPrimaryKeys.change || addPrimaryKeys.columns.length > 1) { addPrimaryKeys.columns = [...new Set(addPrimaryKeys.columns)]; alterTable.push( `ADD ${primaryKeyToSql( snakeCase ? { name: addPrimaryKeys.name, columns: addPrimaryKeys.columns.map(orchidCore.toSnakeCase) } : addPrimaryKeys )}` ); } alterTable.push( ...addConstraints.map( (foreignKey) => ` ADD ${constraintToSql(ast, true, foreignKey, values, snakeCase)}` ) ); const tableName = quoteWithSchema(ast); if (renameItems.length) { queries.push( ...renameItems.map((sql) => ({ text: `ALTER TABLE ${tableName} ${sql}`, values })) ); } if (alterTable.length) { queries.push(alterTableSql(tableName, alterTable, values)); } queries.push(...indexesToQuery(false, ast, dropIndexes, snakeCase, language)); queries.push(...indexesToQuery(true, ast, addIndexes, snakeCase, language)); queries.push(...excludesToQuery(false, ast, dropExcludes, snakeCase)); queries.push(...excludesToQuery(true, ast, addExcludes, snakeCase)); queries.push(...commentsToQuery(ast, comments)); return queries; }; const alterTableSql = (tableName, lines, values) => ({ text: `ALTER TABLE ${tableName} ${lines.join(",\n ")}`, values }); const handlePrerequisitesForTableItem = (key, item, queries, addPrimaryKeys, dropPrimaryKeys, snakeCase) => { if ("item" in item) { const { item: column } = item; if (column instanceof pqb.EnumColumn) { queries.push(makePopulateEnumQuery(column)); } } if (item.type === "add") { if (item.item.data.primaryKey) { addPrimaryKeys.columns.push(getColumnName(item.item, key, snakeCase)); } } else if (item.type === "drop") { if (item.item.data.primaryKey) { dropPrimaryKeys.columns.push(getColumnName(item.item, key, snakeCase)); } } else if (item.type === "change") { if (item.from.column instanceof pqb.EnumColumn) { queries.push(makePopulateEnumQuery(item.from.column)); } if (item.to.column instanceof pqb.EnumColumn) { queries.push(makePopulateEnumQuery(item.to.column)); } if (item.from.primaryKey) { dropPrimaryKeys.columns.push( item.from.column ? getColumnName(item.from.column, key, snakeCase) : snakeCase ? orchidCore.toSnakeCase(key) : key ); dropPrimaryKeys.change = true; } if (item.to.primaryKey) { addPrimaryKeys.columns.push( item.to.column ? getColumnName(item.to.column, key, snakeCase) : snakeCase ? orchidCore.toSnakeCase(key) : key ); addPrimaryKeys.change = true; } } }; const handleTableItemChange = (key, item, ast, alterTable, renameItems, values, addPrimaryKeys, addIndexes, dropIndexes, addExcludes, dropExcludes, addConstraints, dropConstraints, comments, snakeCase) => { if (item.type === "add") { const column = item.item; const name = getColumnName(column, key, snakeCase); addColumnIndex(addIndexes, name, column); addColumnExclude(addExcludes, name, column); addColumnComment(comments, name, column); alterTable.push( `ADD COLUMN ${columnToSql( name, column, values, addPrimaryKeys.columns.length > 1, snakeCase )}` ); } else if (item.type === "drop") { const name = getColumnName(item.item, key, snakeCase); alterTable.push( `DROP COLUMN "${name}"${item.dropMode ? ` ${item.dropMode}` : ""}` ); } else if (item.type === "change") { const { from, to } = item; const name = getChangeColumnName("to", item, key, snakeCase); const fromName = getChangeColumnName("from", item, key, snakeCase); if (fromName !== name) { renameItems.push(renameColumnSql(fromName, name)); } let changeType = false; if (to.type && (from.type !== to.type || from.collate !== to.collate)) { changeType = true; const type = !to.column || to.column.data.isOfCustomType ? to.column && to.column instanceof pqb.DomainColumn ? quoteNameFromString(to.type) : quoteCustomType(to.type) : to.type; const using = item.using?.usingUp ? ` USING ${item.using.usingUp.toSQL({ values })}` : to.column instanceof pqb.EnumColumn ? ` USING "${name}"::text::${type}` : to.column instanceof pqb.ArrayColumn ? ` USING "${name}"::text[]::${type}` : ""; alterTable.push( `ALTER COLUMN "${name}" TYPE ${type}${to.collate ? ` COLLATE ${quoteNameFromString(to.collate)}` : ""}${using}` ); } if (typeof from.identity !== typeof to.identity || !orchidCore.deepCompare(from.identity, to.identity)) { if (from.identity) { alterTable.push(`ALTER COLUMN "${name}" DROP IDENTITY`); } if (to.identity) { alterTable.push( `ALTER COLUMN "${name}" ADD ${identityToSql(to.identity)}` ); } } if (from.default !== to.default) { const value = encodeColumnDefault(to.default, values, to.column); if (changeType && value !== null) { alterTable.push(`ALTER COLUMN "${name}" DROP DEFAULT`); } const expr = value === null ? "DROP DEFAULT" : `SET DEFAULT ${value}`; alterTable.push(`ALTER COLUMN "${name}" ${expr}`); } if (from.nullable !== to.nullable) { alterTable.push( `ALTER COLUMN "${name}" ${to.nullable ? "DROP" : "SET"} NOT NULL` ); } if (from.compression !== to.compression) { alterTable.push( `ALTER COLUMN "${name}" SET COMPRESSION ${to.compression || "DEFAULT"}` ); } const fromChecks = from.checks && nameColumnChecks(ast.name, fromName, from.checks); const toChecks = to.checks && nameColumnChecks(ast.name, name, to.checks); fromChecks?.forEach((fromCheck) => { if (!toChecks?.some((toCheck) => cmpRawSql(fromCheck.sql, toCheck.sql))) { alterTable.push(`DROP CONSTRAINT "${fromCheck.name}"`); } }); toChecks?.forEach((toCheck) => { if (!fromChecks?.some((fromCheck) => cmpRawSql(fromCheck.sql, toCheck.sql))) { alterTable.push( `ADD CONSTRAINT "${toCheck.name}" CHECK (${toCheck.sql.toSQL({ values })})` ); } }); const foreignKeysLen = Math.max( from.foreignKeys?.length || 0, to.foreignKeys?.length || 0 ); for (let i = 0; i < foreignKeysLen; i++) { const fromFkey = from.foreignKeys?.[i]; const toFkey = to.foreignKeys?.[i]; if ((fromFkey || toFkey) && (!fromFkey || !toFkey || fromFkey.options?.name !== toFkey.options?.name || fromFkey.options?.match !== toFkey.options?.match || fromFkey.options?.onUpdate !== toFkey.options?.onUpdate || fromFkey.options?.onDelete !== toFkey.options?.onDelete || fromFkey.options?.dropMode !== toFkey.options?.dropMode || fromFkey.fnOrTable !== toFkey.fnOrTable)) { if (fromFkey) { dropConstraints.push({ name: fromFkey.options?.name, dropMode: fromFkey.options?.dropMode, references: { columns: [name], ...fromFkey, foreignColumns: snakeCase ? fromFkey.foreignColumns.map(orchidCore.toSnakeCase) : fromFkey.foreignColumns } }); } if (toFkey) { addConstraints.push({ name: toFkey.options?.name, dropMode: toFkey.options?.dropMode, references: { columns: [name], ...toFkey, foreignColumns: snakeCase ? toFkey.foreignColumns.map(orchidCore.toSnakeCase) : toFkey.foreignColumns } }); } } } pushIndexesOrExcludes("indexes", from, to, name, addIndexes, dropIndexes); pushIndexesOrExcludes( "excludes", from, to, name, addExcludes, dropExcludes ); if (from.comment !== to.comment) { comments.push({ column: name, comment: to.comment || null }); } } else if (item.type === "rename") { renameItems.push( snakeCase ? renameColumnSql(orchidCore.toSnakeCase(key), orchidCore.toSnakeCase(item.name)) : renameColumnSql(key, item.name) ); } }; const pushIndexesOrExcludes = (key, from, to, name, add2, drop2) => { const len = Math.max(from[key]?.length || 0, to[key]?.length || 0); for (let i = 0; i < len; i++) { const fromItem = from[key]?.[i]; const toItem = to[key]?.[i]; if ((fromItem || toItem) && (!fromItem || !toItem || !orchidCore.deepCompare(fromItem, toItem))) { if (fromItem) { drop2.push({ ...fromItem, columns: [ { column: name, ...fromItem.options, with: fromItem.with } ] }); } if (toItem) { add2.push({ ...toItem, columns: [ { column: name, ...toItem.options, with: toItem.with } ] }); } } } }; const getChangeColumnName = (what, change, key, snakeCase) => { return change.name || (change[what].column ? ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion getColumnName(change[what].column, key, snakeCase) ) : snakeCase ? orchidCore.toSnakeCase(key) : key); }; const renameColumnSql = (from, to) => { return `RENAME COLUMN "${from}" TO "${to}"`; }; const createView = async (migration, up, name, options, sql) => { const ast = makeAst(up, name, options, sql); const query = astToQuery(ast); await migration.adapter.arrays(interpolateSqlValues(query)); }; const makeAst = (up, name, options, sql) => { if (typeof sql === "string") { sql = pqb.raw({ raw: sql }); } return { type: "view", action: up ? "create" : "drop", name, shape: {}, sql, options, deps: [] }; }; const astToQuery = (ast) => { const values = []; const sql = []; const { options } = ast; if (ast.action === "create") { sql.push("CREATE"); if (options?.createOrReplace) sql.push("OR REPLACE"); if (options?.temporary) sql.push("TEMPORARY"); if (options?.recursive) sql.push("RECURSIVE"); sql.push(`VIEW "${ast.name}"`); if (options?.columns) { sql.push( `(${options.columns.map((column) => `"${column}"`).join(", ")})` ); } if (options?.with) { const list = []; if (options.with.checkOption) list.push(`check_option = ${orchidCore.singleQuote(options.with.checkOption)}`); if (options.with.securityBarrier) list.push(`security_barrier = true`); if (options.with.securityInvoker) list.push(`security_invoker = true`); sql.push(`WITH ( ${list.join(", ")} )`); } sql.push(`AS (${ast.sql.toSQL({ values })})`); } else { sql.push("DROP VIEW"); if (options?.dropIfExists) sql.push(`IF EXISTS`); sql.push(`"${ast.name}"`); if (options?.dropMode) sql.push(options.dropMode); } return { text: sql.join(" "), values }; }; const createMigrationInterface = (tx, up, config) => { const adapter = new pqb.TransactionAdapter( tx, tx.client, tx.types ); adapter.schema = adapter.adapter.schema ?? "public"; const { query, arrays } = adapter; const log = pqb.logParamToLogObject(config.logger || console, config.log); adapter.query = (q, types) => { return wrapWithLog(log, q, () => query.call(adapter, q, types)); }; adapter.arrays = (q, types) => { return wrapWithLog(log, q, () => arrays.call(adapter, q, types)); }; Object.assign(adapter, { silentQuery: query, silentArrays: arrays }); const db = pqb.createDb({ adapter, columnTypes: config.columnTypes }); const { prototype: proto } = Migration; for (const key of Object.getOwnPropertyNames(proto)) { db[key] = proto[key]; } return Object.assign(db, { adapter, log, up, options: config }); }; class Migration { createTable(tableName, first, second, third) { return createTable(this, this.up, tableName, first, second, third); } dropTable(tableName, first, second, third) { return createTable(this, !this.up, tableName, first, second, third); } changeTable(tableName, cbOrOptions, cb) { const [fn, options] = typeof cbOrOptions === "function" ? [cbOrOptions, {}] : [cb, cbOrOptions]; return changeTable(this, this.up, tableName, options, fn); } /** * Rename a table: * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * await db.renameTable('oldTableName', 'newTableName'); * }); * ``` * * Prefix table name with a schema to set a different schema: * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * await db.renameTable('fromSchema.oldTable', 'toSchema.newTable'); * }); * ``` * * @param from - rename the table from * @param to - rename the table to */ renameTable(from, to) { return renameType(this, from, to, "TABLE"); } /** * Set a different schema to the table: * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * await db.changeTableSchema('tableName', 'fromSchema', 'toSchema'); * }); * ``` * * @param table - table name * @param from - current table schema * @param to - desired table schema */ changeTableSchema(table, from, to) { return this.renameTable(`${from}.${table}`, `${to}.${table}`); } /** * Add a column to the table on migrating, and remove it on rollback. * * `dropColumn` takes the same arguments, removes a column on migrate, and adds it on rollback. * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * await db.addColumn('tableName', 'columnName', (t) => * t.integer().index().nullable(), * ); * }); * ``` * * @param tableName - name of the table to add the column to * @param columnName - name of the column to add * @param fn - function returning a type of the column */ addColumn(tableName, columnName, fn) { return addColumn(this, this.up, tableName, columnName, fn); } /** * Drop the schema, create it on rollback. See {@link addColumn}. * * @param tableName - name of the table to add the column to * @param columnName - name of the column to add * @param fn - function returning a type of the column */ dropColumn(tableName, columnName, fn) { return addColumn(this, !this.up, tableName, columnName, fn); } /** * Add an index to the table on migrating, and remove it on rollback. * * `dropIndex` takes the same arguments, removes the index on migrate, and adds it on rollback. * * The first argument is the table name, other arguments are the same as in [composite index](#composite-index). * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * await db.addIndex( * 'tableName', * ['column1', { column: 'column2', order: 'DESC' }], * { * name: 'indexName', * }, * ); * }); * ``` * * @param tableName - name of the table to add the index for * @param columns - indexed columns * @param args - index options, or an index name and then options */ addIndex(tableName, columns, ...args) { return addIndex(this, this.up, tableName, columns, args); } /** * Drop the schema, create it on rollback. See {@link addIndex}. * * @param tableName - name of the table to add the index for * @param columns - indexed columns * @param args - index options, or an index name and then options */ dropIndex(tableName, columns, ...args) { return addIndex(this, !this.up, tableName, columns, args); } /** * Rename index: * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * // tableName can be prefixed with a schema * await db.renameIndex('tableName', 'oldIndexName', 'newIndexName'); * }); * ``` * * @param tableName - table which this index belongs to * @param from - rename the index from * @param to - rename the index to */ renameIndex(tableName, from, to) { return renameTableItem(this, tableName, from, to, "INDEX"); } /** * Add a foreign key to a table on migrating, and remove it on rollback. * * `dropForeignKey` takes the same arguments, removes the foreign key on migrate, and adds it on rollback. * * Arguments: * * - table name * - column names in the table * - other table name * - column names in the other table * - options: * - `name`: constraint name * - `match`: 'FULL', 'PARTIAL', or 'SIMPLE' * - `onUpdate` and `onDelete`: 'NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', or 'SET DEFAULT' * * The first argument is the table name, other arguments are the same as in [composite foreign key](#composite-foreign-key). * * ```ts * import { change } from '../dbScript'; * * change(async (db) => { * await db.addForeignKey( * 'tableName', * ['id', 'name'], * 'otherTable', * ['foreignId', 'foreignName'], * { * name: 'constraintName', * match: 'FULL', * onUpdate: 'RESTRICT', * onDelete: 'CASCADE', * }, * ); * }); * ``` * * @param tableName - table name * @param columns - column names in the table * @param foreignTable - other table name * @param foreignColumns - column names in the other table * @param options - foreign key options */ addForeignKey(tableName, columns, foreignTable, foreignColumns, options) { return addForeignKey( this, this.up, tableName, columns, foreignTable, foreignColumns, options ); } /*