|
30 | 30 | * (its #22019 block). |
31 | 31 | */ |
32 | 32 | import { beforeEach, describe, expect, it } from 'vitest'; |
| 33 | +import { ExpressionEngine } from '@objectstack/formula'; |
33 | 34 | import { ObjectQL } from './engine.js'; |
34 | 35 |
|
35 | 36 | type Rec = Record<string, unknown>; |
@@ -204,3 +205,103 @@ describe('#22019 — a formula fault on the read path is said once per (object, |
204 | 205 | expect(other.faultLines('score')).toHaveLength(1); |
205 | 206 | }); |
206 | 207 | }); |
| 208 | + |
| 209 | +/** |
| 210 | + * An unparseable `date()` input is a fault, not a silent null. |
| 211 | + * |
| 212 | + * `date()` / `datetime()` coerce their argument with the stdlib's `toDate`, |
| 213 | + * which answers an INVALID Date for text it cannot parse, and the evaluator |
| 214 | + * reports that as `ok: true`. Predicates rely on that on purpose: a comparison |
| 215 | + * over an invalid Date stays `false`. A formula FIELD has no such use for it: |
| 216 | + * the invalid Date serializes to `null`, the same bytes an empty source gives, |
| 217 | + * and nothing was said. Pinned here: |
| 218 | + * |
| 219 | + * - the answer is still `null` — the bytes a read returns do not move; |
| 220 | + * - the field gets the one line per (object, field) any other fault gets, and |
| 221 | + * the line names `date()` and how to guard the operand; |
| 222 | + * - controls: ISO text still reads its instant with no line, an EMPTY source |
| 223 | + * logs nothing (a null operand is not a fault), and the same expression as a |
| 224 | + * predicate answers exactly as before. |
| 225 | + */ |
| 226 | +const FX_DATE = { |
| 227 | + name: 'fx_date', |
| 228 | + label: 'Date Formula Probe', |
| 229 | + fields: { |
| 230 | + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, |
| 231 | + first: { name: 'first', label: 'First', type: 'text' as const }, |
| 232 | + f_date: { name: 'f_date', label: 'F Date', type: 'formula' as const, expression: { dialect: 'cel', source: 'date(record.first)' } }, |
| 233 | + f_next: { name: 'f_next', label: 'F Next', type: 'formula' as const, expression: { dialect: 'cel', source: 'addDays(date(record.first), 1)' } }, |
| 234 | + }, |
| 235 | +}; |
| 236 | + |
| 237 | +async function makeDateRig(rows: Rec[]) { |
| 238 | + const { logger, warns } = makeRecordingLogger(); |
| 239 | + const engine = new ObjectQL({ logger }); |
| 240 | + const { driver, storeFor } = makeStubDriver(); |
| 241 | + engine.registerDriver(driver as never, true); |
| 242 | + await engine.init(); |
| 243 | + engine.registry.registerObject(FX_DATE as never, 'test'); |
| 244 | + for (const row of rows) storeFor('fx_date').set(String(row.id), row); |
| 245 | + const faultLines = (field?: string) => |
| 246 | + warns.filter((w) => w.meta?.object === 'fx_date' && (field === undefined || w.meta?.field === field)); |
| 247 | + return { engine, warns, faultLines }; |
| 248 | +} |
| 249 | + |
| 250 | +describe('an unparseable date() input in a formula field is a reported fault, and the answer stays null', () => { |
| 251 | + it('text date() cannot parse: the field reads null, and the fault is said once per field', async () => { |
| 252 | + const rig = await makeDateRig([{ id: 'r_g', first: 'grace' }, { id: 'r_h', first: 'hopper' }]); |
| 253 | + const rows = await rig.engine.find('fx_date', {} as never) as Rec[]; |
| 254 | + await rig.engine.findOne('fx_date', { where: { id: 'r_g' } } as never); |
| 255 | + |
| 256 | + const lines = rig.faultLines('f_date'); |
| 257 | + expect(lines, JSON.stringify(rig.warns)).toHaveLength(1); |
| 258 | + // The served bytes are the ones the invalid Date serialized to before; the |
| 259 | + // in-process value is the `null` every other fault reads. |
| 260 | + expect(JSON.stringify(rows[0])).toContain('"f_date":null'); |
| 261 | + expect(rows.map((r) => r.f_date)).toEqual([null, null]); |
| 262 | + expect(lines[0]!.message).toContain("formula field 'f_date' on 'fx_date'"); |
| 263 | + expect(lines[0]!.meta).toMatchObject({ object: 'fx_date', field: 'f_date', kind: 'runtime' }); |
| 264 | + expect(String(lines[0]!.meta?.error)).toContain('date()'); |
| 265 | + // A date function over the invalid Date is the same fault, on its own field. |
| 266 | + expect(rows.map((r) => r.f_next)).toEqual([null, null]); |
| 267 | + expect(rig.faultLines('f_next')).toHaveLength(1); |
| 268 | + expect(rig.faultLines()).toHaveLength(2); |
| 269 | + }); |
| 270 | + |
| 271 | + it('the write response reads null for it and says it there', async () => { |
| 272 | + const rig = await makeDateRig([]); |
| 273 | + const created = await rig.engine.insert('fx_date', { first: 'grace' }) as Rec; |
| 274 | + |
| 275 | + expect(rig.faultLines('f_date')).toHaveLength(1); |
| 276 | + expect(created.f_date).toBeNull(); |
| 277 | + }); |
| 278 | + |
| 279 | + it('control: ISO text still reads its instant, with no line', async () => { |
| 280 | + const rig = await makeDateRig([{ id: 'r_iso', first: '2026-03-04' }]); |
| 281 | + const [row] = await rig.engine.find('fx_date', {} as never) as Rec[]; |
| 282 | + |
| 283 | + expect(row!.f_date).toBeInstanceOf(Date); |
| 284 | + expect((row!.f_date as Date).toISOString()).toBe('2026-03-04T00:00:00.000Z'); |
| 285 | + expect((row!.f_next as Date).toISOString()).toBe('2026-03-05T00:00:00.000Z'); |
| 286 | + expect(rig.faultLines()).toEqual([]); |
| 287 | + }); |
| 288 | + |
| 289 | + it('control: an EMPTY source is not a fault — nothing is logged for it', async () => { |
| 290 | + const rig = await makeDateRig([{ id: 'r_null', first: null }, { id: 'r_blank', first: '' }]); |
| 291 | + const rows = await rig.engine.find('fx_date', {} as never) as Rec[]; |
| 292 | + |
| 293 | + expect(rows).toHaveLength(2); |
| 294 | + // Read in-process as an invalid Date, served as `null`: the bytes this |
| 295 | + // card does not move for an empty source either. |
| 296 | + expect(JSON.parse(JSON.stringify(rows)).map((r: Rec) => r.f_date)).toEqual([null, null]); |
| 297 | + expect(rig.faultLines()).toEqual([]); |
| 298 | + }); |
| 299 | + |
| 300 | + it('control: the same date() as a predicate answers as before — false, no fault', () => { |
| 301 | + const r = ExpressionEngine.evaluate( |
| 302 | + { dialect: 'cel', source: 'date(record.first) == today()' }, |
| 303 | + { record: { first: 'grace' } }, |
| 304 | + ); |
| 305 | + expect(r).toEqual({ ok: true, value: false }); |
| 306 | + }); |
| 307 | +}); |
0 commit comments