feat(schema-diff): support case-insensitive table and column matching

This commit is contained in:
jearhe
2026-09-06 18:24:44 +08:00
committed by GitHub
parent 0ef8369e2b
commit 3123e962c8
23 changed files with 726 additions and 94 deletions
@@ -83,17 +83,17 @@ const restrictTables = ref(false);
const localSelectedTables = ref<string[]>([]);
const activeTableMappings = computed(() => props.options?.tableMappings ?? []);
const tableMatches = computed<SchemaDiffTableMatch[]>(() => buildSchemaDiffTableMatches(localSelectedTables.value, targetTableList.value, activeTableMappings.value));
const tableMatches = computed<SchemaDiffTableMatch[]>(() => buildSchemaDiffTableMatches(localSelectedTables.value, targetTableList.value, activeTableMappings.value, props.options.ignoreTableNameCase));
const matchedTableCount = computed(() => tableMatches.value.filter((match) => match.targetTable).length);
const missingTargetTables = computed(() => tableMatches.value.filter((match) => !match.targetTable).map((match) => match.sourceTable));
const tableMappingConflictSource = ref<string | null>(null);
function targetTableOptions(sourceTable: string): string[] {
return availableSchemaDiffTargetTables(sourceTable, targetTableList.value, activeTableMappings.value);
return availableSchemaDiffTargetTables(sourceTable, targetTableList.value, activeTableMappings.value, props.options.ignoreTableNameCase);
}
function handleTableMappingUpdate(sourceTable: string, targetTable: string) {
const update = updateSchemaDiffTableMapping(activeTableMappings.value, sourceTable, targetTable);
const update = updateSchemaDiffTableMapping(activeTableMappings.value, sourceTable, targetTable, props.options.ignoreTableNameCase);
if (update.accepted) {
tableMappingConflictSource.value = null;
emitTableMappings(update.mappings);
@@ -107,7 +107,7 @@ function emitTableMappings(nextMappings: SchemaDiffTableMapping[]) {
}
function reconcileTableMappings(selectedTables: string[], targetTables = targetTableList.value) {
const nextMappings = targetTableListLoaded.value ? reconcileSchemaDiffTableMappings(selectedTables, targetTables, activeTableMappings.value) : pruneSchemaDiffTableMappings(selectedTables, activeTableMappings.value);
const nextMappings = targetTableListLoaded.value ? reconcileSchemaDiffTableMappings(selectedTables, targetTables, activeTableMappings.value, props.options.ignoreTableNameCase) : pruneSchemaDiffTableMappings(selectedTables, activeTableMappings.value);
emitTableMappings(nextMappings);
}
@@ -591,6 +591,8 @@ async function handleCompare() {
ignoreComments: ignoreComments.value,
cascadeDelete: opts?.cascadeDelete ?? false,
compareColumnOrder: opts.compareColumnOrder,
ignoreTableNameCase: opts.ignoreTableNameCase,
ignoreColumnNameCase: opts.ignoreColumnNameCase,
detectRenames: opts?.detectRenames ?? false,
detectTableRenames: opts?.detectTableRenames ?? false,
renameThreshold: opts?.renameThreshold ?? 0.5,
@@ -112,6 +112,8 @@ describe("SchemaDiffDialog fullscreen layout", () => {
expect(configStepSource).toContain("tableMatchStatus.${match.kind}");
expect(dialogSource).toContain('@update:table-mappings="handleTableMappingsUpdate"');
expect(dialogSource).toContain("tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings");
expect(dialogSource).toContain("ignoreTableNameCase: opts.ignoreTableNameCase");
expect(dialogSource).toContain("ignoreColumnNameCase: opts.ignoreColumnNameCase");
expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);");
});
@@ -2,7 +2,8 @@
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_POSTGRES_OPTIONS, type SchemaDiffCompareOptions, type SchemaDiffOptionItem } from "@/types/schemaDiff";
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
import { DEFAULT_POSTGRES_OPTIONS, normalizeSchemaDiffCompareOptions, type SchemaDiffCompareOptions, type SchemaDiffOptionItem } from "@/types/schemaDiff";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
@@ -49,6 +50,14 @@ afterEach(() => {
});
describe("SchemaDiffOptionsPanel", () => {
it("exposes independent case-insensitive name options with false defaults", () => {
const ids = getSchemaDiffOptionsForDbType("postgres").map((item) => item.id);
expect(ids).toEqual(expect.arrayContaining(["ignoreTableNameCase", "ignoreColumnNameCase"]));
const normalized = normalizeSchemaDiffCompareOptions({});
expect(normalized.ignoreTableNameCase).toBe(false);
expect(normalized.ignoreColumnNameCase).toBe(false);
});
it("keeps unsaved checkbox edits when the parent refreshes equivalent options", async () => {
const options = ref<SchemaDiffCompareOptions>({ ...DEFAULT_POSTGRES_OPTIONS });
const optionTree: SchemaDiffOptionItem[] = [{ id: "views", labelKey: "views", defaultChecked: true }];
+2
View File
@@ -6325,6 +6325,8 @@ export default {
cascadeDelete: "Use CASCADE delete",
sequenceLastValues: "Compare sequence last values",
compareColumnOrder: "Compare column order",
ignoreTableNameCase: "Ignore table name case",
ignoreColumnNameCase: "Ignore column name case",
detectRenames: "Detect renames",
detectTableRenames: "Detect table renames",
enableRollback: "Enable rollback",
+2
View File
@@ -5933,6 +5933,8 @@ export default withEnglishFallback({
cascadeDelete: "Use CASCADE delete",
sequenceLastValues: "Compare sequence last values",
compareColumnOrder: "Compare column order",
ignoreTableNameCase: "Ignorar mayúsculas y minúsculas del nombre de la tabla",
ignoreColumnNameCase: "Ignorar mayúsculas y minúsculas del nombre de la columna",
detectRenames: "Detectar renombrados",
detectTableRenames: "Detectar renombrados de tabla",
enableRollback: "Habilitar reversión",
+2
View File
@@ -5933,6 +5933,8 @@ export default withEnglishFallback({
cascadeDelete: "Usa CASCADE delete",
sequenceLastValues: "Confronta ultimi valori sequenza",
compareColumnOrder: "Confronta ordine colonne",
ignoreTableNameCase: "Ignora maiuscole/minuscole del nome della tabella",
ignoreColumnNameCase: "Ignora maiuscole/minuscole del nome della colonna",
detectRenames: "Rileva ridenominazioni",
detectTableRenames: "Rileva ridenominazioni tabelle",
enableRollback: "Abilita rollback",
+2
View File
@@ -5971,6 +5971,8 @@ export default withEnglishFallback({
cascadeDelete: "CASCADE削除を使用",
sequenceLastValues: "シーケンス最終値を比較",
compareColumnOrder: "列順序を比較",
ignoreTableNameCase: "テーブル名の大文字小文字を無視",
ignoreColumnNameCase: "列名の大文字小文字を無視",
detectRenames: "名前変更を検出",
detectTableRenames: "テーブル名変更を検出",
enableRollback: "ロールバックを有効化",
+2
View File
@@ -5674,6 +5674,8 @@ export default withEnglishFallback({
cascadeDelete: "CASCADE 삭제 사용",
sequenceLastValues: "시퀀스 마지막 값 비교",
compareColumnOrder: "컬럼 순서 비교",
ignoreTableNameCase: "테이블 이름 대소문자 무시",
ignoreColumnNameCase: "컬럼 이름 대소문자 무시",
detectRenames: "이름 변경 감지",
detectTableRenames: "테이블 이름 변경 감지",
enableRollback: "롤백 활성화",
+2
View File
@@ -5935,6 +5935,8 @@ export default withEnglishFallback({
cascadeDelete: "Usar CASCADE delete",
sequenceLastValues: "Comparar últimos valores de sequência",
compareColumnOrder: "Comparar ordem das colunas",
ignoreTableNameCase: "Ignorar maiúsculas/minúsculas do nome da tabela",
ignoreColumnNameCase: "Ignorar maiúsculas/minúsculas do nome da coluna",
detectRenames: "Detectar renomeações",
detectTableRenames: "Detectar renomeações de tabela",
enableRollback: "Habilitar rollback",
+2
View File
@@ -6238,6 +6238,8 @@ export default withEnglishFallback({
cascadeDelete: "CASCADE silme kullan",
sequenceLastValues: "Dizi son değerlerini karşılaştır",
compareColumnOrder: "Sütun sırasını karşılaştır",
ignoreTableNameCase: "Tablo adı büyük/küçük harflerini yoksay",
ignoreColumnNameCase: "Sütun adı büyük/küçük harflerini yoksay",
detectRenames: "Yeniden adlandırmaları algıla",
detectTableRenames: "Tablo yeniden adlandırmalarını algıla",
enableRollback: "Geri almayı etkinleştir",
+2
View File
@@ -6308,6 +6308,8 @@ export default withEnglishFallback({
cascadeDelete: "使用级联删除",
sequenceLastValues: "比较序列最后值",
compareColumnOrder: "比较字段顺序",
ignoreTableNameCase: "忽略表名大小写",
ignoreColumnNameCase: "忽略字段名大小写",
detectRenames: "检测重命名",
detectTableRenames: "检测表重命名",
enableRollback: "启用回滚",
+2
View File
@@ -5253,6 +5253,8 @@ export default withEnglishFallback({
cascadeDelete: "使用級聯刪除",
sequenceLastValues: "比較序列最後值",
compareColumnOrder: "比較欄位順序",
ignoreTableNameCase: "忽略資料表名稱大小寫",
ignoreColumnNameCase: "忽略欄位名稱大小寫",
detectRenames: "檢測重新命名",
detectTableRenames: "檢測表重新命名",
enableRollback: "啟用回溯",
@@ -97,6 +97,17 @@ describe("schemaDiffTableFilter", () => {
expect(result.targetTables.map((t) => t.name)).toEqual(["a", "c"]);
});
it("loads a unique case-insensitive target for explicitly selected tables", () => {
const options = { ...baseOptions, ignoreTableNameCase: true };
const source: TableInfo[] = [{ name: "USER_INFO", table_type: "BASE TABLE" }];
const target: TableInfo[] = [
{ name: "user_info", table_type: "BASE TABLE" },
{ name: "other", table_type: "BASE TABLE" },
];
const result = filterSchemaDiffTables(source, target, compileSchemaDiffTableFilter(options), options, ["USER_INFO"]);
expect(result.targetTables.map((table) => table.name)).toEqual(["user_info"]);
});
it("does not include unselected target tables that would otherwise look like drops", () => {
const source: TableInfo[] = [{ name: "a", table_type: "BASE TABLE" }];
const target: TableInfo[] = [
@@ -15,6 +15,17 @@ describe("schema diff table mappings", () => {
]);
});
it("matches table names case-insensitively only when enabled and rejects ambiguous candidates", () => {
expect(reconcileSchemaDiffTableMappings(["User"], ["user"], [], true)).toEqual([{ sourceTable: "User", targetTable: "user" }]);
expect(reconcileSchemaDiffTableMappings(["user"], ["User", "USER"], [], true)).toEqual([]);
expect(reconcileSchemaDiffTableMappings(["foo", "FOO"], ["FOO"], [], true)).toEqual([{ sourceTable: "FOO", targetTable: "FOO" }]);
expect(buildSchemaDiffTableMatches(["User"], ["user"], [], true)).toEqual([{ sourceTable: "User", targetTable: "user", kind: "automatic" }]);
});
it("keeps explicit table mappings ahead of case-insensitive automatic matching", () => {
expect(reconcileSchemaDiffTableMappings(["source_a"], ["SOURCE_A", "target_b"], [{ sourceTable: "source_a", targetTable: "target_b" }], true)).toEqual([{ sourceTable: "source_a", targetTable: "target_b" }]);
});
it("leaves a source unmatched when only some names exist on target", () => {
expect(buildSchemaDiffTableMatches(["a", "b", "c"], ["a", "x", "c"], [])).toEqual([
{ sourceTable: "a", targetTable: "a", kind: "automatic" },
@@ -79,6 +90,8 @@ describe("schema diff table mappings", () => {
const restored = normalizeSchemaDiffCompareOptions(JSON.parse(JSON.stringify(options)));
expect(restored.tableMappings).toEqual([{ sourceTable: "a", targetTable: "a_new" }]);
expect(normalizeSchemaDiffCompareOptions({}).tableMappings).toEqual([]);
expect(normalizeSchemaDiffCompareOptions({}).ignoreTableNameCase).toBe(false);
expect(normalizeSchemaDiffCompareOptions({}).ignoreColumnNameCase).toBe(false);
});
it("swaps each mapping direction", () => {
@@ -223,6 +223,8 @@ export interface SchemaDiffPreparationOptions {
ignoreComments?: boolean;
cascadeDelete?: boolean;
compareColumnOrder?: boolean;
ignoreTableNameCase?: boolean;
ignoreColumnNameCase?: boolean;
detectRenames?: boolean;
detectTableRenames?: boolean;
renameThreshold?: number;
@@ -23,6 +23,8 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [
{ id: "cascadeDelete", labelKey: "schemaDiff.options.cascadeDelete", defaultChecked: false },
{ id: "sequenceLastValues", labelKey: "schemaDiff.options.sequenceLastValues", defaultChecked: true },
{ id: "compareColumnOrder", labelKey: "schemaDiff.options.compareColumnOrder", defaultChecked: false },
{ id: "ignoreTableNameCase", labelKey: "schemaDiff.options.ignoreTableNameCase", defaultChecked: false },
{ id: "ignoreColumnNameCase", labelKey: "schemaDiff.options.ignoreColumnNameCase", defaultChecked: false },
{ id: "detectRenames", labelKey: "schemaDiff.options.detectRenames", defaultChecked: false },
{ id: "detectTableRenames", labelKey: "schemaDiff.options.detectTableRenames", defaultChecked: false },
{ id: "enableRollback", labelKey: "schemaDiff.options.enableRollback", defaultChecked: false },
@@ -50,11 +50,11 @@ function isSchemaDiffObjectEnabled(table: TableInfo, options: Pick<SchemaDiffCom
return isSchemaDiffView(table) ? options.views : options.tables;
}
type SchemaDiffTableFilterOptions = Pick<SchemaDiffCompareOptions, "tables" | "views"> & {
type SchemaDiffTableFilterOptions = Pick<SchemaDiffCompareOptions, "tables" | "views" | "ignoreTableNameCase"> & {
tableMappings?: SchemaDiffCompareOptions["tableMappings"];
};
export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: SchemaDiffTableFilterOptions = { tables: true, views: true }, selectedTables?: string[]): FilteredSchemaDiffTables {
export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: SchemaDiffTableFilterOptions = { tables: true, views: true, ignoreTableNameCase: false }, selectedTables?: string[]): FilteredSchemaDiffTables {
// Visual (explicit) table selection is applied first, then the existing include/exclude
// regex filter. With an explicit selection, targets are selected by the effective
// source→target mapping; without one, the legacy all-tables path is unchanged.
@@ -72,6 +72,7 @@ export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables:
filteredSourceTables.map((table) => table.name),
filteredTargetTables.map((table) => table.name),
options.tableMappings ?? [],
options.ignoreTableNameCase,
).map((mapping) => mapping.targetTable),
);
@@ -23,6 +23,10 @@ function firstMappingBySource(mappings: readonly SchemaDiffTableMapping[]): Map<
return bySource;
}
function identifiersEqual(left: string, right: string, ignoreCase: boolean): boolean {
return ignoreCase ? left.toLowerCase() === right.toLowerCase() : left === right;
}
/** Keep mappings for selected source tables while preserving their current target, even if the target list is loading. */
export function pruneSchemaDiffTableMappings(selectedTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] {
const selected = new Set(selectedTables);
@@ -40,45 +44,67 @@ export function pruneSchemaDiffTableMappings(selectedTables: readonly string[],
* A target can only be consumed once, so stale duplicate mappings cannot leak
* into the comparison request.
*/
export function reconcileSchemaDiffTableMappings(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] {
export function reconcileSchemaDiffTableMappings(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[], ignoreTableNameCase = false): SchemaDiffTableMapping[] {
const targets = new Set(targetTables);
const explicitBySource = firstMappingBySource(mappings);
const usedTargets = new Set<string>();
const next: SchemaDiffTableMapping[] = [];
const reservedExplicitTargets = new Set<string>();
const explicitTargetsBySource = new Map<string, string>();
for (const sourceTable of selectedTables) {
if (!sourceTable) continue;
let targetTable = explicitBySource.get(sourceTable);
if (!targetTable || !targets.has(targetTable) || usedTargets.has(targetTable)) {
targetTable = targets.has(sourceTable) && !usedTargets.has(sourceTable) ? sourceTable : undefined;
const targetTable = explicitBySource.get(sourceTable);
if (targetTable && targets.has(targetTable) && !reservedExplicitTargets.has(targetTable)) {
reservedExplicitTargets.add(targetTable);
explicitTargetsBySource.set(sourceTable, targetTable);
}
if (!targetTable) continue;
usedTargets.add(targetTable);
next.push({ sourceTable, targetTable });
}
return next;
const resolvedTargetsBySource = new Map(explicitTargetsBySource);
const usedTargets = new Set(reservedExplicitTargets);
// Resolve every exact match before considering case-insensitive candidates.
for (const sourceTable of selectedTables) {
if (!sourceTable || resolvedTargetsBySource.has(sourceTable)) continue;
if (targets.has(sourceTable) && !usedTargets.has(sourceTable)) {
usedTargets.add(sourceTable);
resolvedTargetsBySource.set(sourceTable, sourceTable);
}
}
if (ignoreTableNameCase) {
for (const sourceTable of selectedTables) {
if (!sourceTable || resolvedTargetsBySource.has(sourceTable)) continue;
const candidates = targetTables.filter((candidate) => !usedTargets.has(candidate) && identifiersEqual(sourceTable, candidate, true));
if (candidates.length === 1) {
usedTargets.add(candidates[0]);
resolvedTargetsBySource.set(sourceTable, candidates[0]);
}
}
}
return selectedTables.flatMap((sourceTable) => {
const targetTable = sourceTable ? resolvedTargetsBySource.get(sourceTable) : undefined;
return targetTable ? [{ sourceTable, targetTable }] : [];
});
}
export function buildSchemaDiffTableMatches(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMatch[] {
const reconciled = reconcileSchemaDiffTableMappings(selectedTables, targetTables, mappings);
export function buildSchemaDiffTableMatches(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[], ignoreTableNameCase = false): SchemaDiffTableMatch[] {
const reconciled = reconcileSchemaDiffTableMappings(selectedTables, targetTables, mappings, ignoreTableNameCase);
const targetBySource = new Map(reconciled.map((mapping) => [mapping.sourceTable, mapping.targetTable]));
const explicitSources = new Set(firstMappingBySource(mappings).keys());
return selectedTables.map((sourceTable) => {
const targetTable = targetBySource.get(sourceTable);
let kind: SchemaDiffTableMatchKind;
if (!targetTable) kind = "unmatched";
else if (targetTable === sourceTable) kind = "automatic";
else if (!explicitSources.has(sourceTable) && identifiersEqual(targetTable, sourceTable, ignoreTableNameCase)) kind = "automatic";
else kind = "manual";
return { sourceTable, targetTable, kind };
});
}
/** Update one mapping and reject a target already assigned to another source. */
export function updateSchemaDiffTableMapping(mappings: readonly SchemaDiffTableMapping[], sourceTable: string, targetTable: string): SchemaDiffTableMappingUpdate {
const conflict = mappings.find((mapping) => mapping.sourceTable !== sourceTable && mapping.targetTable === targetTable);
export function updateSchemaDiffTableMapping(mappings: readonly SchemaDiffTableMapping[], sourceTable: string, targetTable: string, ignoreTableNameCase = false): SchemaDiffTableMappingUpdate {
const conflict = mappings.find((mapping) => mapping.sourceTable !== sourceTable && identifiersEqual(mapping.targetTable, targetTable, ignoreTableNameCase));
if (targetTable && conflict) {
return { mappings: [...mappings], accepted: false, conflictSource: conflict.sourceTable };
}
@@ -90,13 +116,10 @@ export function updateSchemaDiffTableMapping(mappings: readonly SchemaDiffTableM
}
/** Hide targets occupied by another source while retaining the current value for the edited row. */
export function availableSchemaDiffTargetTables(sourceTable: string, targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): string[] {
export function availableSchemaDiffTargetTables(sourceTable: string, targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[], ignoreTableNameCase = false): string[] {
const currentTarget = mappings.find((mapping) => mapping.sourceTable === sourceTable)?.targetTable;
const occupiedTargets = new Set<string>();
for (const mapping of mappings) {
if (mapping.sourceTable !== sourceTable) occupiedTargets.add(mapping.targetTable);
}
return targetTables.filter((targetTable) => targetTable === currentTarget || !occupiedTargets.has(targetTable));
const occupiedTargets = mappings.filter((mapping) => mapping.sourceTable !== sourceTable).map((mapping) => mapping.targetTable);
return targetTables.filter((targetTable) => identifiersEqual(targetTable, currentTarget ?? "", ignoreTableNameCase) || !occupiedTargets.some((occupiedTarget) => identifiersEqual(occupiedTarget, targetTable, ignoreTableNameCase)));
}
/** Reverse source/target for swap, dropping malformed duplicate targets defensively. */
+6
View File
@@ -22,6 +22,8 @@ export interface SchemaDiffCompareOptions {
cascadeDelete: boolean;
sequenceLastValues: boolean;
compareColumnOrder: boolean;
ignoreTableNameCase: boolean;
ignoreColumnNameCase: boolean;
tableIncludePattern: string;
tableExcludePattern: string;
tableFilterPriority: SchemaDiffTableFilterPriority;
@@ -100,6 +102,8 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
cascadeDelete: false,
sequenceLastValues: true,
compareColumnOrder: false,
ignoreTableNameCase: false,
ignoreColumnNameCase: false,
tableIncludePattern: "",
tableExcludePattern: "",
tableFilterPriority: "exclude",
@@ -133,6 +137,8 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
cascadeDelete: false,
sequenceLastValues: false,
compareColumnOrder: false,
ignoreTableNameCase: false,
ignoreColumnNameCase: false,
tableIncludePattern: "",
tableExcludePattern: "",
tableFilterPriority: "exclude",
+325 -63
View File
@@ -402,6 +402,10 @@ pub struct SchemaDiffPreparationOptions {
#[serde(default)]
pub compare_column_order: bool,
#[serde(default)]
pub ignore_table_name_case: bool,
#[serde(default)]
pub ignore_column_name_case: bool,
#[serde(default)]
pub detect_renames: bool,
#[serde(default)]
pub detect_table_renames: bool,
@@ -577,13 +581,31 @@ impl DependencyGraph {
Self::build_with_functions(details, tables, &[], &[])
}
pub fn build_with_options(
details: &[TableSchemaDetail],
tables: &[TableInfo],
ignore_table_name_case: bool,
) -> Self {
Self::build_with_functions_and_options(details, tables, &[], &[], ignore_table_name_case)
}
/// Extended build: also extracts dependencies from view DDLs, triggers, and function/sequence definitions.
/// Falls back to regex-based text scanning when no live DB query is available.
pub fn build_with_functions(
details: &[TableSchemaDetail],
tables: &[TableInfo],
functions: &[FunctionInfo],
sequences: &[SequenceInfo],
) -> Self {
Self::build_with_functions_and_options(details, tables, functions, sequences, false)
}
pub fn build_with_functions_and_options(
details: &[TableSchemaDetail],
tables: &[TableInfo],
functions: &[FunctionInfo],
_sequences: &[SequenceInfo],
ignore_table_name_case: bool,
) -> Self {
let table_names: HashSet<&str> =
tables.iter().filter(|t| !t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect();
@@ -608,14 +630,25 @@ impl DependencyGraph {
for table_name in &table_names {
if let Some(detail) = detail_map.get(table_name) {
for fk in &detail.foreign_keys {
if table_names.contains(fk.ref_table.as_str()) {
let referenced_table = if table_names.contains(fk.ref_table.as_str()) {
Some(fk.ref_table.as_str())
} else if ignore_table_name_case {
let mut candidates =
table_names.iter().copied().filter(|name| name.eq_ignore_ascii_case(&fk.ref_table));
let candidate = candidates.next();
candidate.filter(|_| candidates.next().is_none())
} else {
None
};
if let Some(referenced_table) = referenced_table {
if let Some(node) = nodes.get_mut(*table_name) {
if !node.depends_on.contains(&fk.ref_table) {
node.depends_on.push(fk.ref_table.clone());
if !node.depends_on.iter().any(|name| name == referenced_table) {
node.depends_on.push(referenced_table.to_string());
}
}
if let Some(ref_node) = nodes.get_mut(&fk.ref_table) {
if !ref_node.depended_by.iter().any(|d| d == *table_name) {
if let Some(ref_node) = nodes.get_mut(referenced_table) {
if !ref_node.depended_by.iter().any(|name| name == *table_name) {
ref_node.depended_by.push((*table_name).to_string());
}
}
@@ -1075,13 +1108,48 @@ pub fn diff_columns_with_compatibility(
target_dialect: DialectKind,
compatibility_threshold: f64,
field_mappings: &[FieldMapping],
) -> (Vec<ColumnDiff>, Vec<ColumnCompatibilityWarning>) {
diff_columns_with_compatibility_options(
source,
target,
ignore_comments,
compare_column_order,
source_dialect,
target_dialect,
compatibility_threshold,
field_mappings,
false,
)
}
#[allow(clippy::too_many_arguments)]
fn diff_columns_with_compatibility_options(
source: &[ColumnInfo],
target: &[ColumnInfo],
ignore_comments: bool,
compare_column_order: bool,
source_dialect: DialectKind,
target_dialect: DialectKind,
compatibility_threshold: f64,
field_mappings: &[FieldMapping],
ignore_column_name_case: bool,
) -> (Vec<ColumnDiff>, Vec<ColumnCompatibilityWarning>) {
use crate::sql_dialect::descriptor::TypeMappingMatrix;
let matrix = TypeMappingMatrix::for_dialects(source_dialect, target_dialect);
let engine = DefaultTypeInferenceEngine;
let basic_diffs = diff_columns_with_options(source, target, ignore_comments, compare_column_order, false, 0.5);
let basic_diffs = diff_columns_with_identifier_options(
source,
target,
ignore_comments,
compare_column_order,
false,
0.5,
None,
None,
ignore_column_name_case,
);
let mut warnings = Vec::new();
let mut enhanced_diffs = Vec::new();
@@ -1531,6 +1599,8 @@ pub fn shard_diff(options: &SchemaDiffPreparationOptions, shard_strategy: &Shard
ignore_comments: options.ignore_comments,
cascade_delete: options.cascade_delete,
compare_column_order: options.compare_column_order,
ignore_table_name_case: options.ignore_table_name_case,
ignore_column_name_case: options.ignore_column_name_case,
source_dialect: options.source_dialect,
target_dialect: options.target_dialect,
table_mappings: options.table_mappings.clone(),
@@ -1802,6 +1872,8 @@ impl Default for SchemaDiffPreparationOptions {
ignore_comments: false,
cascade_delete: false,
compare_column_order: false,
ignore_table_name_case: false,
ignore_column_name_case: false,
detect_renames: false,
detect_table_renames: false,
rename_threshold: 0.5,
@@ -1895,7 +1967,11 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP
let dialect_str = options.source_dialect.map(|d| d.label().to_string()).unwrap_or_else(|| "generic".to_string());
let options = AstTransmitFilter::filter_diff_preparation_options(options, &dialect_str);
let dep_graph = DependencyGraph::build(&options.source_details, &options.source_tables);
let dep_graph = DependencyGraph::build_with_options(
&options.source_details,
&options.source_tables,
options.ignore_table_name_case,
);
let mut diffs = if let Some(ref strategy) = options.shard_strategy {
shard_diff(&options, strategy)
@@ -1956,7 +2032,7 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP
if let Some(source_detail) = options.source_details.iter().find(|d| d.name == diff.name) {
let target_name = diff.target_name.as_deref().unwrap_or(&diff.name);
if let Some(target_detail) = options.target_details.iter().find(|d| d.name == target_name) {
let (_, warnings) = diff_columns_with_compatibility(
let (_, warnings) = diff_columns_with_compatibility_options(
&source_detail.columns,
&target_detail.columns,
options.ignore_comments,
@@ -1965,6 +2041,7 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP
tgt_dialect,
options.compatibility_threshold,
&options.field_mappings,
options.ignore_column_name_case,
);
all_warnings.extend(warnings);
}
@@ -2076,10 +2153,19 @@ struct SchemaDiffTableNameResolution {
target_only: Vec<String>,
}
fn identifiers_equal(left: &str, right: &str, ignore_case: bool) -> bool {
if ignore_case {
left.eq_ignore_ascii_case(right)
} else {
left == right
}
}
fn resolve_schema_diff_table_names(
source_names: &[String],
target_names: &[String],
mappings: &[SchemaDiffTableMapping],
ignore_table_name_case: bool,
) -> SchemaDiffTableNameResolution {
let target_set: HashSet<&str> = target_names.iter().map(String::as_str).collect();
let mut explicit_by_source: HashMap<&str, &str> = HashMap::new();
@@ -2089,24 +2175,52 @@ fn resolve_schema_diff_table_names(
}
}
let mut used_targets = HashSet::new();
// Reserve valid explicit targets before automatic matching so an explicit mapping
// wins even when another source table appears earlier in the input list.
let mut explicit_targets_by_source = HashMap::new();
let mut reserved_explicit_targets = HashSet::new();
for source_name in source_names {
let Some(target_name) = explicit_by_source.get(source_name.as_str()).copied() else { continue };
if target_set.contains(target_name) && reserved_explicit_targets.insert(target_name) {
explicit_targets_by_source.insert(source_name.clone(), target_name.to_string());
}
}
let mut resolved_targets_by_source = explicit_targets_by_source.clone();
let mut used_targets: HashSet<&str> = reserved_explicit_targets.clone();
// Resolve every exact match before considering case-insensitive candidates.
// This keeps an exact match from losing its target to an earlier case-only match.
for source_name in source_names {
if resolved_targets_by_source.contains_key(source_name) {
continue;
}
if target_set.contains(source_name.as_str()) && used_targets.insert(source_name.as_str()) {
resolved_targets_by_source.insert(source_name.clone(), source_name.clone());
}
}
if ignore_table_name_case {
for source_name in source_names {
if resolved_targets_by_source.contains_key(source_name) {
continue;
}
let mut candidates = target_names.iter().filter(|target_name| {
!used_targets.contains(target_name.as_str()) && identifiers_equal(source_name, target_name, true)
});
let candidate = candidates.next();
if let Some(target_name) = candidate.filter(|_| candidates.next().is_none()) {
used_targets.insert(target_name.as_str());
resolved_targets_by_source.insert(source_name.clone(), target_name.clone());
}
}
}
let mut pairs = Vec::new();
let mut source_only = Vec::new();
for source_name in source_names {
let explicit_target = explicit_by_source
.get(source_name.as_str())
.copied()
.filter(|target_name| target_set.contains(target_name) && !used_targets.contains(target_name));
let target_name = explicit_target.or_else(|| {
target_set
.contains(source_name.as_str())
.then_some(source_name.as_str())
.filter(|target_name| !used_targets.contains(target_name))
});
if let Some(target_name) = target_name {
used_targets.insert(target_name);
pairs.push((source_name.clone(), target_name.to_string()));
if let Some(target_name) = resolved_targets_by_source.get(source_name) {
pairs.push((source_name.clone(), target_name.clone()));
} else {
source_only.push(source_name.clone());
}
@@ -2151,10 +2265,19 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
.map(|table| table.name.clone())
.collect();
let table_resolution =
resolve_schema_diff_table_names(&source_table_names, &target_table_names, &options.table_mappings);
let view_resolution =
resolve_schema_diff_table_names(&source_view_names, &target_view_names, &options.table_mappings);
let table_resolution = resolve_schema_diff_table_names(
&source_table_names,
&target_table_names,
&options.table_mappings,
options.ignore_table_name_case,
);
let view_resolution = resolve_schema_diff_table_names(
&source_view_names,
&target_view_names,
&options.table_mappings,
options.ignore_table_name_case,
);
let table_pairs = table_resolution.pairs.clone();
let mut result = Vec::new();
// A foreign key whose `ref_table` is itself one of the tables being compared is a
@@ -2214,7 +2337,9 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
fk,
&source_table_name_set,
&target_table_name_set,
&table_pairs,
&options.table_mappings,
options.ignore_table_name_case,
);
ForeignKeyDiff {
diff_type: "added".to_string(),
@@ -2390,7 +2515,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
for (name, target_name) in table_resolution.pairs {
let Some(source) = source_details.get(name.as_str()) else { continue };
let Some(target) = target_details.get(target_name.as_str()) else { continue };
let column_diffs = diff_columns_with_dialect_options(
let column_diffs = diff_columns_with_identifier_options(
&source.columns,
&target.columns,
options.ignore_comments,
@@ -2399,8 +2524,9 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
options.rename_threshold,
options.source_dialect,
options.target_dialect,
options.ignore_column_name_case,
);
let index_diffs = diff_indexes(&source.indexes, &target.indexes);
let index_diffs = diff_indexes_with_options(&source.indexes, &target.indexes, options.ignore_column_name_case);
let normalized_source_fks: Vec<ForeignKeyInfo> = source
.foreign_keys
.iter()
@@ -2409,13 +2535,23 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec<TableDiff> {
fk,
&source_table_name_set,
&target_table_name_set,
&table_pairs,
&options.table_mappings,
options.ignore_table_name_case,
)
})
.collect();
let normalized_target_fks: Vec<ForeignKeyInfo> =
target.foreign_keys.iter().map(|fk| normalize_self_referencing_fk(fk, &target_table_name_set)).collect();
let foreign_key_diffs = diff_foreign_keys(&normalized_source_fks, &normalized_target_fks);
let normalized_target_fks: Vec<ForeignKeyInfo> = target
.foreign_keys
.iter()
.map(|fk| normalize_self_referencing_fk(fk, &target_table_name_set, options.ignore_table_name_case))
.collect();
let foreign_key_diffs = diff_foreign_keys_with_options(
&normalized_source_fks,
&normalized_target_fks,
options.ignore_table_name_case,
options.ignore_column_name_case,
);
let trigger_diffs = diff_triggers(&source.triggers, &target.triggers);
let source_comment = source_table_comments.get(name.as_str()).cloned().unwrap_or(None);
let target_comment = target_table_comments.get(target_name.as_str()).cloned().unwrap_or(None);
@@ -2845,18 +2981,77 @@ fn diff_columns_with_dialect_options(
rename_threshold: f64,
source_dialect: Option<DialectKind>,
target_dialect: Option<DialectKind>,
) -> Vec<ColumnDiff> {
diff_columns_with_identifier_options(
source,
target,
ignore_comments,
compare_column_order,
detect_renames,
rename_threshold,
source_dialect,
target_dialect,
false,
)
}
fn resolve_column_matches(
source: &[ColumnInfo],
target: &[ColumnInfo],
ignore_column_name_case: bool,
) -> Vec<Option<usize>> {
let mut matches = vec![None; source.len()];
let mut used_targets = HashSet::new();
// Exact matches always win, even when a case-insensitive candidate is also available.
for (source_index, source_column) in source.iter().enumerate() {
if let Some(target_index) = target.iter().enumerate().find_map(|(target_index, target_column)| {
(target_column.name == source_column.name && !used_targets.contains(&target_index)).then_some(target_index)
}) {
matches[source_index] = Some(target_index);
used_targets.insert(target_index);
}
}
if ignore_column_name_case {
for (source_index, source_column) in source.iter().enumerate() {
if matches[source_index].is_some() {
continue;
}
let mut candidates = target.iter().enumerate().filter(|(target_index, target_column)| {
!used_targets.contains(target_index) && target_column.name.eq_ignore_ascii_case(&source_column.name)
});
let candidate = candidates.next().map(|(target_index, _)| target_index);
if let Some(target_index) = candidate.filter(|_| candidates.next().is_none()) {
matches[source_index] = Some(target_index);
used_targets.insert(target_index);
}
}
}
matches
}
#[allow(clippy::too_many_arguments)]
fn diff_columns_with_identifier_options(
source: &[ColumnInfo],
target: &[ColumnInfo],
ignore_comments: bool,
compare_column_order: bool,
detect_renames: bool,
rename_threshold: f64,
source_dialect: Option<DialectKind>,
target_dialect: Option<DialectKind>,
ignore_column_name_case: bool,
) -> Vec<ColumnDiff> {
let mut diffs = Vec::new();
let target_map: HashMap<&str, &ColumnInfo> = target.iter().map(|column| (column.name.as_str(), column)).collect();
let source_map: HashMap<&str, &ColumnInfo> = source.iter().map(|column| (column.name.as_str(), column)).collect();
let target_position_map: HashMap<&str, usize> =
target.iter().enumerate().map(|(index, column)| (column.name.as_str(), index)).collect();
let can_compare_order = compare_column_order
&& source.len() == target.len()
&& source.iter().all(|column| target_map.contains_key(column.name.as_str()));
let column_matches = resolve_column_matches(source, target, ignore_column_name_case);
let can_compare_order =
compare_column_order && source.len() == target.len() && column_matches.iter().all(Option::is_some);
for (source_index, source_column) in source.iter().enumerate() {
if let Some(target_column) = target_map.get(source_column.name.as_str()) {
if let Some(target_index) = column_matches[source_index] {
let target_column = &target[target_index];
let mut changes = Vec::new();
if !column_types_equal_for_dialects(
&source_column.data_type,
@@ -2892,12 +3087,8 @@ fn diff_columns_with_dialect_options(
source_column.comment.as_deref().unwrap_or_default()
));
}
if can_compare_order {
if let Some(target_index) = target_position_map.get(source_column.name.as_str()) {
if source_index != *target_index {
changes.push(format!("order: {} → {}", *target_index + 1, source_index + 1));
}
}
if can_compare_order && source_index != target_index {
changes.push(format!("order: {} → {}", target_index + 1, source_index + 1));
}
if !changes.is_empty() {
diffs.push(ColumnDiff {
@@ -2922,7 +3113,7 @@ fn diff_columns_with_dialect_options(
}
for (target_index, target_column) in target.iter().enumerate() {
if !source_map.contains_key(target_column.name.as_str()) {
if !column_matches.iter().flatten().any(|matched_index| *matched_index == target_index) {
diffs.push(ColumnDiff {
diff_type: "removed".to_string(),
name: target_column.name.clone(),
@@ -3016,6 +3207,33 @@ fn column_add_position(columns: &[ColumnInfo], index: usize) -> ColumnAddPositio
}
pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec<IndexDiff> {
diff_indexes_with_options(source, target, false)
}
fn index_columns_equal(source: &IndexInfo, target: &IndexInfo, ignore_column_name_case: bool) -> bool {
source.columns.len() == target.columns.len()
&& source.columns.iter().enumerate().all(|(index, source_column)| {
let target_column = &target.columns[index];
let source_is_expression = source.key_is_expression.get(index).copied().unwrap_or(false);
let target_is_expression = target.key_is_expression.get(index).copied().unwrap_or(false);
if source_is_expression || target_is_expression {
source_column == target_column
} else {
identifiers_equal(source_column, target_column, ignore_column_name_case)
}
})
}
fn identifier_lists_equal(left: &[String], right: &[String], ignore_column_name_case: bool) -> bool {
left.len() == right.len()
&& left.iter().zip(right).all(|(left, right)| identifiers_equal(left, right, ignore_column_name_case))
}
fn diff_indexes_with_options(
source: &[IndexInfo],
target: &[IndexInfo],
ignore_column_name_case: bool,
) -> Vec<IndexDiff> {
let mut diffs = Vec::new();
let target_map: HashMap<&str, &IndexInfo> = target.iter().map(|index| (index.name.as_str(), index)).collect();
let source_map: HashMap<&str, &IndexInfo> = source.iter().map(|index| (index.name.as_str(), index)).collect();
@@ -3043,7 +3261,7 @@ pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec<IndexDiff
if source_index.is_unique { "YES" } else { "NO" }
));
}
if source_index.columns.join(",") != target_index.columns.join(",") {
if !index_columns_equal(source_index, target_index, ignore_column_name_case) {
changes.push(format!("columns: {} → {}", target_index.columns.join(", "), source_index.columns.join(", ")));
}
if source_index.index_type.as_deref().unwrap_or_default()
@@ -3064,7 +3282,7 @@ pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec<IndexDiff
}
let source_included = source_index.included_columns.clone().unwrap_or_default();
let target_included = target_index.included_columns.clone().unwrap_or_default();
if source_included.join(",") != target_included.join(",") {
if !identifier_lists_equal(&source_included, &target_included, ignore_column_name_case) {
changes.push(format!(
"include: {} → {}",
if target_included.is_empty() { "none".to_string() } else { target_included.join(", ") },
@@ -3114,9 +3332,15 @@ fn normalized_foreign_key_action(action: Option<&str>) -> Option<String> {
.filter(|value| !value.is_empty())
}
fn normalize_self_referencing_fk(fk: &ForeignKeyInfo, own_table_names: &HashSet<&str>) -> ForeignKeyInfo {
fn normalize_self_referencing_fk(
fk: &ForeignKeyInfo,
own_table_names: &HashSet<&str>,
ignore_table_name_case: bool,
) -> ForeignKeyInfo {
let mut normalized = fk.clone();
if fk.ref_schema.is_some() && own_table_names.contains(fk.ref_table.as_str()) {
if fk.ref_schema.is_some()
&& own_table_names.iter().any(|table_name| identifiers_equal(table_name, &fk.ref_table, ignore_table_name_case))
{
normalized.ref_schema = None;
}
normalized
@@ -3126,19 +3350,42 @@ fn normalize_mapped_foreign_key(
fk: &ForeignKeyInfo,
source_table_names: &HashSet<&str>,
target_table_names: &HashSet<&str>,
table_pairs: &[(String, String)],
mappings: &[SchemaDiffTableMapping],
ignore_table_name_case: bool,
) -> ForeignKeyInfo {
let mut normalized = normalize_self_referencing_fk(fk, source_table_names);
if let Some(mapping) = mappings.iter().find(|mapping| {
mapping.source_table == normalized.ref_table && target_table_names.contains(mapping.target_table.as_str())
}) {
normalized.ref_table = mapping.target_table.clone();
let mut normalized = normalize_self_referencing_fk(fk, source_table_names, ignore_table_name_case);
let mapped_target = mappings
.iter()
.find(|mapping| {
mapping.source_table == normalized.ref_table && target_table_names.contains(mapping.target_table.as_str())
})
.map(|mapping| mapping.target_table.as_str())
.or_else(|| {
table_pairs
.iter()
.find(|(source_table, _)| {
identifiers_equal(source_table, &normalized.ref_table, ignore_table_name_case)
})
.map(|(_, target_table)| target_table.as_str())
});
if let Some(mapped_target) = mapped_target {
normalized.ref_table = mapped_target.to_string();
normalized.ref_schema = None;
}
normalized
}
pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -> Vec<ForeignKeyDiff> {
diff_foreign_keys_with_options(source, target, false, false)
}
fn diff_foreign_keys_with_options(
source: &[ForeignKeyInfo],
target: &[ForeignKeyInfo],
ignore_table_name_case: bool,
ignore_column_name_case: bool,
) -> Vec<ForeignKeyDiff> {
let mut diffs = Vec::new();
let target_map: HashMap<&str, &ForeignKeyInfo> = target.iter().map(|fk| (fk.name.as_str(), fk)).collect();
let source_map: HashMap<&str, &ForeignKeyInfo> = source.iter().map(|fk| (fk.name.as_str(), fk)).collect();
@@ -3156,10 +3403,10 @@ pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -
};
let mut changes = Vec::new();
if source_fk.column != target_fk.column {
if !identifiers_equal(&source_fk.column, &target_fk.column, ignore_column_name_case) {
changes.push(format!("column: {} → {}", target_fk.column, source_fk.column));
}
if source_fk.ref_table != target_fk.ref_table {
if !identifiers_equal(&source_fk.ref_table, &target_fk.ref_table, ignore_table_name_case) {
changes.push(format!("ref table: {} → {}", target_fk.ref_table, source_fk.ref_table));
}
if source_fk.ref_schema != target_fk.ref_schema {
@@ -3169,7 +3416,7 @@ pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -
source_fk.ref_schema.as_deref().unwrap_or("")
));
}
if source_fk.ref_column != target_fk.ref_column {
if !identifiers_equal(&source_fk.ref_column, &target_fk.ref_column, ignore_column_name_case) {
changes.push(format!("ref column: {} → {}", target_fk.ref_column, source_fk.ref_column));
}
let source_on_delete = normalized_foreign_key_action(source_fk.on_delete.as_deref());
@@ -3921,6 +4168,15 @@ fn target_table_name(diff: &TableDiff) -> &str {
diff.target_name.as_deref().unwrap_or(&diff.name)
}
fn ddl_column_name(column: &ColumnDiff) -> &str {
if let (Some(source), Some(target)) = (&column.source, &column.target) {
if source.name.eq_ignore_ascii_case(&target.name) {
return &target.name;
}
}
&column.name
}
fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>, cascade: &str) -> String {
let object_type = if diff.object_type.as_deref() == Some("view") { "VIEW" } else { "TABLE" };
let name = qualified_name(target_table_name(diff), db_type, schema);
@@ -5482,12 +5738,16 @@ fn generate_schema_sync_sql_inner(
}
"modified" => {
if let Some(source) = &column.source {
let mapped = convert_col(source);
let target_column_name = ddl_column_name(column);
let mut mapped = convert_col(source);
if mapped.name != target_column_name {
mapped.name = target_column_name.to_string();
}
if db_type == DatabaseType::SqlServer {
if let Some(target_col) = &column.target {
standalone_statements.extend(sqlserver_column_change_statements(
&table,
&column.name,
target_column_name,
source,
target_col,
&mapped.data_type,
@@ -5503,7 +5763,7 @@ fn generate_schema_sync_sql_inner(
));
}
} else {
let name = quote_id(&column.name, db_type);
let name = quote_id(target_column_name, db_type);
if column.changes.iter().any(|change| change.starts_with("type:")) {
parts.push(format!(" ALTER COLUMN {name} TYPE {}", mapped.data_type));
}
@@ -5618,7 +5878,7 @@ fn generate_schema_sync_sql_inner(
if column.changes.iter().any(|change| change.starts_with("comment:")) {
lines.extend(column_comment_sql(
target_name,
&column.name,
ddl_column_name(column),
source.comment.as_deref().unwrap_or_default(),
db_type,
schema,
@@ -12155,6 +12415,8 @@ mod tests {
ignore_comments: false,
cascade_delete: false,
compare_column_order: false,
ignore_table_name_case: false,
ignore_column_name_case: false,
detect_renames: false,
detect_table_renames: false,
rename_threshold: 0.5,
@@ -49,6 +49,8 @@ fn prepare_schema_diff_function_signature() {
ignore_comments: false,
cascade_delete: false,
compare_column_order: false,
ignore_table_name_case: false,
ignore_column_name_case: false,
detect_renames: false,
detect_table_renames: false,
rename_threshold: 0.5,
@@ -67,6 +69,25 @@ fn prepare_schema_diff_function_signature() {
let _result: SchemaDiffPreparation = prepare_schema_diff(options);
}
#[test]
fn schema_diff_case_options_are_backward_compatible_and_use_camel_case() {
let legacy: SchemaDiffPreparationOptions = serde_json::from_value(serde_json::json!({
"databaseType": "postgres"
}))
.unwrap();
assert!(!legacy.ignore_table_name_case);
assert!(!legacy.ignore_column_name_case);
let mut options = legacy;
options.ignore_table_name_case = true;
options.ignore_column_name_case = true;
let json = serde_json::to_value(options).unwrap();
assert_eq!(json["ignoreTableNameCase"], true);
assert_eq!(json["ignoreColumnNameCase"], true);
assert!(json.get("ignore_table_name_case").is_none());
assert!(json.get("ignore_column_name_case").is_none());
}
/// Verify generate_schema_sync_sql accepts all arg types
#[test]
fn generate_schema_sync_sql_function_signature() {
@@ -127,6 +148,8 @@ fn schema_diff_preparation_field_names() {
ignore_comments: false,
cascade_delete: false,
compare_column_order: false,
ignore_table_name_case: false,
ignore_column_name_case: false,
detect_renames: false,
detect_table_renames: false,
rename_threshold: 0.5,
@@ -0,0 +1,258 @@
use dbx_core::models::connection::DatabaseType;
use dbx_core::schema_diff::TableSchemaDetail;
use dbx_core::schema_diff::{prepare_schema_diff, SchemaDiffPreparationOptions, SchemaDiffTableMapping};
use dbx_core::types::{ColumnInfo, ForeignKeyInfo, IndexInfo, TableInfo};
fn table(name: &str) -> TableInfo {
TableInfo {
name: name.to_string(),
table_type: "BASE TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
}
}
fn column(name: &str, data_type: &str) -> ColumnInfo {
ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), ..Default::default() }
}
fn index(name: &str, columns: &[&str]) -> IndexInfo {
IndexInfo {
name: name.to_string(),
columns: columns.iter().map(|column| (*column).to_string()).collect(),
is_unique: false,
is_primary: false,
filter: None,
index_type: None,
included_columns: None,
comment: None,
key_is_expression: Vec::new(),
column_opclasses: Vec::new(),
constraint_backed: false,
}
}
fn foreign_key(name: &str, column: &str, ref_table: &str, ref_column: &str) -> ForeignKeyInfo {
ForeignKeyInfo {
name: name.to_string(),
column: column.to_string(),
ref_schema: None,
ref_table: ref_table.to_string(),
ref_column: ref_column.to_string(),
on_update: None,
on_delete: None,
}
}
fn detail(
name: &str,
columns: Vec<ColumnInfo>,
indexes: Vec<IndexInfo>,
foreign_keys: Vec<ForeignKeyInfo>,
) -> TableSchemaDetail {
TableSchemaDetail { name: name.to_string(), columns, indexes, foreign_keys, triggers: Vec::new(), ddl: None }
}
fn options(
source_tables: Vec<TableInfo>,
target_tables: Vec<TableInfo>,
source_details: Vec<TableSchemaDetail>,
target_details: Vec<TableSchemaDetail>,
) -> SchemaDiffPreparationOptions {
SchemaDiffPreparationOptions {
source_tables,
target_tables,
source_details,
target_details,
database_type: DatabaseType::Postgres,
..Default::default()
}
}
#[test]
fn keeps_case_sensitive_matching_by_default() {
let result = prepare_schema_diff(options(
vec![table("USER")],
vec![table("user")],
vec![detail("USER", vec![column("ID", "int")], Vec::new(), Vec::new())],
vec![detail("user", vec![column("id", "int")], Vec::new(), Vec::new())],
));
assert_eq!(result.diffs.len(), 2);
assert!(result.diffs.iter().any(|diff| diff.diff_type == "added" && diff.name == "USER"));
assert!(result.diffs.iter().any(|diff| diff.diff_type == "removed" && diff.name == "user"));
}
#[test]
fn matches_table_column_index_and_foreign_key_references_case_insensitively() {
let mut options = options(
vec![table("USER_INFO"), table("USER_ACCESS")],
vec![table("user_info"), table("user_access")],
vec![
detail("USER_INFO", vec![column("ID", "int")], Vec::new(), Vec::new()),
detail(
"USER_ACCESS",
vec![column("USER_ID", "int")],
vec![index("idx_user_id", &["USER_ID"])],
vec![foreign_key("fk_user_info", "USER_ID", "USER_INFO", "ID")],
),
],
vec![
detail("user_info", vec![column("id", "int")], Vec::new(), Vec::new()),
detail(
"user_access",
vec![column("user_id", "int")],
vec![index("idx_user_id", &["user_id"])],
vec![foreign_key("fk_user_info", "user_id", "user_info", "id")],
),
],
);
options.ignore_table_name_case = true;
options.ignore_column_name_case = true;
let result = prepare_schema_diff(options);
assert!(
result.diffs.is_empty(),
"case-only table, column, index, and foreign-key references should not diff: {result:?}"
);
}
#[test]
fn keeps_table_and_column_case_options_independent() {
let base = || {
options(
vec![table("USER")],
vec![table("user")],
vec![detail("USER", vec![column("ID", "int")], Vec::new(), Vec::new())],
vec![detail("user", vec![column("id", "int")], Vec::new(), Vec::new())],
)
};
let mut table_only = base();
table_only.ignore_table_name_case = true;
let table_only_result = prepare_schema_diff(table_only);
assert_eq!(table_only_result.diffs.len(), 1);
assert_eq!(table_only_result.diffs[0].diff_type, "modified");
assert_eq!(table_only_result.diffs[0].columns.as_ref().unwrap().len(), 2);
let mut column_only = base();
column_only.ignore_column_name_case = true;
let column_only_result = prepare_schema_diff(column_only);
assert_eq!(column_only_result.diffs.len(), 2);
assert!(column_only_result.diffs.iter().all(|diff| diff.diff_type == "added" || diff.diff_type == "removed"));
}
#[test]
fn ignores_case_when_comparing_column_order() {
let mut options = options(
vec![table("orders")],
vec![table("orders")],
vec![detail("orders", vec![column("ID", "int"), column("NAME", "text")], Vec::new(), Vec::new())],
vec![detail("orders", vec![column("id", "int"), column("name", "text")], Vec::new(), Vec::new())],
);
options.ignore_column_name_case = true;
options.compare_column_order = true;
assert!(prepare_schema_diff(options).diffs.is_empty());
}
#[test]
fn explicit_table_mappings_take_precedence_over_case_insensitive_matching() {
let mut options = options(
vec![table("source_a")],
vec![table("SOURCE_A"), table("target_b")],
vec![detail("source_a", vec![column("id", "int")], Vec::new(), Vec::new())],
vec![
detail("SOURCE_A", vec![column("id", "int")], Vec::new(), Vec::new()),
detail("target_b", vec![column("id", "int")], Vec::new(), Vec::new()),
],
);
options.ignore_table_name_case = true;
options.table_mappings =
vec![SchemaDiffTableMapping { source_table: "source_a".to_string(), target_table: "target_b".to_string() }];
let result = prepare_schema_diff(options);
assert_eq!(result.diffs.len(), 1);
assert_eq!(result.diffs[0].diff_type, "removed");
assert_eq!(result.diffs[0].name, "SOURCE_A");
}
#[test]
fn exact_table_matches_take_precedence_over_case_insensitive_candidates() {
let mut options = options(
vec![table("foo"), table("FOO")],
vec![table("FOO")],
vec![detail("foo", Vec::new(), Vec::new(), Vec::new()), detail("FOO", Vec::new(), Vec::new(), Vec::new())],
vec![detail("FOO", Vec::new(), Vec::new(), Vec::new())],
);
options.ignore_table_name_case = true;
let result = prepare_schema_diff(options);
assert_eq!(result.diffs.len(), 1);
assert_eq!(result.diffs[0].diff_type, "added");
assert_eq!(result.diffs[0].name, "foo");
}
#[test]
fn does_not_match_ambiguous_case_insensitive_table_candidates() {
let mut options = options(
vec![table("uSeR")],
vec![table("User"), table("USER")],
vec![detail("uSeR", Vec::new(), Vec::new(), Vec::new())],
vec![detail("User", Vec::new(), Vec::new(), Vec::new()), detail("USER", Vec::new(), Vec::new(), Vec::new())],
);
options.ignore_table_name_case = true;
let result = prepare_schema_diff(options);
assert_eq!(result.diffs.len(), 3);
assert!(result.diffs.iter().any(|diff| diff.diff_type == "added" && diff.name == "uSeR"));
assert_eq!(result.diffs.iter().filter(|diff| diff.diff_type == "removed").count(), 2);
}
#[test]
fn case_only_matches_do_not_trigger_table_or_column_rename_detection() {
let mut options = options(
vec![table("User")],
vec![table("user")],
vec![detail("User", vec![column("ID", "int")], Vec::new(), Vec::new())],
vec![detail("user", vec![column("id", "int")], Vec::new(), Vec::new())],
);
options.ignore_table_name_case = true;
options.ignore_column_name_case = true;
options.detect_renames = true;
options.detect_table_renames = true;
let result = prepare_schema_diff(options);
assert!(result.diffs.is_empty());
assert!(result.rename_candidates.is_empty());
}
#[test]
fn preserves_original_identifiers_in_case_insensitive_matches() {
let mut options = options(
vec![table("USER_INFO")],
vec![table("user_info")],
vec![detail("USER_INFO", vec![column("ID", "bigint")], Vec::new(), Vec::new())],
vec![detail("user_info", vec![column("id", "int")], Vec::new(), Vec::new())],
);
options.ignore_table_name_case = true;
options.ignore_column_name_case = true;
let result = prepare_schema_diff(options);
let table_diff = &result.diffs[0];
let column_diff = &table_diff.columns.as_ref().unwrap()[0];
assert_eq!(table_diff.name, "USER_INFO");
assert_eq!(table_diff.target_name.as_deref(), Some("user_info"));
assert_eq!(column_diff.name, "ID");
assert_eq!(column_diff.source.as_ref().unwrap().name, "ID");
assert_eq!(column_diff.target.as_ref().unwrap().name, "id");
assert!(result.sync_sql.contains("ALTER COLUMN \"id\" TYPE bigint"));
assert!(!result.sync_sql.contains("ALTER COLUMN \"ID\" TYPE bigint"));
}