@cap-js/cds-typer 0.19.0 → 0.20.1

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,42 @@ 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
+ this.logger.debug(`Printing operation ${name}:\n${JSON.stringify(operation, null, 2)}`)
360
367
  const ns = this.resolver.resolveNamespace(name.split('.'))
361
368
  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)
369
+ const params = this.#stringifyFunctionParams(operation.params, file)
370
+ const returnType = operation.returns
371
+ ? this.resolver.resolveAndRequire(operation.returns, file)
372
+ : { typeName: 'void', typeInfo: { isArray: false, inflection: { singular: 'void', plural: 'void' } } }
373
+ const returns = this.inlineDeclarationResolver.getPropertyDatatype(
374
+ returnType,
375
+ returnType.typeInfo.isArray ? returnType.typeName : returnType.typeInfo.inflection.singular
365
376
  )
366
- file.addAction(name.split('.').at(-1), params, returns)
377
+ file.addOperation(name.split('.').at(-1), params, returns, kind)
367
378
  }
368
379
 
369
380
  #printType(name, type) {
370
381
  this.logger.debug(`Printing type ${name}:\n${JSON.stringify(type, null, 2)}`)
371
- const [ns, clean] = this.resolver.untangle(name)
382
+ const { namespace: ns, name: clean } = this.resolver.untangle(name)
372
383
  const file = this.fileRepository.getNamespaceFile(ns)
373
- if ('enum' in type && !isReferenceType(type)) { // skip references to enums
384
+ // skip references to enums.
385
+ // "Base" enums will always have a builtin type (don't skip those).
386
+ // A type referencing an enum E will be considered an enum itself and have .type === E (skip).
387
+ if ('enum' in type && !isReferenceType(type) && resolveBuiltin(type.type)) {
374
388
  file.addEnum(name, clean, csnToEnumPairs(type))
375
389
  } else {
376
390
  // alias
@@ -381,7 +395,7 @@ class Visitor {
381
395
 
382
396
  #printAspect(name, aspect) {
383
397
  this.logger.debug(`Printing aspect ${name}`)
384
- const [ns, clean] = this.resolver.untangle(name)
398
+ const { namespace: ns, name: clean } = this.resolver.untangle(name)
385
399
  const file = this.fileRepository.getNamespaceFile(ns)
386
400
  // aspects are technically classes and can therefore be added to the list of defined classes.
387
401
  // Still, when using them as mixins for a class, they need to already be defined.
@@ -393,7 +407,7 @@ class Visitor {
393
407
 
394
408
  #printEvent(name, event) {
395
409
  this.logger.debug(`Printing event ${name}`)
396
- const [ns, clean] = this.resolver.untangle(name)
410
+ const { namespace: ns, name: clean } = this.resolver.untangle(name)
397
411
  const file = this.fileRepository.getNamespaceFile(ns)
398
412
  file.addEvent(clean, name)
399
413
  const buffer = file.events.buffer
@@ -425,33 +439,31 @@ class Visitor {
425
439
  */
426
440
  visitEntity(name, entity) {
427
441
  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}'.`)
442
+ case 'entity':
443
+ this.#printEntity(name, entity)
444
+ break
445
+ case 'action':
446
+ case 'function':
447
+ this.#printOperation(name, entity, entity.kind)
448
+ break
449
+ case 'aspect':
450
+ this.#printAspect(name, entity)
451
+ break
452
+ case 'type': {
453
+ // types like inline definitions can be used very similarly to entities.
454
+ // They can be extended, contain inline enums, etc., so we treat them as entities.
455
+ const handler = entity.elements ? this.#printEntity : this.#printType
456
+ handler.call(this, name, entity)
457
+ break
458
+ }
459
+ case 'event':
460
+ this.#printEvent(name, entity)
461
+ break
462
+ case 'service':
463
+ this.#printService(name, entity)
464
+ break
465
+ default:
466
+ this.logger.debug(`Unhandled entity kind '${entity.kind}'.`)
455
467
  }
456
468
  }
457
469
 
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.1",
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
  },