|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25818] Two shapes of an aggregate query that the engine's `aggregate` |
| 5 | + * answered with a plausible table under 200, now refused before any driver is |
| 6 | + * asked: |
| 7 | + * |
| 8 | + * 1. a `groupBy` entry that date-buckets (`{ field, dateGranularity }`) a field |
| 9 | + * the object does not declare a date or datetime — |
| 10 | + * {@link assertDateBucketTargetsAreTemporal}; |
| 11 | + * 2. two columns of the aggregated row under one name: an aggregation alias |
| 12 | + * used twice, an aggregation alias equal to a group column, or two group |
| 13 | + * entries projecting one column — {@link assertAggregatedColumnsAreDistinct}. |
| 14 | + * |
| 15 | + * ## What ran before this door, measured on `origin/main` `cb0a2fcf4f` |
| 16 | + * |
| 17 | + * Through `findData` (the `POST /api/v1/data/:object/query` door) over three |
| 18 | + * rows, `status` holding `open`, `open`, `won`: |
| 19 | + * |
| 20 | + * | query | answer | |
| 21 | + * |:--|:--| |
| 22 | + * | `groupBy: [{ field: 'name', dateGranularity: 'month' }]` (a `text` field) | `200 [{ name: null, n: 3 }]`: every row in one `null` bucket | |
| 23 | + * | `groupBy: [{ field: 'amount', dateGranularity: 'month' }]` (a `number` field) | `200 [{ amount: '1970-01', n: 3 }]`: the numbers read as epoch milliseconds | |
| 24 | + * | `groupBy: [{ field: 'at_time', dateGranularity: 'day' }]` (a `time` field) | `200 [{ at_time: null, n: 3 }]` | |
| 25 | + * | `aggregations: [{ count, alias: 'n' }, { sum amount, alias: 'n' }]` | `200 [{ n: 60 }]`: the count overwritten by the sum | |
| 26 | + * | `groupBy: ['status'], aggregations: [{ count, alias: 'status' }]` | `200 [{ status: 2 }, { status: 1 }]`: the group keys replaced by counts | |
| 27 | + * | `groupBy: [{ field: 'opened_on', dateGranularity: 'month' }, { field: 'opened_on', dateGranularity: 'year' }]` | `200 [{ opened_on: '2026', n: 1 }, { opened_on: '2026', n: 2 }]`: the month key overwritten by the year | |
| 28 | + * |
| 29 | + * The ingress door already refuses an unknown `groupBy` field, because an |
| 30 | + * unknown column "puts every row in ONE bucket keyed null"; these are that |
| 31 | + * hazard, and its overwrite twin, reached by a declared field. |
| 32 | + * |
| 33 | + * ## Where it stands |
| 34 | + * |
| 35 | + * At the entry of `engine.aggregate`, beside the structured-JSON group door and |
| 36 | + * the aggregate × field-type door, so it holds for every caller that reaches |
| 37 | + * the engine: the REST query door and its dispatcher twin (both through |
| 38 | + * `findData`), the analytics ObjectQL strategy, the MCP data bridge, a flow or |
| 39 | + * a hook. After those two doors, so a `json` field under a `dateGranularity` |
| 40 | + * keeps the structured-JSON refusal's words. |
| 41 | + * |
| 42 | + * ## What "a date field" is |
| 43 | + * |
| 44 | + * The declared type's class, read by `declaredFieldClasses` (having-filter.ts) |
| 45 | + * — the reading `in-memory-aggregation.ts` buckets a `date` field's calendar |
| 46 | + * day by. A `date` (`CALENDAR_DATE_TYPES`) and a `datetime` (`INSTANT_TYPES`) |
| 47 | + * pass. **Not judged:** a field the declaration cannot type — undeclared (the |
| 48 | + * ingress door answers an unknown one first; a system column the map does not |
| 49 | + * declare is bucketed as before), a `formula`, or a registry-less host. |
| 50 | + * |
| 51 | + * ## The column names |
| 52 | + * |
| 53 | + * A group column is the entry's name, or `alias ?? field` for the object form |
| 54 | + * — the name `applyInMemoryAggregation` and `driver-sql` project it under. An |
| 55 | + * aggregation column is its `alias`. Entries that name nothing are left to the |
| 56 | + * shape gates that judge them. |
| 57 | + * |
| 58 | + * `INVALID_FIELD` for the date bucket (a verdict about the named field's type, |
| 59 | + * as the structured-JSON group door gives) and `INVALID_QUERY` for a column |
| 60 | + * collision (the query names one column twice; no field is wrong). Each |
| 61 | + * refusal carries `path`, the position it judged (`groupBy.0.dateGranularity`, |
| 62 | + * `aggregations.1.alias`). |
| 63 | + * |
| 64 | + * @see https://github.com/objectstack-ai/objectstack/issues/25818 |
| 65 | + */ |
| 66 | + |
| 67 | +import { StandardErrorCode } from '@objectstack/spec/api'; |
| 68 | +import { declaredFieldClasses } from './having-filter.js'; |
| 69 | + |
| 70 | +type ShapeRefusal = Error & { |
| 71 | + code?: string; status?: number; httpStatus?: number; |
| 72 | + field?: string; fields?: string[]; object?: string; param?: string; path?: string; |
| 73 | +}; |
| 74 | + |
| 75 | +function refusal( |
| 76 | + code: 'INVALID_FIELD' | 'INVALID_QUERY', |
| 77 | + message: string, |
| 78 | + extra: Partial<ShapeRefusal>, |
| 79 | +): ShapeRefusal { |
| 80 | + const err = new Error(message) as ShapeRefusal; |
| 81 | + err.code = StandardErrorCode.enum[code]; |
| 82 | + err.status = 400; |
| 83 | + // …and `httpStatus`, the same number under ADR-0112 D5's spelling — what a |
| 84 | + // consumer holding the THROWN error reads; `status` stays for the HTTP doors. |
| 85 | + err.httpStatus = 400; |
| 86 | + Object.assign(err, extra); |
| 87 | + return err; |
| 88 | +} |
| 89 | + |
| 90 | +/** |
| 91 | + * Refuse a `groupBy` entry whose `dateGranularity` buckets a field the object |
| 92 | + * declares neither a date nor a datetime — `INVALID_FIELD` / 400, naming the |
| 93 | + * position, the field and its declared type. See the module header. |
| 94 | + */ |
| 95 | +export function assertDateBucketTargetsAreTemporal( |
| 96 | + object: string, |
| 97 | + schema: unknown, |
| 98 | + groupBy: unknown, |
| 99 | +): void { |
| 100 | + if (!Array.isArray(groupBy) || groupBy.length === 0) return; |
| 101 | + const fields = (schema as { fields?: unknown } | undefined)?.fields; |
| 102 | + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return; |
| 103 | + let classes: ReturnType<typeof declaredFieldClasses> | undefined; |
| 104 | + for (const [i, entry] of groupBy.entries()) { |
| 105 | + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue; |
| 106 | + const { field, dateGranularity } = entry as { field?: unknown; dateGranularity?: unknown }; |
| 107 | + if (dateGranularity == null || typeof field !== 'string') continue; |
| 108 | + classes ??= declaredFieldClasses(fields); |
| 109 | + const cls = classes.get(field); |
| 110 | + if (cls === undefined || cls === 'date' || cls === 'datetime') continue; |
| 111 | + const type = String((fields as Record<string, { type?: unknown }>)[field]?.type); |
| 112 | + const path = `groupBy.${i}.dateGranularity`; |
| 113 | + throw refusal( |
| 114 | + 'INVALID_FIELD', |
| 115 | + `aggregate('${object}'): ${path} buckets '${field}' by '${String(dateGranularity)}', but '${field}' ` |
| 116 | + + `is a declared ${type} field — only a date or datetime field can be date-bucketed. The query was ` |
| 117 | + + `NOT run. Group by '${field}' without 'dateGranularity', or bucket a date or datetime field. A ` |
| 118 | + + 'value that is not a date has no calendar position: every row fell into one null bucket (or, ' |
| 119 | + + 'for a number, an epoch bucket) while the answer looked served.', |
| 120 | + { field, fields: [field], object, param: 'groupBy', path }, |
| 121 | + ); |
| 122 | + } |
| 123 | +} |
| 124 | + |
| 125 | +/** The column a `groupBy` entry projects: its name, or `alias ?? field`. */ |
| 126 | +function groupColumnOf(entry: unknown): string | undefined { |
| 127 | + if (typeof entry === 'string') return entry; |
| 128 | + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined; |
| 129 | + const { alias, field } = entry as { alias?: unknown; field?: unknown }; |
| 130 | + const name = alias ?? field; |
| 131 | + return typeof name === 'string' && name.length > 0 ? name : undefined; |
| 132 | +} |
| 133 | + |
| 134 | +/** `groupBy.N` for a name, `groupBy.N.alias` / `groupBy.N.field` for the object form. */ |
| 135 | +function groupColumnPath(entry: unknown, i: number): string { |
| 136 | + if (typeof entry === 'string') return `groupBy.${i}`; |
| 137 | + return (entry as { alias?: unknown }).alias != null ? `groupBy.${i}.alias` : `groupBy.${i}.field`; |
| 138 | +} |
| 139 | + |
| 140 | +/** |
| 141 | + * Refuse a query whose aggregated row would carry two columns under one name — |
| 142 | + * `INVALID_QUERY` / 400, naming both positions. See the module header. |
| 143 | + */ |
| 144 | +export function assertAggregatedColumnsAreDistinct( |
| 145 | + object: string, |
| 146 | + groupBy: unknown, |
| 147 | + aggregations: unknown, |
| 148 | +): void { |
| 149 | + const seen = new Map<string, string>(); |
| 150 | + const claim = (column: string, path: string, param: 'groupBy' | 'aggregations', what: string) => { |
| 151 | + const first = seen.get(column); |
| 152 | + if (first === undefined) { |
| 153 | + seen.set(column, path); |
| 154 | + return; |
| 155 | + } |
| 156 | + throw refusal( |
| 157 | + 'INVALID_QUERY', |
| 158 | + `aggregate('${object}'): ${path} names the column '${column}', which ${first} already names — ` |
| 159 | + + `one row cannot carry two values under one column. The query was NOT run. ${what} A repeated ` |
| 160 | + + 'column name kept only the last value written under it: a count overwritten by a sum, or the ' |
| 161 | + + 'group keys replaced by the numbers, while the answer looked served.', |
| 162 | + { object, param, path, fields: [column] }, |
| 163 | + ); |
| 164 | + }; |
| 165 | + for (const [i, entry] of (Array.isArray(groupBy) ? groupBy : []).entries()) { |
| 166 | + const column = groupColumnOf(entry); |
| 167 | + if (column === undefined) continue; |
| 168 | + claim(column, groupColumnPath(entry, i), 'groupBy', |
| 169 | + "Give one of the group entries its own 'alias'."); |
| 170 | + } |
| 171 | + for (const [i, entry] of (Array.isArray(aggregations) ? aggregations : []).entries()) { |
| 172 | + const alias = (entry as { alias?: unknown } | null)?.alias; |
| 173 | + if (typeof alias !== 'string' || alias.length === 0) continue; |
| 174 | + claim(alias, `aggregations.${i}.alias`, 'aggregations', |
| 175 | + "Give each aggregation an 'alias' of its own, distinct from every group column."); |
| 176 | + } |
| 177 | +} |
0 commit comments