@cap-js/cds-typer 0.25.0 → 0.27.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/util.js CHANGED
@@ -19,6 +19,16 @@ const annotations = {
19
19
  plural: ['@plural'],
20
20
  }
21
21
 
22
+ /**
23
+ * Converts a camelCase string to snake_case.
24
+ * @param {string} camel - The camelCase string.
25
+ * @returns {string} - The snake_case string.
26
+ */
27
+ const camelToSnake = camel => camel
28
+ .replace(/([a-z])([A-Z])/g, '$1_$2') // Handle camelCase
29
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') // Handle sequences of uppercase letters
30
+ .toLowerCase()
31
+
22
32
  /**
23
33
  * Tries to retrieve an annotation that specifies the singular name
24
34
  * from a CSN. Valid annotations are listed in util.annotations
@@ -27,6 +37,7 @@ const annotations = {
27
37
  * @param {EntityCSN} csn - the CSN of an entity to check
28
38
  * @returns {string | undefined} the singular annotation or undefined
29
39
  */
40
+ // @ts-expect-error - can not use possible undefined from find as key
30
41
  const getSingularAnnotation = csn => csn[annotations.singular.find(a => Object.hasOwn(csn, a))]
31
42
 
32
43
  /**
@@ -37,6 +48,7 @@ const getSingularAnnotation = csn => csn[annotations.singular.find(a => Object.h
37
48
  * @param {EntityCSN} csn - the CSN of an entity to check
38
49
  * @returns {string | undefined} the plural annotation or undefined
39
50
  */
51
+ // @ts-expect-error - can not use possible undefined from find as key
40
52
  const getPluralAnnotation = csn => csn[annotations.plural.find(a => Object.hasOwn(csn, a))]
41
53
 
42
54
  /**
@@ -62,9 +74,9 @@ const unlocalize = name => {
62
74
  * @param {boolean?} stripped - if true, leading namespace will be stripped
63
75
  */
64
76
  const singular4 = (dn, stripped = false) => {
65
- let n = dn.name || dn
77
+ let n = dn.name ?? dn
66
78
  if (stripped) {
67
- n = n.match(last)[0]
79
+ n = n.match(last)?.[0] ?? ''
68
80
  }
69
81
  return (
70
82
  getSingularAnnotation(dn) ??
@@ -94,9 +106,9 @@ const singular4 = (dn, stripped = false) => {
94
106
  * @param {boolean} stripped - if true, leading namespace will be stripped
95
107
  */
96
108
  const plural4 = (dn, stripped) => {
97
- let n = dn.name || dn
109
+ let n = dn.name ?? dn
98
110
  if (stripped) {
99
- n = n.match(last)[0]
111
+ n = n.match(last)?.[0]
100
112
  }
101
113
  return (
102
114
  getPluralAnnotation(dn) ??
@@ -123,72 +135,13 @@ const deepMerge = (target, source) => {
123
135
  Object.assign(target, source)
124
136
  }
125
137
 
126
- /**
127
- * Parses command line arguments into named and positional parameters.
128
- * Named parameters are expected to start with a double dash (--).
129
- * If the next argument `B` after a named parameter `A` is not a named parameter itself,
130
- * `B` is used as value for `A`.
131
- * If `A` and `B` are both named parameters, `A` is just treated as a flag (and may receive a default value).
132
- * Only named parameters that occur in validFlags are allowed. Specifying named flags that are not listed there
133
- * will cause an error.
134
- * Named parameters that are either not specified or do not have a value assigned to them may draw a default value
135
- * from their definition in validFlags.
136
- * @param {string[]} argv - list of command line arguments
137
- * @param {{[key: string]: CommandlineFlag}} validFlags - allowed flags. May specify default values.
138
- * @returns {ParsedFlags}
139
- */
140
- const parseCommandlineArgs = (argv, validFlags) => {
141
- const isFlag = (/** @type {string} */ arg) => arg.startsWith('--')
142
- const positional = []
143
- const named = {}
144
-
145
- let i = 0
146
- while (i < argv.length) {
147
- let arg = argv[i]
148
- if (isFlag(arg)) {
149
- arg = arg.slice(2)
150
- if (!(arg in validFlags)) {
151
- throw new Error(`invalid named flag '${arg}'`)
152
- } else {
153
- const next = argv[i + 1]
154
- if (next && !isFlag(next)) {
155
- named[arg] = next
156
- i++
157
- } else {
158
- named[arg] = validFlags[arg].default
159
- }
160
-
161
- const { allowed, allowedHint } = validFlags[arg]
162
- if (allowed && !allowed.includes(named[arg])) {
163
- throw new Error(`invalid value '${named[arg]}' for flag ${arg}. Must be one of ${(allowedHint ?? allowed.join(', '))}`)
164
- }
165
- }
166
- } else {
167
- positional.push(arg)
168
- }
169
- i++
170
- }
171
-
172
- const defaults = Object.entries(validFlags)
173
- .filter(e => !!e[1].default)
174
- .reduce((dict, [k, v]) => {
175
- dict[k] = v.default
176
- return dict
177
- }, {})
178
-
179
- return {
180
- named: Object.assign(defaults, named),
181
- positional,
182
- }
183
- }
184
-
185
138
  module.exports = {
186
139
  annotations,
140
+ camelToSnake,
187
141
  getSingularAnnotation,
188
142
  getPluralAnnotation,
189
143
  unlocalize,
190
144
  singular4,
191
145
  plural4,
192
- parseCommandlineArgs,
193
146
  deepMerge
194
147
  }
package/lib/visitor.js CHANGED
@@ -8,7 +8,7 @@ const { SourceFile, FileRepository, Buffer, Path } = require('./file')
8
8
  const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline')
9
9
  const { Resolver } = require('./resolution/resolver')
10
10
  const { LOG } = require('./logging')
11
- const { docify } = require('./components/wrappers')
11
+ const { docify, createPromiseOf, createUnionOf, createKeysOf } = require('./components/wrappers')
12
12
  const { csnToEnumPairs, propertyToInlineEnumName, isInlineEnumType, stringifyEnumType } = require('./components/enum')
13
13
  const { isReferenceType } = require('./components/reference')
14
14
  const { empty } = require('./components/typescript')
@@ -16,22 +16,15 @@ const { baseDefinitions } = require('./components/basedefs')
16
16
  const { EntityRepository, asIdentifier } = require('./resolution/entity')
17
17
  const { last } = require('./components/identifier')
18
18
  const { getPropertyModifiers } = require('./components/property')
19
+ const { configuration } = require('./config')
19
20
 
20
21
  /** @typedef {import('./file').File} File */
21
22
  /** @typedef {import('./typedefs').visitor.Context} Context */
22
- /** @typedef {import('./typedefs').visitor.CompileParameters} CompileParameters */
23
- /** @typedef {import('./typedefs').visitor.VisitorOptions} VisitorOptions */
24
23
  /** @typedef {import('./typedefs').visitor.Inflection} Inflection */
25
24
  /** @typedef {import('./typedefs').resolver.CSN} CSN */
26
25
  /** @typedef {import('./typedefs').resolver.EntityCSN} EntityCSN */
27
26
  /** @typedef {import('./typedefs').resolver.EnumCSN} EnumCSN */
28
27
 
29
- const defaults = {
30
- // FIXME: add defaults for remaining parameters
31
- propertiesOptional: true,
32
- inlineDeclarations: 'flat'
33
- }
34
-
35
28
  class Visitor {
36
29
  /**
37
30
  * Gathers all files that are supposed to be written to
@@ -45,12 +38,10 @@ class Visitor {
45
38
 
46
39
  /**
47
40
  * @param {{xtended: CSN, inferred: CSN}} csn - root CSN
48
- * @param {VisitorOptions | {}} options - the options
49
41
  */
50
- constructor(csn, options = {}) {
42
+ constructor(csn) {
51
43
  amendCSN(csn.xtended)
52
44
  propagateForeignKeys(csn.inferred)
53
- this.options = { ...defaults, ...options }
54
45
  this.csn = csn
55
46
 
56
47
  /** @type {Context[]} **/
@@ -66,7 +57,7 @@ class Visitor {
66
57
  this.fileRepository = new FileRepository()
67
58
  this.fileRepository.add(baseDefinitions.path.asNamespace(), baseDefinitions)
68
59
  this.inlineDeclarationResolver =
69
- this.options.inlineDeclarations === 'structured'
60
+ configuration.inlineDeclarations === 'structured'
70
61
  ? new StructuredInlineDeclarationResolver(this)
71
62
  : new FlatInlineDeclarationResolver(this)
72
63
 
@@ -102,7 +93,13 @@ class Visitor {
102
93
  // FIXME: references to types of entity properties may be missing from xtendend flavour (see #103)
103
94
  // this should be revisted once we settle on a single flavour.
104
95
  const target = this.csn.xtended.definitions[targetName] ?? this.csn.inferred.definitions[targetName]
105
- this.visitEntity(name, target)
96
+ if (target.kind !== 'type') {
97
+ // skip if the target is a property, like in:
98
+ // books: Association to many Author.books ...
99
+ // as this would result in a type definition that
100
+ // name-clashes with the actual declaration of Author
101
+ this.visitEntity(name, target)
102
+ }
106
103
  } else {
107
104
  LOG.error(`Expecting an autoexposed projection within a service. Skipping ${name}`)
108
105
  }
@@ -148,7 +145,8 @@ class Visitor {
148
145
  returns: action.returns
149
146
  ? this.resolver.resolveAndRequire(action.returns, file).typeName
150
147
  : 'any',
151
- kind: action.kind
148
+ kind: action.kind,
149
+ doc: docify(action.doc)
152
150
  })), '}'
153
151
  ) // end of actions
154
152
  } else {
@@ -156,6 +154,14 @@ class Visitor {
156
154
  }
157
155
  }
158
156
 
157
+ /**
158
+ * @param {Buffer} buffer - the buffer to write the keys into
159
+ * @param {string} clean - the clean name of the entity
160
+ */
161
+ #printStaticKeys(buffer, clean) {
162
+ buffer.add(`declare static readonly keys: ${createKeysOf(clean)}`)
163
+ }
164
+
159
165
  /**
160
166
  * Transforms an entity or CDS aspect into a JS aspect (aka mixin).
161
167
  * That is, for an element A we get:
@@ -168,8 +174,7 @@ class Visitor {
168
174
  * @param {{cleanName?: string}} options - additional options
169
175
  */
170
176
  #aspectify(fq, entity, buffer, options = {}) {
171
- const info = this.entityRepository.getByFq(fq)
172
- if (!info) throw new Error(`could not resolve entity ${fq}`)
177
+ const info = this.entityRepository.getByFqOrThrow(fq)
173
178
  const clean = options?.cleanName ?? info.withoutNamespace
174
179
  const { namespace } = info
175
180
  const file = this.fileRepository.getNamespaceFile(namespace)
@@ -257,18 +262,27 @@ class Visitor {
257
262
  }
258
263
  }
259
264
 
265
+ if ('kind' in entity) {
266
+ buffer.addIndented([`static readonly kind: 'entity' | 'type' | 'aspect' = '${entity.kind}';`])
267
+ }
268
+
260
269
  buffer.addIndented(() => {
261
270
  for (const e of enums) {
271
+ const eDoc = docify(e.doc)
272
+ eDoc.forEach(d => { buffer.add(d) })
262
273
  buffer.add(`static ${e.name} = ${propertyToInlineEnumName(clean, e.name)}`)
263
- file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}))
274
+ file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}), eDoc)
264
275
  }
265
276
  this.#printStaticActions(entity, buffer, ancestorInfos, file)
277
+ this.#printStaticKeys(buffer, clean)
278
+
266
279
  })
267
280
  }, '};') // end of generated class
268
281
  }, '}') // end of aspect
269
282
 
270
283
  // CLASS WITH ADDED ASPECTS
271
284
  file.addImport(baseDefinitions.path)
285
+ docify(entity.doc).forEach(d => { buffer.add(d) })
272
286
  buffer.add(`export class ${identSingular(clean)} extends ${identAspect(toLocalIdent({clean, fq}))}(${baseDefinitions.path.asIdentifier()}.Entity) {${this.#staticClassContents(clean, entity).join('\n')}}`)
273
287
  this.contexts.pop()
274
288
  }
@@ -292,9 +306,7 @@ class Visitor {
292
306
  * @param {string} content - the content to set the name property to
293
307
  */
294
308
  const overrideNameProperty = (clazz, content) => `Object.defineProperty(${clazz}, 'name', { value: '${content}' })`
295
- const info = this.entityRepository.getByFq(fq)
296
- if (!info) throw new Error(`could not resolve entity ${fq}`)
297
- const { namespace: ns, entityName: clean, inflection } = info
309
+ const { namespace: ns, entityName: clean, inflection } = this.entityRepository.getByFqOrThrow(fq)
298
310
  const file = this.fileRepository.getNamespaceFile(ns)
299
311
  let { singular, plural } = inflection
300
312
 
@@ -330,7 +342,6 @@ class Visitor {
330
342
  // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
331
343
  // edge case: @singular annotation present. singular4 will take care of that.
332
344
  file.addInflection(util.singular4(entity, true), plural, clean)
333
- docify(entity.doc).forEach(d => { buffer.add(d) })
334
345
 
335
346
  // in case of projections `entity` is empty -> retrieve from inferred csn where the actual properties are rolled out
336
347
  const target = isProjection(entity) || isView(entity)
@@ -357,6 +368,7 @@ class Visitor {
357
368
  // so it can get passed as value to CQL functions.
358
369
  const additionalProperties = this.#staticClassContents(singular, entity)
359
370
  additionalProperties.push('$count?: number')
371
+ docify(entity.doc).forEach(d => { buffer.add(d) })
360
372
  buffer.add(`export class ${plural} extends Array<${singular}> {${additionalProperties.join('\n')}}`)
361
373
  buffer.add(overrideNameProperty(plural, entity.name))
362
374
  }
@@ -369,18 +381,18 @@ class Visitor {
369
381
  * Also filters out parameters that indicate a binding parameter ({@link https://cap.cloud.sap/docs/releases/jan23#simplified-syntax-for-binding-parameters}).
370
382
  * @param {{[key:string]: EntityCSN}} params - parameter list as found in CSN.
371
383
  * @param {SourceFile} file - source file relative to which the parameter types should be resolved.
372
- * @returns {[string, string][]} pair of names and types.
384
+ * @returns {import('./typedefs').visitor.ParamInfo[]} tuple of name, modifier, type and doc.
373
385
  */
374
386
  #stringifyFunctionParams(params, file) {
375
- return params
376
- ? Object.entries(params)
377
- // filter params of type '[many] $self', as they are not to be part of the implementation
378
- .filter(([, type]) => type?.type !== '$self' && type.items?.type !== '$self')
379
- .map(([name, type]) => [
380
- name,
381
- this.#stringifyFunctionParamType(type, file)
382
- ])
383
- : []
387
+ return Object.entries(params ?? {})
388
+ // filter params of type '[many] $self', as they are not to be part of the implementation
389
+ .filter(([, type]) => type?.type !== '$self' && type.items?.type !== '$self')
390
+ .map(([name, type]) => ({
391
+ name,
392
+ modifier: this.resolver.isOptional(type) ? '?' : '',
393
+ type: this.#stringifyFunctionParamType(type, file),
394
+ doc: docify(type.doc).join('\n'),
395
+ }))
384
396
  }
385
397
 
386
398
  /**
@@ -408,21 +420,27 @@ class Visitor {
408
420
  */
409
421
  #printOperation(fq, operation, kind) {
410
422
  LOG.debug(`Printing operation ${fq}:\n${JSON.stringify(operation, null, 2)}`)
411
- const info = this.entityRepository.getByFq(fq)
412
- if (!info) throw new Error(`could not resolve operation ${fq}`)
413
- const { namespace } = info
423
+ const { namespace } = this.entityRepository.getByFqOrThrow(fq)
414
424
  const file = this.fileRepository.getNamespaceFile(namespace)
415
425
  const params = this.#stringifyFunctionParams(operation.params, file)
416
426
  const returnType = operation.returns
417
427
  ? this.resolver.resolveAndRequire(operation.returns, file)
418
428
  : { typeName: 'void', typeInfo: { plainName: 'void', isArray: false, inflection: { singular: 'void', plural: 'void' } } }
419
- const returns = this.inlineDeclarationResolver.getPropertyDatatype(
429
+ let returns = this.inlineDeclarationResolver.getPropertyDatatype(
420
430
  returnType,
421
431
  returnType.typeInfo.isArray
422
432
  ? returnType.typeName
423
433
  : returnType.typeInfo.inflection.singular
424
434
  )
425
- file.addOperation(last(fq), params, returns, kind)
435
+ if (operation.returns) {
436
+ // operation results may be a Promise
437
+ returns = createUnionOf(createPromiseOf(returns), returns)
438
+ }
439
+ // Actions for ABAP RFC modules have 'parameter categories' (import/export/changing/tables) that cannot be called in a flat order.
440
+ // Prevent positional call style there.
441
+ // TODO find a better way to detect ABAP RFC actions
442
+ const isRFC = Object.values(operation.params ?? {}).some(p => Object.keys(p).some(k => k.startsWith('@RFC')))
443
+ file.addOperation(last(fq), params, returns, kind, docify(operation.doc), {named: true, positional: !isRFC})
426
444
  }
427
445
 
428
446
  /**
@@ -431,15 +449,13 @@ class Visitor {
431
449
  */
432
450
  #printType(fq, type) {
433
451
  LOG.debug(`Printing type ${fq}:\n${JSON.stringify(type, null, 2)}`)
434
- const info = this.entityRepository.getByFq(fq)
435
- if (!info) throw new Error(`could not resolve type ${fq}`)
436
- const { namespace, entityName } = info
452
+ const { namespace, entityName } = this.entityRepository.getByFqOrThrow(fq)
437
453
  const file = this.fileRepository.getNamespaceFile(namespace)
438
454
  // skip references to enums.
439
455
  // "Base" enums will always have a builtin type (don't skip those).
440
456
  // A type referencing an enum E will be considered an enum itself and have .type === E (skip).
441
457
  if (isEnum(type) && !isReferenceType(type) && this.resolver.builtinResolver.resolveBuiltin(type.type)) {
442
- file.addEnum(fq, entityName, csnToEnumPairs(type))
458
+ file.addEnum(fq, entityName, csnToEnumPairs(type), docify(type.doc))
443
459
  } else {
444
460
  const isEnumReference = typeof type.type === 'string' && isEnum(this.csn.inferred.definitions[type?.type])
445
461
  // alias
@@ -454,9 +470,7 @@ class Visitor {
454
470
  */
455
471
  #printAspect(fq, aspect) {
456
472
  LOG.debug(`Printing aspect ${fq}`)
457
- const info = this.entityRepository.getByFq(fq)
458
- if (!info) throw new Error(`could not resolve aspect ${fq}`)
459
- const { namespace, entityName, inflection } = info
473
+ const { namespace, entityName, inflection } = this.entityRepository.getByFqOrThrow(fq)
460
474
  const file = this.fileRepository.getNamespaceFile(namespace)
461
475
  // aspects are technically classes and can therefore be added to the list of defined classes.
462
476
  // Still, when using them as mixins for a class, they need to already be defined.
@@ -471,37 +485,44 @@ class Visitor {
471
485
  * @param {EntityCSN} event - CSN
472
486
  */
473
487
  #printEvent(fq, event) {
474
- LOG.debug(`Printing event ${fq}`)
475
- const info = this.entityRepository.getByFq(fq)
476
- if (!info) throw new Error(`could not resolve event ${fq}`)
477
- const { namespace, entityName } = info
488
+ const { namespace, entityName } = this.entityRepository.getByFqOrThrow(fq)
478
489
  const file = this.fileRepository.getNamespaceFile(namespace)
479
490
  file.addEvent(entityName, fq)
480
491
  const buffer = file.events.buffer
481
492
  buffer.add('// event')
482
493
  // only declare classes, as their properties are not optional, so we don't have to do awkward initialisation thereof.
483
494
  buffer.addIndentedBlock(`export declare class ${entityName} {`, () => {
484
- const propOpt = this.options.propertiesOptional
485
- this.options.propertiesOptional = false
495
+ const propOpt = configuration.propertiesOptional
496
+ // FIXME: shouldn't need to change config here! Idea: init Visitor with .options fed from config, then manipulate that
497
+ configuration.propertiesOptional = false
486
498
  for (const [ename, element] of Object.entries(event.elements ?? {})) {
487
499
  this.visitElement(ename, element, file, buffer)
488
500
  }
489
- this.options.propertiesOptional = propOpt
501
+ configuration.propertiesOptional = propOpt
490
502
  }, '}')
491
503
  }
492
504
 
493
505
  /**
494
506
  * @param {string} fq - fully qualified name of the service
495
- * @param {EntityCSN} service - CSN
507
+ * @param {import('./typedefs').resolver.EntityCSN} service - CSN
496
508
  */
497
509
  #printService(fq, service) {
498
510
  LOG.debug(`Printing service ${fq}:\n${JSON.stringify(service, null, 2)}`)
499
- const info = this.entityRepository.getByFq(fq)
500
- if (!info) throw new Error(`could not resolve service ${fq}`)
501
- const { namespace } = info
511
+ const { namespace } = this.entityRepository.getByFqOrThrow(fq)
502
512
  const file = this.fileRepository.getNamespaceFile(namespace)
503
- // service.name is clean of namespace
504
- file.services.buffer.add(`export default { name: '${service.name}' }`)
513
+ const buffer = file.services.buffer
514
+ const serviceNameSimple = service.name.split('.').pop()
515
+
516
+ docify(service.doc).forEach(d => { buffer.add(d) })
517
+ // file.addImport(new Path(['cds'], '')) TODO make sap/cds import work
518
+ buffer.addIndentedBlock(`export class ${serviceNameSimple} extends cds.Service {`, () => {
519
+ Object.entries(service.operations ?? {}).forEach(([name, {doc}]) => {
520
+ docify(doc).forEach(d => { buffer.add(d) })
521
+ buffer.add(`declare ${name}: typeof ${name}`)
522
+ })
523
+ }, '}')
524
+ buffer.add(`export default ${serviceNameSimple}`)
525
+ buffer.add('')
505
526
  file.addService(service.name)
506
527
  }
507
528
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.25.0",
3
+ "version": "0.27.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,15 +40,17 @@
40
40
  "bin": {
41
41
  "cds-typer": "./lib/cli.js"
42
42
  },
43
- "dependencies": {
44
- "@sap/cds": ">=7.7"
43
+ "peerDependencies": {
44
+ "@cap-js/cds-types": ">=0.6.4",
45
+ "@sap/cds": ">=8"
45
46
  },
46
47
  "devDependencies": {
47
- "@stylistic/eslint-plugin-js": "^1.6.3",
48
- "@cap-js/cds-types": ">=0.6",
48
+ "@cap-js/cds-types": "^0",
49
+ "@sap/cds": "^8",
50
+ "@stylistic/eslint-plugin-js": "^2.7.2",
49
51
  "acorn": "^8.10.0",
50
52
  "eslint": "^9",
51
- "eslint-plugin-jsdoc": "^48.2.7",
53
+ "eslint-plugin-jsdoc": "^50.2.2",
52
54
  "globals": "^15.0.0",
53
55
  "jest": "^29",
54
56
  "typescript": ">=4.6.4"