feat(schema-diff): add MySQL ignore foreign key checks deploy option

This commit is contained in:
DengQingNian
2026-10-10 21:10:58 +08:00
committed by GitHub
parent 5eaccd3062
commit 13601ebe1d
22 changed files with 371 additions and 0 deletions
@@ -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 {
+2
View File
@@ -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ı",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -6767,6 +6767,8 @@ export default withEnglishFallback({
recentConfigs: "最近の設定",
selectRecentConfig: "最近の設定を選択",
ignoreComments: "コメントの差分を無視",
ignoreForeignKeyChecks: "外部キー検査を無視",
ignoreForeignKeyChecksHint: "スクリプトの前後に SET FOREIGN_KEY_CHECKS = 0 / = 1 を付け、テーブルを任意の順序で削除・再作成できるようにします",
compare: "比較",
comparing: "スキーマを比較中...",
noDifferences: "差分が見つかりません",
+2
View File
@@ -6580,6 +6580,8 @@ export default withEnglishFallback({
recentConfigs: "최근 설정",
selectRecentConfig: "최근 설정 선택",
ignoreComments: "주석 차이 무시",
ignoreForeignKeyChecks: "외래 키 검사 무시",
ignoreForeignKeyChecksHint: "스크립트 앞뒤에 SET FOREIGN_KEY_CHECKS = 0 / = 1을 추가해 테이블을 임의 순서로 삭제하고 다시 만들 수 있게 합니다",
compare: "비교",
comparing: "스키마를 비교하는 중...",
noDifferences: "차이점이 없습니다",
+2
View File
@@ -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",
+2
View File
@@ -7979,6 +7979,8 @@ export default withEnglishFallback({
recentConfigs: "Недавние конфигурации",
selectRecentConfig: "Выбрать недавнюю конфигурацию",
ignoreComments: "Игнорировать различия комментариев",
ignoreForeignKeyChecks: "Игнорировать проверку внешних ключей",
ignoreForeignKeyChecksHint: "Оборачивает скрипт в SET FOREIGN_KEY_CHECKS = 0 / = 1, позволяя удалять и создавать таблицы в любом порядке",
compare: "Сравнить",
comparing: "Сравнение схем...",
noDifferences: "Различия не найдены",
+2
View File
@@ -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ı",
+2
View File
@@ -7646,6 +7646,8 @@ export default withEnglishFallback({
recentConfigs: "最近配置",
selectRecentConfig: "选择最近配置",
ignoreComments: "忽略注释差异",
ignoreForeignKeyChecks: "忽略外键检查",
ignoreForeignKeyChecksHint: "在脚本首尾加上 SET FOREIGN_KEY_CHECKS = 0 / = 1,允许以任意顺序删除和重建表",
compare: "开始比较",
comparing: "正在比较结构...",
noDifferences: "两个数据库结构完全一致",
+2
View File
@@ -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"]);
+159
View File
@@ -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 {
+4
View File
@@ -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),
))
}
+2
View File
@@ -115,6 +115,8 @@ DBX 提供两类对比工作流:
最终部署页面会再次显示目标服务器、数据库、Schema、数据库版本和语句数量。生产环境保护会在执行前要求明确确认。
目标为 MySQL 时,部署审核页还会提供 **忽略外键检查**。它会为部署脚本(以及回滚脚本)在首尾加上 `SET FOREIGN_KEY_CHECKS = 0` 与 `SET FOREIGN_KEY_CHECKS = 1`,让互相引用的表可以按任意顺序删除和重建。该选项默认不勾选;对其他数据库类型无效,因为它们没有这个会话变量。
<Callout type="info">“在事务中执行”只在目标数据库和语句支持事务 DDL 时提供真正的整体回滚。MySQL 等数据库的 DDL 可能隐式提交。</Callout>
## 方言语义
+2
View File
@@ -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
+4
View File
@@ -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),
))
}