From 44cc77d8f6f0dc681be9eea342486d77492f9b7f Mon Sep 17 00:00:00 2001 From: le king fu Date: Fri, 1 May 2026 07:18:53 -0400 Subject: [PATCH] fix(balance): use ROW_NUMBER window function in getAccountsPeriodAnchor 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 --- CHANGELOG.fr.md | 4 ++ CHANGELOG.md | 4 ++ src/services/balance.service.test.ts | 61 +++++++++++++++++++++++++++- src/services/balance.service.ts | 34 ++++++++++------ 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7a63b34..949a5bd 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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é diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c70cb..0302bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 4a840e3..d9a78d2 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -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\)/); + }); }); // ----------------------------------------------------------------------------- diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index 7a00732..8052481 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -984,6 +984,12 @@ export async function getAccountsPeriodAnchor( ): Promise { // 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( - `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 - FROM balance_snapshot_lines l - JOIN balance_snapshots s ON s.id = l.snapshot_id - ${where} - GROUP BY l.account_id`, + `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} + ) + WHERE rn = 1`, params ); }