@cap-js/cds-typer 0.23.0 → 0.25.0

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.
package/lib/csn.js CHANGED
@@ -1,31 +1,65 @@
1
1
  const annotation = '@odata.draft.enabled'
2
2
 
3
+ /** @typedef {import('./typedefs').resolver.CSN} CSN */
4
+ /** @typedef {import('./typedefs').resolver.EntityCSN} EntityCSN */
5
+ /** @typedef {import('./typedefs').resolver.ProjectionCSN} ProjectionCSN */
6
+ /** @typedef {import('./typedefs').resolver.ViewCSN} ViewCSN */
7
+
3
8
  /**
4
9
  * FIXME: this is pretty handwavey: we are looking for view-entities,
5
10
  * i.e. ones that have a query, but are not a cds level projection.
6
11
  * Those are still not expanded and we have to retrieve their definition
7
12
  * with all properties from the inferred model.
8
13
  * @param {any} entity - the entity
14
+ * @returns {entity is ViewCSN}
9
15
  */
10
16
  const isView = entity => entity.query && !entity.projection
11
17
 
18
+ /**
19
+ * @param {EntityCSN} entity - the entity
20
+ * @returns {entity is ProjectionCSN | ViewCSN}
21
+ */
22
+ const isViewOrProjection = entity => Object.hasOwn(entity, 'query') || Object.hasOwn(entity, 'projection')
23
+
24
+ /**
25
+ * @param {EntityCSN | ProjectionCSN} entity - the entity
26
+ * @returns {entity is ProjectionCSN}
27
+ */
12
28
  const isProjection = entity => entity.projection
13
29
 
14
30
  /**
15
- * @param {any} entity - the entity
31
+ * @param {EntityCSN} entity - the entity
16
32
  * @see isView
17
33
  * Unresolved entities have to be looked up from inferred csn.
18
34
  */
19
35
  const isUnresolved = entity => entity._unresolved === true
20
36
 
37
+ /**
38
+ * @param {EntityCSN} entity - the entity
39
+ */
21
40
  const isCsnAny = entity => entity?.constructor?.name === 'any'
22
41
 
42
+ /**
43
+ * @param {EntityCSN} entity - the entity
44
+ */
23
45
  const isDraftEnabled = entity => entity['@odata.draft.enabled'] === true
24
46
 
47
+ /**
48
+ * @param {EntityCSN} entity - the entity
49
+ */
25
50
  const isType = entity => entity?.kind === 'type'
26
51
 
52
+ /**
53
+ * @param {EntityCSN} entity - the entity
54
+ */
27
55
  const isEntity = entity => entity?.kind === 'entity'
28
56
 
57
+ /**
58
+ * @param {EntityCSN | undefined} entity - the entity
59
+ * @returns {entity is import("./typedefs").resolver.EnumCSN}
60
+ */
61
+ const isEnum = entity => Boolean(entity && Object.hasOwn(entity, 'enum'))
62
+
29
63
  /**
30
64
  * Attempts to retrieve the max cardinality of a CSN for an entity.
31
65
  * @param {EntityCSN} element - csn of entity to retrieve cardinality for
@@ -34,13 +68,24 @@ const isEntity = entity => entity?.kind === 'entity'
34
68
  * If it is set to '*', result is Infinity.
35
69
  */
36
70
  const getMaxCardinality = element => {
37
- const cardinality = element?.cardinality?.max ?? 1
71
+ const cardinality = element?.cardinality?.max ?? '1'
38
72
  return cardinality === '*' ? Infinity : parseInt(cardinality)
39
73
  }
40
74
 
41
- const getViewTarget = entity => entity.query?.SELECT?.from?.ref?.[0]
75
+ /**
76
+ * @param {EntityCSN} entity - the entity
77
+ */
78
+ const getViewTarget = entity => isView(entity)
79
+ ? entity.query?.SELECT?.from?.ref?.[0]
80
+ : undefined
42
81
 
43
- const getProjectionTarget = entity => entity.projection?.from?.ref?.[0]
82
+ /**
83
+ * @param {EntityCSN} entity - the entity
84
+ * @returns {string | undefined}
85
+ */
86
+ const getProjectionTarget = entity => isProjection(entity)
87
+ ? entity.projection?.from?.ref?.[0]
88
+ : undefined
44
89
 
45
90
  class DraftUnroller {
46
91
  /** @type {Set<string>} */
@@ -48,15 +93,18 @@ class DraftUnroller {
48
93
  /** @type {{[key: string]: boolean}} */
49
94
  #draftable = {}
50
95
  /** @type {{[key: string]: string}} */
51
- #projections
52
- /** @type {object[]} */
53
- #entities
96
+ #projections = {}
97
+ /** @type {EntityCSN[]} */
98
+ #entities = []
99
+ /** @type {CSN | undefined} */
54
100
  #csn
55
101
  set csn(c) {
56
102
  this.#csn = c
103
+ if (c === undefined) return
57
104
  this.#entities = Object.values(c.definitions)
58
105
  this.#projections = this.#entities.reduce((pjs, entity) => {
59
106
  if (isProjection(entity)) {
107
+ // @ts-ignore - we know that entity is a projection here
60
108
  pjs[entity.name] = getProjectionTarget(entity)
61
109
  }
62
110
  return pjs
@@ -65,11 +113,13 @@ class DraftUnroller {
65
113
  get csn() { return this.#csn }
66
114
 
67
115
  /**
68
- * @param {object | string} entity - entity to set draftable annotation for.
116
+ * @param {EntityCSN | string} entityOrFq - entity to set draftable annotation for.
69
117
  * @param {boolean} value - whether the entity is draftable.
70
118
  */
71
- #setDraftable(entity, value) {
72
- if (typeof entity === 'string') entity = this.#getDefinition(entity)
119
+ #setDraftable(entityOrFq, value) {
120
+ const entity = typeof entityOrFq === 'string'
121
+ ? this.#getDefinition(entityOrFq)
122
+ : entityOrFq
73
123
  if (!entity) return // inline definition -- not found in definitions
74
124
  entity[annotation] = value
75
125
  this.#draftable[entity.name] = value
@@ -81,32 +131,38 @@ class DraftUnroller {
81
131
  }
82
132
 
83
133
  /**
84
- * @param {object | string} entityOrName - entity to look draftability up for.
134
+ * @param {EntityCSN | string} entityOrFq - entity to look draftability up for.
85
135
  * @returns {boolean}
86
136
  */
87
- #getDraftable(entityOrName) {
88
- const entity = (typeof entityOrName === 'string')
89
- ? this.#getDefinition(entityOrName)
90
- : entityOrName
137
+ #getDraftable(entityOrFq) {
138
+ const entity = typeof entityOrFq === 'string'
139
+ ? this.#getDefinition(entityOrFq)
140
+ : entityOrFq
91
141
  // assert(typeof entity !== 'string')
92
- const name = entity?.name ?? entityOrName
142
+ const name = entity?.name ?? entityOrFq
143
+ // @ts-expect-error - .name not being present means entityOrFq is a string, so name is always a string and therefore a valid index
93
144
  return this.#draftable[name] ??= this.#propagateInheritance(entity)
94
145
  }
95
146
 
96
147
  /**
148
+ * FIXME: could use EntityRepository here
97
149
  * @param {string} name - name of the entity.
150
+ * @returns {EntityCSN}
98
151
  */
99
- #getDefinition(name) { return this.csn.definitions[name] }
152
+ // @ts-expect-error - poor man's #getDefinitionOrThrow. We are always sure name is a valid key
153
+ #getDefinition(name) { return this.csn?.definitions[name] }
100
154
 
101
155
  /**
102
156
  * Propagate draft annotations through inheritance (includes).
103
157
  * The latest annotation through the inheritance chain "wins".
104
158
  * Annotations on the entity itself are always queued last, so they will always be decisive over ancestors.
105
- * @param {object} entity - entity to pull draftability from its parents.
159
+ * @param {EntityCSN | undefined} entity - entity to pull draftability from its parents.
106
160
  */
107
161
  #propagateInheritance(entity) {
108
- const annotations = (entity?.includes ?? []).map(parent => this.#getDraftable(parent))
109
- annotations.push(entity?.[annotation])
162
+ if (!entity) return
163
+ /** @type {(boolean | undefined)[]} */
164
+ const annotations = (entity.includes ?? []).map(parent => this.#getDraftable(parent))
165
+ annotations.push(entity[annotation])
110
166
  this.#setDraftable(entity, annotations.filter(a => a !== undefined).at(-1) ?? false)
111
167
  }
112
168
 
@@ -114,6 +170,10 @@ class DraftUnroller {
114
170
  * Propagate draft-enablement through projections.
115
171
  */
116
172
  #propagateProjections() {
173
+ /**
174
+ * @param {string} from - entity to propagate draftability from.
175
+ * @param {string} to - entity to propagate draftability to.
176
+ */
117
177
  const propagate = (from, to) => {
118
178
  do {
119
179
  this.#setDraftable(to, this.#getDraftable(to) || this.#getDraftable(from))
@@ -131,7 +191,7 @@ class DraftUnroller {
131
191
  /**
132
192
  * If an entity E is draftable and contains any composition of entities,
133
193
  * then those entities also become draftable. Recursively.
134
- * @param {object} entity - entity to propagate all compositions from.
194
+ * @param {EntityCSN} entity - entity to propagate all compositions from.
135
195
  */
136
196
  #propagateCompositions(entity) {
137
197
  if (!this.#getDraftable(entity)) return
@@ -146,6 +206,7 @@ class DraftUnroller {
146
206
  }
147
207
  }
148
208
 
209
+ /** @param {CSN} csn - the full csn */
149
210
  unroll(csn) {
150
211
  this.csn = csn
151
212
 
@@ -241,7 +302,7 @@ function propagateForeignKeys(csn) {
241
302
  // foreign key fields from Associations/ Compositions.
242
303
  if (!Object.hasOwn(this, '__keys')) {
243
304
  const ownKeys = Object.entries(this.elements ?? {}).filter(([,el]) => el.key === true)
244
- const inheritedKeys = this.includes?.flatMap(parent => Object.entries(csn.definitions[parent].keys)) ?? []
305
+ const inheritedKeys = this.includes?.flatMap((/** @type {string} */ parent) => Object.entries(csn.definitions[parent].keys)) ?? []
245
306
  // not sure why, but .associations contains both Associations, as well as Compositions in CSN.
246
307
  // (.compositions contains only Compositions, if any)
247
308
  const remoteKeys = Object.entries(this.associations ?? {})
@@ -249,9 +310,7 @@ function propagateForeignKeys(csn) {
249
310
  .flatMap(([kname, key]) => Object.entries(csn.definitions[key.target].keys)
250
311
  .map(([ckname, ckey]) => [`${kname}_${ckname}`, ckey]))
251
312
 
252
- this.__keys = Object.fromEntries(ownKeys
253
- .concat(inheritedKeys)
254
- .concat(remoteKeys)
313
+ this.__keys = Object.fromEntries([...ownKeys, ...inheritedKeys, ...remoteKeys]
255
314
  .filter(([,ckey]) => !ckey.target) // discard keys that are Associations. Those are already part of .elements
256
315
  )
257
316
  }
@@ -270,8 +329,11 @@ function amendCSN(csn) {
270
329
  propagateForeignKeys(csn)
271
330
  }
272
331
 
273
-
332
+ /**
333
+ * @param {EntityCSN} entity - the entity
334
+ */
274
335
  const getProjectionAliases = entity => {
336
+ /** @type {Record<string, string[]>} */
275
337
  const aliases = {}
276
338
  let all = false
277
339
  for (const col of entity?.projection?.columns ?? []) {
@@ -290,8 +352,10 @@ module.exports = {
290
352
  amendCSN,
291
353
  isView,
292
354
  isProjection,
355
+ isViewOrProjection,
293
356
  isDraftEnabled,
294
357
  isEntity,
358
+ isEnum,
295
359
  isUnresolved,
296
360
  isType,
297
361
  getMaxCardinality,
package/lib/file.js CHANGED
@@ -13,6 +13,13 @@ const AUTO_GEN_NOTE = '// This is an automatically generated file. Please do not
13
13
  /** @typedef {import('./typedefs').file.Namespace} Namespace */
14
14
 
15
15
  class File {
16
+ /**
17
+ * The Path for this library file, which is constructed from its namespace.
18
+ * @type {Path}
19
+ */
20
+ // @ts-expect-error - not initialised, but will be done in subclasses (can't make File abstract in JS)
21
+ path
22
+
16
23
  /**
17
24
  * Creates one string from the buffers representing the type definitions.
18
25
  * @returns {string} complete file contents.
@@ -42,6 +49,9 @@ class Library extends File {
42
49
  return this.contents
43
50
  }
44
51
 
52
+ /**
53
+ * @param {string} file - path to the file
54
+ */
45
55
  constructor(file) {
46
56
  super()
47
57
  this.contents = readFileSync(file, 'utf-8')
@@ -102,7 +112,7 @@ class SourceFile extends File {
102
112
  super()
103
113
  /** @type {Path} */
104
114
  this.path = path instanceof Path ? path : new Path(path.split('.'))
105
- /** @type {object} */
115
+ /** @type {{[key:string]: any}} */
106
116
  this.imports = {}
107
117
  /** @type {Buffer} */
108
118
  this.preamble = new Buffer()
@@ -110,7 +120,7 @@ class SourceFile extends File {
110
120
  this.events = { buffer: new Buffer(), fqs: []}
111
121
  /** @type {Buffer} */
112
122
  this.types = new Buffer()
113
- /** @type {{ buffer: Buffer, data: {kvs: [string[]], name: string, fq: string, property?: string}[]}} */
123
+ /** @type {{ buffer: Buffer, data: {kvs: [string, string][], name: string, fq: string, property?: string}[]}} */
114
124
  this.enums = { buffer: new Buffer(), data: [] }
115
125
  /** @type {{ buffer: Buffer }} */
116
126
  this.inlineEnums = { buffer: new Buffer() }
@@ -134,8 +144,14 @@ class SourceFile extends File {
134
144
 
135
145
  /**
136
146
  * Stringifies a lambda expression.
137
- * @param {{name: string, parameters: [string, string][], returns: string, initialiser: string}} param - name, parameters, return type, and initialiser expression
138
- * @returns {string} - the stringified lambda
147
+ * @param {object} options - options
148
+ * @param {string} options.name - name of the lambda
149
+ * @param {[string, string][]} [options.parameters] - list of parameters, passed as [name, type] pairs
150
+ * @param {string} [options.returns] - the return type of the function
151
+ * @param {'action' | 'function'} options.kind - kind of the lambda
152
+ * @param {string} [options.initialiser] - the initialiser expression
153
+ * @param {boolean} [options.isStatic] - whether the lambda is static
154
+ * @returns {string} the stringified lambda
139
155
  * @example
140
156
  * ```js
141
157
  * // note: these samples are actually simplified! See below.
@@ -187,7 +203,7 @@ class SourceFile extends File {
187
203
  /**
188
204
  * Adds a function definition in form of a arrow function to the file.
189
205
  * @param {string} name - name of the function
190
- * @param {{relative: string | undefined, local: boolean, posix: boolean}} parameters - list of parameters, passed as [name, type] pairs
206
+ * @param {[string, string][]} parameters - list of parameters, passed as [name, type] pairs
191
207
  * @param {string} returns - the return type of the function
192
208
  * @param {'function' | 'action'} kind - kind of the node
193
209
  */
@@ -223,11 +239,8 @@ class SourceFile extends File {
223
239
  * @param {string} fq - fully qualified name of the enum (entity name within CSN)
224
240
  * @param {string} name - local name of the enum
225
241
  * @param {[string, string][]} kvs - list of key-value pairs
226
- * @param {string?} _property - property to which the enum is attached.
227
- * If given, the enum is considered to be an inline definition of an enum.
228
- * If not, it is considered to be regular, named enum.
229
242
  */
230
- addEnum(fq, name, kvs, _property) {
243
+ addEnum(fq, name, kvs) {
231
244
  this.enums.data.push({ name, fq, kvs })
232
245
  printEnum(this.enums.buffer, name, kvs)
233
246
  }
@@ -318,10 +331,14 @@ class SourceFile extends File {
318
331
  * @param {string} fq - fully qualified name of the enum
319
332
  * @param {string} clean - local name of the enum
320
333
  * @param {string} rhs - the right hand side of the assignment
334
+ * @param {boolean} exportValueLevel - whether to export the value level of the type (relevant to enums)
321
335
  */
322
- addType(fq, clean, rhs) {
336
+ addType(fq, clean, rhs, exportValueLevel = false) {
323
337
  this.typeNames[clean] = fq
324
338
  this.types.add(`export type ${clean} = ${rhs};`)
339
+ if (exportValueLevel) {
340
+ this.types.add(`export const ${clean} = ${rhs};`)
341
+ }
325
342
  }
326
343
 
327
344
  /**
@@ -438,6 +455,10 @@ class Buffer {
438
455
  * @type {string}
439
456
  */
440
457
  this.currentIndent = ''
458
+ /**
459
+ * @type {boolean}
460
+ */
461
+ this.closed = false
441
462
  }
442
463
 
443
464
  /**
@@ -483,7 +504,7 @@ class Buffer {
483
504
 
484
505
  /**
485
506
  * Adds an element to the buffer with one level of indent.
486
- * @param {string | (() => void)} part - either a string or a function. If it is a string, it is added to the buffer.
507
+ * @param {string | string[] | (() => void)} part - either a string or a function. If it is a string, it is added to the buffer.
487
508
  * If not, it is expected to be a function that manipulates the buffer as a side effect.
488
509
  */
489
510
  addIndented(part) {
@@ -492,14 +513,16 @@ class Buffer {
492
513
  part()
493
514
  } else if (Array.isArray(part)) {
494
515
  part.forEach(p => { this.add(p) })
516
+ } else if (typeof part === 'string') {
517
+ this.add(part)
495
518
  }
496
519
  this.outdent()
497
520
  }
498
521
 
499
522
  /**
500
- * Adds an element to a buffer with one level of indent and and opener and closer surrounding it.
523
+ * Adds an element to a buffer with one level of indent and opener and closer surrounding it.
501
524
  * @param {string} opener - the string to put before the indent
502
- * @param {string} content - the content to indent (see {@link addIndented})
525
+ * @param {string | string[] | (() => void)} content - the content to indent (see {@link addIndented})
503
526
  * @param {string} closer - the string to put after the indent
504
527
  */
505
528
  addIndentedBlock(opener, content, closer) {
@@ -518,7 +541,7 @@ class Path {
518
541
  * @param {string[]} parts - parts of the path. 'a.b.c' -> ['a', 'b', 'c']
519
542
  * @param {string} kind - FIXME: currently unused
520
543
  */
521
- constructor(parts, kind) {
544
+ constructor(parts, kind = '') {
522
545
  this.parts = parts
523
546
  this.kind = kind
524
547
  }
@@ -533,9 +556,9 @@ class Path {
533
556
  /**
534
557
  * Transfoms the Path into a directory path.
535
558
  * @param {object} params - parameters
536
- * @param {string?} params.relative - if defined, the path is constructed relative to this directory
537
- * @param {boolean} params.local - if set to true, './' is prefixed to the directory
538
- * @param {boolean} params.posix - if set to true, all slashes will be forward slashes on every OS. Useful for require/ import
559
+ * @param {string} [params.relative] - if defined, the path is constructed relative to this directory
560
+ * @param {boolean} [params.local] - if set to true, './' is prefixed to the directory
561
+ * @param {boolean} [params.posix] - if set to true, all slashes will be forward slashes on every OS. Useful for require/ import
539
562
  * @returns {string} directory 'a.b.c'.asDirectory() -> 'a/b/c' (or a\b\c when on Windows without passing posix = true)
540
563
  */
541
564
  asDirectory(params = {}) {
@@ -569,7 +592,7 @@ class Path {
569
592
  }
570
593
 
571
594
  /**
572
- * @param {string} relative - directory to which we check relatively
595
+ * @param {string} [relative] - directory to which we check relatively
573
596
  * @returns {boolean} true, iff the Path refers to the current working directory, aka './'
574
597
  */
575
598
  isCwd(relative = undefined) {
@@ -580,6 +603,7 @@ class Path {
580
603
  // TODO: having the repository pattern in place we can separate (some of) the printing logic from the visitor.
581
604
  // Most of it hinges primarily on resolving specific files. We can now pass the repository and the resolver to a printer.
582
605
  class FileRepository {
606
+ /** @type {{[key:string]: SourceFile}} */
583
607
  #files = {}
584
608
 
585
609
  /**
@@ -628,7 +652,7 @@ const writeout = async (root, sources) =>
628
652
  fs.writeFile(path.join(dir, 'index.js'), source.toJSExports()),
629
653
  ])
630
654
 
631
- } catch (err) {
655
+ } catch (/** @type {any} **/err) {
632
656
  // eslint-disable-next-line no-console
633
657
  console.error(`Could not create parent directory ${dir}: ${err}.`)
634
658
  // eslint-disable-next-line no-console
package/lib/logging.js CHANGED
@@ -1,12 +1,16 @@
1
1
  const cds = require('@sap/cds')
2
2
 
3
+ /** @param {string} value - the value */
4
+ // @ts-expect-error - yes, cds.log.levels exists...
3
5
  const _keyFor = value => Object.entries(cds.log.levels).find(([,val]) => val === value)?.[0]
4
6
 
5
7
  // workaround until retroactively setting log level to 0 is possible
8
+ // @ts-expect-error - yes, cds.log.levels exists...
6
9
  cds.log('cds-typer', _keyFor(cds.log.levels.SILENT))
7
10
  module.exports = {
8
11
  _keyFor,
9
- setLevel: level => { cds.log('cds-typer', level) },
12
+ setLevel: (/** @type {string | number} */ level) => { cds.log('cds-typer', level) },
13
+ /** @type {Record<string, string>} */
10
14
  deprecated: {
11
15
  WARNING: 'WARN',
12
16
  CRITICAL: 'ERROR',
@@ -2,6 +2,7 @@ class BuiltinResolver {
2
2
  /**
3
3
  * Builtin types defined by CDS.
4
4
  */
5
+ /** @type {Record<string, string>} */
5
6
  #builtins = {
6
7
  UUID: 'string',
7
8
  String: 'string',
@@ -32,7 +33,7 @@ class BuiltinResolver {
32
33
 
33
34
  /**
34
35
  * @param {object} options - additional resolution options
35
- * @param {boolean} options.IEEE754Compatible - if true, the Decimal, DecimalFloat, Float, and Double types are also allowed to be strings
36
+ * @param {boolean} [options.IEEE754Compatible] - if true, the Decimal, DecimalFloat, Float, and Double types are also allowed to be strings
36
37
  */
37
38
  constructor ({ IEEE754Compatible } = {}) {
38
39
  if (IEEE754Compatible) {
@@ -45,7 +46,7 @@ class BuiltinResolver {
45
46
  }
46
47
 
47
48
  /**
48
- * @param {string | string[]} t - name or parts of the type name split on dots
49
+ * @param {string | string[] | import("@sap/cds").ref} t - name or parts of the type name split on dots
49
50
  * @returns {string | undefined | false} if t refers to a builtin, the name of the corresponding TS type is returned.
50
51
  * If t _looks like_ a builtin (`cds.X`), undefined is returned.
51
52
  * If t is obviously not a builtin, false is returned.
@@ -1,3 +1,5 @@
1
+ const { isType } = require('../csn')
2
+
1
3
  class EntityInfo {
2
4
  /**
3
5
  * @example
@@ -6,7 +8,7 @@ class EntityInfo {
6
8
  * // v
7
9
  * Path(['n1', 'n2'])
8
10
  * ```
9
- * @type {Path}
11
+ * @type {import('../file').Path}
10
12
  */
11
13
  namespace
12
14
 
@@ -44,7 +46,7 @@ class EntityInfo {
44
46
  */
45
47
  propertyAccess
46
48
 
47
- /** @type {{singular: string, plural: string}} */
49
+ /** @type {{singular: string, plural: string} | undefined} */
48
50
  #inflection
49
51
 
50
52
  /** @type {import('./resolver').Resolver} */
@@ -53,10 +55,10 @@ class EntityInfo {
53
55
  /** @type {EntityRepository} */
54
56
  #repository
55
57
 
56
- /** @type {EntityInfo} */
57
- #parent
58
+ /** @type {EntityInfo | null} */
59
+ #parent = null
58
60
 
59
- /** @type {import('../typedefs').resolver.EntityCSN} */
61
+ /** @type {import('../typedefs').resolver.EntityCSN | undefined} */
60
62
  #csn
61
63
 
62
64
  get csn () {
@@ -124,7 +126,7 @@ class EntityInfo {
124
126
  }
125
127
 
126
128
  class EntityRepository {
127
- /** @type {{ [key: string]: EntityInfo }} */
129
+ /** @type {{ [key: string]: EntityInfo | null }} */
128
130
  #cache = {}
129
131
 
130
132
  /** @type {import('./resolver').Resolver} */
@@ -142,6 +144,19 @@ class EntityRepository {
142
144
  return this.#cache[fq]
143
145
  }
144
146
 
147
+ /**
148
+ * Convenience for getByFq when you are 100% sure the entity exists.
149
+ * Serves to eliminate cumbersome null-handling where you know it's not necessary.
150
+ * For example when fq is derived from a reference to another entity.
151
+ * @param {string} fq - fully qualified name of the entity
152
+ * @returns {EntityInfo}
153
+ */
154
+ getByFqOrThrow(fq) {
155
+ const entityInfo = this.getByFq(fq)
156
+ if (entityInfo === null) throw new Error(`Entity with fq "${fq}" is not part of the model`)
157
+ return entityInfo
158
+ }
159
+
145
160
  /**
146
161
  * @param {import('./resolver').Resolver} resolver - the resolver
147
162
  */
@@ -150,6 +165,30 @@ class EntityRepository {
150
165
  }
151
166
  }
152
167
 
168
+ /**
169
+ * Derives an identifier from an entity info.
170
+ * That identifier can be used to refer to a specific entity within an index.ts file.
171
+ * By passing a relative file, the identifier will be preceeded with a scope if needed.
172
+ * @param {object} options - the options
173
+ * @param {EntityInfo} options.info - the entity info
174
+ * @param {function(string): string} [options.wrapper] - a function to wrap the identifier
175
+ * @param {import('../file').Path} [options.relative] - the path to resolve the identifier relative to
176
+ * @returns {string} the identifier
177
+ */
178
+ function asIdentifier ({info, wrapper = undefined, relative = undefined}) {
179
+ const name = isType(info.csn)
180
+ ? info.entityName
181
+ : info.inflection.singular
182
+
183
+ const wrapped = typeof wrapper === 'function'
184
+ ? wrapper(name)
185
+ : name
186
+ return !relative || relative.isCwd(info.namespace.asDirectory())
187
+ ? wrapped
188
+ : `${info.namespace.asIdentifier()}.${wrapped}`
189
+ }
190
+
153
191
  module.exports = {
154
- EntityRepository
192
+ EntityRepository,
193
+ asIdentifier
155
194
  }