|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#6814] Aggregate-vocabulary conformance for `driver-memory` — the shared |
| 5 | + * `@objectstack/spec/data` cases, on rows, executed in process. |
| 6 | + * |
| 7 | + * The SQL twins (`sql-driver-aggregation-conformance.test.ts`, |
| 8 | + * `turso-remote-aggregation-conformance.test.ts`, |
| 9 | + * `sqlite-wasm-aggregation-conformance.test.ts`) run this same table against a |
| 10 | + * real database. This file is the reason the cases live in the spec package |
| 11 | + * rather than beside one driver: an aggregate that answers differently here |
| 12 | + * than under SQL pushdown is one query with two numbers, decided by a driver |
| 13 | + * capability bit the caller never sees. |
| 14 | + * |
| 15 | + * ## Why nothing here is a "modelled" evaluation |
| 16 | + * |
| 17 | + * This driver runs in process, so every case below is a REAL execution — no |
| 18 | + * server-free half in the shape `driver-mongodb` needs (#5517), and no emitted- |
| 19 | + * string assertion standing in for a number. That matters for the defect this |
| 20 | + * file was written against: `computeAggregate` had no `count_distinct` arm at |
| 21 | + * all, so the function fell to `default: return null` and `aggregate()` resolved |
| 22 | + * with `{ n: null }` — no error, no log, no refusal. Only executing the case |
| 23 | + * says so; a lowering-shape assertion has nothing to look at. |
| 24 | + * |
| 25 | + * ## Both doors of the data face, and the analytics face beside it |
| 26 | + * |
| 27 | + * `find()` and `aggregate(AST)` are two entries to the same |
| 28 | + * `performAggregation`, and objectql's engine uses the second one. Both are |
| 29 | + * driven, because "the aggregate works" measured through one door is what let |
| 30 | + * this package answer one declared function two ways for as long as it did |
| 31 | + * (#5374). The analytics face (`memory-analytics.ts`) is driven in the last |
| 32 | + * block for the same reason — it implements `count_distinct` independently, so |
| 33 | + * it is a third answer unless something demands they agree. |
| 34 | + * |
| 35 | + * ## Reverse verification — direction predicted BEFORE it was run |
| 36 | + * |
| 37 | + * **(A) the `count_distinct` arm removed** (the pre-#6814 state). Predicted: |
| 38 | + * the three `count_distinct` cases fail on `null` — the value, not a throw — |
| 39 | + * while every arithmetic case stays green, because the missing arm is a silent |
| 40 | + * fall-through rather than a broken computation. |
| 41 | + * |
| 42 | + * **(B) the arm present but written `new Set(values).size`** — null NOT |
| 43 | + * excluded, the mistake `driver-mongodb`'s `$addToSet` made (#6814's other |
| 44 | + * half). Predicted: `count_distinct(stage)` answers 3 instead of 2 and the |
| 45 | + * grouped case answers `west` 3 / `east` 2 instead of 2 / 1, while |
| 46 | + * `count_distinct(score)` stays GREEN at 6 — that column has no nulls, so it |
| 47 | + * cannot see the mistake. (B) is the direction this file exists for. |
| 48 | + * |
| 49 | + * Measured after writing the above, of 34: |
| 50 | + * |
| 51 | + * - **(A) 7 failed / 27 passed.** Every failure was on the VALUE `null` |
| 52 | + * (`expected [{ group: null, value: null }] to deeply equal |
| 53 | + * [{ group: null, value: 2 }]`), through BOTH doors, plus the |
| 54 | + * never-answers-null row — not one on a throw, as predicted. Every |
| 55 | + * arithmetic case stayed green. The analytics block stayed green too, which |
| 56 | + * is the point of driving the faces separately: this revert is one face's |
| 57 | + * defect and the file says which one. |
| 58 | + * - **(B) 6 failed / 28 passed**, on `expected 3 to be 2` ungrouped and |
| 59 | + * `east` 2 / `west` 3 grouped, through both doors, plus the two analytics |
| 60 | + * rows over the same column. `count_distinct(score)` stayed green at 6 |
| 61 | + * throughout, exactly as predicted — which is why the table carries both |
| 62 | + * columns, and why (B) is unreachable by a suite that only tests one. |
| 63 | + * |
| 64 | + * Pre-fix, on unmodified `origin/main` @ `21888ab`: **11 failed / 23 passed** — |
| 65 | + * (A)'s seven plus four more the analytics face contributed on its own account |
| 66 | + * (see the last block). |
| 67 | + */ |
| 68 | + |
| 69 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 70 | +import { AGGREGATION_CASES, AGGREGATION_ROWS } from '@objectstack/spec/data'; |
| 71 | +import type { AggregationCase, Cube } from '@objectstack/spec/data'; |
| 72 | +import { InMemoryDriver } from './memory-driver.js'; |
| 73 | +import { MemoryAnalyticsService } from './memory-analytics.js'; |
| 74 | + |
| 75 | +const TABLE = 'conformance_agg'; |
| 76 | + |
| 77 | +/** The case as the `DriverQuery` shape both doors consume. */ |
| 78 | +const queryFor = (c: AggregationCase) => ({ |
| 79 | + aggregations: [{ function: c.function, ...(c.field ? { field: c.field } : {}), alias: 'n' }], |
| 80 | + // [#6401] A case carrying `groupByAlias` is sent as the STRUCTURED node, so |
| 81 | + // the face receives the union member that declares `alias`. Without this the |
| 82 | + // alias axis would send a bare string and pin nothing. |
| 83 | + ...(c.groupBy |
| 84 | + ? { groupBy: [c.groupByAlias ? { field: c.groupBy, alias: c.groupByAlias } : c.groupBy] } |
| 85 | + : {}), |
| 86 | +}); |
| 87 | + |
| 88 | +/** |
| 89 | + * The rows a case must produce, in the table's own order: `group` ascending for |
| 90 | + * a grouped case, one `null`-grouped row otherwise. |
| 91 | + * |
| 92 | + * [#6401] The group value is read from the column the case SAYS it lands in — |
| 93 | + * `groupByAlias ?? groupBy`. Reading `c.groupBy` unconditionally is the mistake |
| 94 | + * this axis exists to catch: green on a face that ignores the alias. |
| 95 | + * |
| 96 | + * `value` is deliberately NOT coerced with `Number()`. The defect this file was |
| 97 | + * written against answers `null`, and `Number(null)` is `0` — a coercion here |
| 98 | + * would turn "no arm at all" into an ordinary off-by-one and hide the shape of |
| 99 | + * the failure. |
| 100 | + */ |
| 101 | +const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) => { |
| 102 | + const groupKey = c.groupByAlias ?? c.groupBy; |
| 103 | + return rows |
| 104 | + .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: r.n })) |
| 105 | + .sort((x, y) => String(x.group).localeCompare(String(y.group))); |
| 106 | +}; |
| 107 | + |
| 108 | +const expectedFor = (c: AggregationCase) => |
| 109 | + [...c.expected] |
| 110 | + .map((e) => ({ group: e.group, value: e.value })) |
| 111 | + .sort((x, y) => String(x.group).localeCompare(String(y.group))); |
| 112 | + |
| 113 | +async function seed(): Promise<InMemoryDriver> { |
| 114 | + const driver = new InMemoryDriver(); |
| 115 | + for (const row of AGGREGATION_ROWS) await driver.create(TABLE, { ...row }); |
| 116 | + return driver; |
| 117 | +} |
| 118 | + |
| 119 | +describe('[#6814] InMemoryDriver — aggregate vocabulary conformance', () => { |
| 120 | + let driver: InMemoryDriver; |
| 121 | + beforeEach(async () => { driver = await seed(); }); |
| 122 | + |
| 123 | + /** |
| 124 | + * The fixture first, read back rather than trusted — a case that answers 2 |
| 125 | + * because only two rows landed is not a case that deduplicated correctly, and |
| 126 | + * the null-bearing column is the one a seed is most likely to mangle. |
| 127 | + */ |
| 128 | + it('the fixture is all six rows, with the nulls stored AS nulls', async () => { |
| 129 | + const rows = await driver.find(TABLE, { orderBy: [{ field: 'id', order: 'asc' }] }); |
| 130 | + expect(rows.map((r: any) => String(r.id))).toEqual(['1', '2', '3', '4', '5', '6']); |
| 131 | + for (const r of rows as any[]) { |
| 132 | + const seeded = AGGREGATION_ROWS.find((s) => s.id === String(r.id))!; |
| 133 | + expect([r.region, r.stage, r.score], r.id).toEqual([seeded.region, seeded.stage, seeded.score]); |
| 134 | + } |
| 135 | + // The property every null case hangs off, asserted directly: an empty |
| 136 | + // string in place of a null keeps the count_distinct cases green at the |
| 137 | + // wrong number. |
| 138 | + expect((rows as any[]).filter((r) => r.stage === null)).toHaveLength(2); |
| 139 | + }); |
| 140 | + |
| 141 | + for (const c of AGGREGATION_CASES) { |
| 142 | + it(`find(): ${c.name}`, async () => { |
| 143 | + const rows = await driver.find(TABLE, queryFor(c) as any); |
| 144 | + expect(actualFor(c, rows as any[]), c.note ?? c.name).toEqual(expectedFor(c)); |
| 145 | + }); |
| 146 | + |
| 147 | + /** |
| 148 | + * The SECOND door onto the same computation — objectql's engine calls |
| 149 | + * `aggregate(object, AST)`, not `find()`. Two doors that can disagree is |
| 150 | + * this package's recurring defect class (#5374), so neither is trusted to |
| 151 | + * stand for the other. |
| 152 | + */ |
| 153 | + it(`aggregate(AST): ${c.name}`, async () => { |
| 154 | + const rows = await driver.aggregate(TABLE, queryFor(c) as any); |
| 155 | + expect(actualFor(c, rows as any[]), c.note ?? c.name).toEqual(expectedFor(c)); |
| 156 | + }); |
| 157 | + } |
| 158 | + |
| 159 | + /** |
| 160 | + * The #4157 shape, asserted as a property rather than per case: an aggregate |
| 161 | + * the Query Protocol declares must never resolve with `null`. That is what |
| 162 | + * `default: return null` produced here — a wrong ANSWER rather than a wrong |
| 163 | + * number, and the one failure mode a value comparison per case could be |
| 164 | + * "passed" by if a future case-set row ever expected zero. |
| 165 | + */ |
| 166 | + it('never answers null for a declared aggregate function', async () => { |
| 167 | + for (const c of AGGREGATION_CASES) { |
| 168 | + const rows = await driver.find(TABLE, queryFor(c) as any); |
| 169 | + for (const row of rows as any[]) { |
| 170 | + expect(row.n, `${c.name} — a declared function resolving null is the #6814 defect`).not.toBeNull(); |
| 171 | + expect(typeof row.n, c.name).toBe('number'); |
| 172 | + } |
| 173 | + } |
| 174 | + }); |
| 175 | +}); |
| 176 | + |
| 177 | +/** |
| 178 | + * [#5374] The ANALYTICS face answers the same function the same way. |
| 179 | + * |
| 180 | + * This package's recurring defect is not "a face is wrong", it is "the faces |
| 181 | + * disagree" — and `count_distinct` was exactly that. #6814 read this face as |
| 182 | + * the one that "DOES implement `count_distinct`", which executing it corrects: |
| 183 | + * `buildAggregator` emitted `{ $addToSet }` under a comment reading "Will need |
| 184 | + * post-processing for count", and no post-processing existed. So the measure |
| 185 | + * answered the raw ARRAY — `['won','lost',null]` — under a field |
| 186 | + * `measureTypeToFieldType` describes as `number`. |
| 187 | + * |
| 188 | + * One declared function, three answers: `null` on the data face, an array here, |
| 189 | + * and the standard's number nowhere. Aligning the data face alone would have |
| 190 | + * left this one free to keep its own. |
| 191 | + */ |
| 192 | +describe('[#6814] the analytics face answers count_distinct the same number', () => { |
| 193 | + const cube: Cube = { |
| 194 | + name: 'agg', |
| 195 | + title: 'Agg', |
| 196 | + sql: TABLE, |
| 197 | + measures: { |
| 198 | + distinctStage: { name: 'distinct_stage', label: 'Distinct stage', type: 'count_distinct', sql: 'stage' }, |
| 199 | + distinctScore: { name: 'distinct_score', label: 'Distinct score', type: 'count_distinct', sql: 'score' }, |
| 200 | + }, |
| 201 | + dimensions: { |
| 202 | + region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, |
| 203 | + }, |
| 204 | + } as unknown as Cube; |
| 205 | + |
| 206 | + let service: MemoryAnalyticsService; |
| 207 | + |
| 208 | + beforeEach(async () => { |
| 209 | + const driver = await seed(); |
| 210 | + service = new MemoryAnalyticsService({ driver, cubes: [cube] }); |
| 211 | + }); |
| 212 | + |
| 213 | + /** The ungrouped pair, against the same numbers `AGGREGATION_CASES` states. */ |
| 214 | + it('count_distinct(stage) is 2 — distinct NON-NULL values, not 3', async () => { |
| 215 | + const result = await service.query({ cube: 'agg', measures: ['agg.distinctStage'] } as any); |
| 216 | + expect(result.rows[0]['agg.distinctStage']).toBe(2); |
| 217 | + }); |
| 218 | + |
| 219 | + it('count_distinct(score) is 6 — the all-distinct control', async () => { |
| 220 | + const result = await service.query({ cube: 'agg', measures: ['agg.distinctScore'] } as any); |
| 221 | + expect(result.rows[0]['agg.distinctScore']).toBe(6); |
| 222 | + }); |
| 223 | + |
| 224 | + /** |
| 225 | + * Grouped, because a face computing the aggregate over the whole table and |
| 226 | + * repeating it per group answers 2/2 and the ungrouped case above cannot see |
| 227 | + * it — the same argument `AGGREGATION_CASES`' grouped row is built on. |
| 228 | + */ |
| 229 | + it('count_distinct(stage) grouped by region is east 1 / west 2', async () => { |
| 230 | + const result = await service.query({ |
| 231 | + cube: 'agg', |
| 232 | + measures: ['agg.distinctStage'], |
| 233 | + dimensions: ['agg.region'], |
| 234 | + } as any); |
| 235 | + const byRegion = Object.fromEntries( |
| 236 | + result.rows.map((r: any) => [r['agg.region'], r['agg.distinctStage']]), |
| 237 | + ); |
| 238 | + expect(byRegion).toEqual({ east: 1, west: 2 }); |
| 239 | + }); |
| 240 | + |
| 241 | + /** |
| 242 | + * The declared TYPE is `number` (`measureTypeToFieldType`), so the value has |
| 243 | + * to be one. An `$addToSet` handed back unsized is an ARRAY under a field the |
| 244 | + * response describes as numeric — a shape divergence a value comparison alone |
| 245 | + * would report as an ordinary wrong number. |
| 246 | + */ |
| 247 | + it('answers a NUMBER, matching the field type the response declares', async () => { |
| 248 | + const result = await service.query({ cube: 'agg', measures: ['agg.distinctStage'] } as any); |
| 249 | + expect(result.fields.find((f: any) => f.name === 'agg.distinctStage')?.type).toBe('number'); |
| 250 | + expect(typeof result.rows[0]['agg.distinctStage']).toBe('number'); |
| 251 | + }); |
| 252 | +}); |
0 commit comments