@voxgig/sdkgen 3.7.2 → 3.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -242,8 +242,15 @@ function seedRecord(e: any, idx: number): Record<string, any> {
242
242
  else if (e.parents.includes(f.name)) {
243
243
  // A nested entity's parent id must match a record the parent seeds, or
244
244
  // the offline store answers nothing and every nested test reads as a
245
- // false pass.
246
- out[f.name] = `${f.parentEntity}0`
245
+ // false pass. Reuses parentSeed's fallback rather than f.parentEntity
246
+ // directly: when no entity in the model shares this key's name (the
247
+ // common case for a scoping param like `user_id` with no `user`
248
+ // entity, or a same-named response field that means something else
249
+ // entirely, like GitHub's `owner`), f.parentEntity is '' and seeding
250
+ // '0' desynced the record from every query built against the SAME
251
+ // key via parentSeed (parentPairs, crudTest, ...) — 0 results, or a
252
+ // seeded field asserted against the wrong literal.
253
+ out[f.name] = parentSeed(e, f.name)
247
254
  }
248
255
  else if ('number' === f.kind) {
249
256
  out[f.name] = 100 * (idx + 1)
@@ -407,11 +414,20 @@ describe('${provider.fileBase}', () => {
407
414
 
408
415
  `)
409
416
 
410
- if (subject.cmds.includes('list')) {
411
- Content(`
412
- it('${subject.name}-list', async () => {
417
+ // Every flat entity (no parent keys), not just one "subject" — a
418
+ // provider with two or more flat siblings used to leave every one
419
+ // but the busiest untested beyond the accessor check above. A bare
420
+ // `list$()`/`load$(id)` call has no way to carry a parent key, so
421
+ // entities that need one are covered by the `nested` block below
422
+ // instead, with their keys filled in.
423
+ const flat = provider.entities.filter((e: any) => 0 === e.parents.length)
424
+
425
+ each(flat, (e: any) => {
426
+ if (e.cmds.includes('list')) {
427
+ Content(`
428
+ it('${e.name}-list', async () => {
413
429
  const seneca = await makeSeneca()
414
- const list = await seneca.entity('provider/${provider.lower}/${subject.name}').list$()
430
+ const list = await seneca.entity('provider/${provider.lower}/${e.name}').list$()
415
431
 
416
432
  assert.equal(list.length, 2)
417
433
 
@@ -420,25 +436,25 @@ describe('${provider.fileBase}', () => {
420
436
  // survive into the Seneca entity.
421
437
  assert.equal(
422
438
  list[0].canon$({ string: true }),
423
- 'provider/${provider.lower}/${subject.name}',
439
+ 'provider/${provider.lower}/${e.name}',
424
440
  )
425
441
  })
426
442
 
427
443
  `)
428
- }
444
+ }
429
445
 
430
- if (subject.cmds.includes('load')) {
431
- Content(`
432
- it('${subject.name}-load', async () => {
446
+ if (e.cmds.includes('load')) {
447
+ Content(`
448
+ it('${e.name}-load', async () => {
433
449
  const seneca = await makeSeneca()
434
450
  const found = await seneca
435
- .entity('provider/${provider.lower}/${subject.name}')
436
- .load$('${subject.name}0')
451
+ .entity('provider/${provider.lower}/${e.name}')
452
+ .load$('${e.name}0')
437
453
 
438
- assert.equal(found.${subject.idf || 'id'}, '${subject.name}0')
454
+ assert.equal(found.${e.idf || 'id'}, '${e.name}0')
439
455
  assert.equal(
440
456
  found.canon$({ string: true }),
441
- 'provider/${provider.lower}/${subject.name}',
457
+ 'provider/${provider.lower}/${e.name}',
442
458
  )
443
459
  })
444
460
 
@@ -446,17 +462,18 @@ describe('${provider.fileBase}', () => {
446
462
  // A 404 from a single-item read is an ordinary "not found" answer, not a
447
463
  // failure: the provider turns it into null rather than letting the SDK
448
464
  // throw.
449
- it('${subject.name}-load-missing', async () => {
465
+ it('${e.name}-load-missing', async () => {
450
466
  const seneca = await makeSeneca()
451
467
  const missing = await seneca
452
- .entity('provider/${provider.lower}/${subject.name}')
453
- .load$('nosuch${subject.name}')
468
+ .entity('provider/${provider.lower}/${e.name}')
469
+ .load$('nosuch${e.name}')
454
470
 
455
471
  assert.equal(missing, null)
456
472
  })
457
473
 
458
474
  `)
459
- }
475
+ }
476
+ })
460
477
 
461
478
  // A nested entity cannot build its path without the parent id. That is
462
479
  // the mistake this target exists to make impossible, so pin it.
@@ -469,17 +486,31 @@ describe('${provider.fileBase}', () => {
469
486
  const pairs = e.parents
470
487
  .map((k: string) => `${k}: '${parentSeed(e, k)}'`).join(', ')
471
488
 
472
- Content(`
489
+ // The guard is PER OP (Main's opParents), not a blanket property of
490
+ // the entity, so the op this test calls has to be one that actually
491
+ // requires `key` — hardcoding `list` assumed every nested entity's
492
+ // list is parent-scoped, which fails for e.g. an entity guarded on
493
+ // load/update/remove but whose list is unscoped (GitHub's `repo`:
494
+ // owner guards load, not list).
495
+ const guardOp = ['list', 'load', 'update', 'remove']
496
+ .find((op: string) => (e.opParents[op] || []).includes(key))
497
+
498
+ if (null != guardOp) {
499
+ const call = 'list' === guardOp ?
500
+ `${guardOp}$({})` : `${guardOp}$({ id: '${e.name}0' })`
501
+
502
+ Content(`
473
503
  it('${e.name}-needs-${key}', async () => {
474
504
  const seneca = await makeSeneca()
475
505
 
476
506
  await assert.rejects(
477
- () => seneca.entity('provider/${provider.lower}/${e.name}').list$({}),
507
+ () => seneca.entity('provider/${provider.lower}/${e.name}').${call},
478
508
  /${key} is required/,
479
509
  )
480
510
  })
481
511
 
482
512
  `)
513
+ }
483
514
  if (e.cmds.includes('list')) {
484
515
  // Assert on the SEEDED RECORDS, not merely that an array came back.
485
516
  // `Array.isArray` is true of the empty array, so the nested-list
@@ -4024,4 +4055,6 @@ export {
4024
4055
  Workflow,
4025
4056
  Readme,
4026
4057
  Docs,
4058
+ seedRecord,
4059
+ parentSeed,
4027
4060
  }
@@ -2,7 +2,7 @@ import {
2
2
  cmp, each,
3
3
  File, Content, Copy, Folder,
4
4
  entityCollection, entityOps, entityIdField, entityClassName,
5
- opRequestShape, opParams, entityPath,
5
+ opRequestShape, opParams, ownPoint, entityPath,
6
6
  collectDeps, repoInfo, packageName, packageVersion, apiName, envName,
7
7
  authorInfo, contributorList, isAuthActive, jsKey, jsProp,
8
8
  SdkGenError,
@@ -74,12 +74,19 @@ function requiredKeys(ent: any, opname: string): string[] {
74
74
  // `<entity>_id` path param to `id` besides, which is why most APIs never reach
75
75
  // the fallback.
76
76
  //
77
- // When it does NOT answer, the record key is the LAST required path param of a
78
- // single-item op: a route addresses parents first and the record last, by
79
- // construction. Without this the key was simply unknown, so a param named
80
- // `code` failed the `!== idf` test in opParentKeys and was classified as a
81
- // PARENT — `load$('SAVE20')` threw "coupon load: code is required" instead of
82
- // loading anything, and the entity was treated as nested throughout.
77
+ // When it does NOT answer, the record key is the LAST path param of the
78
+ // op's own point, in PATH order — a route addresses parents first and the
79
+ // record last, by construction. Without this the key was simply unknown,
80
+ // so a param named `code` failed the `!== idf` test in opParentKeys and
81
+ // was classified as a PARENT — `load$('SAVE20')` threw "coupon load: code
82
+ // is required" instead of loading anything, and the entity was treated as
83
+ // nested throughout.
84
+ //
85
+ // opParams(op) is NOT the source here: it alphabetizes params for output
86
+ // stability, which loses path order on a 3+-param route — Airtable's
87
+ // record (base_id, table_id, record_id) alphabetizes with table_id last,
88
+ // so the old `params[params.length - 1]` picked the wrong parent as the
89
+ // record's own key. The point's own `parts` still has the true order.
83
90
  function recordKey(ent: any): string {
84
91
  const idf = entityIdField(ent)
85
92
  if (null != idf && '' !== idf) {
@@ -88,13 +95,18 @@ function recordKey(ent: any): string {
88
95
 
89
96
  for (const opname of ['load', 'remove', 'update']) {
90
97
  const op = (ent.op || {})[opname]
91
- if (null == op) {
98
+ if (null == op || 0 === (op.points || []).length) {
92
99
  continue
93
100
  }
94
101
 
95
- const params = opParams(op).filter((p: any) => false !== p.reqd)
96
- if (0 < params.length) {
97
- return String((params[params.length - 1] as any).name)
102
+ const canonical = op.points.filter((pt: any) =>
103
+ null == (pt && pt.select && pt.select['$action']))
104
+ const point = ownPoint(0 < canonical.length ? canonical : op.points)
105
+ const parts: string[] = (point && point.parts) || []
106
+ const lastParam = [...parts].reverse().find((p: string) => p.startsWith('{'))
107
+
108
+ if (null != lastParam) {
109
+ return lastParam.slice(1, -1)
98
110
  }
99
111
  }
100
112
 
@@ -966,4 +978,5 @@ if ('undefined' !== typeof module) {
966
978
 
967
979
  export {
968
980
  Main,
981
+ recordKey,
969
982
  }
@@ -153,6 +153,92 @@ function unwrapListData(data: any): any[] | null {
153
153
  })
154
154
 
155
155
 
156
+ // GraphQL-backed op: a REST-shaped direct() call (GET, params in the URL)
157
+ // cannot reach it — every op, including list, synthesizes POST with the
158
+ // query/variables as a JSON body, not URL params (see
159
+ // MakeFetchDefUtility: spec.body only ever comes from an explicit `body`
160
+ // field, never derived from `params`). apidef already built a real, valid
161
+ // query or mutation document per point (point.graphql.doc, with variables
162
+ // declared to match), so reuse that verbatim through the SDK's own
163
+ // graphql() escape hatch instead of re-deriving a REST-shaped call that
164
+ // cannot represent one.
165
+ function generateDirectGraphql(
166
+ opname: 'load' | 'list',
167
+ entity: ModelEntity,
168
+ point: any,
169
+ strict: boolean,
170
+ ) {
171
+ const doc: string = point.graphql.doc
172
+ const vars: any[] = point.graphql.vars || []
173
+
174
+ const varLine = (target: string, key: string, v: any) =>
175
+ ` ${target}[${JSON.stringify(v.name)}] = ${key}`
176
+
177
+ const mockVarLines = vars.map((v: any, i: number) =>
178
+ varLine('variables', `'direct0${i + 1}'`, v)).join('\n')
179
+
180
+ const liveVarLines = vars.map((v: any) => {
181
+ const from = v.from || v.name
182
+ const key = ('id' === from ? entity.name : from.replace(/_id$/, '')) + '01'
183
+ return varLine('variables', `setup.idmap['${key}']`, v)
184
+ }).join('\n')
185
+
186
+ const liveIdKeys = vars.map((v: any) => {
187
+ const from = v.from || v.name
188
+ return ('id' === from ? entity.name : from.replace(/_id$/, '')) + '01'
189
+ })
190
+
191
+ const skipMissingLine = 0 < liveIdKeys.length
192
+ ? ` if (skipIfMissingIds(t, setup, ${JSON.stringify(liveIdKeys)})) return\n`
193
+ : ''
194
+
195
+ // Asserted against the OUTGOING request body (what we sent), not the
196
+ // mocked response — response-shape correctness is the entity-level
197
+ // load/list tests' job; direct/graphql only has to prove the raw path
198
+ // reaches the endpoint with the right method and payload.
199
+ const varAsserts = vars.map((_v: any, i: number) =>
200
+ ' assert(calls[0].init.body.includes(\'direct0' + (i + 1) + '\'))\n').join('')
201
+
202
+ const offlineChecks = ` assert(result.ok === true)
203
+ assert(result.status === 200)
204
+ assert(null != result.data)
205
+ assert(calls.length === 1)
206
+ assert(calls[0].init.method === 'POST')
207
+ ${varAsserts}`
208
+
209
+ const checks = strict ?
210
+ offlineChecks.replace(/^ {6}/gm, ' ').replace(/^ {4}$/gm, '') :
211
+ ` if (setup.live) {
212
+ // Live mode is lenient: synthetic ids frequently fail server-side
213
+ // validation. Skip rather than fail when the call doesn't come back
214
+ // clean.
215
+ if (!result.ok || result.status < 200 || result.status >= 300) {
216
+ return
217
+ }
218
+ } else {
219
+ ${offlineChecks} }`
220
+
221
+ Content(`
222
+ test('direct-${opname}-${entity.name}', async (t: any) => {
223
+ const setup = directSetup()
224
+ if (maybeSkipControl(t, 'direct', 'direct-${opname}-${entity.name}', setup.live)) return
225
+ ${skipMissingLine} const { client, calls } = setup
226
+
227
+ const variables: any = {}
228
+ if (setup.live) {
229
+ ${liveVarLines || ' // no variables'}
230
+ } else {
231
+ ${mockVarLines || ' // no variables'}
232
+ }
233
+
234
+ const result: any = await client.graphql(${JSON.stringify(doc)}, variables)
235
+
236
+ ${checks}
237
+ })
238
+ `)
239
+ }
240
+
241
+
156
242
  function generateDirectLoad(model: Model, entity: ModelEntity, strict: boolean) {
157
243
  const loadOp = entity.op?.load
158
244
  const loadPoint: ModelPoint | undefined = loadOp?.points?.[0]
@@ -161,6 +247,11 @@ function generateDirectLoad(model: Model, entity: ModelEntity, strict: boolean)
161
247
  return
162
248
  }
163
249
 
250
+ if ('graphql' === (loadPoint as any).kind) {
251
+ generateDirectGraphql('load', entity, loadPoint, strict)
252
+ return
253
+ }
254
+
164
255
  const allLoadParams = loadPoint.args?.params || []
165
256
  const loadPath = normalizePathParams(loadPoint.parts || [], allLoadParams, loadPoint.rename?.param)
166
257
 
@@ -377,6 +468,11 @@ function generateDirectList(model: Model, entity: ModelEntity, strict: boolean)
377
468
  return
378
469
  }
379
470
 
471
+ if ('graphql' === (listPoint as any).kind) {
472
+ generateDirectGraphql('list', entity, listPoint, strict)
473
+ return
474
+ }
475
+
380
476
  const listParams = listPoint.args?.params || []
381
477
  const listPath = normalizePathParams(listPoint.parts || [], listParams, listPoint.rename?.param)
382
478
 
@@ -8,6 +8,35 @@ import { BaseFeature } from '../base/BaseFeature'
8
8
  const S_NOT_FOUND = 'Not found'
9
9
 
10
10
 
11
+ // Which param is entity X's own identifier, as opposed to a parent key —
12
+ // the load op's canonical point's LAST path segment, by construction (a
13
+ // route addresses parents first, the record last). Mirrors recordKey in
14
+ // sdkgen's Main_seneca-provider.ts; written again here because a template
15
+ // ships standalone, outside that package. A renamed id (e.g. Airtable's
16
+ // record_id) needs its own seeded field: matching only ever happens
17
+ // against the API's real param names, never a bare 'id' the API itself
18
+ // does not use.
19
+ function ownIdField(config: any, getpath: any, entityName: string): string {
20
+ for (const opname of ['load', 'remove', 'update']) {
21
+ const points = getpath(config, ['entity', entityName, 'op', opname, 'points']) || []
22
+ const canonical = points.filter((pt: any) =>
23
+ null == (pt && pt.select && pt.select['$action']))
24
+ const use = 0 < canonical.length ? canonical : points
25
+ let best = use[0]
26
+ for (const pt of use) {
27
+ if (null == pt || null == pt.parts || null == best || null == best.parts) continue
28
+ const ptterm = 0 < pt.parts.length && String(pt.parts[pt.parts.length - 1]).startsWith('{')
29
+ const bestterm = 0 < best.parts.length && String(best.parts[best.parts.length - 1]).startsWith('{')
30
+ if (ptterm !== bestterm ? ptterm : pt.parts.length < best.parts.length) best = pt
31
+ }
32
+ const parts: string[] = (best && best.parts) || []
33
+ const last = [...parts].reverse().find((p: string) => p.startsWith('{'))
34
+ if (null != last) return last.slice(1, -1)
35
+ }
36
+ return 'id'
37
+ }
38
+
39
+
11
40
  class TestFeature extends BaseFeature {
12
41
  version = '0.0.1'
13
42
  name = 'test'
@@ -30,10 +59,16 @@ class TestFeature extends BaseFeature {
30
59
 
31
60
  this._client._mode = 'test'
32
61
 
62
+ const getpath = struct.getpath
63
+
33
64
  // Ensure entity ids are correct.
34
65
  walk(entity, (k: any, v: any, _parent: any, path: any) => {
35
66
  if (2 === size(path)) {
36
67
  setprop(v, 'id', k)
68
+ const idField = ownIdField(ctx.config, getpath, String(path[0]))
69
+ if ('id' !== idField) {
70
+ setprop(v, idField, k)
71
+ }
37
72
  }
38
73
  return v
39
74
  })
@@ -172,6 +207,16 @@ class TestFeature extends BaseFeature {
172
207
 
173
208
  const ent = clone(ctx.reqdata)
174
209
  setprop(ent, 'id', id)
210
+
211
+ // A record created during the run needs the same real-key seeding
212
+ // the initial walk gives seed data (see ownIdField above) — without
213
+ // it, only `id` is set, and a load by the entity's own key right
214
+ // after create (recordKey !== 'id') finds nothing.
215
+ const idField = ownIdField(ctx.config, struct.getpath, getprop(op, 'entity'))
216
+ if ('id' !== idField && null == getprop(ent, idField)) {
217
+ setprop(ent, idField, id)
218
+ }
219
+
175
220
  setprop(entmap, id, ent)
176
221
  delprop(ent, '$KEY')
177
222
  const out = clone(ent)
@@ -310,7 +355,8 @@ class TestFeature extends BaseFeature {
310
355
 
311
356
 
312
357
  export {
313
- TestFeature
358
+ TestFeature,
359
+ ownIdField,
314
360
  }
315
361
 
316
362
 
@@ -3,7 +3,7 @@
3
3
  "package": 1
4
4
  },
5
5
  "name": "@voxgig/sdkgen",
6
- "version": "3.7.2",
6
+ "version": "3.7.3",
7
7
  "provides": {
8
8
  "target": [
9
9
  "c",
@@ -680,6 +680,7 @@ export {
680
680
  entityCollection,
681
681
  opTypeName,
682
682
  opParams,
683
+ ownPoint,
683
684
  opActions,
684
685
  entityActions,
685
686
  entityPath,
package/src/sdkgen.ts CHANGED
@@ -58,7 +58,7 @@ import { getMatchEntries } from './helpers/getMatchEntries'
58
58
  import { collectDeps } from './helpers/collectDeps'
59
59
  import type { DepEntry } from './helpers/collectDeps'
60
60
  import { canonToType, canonToDtype, canonKey } from './helpers/canonType'
61
- import { OP_SUFFIX, opTypeName, opParams, opActions, entityActions, entityPath, opRequestShape, entityIdField, entityDataIdField, entityOps, entityPrimaryOp, pickExampleEntity, entityClassName, entityTypeCollisions, warnEntityTypeCollisions, deriveEntityNames, entityCollection } from './helpers/opShape'
61
+ import { OP_SUFFIX, opTypeName, opParams, ownPoint, opActions, entityActions, entityPath, opRequestShape, entityIdField, entityDataIdField, entityOps, entityPrimaryOp, pickExampleEntity, entityClassName, entityTypeCollisions, warnEntityTypeCollisions, deriveEntityNames, entityCollection } from './helpers/opShape'
62
62
  import { isReservedName, safeVarName, exampleVarName, phpEntityAccessor, entityCacheField, isRbCoreConstant, isRbSdkConstant, rbSafeTypeName, isSwiftSdkType, swiftSafeTypeName, isPhpReservedType, phpSafeTypeName, isTsReservedType, tsSafeTypeName, jsProp, jsOptProp, jsKey } from './helpers/naming'
63
63
  import { serverVariables, hasServerVariables } from './helpers/serverVars'
64
64
  import { primaryOpCall, idLiteral, matchArg, dataArg, litFor } from './helpers/opExample'
@@ -1063,6 +1063,7 @@ export {
1063
1063
  OP_SUFFIX,
1064
1064
  opTypeName,
1065
1065
  opParams,
1066
+ ownPoint,
1066
1067
  opActions,
1067
1068
  entityActions,
1068
1069
  entityPath,