Skip to content

Commit dcc4eb6

Browse files
committed
test: 잔여 뮤턴트 정리 (rank 공간 치환 + 심링크/PK 테스트)
cargo-mutants 5개 shard에 남아 있던 미검출 뮤턴트를 정리한다. - sort_delete_tables: slot 공간 대신 rank 공간(0..k)에서 치환을 적용하도록 재작성. delete_indices는 오름차순이라 rank r과 slot delete_indices[r]이 순서 동형이므로 `slot - rank_base` 변환과 delete slot 사이의 gap까지 포괄하던 희소 역인덱스가 통째로 불필요해진다. 이 산술을 겨냥하던 뮤턴트 5건이 구조적으로 사라지며, 출력은 이전과 바이트 동일하다. 비연속·비-0 시작 delete run 3건을 permute하는 테스트로 재작성분을 고정했다. - string_enum_value_removed: 옛 default가 신·구 enum 어디에도 속하지 않으면 재정렬이 일어나지 않아야 함을 고정. `any(|v| v == needle)`이 `!=`로 뒤집히면 모든 비-멤버 default를 "제거된 값"으로 오판해 두 액션을 뒤바꾼다. - collect_model_paths: models/ 안의 파일 심링크는 따라가고 끊어진 심링크는 건너뛰는 #[cfg(unix)] 테스트 추가. `is_symlink && ...` 두 연접이 `||`로 바뀌면 각각 파일을 read_dir 하거나 없는 경로를 read_to_string 해 Err가 된다. - should_skip_sqlite_auto_increment_pk: auto-increment를 지원하지 않는 타입 (TEXT)에 auto_increment PK를 걸면 SQLite도 명시적 PRIMARY KEY 절을 유지해야 함을 3-백엔드 스냅샷으로 고정. - render_migration_name의 `+=` 뮤턴트는 기존 E18(비종료) 범주에 편입. `*= 1` / `/= 1`은 항등이라 커서가 루프의 감소 측도이길 멈춰 스캔이 bytes.len()에 도달하지 못한다. 검증: 위 4개 뮤턴트를 수동 적용해 CAUGHT 확인(심링크 2건은 WSL Linux에서 실행), fmt / test / clippy -D warnings / line-budget 모두 통과.
1 parent ad6101a commit dcc4eb6

8 files changed

Lines changed: 255 additions & 33 deletions

‎.cargo/mutants.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,13 @@ exclude_re = [
183183
"check_expr_parser[.]rs:[0-9]+:[0-9]+: replace < with (<=|==|>) in tokenize_spanned",
184184
"check_expr_parser[.]rs:[0-9]+:[0-9]+: replace [+]= with (-=|[*]=|/=) in Parser::parse_predicate",
185185
"layout[.]rs:[0-9]+:[0-9]+: replace [+]= with (-=|[*]=|/=) in rebalance_groups",
186+
# Same non-termination class in the migration-filename byte scanner:
187+
# `render_migration_name` walks `i` (and the inner width cursor `j`) forward
188+
# by 1 or 2 per step. `*= 1` is the identity, and `-=` walks backwards into
189+
# an underflow, so either way the cursor stops being the loop's decreasing
190+
# measure and the scan never reaches `bytes.len()`. The scanner's comparison
191+
# and slice-bound mutants stay in the gate - `%012` and `%06v` cover those.
192+
"cli/src/utils[.]rs:[0-9]+:[0-9]+: replace [+]= with (-=|[*]=|/=) in render_migration_name",
186193
# E19: `String::with_capacity` / `Vec::reserve` sizing arithmetic. These
187194
# mutants change only how much the buffer pre-allocates; every one of these
188195
# functions then pushes the same bytes in the same order, so the returned

‎crates/vespertide-loader/src/models.rs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,67 @@ mod tests {
214214
assert_eq!(models[0].name, "users");
215215
}
216216

217+
// `DirEntry::file_type()` describes the LINK, never its target, so symlinked
218+
// entries fall back to `Path::is_dir` / `Path::is_file`. Three shapes must be
219+
// told apart inside `models/`:
220+
// * `order.json` -> a real file OUTSIDE models/ => loaded
221+
// * `nowhere.json` -> a missing target (dangling) => skipped
222+
// * `user.json` -> a plain file => loaded
223+
// Misreading a file symlink as a directory would `read_dir` a file, and
224+
// misreading a dangling symlink as a file would `read_to_string` a path that
225+
// does not exist. Both surface as `Err`, so the successful two-table load
226+
// pins each `is_symlink && ...` conjunction.
227+
#[cfg(unix)]
228+
#[test]
229+
#[serial]
230+
fn load_models_follows_file_symlinks_and_skips_dangling_ones() {
231+
use std::os::unix::fs::symlink;
232+
233+
let tmp = tempdir().unwrap();
234+
let _guard = CwdGuard::new(tmp.path());
235+
write_default_config("vespertide.json");
236+
237+
fs::create_dir_all("models").unwrap();
238+
fs::create_dir_all("external").unwrap();
239+
240+
let write_model = |path: &str, name: &str| {
241+
let table = TableDef {
242+
name: name.into(),
243+
description: None,
244+
columns: vec![ColumnDef::new(
245+
"id",
246+
ColumnType::Simple(SimpleColumnType::Integer),
247+
false,
248+
)],
249+
constraints: vec![TableConstraint::PrimaryKey {
250+
auto_increment: false,
251+
columns: vec!["id".into()],
252+
strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
253+
}],
254+
};
255+
fs::write(path, serde_json::to_string(&table).unwrap()).unwrap();
256+
};
257+
258+
write_model("models/user.json", "users");
259+
write_model("external/order.json", "orders");
260+
261+
symlink(tmp.path().join("external/order.json"), "models/order.json").unwrap();
262+
symlink(
263+
tmp.path().join("external/nowhere.json"),
264+
"models/nowhere.json",
265+
)
266+
.unwrap();
267+
268+
let mut names: Vec<String> = load_models(&VespertideConfig::default())
269+
.unwrap()
270+
.into_iter()
271+
.map(|t| t.name.into_inner())
272+
.collect();
273+
names.sort();
274+
275+
assert_eq!(names, ["orders", "users"]);
276+
}
277+
217278
#[test]
218279
#[serial]
219280
fn load_models_recursive_processes_subdirectories() {

‎crates/vespertide-planner/src/diff/ordering.rs‎

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -185,45 +185,44 @@ pub(super) fn sort_delete_tables(
185185
// Reorder the DeleteTable actions among their existing slots according to
186186
// `sorted_positions`, WITHOUT cloning each action.
187187
//
188-
// `delete_indices` is ascending (it comes from `.enumerate()`). `order[k]`
189-
// holds the ORIGINAL slot whose action belongs at the k-th delete slot; the
190-
// stable sort keeps equal-position actions in their original relative order,
191-
// matching the previous stable `sort_by` byte-for-byte.
192-
let mut order: Vec<usize> = delete_indices.clone();
193-
order.sort_by_key(|&i| {
194-
let name = extract_delete_table_name(&actions[i]);
188+
// Everything below works in RANK space (`0..k` over the delete run) rather
189+
// than in raw slot space. `delete_indices` is ascending (it comes from
190+
// `.enumerate()`), so rank `r` and slot `delete_indices[r]` are
191+
// order-isomorphic — ranks index the bookkeeping arrays directly, with no
192+
// `slot - base` translation and no sparse side table spanning the gaps
193+
// between delete slots.
194+
//
195+
// `order[dst]` holds the ORIGINAL rank whose action belongs at rank `dst`;
196+
// the stable sort keeps equal-position actions in their original relative
197+
// order, matching the previous stable `sort_by` byte-for-byte.
198+
let k = delete_indices.len();
199+
let mut order: Vec<usize> = (0..k).collect();
200+
order.sort_by_key(|&rank| {
201+
let name = extract_delete_table_name(&actions[delete_indices[rank]]);
195202
sorted_positions.get(name).copied().unwrap_or(0)
196203
});
197204

198205
// Apply the permutation `order` onto the delete slots via selection-style
199206
// swaps, moving each action into its destination with owned moves and no
200-
// clone. We keep two mutually-inverse index arrays and update BOTH in O(1)
201-
// per swap, so resolving "which rank currently holds the wanted action" is a
202-
// direct array read instead of the previous inner `where_now.position(..)`
203-
// linear rescan — making the apply O(k) instead of O(k²):
204-
// * `where_now[rank]` — original slot currently occupying that delete rank
205-
// * `slot_at_rank` — its inverse, keyed by rank offset within the run
207+
// clone. We keep two mutually-inverse rank arrays and update BOTH in O(1)
208+
// per swap, so resolving "which position currently holds the wanted action"
209+
// is a direct array read instead of an inner `.position(..)` linear rescan
210+
// — making the apply O(k) instead of O(k²):
211+
// * `origin_at[pos]` — original rank of the action now sitting at `pos`
212+
// * `pos_of[origin]` — its inverse
206213
// The reordered `DeleteTable` payloads stay byte-identical to before.
207-
//
208-
// Delete slots are ascending, so `delete_indices[0]` is the lowest slot;
209-
// `rank_base` lets us index the inverse array by `slot - rank_base` cheaply.
210-
let mut where_now: Vec<usize> = delete_indices.clone();
211-
let rank_base = delete_indices[0];
212-
let span = delete_indices[delete_indices.len() - 1] - rank_base + 1;
213-
let mut slot_to_rank = vec![usize::MAX; span];
214-
for (rank, &slot) in delete_indices.iter().enumerate() {
215-
slot_to_rank[slot - rank_base] = rank;
216-
}
217-
for dst_rank in 0..delete_indices.len() {
218-
let want_slot = order[dst_rank];
219-
let src_rank = slot_to_rank[want_slot - rank_base];
220-
if src_rank != dst_rank {
221-
actions.swap(delete_indices[dst_rank], delete_indices[src_rank]);
222-
// Swap the two slots' bookkeeping so both arrays stay consistent.
223-
let displaced_slot = where_now[dst_rank];
224-
where_now.swap(dst_rank, src_rank);
225-
slot_to_rank[want_slot - rank_base] = dst_rank;
226-
slot_to_rank[displaced_slot - rank_base] = src_rank;
214+
let mut origin_at: Vec<usize> = (0..k).collect();
215+
let mut pos_of: Vec<usize> = (0..k).collect();
216+
for dst in 0..k {
217+
let want = order[dst];
218+
let src = pos_of[want];
219+
if src != dst {
220+
actions.swap(delete_indices[dst], delete_indices[src]);
221+
// Swap the two positions' bookkeeping so both arrays stay consistent.
222+
let displaced = origin_at[dst];
223+
origin_at.swap(dst, src);
224+
pos_of[want] = dst;
225+
pos_of[displaced] = src;
227226
}
228227
}
229228
}

‎crates/vespertide-planner/src/diff/tests/ordering_sort.rs‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,17 @@ mod topo_and_delete_sort_tests {
228228
MigrationAction::DeleteTable { table: name.into() }
229229
}
230230

231+
// An action `sort_delete_tables` must never touch, tagged by column name so
232+
// the assertions can prove it stayed in its original slot.
233+
fn marker(column: &str) -> MigrationAction {
234+
MigrationAction::ModifyColumnDefault {
235+
table: "unrelated".into(),
236+
column: column.into(),
237+
new_default: None,
238+
backfill: None,
239+
}
240+
}
241+
231242
// A standalone table `a` plus a `b <-> c` FK cycle. The error must list
232243
// ONLY the cyclic tables (b, c), proving the `!result.iter().any(name ==
233244
// t.name)` "not-yet-placed" filter. The `delete !` mutant and the `== ->
@@ -329,4 +340,102 @@ mod topo_and_delete_sort_tests {
329340
MigrationAction::ModifyColumnDefault { .. }
330341
));
331342
}
343+
344+
// Three FK-chained deletes (c -> b -> a) that neither start at slot 0 nor
345+
// occupy contiguous slots: inert actions sit before and between them. The
346+
// delete payloads must be permuted among exactly their own slots (1, 3, 4)
347+
// into dependent-first order while the inert actions stay put. Pins the
348+
// rank-space permutation apply — any confusion between a delete's rank
349+
// (0..3) and its slot (1, 3, 4) either displaces an inert action or drops
350+
// a delete on the floor.
351+
#[test]
352+
fn sort_delete_permutes_only_delete_slots_in_a_three_table_chain() {
353+
let a = pk_table("a", None);
354+
let b = pk_table("b", Some("a"));
355+
let c = pk_table("c", Some("b"));
356+
let mut all: BTreeMap<&str, &TableDef> = BTreeMap::new();
357+
all.insert("a", &a);
358+
all.insert("b", &b);
359+
all.insert("c", &c);
360+
361+
let mut actions = vec![
362+
marker("first"),
363+
delete("a"),
364+
marker("middle"),
365+
delete("b"),
366+
delete("c"),
367+
];
368+
sort_delete_tables(&mut actions, &all);
369+
370+
// Dependents first: c (FK-> b), then b (FK-> a), then a.
371+
assert_eq!(extract_delete_table_name(&actions[1]), "c");
372+
assert_eq!(extract_delete_table_name(&actions[3]), "b");
373+
assert_eq!(extract_delete_table_name(&actions[4]), "a");
374+
375+
// The non-delete actions never move.
376+
assert!(matches!(
377+
&actions[0],
378+
MigrationAction::ModifyColumnDefault { column, .. } if column.as_str() == "first"
379+
));
380+
assert!(matches!(
381+
&actions[2],
382+
MigrationAction::ModifyColumnDefault { column, .. } if column.as_str() == "middle"
383+
));
384+
}
385+
386+
// The old default ('archived') belongs to NEITHER the old nor the new enum
387+
// — a stale default left behind by a hand edit. Dropping 'inactive' does
388+
// not remove the *default's* value, so the default change carries no
389+
// ordering dependency and the actions must stay as-is. Pins the
390+
// `from_values` membership half of `string_enum_value_removed`: an
391+
// `any(|v| v != needle)` mutant classifies every non-member default as
392+
// removed and reorders the pair.
393+
#[test]
394+
fn enum_default_reorder_skips_when_old_default_is_not_an_enum_member() {
395+
use crate::diff::ordering::sort_enum_default_dependencies;
396+
use vespertide_core::{ComplexColumnType, DefaultValue, EnumValues};
397+
398+
let old_enum = ColumnType::Complex(ComplexColumnType::Enum {
399+
name: "e".into(),
400+
values: EnumValues::String(vec!["active".into(), "inactive".into()]),
401+
});
402+
let new_enum = ColumnType::Complex(ComplexColumnType::Enum {
403+
name: "e".into(),
404+
values: EnumValues::String(vec!["active".into()]),
405+
});
406+
407+
let mut from_col = col("status", old_enum);
408+
from_col.default = Some(DefaultValue::String("'archived'".into()));
409+
let from_table = table("t", vec![from_col], vec![]);
410+
let mut from_map: BTreeMap<&str, &TableDef> = BTreeMap::new();
411+
from_map.insert("t", &from_table);
412+
413+
let mut actions = vec![
414+
MigrationAction::ModifyColumnType {
415+
table: "t".into(),
416+
column: "status".into(),
417+
new_type: new_enum,
418+
fill_with: None,
419+
narrowing_strategy: None,
420+
timezone: None,
421+
},
422+
MigrationAction::ModifyColumnDefault {
423+
table: "t".into(),
424+
column: "status".into(),
425+
new_default: Some("'active'".into()),
426+
backfill: None,
427+
},
428+
];
429+
430+
sort_enum_default_dependencies(&mut actions, &from_map);
431+
432+
assert!(
433+
matches!(&actions[0], MigrationAction::ModifyColumnType { .. }),
434+
"type change must stay first when the old default is not an enum member"
435+
);
436+
assert!(matches!(
437+
&actions[1],
438+
MigrationAction::ModifyColumnDefault { .. }
439+
));
440+
}
332441
}

‎crates/vespertide-query/src/sql/create_table.rs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,37 @@ mod tests {
695695
});
696696
}
697697

698+
#[rstest]
699+
#[case::auto_increment_unsupported_type_postgres(DatabaseBackend::Postgres)]
700+
#[case::auto_increment_unsupported_type_mysql(DatabaseBackend::MySql)]
701+
#[case::auto_increment_unsupported_type_sqlite(DatabaseBackend::Sqlite)]
702+
fn test_create_table_auto_increment_pk_on_non_integer_column(#[case] backend: DatabaseBackend) {
703+
// SQLite folds an auto-increment PK into the column definition
704+
// (`INTEGER PRIMARY KEY AUTOINCREMENT`) and therefore SKIPS the separate
705+
// PRIMARY KEY clause — but only when the PK column's type can actually
706+
// carry AUTOINCREMENT. TEXT cannot, so nothing is folded into the column
707+
// and the explicit PRIMARY KEY clause must survive on every backend.
708+
let columns = vec![col("id", ColumnType::Simple(SimpleColumnType::Text))];
709+
let constraints = vec![TableConstraint::PrimaryKey {
710+
auto_increment: true,
711+
columns: vec!["id".into()],
712+
strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
713+
}];
714+
715+
let queries = build_create_table(backend, "users", &columns, &constraints).unwrap();
716+
let sql = join_queries(&queries, backend, ";\n");
717+
718+
assert!(
719+
sql.to_uppercase().contains("PRIMARY KEY"),
720+
"auto-increment PK on a type without auto-increment support must still \
721+
emit an explicit PRIMARY KEY, got: {sql}"
722+
);
723+
724+
with_settings!({ snapshot_suffix => format!("create_table_auto_increment_pk_on_non_integer_column_{:?}", backend) }, {
725+
assert_snapshot!(sql);
726+
});
727+
}
728+
698729
#[rstest]
699730
#[case::inline_auto_increment_postgres(DatabaseBackend::Postgres)]
700731
#[case::inline_auto_increment_mysql(DatabaseBackend::MySql)]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: crates/vespertide-query/src/sql/create_table.rs
3+
expression: sql
4+
---
5+
CREATE TABLE `users` ( `id` text, PRIMARY KEY (`id`) )
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: crates/vespertide-query/src/sql/create_table.rs
3+
expression: sql
4+
---
5+
CREATE TABLE "users" ( "id" text, PRIMARY KEY ("id") )
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: crates/vespertide-query/src/sql/create_table.rs
3+
expression: sql
4+
---
5+
CREATE TABLE "users" ( "id" text, PRIMARY KEY ("id") )

0 commit comments

Comments
 (0)