@cap-js/cds-typer 0.25.0 → 0.26.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/CHANGELOG.md CHANGED
@@ -4,7 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
  This project adheres to [Semantic Versioning](http://semver.org/).
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
6
6
 
7
- ## Version 0.26.0 - TBD
7
+ ## Version 0.27.0 - TBD
8
+
9
+ ## Version 0.26.0 - 2024-09-11
10
+ ### Added
11
+ - Added a CLI option `--useEntitiesProxy`. When set to `true`, all entities are wrapped into `Proxy` objects during runtime, allowing top level imports of entity types.
12
+ - Added a static `.kind` property for entities and types, which contains `'entity'` or `'type'` respectively
13
+ - Apps need to provide `@sap/cds` version `8.2` or higher.
14
+ - Apps need to provide `@cap-js/cds-types` version `0.6.4` or higher.
15
+ - Typed methods are now generated for calls of unbound actions. Named and positional call styles are supported, e.g. `service.action({one, two})` and `service.action(one, two)`.
16
+ - Action parameters can be optional in the named call style (`service.action({one:1, ...})`).
17
+ - Actions for ABAP RFC modules cannot be called with positional parameters, but only with named ones. They have 'parameter categories' (import/export/changing/tables) that cannot be called in a flat order.
18
+ - Services now have their own export (named like the service itself). The current default export is not usable in some scenarios from CommonJS modules.
19
+ - Enums and operation parameters can have doc comments
20
+
8
21
 
9
22
  ## Version 0.25.0 - 2024-08-13
10
23
  ### Added
@@ -85,7 +98,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
85
98
  - Importing an enum into a service will now generate an alias to the original enum, instead of incorrectly duplicating the definition
86
99
  - Returning entities from actions/ functions and using them as parameters will now properly use the singular inflection instead of returning an array thereof
87
100
  - Aspects are now consistently named and called in their singular form
88
- - Only detect inflection clash if singular and plural share the same namespace. This also no longer reports `sap.common` as erroneous during type creation
101
+ - Only detect inflection clash if singular and plural share the same namespace. This also no longer reports `sap.common` as erroneous during type creation
89
102
 
90
103
  ## Version 0.19.0 - 2024-03-28
91
104
  ### Added
package/README.md CHANGED
@@ -9,6 +9,25 @@ Generates `.ts` files for a CDS model to receive code completion in VS Code.
9
9
 
10
10
  Exhaustive documentation can be found on [CAPire](https://cap.cloud.sap/docs/tools/cds-typer).
11
11
 
12
+ ## Known Restrictions
13
+
14
+ Certain language features of CDS can not be represented in TypeScript.
15
+ Trying to generate types for models using these features will therefore result in incorrect or broken TypeScript code.
16
+
17
+ ### Changing Types
18
+
19
+ While the following is valid CDS, there is no TypeScript equivalent that would allow the type of an inherited property to change (TS2416).
20
+
21
+ ```cds
22
+ entity A {
23
+ foo: Integer
24
+ };
25
+
26
+ entity B: A {
27
+ foo: String
28
+ }
29
+ ```
30
+
12
31
  ## Support, Feedback, Contributing
13
32
 
14
33
  This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/cds-typer/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md).
package/lib/cli.js CHANGED
@@ -35,6 +35,11 @@ const flags = {
35
35
  desc: `Path to where the jsconfig.json should be written.${EOL}If specified, ${toolName} will create a jsconfig.json file and${EOL}set it up to restrict property usage in types entities to${EOL}existing properties only.`,
36
36
  type: 'string'
37
37
  },
38
+ useEntitiesProxy: {
39
+ desc: `If set to true the 'cds.entities' exports in the generated 'index.js'${EOL}files will be wrapped in 'Proxy' objects\nso static import/require calls can be used everywhere.${EOL}${EOL}WARNING: entity properties can still only be accessed after${EOL}'cds.entities' has been loaded`,
40
+ allowed: ['true', 'false'],
41
+ default: 'false'
42
+ },
38
43
  version: {
39
44
  desc: 'Prints the version of this tool.'
40
45
  },
@@ -117,6 +122,7 @@ const main = async (/** @type {any} */ args) => {
117
122
  // temporary fix until rootDir is faded out
118
123
  outputDirectory: [args.named.outputDirectory, args.named.rootDir].find(d => d !== './') ?? './',
119
124
  logLevel: args.named.logLevel,
125
+ useEntitiesProxy: args.named.useEntitiesProxy === 'true',
120
126
  jsConfigPath: args.named.jsConfigPath,
121
127
  inlineDeclarations: args.named.inlineDeclarations,
122
128
  propertiesOptional: args.named.propertiesOptional === 'true',
@@ -22,7 +22,7 @@ const stringifyEnumType = kvs => [...uniqueValues(kvs)].join(' | ')
22
22
  /**
23
23
  * @param {string} key - the key of the enum
24
24
  * @param {any} value - the value of the enum
25
- * @param {string | import('@sap/cds').ref} enumType - the type of the enum
25
+ * @param {string | import('../typedefs').resolver.ref} enumType - the type of the enum
26
26
  */
27
27
  const enumVal = (key, value, enumType) => enumType === 'cds.String' ? JSON.stringify(`${value ?? key}`) : value
28
28
 
@@ -50,10 +50,12 @@ const enumVal = (key, value, enumType) => enumType === 'cds.String' ? JSON.strin
50
50
  * @param {string} name - local name of the enum, i.e. the name under which it should be created in the .ts file
51
51
  * @param {[string, string][]} kvs - list of key-value pairs
52
52
  * @param {object} options - options for printing the enum
53
+ * @param {string[]} doc - the enum docs
53
54
  */
54
- function printEnum(buffer, name, kvs, options = {}) {
55
+ function printEnum(buffer, name, kvs, options = {}, doc=[]) {
55
56
  const opts = {...{export: true}, ...options}
56
57
  buffer.add('// enum')
58
+ if (opts.export) doc.forEach(d => { buffer.add(d) })
57
59
  buffer.addIndentedBlock(`${opts.export ? 'export ' : ''}const ${name} = {`, () =>
58
60
  kvs.forEach(([k, v]) => { buffer.add(`${normalise(k)}: ${v},`) })
59
61
  , '} as const;')
@@ -0,0 +1,28 @@
1
+ /* eslint-disable no-undef */
2
+ // this function will be stringified as part of the generated types and is not supposed to be used internally
3
+ // @ts-nocheck
4
+ const proxyAccessFunction = function (fqParts, opts = {}) {
5
+ const { target, customProps } = { target: {}, customProps: [], ...opts }
6
+ const fq = fqParts.join('.')
7
+ return new Proxy(target, {
8
+ get: function (target, prop) {
9
+ if (cds.entities) {
10
+ target.__proto__ = cds.entities[fq]
11
+ // overwrite/simplify getter after cds.entities is accessible
12
+ this.get = (target, prop) => target[prop]
13
+ return target[prop]
14
+ }
15
+ // we already know the name so we skip the cds.entities proxy access
16
+ if (prop === 'name') return fq
17
+ // custom properties access on 'target' as well as cached _entity property access goes here
18
+ if (Object.hasOwn(target, prop)) return target[prop]
19
+ // inline enums have to be caught here for first time access, as they do not exist on the entity
20
+ if (customProps.includes(prop)) return target[prop]
21
+ // last but not least we pass the property access to cds.entities
22
+ throw new Error(`Property ${prop} does not exist on entity '${fq}' or cds.entities is not yet defined. Ensure the CDS runtime is fully booted before accessing properties.`)
23
+ }
24
+ })
25
+ }.toString()
26
+ /* eslint-enable no-undef */
27
+
28
+ module.exports = { proxyAccessFunction }
@@ -45,6 +45,24 @@ const createArrayOf = t => `Array<${t}>`
45
45
  */
46
46
  const createObjectOf = t => `{${t}}`
47
47
 
48
+ /**
49
+ * Wraps types into a union type string
50
+ * @param {string[]} types - an array of types
51
+ * @returns {string}
52
+ */
53
+ const createUnionOf = (...types) => types.join(' | ')
54
+
55
+ /**
56
+ * Wraps type into a promise
57
+ * @param {string} t - the type to wrap.
58
+ * @returns {string}
59
+ * @example
60
+ * ```js
61
+ * createPromiseOf('string') // -> 'Promise<string>'
62
+ * ```
63
+ */
64
+ const createPromiseOf = t => `Promise<${t}>`
65
+
48
66
  /**
49
67
  * Wraps type into a deep require (removes all posibilities of undefined recursively).
50
68
  * @param {string} t - the singular type name.
@@ -59,13 +77,18 @@ const deepRequire = (t, lookup = '') => `${base}.DeepRequired<${t}>${lookup}`
59
77
  * @returns {string[]} an array of lines wrapped in doc format. The result is not
60
78
  * concatenated to be properly indented by `buffer.add(...)`.
61
79
  */
62
- const docify = doc => doc
63
- ? ['/**'].concat(doc.split('\n').map(line => `* ${line}`)).concat(['*/'])
64
- : []
80
+ const docify = doc => {
81
+ if (!doc) return []
82
+ const lines = doc.split(/\r?\n/).map(l => l.trim().replaceAll('*/', '*\\/')) // mask any */ with *\/
83
+ if (lines.length === 1) return [`/** ${lines[0]} */`] // one-line doc
84
+ return ['/**'].concat(lines.map(line => `* ${line}`)).concat(['*/'])
85
+ }
65
86
 
66
87
  module.exports = {
67
88
  createArrayOf,
68
89
  createObjectOf,
90
+ createPromiseOf,
91
+ createUnionOf,
69
92
  createToOneAssociation,
70
93
  createToManyAssociation,
71
94
  createCompositionOfOne,
package/lib/file.js CHANGED
@@ -6,11 +6,13 @@ const { readFileSync } = require('fs')
6
6
  const { printEnum, propertyToInlineEnumName, stringifyEnumImplementation } = require('./components/enum')
7
7
  const { normalise } = require('./components/identifier')
8
8
  const { empty } = require('./components/typescript')
9
+ const { proxyAccessFunction } = require('./components/javascript')
9
10
  const { createObjectOf } = require('./components/wrappers')
10
11
 
11
12
  const AUTO_GEN_NOTE = '// This is an automatically generated file. Please do not change its contents manually!'
12
13
 
13
14
  /** @typedef {import('./typedefs').file.Namespace} Namespace */
15
+ /** @typedef {import('./typedefs').file.FileOptions} FileOptions */
14
16
 
15
17
  class File {
16
18
  /**
@@ -107,9 +109,11 @@ class Library extends File {
107
109
  class SourceFile extends File {
108
110
  /**
109
111
  * @param {string | Path} path - path to the file
112
+ * @param {FileOptions} [options] - options for file output
110
113
  */
111
- constructor(path) {
114
+ constructor(path, options) {
112
115
  super()
116
+ this.options = options ?? { useEntitiesProxy: false }
113
117
  /** @type {Path} */
114
118
  this.path = path instanceof Path ? path : new Path(path.split('.'))
115
119
  /** @type {{[key:string]: any}} */
@@ -140,50 +144,64 @@ class SourceFile extends File {
140
144
  this.inflections = []
141
145
  /** @type {{ buffer: Buffer, names: string[]}} */
142
146
  this.services = { buffer: new Buffer(), names: [] }
147
+ /** @type {Record<string,string[]>} */
148
+ this.entityProxies = {}
143
149
  }
144
150
 
145
151
  /**
146
152
  * Stringifies a lambda expression.
147
153
  * @param {object} options - options
148
154
  * @param {string} options.name - name of the lambda
149
- * @param {[string, string][]} [options.parameters] - list of parameters, passed as [name, type] pairs
155
+ * @param {import('./typedefs').visitor.ParamInfo[]} [options.parameters] - list of parameters, passed as [name, modifier, type, doc] pairs
150
156
  * @param {string} [options.returns] - the return type of the function
151
157
  * @param {'action' | 'function'} options.kind - kind of the lambda
152
158
  * @param {string} [options.initialiser] - the initialiser expression
153
159
  * @param {boolean} [options.isStatic] - whether the lambda is static
160
+ * @param {{positional?: boolean, named?: boolean}} [options.callStyles] - whether to generate positional and/or named call styles
161
+ * @param {string[]?} [options.doc] - documentation for the operation
154
162
  * @returns {string} the stringified lambda
155
163
  * @example
156
164
  * ```js
157
165
  * // note: these samples are actually simplified! See below.
158
- * stringifyLambda({parameters: [['p','T']]}) // f: { (p: T): any, ... }
159
- * stringifyLambda({name: 'f', parameters: [['p','T']]}) // f: { (p: T) => any, ... }
160
- * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'}) // f: { (p: T) => number, ... }
161
- * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number', initialiser: '_ => 42'}) // f: { (p: T): string = _ => 42, ... }
166
+ * stringifyLambda({parameters: [['p','','T']]}) // f: { (p: T): any, ... }
167
+ * stringifyLambda({name: 'f', parameters: [{name:'p',type:'T'}]}) // f: { (p: T) => any, ... }
168
+ * stringifyLambda({name: 'f', parameters: [{name:'p',modifier:'?',type:'T',doc:'/** doc *\/'}], returns: 'number'}) // /** doc *\/f?: { (p: T) => number, ... }
169
+ * stringifyLambda({name: 'f', parameters: [{name:'p',type:'T'}], returns: 'number', initialiser: '_ => 42'}) // f?: { (p: T): string = _ => 42, ... }
162
170
  * ```
163
171
  *
164
- * The generated string will not be just the signature of the function. Instead, it will be an object offering a callable signature.
172
+ * The generated string will not be just the signature of the function. Instead, it will be an object offering callable signature(s).
165
173
  * On top of that, it will also expose a property `__parameters`, which is an object reflecting the functions parameters.
166
174
  * The reason for this is that the CDS runtime actually treats the function parameters as a named object. This can not be rectified via
167
175
  * type magic, as parameter names do not exist on type level. So we can not use these names to reuse them as object properties.
168
176
  * Instead, we generate this utility object for the runtime to use:
169
177
  * @example
170
178
  * ```js
171
- * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'}) // { (p: T): number, __parameters: { p: T } }
179
+ * stringifyLambda({name: 'f', parameters: [{name:'p',type:'T'}], returns: 'number'}) // { (p: T): number, __parameters: { p: T } }
172
180
  * ```
173
181
  */
174
- static stringifyLambda({name, parameters=[], returns='any', initialiser, isStatic=false, kind}) {
175
- const parameterTypes = parameters.map(([n, t]) => `${normalise(n)}: ${t}`).join(', ')
182
+ static stringifyLambda({name, parameters=[], returns='any', kind, initialiser, isStatic=false, callStyles={positional:true, named:true}, doc}) {
183
+ let docStr = doc?.length ? doc.join('\n')+'\n' : ''
184
+ const parameterTypes = parameters.map(({name, modifier, type, doc}) => `${doc?'\n'+doc:''}${normalise(name)}${modifier}: ${type}`).join(', ')
176
185
  const parameterTypeAsObject = parameterTypes.length
177
186
  ? createObjectOf(parameterTypes)
178
187
  : empty
179
- const callableSignature = `(${parameterTypes}): ${returns}`
188
+ const callableSignatures = []
189
+ if (callStyles.positional) {
190
+ const paramTypesPositional = parameters.map(({name, type, doc}) => `${doc?'\n'+doc:''}${normalise(name)}: ${type}`).join(', ') // must not include ? modifiers
191
+ callableSignatures.push(`// positional\n${docStr}(${paramTypesPositional}): ${returns}`) // docs shows up on action consumer side: `.action(...)`
192
+ }
193
+ if (callStyles.named) {
194
+ const parameterNames = createObjectOf(parameters.map(({name}) => normalise(name)).join(', '))
195
+ callableSignatures.push(`// named\n${docStr}(${parameterNames}: ${parameterTypeAsObject}): ${returns}`)
196
+ }
197
+ if (callableSignatures.length === 0) throw new Error('At least one call style must be specified')
180
198
  let prefix = name ? `${normalise(name)}: `: ''
181
199
  if (prefix && isStatic) {
182
200
  prefix = `static ${prefix}`
183
201
  }
184
202
  const kindDef = kind ? `, kind: '${kind}'` : ''
185
203
  const suffix = initialiser ? ` = ${initialiser}` : ''
186
- const lambda = `{ ${callableSignature}, __parameters: ${parameterTypeAsObject}, __returns: ${returns}${kindDef}}`
204
+ const lambda = `{\n${callableSignatures.join('\n')}, \n// metadata (do not use)\n__parameters: ${parameterTypeAsObject}, __returns: ${returns}${kindDef}}`
187
205
  return prefix + lambda + suffix
188
206
  }
189
207
 
@@ -203,13 +221,16 @@ class SourceFile extends File {
203
221
  /**
204
222
  * Adds a function definition in form of a arrow function to the file.
205
223
  * @param {string} name - name of the function
206
- * @param {[string, string][]} parameters - list of parameters, passed as [name, type] pairs
224
+ * @param {import('./typedefs').visitor.ParamInfo[]} parameters - list of parameters, passed as [name, modifier, type] tuple
207
225
  * @param {string} returns - the return type of the function
208
226
  * @param {'function' | 'action'} kind - kind of the node
227
+ * @param {string[]} doc - documentation for the function
228
+ * @param {{positional?: boolean, named?: boolean}} callStyles - how the operation can be called
209
229
  */
210
- addOperation(name, parameters, returns, kind) {
211
- //this.operations.buffer.add("// operation")
212
- this.operations.buffer.add(`export declare const ${SourceFile.stringifyLambda({name, parameters, returns, kind})};`)
230
+ addOperation(name, parameters, returns, kind, doc, callStyles) {
231
+ // this.operations.buffer.add(`// ${kind}`)
232
+ if (doc) this.operations.buffer.add(doc.join('\n')) // docs shows up on action provider side: `.on(action,...)`
233
+ this.operations.buffer.add(`export declare const ${SourceFile.stringifyLambda({name, parameters, returns, kind, doc, callStyles})};`)
213
234
  this.operations.names.push(name)
214
235
  }
215
236
 
@@ -239,10 +260,11 @@ class SourceFile extends File {
239
260
  * @param {string} fq - fully qualified name of the enum (entity name within CSN)
240
261
  * @param {string} name - local name of the enum
241
262
  * @param {[string, string][]} kvs - list of key-value pairs
263
+ * @param {string[]} doc - the enum docs
242
264
  */
243
- addEnum(fq, name, kvs) {
265
+ addEnum(fq, name, kvs, doc) {
244
266
  this.enums.data.push({ name, fq, kvs })
245
- printEnum(this.enums.buffer, name, kvs)
267
+ printEnum(this.enums.buffer, name, kvs, {}, doc)
246
268
  }
247
269
 
248
270
  /**
@@ -251,6 +273,7 @@ class SourceFile extends File {
251
273
  * @param {string} entityFqName - name of the entity the enum is attached to with namespace
252
274
  * @param {string} propertyName - property to which the enum is attached.
253
275
  * @param {[string, string][]} kvs - list of key-value pairs
276
+ * @param {string[]} doc - the enum docs
254
277
  * If given, the enum is considered to be an inline definition of an enum.
255
278
  * If not, it is considered to be regular, named enum.
256
279
  * @example
@@ -274,14 +297,16 @@ class SourceFile extends File {
274
297
  * }
275
298
  * ```
276
299
  */
277
- addInlineEnum(entityCleanName, entityFqName, propertyName, kvs) {
300
+ addInlineEnum(entityCleanName, entityFqName, propertyName, kvs, doc=[]) {
278
301
  this.enums.data.push({
279
302
  name: `${entityCleanName}.${propertyName}`,
280
303
  property: propertyName,
281
304
  kvs,
282
305
  fq: `${entityCleanName}.${propertyName}`
283
306
  })
284
- printEnum(this.inlineEnums.buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false})
307
+ const entityProxy = this.entityProxies[entityCleanName] ?? (this.entityProxies[entityCleanName] = [])
308
+ entityProxy.push(propertyName)
309
+ printEnum(this.inlineEnums.buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false}, doc)
285
310
  }
286
311
 
287
312
  /**
@@ -363,11 +388,16 @@ class SourceFile extends File {
363
388
  */
364
389
  getImports() {
365
390
  const buffer = new Buffer()
391
+ if (this.services.names.length) {
392
+ // currently only needed to extend cds.Service and would trigger unused-variable-errors in strict configs
393
+ buffer.add('import cds from \'@sap/cds\'') // TODO should go to visitor#printService, but can't express this as Path
394
+ }
366
395
  for (const imp of Object.values(this.imports)) {
367
396
  if (!imp.isCwd(this.path.asDirectory())) {
368
397
  buffer.add(`import * as ${imp.asIdentifier()} from '${imp.asDirectory({relative: this.path.asDirectory()})}';`)
369
398
  }
370
399
  }
400
+ buffer.add('') // empty line after imports
371
401
  return buffer
372
402
  }
373
403
 
@@ -394,31 +424,92 @@ class SourceFile extends File {
394
424
  namespaces.join() // needs to be after classes for possible declaration merging
395
425
  ].filter(Boolean).join('\n')
396
426
  }
427
+ #getEntityProxyFunctionExport() {
428
+ return `module.exports.createEntityProxy = ${proxyAccessFunction}`
429
+ }
430
+ /**
431
+ * Returns boilerplate code for `index.js` files
432
+ * - `useEntitiesProxy = true` -> import `createEntityProxy` function for entity proxy
433
+ * - `useEntitiesProxy = false` -> retrieve entities via `cds.entities(namespace)`
434
+ * @returns {string[]}
435
+ */
436
+ #getJSExportBoilerplate() {
437
+ const namespace = this.path.asNamespace()
397
438
 
439
+ const boilerplate = [AUTO_GEN_NOTE]
440
+ if (this.options.useEntitiesProxy) {
441
+ if (namespace === '_') {
442
+ boilerplate.push('const cds = require(\'@sap/cds\')', this.#getEntityProxyFunctionExport())
443
+ } else {
444
+ boilerplate.push(`const { createEntityProxy } = require('${new Path(['_']).asDirectory({relative: this.path.asDirectory()})}')`)
445
+ }
446
+ } else {
447
+ boilerplate.push(
448
+ 'const cds = require(\'@sap/cds\')',
449
+ `const csn = cds.entities('${namespace}')`
450
+ )
451
+ }
452
+ return boilerplate
453
+ }
454
+ /**
455
+ * Returns RHS for entity `module.exports` assignments
456
+ * - `useEntitiesProxy = true` -> use function calls to create `Proxy` objects
457
+ * - `useEntitiesProxy = false` -> access entity from CSN directly
458
+ * @param {string} singular - singular name of entity
459
+ * @param {string} original - original name of entity
460
+ * @returns {{singularRhs: string, pluralRhs: string}}
461
+ */
462
+ #getEntityExportsRhs(singular, original) {
463
+ if (this.options.useEntitiesProxy) {
464
+ const namespace = this.path.asNamespace()
465
+ // determine the custom properties for the proxy function call
466
+ const customProps = this.entityProxies[singular] ?? []
467
+ let customPropsStr = customProps.length ? `, customProps: ${JSON.stringify(customProps)}` : ''
468
+
469
+ return {
470
+ singularRhs: `createEntityProxy(['${namespace}', '${original}'], { target: { is_singular: true }${customPropsStr} })`,
471
+ pluralRhs: `createEntityProxy(['${namespace}', '${original}'])`,
472
+ }
473
+ } else {
474
+ return {
475
+ singularRhs: `{ is_singular: true, __proto__: csn.${original} }`,
476
+ pluralRhs: `csn.${original}`
477
+ }
478
+ }
479
+ }
398
480
  toJSExports() {
399
- return [AUTO_GEN_NOTE, 'const cds = require(\'@sap/cds\')', `const csn = cds.entities('${this.path.asNamespace()}')`] // boilerplate
481
+ return this.#getJSExportBoilerplate() // boilerplate
400
482
  .concat(
401
483
  // FIXME: move stringification of service into own module
402
- this.services.names.map(name => `module.exports = { name: '${name}' }`)) // there should be only one
484
+ this.services.names.flatMap(name => {
485
+ const nameSimple = name.split('.').pop()
486
+ return [
487
+ '// service',
488
+ `const ${nameSimple} = { name: '${name}' }`,
489
+ `module.exports = ${nameSimple}`, // there should be only one and must be the first
490
+ `module.exports.${nameSimple} = ${nameSimple}`
491
+ ]
492
+ })
493
+ )
403
494
  .concat(this.inflections
404
495
  // sorting the entries based on the number of dots in their singular.
405
496
  // that makes sure we have defined all parent namespaces before adding subclasses to them e.g.:
406
497
  // "module.exports.Books" is defined before "module.exports.Books.text"
407
498
  .sort(([a], [b]) => a.split('.').length - b.split('.').length)
408
499
  .flatMap(([singular, plural, original]) => {
409
- const exports = [`module.exports.${singular} = { is_singular: true, __proto__: csn.${original} }`]
500
+ const { singularRhs, pluralRhs } = this.#getEntityExportsRhs(singular, original)
501
+
502
+ const exports = [`// ${original}`, `module.exports.${singular} = ${singularRhs}`]
410
503
  if (!/Array<.*>/.test(plural) && plural !== original) {
411
504
  // FIXME: this is a hack to support CDS types that will produce "Array<MyType>" as plural, which we do not want as export in the index.js files
412
- exports.push(`module.exports.${plural} = csn.${original}`)
505
+ exports.push(`module.exports.${plural} = ${pluralRhs}`)
413
506
  }
414
507
  // FIXME: we currently produce at most 3 entries.
415
508
  // This could be an issue when the user re-used the original name in a @singular/@plural annotation.
416
509
  // Seems unlikely, but we have to eliminate the original entry if users start running into this.
417
510
  if (singular !== original) {
418
511
  // do not do the is_singular spiel if the original name is used for the plural
419
- const rhs = plural === original
420
- ? `csn.${original}`
421
- : `{ is_singular: true, __proto__: csn.${original} }`
512
+ const rhs = plural === original ? pluralRhs : singularRhs
422
513
  exports.push(`module.exports.${original} = ${rhs}`)
423
514
  }
424
515
  return exports
@@ -606,6 +697,13 @@ class FileRepository {
606
697
  /** @type {{[key:string]: SourceFile}} */
607
698
  #files = {}
608
699
 
700
+ /**
701
+ * @param {FileOptions} options - options to control file
702
+ */
703
+ constructor(options) {
704
+ this.options = options
705
+ }
706
+
609
707
  /**
610
708
  * @param {string} name - file name
611
709
  * @param {SourceFile} file - the file
@@ -622,7 +720,7 @@ class FileRepository {
622
720
  */
623
721
  getNamespaceFile(path) {
624
722
  const key = path instanceof Path ? path.asNamespace() : path
625
- return (this.#files[key] ??= new SourceFile(path))
723
+ return (this.#files[key] ??= new SourceFile(path, this.options))
626
724
  }
627
725
 
628
726
  /**
@@ -58,6 +58,15 @@ class Resolver {
58
58
  return this.existsInCsn(fq) || Boolean(this.builtinResolver.resolveBuiltin(fq))
59
59
  }
60
60
 
61
+ /**
62
+ * @param {EntityCSN} type - a CSN type
63
+ * @returns {boolean} whether the type is configured to be optional
64
+ */
65
+ isOptional(type) {
66
+ // TODO temporary solution to determine optional parameters. Align w/ compiler/importer.
67
+ return Object.keys(type).some(k => k.startsWith('@Core.OptionalParameter'))
68
+ }
69
+
61
70
  /**
62
71
  * Returns all libraries that have been referenced at least once.
63
72
  * @returns {Library[]}
package/lib/typedefs.d.ts CHANGED
@@ -8,6 +8,7 @@ export module resolver {
8
8
 
9
9
  export type EntityCSN = {
10
10
  actions?: OperationCSN[],
11
+ operations?: OperationCSN[],
11
12
  cardinality?: { max?: '*' | string }
12
13
  compositions?: { target: string }[]
13
14
  doc?: string,
@@ -118,6 +119,7 @@ export module visitor {
118
119
  export type CompileParameters = {
119
120
  outputDirectory: string,
120
121
  logLevel: number,
122
+ useEntitiesProxy: boolean,
121
123
  jsConfigPath?: string,
122
124
  inlineDeclarations: 'flat' | 'structured',
123
125
  propertiesOptional: boolean,
@@ -132,6 +134,10 @@ export module visitor {
132
134
  * `inlineDeclarations = 'flat'` -> @see {@link inline.FlatInlineDeclarationResolver}
133
135
  */
134
136
  inlineDeclarations: 'flat' | 'structured',
137
+ /**
138
+ * `useEntitiesProxy = true` will wrap the `module.exports.<entityName>` in `Proxy` objects
139
+ */
140
+ useEntitiesProxy: boolean
135
141
  }
136
142
 
137
143
  export type Inflection = {
@@ -143,8 +149,18 @@ export module visitor {
143
149
  export type Context = {
144
150
  entity: string
145
151
  }
152
+
153
+ export type ParamInfo = {
154
+ name: string,
155
+ modifier: '' | '?',
156
+ type: string,
157
+ doc?: string
158
+ }
146
159
  }
147
160
 
148
161
  export module file {
149
162
  export type Namespace = Object<string, Buffer>
163
+ export type FileOptions = {
164
+ useEntitiesProxy: boolean
165
+ }
150
166
  }
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 } = 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')
@@ -29,6 +29,7 @@ const { getPropertyModifiers } = require('./components/property')
29
29
  const defaults = {
30
30
  // FIXME: add defaults for remaining parameters
31
31
  propertiesOptional: true,
32
+ useEntitiesProxy: false,
32
33
  inlineDeclarations: 'flat'
33
34
  }
34
35
 
@@ -63,7 +64,9 @@ class Visitor {
63
64
  this.entityRepository = new EntityRepository(this.resolver)
64
65
 
65
66
  /** @type {FileRepository} */
66
- this.fileRepository = new FileRepository()
67
+ this.fileRepository = new FileRepository(this.options)
68
+ // REVISIT: better way to pass options to base source file ???
69
+ baseDefinitions.options = this.options
67
70
  this.fileRepository.add(baseDefinitions.path.asNamespace(), baseDefinitions)
68
71
  this.inlineDeclarationResolver =
69
72
  this.options.inlineDeclarations === 'structured'
@@ -148,7 +151,8 @@ class Visitor {
148
151
  returns: action.returns
149
152
  ? this.resolver.resolveAndRequire(action.returns, file).typeName
150
153
  : 'any',
151
- kind: action.kind
154
+ kind: action.kind,
155
+ doc: docify(action.doc)
152
156
  })), '}'
153
157
  ) // end of actions
154
158
  } else {
@@ -168,8 +172,7 @@ class Visitor {
168
172
  * @param {{cleanName?: string}} options - additional options
169
173
  */
170
174
  #aspectify(fq, entity, buffer, options = {}) {
171
- const info = this.entityRepository.getByFq(fq)
172
- if (!info) throw new Error(`could not resolve entity ${fq}`)
175
+ const info = this.entityRepository.getByFqOrThrow(fq)
173
176
  const clean = options?.cleanName ?? info.withoutNamespace
174
177
  const { namespace } = info
175
178
  const file = this.fileRepository.getNamespaceFile(namespace)
@@ -257,10 +260,16 @@ class Visitor {
257
260
  }
258
261
  }
259
262
 
263
+ if ('kind' in entity) {
264
+ buffer.addIndented([`static readonly kind: 'entity' | 'type' | 'aspect' = '${entity.kind}';`])
265
+ }
266
+
260
267
  buffer.addIndented(() => {
261
268
  for (const e of enums) {
269
+ const eDoc = docify(e.doc)
270
+ eDoc.forEach(d => { buffer.add(d) })
262
271
  buffer.add(`static ${e.name} = ${propertyToInlineEnumName(clean, e.name)}`)
263
- file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}))
272
+ file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}), eDoc)
264
273
  }
265
274
  this.#printStaticActions(entity, buffer, ancestorInfos, file)
266
275
  })
@@ -269,6 +278,7 @@ class Visitor {
269
278
 
270
279
  // CLASS WITH ADDED ASPECTS
271
280
  file.addImport(baseDefinitions.path)
281
+ docify(entity.doc).forEach(d => { buffer.add(d) })
272
282
  buffer.add(`export class ${identSingular(clean)} extends ${identAspect(toLocalIdent({clean, fq}))}(${baseDefinitions.path.asIdentifier()}.Entity) {${this.#staticClassContents(clean, entity).join('\n')}}`)
273
283
  this.contexts.pop()
274
284
  }
@@ -292,9 +302,7 @@ class Visitor {
292
302
  * @param {string} content - the content to set the name property to
293
303
  */
294
304
  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
305
+ const { namespace: ns, entityName: clean, inflection } = this.entityRepository.getByFqOrThrow(fq)
298
306
  const file = this.fileRepository.getNamespaceFile(ns)
299
307
  let { singular, plural } = inflection
300
308
 
@@ -330,7 +338,6 @@ class Visitor {
330
338
  // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
331
339
  // edge case: @singular annotation present. singular4 will take care of that.
332
340
  file.addInflection(util.singular4(entity, true), plural, clean)
333
- docify(entity.doc).forEach(d => { buffer.add(d) })
334
341
 
335
342
  // in case of projections `entity` is empty -> retrieve from inferred csn where the actual properties are rolled out
336
343
  const target = isProjection(entity) || isView(entity)
@@ -357,6 +364,7 @@ class Visitor {
357
364
  // so it can get passed as value to CQL functions.
358
365
  const additionalProperties = this.#staticClassContents(singular, entity)
359
366
  additionalProperties.push('$count?: number')
367
+ docify(entity.doc).forEach(d => { buffer.add(d) })
360
368
  buffer.add(`export class ${plural} extends Array<${singular}> {${additionalProperties.join('\n')}}`)
361
369
  buffer.add(overrideNameProperty(plural, entity.name))
362
370
  }
@@ -369,18 +377,18 @@ class Visitor {
369
377
  * Also filters out parameters that indicate a binding parameter ({@link https://cap.cloud.sap/docs/releases/jan23#simplified-syntax-for-binding-parameters}).
370
378
  * @param {{[key:string]: EntityCSN}} params - parameter list as found in CSN.
371
379
  * @param {SourceFile} file - source file relative to which the parameter types should be resolved.
372
- * @returns {[string, string][]} pair of names and types.
380
+ * @returns {import('./typedefs').visitor.ParamInfo[]} tuple of name, modifier, type and doc.
373
381
  */
374
382
  #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
- : []
383
+ return Object.entries(params ?? {})
384
+ // filter params of type '[many] $self', as they are not to be part of the implementation
385
+ .filter(([, type]) => type?.type !== '$self' && type.items?.type !== '$self')
386
+ .map(([name, type]) => ({
387
+ name,
388
+ modifier: this.resolver.isOptional(type) ? '?' : '',
389
+ type: this.#stringifyFunctionParamType(type, file),
390
+ doc: docify(type.doc).join('\n'),
391
+ }))
384
392
  }
385
393
 
386
394
  /**
@@ -408,21 +416,27 @@ class Visitor {
408
416
  */
409
417
  #printOperation(fq, operation, kind) {
410
418
  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
419
+ const { namespace } = this.entityRepository.getByFqOrThrow(fq)
414
420
  const file = this.fileRepository.getNamespaceFile(namespace)
415
421
  const params = this.#stringifyFunctionParams(operation.params, file)
416
422
  const returnType = operation.returns
417
423
  ? this.resolver.resolveAndRequire(operation.returns, file)
418
424
  : { typeName: 'void', typeInfo: { plainName: 'void', isArray: false, inflection: { singular: 'void', plural: 'void' } } }
419
- const returns = this.inlineDeclarationResolver.getPropertyDatatype(
425
+ let returns = this.inlineDeclarationResolver.getPropertyDatatype(
420
426
  returnType,
421
427
  returnType.typeInfo.isArray
422
428
  ? returnType.typeName
423
429
  : returnType.typeInfo.inflection.singular
424
430
  )
425
- file.addOperation(last(fq), params, returns, kind)
431
+ if (operation.returns) {
432
+ // operation results may be a Promise
433
+ returns = createUnionOf(createPromiseOf(returns), returns)
434
+ }
435
+ // Actions for ABAP RFC modules have 'parameter categories' (import/export/changing/tables) that cannot be called in a flat order.
436
+ // Prevent positional call style there.
437
+ // TODO find a better way to detect ABAP RFC actions
438
+ const isRFC = Object.values(operation.params ?? {}).some(p => Object.keys(p).some(k => k.startsWith('@RFC')))
439
+ file.addOperation(last(fq), params, returns, kind, docify(operation.doc), {named: true, positional: !isRFC})
426
440
  }
427
441
 
428
442
  /**
@@ -431,15 +445,13 @@ class Visitor {
431
445
  */
432
446
  #printType(fq, type) {
433
447
  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
448
+ const { namespace, entityName } = this.entityRepository.getByFqOrThrow(fq)
437
449
  const file = this.fileRepository.getNamespaceFile(namespace)
438
450
  // skip references to enums.
439
451
  // "Base" enums will always have a builtin type (don't skip those).
440
452
  // A type referencing an enum E will be considered an enum itself and have .type === E (skip).
441
453
  if (isEnum(type) && !isReferenceType(type) && this.resolver.builtinResolver.resolveBuiltin(type.type)) {
442
- file.addEnum(fq, entityName, csnToEnumPairs(type))
454
+ file.addEnum(fq, entityName, csnToEnumPairs(type), docify(type.doc))
443
455
  } else {
444
456
  const isEnumReference = typeof type.type === 'string' && isEnum(this.csn.inferred.definitions[type?.type])
445
457
  // alias
@@ -454,9 +466,7 @@ class Visitor {
454
466
  */
455
467
  #printAspect(fq, aspect) {
456
468
  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
469
+ const { namespace, entityName, inflection } = this.entityRepository.getByFqOrThrow(fq)
460
470
  const file = this.fileRepository.getNamespaceFile(namespace)
461
471
  // aspects are technically classes and can therefore be added to the list of defined classes.
462
472
  // Still, when using them as mixins for a class, they need to already be defined.
@@ -471,10 +481,7 @@ class Visitor {
471
481
  * @param {EntityCSN} event - CSN
472
482
  */
473
483
  #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
484
+ const { namespace, entityName } = this.entityRepository.getByFqOrThrow(fq)
478
485
  const file = this.fileRepository.getNamespaceFile(namespace)
479
486
  file.addEvent(entityName, fq)
480
487
  const buffer = file.events.buffer
@@ -492,16 +499,25 @@ class Visitor {
492
499
 
493
500
  /**
494
501
  * @param {string} fq - fully qualified name of the service
495
- * @param {EntityCSN} service - CSN
502
+ * @param {import('./typedefs').resolver.EntityCSN} service - CSN
496
503
  */
497
504
  #printService(fq, service) {
498
505
  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
506
+ const { namespace } = this.entityRepository.getByFqOrThrow(fq)
502
507
  const file = this.fileRepository.getNamespaceFile(namespace)
503
- // service.name is clean of namespace
504
- file.services.buffer.add(`export default { name: '${service.name}' }`)
508
+ const buffer = file.services.buffer
509
+ const serviceNameSimple = service.name.split('.').pop()
510
+
511
+ docify(service.doc).forEach(d => { buffer.add(d) })
512
+ // file.addImport(new Path(['cds'], '')) TODO make sap/cds import work
513
+ buffer.addIndentedBlock(`export class ${serviceNameSimple} extends cds.Service {`, () => {
514
+ Object.entries(service.operations ?? {}).forEach(([name, {doc}]) => {
515
+ docify(doc).forEach(d => { buffer.add(d) })
516
+ buffer.add(`declare ${name}: typeof ${name}`)
517
+ })
518
+ }, '}')
519
+ buffer.add(`export default ${serviceNameSimple}`)
520
+ buffer.add('')
505
521
  file.addService(service.name)
506
522
  }
507
523
 
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.26.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"