|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A `get_record` node answers `output: { record, id, object }` on its |
| 5 | + * single-row (`findOne`) branch, and a later node reads that `id` as |
| 6 | + * `{<node>.id}`. The `id` must be the row's id whatever the author's `fields` |
| 7 | + * projection names. |
| 8 | + * |
| 9 | + * The SQL drivers return exactly the columns a projection names. The node used |
| 10 | + * to hand the author's `fields` to the engine unchanged, so a projection |
| 11 | + * without `id` read the row with no `id` column: `output.id` was `undefined`, |
| 12 | + * and a downstream `update_record` filtering on `{read.id}` was refused for an |
| 13 | + * empty template. The node now names `id` in the projection it hands the |
| 14 | + * engine on that branch, and only there: |
| 15 | + * - a projection that already names `id` is handed over as written (no |
| 16 | + * second `id`); |
| 17 | + * - no projection, or an empty one, stays a whole-row read (no projection is |
| 18 | + * invented); |
| 19 | + * - the `find` branch (`limit > 1`) declares no top-level `id` and is not |
| 20 | + * touched. |
| 21 | + * |
| 22 | + * `record` carries the `id` the engine returned, so `{rec.id}` through the |
| 23 | + * node's `outputVariable` agrees with `{read.id}`; the row's other unnamed |
| 24 | + * columns stay out. |
| 25 | + * |
| 26 | + * Composition: the real stack the other `*.integration.test.ts` files in this |
| 27 | + * package boot: `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on |
| 28 | + * better-sqlite3 `:memory:`, and the real `AutomationServicePlugin` for the |
| 29 | + * flow-level case. The node-level cases run the registered `get_record` |
| 30 | + * executor itself over the same engine, behind a recorder that keeps the |
| 31 | + * projection each read handed it. |
| 32 | + */ |
| 33 | + |
| 34 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 35 | +import { ObjectKernel } from '@objectstack/core'; |
| 36 | +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; |
| 37 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 38 | +import { AutomationServicePlugin } from '../plugin.js'; |
| 39 | +import { AutomationEngine } from '../engine.js'; |
| 40 | +import { registerCrudNodes } from './crud-nodes.js'; |
| 41 | + |
| 42 | +const SYS = { isSystem: true } as const; |
| 43 | + |
| 44 | +const OBJECT = { |
| 45 | + name: 'pin_get_record_id', |
| 46 | + label: 'Pin get_record id', |
| 47 | + fields: { |
| 48 | + title: { name: 'title', label: 'Title', type: 'text' }, |
| 49 | + status: { name: 'status', label: 'Status', type: 'text' }, |
| 50 | + }, |
| 51 | +}; |
| 52 | + |
| 53 | +function makeLogger(): any { |
| 54 | + const l: any = { info() {}, warn() {}, error() {}, debug() {}, trace() {}, fatal() {} }; |
| 55 | + l.child = () => l; |
| 56 | + return l; |
| 57 | +} |
| 58 | + |
| 59 | +type Executor = { execute: (node: any, variables: Map<string, unknown>, context: any) => Promise<any> }; |
| 60 | + |
| 61 | +describe('get_record answers the row id as output.id whatever its fields projection names', () => { |
| 62 | + let kernel: ObjectKernel; |
| 63 | + let ql: ObjectQL; |
| 64 | + let automation: AutomationEngine; |
| 65 | + let rowId: string; |
| 66 | + /** The projection each engine read was handed, in order. */ |
| 67 | + const handed: Array<{ method: string; fields: unknown }> = []; |
| 68 | + let getRecord: Executor; |
| 69 | + |
| 70 | + beforeAll(async () => { |
| 71 | + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); |
| 72 | + await kernel.use(new ObjectQLPlugin()); |
| 73 | + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); |
| 74 | + await kernel.bootstrap(); |
| 75 | + ql = kernel.getService<ObjectQL>('objectql'); |
| 76 | + automation = kernel.getService<AutomationEngine>('automation'); |
| 77 | + |
| 78 | + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); |
| 79 | + await driver.connect(); |
| 80 | + ql.registerDriver(driver, true); |
| 81 | + ql.registry.registerObject(OBJECT as any, 'pin-get-record-id', 'pin-get-record-id'); |
| 82 | + await ql.syncSchemas(); |
| 83 | + |
| 84 | + const row: any = await ql.insert(OBJECT.name, { title: 'alpha', status: 'open' }, { context: SYS }); |
| 85 | + rowId = row.id; |
| 86 | + expect(typeof rowId, 'control: the engine assigned the row an id').toBe('string'); |
| 87 | + |
| 88 | + // The executor itself, over the same engine: a recorder in front of the |
| 89 | + // engine keeps the projection each read was handed. |
| 90 | + const recorder = new Proxy(ql as any, { |
| 91 | + get(target, key) { |
| 92 | + const value = Reflect.get(target, key, target); |
| 93 | + if (key === 'findOne' || key === 'find') { |
| 94 | + return (object: string, query: any) => { |
| 95 | + handed.push({ method: key, fields: Array.isArray(query?.fields) ? [...query.fields] : query?.fields }); |
| 96 | + return value.call(target, object, query); |
| 97 | + }; |
| 98 | + } |
| 99 | + return typeof value === 'function' ? value.bind(target) : value; |
| 100 | + }, |
| 101 | + }); |
| 102 | + const executors = new Map<string, Executor>(); |
| 103 | + const capture = new Proxy(new AutomationEngine(makeLogger()), { |
| 104 | + get(target, key, receiver) { |
| 105 | + if (key === 'registerNodeExecutor') return (e: any) => { executors.set(e.type, e); }; |
| 106 | + return Reflect.get(target, key, receiver); |
| 107 | + }, |
| 108 | + }); |
| 109 | + registerCrudNodes(capture as AutomationEngine, { logger: makeLogger(), getService: (n: string) => (n === 'data' ? recorder : undefined) } as any); |
| 110 | + getRecord = executors.get('get_record')!; |
| 111 | + expect(getRecord, 'control: the get_record executor was registered').toBeDefined(); |
| 112 | + }, 60_000); |
| 113 | + |
| 114 | + afterAll(async () => { |
| 115 | + try { await kernel?.shutdown(); } catch { /* best-effort teardown */ } |
| 116 | + }); |
| 117 | + |
| 118 | + /** Run the node once and return its result and the projection the engine was handed. */ |
| 119 | + async function readOne(config: Record<string, unknown>): Promise<{ result: any; fields: unknown }> { |
| 120 | + handed.length = 0; |
| 121 | + const result = await getRecord.execute( |
| 122 | + { id: 'read', type: 'get_record', config: { objectName: OBJECT.name, filter: { title: 'alpha' }, ...config } }, |
| 123 | + new Map(), |
| 124 | + { runAs: 'system', flowName: 'pin_get_record_id' }, |
| 125 | + ); |
| 126 | + expect(result.success, `the read failed: ${JSON.stringify(result)}`).toBe(true); |
| 127 | + expect(handed, 'the node reads through findOne exactly once').toHaveLength(1); |
| 128 | + expect(handed[0].method).toBe('findOne'); |
| 129 | + return { result, fields: handed[0].fields }; |
| 130 | + } |
| 131 | + |
| 132 | + it('control: the SQL driver returns exactly the columns a projection names, so a projection without id reads no id', async () => { |
| 133 | + const direct: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, fields: ['title'], context: SYS }); |
| 134 | + expect(direct).toEqual({ title: 'alpha' }); |
| 135 | + }); |
| 136 | + |
| 137 | + it('fields that omit id: output.id is the row id, and record carries it beside the named columns', async () => { |
| 138 | + const { result, fields } = await readOne({ fields: ['title'] }); |
| 139 | + expect(result.output.id).toBe(rowId); |
| 140 | + expect(result.output.record).toEqual({ id: rowId, title: 'alpha' }); |
| 141 | + expect(fields).toEqual(['title', 'id']); |
| 142 | + }); |
| 143 | + |
| 144 | + it('fields that name id: handed to the engine as written, with id once', async () => { |
| 145 | + const { result, fields } = await readOne({ fields: ['id', 'title'] }); |
| 146 | + expect(fields).toEqual(['id', 'title']); |
| 147 | + expect(result.output.id).toBe(rowId); |
| 148 | + expect(result.output.record).toEqual({ id: rowId, title: 'alpha' }); |
| 149 | + }); |
| 150 | + |
| 151 | + for (const [label, config] of [['no fields', {}], ['empty fields', { fields: [] }]] as const) { |
| 152 | + it(`control, ${label}: the whole row is read and the output is the engine's own answer`, async () => { |
| 153 | + const whole: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, context: SYS }); |
| 154 | + expect(whole.status, 'control: a whole-row read carries the unnamed columns').toBe('open'); |
| 155 | + const { result, fields } = await readOne(config); |
| 156 | + expect(fields, 'no projection is invented').toEqual((config as { fields?: unknown }).fields); |
| 157 | + expect(result.output).toEqual({ record: whole, id: rowId, object: OBJECT.name }); |
| 158 | + }); |
| 159 | + } |
| 160 | + |
| 161 | + it('a downstream update_record filtering on {read.id} updates the row the narrowed get_record read', async () => { |
| 162 | + await ql.insert(OBJECT.name, { title: 'beta', status: 'open' }, { context: SYS }); |
| 163 | + const def = { |
| 164 | + name: 'pin_get_record_id_flow', label: 'pin_get_record_id_flow', type: 'autolaunched', runAs: 'system', |
| 165 | + variables: [{ name: 'rec', type: 'object', isOutput: true }], |
| 166 | + nodes: [ |
| 167 | + { id: 'start', type: 'start', label: 'Start' }, |
| 168 | + { id: 'read', type: 'get_record', label: 'Read', config: { objectName: OBJECT.name, filter: { title: 'beta' }, fields: ['title'], outputVariable: 'rec' } }, |
| 169 | + { id: 'close', type: 'update_record', label: 'Close', config: { objectName: OBJECT.name, filter: { id: '{read.id}' }, fields: { status: 'closed' } } }, |
| 170 | + { id: 'end', type: 'end', label: 'End' }, |
| 171 | + ], |
| 172 | + edges: [ |
| 173 | + { id: 'e1', source: 'start', target: 'read' }, |
| 174 | + { id: 'e2', source: 'read', target: 'close' }, |
| 175 | + { id: 'e3', source: 'close', target: 'end' }, |
| 176 | + ], |
| 177 | + }; |
| 178 | + automation.registerFlow(def.name, def as any); |
| 179 | + const res: any = await automation.execute(def.name, {}); |
| 180 | + expect(res.success, `run failed: ${JSON.stringify(res.error ?? res)}`).toBe(true); |
| 181 | + const beta: any = await ql.findOne(OBJECT.name, { where: { title: 'beta' }, context: SYS }); |
| 182 | + expect(beta.status).toBe('closed'); |
| 183 | + expect(res.output.rec).toEqual({ id: beta.id, title: 'beta' }); |
| 184 | + const alpha: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, context: SYS }); |
| 185 | + expect(alpha.status, 'the update reached only the row the node read').toBe('open'); |
| 186 | + }); |
| 187 | +}); |
0 commit comments