This commit is contained in:
commit
3260ea8c47
4 changed files with 89 additions and 14 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [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
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1057,8 +1057,14 @@ describe("getAccountsPeriodAnchor", () => {
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0].anchor_value).toBe(1000);
|
expect(rows[0].anchor_value).toBe(1000);
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
expect(sql).toContain("MIN(s.snapshot_date)");
|
// Window function: ROW_NUMBER partitioned by account_id, earliest first.
|
||||||
expect(sql).toContain("GROUP BY l.account_id");
|
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"]);
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1075,6 +1081,57 @@ describe("getAccountsPeriodAnchor", () => {
|
||||||
// No WHERE clause when neither bound is set.
|
// No WHERE clause when neither bound is set.
|
||||||
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
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[]> {
|
): Promise<AccountPeriodAnchor[]> {
|
||||||
// For each account, find the earliest snapshot_date >= range.from (and
|
// For each account, find the earliest snapshot_date >= range.from (and
|
||||||
// <= range.to when set), then read that line's value.
|
// <= 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 params: unknown[] = [];
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
if (range.from) {
|
if (range.from) {
|
||||||
|
|
@ -997,18 +1003,22 @@ export async function getAccountsPeriodAnchor(
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
return db.select<AccountPeriodAnchor[]>(
|
return db.select<AccountPeriodAnchor[]>(
|
||||||
`SELECT l.account_id AS account_id,
|
`SELECT account_id,
|
||||||
MIN(s.snapshot_date) AS anchor_snapshot_date,
|
snapshot_date AS anchor_snapshot_date,
|
||||||
(SELECT l2.value
|
value AS anchor_value
|
||||||
FROM balance_snapshot_lines l2
|
FROM (
|
||||||
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
|
SELECT l.account_id AS account_id,
|
||||||
WHERE l2.account_id = l.account_id
|
s.snapshot_date AS snapshot_date,
|
||||||
AND s2.snapshot_date = MIN(s.snapshot_date)
|
l.value AS value,
|
||||||
LIMIT 1) AS anchor_value
|
ROW_NUMBER() OVER (
|
||||||
FROM balance_snapshot_lines l
|
PARTITION BY l.account_id
|
||||||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
ORDER BY s.snapshot_date ASC
|
||||||
${where}
|
) AS rn
|
||||||
GROUP BY l.account_id`,
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
${where}
|
||||||
|
)
|
||||||
|
WHERE rn = 1`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue