fix(balance): use ROW_NUMBER window function in getAccountsPeriodAnchor
All checks were successful
PR Check / rust (pull_request) Successful in 22m48s
PR Check / frontend (pull_request) Successful in 2m22s

SQLite raised "misuse of aggregate function MIN()" because MIN was used
in the WHERE clause of a scalar subquery. Replace with ROW_NUMBER()
OVER (PARTITION BY account_id ORDER BY snapshot_date ASC) filtered on
rn = 1.

Adds vitest coverage and a regression test for /balance load.

Resolves #175
This commit is contained in:
le king fu 2026-05-01 07:18:53 -04:00
parent bde47dabed
commit 44cc77d8f6
4 changed files with 89 additions and 14 deletions

View file

@ -2,6 +2,10 @@
## [Non publié]
### Corrigé
- Bilan : correction de l'erreur SQLite « misuse of aggregate function MIN() » au chargement de /balance avec des snapshots existants ; remplacement du pattern aggregate-in-WHERE par une window function ROW_NUMBER() dans getAccountsPeriodAnchor (#175).
## [0.9.0] - 2026-04-29
### Ajouté

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Bilan: fix SQLite "misuse of aggregate function MIN()" error when loading /balance with existing snapshots; replaced aggregate-in-WHERE pattern with ROW_NUMBER() window function in getAccountsPeriodAnchor (#175).
## [0.9.0] - 2026-04-29
### Added

View file

@ -1057,8 +1057,14 @@ describe("getAccountsPeriodAnchor", () => {
expect(rows).toHaveLength(1);
expect(rows[0].anchor_value).toBe(1000);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("MIN(s.snapshot_date)");
expect(sql).toContain("GROUP BY l.account_id");
// Window function: ROW_NUMBER partitioned by account_id, earliest first.
expect(sql).toContain("ROW_NUMBER()");
expect(sql).toContain("PARTITION BY l.account_id");
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
expect(sql).toContain("WHERE rn = 1");
// Old aggregate-in-WHERE pattern must be gone (regression guard, #175).
expect(sql).not.toContain("MIN(s.snapshot_date)");
expect(sql).not.toContain("GROUP BY l.account_id");
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
});
@ -1075,6 +1081,57 @@ describe("getAccountsPeriodAnchor", () => {
// No WHERE clause when neither bound is set.
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
});
it("returns earliest snapshot per account within range", async () => {
// Multiple accounts, each with multiple snapshots in the window.
// The DB returns one row per account (the rn = 1 row), so the mocked
// result mirrors that contract.
mockSelect.mockResolvedValueOnce([
{ account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
{ account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
]);
const rows = await getAccountsPeriodAnchor({
from: "2026-02-01",
to: "2026-06-30",
});
expect(rows).toEqual([
{ account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
{ account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
]);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("ROW_NUMBER()");
expect(sql).toContain("PARTITION BY l.account_id");
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
expect(sql).toContain("WHERE rn = 1");
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-02-01", "2026-06-30"]);
});
it("returns [] for an empty window (no snapshots in range)", async () => {
mockSelect.mockResolvedValueOnce([]);
const rows = await getAccountsPeriodAnchor({
from: "2099-01-01",
to: "2099-12-31",
});
expect(rows).toEqual([]);
});
// Regression: /balance load (issue #175) used to throw "misuse of aggregate
// function MIN()" because MIN was used inside the WHERE of a scalar
// subquery. With ROW_NUMBER() the query is plain SQLite — assert the
// service forwards rows from db.select without throwing.
it("regression #175: loads without SQLite aggregate misuse error", async () => {
mockSelect.mockResolvedValueOnce([
{ account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
]);
await expect(
getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" })
).resolves.toEqual([
{ account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
]);
const sql = mockSelect.mock.calls[0][0] as string;
// The exact pattern that triggered the SQLite error must not reappear.
expect(sql).not.toMatch(/=\s*MIN\(s\.snapshot_date\)/);
});
});
// -----------------------------------------------------------------------------

View file

@ -984,6 +984,12 @@ export async function getAccountsPeriodAnchor(
): Promise<AccountPeriodAnchor[]> {
// For each account, find the earliest snapshot_date >= range.from (and
// <= range.to when set), then read that line's value.
//
// We use a ROW_NUMBER() window function partitioned by account_id and
// ordered by snapshot_date ASC, then keep only rn = 1 per account. This
// avoids the previous "MIN(s.snapshot_date) inside a scalar subquery
// WHERE" pattern, which SQLite rejects with "misuse of aggregate function
// MIN()" (issue #175).
const params: unknown[] = [];
const conditions: string[] = [];
if (range.from) {
@ -997,18 +1003,22 @@ export async function getAccountsPeriodAnchor(
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const db = await getDb();
return db.select<AccountPeriodAnchor[]>(
`SELECT l.account_id AS account_id,
MIN(s.snapshot_date) AS anchor_snapshot_date,
(SELECT l2.value
FROM balance_snapshot_lines l2
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
WHERE l2.account_id = l.account_id
AND s2.snapshot_date = MIN(s.snapshot_date)
LIMIT 1) AS anchor_value
`SELECT account_id,
snapshot_date AS anchor_snapshot_date,
value AS anchor_value
FROM (
SELECT l.account_id AS account_id,
s.snapshot_date AS snapshot_date,
l.value AS value,
ROW_NUMBER() OVER (
PARTITION BY l.account_id
ORDER BY s.snapshot_date ASC
) AS rn
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
${where}
GROUP BY l.account_id`,
)
WHERE rn = 1`,
params
);
}