@cap-js/cds-typer 0.19.0 → 0.20.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/visitor.js CHANGED
@@ -4,18 +4,20 @@ const util = require('./util')
4
4
 
5
5
  const { amendCSN, isView, isUnresolved, propagateForeignKeys, isDraftEnabled, isType, isProjection } = require('./csn')
6
6
  // eslint-disable-next-line no-unused-vars
7
- const { SourceFile, FileRepository, baseDefinitions, Buffer } = require('./file')
7
+ const { SourceFile, FileRepository, Buffer } = require('./file')
8
8
  const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline')
9
9
  const { Resolver, resolveBuiltin } = require('./components/resolver')
10
10
  const { Logger } = require('./logging')
11
11
  const { docify } = require('./components/wrappers')
12
12
  const { csnToEnumPairs, propertyToInlineEnumName, isInlineEnumType, stringifyEnumType } = require('./components/enum')
13
13
  const { isReferenceType } = require('./components/reference')
14
+ const { empty } = require('./components/typescript')
15
+ const { baseDefinitions } = require('./components/basedefs')
14
16
 
15
- /** @typedef {import('./file').File} File */
16
- /** @typedef {{ entity: String }} Context */
17
+ /** @typedef {import('./file').File} File */
18
+ /** @typedef {{ entity: String }} Context */
17
19
 
18
- /**
20
+ /**
19
21
  * @typedef { {
20
22
  * rootDirectory: string,
21
23
  * logLevel: number,
@@ -23,7 +25,7 @@ const { isReferenceType } = require('./components/reference')
23
25
  * }} CompileParameters
24
26
  */
25
27
 
26
- /**
28
+ /**
27
29
  * - `propertiesOptional = true` -> all properties are generated as optional ?:. (standard CAP behaviour, where properties be unavailable)
28
30
  * - `inlineDeclarations = 'structured'` -> @see inline.StructuredInlineDeclarationResolver
29
31
  * - `inlineDeclarations = 'flat'` -> @see inline.FlatInlineDeclarationResolver
@@ -33,7 +35,7 @@ const { isReferenceType } = require('./components/reference')
33
35
  * }} VisitorOptions
34
36
  */
35
37
 
36
- /**
38
+ /**
37
39
  * @typedef {{
38
40
  * typeName: string,
39
41
  * singular: string,
@@ -153,10 +155,10 @@ class Visitor {
153
155
  */
154
156
  #aspectify(name, entity, buffer, options = {}) {
155
157
  const clean = options?.cleanName ?? this.resolver.trimNamespace(name)
156
- const ns = this.resolver.resolveNamespace(name.split('.'))
157
- const file = this.fileRepository.getNamespaceFile(ns)
158
- const identSingular = (name) => name
159
- const identAspect = (name) => `_${name}Aspect`
158
+ const namespace = this.resolver.resolveNamespace(name.split('.'))
159
+ const file = this.fileRepository.getNamespaceFile(namespace)
160
+ const identSingular = name => name
161
+ const identAspect = name => `_${name}Aspect`
160
162
 
161
163
  this.contexts.push({ entity: name })
162
164
 
@@ -201,36 +203,45 @@ class Visitor {
201
203
  file.addInlineEnum(clean, name, e.name, csnToEnumPairs(e, {unwrapVals: true}))
202
204
  }
203
205
  const actions = Object.entries(entity.actions ?? {})
204
- buffer.addIndentedBlock('static actions: {',
205
- actions.map(([aname, action]) => SourceFile.stringifyLambda({
206
- name: aname,
207
- parameters: this.#stringifyFunctionParams(action.params, file),
208
- returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
209
- }))
210
- , '}') // end of actions
206
+ if (actions.length) {
207
+ buffer.addIndentedBlock('static readonly actions: {',
208
+ actions.map(([aname, action]) => SourceFile.stringifyLambda({
209
+ name: aname,
210
+ parameters: this.#stringifyFunctionParams(action.params, file),
211
+ returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any',
212
+ kind: action.kind
213
+ }))
214
+ , '}') // end of actions
215
+ } else {
216
+ buffer.add(`static readonly actions: ${empty}`)
217
+ }
211
218
  }.bind(this))
212
219
  }.bind(this), '};') // end of generated class
213
220
  }.bind(this), '}') // end of aspect
214
221
 
215
222
  // CLASS WITH ADDED ASPECTS
216
223
  file.addImport(baseDefinitions.path)
217
- const rhs = (entity.includes ?? [])
218
- .map((parent) => {
219
- const [ns, n] = this.resolver.untangle(parent)
220
- file.addImport(ns)
221
- return [ns, n]
224
+ const ancestors = (entity.includes ?? [])
225
+ .map(parent => {
226
+ const { namespace, name } = this.resolver.untangle(parent)
227
+ file.addImport(namespace)
228
+ return [namespace, name, parent]
222
229
  })
223
- .concat([[undefined, clean]]) // add own aspect without namespace AFTER imports were created
230
+ .concat([[undefined, clean, [namespace, clean].filter(Boolean).join('.')]]) // add own aspect without namespace AFTER imports were created
224
231
  .reverse() // reverse so that own aspect A is applied before extensions B,C: B(C(A(Entity)))
225
- .reduce(
226
- (wrapped, [ns, n]) =>
227
- !ns || ns.isCwd(file.path.asDirectory())
228
- ? `${identAspect(n)}(${wrapped})`
229
- : `${ns.asIdentifier()}.${identAspect(n)}(${wrapped})`,
230
- `${baseDefinitions.path.asIdentifier()}.Entity`
232
+ .reduce((wrapped, [ns, n, fq]) => {
233
+ // types are not inflected, so don't change those to singular
234
+ const refersToType = isType(this.csn.inferred.definitions[fq])
235
+ const ident = identAspect(refersToType ? n : util.singular4(n))
236
+
237
+ return !ns || ns.isCwd(file.path.asDirectory())
238
+ ? `${ident}(${wrapped})`
239
+ : `${ns.asIdentifier()}.${ident}(${wrapped})`
240
+ },
241
+ `${baseDefinitions.path.asIdentifier()}.Entity`
231
242
  )
232
243
 
233
- buffer.add(`export class ${identSingular(clean)} extends ${rhs} {${this.#staticClassContents(clean, entity).join('\n')}}`)
244
+ buffer.add(`export class ${identSingular(clean)} extends ${ancestors} {${this.#staticClassContents(clean, entity).join('\n')}}`)
234
245
  //buffer.add(`export type ${clean} = InstanceType<typeof ${identSingular(clean)}>`)
235
246
  this.contexts.pop()
236
247
  }
@@ -242,7 +253,7 @@ class Visitor {
242
253
  #printEntity(name, entity) {
243
254
  // static .name has to be defined more forcefully: https://github.com/microsoft/TypeScript/issues/442
244
255
  const overrideNameProperty = (clazz, content) => `Object.defineProperty(${clazz}, 'name', { value: '${content}' })`
245
- const [ns, clean] = this.resolver.untangle(name)
256
+ const { namespace: ns, name: clean } = this.resolver.untangle(name)
246
257
  const file = this.fileRepository.getNamespaceFile(ns)
247
258
  // entities are expected to be in plural anyway, so we would favour the regular name.
248
259
  // If the user decides to pass a @plural annotation, that gets precedence over the regular name.
@@ -251,21 +262,19 @@ class Visitor {
251
262
  let plural = this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
252
263
  const singular = this.resolver.trimNamespace(util.singular4(entity, true))
253
264
  */
254
- let { singular, plural } = this.resolver.inflect({csn: entity, plainName: clean}, ns.asNamespace())
265
+ let { singular, plural } = this.resolver.inflect({csn: entity, plainName: clean}, ns.asNamespace())
255
266
 
256
267
  // trimNamespace does not properly detect scoped entities, like A.B where both A and B are
257
268
  // entities. So to see if we would run into a naming collision, we forcefully take the last
258
269
  // part of the name, so "A.B" and "A.Bs" just become "B" and "Bs" to be compared.
259
270
  // FIXME: put this in a util function
260
- //if (singular.split('.').at(-1) === plural.split('.').at(-1)) {
261
271
  if (plural.split('.').at(-1) === `${singular.split('.').at(-1)}_`) {
262
- //plural += '_'
263
272
  this.logger.warning(
264
273
  `Derived singular and plural forms for '${singular}' are the same. This usually happens when your CDS entities are named following singular flexion. Consider naming your entities in plural or providing '@singular:'/ '@plural:' annotations to have a clear distinction between the two. Plural form will be renamed to '${plural}' to avoid compilation errors within the output.`
265
274
  )
266
275
  }
267
- // as types are not inflected, their singular will always clash and there is also no plural for them anyway
268
- if (!isType(entity) && singular in this.csn.xtended.definitions) {
276
+ // as types are not inflected, their singular will always clash and there is also no plural for them anyway -> skip
277
+ if (!isType(entity) && `${ns.asNamespace()}.${singular}` in this.csn.xtended.definitions) {
269
278
  this.logger.error(
270
279
  `Derived singular '${singular}' for your entity '${name}', already exists. The resulting types will be erronous. Consider using '@singular:'/ '@plural:' annotations in your model or move the offending declarations into different namespaces to resolve this collision.`
271
280
  )
@@ -285,7 +294,7 @@ class Visitor {
285
294
  // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
286
295
  // edge case: @singular annotation present. singular4 will take care of that.
287
296
  file.addInflection(util.singular4(entity, true), plural, clean)
288
- docify(entity.doc).forEach(d => buffer.add(d))
297
+ docify(entity.doc).forEach(d => { buffer.add(d) })
289
298
 
290
299
  // in case of projections `entity` is empty -> retrieve from inferred csn where the actual properties are rolled out
291
300
  const target = isProjection(entity) || isView(entity)
@@ -309,7 +318,9 @@ class Visitor {
309
318
  }
310
319
  // plural can not be a type alias to $singular[] but needs to be a proper class instead,
311
320
  // so it can get passed as value to CQL functions.
312
- buffer.add(`export class ${plural} extends Array<${singular}> {${this.#staticClassContents(singular, entity).join('\n')}}`)
321
+ const additionalProperties = this.#staticClassContents(singular, entity)
322
+ additionalProperties.push('$count?: number')
323
+ buffer.add(`export class ${plural} extends Array<${singular}> {${additionalProperties.join('\n')}}`)
313
324
  buffer.add(overrideNameProperty(plural, entity.name))
314
325
  }
315
326
  buffer.add('')
@@ -338,39 +349,41 @@ class Visitor {
338
349
  #stringifyFunctionParamType(type, file) {
339
350
  // if type.type is not 'cds.String', 'cds.Integer', ..., then we are actually looking
340
351
  // at a named enum type. In that case also resolve that type name
341
- return type.enum && resolveBuiltin(type.type)
342
- ? stringifyEnumType(csnToEnumPairs(type))
343
- : this.inlineDeclarationResolver.getPropertyDatatype(this.resolver.resolveAndRequire(type, file))
344
- }
345
-
346
- #printFunction(name, func) {
347
- // FIXME: mostly duplicate of printAction -> reuse
348
- this.logger.debug(`Printing function ${name}:\n${JSON.stringify(func, null, 2)}`)
349
- const ns = this.resolver.resolveNamespace(name.split('.'))
350
- const file = this.fileRepository.getNamespaceFile(ns)
351
- const params = this.#stringifyFunctionParams(func.params, file)
352
- const returns = this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(
353
- this.resolver.resolveAndRequire(func.returns, file)
352
+ if (type.enum && resolveBuiltin(type.type)) return stringifyEnumType(csnToEnumPairs(type))
353
+ const paramType = this.resolver.resolveAndRequire(type, file)
354
+ return this.inlineDeclarationResolver.getPropertyDatatype(
355
+ paramType,
356
+ paramType.typeInfo.isArray ? paramType.typeName : paramType.typeInfo.inflection.singular
354
357
  )
355
- file.addFunction(name.split('.').at(-1), params, returns)
356
358
  }
357
359
 
358
- #printAction(name, action) {
359
- this.logger.debug(`Printing action ${name}:\n${JSON.stringify(action, null, 2)}`)
360
+ /**
361
+ * @param {string} name
362
+ * @param {object} operation
363
+ * @param {'function' | 'action'} kind
364
+ */
365
+ #printOperation(name, operation, kind) {
366
+ // FIXME: mostly duplicate of printAction -> reuse
367
+ this.logger.debug(`Printing operation ${name}:\n${JSON.stringify(operation, null, 2)}`)
360
368
  const ns = this.resolver.resolveNamespace(name.split('.'))
361
369
  const file = this.fileRepository.getNamespaceFile(ns)
362
- const params = this.#stringifyFunctionParams(action.params, file)
363
- const returns = this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(
364
- this.resolver.resolveAndRequire(action.returns, file)
370
+ const params = this.#stringifyFunctionParams(operation.params, file)
371
+ const returnType = this.resolver.resolveAndRequire(operation.returns, file)
372
+ const returns = this.inlineDeclarationResolver.getPropertyDatatype(
373
+ returnType,
374
+ returnType.typeInfo.isArray ? returnType.typeName : returnType.typeInfo.inflection.singular
365
375
  )
366
- file.addAction(name.split('.').at(-1), params, returns)
376
+ file.addOperation(name.split('.').at(-1), params, returns, kind)
367
377
  }
368
378
 
369
379
  #printType(name, type) {
370
380
  this.logger.debug(`Printing type ${name}:\n${JSON.stringify(type, null, 2)}`)
371
- const [ns, clean] = this.resolver.untangle(name)
381
+ const { namespace: ns, name: clean } = this.resolver.untangle(name)
372
382
  const file = this.fileRepository.getNamespaceFile(ns)
373
- if ('enum' in type && !isReferenceType(type)) { // skip references to enums
383
+ // skip references to enums.
384
+ // "Base" enums will always have a builtin type (don't skip those).
385
+ // A type referencing an enum E will be considered an enum itself and have .type === E (skip).
386
+ if ('enum' in type && !isReferenceType(type) && resolveBuiltin(type.type)) {
374
387
  file.addEnum(name, clean, csnToEnumPairs(type))
375
388
  } else {
376
389
  // alias
@@ -381,7 +394,7 @@ class Visitor {
381
394
 
382
395
  #printAspect(name, aspect) {
383
396
  this.logger.debug(`Printing aspect ${name}`)
384
- const [ns, clean] = this.resolver.untangle(name)
397
+ const { namespace: ns, name: clean } = this.resolver.untangle(name)
385
398
  const file = this.fileRepository.getNamespaceFile(ns)
386
399
  // aspects are technically classes and can therefore be added to the list of defined classes.
387
400
  // Still, when using them as mixins for a class, they need to already be defined.
@@ -393,7 +406,7 @@ class Visitor {
393
406
 
394
407
  #printEvent(name, event) {
395
408
  this.logger.debug(`Printing event ${name}`)
396
- const [ns, clean] = this.resolver.untangle(name)
409
+ const { namespace: ns, name: clean } = this.resolver.untangle(name)
397
410
  const file = this.fileRepository.getNamespaceFile(ns)
398
411
  file.addEvent(clean, name)
399
412
  const buffer = file.events.buffer
@@ -425,33 +438,31 @@ class Visitor {
425
438
  */
426
439
  visitEntity(name, entity) {
427
440
  switch (entity.kind) {
428
- case 'entity':
429
- this.#printEntity(name, entity)
430
- break
431
- case 'action':
432
- this.#printFunction(name, entity)
433
- break
434
- case 'function':
435
- this.#printAction(name, entity)
436
- break
437
- case 'aspect':
438
- this.#printAspect(name, entity)
439
- break
440
- case 'type': {
441
- // types like inline definitions can be used very similarly to entities.
442
- // They can be extended, contain inline enums, etc., so we treat them as entities.
443
- const handler = entity.elements ? this.#printEntity : this.#printType
444
- handler.call(this, name, entity)
445
- break
446
- }
447
- case 'event':
448
- this.#printEvent(name, entity)
449
- break
450
- case 'service':
451
- this.#printService(name, entity)
452
- break
453
- default:
454
- this.logger.debug(`Unhandled entity kind '${entity.kind}'.`)
441
+ case 'entity':
442
+ this.#printEntity(name, entity)
443
+ break
444
+ case 'action':
445
+ case 'function':
446
+ this.#printOperation(name, entity, entity.kind)
447
+ break
448
+ case 'aspect':
449
+ this.#printAspect(name, entity)
450
+ break
451
+ case 'type': {
452
+ // types like inline definitions can be used very similarly to entities.
453
+ // They can be extended, contain inline enums, etc., so we treat them as entities.
454
+ const handler = entity.elements ? this.#printEntity : this.#printType
455
+ handler.call(this, name, entity)
456
+ break
457
+ }
458
+ case 'event':
459
+ this.#printEvent(name, entity)
460
+ break
461
+ case 'service':
462
+ this.#printService(name, entity)
463
+ break
464
+ default:
465
+ this.logger.debug(`Unhandled entity kind '${entity.kind}'.`)
455
466
  }
456
467
  }
457
468
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Generates .ts files for a CDS model to receive code completion in VS Code",
5
5
  "main": "index.js",
6
6
  "repository": "github:cap-js/cds-typer",
@@ -40,10 +40,10 @@
40
40
  "@sap/cds": ">=7.7"
41
41
  },
42
42
  "devDependencies": {
43
- "@babel/eslint-parser": "^7.23.3",
44
43
  "@stylistic/eslint-plugin-js": "^1.6.3",
45
44
  "acorn": "^8.10.0",
46
- "eslint": "^8.15.0",
45
+ "eslint": "^9",
46
+ "globals": "^15.0.0",
47
47
  "jest": "^29",
48
48
  "typescript": ">=4.6.4"
49
49
  },