mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(schema-diff): add MySQL ignore foreign key checks deploy option
This commit is contained in:
@@ -48,11 +48,16 @@ const props = defineProps<{
|
||||
missingRollbackObjects?: MissingRollbackObject[];
|
||||
canExecute?: boolean;
|
||||
destructiveStatementCount?: number;
|
||||
/** Current value of the "ignore foreign key checks" deploy option. */
|
||||
ignoreForeignKeyChecks?: boolean;
|
||||
/** Whether the target database is MySQL, the only engine that gets the option. */
|
||||
showIgnoreForeignKeyChecks?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:deploySql": [sql: string];
|
||||
"update:deploySqlMode": [mode: "forward" | "rollback"];
|
||||
"update:ignoreForeignKeyChecks": [enabled: boolean];
|
||||
back: [];
|
||||
deploy: [];
|
||||
}>();
|
||||
@@ -383,6 +388,13 @@ function getObjectIconColor(kind: DiffObjectKind): string {
|
||||
<Download class="w-3.5 h-3.5" />
|
||||
{{ t("diff.exportSql") }}
|
||||
</Button>
|
||||
<!-- Offered for MySQL targets only: the toggle is a MySQL session
|
||||
variable, and for any other engine the backend returns the script
|
||||
without it. -->
|
||||
<label v-if="showIgnoreForeignKeyChecks" class="flex items-center gap-1.5 text-xs" :class="executing ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'" :title="t('diff.ignoreForeignKeyChecksHint')">
|
||||
<input type="checkbox" class="accent-primary" :checked="ignoreForeignKeyChecks === true" :disabled="executing" @change="emit('update:ignoreForeignKeyChecks', ($event.target as HTMLInputElement).checked)" />
|
||||
{{ t("diff.ignoreForeignKeyChecks") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-7 text-xs gap-1" @click="$emit('back')">
|
||||
|
||||
@@ -200,6 +200,22 @@ const showRenamePanel = ref(true);
|
||||
// Rollback / forward SQL mode in deploy step
|
||||
const deploySqlMode = ref<"forward" | "rollback">("forward");
|
||||
|
||||
// Deploy review: bracket the deploy script with MySQL's FOREIGN_KEY_CHECKS toggle.
|
||||
// Only MySQL honours the session variable, so the option is offered — and sent to
|
||||
// the backend — for MySQL targets alone; the backend re-checks the target type and
|
||||
// returns every other engine's script unchanged.
|
||||
const ignoreForeignKeyChecks = ref(false);
|
||||
const isMysqlDeployTarget = computed(() => targetEngineDbType.value === "mysql");
|
||||
const effectiveIgnoreForeignKeyChecks = computed(() => ignoreForeignKeyChecks.value && isMysqlDeployTarget.value);
|
||||
|
||||
// Ticking the box rewrites the script through the plan generator, so the header
|
||||
// and trailer come from the same backend implementation the copy, export and
|
||||
// deploy paths all read from, and unticking restores the plain script.
|
||||
watch(ignoreForeignKeyChecks, () => {
|
||||
if (step.value !== "deploy-review" || !lastDiffResult.value) return;
|
||||
void regenerateSelectedDeploySql();
|
||||
});
|
||||
|
||||
// Dialog size memory (width + height + splitpanes ratio)
|
||||
const DIALOG_SIZE_KEY = "dbx-schema-diff-size";
|
||||
const SPLITPANES_SIZE_KEY = "dbx-schema-diff-splitpanes-v2";
|
||||
@@ -420,6 +436,7 @@ function resetComparisonResultState() {
|
||||
permissionDiffs.value = [];
|
||||
dependencyGraph.value = null;
|
||||
deploySqlMode.value = "forward";
|
||||
ignoreForeignKeyChecks.value = false;
|
||||
showConfirmDialog.value = false;
|
||||
showResultDialog.value = false;
|
||||
deployResult.value = null;
|
||||
@@ -827,6 +844,7 @@ function buildSchemaSyncPlanOptions(options: SchemaDiffCompareOptions) {
|
||||
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : sourceEngineDbType.value ? databaseTypeToDialectKind(sourceEngineDbType.value) : undefined,
|
||||
fieldMappings: options.fieldMappings,
|
||||
enableRollback: options.enableRollback,
|
||||
ignoreForeignKeyChecks: effectiveIgnoreForeignKeyChecks.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1507,6 +1525,8 @@ const targetConnectionInfo = computed(() => {
|
||||
<template v-else-if="step === 'deploy-review'">
|
||||
<SchemaDiffDeployStep
|
||||
v-model:deploy-sql="selectedDeploySql"
|
||||
v-model:ignore-foreign-key-checks="ignoreForeignKeyChecks"
|
||||
:show-ignore-foreign-key-checks="isMysqlDeployTarget"
|
||||
:selected-objects="currentTabDiffObjects"
|
||||
:target-connection-id="targetConnectionId"
|
||||
:target-database="targetDatabase"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({ editorSettings: { theme: "default", fontSize: 12, fontFamily: "monospace" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/lib/editor/editorThemes", () => ({
|
||||
loadEditorTheme: async () => [],
|
||||
editorFontTheme: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/editor/codemirrorSqlDialect", () => ({ createDbxCodeMirrorSqlDialect: () => undefined }));
|
||||
|
||||
// The deploy script preview is a CodeMirror view; the option under test lives in
|
||||
// the footer, so the editor is replaced with the smallest view that still accepts
|
||||
// the component's document updates.
|
||||
vi.mock("@codemirror/view", () => ({
|
||||
EditorView: class {
|
||||
static scrollIntoView = vi.fn(() => ({}));
|
||||
static updateListener = { of: () => [] };
|
||||
state: { doc: { toString: () => string; length: number } };
|
||||
constructor(config: { state: { doc: { toString: () => string; length: number } } }) {
|
||||
this.state = config.state;
|
||||
}
|
||||
dispatch() {}
|
||||
destroy() {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@codemirror/state", () => ({
|
||||
EditorState: {
|
||||
create: (config: { doc: string }) => ({ doc: { toString: () => config.doc, length: config.doc.length } }),
|
||||
},
|
||||
Compartment: class {
|
||||
of() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@codemirror/lang-sql", () => ({ sql: () => [] }));
|
||||
|
||||
vi.mock("codemirror", () => ({ basicSetup: [] }));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function mountDeployStep(extraProps: Record<string, unknown> = {}, onIgnoreForeignKeyChecks?: (value: boolean) => void) {
|
||||
const state = reactive({ ignoreForeignKeyChecks: false });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(SchemaDiffDeployStep, {
|
||||
deploySql: "ALTER TABLE users ADD COLUMN nickname varchar(64);",
|
||||
selectedObjects: [],
|
||||
targetConnectionId: "mysql-1",
|
||||
targetDatabase: "shop",
|
||||
targetSchema: "shop",
|
||||
executing: false,
|
||||
...extraProps,
|
||||
ignoreForeignKeyChecks: state.ignoreForeignKeyChecks,
|
||||
"onUpdate:ignoreForeignKeyChecks": (value: boolean) => {
|
||||
state.ignoreForeignKeyChecks = value;
|
||||
onIgnoreForeignKeyChecks?.(value);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return { container, state };
|
||||
}
|
||||
|
||||
function foreignKeyCheckbox(container: HTMLElement): HTMLInputElement | null {
|
||||
const label = [...container.querySelectorAll<HTMLLabelElement>("label")].find((candidate) => candidate.textContent?.includes("diff.ignoreForeignKeyChecks"));
|
||||
return label?.querySelector<HTMLInputElement>('input[type="checkbox"]') ?? null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("SchemaDiffDeployStep foreign key check option", () => {
|
||||
it("offers the option next to the export button for a MySQL target and leaves it unchecked by default", async () => {
|
||||
const { container } = await mountDeployStep({ showIgnoreForeignKeyChecks: true });
|
||||
|
||||
const checkbox = foreignKeyCheckbox(container);
|
||||
expect(checkbox).not.toBeNull();
|
||||
expect(checkbox?.checked).toBe(false);
|
||||
expect(checkbox?.closest("label")?.textContent).toContain("diff.ignoreForeignKeyChecks");
|
||||
expect(container.textContent).toContain("diff.exportSql");
|
||||
});
|
||||
|
||||
it("keeps the option out of the footer for targets whose engine has no FOREIGN_KEY_CHECKS", async () => {
|
||||
const { container } = await mountDeployStep({ showIgnoreForeignKeyChecks: false });
|
||||
|
||||
expect(foreignKeyCheckbox(container)).toBeNull();
|
||||
expect(container.textContent).not.toContain("diff.ignoreForeignKeyChecks");
|
||||
});
|
||||
|
||||
it("reports the ticked and unticked states to the dialog", async () => {
|
||||
const updates: boolean[] = [];
|
||||
const { container, state } = await mountDeployStep({ showIgnoreForeignKeyChecks: true }, (value) => updates.push(value));
|
||||
|
||||
const checkbox = foreignKeyCheckbox(container)!;
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event("change"));
|
||||
await nextTick();
|
||||
checkbox.checked = false;
|
||||
checkbox.dispatchEvent(new Event("change"));
|
||||
await nextTick();
|
||||
|
||||
expect(updates).toEqual([true, false]);
|
||||
expect(state.ignoreForeignKeyChecks).toBe(false);
|
||||
});
|
||||
|
||||
it("disables the option while a deploy is running", async () => {
|
||||
const { container } = await mountDeployStep({ showIgnoreForeignKeyChecks: true, executing: true });
|
||||
|
||||
expect(foreignKeyCheckbox(container)?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,7 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
|
||||
keys: ["pluginPlatform.dataAccessConsent", "pluginPlatform.dataAccessConsentTitle", "pluginPlatform.aiTools.*", "pluginPlatform.dataAccess.*", "ai.toolApproval.*"],
|
||||
translated: true,
|
||||
},
|
||||
{ feature: "schema diff deploy ignores foreign key checks", keys: under("diff", ["ignoreForeignKeyChecks", "ignoreForeignKeyChecksHint"]), translated: true },
|
||||
];
|
||||
|
||||
function lookup(messages: unknown, path: string): unknown {
|
||||
|
||||
@@ -6710,6 +6710,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "Son konfiqurasiyalar",
|
||||
selectRecentConfig: "Son konfiqurasiyanı seç",
|
||||
ignoreComments: "Şərh fərqlərini nəzərə alma",
|
||||
ignoreForeignKeyChecks: "Xarici açar yoxlamalarını nəzərə alma",
|
||||
ignoreForeignKeyChecksHint: "Cədvəllərin istənilən sıra ilə silinib yenidən yaradılması üçün skripti SET FOREIGN_KEY_CHECKS = 0 / = 1 ilə əhatə edir",
|
||||
compare: "Müqayisə et",
|
||||
comparing: "Sxemlər müqayisə edilir...",
|
||||
noDifferences: "Fərq tapılmadı",
|
||||
|
||||
@@ -7661,6 +7661,8 @@ export default {
|
||||
recentConfigs: "Recent Configs",
|
||||
selectRecentConfig: "Select recent config",
|
||||
ignoreComments: "Ignore comment differences",
|
||||
ignoreForeignKeyChecks: "Ignore foreign key checks",
|
||||
ignoreForeignKeyChecksHint: "Wraps the script in SET FOREIGN_KEY_CHECKS = 0 / = 1 so tables can be dropped and recreated in any order",
|
||||
compare: "Compare",
|
||||
comparing: "Comparing schemas...",
|
||||
noDifferences: "No differences found",
|
||||
|
||||
@@ -6955,6 +6955,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "Configs recientes",
|
||||
selectRecentConfig: "Seleccionar config reciente",
|
||||
ignoreComments: "Ignorar diferencias de comentarios",
|
||||
ignoreForeignKeyChecks: "Ignorar la comprobación de claves foráneas",
|
||||
ignoreForeignKeyChecksHint: "Envuelve el script con SET FOREIGN_KEY_CHECKS = 0 / = 1 para poder eliminar y recrear tablas en cualquier orden",
|
||||
compare: "Comparar",
|
||||
comparing: "Comparando esquemas...",
|
||||
noDifferences: "No se encontraron diferencias",
|
||||
|
||||
@@ -7391,6 +7391,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "Konfigurasi Terbaru",
|
||||
selectRecentConfig: "Pilih konfigurasi terbaru",
|
||||
ignoreComments: "Abaikan perbedaan komentar",
|
||||
ignoreForeignKeyChecks: "Abaikan pemeriksaan kunci asing",
|
||||
ignoreForeignKeyChecksHint: "Membungkus skrip dengan SET FOREIGN_KEY_CHECKS = 0 / = 1 agar tabel dapat dihapus dan dibuat ulang dalam urutan apa pun",
|
||||
compare: "Bandingkan",
|
||||
comparing: "Membandingkan skema...",
|
||||
noDifferences: "Tidak ditemukan perbedaan",
|
||||
|
||||
@@ -6739,6 +6739,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "Configurazioni Recenti",
|
||||
selectRecentConfig: "Seleziona configurazione recente",
|
||||
ignoreComments: "Ignora differenze nei commenti",
|
||||
ignoreForeignKeyChecks: "Ignora i controlli delle chiavi esterne",
|
||||
ignoreForeignKeyChecksHint: "Racchiude lo script tra SET FOREIGN_KEY_CHECKS = 0 / = 1 per eliminare e ricreare le tabelle in qualsiasi ordine",
|
||||
compare: "Confronta",
|
||||
comparing: "Confronto degli schemi...",
|
||||
noDifferences: "Nessuna differenza trovata",
|
||||
|
||||
@@ -6767,6 +6767,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "最近の設定",
|
||||
selectRecentConfig: "最近の設定を選択",
|
||||
ignoreComments: "コメントの差分を無視",
|
||||
ignoreForeignKeyChecks: "外部キー検査を無視",
|
||||
ignoreForeignKeyChecksHint: "スクリプトの前後に SET FOREIGN_KEY_CHECKS = 0 / = 1 を付け、テーブルを任意の順序で削除・再作成できるようにします",
|
||||
compare: "比較",
|
||||
comparing: "スキーマを比較中...",
|
||||
noDifferences: "差分が見つかりません",
|
||||
|
||||
@@ -6580,6 +6580,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "최근 설정",
|
||||
selectRecentConfig: "최근 설정 선택",
|
||||
ignoreComments: "주석 차이 무시",
|
||||
ignoreForeignKeyChecks: "외래 키 검사 무시",
|
||||
ignoreForeignKeyChecksHint: "스크립트 앞뒤에 SET FOREIGN_KEY_CHECKS = 0 / = 1을 추가해 테이블을 임의 순서로 삭제하고 다시 만들 수 있게 합니다",
|
||||
compare: "비교",
|
||||
comparing: "스키마를 비교하는 중...",
|
||||
noDifferences: "차이점이 없습니다",
|
||||
|
||||
@@ -6733,6 +6733,8 @@ export default withEnglishFallback({
|
||||
selectDatabase: "Selecionar banco de dados",
|
||||
selectSchema: "Selecionar schema",
|
||||
ignoreComments: "Ignorar diferenças de comentários",
|
||||
ignoreForeignKeyChecks: "Ignorar verificações de chave estrangeira",
|
||||
ignoreForeignKeyChecksHint: "Envolve o script com SET FOREIGN_KEY_CHECKS = 0 / = 1 para permitir remover e recriar tabelas em qualquer ordem",
|
||||
compare: "Comparar",
|
||||
comparing: "Comparando schemas...",
|
||||
noDifferences: "Nenhuma diferença encontrada",
|
||||
|
||||
@@ -7979,6 +7979,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "Недавние конфигурации",
|
||||
selectRecentConfig: "Выбрать недавнюю конфигурацию",
|
||||
ignoreComments: "Игнорировать различия комментариев",
|
||||
ignoreForeignKeyChecks: "Игнорировать проверку внешних ключей",
|
||||
ignoreForeignKeyChecksHint: "Оборачивает скрипт в SET FOREIGN_KEY_CHECKS = 0 / = 1, позволяя удалять и создавать таблицы в любом порядке",
|
||||
compare: "Сравнить",
|
||||
comparing: "Сравнение схем...",
|
||||
noDifferences: "Различия не найдены",
|
||||
|
||||
@@ -6591,6 +6591,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "Son Yapılandırmalar",
|
||||
selectRecentConfig: "Son yapılandırmayı seç",
|
||||
ignoreComments: "Açıklama farklarını yoksay",
|
||||
ignoreForeignKeyChecks: "Yabancı anahtar denetimlerini yoksay",
|
||||
ignoreForeignKeyChecksHint: "Tabloların herhangi bir sırayla silinip yeniden oluşturulabilmesi için betiği SET FOREIGN_KEY_CHECKS = 0 / = 1 ile sarar",
|
||||
compare: "Karşılaştır",
|
||||
comparing: "Şemalar karşılaştırılıyor...",
|
||||
noDifferences: "Fark bulunamadı",
|
||||
|
||||
@@ -7646,6 +7646,8 @@ export default withEnglishFallback({
|
||||
recentConfigs: "最近配置",
|
||||
selectRecentConfig: "选择最近配置",
|
||||
ignoreComments: "忽略注释差异",
|
||||
ignoreForeignKeyChecks: "忽略外键检查",
|
||||
ignoreForeignKeyChecksHint: "在脚本首尾加上 SET FOREIGN_KEY_CHECKS = 0 / = 1,允许以任意顺序删除和重建表",
|
||||
compare: "开始比较",
|
||||
comparing: "正在比较结构...",
|
||||
noDifferences: "两个数据库结构完全一致",
|
||||
|
||||
@@ -6086,6 +6086,8 @@ export default withEnglishFallback({
|
||||
selectDatabase: "選擇資料庫",
|
||||
selectSchema: "選擇 Schema",
|
||||
ignoreComments: "忽略註解差異",
|
||||
ignoreForeignKeyChecks: "忽略外鍵檢查",
|
||||
ignoreForeignKeyChecksHint: "在指令碼開頭與結尾加上 SET FOREIGN_KEY_CHECKS = 0 / = 1,允許以任意順序刪除與重建資料表",
|
||||
compare: "開始比較",
|
||||
comparing: "正在比較結構……",
|
||||
noDifferences: "兩個資料庫結構完全一致",
|
||||
|
||||
@@ -376,6 +376,13 @@ export interface GenerateSchemaSyncPlanOptions {
|
||||
sourceDialect?: string;
|
||||
fieldMappings?: FieldMappingEntry[];
|
||||
enableRollback?: boolean;
|
||||
/**
|
||||
* Bracket the generated forward and rollback scripts with MySQL's
|
||||
* `SET FOREIGN_KEY_CHECKS = 0` / `= 1` pair so a deploy can drop and recreate
|
||||
* foreign-key-related objects in any order. The backend applies it for MySQL
|
||||
* targets only and returns every other engine's script unchanged.
|
||||
*/
|
||||
ignoreForeignKeyChecks?: boolean;
|
||||
}
|
||||
|
||||
const MYSQL_LIKE_SCHEMA_DIFF_TARGET_TYPES = new Set<DatabaseType>(["mysql", "doris", "starrocks", "goldendb", "sundb", "databend", "gbase"]);
|
||||
|
||||
@@ -6009,6 +6009,30 @@ pub fn generate_schema_sync_sql(
|
||||
.0
|
||||
}
|
||||
|
||||
/// Header that turns MySQL's session-scoped foreign key validation off.
|
||||
pub const MYSQL_DISABLE_FOREIGN_KEY_CHECKS: &str = "SET FOREIGN_KEY_CHECKS = 0;";
|
||||
/// Trailer that restores MySQL's session default for foreign key validation.
|
||||
pub const MYSQL_ENABLE_FOREIGN_KEY_CHECKS: &str = "SET FOREIGN_KEY_CHECKS = 1;";
|
||||
|
||||
/// Wrap a generated deploy script in MySQL's FOREIGN_KEY_CHECKS toggle.
|
||||
///
|
||||
/// The deploy path runs the whole script on one checked-out connection inside a
|
||||
/// single transaction, so the header suppresses foreign key validation for every
|
||||
/// statement in between and the trailer restores the session default before that
|
||||
/// connection returns to the pool.
|
||||
///
|
||||
/// Only MySQL gets the wrapper: FOREIGN_KEY_CHECKS is a MySQL session variable,
|
||||
/// and PostgreSQL, Oracle and the rest reject the statement outright, so a client
|
||||
/// that asks for the option on another engine cannot make the plan unexecutable.
|
||||
/// A script without executable statements is returned unchanged; wrapping it
|
||||
/// would turn "nothing to deploy" into a runnable two-statement script.
|
||||
pub fn wrap_script_with_foreign_key_checks(sql: &str, db_type: DatabaseType) -> String {
|
||||
if db_type != DatabaseType::Mysql || sql.trim().is_empty() {
|
||||
return sql.to_string();
|
||||
}
|
||||
format!("{MYSQL_DISABLE_FOREIGN_KEY_CHECKS}\n{sql}\n{MYSQL_ENABLE_FOREIGN_KEY_CHECKS}")
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn generate_schema_sync_sql_plan(
|
||||
diffs: &[TableDiff],
|
||||
@@ -6022,6 +6046,7 @@ pub fn generate_schema_sync_sql_plan(
|
||||
source_dialect: Option<DialectKind>,
|
||||
field_mappings: &[FieldMapping],
|
||||
enable_rollback: bool,
|
||||
ignore_foreign_key_checks: bool,
|
||||
) -> SchemaSyncSqlPlan {
|
||||
let (sync_sql, _) = generate_schema_sync_sql_inner(
|
||||
diffs,
|
||||
@@ -6035,11 +6060,14 @@ pub fn generate_schema_sync_sql_plan(
|
||||
source_dialect,
|
||||
field_mappings,
|
||||
);
|
||||
let sync_sql =
|
||||
if ignore_foreign_key_checks { wrap_script_with_foreign_key_checks(&sync_sql, db_type) } else { sync_sql };
|
||||
|
||||
let (rollback_sync_sql, missing_rollback_objects) = if enable_rollback {
|
||||
let dependency_graph = DependencyGraph { nodes: HashMap::new(), topological_order: Vec::new() };
|
||||
let rollback_graph = RollbackGraph::from_forward_diffs(diffs, &[], &dependency_graph);
|
||||
let (sql, missing) = generate_rollback_sync_sql_with_missing(&rollback_graph, db_type, schema, cascade_delete);
|
||||
let sql = if ignore_foreign_key_checks { wrap_script_with_foreign_key_checks(&sql, db_type) } else { sql };
|
||||
(Some(sql), missing)
|
||||
} else {
|
||||
(None, Vec::new())
|
||||
@@ -7837,6 +7865,7 @@ mod tests {
|
||||
Some(DialectKind::SqlServer),
|
||||
&[],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let rollback = plan.rollback_sync_sql.expect("rollback SQL");
|
||||
|
||||
@@ -7981,6 +8010,7 @@ mod tests {
|
||||
Some(DialectKind::SqlServer),
|
||||
&[],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let rollback = plan.rollback_sync_sql.expect("rollback SQL");
|
||||
|
||||
@@ -9935,6 +9965,7 @@ mod tests {
|
||||
None,
|
||||
&[],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(plan.sync_sql.contains("ADD COLUMN `nickname`"), "{}", plan.sync_sql);
|
||||
@@ -9943,6 +9974,134 @@ mod tests {
|
||||
assert_eq!(plan.rollback_completeness, RollbackCompleteness::Complete);
|
||||
}
|
||||
|
||||
/// One-column MySQL/PostgreSQL table diff used by the foreign-key-check wrapper tests.
|
||||
fn added_nickname_column_diff() -> TableDiff {
|
||||
TableDiff {
|
||||
diff_type: "modified".to_string(),
|
||||
object_type: Some("table".to_string()),
|
||||
name: "users".to_string(),
|
||||
target_name: None,
|
||||
columns: Some(vec![ColumnDiff {
|
||||
diff_type: "added".to_string(),
|
||||
name: "nickname".to_string(),
|
||||
source: Some(column("nickname", "varchar(64)", None)),
|
||||
target: None,
|
||||
changes: Vec::new(),
|
||||
add_position: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_key_check_wrapper_brackets_mysql_scripts_and_leaves_other_engines_alone() {
|
||||
let script = "ALTER TABLE `users` ADD COLUMN `nickname` varchar(64);";
|
||||
assert_eq!(
|
||||
wrap_script_with_foreign_key_checks(script, DatabaseType::Mysql),
|
||||
format!("{MYSQL_DISABLE_FOREIGN_KEY_CHECKS}\n{script}\n{MYSQL_ENABLE_FOREIGN_KEY_CHECKS}")
|
||||
);
|
||||
for db_type in [DatabaseType::Postgres, DatabaseType::SqlServer, DatabaseType::Oracle, DatabaseType::Sqlite] {
|
||||
assert_eq!(
|
||||
wrap_script_with_foreign_key_checks(script, db_type),
|
||||
script,
|
||||
"{db_type:?} must keep the script unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_key_check_wrapper_leaves_scripts_without_statements_alone() {
|
||||
for script in ["", " \n\t"] {
|
||||
assert_eq!(wrap_script_with_foreign_key_checks(script, DatabaseType::Mysql), script);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_plan_brackets_forward_and_rollback_sql_when_foreign_key_checks_are_ignored() {
|
||||
let plan = generate_schema_sync_sql_plan(
|
||||
&[added_nickname_column_diff()],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
DatabaseType::Mysql,
|
||||
Some("shop"),
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(plan.sync_sql.starts_with(MYSQL_DISABLE_FOREIGN_KEY_CHECKS), "{}", plan.sync_sql);
|
||||
assert!(plan.sync_sql.ends_with(MYSQL_ENABLE_FOREIGN_KEY_CHECKS), "{}", plan.sync_sql);
|
||||
let rollback = plan.rollback_sync_sql.expect("rollback SQL");
|
||||
assert!(rollback.starts_with(MYSQL_DISABLE_FOREIGN_KEY_CHECKS), "{rollback}");
|
||||
assert!(rollback.ends_with(MYSQL_ENABLE_FOREIGN_KEY_CHECKS), "{rollback}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_plan_keeps_the_plain_script_when_foreign_key_checks_are_not_ignored() {
|
||||
let plan = generate_schema_sync_sql_plan(
|
||||
&[added_nickname_column_diff()],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
DatabaseType::Mysql,
|
||||
Some("shop"),
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(!plan.sync_sql.contains("FOREIGN_KEY_CHECKS"), "{}", plan.sync_sql);
|
||||
let rollback = plan.rollback_sync_sql.expect("rollback SQL");
|
||||
assert!(!rollback.contains("FOREIGN_KEY_CHECKS"), "{rollback}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_plan_ignores_the_foreign_key_check_option() {
|
||||
let plan = generate_schema_sync_sql_plan(
|
||||
&[added_nickname_column_diff()],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
DatabaseType::Postgres,
|
||||
Some("public"),
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(plan.sync_sql.contains("ADD COLUMN"), "{}", plan.sync_sql);
|
||||
assert!(!plan.sync_sql.contains("FOREIGN_KEY_CHECKS"), "{}", plan.sync_sql);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_plan_is_not_wrapped_even_when_foreign_key_checks_are_ignored() {
|
||||
let plan = generate_schema_sync_sql_plan(
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
DatabaseType::Mysql,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
assert_eq!(plan.sync_sql, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifies_generated_schema_sync_sql_with_target_schema() {
|
||||
let diffs = vec![TableDiff {
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct GenerateSchemaSyncSqlRequest {
|
||||
pub source_dialect: Option<String>,
|
||||
pub field_mappings: Option<Vec<dbx_core::schema_diff::FieldMapping>>,
|
||||
pub enable_rollback: Option<bool>,
|
||||
/// Wrap the generated forward and rollback scripts in MySQL's
|
||||
/// FOREIGN_KEY_CHECKS toggle. Ignored for targets that are not MySQL.
|
||||
pub ignore_foreign_key_checks: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn generate_schema_sync_plan(
|
||||
@@ -32,6 +35,7 @@ pub async fn generate_schema_sync_plan(
|
||||
req.source_dialect.as_deref().and_then(dbx_core::sql_dialect::descriptor::DialectKind::from_label),
|
||||
req.field_mappings.as_deref().unwrap_or(&[]),
|
||||
req.enable_rollback.unwrap_or(false),
|
||||
req.ignore_foreign_key_checks.unwrap_or(false),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ DBX 提供两类对比工作流:
|
||||
|
||||
最终部署页面会再次显示目标服务器、数据库、Schema、数据库版本和语句数量。生产环境保护会在执行前要求明确确认。
|
||||
|
||||
目标为 MySQL 时,部署审核页还会提供 **忽略外键检查**。它会为部署脚本(以及回滚脚本)在首尾加上 `SET FOREIGN_KEY_CHECKS = 0` 与 `SET FOREIGN_KEY_CHECKS = 1`,让互相引用的表可以按任意顺序删除和重建。该选项默认不勾选;对其他数据库类型无效,因为它们没有这个会话变量。
|
||||
|
||||
<Callout type="info">“在事务中执行”只在目标数据库和语句支持事务 DDL 时提供真正的整体回滚。MySQL 等数据库的 DDL 可能隐式提交。</Callout>
|
||||
|
||||
## 方言语义
|
||||
|
||||
@@ -115,6 +115,8 @@ For selected objects, review:
|
||||
|
||||
The final deployment page repeats the target server, database, schema, database version, and statement count. Production protection requires explicit confirmation before execution.
|
||||
|
||||
When the target is MySQL, deployment review also offers **Ignore foreign key checks**. It brackets the deployment script — and its rollback script — with `SET FOREIGN_KEY_CHECKS = 0` and `SET FOREIGN_KEY_CHECKS = 1`, so tables that reference each other can be dropped and recreated in any order. The option is off by default; it has no effect on other engines, which do not have the session variable.
|
||||
|
||||
<Callout type="info">Run in transaction provides full rollback only when the target database and statements support transactional DDL. MySQL and other databases can implicitly commit DDL.</Callout>
|
||||
|
||||
## Dialect Semantics
|
||||
|
||||
@@ -47,6 +47,9 @@ pub fn generate_schema_sync_plan(
|
||||
source_dialect: Option<dbx_core::sql_dialect::descriptor::DialectKind>,
|
||||
field_mappings: Option<Vec<dbx_core::schema_diff::FieldMapping>>,
|
||||
enable_rollback: Option<bool>,
|
||||
// Wrap the generated forward and rollback scripts in MySQL's FOREIGN_KEY_CHECKS
|
||||
// toggle. Ignored for targets that are not MySQL.
|
||||
ignore_foreign_key_checks: Option<bool>,
|
||||
) -> Result<dbx_core::schema_diff::SchemaSyncSqlPlan, String> {
|
||||
Ok(dbx_core::schema_diff::generate_schema_sync_sql_plan(
|
||||
&diffs,
|
||||
@@ -60,5 +63,6 @@ pub fn generate_schema_sync_plan(
|
||||
source_dialect,
|
||||
&field_mappings.unwrap_or_default(),
|
||||
enable_rollback.unwrap_or(false),
|
||||
ignore_foreign_key_checks.unwrap_or(false),
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user