@cap-js/cds-typer 0.26.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/CHANGELOG.md CHANGED
@@ -4,10 +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.27.0 - TBD
7
+ ## Version 0.28.0 - TBD
8
+
9
+ ## Version 0.27.0 - 2024-10-02
10
+ ### Changed
11
+ - Any configuration variable (via CLI or `cds.env`) can now be passed in snake_case in addition to camelCase
12
+ - Action parameters are now generated as optional by default, which is how the runtime treats them. Mandatory parameters have to be marked as `not null` in CDS/CDL, or `notNull` in CSN.
13
+
14
+ ### Fixed
15
+ - Fix build task for projects with spaces
16
+ - Fixa bug where cds-typer would produce redundant type declarations when the model contains an associations to another entity's property
8
17
 
9
18
  ## Version 0.26.0 - 2024-09-11
10
19
  ### Added
20
+ - Added a static `.keys` property in all entities. That property is dictionary which holds all properties as keys that are marked as `key` in CDS
11
21
  - 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
22
  - Added a static `.kind` property for entities and types, which contains `'entity'` or `'type'` respectively
13
23
  - Apps need to provide `@sap/cds` version `8.2` or higher.
@@ -47,6 +57,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
47
57
 
48
58
 
49
59
  ## Version 0.22.0 - 2024-06-20
60
+
50
61
  ### Fixed
51
62
  - Fixed a bug where keys would sometimes inconsistently become nullable
52
63
 
package/cds-plugin.js CHANGED
@@ -36,7 +36,7 @@ const rmDirIfExists = dir => {
36
36
  * Remove files with given extensions from a directory recursively.
37
37
  * @param {string} dir - The directory to start from.
38
38
  * @param {string[]} exts - The extensions to remove.
39
- * @returns {Promise<void>}
39
+ * @returns {Promise<unknown>}
40
40
  */
41
41
  const rmFiles = async (dir, exts) => fs.existsSync(dir)
42
42
  ? Promise.all(
@@ -68,10 +68,15 @@ cds.build?.register?.('typescript', class extends cds.build.Plugin {
68
68
 
69
69
  get #appFolder () { return cds?.env?.folders?.app ?? 'app' }
70
70
 
71
+ /**
72
+ * cds.env > tsconfig.compilerOptions.paths > '@cds-models' (default)
73
+ */
71
74
  get #modelDirectoryName () {
75
+ const outputDirectory = cds.env.typer?.outputDirectory
76
+ if (outputDirectory) return outputDirectory
72
77
  try {
73
78
  // expected format: { '#cds-models/*': [ './@cds-models/*/index.ts' ] }
74
- // ^^^^^^^^^^^^^^^
79
+ // ^^^^^^^^^^^
75
80
  // relevant part - may be changed by user
76
81
  const config = JSON.parse(fs.readFileSync ('tsconfig.json', 'utf8'))
77
82
  const alias = config.compilerOptions.paths['#cds-models/*'][0]
@@ -89,7 +94,9 @@ cds.build?.register?.('typescript', class extends cds.build.Plugin {
89
94
 
90
95
  async #runCdsTyper () {
91
96
  DEBUG?.('running cds-typer')
92
- await typer.compileFromFile('*', { outputDirectory: this.#modelDirectoryName })
97
+ cds.env.typer ??= {}
98
+ cds.env.typer.outputDirectory ??= this.#modelDirectoryName
99
+ await typer.compileFromFile('*')
93
100
  }
94
101
 
95
102
  async #buildWithConfig () {
@@ -103,7 +110,7 @@ cds.build?.register?.('typescript', class extends cds.build.Plugin {
103
110
  DEBUG?.('building without config')
104
111
  // this will include gen/ that was created by the nodejs task
105
112
  // _within_ the project directory. So we need to remove it afterwards.
106
- await exec(`npx tsc --outDir ${this.task.dest}`)
113
+ await exec(`npx tsc --outDir "${this.task.dest}"`)
107
114
  rmDirIfExists(path.join(this.task.dest, cds.env.build.target))
108
115
  rmDirIfExists(path.join(this.task.dest, this.#appFolder))
109
116
  }
package/lib/cli.js CHANGED
@@ -2,20 +2,147 @@
2
2
  /* eslint-disable no-console */
3
3
  'use strict'
4
4
 
5
+ /**
6
+ * @typedef {import('./typedefs').config.cli.ParameterSchema[number]} Parameter
7
+ */
8
+
5
9
  const cds = require('@sap/cds')
6
10
  const { compileFromFile } = require('./compile')
7
- const { parseCommandlineArgs } = require('./util')
11
+ const { camelToSnake } = require('./util')
8
12
  const { deprecated, _keyFor } = require('./logging')
9
13
  const path = require('path')
10
14
  const { EOL } = require('node:os')
15
+ const { camelSnakeHybrid, configuration } = require('./config')
11
16
 
12
17
  const EOL2 = EOL + EOL
13
18
  const toolName = 'cds-typer'
14
-
15
19
  // @ts-expect-error - nope, it is actually there. Types just seem to be out of sync.
16
20
  const lls = cds.log.levels
21
+ const parameterTypes = {
22
+ boolean:
23
+ /**
24
+ * @param {Parameter} props - additional parameter properties
25
+ * @returns {Parameter}
26
+ */
27
+ props => ({...{
28
+ allowed: ['true', 'false'],
29
+ type: 'boolean',
30
+ postprocess: (/** @type {string} */ value) => value === 'true'
31
+ },
32
+ ...props})
33
+ }
34
+
35
+ /**
36
+ * Adds additional properties to the CLI parameter schema.
37
+ * @param {import('./typedefs').config.cli.ParameterSchema } flags - The CLI parameter schema.
38
+ * @returns {import('./typedefs').config.cli.ParameterSchema} - The enriched schema.
39
+ */
40
+ const enrichFlagSchema = flags => {
41
+ const flagKeys = Object.keys(flags)
42
+ for (const [key, value] of Object.entries(flags)) {
43
+ /** @type {Parameter} */(value).camel = key;
44
+ /** @type {Parameter} */(value).snake = camelToSnake(key)
45
+ }
46
+ // non-enumerable utilities
47
+ Object.defineProperties(flags, {
48
+ hasFlag: {
49
+ value: (/** @type {string} **/ flag) => Object.values(flags).some(f => f.snake === flag || f.camel === flag)
50
+ },
51
+ keys: {
52
+ value: flagKeys
53
+ }
54
+ })
55
+ return camelSnakeHybrid(flags)
56
+ }
57
+
58
+ /**
59
+ * Parses command line arguments into named and positional parameters.
60
+ * Named parameters are expected to start with a double dash (--).
61
+ * If the next argument `B` after a named parameter `A` is not a named parameter itself,
62
+ * `B` is used as value for `A`.
63
+ * If `A` and `B` are both named parameters, `A` is just treated as a flag (and may receive a default value).
64
+ * Only named parameters that occur in validFlags are allowed. Specifying named flags that are not listed there
65
+ * will cause an error.
66
+ * Named parameters that are either not specified or do not have a value assigned to them may draw a default value
67
+ * from their definition in validFlags.
68
+ * @param {string[]} argv - list of command line arguments
69
+ * @param {import('./typedefs').config.cli.ParameterSchema} schema - allowed flags. May specify default values.
70
+ * @returns {import('./typedefs').config.cli.ParsedParameters} - parsed arguments
71
+ */
72
+ const parseCommandlineArgs = (argv, schema) => {
73
+ const isFlag = (/** @type {string} */ arg) => arg.startsWith('--')
74
+ const positional = []
75
+ /** @type {import('./typedefs').config.cli.ParsedParameters['named']} */
76
+ const named = {}
77
+
78
+ let i = 0
79
+ while (i < argv.length) {
80
+ const originalArgName = argv[i] // so our feedback to the user is less confusing
81
+ let arg = originalArgName
82
+ if (isFlag(arg)) {
83
+ arg = camelToSnake(arg.slice(2))
84
+ // @ts-expect-error - cba to add hasFlag to the general dictionary
85
+ if (!schema.hasFlag(arg)) {
86
+ throw new Error(`invalid named flag '${originalArgName}'`)
87
+ }
88
+ const next = argv[i + 1]
89
+ if (next && !isFlag(next)) {
90
+ named[arg] = { value: next, isDefault: false }
91
+ i++
92
+ } else {
93
+ named[arg] = { value: schema[arg].default, isDefault: true }
94
+ }
95
+
96
+ const { allowed, allowedHint } = schema[arg]
97
+ if (allowed && !allowed.includes(named[arg].value)) {
98
+ throw new Error(`invalid value '${named[arg]?.value ?? named[arg]}' for flag ${originalArgName}. Must be one of ${(allowedHint ?? allowed.join(', '))}`)
99
+ }
100
+ } else {
101
+ positional.push(arg)
102
+ }
103
+ i++
104
+ }
105
+
106
+ // enrich with defaults
107
+ /** @type {import('./typedefs').config.cli.ParameterSchema} */
108
+ const defaults = Object.entries(schema)
109
+ .filter(e => !!e[1].default)
110
+ .reduce((dict, [k, v]) => {
111
+ // @ts-expect-error - can't infer type of initial {}
112
+ dict[camelToSnake(k)] = { value: v.default, isDefault: true }
113
+ return dict
114
+ }, {})
115
+
116
+ const namedWithDefaults = {...defaults, ...named}
117
+
118
+ // apply postprocessing
119
+ for (const [key, {value}] of Object.entries(namedWithDefaults)) {
120
+ const { postprocess } = schema[key]
121
+ if (typeof postprocess === 'function') {
122
+ namedWithDefaults[key].value = postprocess(value)
123
+ }
124
+ }
125
+
126
+ return {
127
+ named: namedWithDefaults,
128
+ positional,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Adds CLI parameters to the configuration object.
134
+ * Precedence: CLI > env > default.
135
+ * @param {ReturnType<parseCommandlineArgs>['named']} params - CLI parameters.
136
+ */
137
+ const addCLIParamsToConfig = params => {
138
+ for (const [key, value] of Object.entries(params)) {
139
+ if (!value.isDefault || Object.hasOwn(configuration, key)) {
140
+ configuration[key] = value.value
141
+ }
142
+ }
143
+ }
17
144
 
18
- const flags = {
145
+ const flags = enrichFlagSchema({
19
146
  outputDirectory: {
20
147
  desc: 'Root directory to write the generated files to.',
21
148
  default: './',
@@ -30,16 +157,24 @@ const flags = {
30
157
  allowedHint: Object.keys(lls).join(' | '), // FIXME: remove once old levels are faded out
31
158
  defaultHint: _keyFor(lls.ERROR),
32
159
  default: cds?.env?.log?.levels?.['cds-typer'] ?? _keyFor(lls.ERROR),
160
+ postprocess: level => {
161
+ const newLogLevel = deprecated[level]
162
+ if (newLogLevel) {
163
+ console.warn(`deprecated log level '${level}', use '${newLogLevel}' instead (changing this automatically for now).`)
164
+ return newLogLevel
165
+ }
166
+ return level
167
+ }
33
168
  },
34
169
  jsConfigPath: {
35
170
  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
- type: 'string'
171
+ type: 'string',
172
+ postprocess: file => file && !file.endsWith('jsconfig.json') ? path.join(file, 'jsconfig.json') : path
37
173
  },
38
- useEntitiesProxy: {
174
+ useEntitiesProxy: parameterTypes.boolean({
39
175
  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
176
  default: 'false'
42
- },
177
+ }),
43
178
  version: {
44
179
  desc: 'Prints the version of this tool.'
45
180
  },
@@ -48,17 +183,15 @@ const flags = {
48
183
  allowed: ['flat', 'structured'],
49
184
  default: 'structured'
50
185
  },
51
- propertiesOptional: {
186
+ propertiesOptional: parameterTypes.boolean({
52
187
  desc: `If set to true, properties in entities are${EOL}always generated as optional (a?: T).`,
53
- allowed: ['true', 'false'],
54
188
  default: 'true'
55
- },
56
- IEEE754Compatible: {
189
+ }),
190
+ IEEE754Compatible: parameterTypes.boolean({
57
191
  desc: `If set to true, floating point properties are generated${EOL}as IEEE754 compatible '(number | string)' instead of 'number'.`,
58
- allowed: ['true', 'false'],
59
192
  default: 'false'
60
- }
61
- }
193
+ })
194
+ })
62
195
 
63
196
  const hint = () => 'Missing or invalid parameter(s). Call with --help for more details.'
64
197
  /**
@@ -74,19 +207,22 @@ const help = () => `SYNOPSIS${EOL2}` +
74
207
  indent('cds-typer [cds file | "*"]', ' ') + EOL2 +
75
208
  indent(`Generates type information based on a CDS model.${EOL}Call with at least one positional parameter pointing${EOL}to the (root) CDS file you want to compile.`, ' ') + EOL2 +
76
209
  `OPTIONS${EOL2}` +
77
- Object.entries(flags)
210
+ flags.keys
78
211
  .sort(([a], [b]) => a.localeCompare(b))
79
- .map(([key, value]) => {
212
+ .map(key => {
213
+ const value = flags[key]
80
214
  let s = indent(`--${key}`, ' ')
81
- // @ts-expect-error - not going to check presence of each property. Same for the following expect-errors.
215
+ const snake = camelToSnake(key)
216
+ if (key !== snake) s += EOL + indent(`--${snake}`, ' ')
217
+ // ts-expect-error - not going to check presence of each property. Same for the following expect-errors.
82
218
  if (value.allowedHint) s += ` ${value.allowedHint}`
83
- // @ts-expect-error
219
+ // ts-expect-error
84
220
  else if (value.allowed) s += `: <${value.allowed.join(' | ')}>`
85
221
  else if ('type' in value && value.type) s += `: <${value.type}>`
86
- // @ts-expect-error
222
+ // ts-expect-error
87
223
  if (value.defaultHint || value.default) {
88
224
  s += EOL
89
- // @ts-expect-error
225
+ // ts-expect-error
90
226
  s += indent(`(default: ${value.defaultHint ?? value.default})`, ' ')
91
227
  }
92
228
  s += `${EOL2}${indent(value.desc, ' ')}`
@@ -96,7 +232,9 @@ const help = () => `SYNOPSIS${EOL2}` +
96
232
 
97
233
  const version = () => require('../package.json').version
98
234
 
99
- const main = async (/** @type {any} */ args) => {
235
+ const prepareParameters = (/** @type {any[]} */ argv) => {
236
+ const args = parseCommandlineArgs(argv, flags)
237
+
100
238
  if ('help' in args.named) {
101
239
  console.log(help())
102
240
  process.exit(0)
@@ -109,27 +247,21 @@ const main = async (/** @type {any} */ args) => {
109
247
  console.log(hint())
110
248
  process.exit(1)
111
249
  }
112
- if (args.named.jsConfigPath && !args.named.jsConfigPath.endsWith('jsconfig.json')) {
113
- args.named.jsConfigPath = path.join(args.named.jsConfigPath, 'jsconfig.json')
114
- }
115
- const newLogLevel = deprecated[args.named.logLevel]
116
- if (newLogLevel) {
117
- console.warn(`deprecated log level '${args.named.logLevel}', use '${newLogLevel}' instead (changing this automatically for now).`)
118
- args.named.logLevel = newLogLevel
119
- }
120
250
 
121
- compileFromFile(args.positional, {
122
- // temporary fix until rootDir is faded out
123
- outputDirectory: [args.named.outputDirectory, args.named.rootDir].find(d => d !== './') ?? './',
124
- logLevel: args.named.logLevel,
125
- useEntitiesProxy: args.named.useEntitiesProxy === 'true',
126
- jsConfigPath: args.named.jsConfigPath,
127
- inlineDeclarations: args.named.inlineDeclarations,
128
- propertiesOptional: args.named.propertiesOptional === 'true',
129
- IEEE754Compatible: args.named.IEEE754Compatible === 'true'
130
- })
251
+ addCLIParamsToConfig(args.named)
252
+ return args
253
+ }
254
+
255
+ const main = async (/** @type {any[]} */ argv) => {
256
+ const { positional } = prepareParameters(argv)
257
+ compileFromFile(positional)
131
258
  }
132
259
 
133
260
  if (require.main === module) {
134
- main(parseCommandlineArgs(process.argv.slice(2), flags))
261
+ main(process.argv.slice(2))
262
+ }
263
+
264
+ module.exports = {
265
+ flags,
266
+ prepareParameters
135
267
  }
package/lib/compile.js CHANGED
@@ -7,10 +7,7 @@ const util = require('./util')
7
7
  const { writeout } = require('./file')
8
8
  const { Visitor } = require('./visitor')
9
9
  const { LOG, setLevel } = require('./logging')
10
-
11
- /**
12
- * @typedef {import('./typedefs').visitor.CompileParameters} CompileParameters
13
- */
10
+ const { configuration } = require('./config')
14
11
 
15
12
  /**
16
13
  * Writes the accompanying jsconfig.json file to the specified paths.
@@ -40,31 +37,28 @@ const writeJsConfig = path => {
40
37
  /**
41
38
  * Compiles a CSN object to Typescript types.
42
39
  * @param {{xtended: import('./typedefs').resolver.CSN, inferred: import('./typedefs').resolver.CSN}} csn - csn tuple
43
- * @param {CompileParameters} parameters - path to root directory for all generated files, min log level
44
40
  */
45
- const compileFromCSN = async (csn, parameters) => {
46
- const envSettings = cds.env?.typer ?? {}
47
- parameters = { ...envSettings, ...parameters }
48
- setLevel(parameters.logLevel)
49
- if (parameters.jsConfigPath) {
50
- writeJsConfig(parameters.jsConfigPath)
41
+ const compileFromCSN = async csn => {
42
+
43
+ setLevel(configuration.logLevel)
44
+ if (configuration.jsConfigPath) {
45
+ writeJsConfig(configuration.jsConfigPath)
51
46
  }
52
47
  return writeout(
53
- parameters.outputDirectory,
54
- Object.values(new Visitor(csn, parameters).getWriteoutFiles())
48
+ configuration.outputDirectory,
49
+ Object.values(new Visitor(csn).getWriteoutFiles())
55
50
  )
56
51
  }
57
52
 
58
53
  /**
59
54
  * Compiles a .cds file to Typescript types.
60
55
  * @param {string | string[]} inputFile - path to input .cds file
61
- * @param {CompileParameters} parameters - path to root directory for all generated files, min log level, etc.
62
56
  */
63
- const compileFromFile = async (inputFile, parameters) => {
57
+ const compileFromFile = async inputFile => {
64
58
  const paths = typeof inputFile === 'string' ? normalize(inputFile) : inputFile.map(f => normalize(f))
65
59
  const xtended = await cds.linked(await cds.load(paths, { docs: true, flavor: 'xtended' }))
66
60
  const inferred = await cds.linked(await cds.load(paths, { docs: true }))
67
- return compileFromCSN({xtended, inferred}, parameters)
61
+ return compileFromCSN({xtended, inferred})
68
62
  }
69
63
 
70
64
  module.exports = {
@@ -42,6 +42,12 @@ export type DeepRequired<T> = {
42
42
  [K in keyof T]: DeepRequired<T[K]>
43
43
  } & Exclude<Required<T>, null>;
44
44
 
45
+ const key = Symbol('key') // to avoid .key showing up in IDE's auto-completion
46
+ export type Key<T> = T & {[key]?: true}
47
+
48
+ export type KeysOf<T> = {
49
+ [K in keyof T as NonNullable<T[K]> extends Key<unknown> ? K : never]-?: Key<{}> // T[K]
50
+ }
45
51
 
46
52
  /**
47
53
  * Dates and timestamps are strings during runtime, so cds-typer represents them as such.
@@ -1,3 +1,4 @@
1
+ const { configuration } = require('../config')
1
2
  const { SourceFile, Buffer } = require('../file')
2
3
  const { normalise } = require('./identifier')
3
4
  const { docify } = require('./wrappers')
@@ -94,7 +95,7 @@ class InlineDeclarationResolver {
94
95
  * @returns {'?:'|':'}
95
96
  */
96
97
  getPropertyTypeSeparator() {
97
- return this.visitor.options.propertiesOptional ? '?:' : ':'
98
+ return configuration.propertiesOptional ? '?:' : ':'
98
99
  }
99
100
 
100
101
  /**
@@ -3,11 +3,11 @@
3
3
  // @ts-nocheck
4
4
  const proxyAccessFunction = function (fqParts, opts = {}) {
5
5
  const { target, customProps } = { target: {}, customProps: [], ...opts }
6
- const fq = fqParts.join('.')
6
+ const fq = fqParts.filter(p => !!p).join('.')
7
7
  return new Proxy(target, {
8
8
  get: function (target, prop) {
9
9
  if (cds.entities) {
10
- target.__proto__ = cds.entities[fq]
10
+ target.__proto__ = cds.entities(fqParts[0])[fqParts[1]]
11
11
  // overwrite/simplify getter after cds.entities is accessible
12
12
  this.get = (target, prop) => target[prop]
13
13
  return target[prop]
@@ -3,6 +3,20 @@
3
3
  // this was derived from baseDefinitions before, but caused a circular dependency
4
4
  const base = '__'
5
5
 
6
+ /**
7
+ * Wraps type into the Key type.
8
+ * @param {string} t - the type name.
9
+ * @returns {string}
10
+ */
11
+ const createKey = t => `${base}.Key<${t}>`
12
+
13
+ /**
14
+ * Wraps type into KeysOf type.
15
+ * @param {string} t - the type name.
16
+ * @returns {string}
17
+ */
18
+ const createKeysOf = t => `${base}.KeysOf<${t}>`
19
+
6
20
  /**
7
21
  * Wraps type into association to scalar.
8
22
  * @param {string} t - the singular type name.
@@ -86,6 +100,8 @@ const docify = doc => {
86
100
 
87
101
  module.exports = {
88
102
  createArrayOf,
103
+ createKey,
104
+ createKeysOf,
89
105
  createObjectOf,
90
106
  createPromiseOf,
91
107
  createUnionOf,
package/lib/config.js ADDED
@@ -0,0 +1,117 @@
1
+ const cds = require('@sap/cds')
2
+ const { camelToSnake } = require('./util')
3
+
4
+ /**
5
+ * Makes properties of an object accessible in both camelCase and snake_case.
6
+ * Snake_case gets precedence over camelCase.
7
+ * @template T
8
+ * @param {T} target - The object to proxy.
9
+ * @returns {T} - The proxied object.
10
+ */
11
+ const camelSnakeHybrid = target => {
12
+ // @ts-expect-error - expecting target to be of type {}, which is not T (same for following)
13
+ const proxy = new Proxy(target, {
14
+ get(target, prop) {
15
+ // @ts-expect-error
16
+ return target[camelToSnake(prop)] ?? target[prop]
17
+ },
18
+ set(target, p, v) {
19
+ // @ts-expect-error
20
+ target[camelToSnake(p)] = v
21
+ return true
22
+ }
23
+ })
24
+ // need to make sure all properties are initially available in snake_case
25
+ // @ts-expect-error
26
+ for (const [k,v] of Object.entries(target)) {
27
+ // @ts-expect-error
28
+ proxy[k] = v
29
+ }
30
+ // @ts-expect-error
31
+ return proxy
32
+ }
33
+ class Config {
34
+ static #defaults = {
35
+ propertiesOptional: true,
36
+ useEntitiesProxy: false,
37
+ inlineDeclarations: 'flat'
38
+ }
39
+
40
+ values = undefined
41
+ proxy = undefined
42
+
43
+ init () {
44
+ this.values = {...Config.#defaults, ...(cds.env.typer ?? {})}
45
+ this.proxy = camelSnakeHybrid(this.values)
46
+ }
47
+
48
+ constructor() {
49
+ // proxy around config still allows arbitrary property access:
50
+ // require('config').configuration.logLevel = 'warn' will work
51
+ // eslint-disable-next-line no-constructor-return
52
+ return new Proxy(this, {
53
+ get(target, prop) {
54
+ // lazy loading of cds.env
55
+ // if we don't do this, configuration will load cds.env whenever it is
56
+ // first imported anywhere (even by proxy from, say, cli.js).
57
+ // So we don't get to modify cds.env before that, which is important
58
+ // in cds-build.js.
59
+ // FIXME: revisit. This is horrible.
60
+ if (target.values === undefined) target.init()
61
+ return target[prop] ?? target.proxy[prop]
62
+ },
63
+ set(target, p, v) {
64
+ if (target.values === undefined) target.init()
65
+
66
+ // this.value, this.proxy etc should not be forwarded to the wrapped values
67
+ if (target[p]) {
68
+ target[p] = v
69
+ } else {
70
+ target.proxy[p] = v
71
+ }
72
+ return true
73
+ }
74
+ })
75
+ }
76
+
77
+ /**
78
+ * @param {string} key - The key to set.
79
+ * @param {any} value - The value to set
80
+ */
81
+ setOne (key, value) {
82
+ this.proxy[key] = value
83
+ }
84
+
85
+ /**
86
+ * @param {object} props - The properties to set.
87
+ */
88
+ setMany (props) {
89
+ for (const [k,v] of Object.entries(props)) {
90
+ this.proxy[k] = v
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Resets the config value and sets all its values from another passed
96
+ * config object. This allows to keep the reference to the same object.
97
+ * @param {Config} config - Another config object to set all config entries from.
98
+ */
99
+ setFrom (config) {
100
+ this.values = camelSnakeHybrid({})
101
+ this.setMany(config.values)
102
+ }
103
+
104
+ clone () {
105
+ const res = new Config()
106
+ res.init()
107
+ res.setMany(this.values)
108
+ return res
109
+ }
110
+ }
111
+
112
+ module.exports = {
113
+ camelSnakeHybrid,
114
+ /** @type {import('./typedefs').config.Configuration} */
115
+ // @ts-ignore
116
+ configuration: new Config()
117
+ }
package/lib/file.js CHANGED
@@ -8,11 +8,11 @@ const { normalise } = require('./components/identifier')
8
8
  const { empty } = require('./components/typescript')
9
9
  const { proxyAccessFunction } = require('./components/javascript')
10
10
  const { createObjectOf } = require('./components/wrappers')
11
+ const { configuration } = require('./config')
11
12
 
12
13
  const AUTO_GEN_NOTE = '// This is an automatically generated file. Please do not change its contents manually!'
13
14
 
14
15
  /** @typedef {import('./typedefs').file.Namespace} Namespace */
15
- /** @typedef {import('./typedefs').file.FileOptions} FileOptions */
16
16
 
17
17
  class File {
18
18
  /**
@@ -109,11 +109,9 @@ class Library extends File {
109
109
  class SourceFile extends File {
110
110
  /**
111
111
  * @param {string | Path} path - path to the file
112
- * @param {FileOptions} [options] - options for file output
113
112
  */
114
- constructor(path, options) {
113
+ constructor(path) {
115
114
  super()
116
- this.options = options ?? { useEntitiesProxy: false }
117
115
  /** @type {Path} */
118
116
  this.path = path instanceof Path ? path : new Path(path.split('.'))
119
117
  /** @type {{[key:string]: any}} */
@@ -437,7 +435,7 @@ class SourceFile extends File {
437
435
  const namespace = this.path.asNamespace()
438
436
 
439
437
  const boilerplate = [AUTO_GEN_NOTE]
440
- if (this.options.useEntitiesProxy) {
438
+ if (configuration.useEntitiesProxy) {
441
439
  if (namespace === '_') {
442
440
  boilerplate.push('const cds = require(\'@sap/cds\')', this.#getEntityProxyFunctionExport())
443
441
  } else {
@@ -460,7 +458,7 @@ class SourceFile extends File {
460
458
  * @returns {{singularRhs: string, pluralRhs: string}}
461
459
  */
462
460
  #getEntityExportsRhs(singular, original) {
463
- if (this.options.useEntitiesProxy) {
461
+ if (configuration.useEntitiesProxy) {
464
462
  const namespace = this.path.asNamespace()
465
463
  // determine the custom properties for the proxy function call
466
464
  const customProps = this.entityProxies[singular] ?? []
@@ -697,13 +695,6 @@ class FileRepository {
697
695
  /** @type {{[key:string]: SourceFile}} */
698
696
  #files = {}
699
697
 
700
- /**
701
- * @param {FileOptions} options - options to control file
702
- */
703
- constructor(options) {
704
- this.options = options
705
- }
706
-
707
698
  /**
708
699
  * @param {string} name - file name
709
700
  * @param {SourceFile} file - the file
@@ -720,7 +711,7 @@ class FileRepository {
720
711
  */
721
712
  getNamespaceFile(path) {
722
713
  const key = path instanceof Path ? path.asNamespace() : path
723
- return (this.#files[key] ??= new SourceFile(path, this.options))
714
+ return (this.#files[key] ??= new SourceFile(path))
724
715
  }
725
716
 
726
717
  /**
@@ -3,7 +3,7 @@
3
3
  const util = require('../util')
4
4
  // eslint-disable-next-line no-unused-vars
5
5
  const { Buffer, SourceFile, Path, Library } = require('../file')
6
- const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('../components/wrappers')
6
+ const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne, createKey } = require('../components/wrappers')
7
7
  const { StructuredInlineDeclarationResolver } = require('../components/inline')
8
8
  const { isInlineEnumType, propertyToInlineEnumName } = require('../components/enum')
9
9
  const { isReferenceType } = require('../components/reference')
@@ -13,6 +13,7 @@ const { BuiltinResolver } = require('./builtin')
13
13
  const { LOG } = require('../logging')
14
14
  const { last } = require('../components/identifier')
15
15
  const { getPropertyModifiers } = require('../components/property')
16
+ const { configuration } = require('../config')
16
17
 
17
18
  /** @typedef {import('../visitor').Visitor} Visitor */
18
19
  /** @typedef {import('../typedefs').resolver.CSN} CSN */
@@ -30,7 +31,7 @@ class Resolver {
30
31
  this.visitor = visitor
31
32
 
32
33
  /** @type {BuiltinResolver} */
33
- this.builtinResolver = new BuiltinResolver(visitor.options)
34
+ this.builtinResolver = new BuiltinResolver({ IEEE754Compatible: configuration.IEEE754Compatible })
34
35
 
35
36
  /** @type {Library[]} */
36
37
  this.libraries = [new Library(require.resolve('../../library/cds.hana.ts'))]
@@ -63,8 +64,7 @@ class Resolver {
63
64
  * @returns {boolean} whether the type is configured to be optional
64
65
  */
65
66
  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'))
67
+ return !type.notNull
68
68
  }
69
69
 
70
70
  /**
@@ -159,8 +159,8 @@ class Resolver {
159
159
  if (parts.length <= 1) return []
160
160
 
161
161
  /**
162
- * @param {string} property
163
- * @param {import('../typedefs').resolver.EntityCSN} entity
162
+ * @param {string} property - the property to check
163
+ * @param {import('../typedefs').resolver.EntityCSN} entity - the entity to check the property against
164
164
  */
165
165
  const isPropertyOf = (property, entity) => property && Object.hasOwn(entity?.elements ?? {}, property)
166
166
 
@@ -385,6 +385,10 @@ class Resolver {
385
385
  plural: typeName
386
386
  }
387
387
 
388
+ if (element.key === true) {
389
+ typeName = createKey(typeName)
390
+ }
391
+
388
392
  // FIXME: typeName could probably just become part of typeInfo
389
393
  return { typeName, typeInfo }
390
394
  }
package/lib/typedefs.d.ts CHANGED
@@ -99,47 +99,13 @@ export module resolver {
99
99
 
100
100
  export module util {
101
101
  export type Annotations = {
102
- name?: string,
102
+ name: string,
103
103
  '@singular'?: string,
104
104
  '@plural'?: string
105
105
  }
106
-
107
- export type CommandLineFlags = {
108
- desc: string,
109
- default?: any
110
- }
111
-
112
- export type ParsedFlag = {
113
- positional: string[],
114
- named: { [key: string]: any }
115
- }
116
106
  }
117
107
 
118
108
  export module visitor {
119
- export type CompileParameters = {
120
- outputDirectory: string,
121
- logLevel: number,
122
- useEntitiesProxy: boolean,
123
- jsConfigPath?: string,
124
- inlineDeclarations: 'flat' | 'structured',
125
- propertiesOptional: boolean,
126
- IEEE754Compatible: boolean,
127
- }
128
-
129
- export type VisitorOptions = {
130
- /** `propertiesOptional = true` -> all properties are generated as optional ?:. (standard CAP behaviour, where properties be unavailable) */
131
- propertiesOptional: boolean,
132
- /**
133
- * `inlineDeclarations = 'structured'` -> @see {@link inline.StructuredInlineDeclarationResolver}
134
- * `inlineDeclarations = 'flat'` -> @see {@link inline.FlatInlineDeclarationResolver}
135
- */
136
- inlineDeclarations: 'flat' | 'structured',
137
- /**
138
- * `useEntitiesProxy = true` will wrap the `module.exports.<entityName>` in `Proxy` objects
139
- */
140
- useEntitiesProxy: boolean
141
- }
142
-
143
109
  export type Inflection = {
144
110
  typeName?: string,
145
111
  singular: string,
@@ -158,9 +124,54 @@ export module visitor {
158
124
  }
159
125
  }
160
126
 
127
+ export module config {
128
+ export module cli {
129
+ export type CLIFlags = 'version' | 'help'
130
+ export type ParameterSchema = {
131
+ [key: string]: {
132
+ desc: string,
133
+ allowed?: string[],
134
+ allowedHint?: string,
135
+ type?: 'string' | 'boolean' | 'number',
136
+ default?: string,
137
+ defaultHint?: string,
138
+ postprocess?: (value: string) => any,
139
+ camel?: string,
140
+ snake?: string
141
+ }
142
+ }
143
+
144
+ export type ParsedParameters = {
145
+ positional: string[],
146
+ named: { [key: keyof RuntimeParameters]: {
147
+ value: any,
148
+ isDefault: boolean,
149
+ } }
150
+ }
151
+ }
152
+
153
+ export type Configuration = {
154
+ outputDirectory: string,
155
+ logLevel: number,
156
+ /**
157
+ * `useEntitiesProxy = true` will wrap the `module.exports.<entityName>` in `Proxy` objects
158
+ */
159
+ useEntitiesProxy: boolean,
160
+ jsConfigPath?: string,
161
+ /**
162
+ * `inlineDeclarations = 'structured'` -> @see {@link inline.StructuredInlineDeclarationResolver}
163
+ * `inlineDeclarations = 'flat'` -> @see {@link inline.FlatInlineDeclarationResolver}
164
+ */
165
+ inlineDeclarations: 'flat' | 'structured',
166
+ /** `propertiesOptional = true` -> all properties are generated as optional ?:. (standard CAP behaviour, where properties be unavailable) */
167
+ propertiesOptional: boolean,
168
+ /**
169
+ * `IEEE754Compatible = true` -> any cds.Decimal will become `number | string`
170
+ */
171
+ IEEE754Compatible: boolean
172
+ }
173
+ }
174
+
161
175
  export module file {
162
176
  export type Namespace = Object<string, Buffer>
163
- export type FileOptions = {
164
- useEntitiesProxy: boolean
165
- }
166
177
  }
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, createPromiseOf, createUnionOf } = 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,23 +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
- useEntitiesProxy: false,
33
- inlineDeclarations: 'flat'
34
- }
35
-
36
28
  class Visitor {
37
29
  /**
38
30
  * Gathers all files that are supposed to be written to
@@ -46,12 +38,10 @@ class Visitor {
46
38
 
47
39
  /**
48
40
  * @param {{xtended: CSN, inferred: CSN}} csn - root CSN
49
- * @param {VisitorOptions | {}} options - the options
50
41
  */
51
- constructor(csn, options = {}) {
42
+ constructor(csn) {
52
43
  amendCSN(csn.xtended)
53
44
  propagateForeignKeys(csn.inferred)
54
- this.options = { ...defaults, ...options }
55
45
  this.csn = csn
56
46
 
57
47
  /** @type {Context[]} **/
@@ -64,12 +54,10 @@ class Visitor {
64
54
  this.entityRepository = new EntityRepository(this.resolver)
65
55
 
66
56
  /** @type {FileRepository} */
67
- this.fileRepository = new FileRepository(this.options)
68
- // REVISIT: better way to pass options to base source file ???
69
- baseDefinitions.options = this.options
57
+ this.fileRepository = new FileRepository()
70
58
  this.fileRepository.add(baseDefinitions.path.asNamespace(), baseDefinitions)
71
59
  this.inlineDeclarationResolver =
72
- this.options.inlineDeclarations === 'structured'
60
+ configuration.inlineDeclarations === 'structured'
73
61
  ? new StructuredInlineDeclarationResolver(this)
74
62
  : new FlatInlineDeclarationResolver(this)
75
63
 
@@ -105,7 +93,13 @@ class Visitor {
105
93
  // FIXME: references to types of entity properties may be missing from xtendend flavour (see #103)
106
94
  // this should be revisted once we settle on a single flavour.
107
95
  const target = this.csn.xtended.definitions[targetName] ?? this.csn.inferred.definitions[targetName]
108
- 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
+ }
109
103
  } else {
110
104
  LOG.error(`Expecting an autoexposed projection within a service. Skipping ${name}`)
111
105
  }
@@ -160,6 +154,14 @@ class Visitor {
160
154
  }
161
155
  }
162
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
+
163
165
  /**
164
166
  * Transforms an entity or CDS aspect into a JS aspect (aka mixin).
165
167
  * That is, for an element A we get:
@@ -272,6 +274,8 @@ class Visitor {
272
274
  file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}), eDoc)
273
275
  }
274
276
  this.#printStaticActions(entity, buffer, ancestorInfos, file)
277
+ this.#printStaticKeys(buffer, clean)
278
+
275
279
  })
276
280
  }, '};') // end of generated class
277
281
  }, '}') // end of aspect
@@ -488,12 +492,13 @@ class Visitor {
488
492
  buffer.add('// event')
489
493
  // only declare classes, as their properties are not optional, so we don't have to do awkward initialisation thereof.
490
494
  buffer.addIndentedBlock(`export declare class ${entityName} {`, () => {
491
- const propOpt = this.options.propertiesOptional
492
- 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
493
498
  for (const [ename, element] of Object.entries(event.elements ?? {})) {
494
499
  this.visitElement(ename, element, file, buffer)
495
500
  }
496
- this.options.propertiesOptional = propOpt
501
+ configuration.propertiesOptional = propOpt
497
502
  }, '}')
498
503
  }
499
504
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.26.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",