diff --git a/src/api/CatalogApi.js b/src/api/CatalogApi.js index 122ff3e..e92bce9 100644 --- a/src/api/CatalogApi.js +++ b/src/api/CatalogApi.js @@ -372,6 +372,21 @@ const query = { getAvailableExtensions: function () { return `SELECT name, installed_version FROM pg_available_extensions`; }, + /** + * + * @param {String[]} schemas + */ + getEnumTypes: function (schemas) { + return `SELECT n.nspname AS schema_name, t.typname AS type_name, e.enumlabel AS enum_value + FROM pg_type t + JOIN pg_enum e ON e.enumtypid = t.oid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname IN ('${schemas.join("','")}') + AND t.oid NOT IN ( + SELECT d.objid FROM pg_depend d WHERE d.deptype = 'e' + ) + ORDER BY n.nspname, t.typname, e.enumsortorder`; + }, }; class CatalogApi { @@ -827,6 +842,25 @@ class CatalogApi { return result; } + /** + * + * @param {import("pg").Client} client + * @param {import("../models/config")} config + */ + static async retrieveEnumTypes(client, config) { + let result = {}; + + const rows = await client.query(query.getEnumTypes(config.compareOptions.schemaCompare.namespaces)); + + rows.rows.forEach((row) => { + const fullTypeName = `"${row.schema_name}"."${row.type_name}"`; + if (!result[fullTypeName]) result[fullTypeName] = { values: [] }; + result[fullTypeName].values.push(row.enum_value); + }); + + return result; + } + /** * * @param {import("pg").Client} client diff --git a/src/api/CompareApi.js b/src/api/CompareApi.js index f50af50..70b1619 100644 --- a/src/api/CompareApi.js +++ b/src/api/CompareApi.js @@ -107,6 +107,7 @@ class CompareApi { dbObjects.aggregates = await catalogApi.retrieveAggregates(client, config); dbObjects.sequences = await catalogApi.retrieveSequences(client, config); dbObjects.extensions = await catalogApi.retrieveExtensions(client); + dbObjects.enumTypes = await catalogApi.retrieveEnumTypes(client, config); //TODO: Add a way to retrieve AGGREGATE and WINDOW functions //TODO: Do we need to retrieve roles? @@ -142,6 +143,10 @@ class CompareApi { ) { let sqlPatch = []; + let enumScripts = this.compareEnumTypes(dbSourceObjects.enumTypes, dbTargetObjects.enumTypes); + sqlPatch.push(...enumScripts.topScripts); + eventEmitter.emit("compare", "ENUM TYPE objects have been compared", 43); + sqlPatch.push(...this.compareExtensions(dbSourceObjects.extensions, dbTargetObjects.extensions)); eventEmitter.emit("compare", "SCHEMA objects have been compared", 45); @@ -188,9 +193,50 @@ class CompareApi { sqlPatch.push(...this.compareTablesTriggers(dbSourceObjects.tables, dbTargetObjects.tables, addedTables)); eventEmitter.emit("compare", "TRIGGER objects have been compared", 85); + sqlPatch.push(...enumScripts.bottomScripts); + return sqlPatch; } + /** + * + * @param {Object} sourceEnumTypes + * @param {Object} targetEnumTypes + * @returns {{ topScripts: String[], bottomScripts: String[] }} + */ + static compareEnumTypes(sourceEnumTypes, targetEnumTypes) { + let topScripts = []; + let bottomScripts = []; + + for (let sourceType in sourceEnumTypes) { + if (!targetEnumTypes[sourceType]) { + topScripts.push(...this.finalizeScript(`CREATE ENUM TYPE ${sourceType}`, [sql.generateCreateEnumTypeScript(sourceType, sourceEnumTypes[sourceType].values)])); + } else { + const tgtSet = new Set(targetEnumTypes[sourceType].values); + const srcSet = new Set(sourceEnumTypes[sourceType].values); + const newValues = sourceEnumTypes[sourceType].values.filter((v) => !tgtSet.has(v)); + const removedValues = targetEnumTypes[sourceType].values.filter((v) => !srcSet.has(v)); + + if (newValues.length > 0) + topScripts.push(...this.finalizeScript(`ALTER ENUM TYPE ${sourceType}`, [sql.generateAddEnumValueScript(sourceType, newValues)])); + + if (removedValues.length > 0) + bottomScripts.push( + ...this.finalizeScript(`WARNING ENUM TYPE ${sourceType}`, [ + sql.generateRebuildEnumTypeWarningScript(sourceType, sourceEnumTypes[sourceType].values, targetEnumTypes[sourceType].values), + ]) + ); + } + } + + for (let targetType in targetEnumTypes) { + if (!sourceEnumTypes[targetType]) + bottomScripts.push(...this.finalizeScript(`DROP ENUM TYPE ${targetType}`, [sql.generateDropEnumTypeScript(targetType)])); + } + + return { topScripts, bottomScripts }; + } + /** * * @param {String} scriptLabel @@ -225,7 +271,7 @@ class CompareApi { sqlScript.push(sql.generateChangeCommentScript(objectType.SCHEMA, sourceSchema, sourceSchemas[sourceSchema].comment)); } - if (targetSchemas[sourceSchema] && sourceSchemas[sourceSchema].comment != targetSchemas[sourceSchema].comment) + if (targetSchemas[sourceSchema] && CompareApi.normalizeComment(sourceSchemas[sourceSchema].comment) != CompareApi.normalizeComment(targetSchemas[sourceSchema].comment)) sqlScript.push(sql.generateChangeCommentScript(objectType.SCHEMA, sourceSchema, sourceSchemas[sourceSchema].comment)); finalizedScript.push(...this.finalizeScript(`CREATE OR UPDATE SCHEMA ${sourceSchema}`, sqlScript)); @@ -299,7 +345,7 @@ class CompareApi { if (sourceTables[sourceTable].owner != dbTargetObjects.tables[sourceTable].owner) sqlScript.push(sql.generateChangeTableOwnerScript(sourceTable, sourceTables[sourceTable].owner)); - if (sourceTables[sourceTable].comment != dbTargetObjects.tables[sourceTable].comment) + if (CompareApi.normalizeComment(sourceTables[sourceTable].comment) != CompareApi.normalizeComment(dbTargetObjects.tables[sourceTable].comment)) sqlScript.push(sql.generateChangeCommentScript(objectType.TABLE, sourceTable, sourceTables[sourceTable].comment)); } else { //Table not exists on target database, then generate the script to create table @@ -502,7 +548,7 @@ class CompareApi { sqlScript.push(sql.generateChangeTableColumnScript(tableName, columnName, changes)); } - if (sourceTableColumn.comment != targetTableColumn.comment) + if (CompareApi.normalizeComment(sourceTableColumn.comment) != CompareApi.normalizeComment(targetTableColumn.comment)) sqlScript.push(sql.generateChangeCommentScript(objectType.COLUMN, `${tableName}.${columnName}`, sourceTableColumn.comment)); return sqlScript; @@ -538,7 +584,7 @@ class CompareApi { sql.generateChangeCommentScript(objectType.CONSTRAINT, constraint, sourceTableConstraints[constraint].comment, tableName) ); } else { - if (sourceTableConstraints[constraint].comment != targetTableConstraints[constraint].comment) + if (CompareApi.normalizeComment(sourceTableConstraints[constraint].comment) != CompareApi.normalizeComment(targetTableConstraints[constraint].comment)) sqlScript.push( sql.generateChangeCommentScript( objectType.CONSTRAINT, @@ -605,7 +651,7 @@ class CompareApi { ) ); } else { - if (sourceTableIndexes[index].comment != targetTableIndexes[index].comment) + if (CompareApi.normalizeComment(sourceTableIndexes[index].comment) != CompareApi.normalizeComment(targetTableIndexes[index].comment)) sqlScript.push( sql.generateChangeCommentScript( objectType.INDEX, @@ -726,7 +772,7 @@ class CompareApi { if (sourceTableTriggers[trigger].definition != targetTableTriggers[trigger].definition) { sqlScript.push(sql.generateDropTriggerScript(tableName, trigger)); sqlScript.push(sql.generateCreateTriggerScript(sourceTableTriggers[trigger])); - if (sourceTableTriggers[trigger].comment != targetTableTriggers[trigger].comment) + if (CompareApi.normalizeComment(sourceTableTriggers[trigger].comment) != CompareApi.normalizeComment(targetTableTriggers[trigger].comment)) sqlScript.push(sql.generateChangeCommentScript(objectType.TRIGGER, trigger, sourceTableTriggers[trigger].comment, tableName)); } } else { @@ -778,7 +824,7 @@ class CompareApi { if (sourceViews[view].owner != targetViews[view].owner) sqlScript.push(sql.generateChangeTableOwnerScript(view, sourceViews[view].owner)); - if (sourceViews[view].comment != targetViews[view].comment) + if (CompareApi.normalizeComment(sourceViews[view].comment) != CompareApi.normalizeComment(targetViews[view].comment)) sqlScript.push(sql.generateChangeCommentScript(objectType.VIEW, view, sourceViews[view].comment)); } } else { @@ -852,7 +898,7 @@ class CompareApi { if (sourceMaterializedViews[view].owner != targetMaterializedViews[view].owner) sqlScript.push(sql.generateChangeTableOwnerScript(view, sourceMaterializedViews[view].owner)); - if (sourceMaterializedViews[view].comment != targetMaterializedViews[view].comment) + if (CompareApi.normalizeComment(sourceMaterializedViews[view].comment) != CompareApi.normalizeComment(targetMaterializedViews[view].comment)) sqlScript.push(sql.generateChangeCommentScript(objectType.MATERIALIZED_VIEW, view, sourceMaterializedViews[view].comment)); } } else { @@ -932,7 +978,7 @@ class CompareApi { ) ); - if (sourceFunctions[procedure][procedureArgs].comment != sourceFunctions[procedure][procedureArgs].comment) + if (CompareApi.normalizeComment(sourceFunctions[procedure][procedureArgs].comment) != CompareApi.normalizeComment(targetFunctions[procedure][procedureArgs].comment)) sqlScript.push( sql.generateChangeCommentScript( procedureType, @@ -1018,7 +1064,7 @@ class CompareApi { sql.generateChangeAggregateOwnerScript(aggregate, aggregateArgs, sourceAggregates[aggregate][aggregateArgs].owner) ); - if (sourceAggregates[aggregate][aggregateArgs].comment != targetAggregates[aggregate][aggregateArgs].comment) + if (CompareApi.normalizeComment(sourceAggregates[aggregate][aggregateArgs].comment) != CompareApi.normalizeComment(targetAggregates[aggregate][aggregateArgs].comment)) sqlScript.push( sql.generateChangeCommentScript( objectType.AGGREGATE, @@ -1117,7 +1163,7 @@ class CompareApi { ...this.compareSequencePrivileges(sequence, sourceSequences[sequence].privileges, targetSequences[targetSequence].privileges) ); - if (sourceSequences[sequence].comment != targetSequences[targetSequence].comment) + if (CompareApi.normalizeComment(sourceSequences[sequence].comment) != CompareApi.normalizeComment(targetSequences[targetSequence].comment)) sqlScript.push(sql.generateChangeCommentScript(objectType.SEQUENCE, sequence, sourceSequences[sequence].comment)); } else { //Sequence not exists on target database, then generate the script to create sequence @@ -1650,6 +1696,11 @@ class CompareApi { return finalizedScript; } + + static normalizeComment(comment) { + if (!comment) return comment; + return comment.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + } } module.exports = CompareApi; diff --git a/src/enums/objectType.js b/src/enums/objectType.js index 0e8784c..9aa9586 100644 --- a/src/enums/objectType.js +++ b/src/enums/objectType.js @@ -11,4 +11,5 @@ module.exports = { SEQUENCE: "SEQUENCE", PROCEDURE: "PROCEDURE", TRIGGER: "TRIGGER", + ENUM_TYPE: "ENUM TYPE", }; diff --git a/src/models/databaseObjects.js b/src/models/databaseObjects.js index 0a03ab4..1581e97 100644 --- a/src/models/databaseObjects.js +++ b/src/models/databaseObjects.js @@ -16,6 +16,8 @@ class DatabaseObjects { this.sequences = null; /** @type {Object} The definition of extensions*/ this.extensions = null; + /** @type {Object} The definition of enum types*/ + this.enumTypes = null; } } diff --git a/src/sqlScriptGenerator.js b/src/sqlScriptGenerator.js index 465bc7e..5714c9c 100644 --- a/src/sqlScriptGenerator.js +++ b/src/sqlScriptGenerator.js @@ -869,6 +869,52 @@ CREATE SEQUENCE IF NOT EXISTS ${sequence} let script = `\nALTER EXTENSION "${name}" UPDATE TO '${version}';${hints.extensionToUpdate}\n`; return script; }, + /** + * + * @param {String} typeName fully-qualified enum type name (e.g. "public"."my_type") + * @param {String[]} values ordered list of enum values + */ + generateCreateEnumTypeScript: function (typeName, values) { + const valueList = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", "); + return `\nCREATE TYPE ${typeName} AS ENUM (${valueList});\n`; + }, + /** + * + * @param {String} typeName fully-qualified enum type name + * @param {String[]} newValues values to add (must not already exist in target) + */ + generateAddEnumValueScript: function (typeName, newValues) { + const lines = newValues.map((v) => `ALTER TYPE ${typeName} ADD VALUE IF NOT EXISTS '${v.replace(/'/g, "''")}';`); + return `\n${lines.join("\n")}\n`; + }, + /** + * + * @param {String} typeName fully-qualified enum type name + */ + generateDropEnumTypeScript: function (typeName) { + return `\nDROP TYPE IF EXISTS ${typeName};\n`; + }, + /** + * Generates a warning comment block when enum values were removed in source. + * PostgreSQL does not support removing enum values without recreating the type. + * + * @param {String} typeName fully-qualified enum type name + * @param {String[]} sourceValues desired values (from source) + * @param {String[]} targetValues current values (in target) + */ + generateRebuildEnumTypeWarningScript: function (typeName, sourceValues, targetValues) { + const srcSet = new Set(sourceValues); + const removedValues = targetValues.filter((v) => !srcSet.has(v)); + const valueList = sourceValues.map((v) => `'${v.replace(/'/g, "''")}'`).join(", "); + return [ + `--WARN: Enum values present in TARGET but not in SOURCE: [${removedValues.join(", ")}]`, + `--WARN: PostgreSQL does not support removing enum values without recreating the type. Manual action required:`, + `-- 1. ALTER TYPE ${typeName} RENAME TO ;`, + `-- 2. CREATE TYPE ${typeName} AS ENUM (${valueList});`, + `-- 3. Migrate all columns and objects that reference this type.`, + `-- 4. DROP TYPE ;`, + ].join("\n"); + }, }; module.exports = helper;