@cap-js/cds-typer 0.2.5-beta.1 → 0.4.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/file.js CHANGED
@@ -101,11 +101,11 @@ class SourceFile extends File {
101
101
  this.preamble = new Buffer()
102
102
  /** @type {Buffer} */
103
103
  this.types = new Buffer()
104
- /** @type {Buffer} */
105
- this.enums = new Buffer()
104
+ /** @type {{ buffer: Buffer, fqs: {name: string, fq: string}[]}} */
105
+ this.enums = { buffer: new Buffer(), fqs: [] }
106
106
  /** @type {Buffer} */
107
107
  this.classes = new Buffer()
108
- /** @type {{ buffer: Buffer, names: string[]} */
108
+ /** @type {{ buffer: Buffer, names: string[]}} */
109
109
  this.actions = { buffer: new Buffer(), names: [] }
110
110
  /** @type {Buffer} */
111
111
  this.aspects = new Buffer()
@@ -136,6 +136,19 @@ class SourceFile extends File {
136
136
  this.inflections.push([singular, plural, original])
137
137
  }
138
138
 
139
+ /**
140
+ * Adds a function definition in form of a arrow function to the file.
141
+ * @param {string} name name of the function
142
+ * @param {{relative: string | undefined, local: boolean, posix: boolean}} params list of parameters, passed as [name, type] pairs
143
+ * @param returns the return type of the function
144
+ */
145
+ addFunction(name, params, returns) {
146
+ // FIXME: use different buffers for buffers and actions, or at least rename buffer to the more general category "functions"?
147
+ this.actions.buffer.add("// function")
148
+ this.actions.buffer.add(`export declare const ${SourceFile.stringifyLambda(name, params, returns)};`)
149
+ this.actions.names.push(name)
150
+ }
151
+
139
152
  /**
140
153
  * Adds an action definition in form of a arrow function to the file.
141
154
  * @param {string} name name of the action
@@ -178,13 +191,26 @@ class SourceFile extends File {
178
191
  * @param {[string, string][]} kvs list of key-value pairs
179
192
  */
180
193
  addEnum(fq, name, kvs) {
181
- this.enums.add(`export enum ${name} {`)
182
- this.enums.indent()
194
+ // CDS differ from TS enums as they can use bools as value (TS: only number and string)
195
+ // So we have to emulate enums by adding an object (name -> value mappings)
196
+ // and a type containing all disctinct values.
197
+ // We can get away with this as TS doesn't feature nominal typing, so the structure
198
+ // is all we care about.
199
+ this.enums.fqs.push({ name, fq })
200
+ const bu = this.enums.buffer
201
+ bu.add('// enum')
202
+ bu.add(`export const ${name} = {`)
203
+ bu.indent()
204
+ const vals = new Set()
183
205
  for (const [k, v] of kvs) {
184
- this.enums.add(`${k} = ${v},`)
206
+ bu.add(`${k}: ${v},`)
207
+ vals.add(v)
185
208
  }
186
- this.enums.outdent()
187
- this.enums.add('}')
209
+ bu.outdent()
210
+ bu.add('}')
211
+ bu.add(`export type ${name} = ${[...vals].join(' | ')}`)
212
+ bu.add('')
213
+
188
214
  }
189
215
 
190
216
  /**
@@ -259,7 +285,7 @@ class SourceFile extends File {
259
285
  this.getImports().join(),
260
286
  this.preamble.join(),
261
287
  this.types.join(),
262
- this.enums.join(),
288
+ this.enums.buffer.join(),
263
289
  namespaces.join(),
264
290
  this.aspects.join(), // needs to be before classes
265
291
  this.classes.join(),
@@ -287,6 +313,8 @@ class SourceFile extends File {
287
313
  ) // singular -> plural aliases
288
314
  .concat(['// actions'])
289
315
  .concat(this.actions.names.map(name => `module.exports.${name} = '${name}'`))
316
+ .concat(['// enums'])
317
+ .concat(this.enums.fqs.map(({fq, name}) => `module.exports.${name} = Object.fromEntries(Object.entries(cds.model.definitions['${fq}'].enum).map(([k,v]) => [k,v.val]))`))
290
318
  .join('\n') + '\n'
291
319
  }
292
320
  }
@@ -428,6 +456,7 @@ class Path {
428
456
  * @type {SourceFile}
429
457
  */
430
458
  const baseDefinitions = new SourceFile('_')
459
+ // FIXME: this should be a library someday
431
460
  baseDefinitions.addPreamble(`
432
461
  export namespace Association {
433
462
  export type to <T> = T;
package/lib/logging.js CHANGED
@@ -16,15 +16,16 @@ class Logger {
16
16
  for (let i = 0; i < lvls.length - 1; i++) {
17
17
  // -1 to ignore NONE
18
18
  const level = lvls[i]
19
- this[level.toLowerCase()] = function (message) {
20
- this._log(level, message)
21
- }.bind(this)
19
+ this[level.toLowerCase()] = function (message) { this._log(level, message) }.bind(this)
22
20
  }
23
21
  }
24
22
 
23
+ // only temporarily to disable those warnings...
24
+ //warning(s) {}; error(s) {}; info(s) {}; debug(s) {};
25
+
25
26
  /**
26
27
  * Add all log levels starting at level.
27
- * @param {number} level level to start from.
28
+ * @param {number} baseLevel level to start from.
28
29
  */
29
30
  addFrom(baseLevel) {
30
31
  const vals = Object.values(Levels)
package/lib/util.js CHANGED
@@ -61,9 +61,9 @@ const unlocalize = (name) => {
61
61
  * Attempts to derive the singular form of an English noun.
62
62
  * If '@singular' is passed as annotation, that is preferred.
63
63
  * @param {Annotations} dn annotations
64
- * @param {boolean} stripped if true, leading namespace will be stripped
64
+ * @param {boolean?} stripped if true, leading namespace will be stripped
65
65
  */
66
- const singular4 = (dn, stripped) => {
66
+ const singular4 = (dn, stripped = false) => {
67
67
  let n = dn.name || dn
68
68
  if (stripped) {
69
69
  n = n.match(last)[0]
@@ -184,6 +184,48 @@ const parseCommandlineArgs = (argv, validFlags) => {
184
184
  }
185
185
  }
186
186
 
187
+ /**
188
+ * Entities inherit their ancestors annotations:
189
+ * https://cap.cloud.sap/docs/cds/cdl#annotation-propagation
190
+ * This is a problem if we annotate @singular/ @plural to an entity A,
191
+ * as we don't want all descendents B, C, ... to share the ancestor's
192
+ * annotated inflexion
193
+ * -> remove all such annotations that appear in a parent as well.
194
+ * BUT: we can't just delete the attributes. Imagine three classes
195
+ * A <- B <- C
196
+ * where A contains a @singular annotation.
197
+ * If we erase the annotation from B, C will still contain it and
198
+ * can not detect that its own annotation was inherited without
199
+ * travelling up the entire inheritance chain up to A.
200
+ * So instead, we monkey patch and maintain a dictionary "erased"
201
+ * when removing an annotation which we also check.
202
+ */
203
+ function fixCSN(csn) {
204
+ const erase = (entity, parent, attr) => {
205
+ if (attr in entity) {
206
+ const ea = entity[attr]
207
+ if (parent[attr] === ea || (parent.erased && parent.erased[attr] === ea)) {
208
+ entity.erased ??= {}
209
+ entity.erased[attr] = ea
210
+ delete entity[attr]
211
+ //this.logger.info(`Removing inherited attribute ${attr} from ${entity.name}.`)
212
+ }
213
+ }
214
+ }
215
+
216
+ for (const entity of Object.values(csn.definitions)) {
217
+ let i = 0
218
+ while (
219
+ (getSingularAnnotation(entity) || getPluralAnnotation(entity)) &&
220
+ i < (entity.includes ?? []).length
221
+ ) {
222
+ const parent = csn.definitions[entity.includes[i]]
223
+ Object.values(annotations).flat().forEach(an => erase(entity, parent, an))
224
+ i++
225
+ }
226
+ }
227
+ }
228
+
187
229
  module.exports = {
188
230
  annotations,
189
231
  getSingularAnnotation,
@@ -193,4 +235,5 @@ module.exports = {
193
235
  plural4,
194
236
  parseCommandlineArgs,
195
237
  deepMerge,
238
+ fixCSN
196
239
  }
package/lib/visitor.js ADDED
@@ -0,0 +1,353 @@
1
+ 'use strict'
2
+
3
+ const util = require('./util')
4
+
5
+ const { SourceFile, baseDefinitions, Buffer } = require('./file')
6
+ const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline')
7
+ const { Resolver } = require('./components/resolver')
8
+ const { Logger } = require('./logging')
9
+ const { docify } = require('./components/wrappers')
10
+
11
+ /** @typedef {import('./file').File} File */
12
+ /** @typedef {{ entity: String }} Context */
13
+
14
+ /**
15
+ * @typedef { {
16
+ * rootDirectory: string,
17
+ * logLevel: number,
18
+ * jsConfigPath?: string
19
+ * }} CompileParameters
20
+ */
21
+
22
+ /**
23
+ * - `propertiesOptional = true` -> all properties are generated as optional ?:. (standard CAP behaviour, where properties be unavailable)
24
+ * - `inlineDeclarations = 'structured'` -> @see inline.StructuredInlineDeclarationResolver
25
+ * - `inlineDeclarations = 'flat'` -> @see inline.FlatInlineDeclarationResolver
26
+ * @typedef { {
27
+ * propertiesOptional: boolean,
28
+ * inlineDeclarations: 'flat' | 'structured',
29
+ * }} VisitorOptions
30
+ */
31
+
32
+ /**
33
+ * @typedef {{
34
+ * typeName: string,
35
+ * singular: string,
36
+ * plural: string
37
+ * }} Inflection
38
+ */
39
+
40
+ const defaults = {
41
+ // FIXME: add defaults for remaining parameters
42
+ propertiesOptional: true,
43
+ inlineDeclarations: 'flat'
44
+ }
45
+
46
+ class Visitor {
47
+ /**
48
+ * Gathers all files that are supposed to be written to
49
+ * the type directory. Including generated source files,
50
+ * as well as library files.
51
+ * @returns {File[]} a full list of files to be written
52
+ */
53
+ getWriteoutFiles() {
54
+ return Object.values(this.files).concat(this.resolver.getUsedLibraries())
55
+ }
56
+
57
+ /**
58
+ * @param csn root CSN
59
+ * @param {VisitorOptions} options
60
+ */
61
+ constructor(csn, options = {}, logger = new Logger()) {
62
+ util.fixCSN(csn)
63
+ this.options = { ...defaults, ...options }
64
+ this.logger = logger
65
+ this.csn = csn
66
+
67
+ /** @type {Context[]} **/
68
+ this.contexts = []
69
+
70
+ /** @type {Resolver} */
71
+ this.resolver = new Resolver(this)
72
+
73
+ /** @type {Object<string, File>} */
74
+ this.files = {}
75
+ this.files[baseDefinitions.path.asIdentifier()] = baseDefinitions
76
+ this.inlineDeclarationResolver =
77
+ this.options.inlineDeclarations === 'structured'
78
+ ? new StructuredInlineDeclarationResolver(this)
79
+ : new FlatInlineDeclarationResolver(this)
80
+
81
+ this.visitDefinitions()
82
+ }
83
+
84
+ /**
85
+ * Determines the file corresponding to the namespace.
86
+ * If no such file exists yet, it is created first.
87
+ * @param {string} path the name of the namespace (foo.bar.baz)
88
+ * @returns {SourceFile} the file corresponding to that namespace name
89
+ */
90
+ getNamespaceFile(path) {
91
+ return (this.files[path] ??= new SourceFile(path))
92
+ }
93
+
94
+ /**
95
+ * Visits all definitions within the CSN definitions.
96
+ */
97
+ visitDefinitions() {
98
+ for (const [name, entity] of Object.entries(this.csn.definitions)) {
99
+ this.visitEntity(name, entity)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Transforms an entity or CDS aspect into a JS aspect (aka mixin).
105
+ * That is, for an element A we get:
106
+ * - the function A(B) to mix the aspect into another class B
107
+ * - the const AXtended which represents the entity A with all of its aspects mixed in (this const is not exported)
108
+ * - the type A to use for external typing and is derived from AXtended.
109
+ * @param {string} name the name of the entity
110
+ * @param {CSN} element the pointer into the CSN to extract the elements from
111
+ * @param {Buffer} buffer the buffer to write the resulting definitions into
112
+ * @param {string?} cleanName the clean name to use. If not passed, it is derived from the passed name instead.
113
+ */
114
+ _aspectify(name, entity, buffer, cleanName = undefined) {
115
+ const clean = cleanName ?? this.resolver.trimNamespace(name)
116
+ const ns = this.resolver.resolveNamespace(name.split('.'))
117
+ const file = this.getNamespaceFile(ns)
118
+
119
+ const identSingular = (name) => name
120
+ const identAspect = (name) => `_${name}Aspect`
121
+
122
+ this.contexts.push({
123
+ entity: name,
124
+ })
125
+
126
+ // CLASS ASPECT
127
+ buffer.add(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => any>(Base: TBase) {`)
128
+ buffer.indent()
129
+ buffer.add(`return class ${clean} extends Base {`)
130
+ buffer.indent()
131
+ for (const [ename, element] of Object.entries(entity.elements ?? {})) {
132
+ this.visitElement(ename, element, file, buffer)
133
+ }
134
+ for (const [aname, action] of Object.entries(entity.actions ?? {})) {
135
+ buffer.add(
136
+ SourceFile.stringifyLambda(
137
+ aname,
138
+ Object.entries(action.params ?? {}).map(([n, t]) => [
139
+ n,
140
+ this.resolver.resolveAndRequire(t, file).typeName,
141
+ ]),
142
+ action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
143
+ )
144
+ )
145
+ //this.visitEntity(aname, action, file, buffer)
146
+ }
147
+ buffer.outdent()
148
+ buffer.add('};')
149
+ buffer.outdent()
150
+ buffer.add('}')
151
+
152
+ // CLASS WITH ADDED ASPECTS
153
+ file.addImport(baseDefinitions.path)
154
+ const rhs = (entity.includes ?? [])
155
+ .map((parent) => {
156
+ const [ns, n] = this.resolver.untangle(parent)
157
+ file.addImport(ns)
158
+ return [ns, n]
159
+ })
160
+ .concat([[undefined, clean]]) // add own aspect without namespace AFTER imports were created
161
+ .reverse() // reverse so that own aspect A is applied before extensions B,C: B(C(A(Entity)))
162
+ .reduce(
163
+ (wrapped, [ns, n]) =>
164
+ !ns || ns.isCwd(file.path.asDirectory())
165
+ ? `${identAspect(n)}(${wrapped})`
166
+ : `${ns.asIdentifier()}.${identAspect(n)}(${wrapped})`,
167
+ `${baseDefinitions.path.asIdentifier()}.Entity`
168
+ )
169
+
170
+ buffer.add(`export class ${identSingular(clean)} extends ${rhs} {}`)
171
+ //buffer.add(`export type ${clean} = InstanceType<typeof ${identSingular(clean)}>`)
172
+ this.contexts.pop()
173
+ }
174
+
175
+ #printEntity(name, entity) {
176
+ const clean = this.resolver.trimNamespace(name)
177
+ const ns = this.resolver.resolveNamespace(name.split('.'))
178
+ const file = this.getNamespaceFile(ns)
179
+ // entities are expected to be in plural anyway, so we would favour the regular name.
180
+ // If the user decides to pass a @plural annotation, that gets precedence over the regular name.
181
+ let plural = util.unlocalize(
182
+ this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
183
+ )
184
+ const singular = util.unlocalize(util.singular4(entity, true))
185
+ if (singular === plural) {
186
+ plural += '_'
187
+ this.logger.warning(
188
+ `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.`
189
+ )
190
+ }
191
+ if (singular in this.csn.definitions) {
192
+ this.logger.error(
193
+ `Derived singular '${singular}' for your entity '${name}', already exists. The resulting types will be erronous. Please consider using '@singular:'/ '@plural:' annotations in your model to resolve this collision.`
194
+ )
195
+ }
196
+ file.addClass(singular, name)
197
+ file.addClass(plural, name)
198
+
199
+ const parent = this.resolver.resolveParent(entity.name)
200
+ const buffer =
201
+ parent && parent.kind === 'entity'
202
+ ? file.getSubNamespace(this.resolver.trimNamespace(parent.name))
203
+ : file.classes
204
+
205
+ // we can't just use "singular" here, as it may have the subnamespace removed:
206
+ // "Books.text" is just "text" in "singular". Within the inflected exports we need
207
+ // to have Books.texts = Books.text, so we derive the singular once more without cutting off the ns.
208
+ // Directly deriving it from the plural makes sure we retain any parent namespaces of kind "entity",
209
+ // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
210
+ file.addInflection(util.singular4(plural), plural, clean)
211
+ if ('doc' in entity) {
212
+ docify(entity.doc).forEach((d) => buffer.add(d))
213
+ }
214
+
215
+ this._aspectify(name, entity, file.classes, singular)
216
+
217
+ // PLURAL
218
+ if (plural.includes('.')) {
219
+ // Foo.text -> namespace Foo { class text { ... }}
220
+ plural = plural.split('.').pop()
221
+ }
222
+ // plural can not be a type alias to $singular[] but needs to be a proper class instead,
223
+ // so it can get passed as value to CQL functions.
224
+ buffer.add(`export class ${plural} extends Array<${singular}> {}`)
225
+ buffer.add('')
226
+ }
227
+
228
+ #printFunction(name, func) {
229
+ // FIXME: mostly duplicate of printAction -> reuse
230
+ this.logger.debug(`Printing function ${name}:\n${JSON.stringify(func, null, 2)}`)
231
+ const ns = this.resolver.resolveNamespace(name.split('.'))
232
+ const file = this.getNamespaceFile(ns)
233
+ const params = func.params
234
+ ? Object.entries(func.params).map(([pname, ptype]) => [
235
+ pname,
236
+ this.resolver.resolveAndRequire(ptype, file).typeName,
237
+ ])
238
+ : []
239
+ const returns = this.resolver.resolveAndRequire(func.returns, file).typeName
240
+ file.addFunction(name.split('.').at(-1), params, returns)
241
+ }
242
+
243
+ #printAction(name, action) {
244
+ this.logger.debug(`Printing action ${name}:\n${JSON.stringify(action, null, 2)}`)
245
+ const ns = this.resolver.resolveNamespace(name.split('.'))
246
+ const file = this.getNamespaceFile(ns)
247
+ const params = action.params
248
+ ? Object.entries(action.params).map(([pname, ptype]) => [
249
+ pname,
250
+ this.resolver.resolveAndRequire(ptype, file).typeName,
251
+ ])
252
+ : []
253
+ const returns = this.resolver.resolveAndRequire(action.returns, file).typeName
254
+ file.addAction(name.split('.').at(-1), params, returns)
255
+ }
256
+
257
+ #printType(name, type) {
258
+ this.logger.debug(`Printing type ${name}:\n${JSON.stringify(type, null, 2)}`)
259
+ const clean = this.resolver.trimNamespace(name)
260
+ const ns = this.resolver.resolveNamespace(name.split('.'))
261
+ const file = this.getNamespaceFile(ns)
262
+ if ('enum' in type) {
263
+ // in case of strings, wrap in quotes and fallback to key to make sure values are attached for every key
264
+ const val = (k,v) => type.type === 'cds.String' ? `"${v ?? k}"` : v
265
+ file.addEnum(
266
+ name,
267
+ clean,
268
+ Object.entries(type.enum).map(([k, v]) => [k, val(k, v.val)])
269
+ )
270
+ } else {
271
+ // alias
272
+ file.addType(name, clean, this.resolver.resolveAndRequire(type, file).typeName)
273
+ }
274
+ // TODO: annotations not handled yet
275
+ }
276
+
277
+ #printAspect(name, aspect) {
278
+ this.logger.debug(`Printing aspect ${name}`)
279
+ const clean = this.resolver.trimNamespace(name)
280
+ const ns = this.resolver.resolveNamespace(name.split('.'))
281
+ const file = this.getNamespaceFile(ns)
282
+ // aspects are technically classes and can therefore be added to the list of defined classes.
283
+ // Still, when using them as mixins for a class, they need to already be defined.
284
+ // So we separate them into another buffer which is printed before the classes.
285
+ file.addClass(clean, name)
286
+ file.aspects.add(`// the following represents the CDS aspect '${clean}'`)
287
+ this._aspectify(name, aspect, file.aspects, clean)
288
+ }
289
+
290
+ /**
291
+ * Visits a single entity from the CSN's definition field.
292
+ * Will call #printEntity or #printAction based on the entity's kind.
293
+ * @param {string} name name of the entity, fully qualified as is used in the definition field.
294
+ * @param {CSN} entity CSN data belonging to the entity to perform lookups in.
295
+ */
296
+ visitEntity(name, entity) {
297
+ switch (entity.kind) {
298
+ case 'entity':
299
+ this.#printEntity(name, entity)
300
+ break
301
+ case 'action':
302
+ this.#printFunction(name, entity)
303
+ break
304
+ case 'function':
305
+ this.#printAction(name, entity)
306
+ break
307
+ case 'type':
308
+ this.#printType(name, entity)
309
+ break
310
+ case 'aspect':
311
+ this.#printAspect(name, entity)
312
+ break
313
+ default:
314
+ this.logger.error(`Unhandled entity kind '${entity.kind}'.`)
315
+ }
316
+ }
317
+
318
+ /**
319
+ * A self reference is a property that references the class it appears in.
320
+ * They need to be detected on CDS level, as the emitted TS types will try to
321
+ * refer to refer to types via their alias that hides the aspectification.
322
+ * If we attempt to directly refer to this alias while it has not been fully created,
323
+ * that will result in a TS error.
324
+ * @param {String} entityName
325
+ * @returns {boolean} true, if `entityName` refers to the surrounding class
326
+ * @example
327
+ * ```ts
328
+ * class TreeNode {
329
+ * value: number
330
+ * parent: TreeNode // <- self reference
331
+ * }
332
+ * ```
333
+ */
334
+ isSelfReference(entityName) {
335
+ return entityName === this.contexts.at(-1)?.entity
336
+ }
337
+
338
+ /**
339
+ * Visits a single element in an entity.
340
+ * @param {string} name name of the element
341
+ * @param {import('./components/resolver').CSN} element CSN data belonging to the the element.
342
+ * @param {SourceFile} file the namespace file the surrounding entity is being printed into.
343
+ * @param {Buffer} buffer buffer to add the definition to. If no buffer is passed, the passed file's class buffer is used instead.
344
+ * @returns @see InlineDeclarationResolver.visitElement
345
+ */
346
+ visitElement(name, element, file, buffer) {
347
+ return this.inlineDeclarationResolver.visitElement(name, element, file, buffer)
348
+ }
349
+ }
350
+
351
+ module.exports = {
352
+ Visitor
353
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.2.5-beta.1",
3
+ "version": "0.4.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
  "homepage": "https://cap.cloud.sap/",