fix(transactions): bind the manual-categorization flag as an integer (#390)
updateTransactionCategory bound the JS boolean `true`. tauri-plugin-sql passes a bool to sqlx as a JSON value, so the column received the TEXT 'true', which the getting-started checklist's `is_manually_categorized = 1` never matches: step 5 never ticked. toSqlFlag turns a boolean, a number or a backup's string into 0/1. It is used when a category is set by hand and when a JSON or CSV backup is restored, since a backup exported while the bug stood carries "true" (the CSV path read it as 0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8907eef5df
commit
f758282c2e
6 changed files with 134 additions and 4 deletions
|
|
@ -38,6 +38,7 @@ import {
|
|||
importCategoriesOnly,
|
||||
importTransactionsOnly,
|
||||
importTransactionsWithCategories,
|
||||
parseImportedCsv,
|
||||
parseImportedJson,
|
||||
serializeToJson,
|
||||
validateImportedFormatRows,
|
||||
|
|
@ -728,3 +729,36 @@ describe("the refusal messages are translated (#331)", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. The manual-categorization flag (#390)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a restore writes the manual flag as an integer (#390)", () => {
|
||||
// A backup exported while the Transactions page bound the JS boolean carries
|
||||
// the text "true"; written back as is, the checklist's `= 1` misses it.
|
||||
const fromTheBug = { ...transactionFixture(1), is_manually_categorized: "true" as unknown as number };
|
||||
|
||||
it("restores the 'true' of an older JSON backup as 1", async () => {
|
||||
await importTransactionsWithCategories(
|
||||
envelopeData({ transactions: [fromTheBug, transactionFixture(2)] }),
|
||||
"backup.json"
|
||||
);
|
||||
expect(db.tables.transactions.map((t) => t.is_manually_categorized)).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
it("does the same in transactions_only mode", async () => {
|
||||
await importTransactionsOnly(envelopeData({ transactions: [fromTheBug] }), "backup.json");
|
||||
expect(db.tables.transactions[0].is_manually_categorized).toBe(1);
|
||||
});
|
||||
|
||||
it("reads the flag of a CSV export, 'true' included", () => {
|
||||
const csv =
|
||||
"date,description,amount,is_manually_categorized,is_split\n" +
|
||||
"2026-01-01,a,-1,true,0\n" +
|
||||
"2026-01-02,b,-2,1,0\n" +
|
||||
"2026-01-03,c,-3,0,0\n";
|
||||
const { transactions } = parseImportedCsv(csv);
|
||||
expect(transactions.map((t) => t.is_manually_categorized)).toEqual([1, 1, 0]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type Database from "@tauri-apps/plugin-sql";
|
||||
import { getDb, withTransaction } from "./db";
|
||||
import Papa from "papaparse";
|
||||
import { toSqlFlag } from "../utils/sqlFlag";
|
||||
import type {
|
||||
Category,
|
||||
ImportConfigTemplate,
|
||||
|
|
@ -360,7 +361,8 @@ export function parseImportedCsv(content: string): {
|
|||
category_name: row.category_name || null,
|
||||
original_description: row.original_description || null,
|
||||
notes: row.notes || null,
|
||||
is_manually_categorized: parseInt(row.is_manually_categorized) || 0,
|
||||
// An export from the time of #390 carries "true" in this column.
|
||||
is_manually_categorized: toSqlFlag(row.is_manually_categorized),
|
||||
is_split: parseInt(row.is_split) || 0,
|
||||
parent_transaction_id: row.parent_transaction_id
|
||||
? parseInt(row.parent_transaction_id)
|
||||
|
|
@ -638,7 +640,8 @@ async function attachTransactions(
|
|||
tx.category_id,
|
||||
tx.original_description,
|
||||
tx.notes,
|
||||
tx.is_manually_categorized,
|
||||
// A JSON backup from the time of #390 carries the text "true".
|
||||
toSqlFlag(tx.is_manually_categorized),
|
||||
tx.is_split,
|
||||
tx.parent_transaction_id,
|
||||
sourceId,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ vi.mock("./db", () => {
|
|||
});
|
||||
|
||||
import { getDb } from "./db";
|
||||
import { getTransactionCount } from "./transactionService";
|
||||
import { getTransactionCount, updateTransactionCategory } from "./transactionService";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
const mockExecute = vi.fn();
|
||||
|
|
@ -41,3 +41,22 @@ describe("getTransactionCount (#351)", () => {
|
|||
await expect(getTransactionCount()).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTransactionCategory (#390)", () => {
|
||||
// tauri-plugin-sql stores a bound JS boolean as the TEXT 'true', which the
|
||||
// getting-started checklist's `is_manually_categorized = 1` never matches.
|
||||
it("binds the manual flag as the integer 1, never as a boolean", async () => {
|
||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||
await updateTransactionCategory(5, 12, true);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||||
expect(params).toEqual([12, 1, 5]);
|
||||
expect(typeof params[1]).toBe("number");
|
||||
});
|
||||
|
||||
it("binds 0 when the category is not set by hand", async () => {
|
||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||||
await updateTransactionCategory(5, null, false);
|
||||
expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
isLinkedTransactionFkError,
|
||||
type LinkedTransferTooltipRow,
|
||||
} from "./balance.service";
|
||||
import { toSqlFlag } from "../utils/sqlFlag";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionRow,
|
||||
|
|
@ -299,9 +300,11 @@ export async function updateTransactionCategory(
|
|||
isManual: boolean
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
// Never bind the boolean itself: tauri-plugin-sql would store the TEXT
|
||||
// 'true', which `is_manually_categorized = 1` never matches (#390).
|
||||
await db.execute(
|
||||
`UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
|
||||
[categoryId, isManual, txId]
|
||||
[categoryId, toSqlFlag(isManual), txId]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
49
src/utils/sqlFlag.test.ts
Normal file
49
src/utils/sqlFlag.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { toSqlFlag } from "./sqlFlag";
|
||||
|
||||
describe("toSqlFlag (#390)", () => {
|
||||
it("maps the UI boolean to an integer, never to the boolean itself", () => {
|
||||
expect(toSqlFlag(true)).toBe(1);
|
||||
expect(toSqlFlag(false)).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps database integers", () => {
|
||||
expect(toSqlFlag(1)).toBe(1);
|
||||
expect(toSqlFlag(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats any finite non-zero number as set, like SQLite", () => {
|
||||
expect(toSqlFlag(2)).toBe(1);
|
||||
expect(toSqlFlag(-1)).toBe(1);
|
||||
});
|
||||
|
||||
it("maps NaN and infinities to 0", () => {
|
||||
expect(toSqlFlag(Number.NaN)).toBe(0);
|
||||
expect(toSqlFlag(Number.POSITIVE_INFINITY)).toBe(0);
|
||||
});
|
||||
|
||||
it("reads the 'true' a backup carries from the time of the bug", () => {
|
||||
expect(toSqlFlag("true")).toBe(1);
|
||||
expect(toSqlFlag("TRUE")).toBe(1);
|
||||
expect(toSqlFlag(" true ")).toBe(1);
|
||||
});
|
||||
|
||||
it("reads a CSV cell", () => {
|
||||
expect(toSqlFlag("1")).toBe(1);
|
||||
expect(toSqlFlag(" 1 ")).toBe(1);
|
||||
expect(toSqlFlag("0")).toBe(0);
|
||||
expect(toSqlFlag("")).toBe(0);
|
||||
});
|
||||
|
||||
it("maps every other string to 0", () => {
|
||||
expect(toSqlFlag("false")).toBe(0);
|
||||
expect(toSqlFlag("yes")).toBe(0);
|
||||
expect(toSqlFlag("01")).toBe(0);
|
||||
});
|
||||
|
||||
it("maps absent values to 0", () => {
|
||||
expect(toSqlFlag(null)).toBe(0);
|
||||
expect(toSqlFlag(undefined)).toBe(0);
|
||||
expect(toSqlFlag({})).toBe(0);
|
||||
});
|
||||
});
|
||||
22
src/utils/sqlFlag.ts
Normal file
22
src/utils/sqlFlag.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Turn any truthy-looking value into the integer SQLite expects for a 0/1
|
||||
* flag column (#390).
|
||||
*
|
||||
* Never bind a JS boolean through tauri-plugin-sql: the plugin only special-
|
||||
* cases null, strings and numbers, so `true` reaches sqlx as a JSON value and
|
||||
* is stored as the TEXT `'true'`. An INTEGER column keeps that text (it is not
|
||||
* a number), and `WHERE flag = 1` never matches it.
|
||||
*
|
||||
* Accepts what a flag can look like on the way in: a boolean from the UI, a
|
||||
* number from the database, or a string from a backup (`"1"`, or `"true"` in a
|
||||
* backup exported while the bug stood).
|
||||
*/
|
||||
export function toSqlFlag(value: unknown): 0 | 1 {
|
||||
if (value === true) return 1;
|
||||
if (typeof value === "number") return Number.isFinite(value) && value !== 0 ? 1 : 0;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" ? 1 : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue