Compare commits
No commits in common. "issue-228-v16-guard-convertible-scope" and "main" have entirely different histories.
issue-228-
...
main
1 changed files with 3 additions and 119 deletions
|
|
@ -306,8 +306,7 @@ pub fn run() {
|
||||||
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
||||||
SELECT 1 FROM balance_snapshot_lines sl \
|
SELECT 1 FROM balance_snapshot_lines sl \
|
||||||
JOIN balance_accounts a ON a.id = sl.account_id \
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
JOIN balance_categories c ON c.id = a.balance_category_id \
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \
|
||||||
WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL AND sl.quantity IS NULL \
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
||||||
) THEN 0 ELSE 1 END; \
|
) THEN 0 ELSE 1 END; \
|
||||||
DROP TABLE _v16_guard;",
|
DROP TABLE _v16_guard;",
|
||||||
|
|
@ -2307,8 +2306,7 @@ mod tests {
|
||||||
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
||||||
SELECT 1 FROM balance_snapshot_lines sl \
|
SELECT 1 FROM balance_snapshot_lines sl \
|
||||||
JOIN balance_accounts a ON a.id = sl.account_id \
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
JOIN balance_categories c ON c.id = a.balance_category_id \
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \
|
||||||
WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL AND sl.quantity IS NULL \
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
||||||
) THEN 0 ELSE 1 END; \
|
) THEN 0 ELSE 1 END; \
|
||||||
DROP TABLE _v16_guard;";
|
DROP TABLE _v16_guard;";
|
||||||
|
|
@ -2497,115 +2495,6 @@ mod tests {
|
||||||
assert_eq!(v, 21.0, "intact line keeps its value");
|
assert_eq!(v, 21.0, "intact line keeps its value");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v16_leaves_simple_account_with_residual_symbol_intact() {
|
|
||||||
// Regression #228: the v16 abort guard keyed on `a.symbol IS NOT NULL`
|
|
||||||
// instead of convertibility. A SIMPLE-category account carrying a
|
|
||||||
// residual symbol (left by a priced→simple recategorization; AccountForm
|
|
||||||
// renders the symbol field unconditionally and updateBalanceAccount
|
|
||||||
// preserves it) has quantity-NULL lines by construction. Those satisfied
|
|
||||||
// the over-broad guard predicate (symbol NOT NULL AND qty NULL AND no
|
|
||||||
// holding) → guard inserts 0 → CHECK(ok = 1) fails → the whole v16
|
|
||||||
// migration aborts → the app no longer starts for that profile. The fix
|
|
||||||
// scopes the guard to convertible accounts (c.asset_type IS NOT NULL).
|
|
||||||
let conn = db_through_v13();
|
|
||||||
conn.execute_batch(V14_SQL).expect("apply v14");
|
|
||||||
conn.execute_batch(V15_SQL).expect("apply v15");
|
|
||||||
|
|
||||||
// Simple category: kind = 'simple', asset_type NULL by construction.
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
|
|
||||||
VALUES ('custom_simple', 'custom', 'simple', 90, 0)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let simple_cat: i64 = conn
|
|
||||||
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Account under the simple category, carrying a residual symbol.
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name, symbol, kind) \
|
|
||||||
VALUES (?1, 'Compte residuel', 'RESID', 'simple')",
|
|
||||||
[simple_cat],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let acc: i64 = conn
|
|
||||||
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// One snapshot line with quantity NULL (simple-kind line invariant).
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-01')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let snap_id: i64 = conn
|
|
||||||
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines \
|
|
||||||
(snapshot_id, account_id, quantity, unit_price, value, price_source) \
|
|
||||||
VALUES (?1, ?2, NULL, NULL, 123.0, 'manual')",
|
|
||||||
rusqlite::params![snap_id, acc],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let line: i64 = conn
|
|
||||||
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// v16 must apply cleanly. Pre-fix, the over-broad guard flagged this line
|
|
||||||
// (symbol NOT NULL, qty NULL, no holding) and aborted the whole migration.
|
|
||||||
conn.execute_batch(V16_SQL)
|
|
||||||
.expect("v16 must not abort on a simple account with a residual symbol");
|
|
||||||
|
|
||||||
// The residual symbol minted no security (simple category has no
|
|
||||||
// asset_type → excluded from the securities INSERT, step 1).
|
|
||||||
let resid_secs: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_securities WHERE symbol = 'RESID'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resid_secs, 0, "no security for a simple account's residual symbol");
|
|
||||||
|
|
||||||
// No holding attached to the line.
|
|
||||||
let holdings: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
|
|
||||||
[line],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(holdings, 0, "no holding minted for the simple account");
|
|
||||||
|
|
||||||
// The line is untouched (qty/unit_price stay NULL, value preserved).
|
|
||||||
let (l_qty, l_price, l_val): (Option<f64>, Option<f64>, f64) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
|
|
||||||
[line],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(l_qty.is_none(), "simple line quantity stays NULL");
|
|
||||||
assert!(l_price.is_none(), "simple line unit_price stays NULL");
|
|
||||||
assert_eq!(l_val, 123.0, "simple line value preserved");
|
|
||||||
|
|
||||||
// The account itself is NOT converted: kind stays 'simple', the residual
|
|
||||||
// symbol is preserved, and detailed_since stays NULL (criterion: intact).
|
|
||||||
let (a_kind, a_symbol, a_detailed_since): (String, Option<String>, Option<String>) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT kind, symbol, detailed_since FROM balance_accounts WHERE id = ?1",
|
|
||||||
[acc],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(a_kind, "simple", "simple account must not be converted to detailed");
|
|
||||||
assert_eq!(a_symbol.as_deref(), Some("RESID"), "residual symbol preserved");
|
|
||||||
assert!(a_detailed_since.is_none(), "detailed_since stays NULL");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_v16_is_idempotent_on_rerun() {
|
fn migration_v16_is_idempotent_on_rerun() {
|
||||||
let (conn, line_a, _line_b) = db_pre_v16();
|
let (conn, line_a, _line_b) = db_pre_v16();
|
||||||
|
|
@ -2645,10 +2534,6 @@ mod tests {
|
||||||
|
|
||||||
// The corrupted batch: step 1 + step 3 + guard, with step 2 (holdings)
|
// The corrupted batch: step 1 + step 3 + guard, with step 2 (holdings)
|
||||||
// intentionally removed. NULLing now happens on lines with no holding.
|
// intentionally removed. NULLing now happens on lines with no holding.
|
||||||
// The guard carries the same narrowed predicate as production (#228): the
|
|
||||||
// abort is driven by the convertible line_a (AAPL, asset_type = 'stock'),
|
|
||||||
// NULLed without a holding; line_b (asset_type NULL) no longer trips the
|
|
||||||
// guard, but the convertible line alone is enough to prove abort+rollback.
|
|
||||||
const V16_CORRUPT: &str = "\
|
const V16_CORRUPT: &str = "\
|
||||||
INSERT INTO balance_securities (symbol, currency, asset_type) \
|
INSERT INTO balance_securities (symbol, currency, asset_type) \
|
||||||
SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
|
SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
|
||||||
|
|
@ -2662,8 +2547,7 @@ mod tests {
|
||||||
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
||||||
SELECT 1 FROM balance_snapshot_lines sl \
|
SELECT 1 FROM balance_snapshot_lines sl \
|
||||||
JOIN balance_accounts a ON a.id = sl.account_id \
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
JOIN balance_categories c ON c.id = a.balance_category_id \
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \
|
||||||
WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL AND sl.quantity IS NULL \
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
||||||
) THEN 0 ELSE 1 END; \
|
) THEN 0 ELSE 1 END; \
|
||||||
DROP TABLE _v16_guard;";
|
DROP TABLE _v16_guard;";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue