This commit is contained in:
commit
3260ea8c47
4 changed files with 89 additions and 14 deletions
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue