Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Print Enums Explicitly During cds-typer Run #107

Merged
merged 5 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
The format is based on [Keep a Changelog](http://keepachangelog.com/).

## Version 0.13.0 - TBD
### Changes
- Enums are now generated ecplicitly in the respective _index.js_ files and don't have to extract their values from the model at runtime anymore

## Version 0.12.0 - 2023-11-23

Expand Down
60 changes: 34 additions & 26 deletions lib/components/enum.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ function printEnum(buffer, name, kvs, options = {}) {
buffer.indent()
const vals = new Set()
for (const [k, v] of kvs) {
buffer.add(`${k}: ${JSON.stringify(v)},`)
vals.add(JSON.stringify(v.val ?? v)) // in case of wrapped vals we need to unwrap here for the type
buffer.add(`${k}: ${v},`)
vals.add(v?.val ?? v) // in case of wrapped vals we need to unwrap here for the type
}
buffer.outdent()
buffer.add('} as const;')
Expand All @@ -41,35 +41,40 @@ function printEnum(buffer, name, kvs, options = {}) {
}

// in case of strings, wrap in quotes and fallback to key to make sure values are attached for every key
const enumVal = (key, value, enumType) => enumType === 'cds.String' ? `${value ?? key}` : value
const enumVal = (key, value, enumType) => enumType === 'cds.String' ? JSON.stringify(`${value ?? key}`) : value

/**
* Converts a CSN type describing an enum into a list of kv-pairs.
* Values from CSN are unwrapped from their `.val` structure and
* will fall back to the key if no value is provided.
*
* @param {{enum: {[key: name]: string}, type: string}} enumCsn
* @param {{unwrapVals: boolean}} options if `unwrapVals` is passed,
* then the CSN structure `{val:x}` is flattened to just `x`.
* Retaining `val` is closer to the actual CSN structure and should be used where we want
* to mimic the runtime as closely as possible (anoymous enum types).
* to mimic the runtime as closely as possible (inline enum types).
* Stripping that additional wrapper would be more readable for users.
*
* @example
* ```ts
* const csn = {enum: {x: {val: 42}, y: {val: -42}}}
* csnToEnum(csn) // -> [['x', 42], ['y': -42]]
* csnToEnum(csn, {unwrapVals: false}) // -> [['x', {val:42}], ['y': {val:-42}]]
* const csn = {enum: {X: {val: 'a'}, Y: {val: 'b'}, Z: {}}}
* csnToEnumPairs(csn) // -> [['X', 'a'], ['Y': 'b'], ['Z': 'Z']]
* csnToEnumPairs(csn, {unwrapVals: false}) // -> [['X', {val:'a'}], ['Y': {val:'b'}], ['Z':'Z']]
* ```
*/
const csnToEnum = ({enum: enm, type}, options = {}) => {
const csnToEnumPairs = ({enum: enm, type}, options = {}) => {
options = {...{unwrapVals: true}, ...options}
return Object.entries(enm).map(([k, v]) => {
const val = enumVal(k, v.val, type)
return [k, options.unwrapVals ? val : { val }]
return [k, (options.unwrapVals ? val : { val })]
})
}

/**
* @param {string} entity
* @param {string} property
*/
const propertyToAnonymousEnumName = (entity, property) => `${entity}_${property}`
const propertyToInlineEnumName = (entity, property) => `${entity}_${property}`

/**
* A type is considered to be an inline enum, iff it has a `.enum` property
Expand All @@ -82,27 +87,30 @@ const propertyToAnonymousEnumName = (entity, property) => `${entity}_${property}
*/
const isInlineEnumType = (element, csn) => element.enum && !(element.type in csn.definitions)

const stringifyEnumImplementation = (name, enm) => `module.exports.${name} = Object.fromEntries(Object.entries(${enm}).map(([k,v]) => [k,v.val??k]))`

/**
* @param {string} name
* @param {string} fq
* @returns {string}
*/
const stringifyNamedEnum = (name, fq) => stringifyEnumImplementation(name, `cds.model.definitions['${fq}'].enum`)
/**
* Stringifies an enum into a runtime artifact.
* ```cds
* type Language: String enum {
* DE = "German";
* EN = "English";
* FR;
* }
* ```
* becomes
*
* ```js
* module.exports.Language = { DE: "German", EN: "English", FR: "FR" }
* ```
* @param {string} name
* @param {string} fq
* @param {string} property
* @returns {string}
* @param {[string, string][]} kvs a list of key-value pairs. Values that are falsey are replaced by
*/
const stringifyAnonymousEnum = (name, fq, property) => stringifyEnumImplementation(fq, `cds.model.definitions['${name}'].elements.${property}.enum`)
const stringifyEnumImplementation = (name, kvs) => `module.exports.${name} = { ${kvs.map(([k,v]) => `${k}: ${v}`).join(', ')} }`


module.exports = {
printEnum,
csnToEnum,
propertyToAnonymousEnumName,
csnToEnumPairs,
propertyToInlineEnumName,
isInlineEnumType,
stringifyNamedEnum,
stringifyAnonymousEnum
stringifyEnumImplementation
}
8 changes: 4 additions & 4 deletions lib/components/resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

const util = require('../util')
// eslint-disable-next-line no-unused-vars
const { Buffer, SourceFile, Path, Library, baseDefinitions } = require("../file")
const { Buffer, SourceFile, Path, Library, baseDefinitions } = require('../file')
const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('./wrappers')
const { StructuredInlineDeclarationResolver } = require("./inline")
const { isInlineEnumType, propertyToAnonymousEnumName } = require('./enum')
const { StructuredInlineDeclarationResolver } = require('./inline')
const { isInlineEnumType, propertyToInlineEnumName } = require('./enum')

/** @typedef {{ cardinality?: { max?: '*' | number } }} EntityCSN */
/** @typedef {{ definitions?: Object<string, EntityCSN> }} CSN */
Expand Down Expand Up @@ -374,7 +374,7 @@ class Resolver {
// we use the singular as the initial declaration of these enums takes place
// while defining the singular class. Which therefore uses the singular over the plural name.
const cleanEntityName = util.singular4(element.parent, true)
const enumName = propertyToAnonymousEnumName(cleanEntityName, element.name)
const enumName = propertyToInlineEnumName(cleanEntityName, element.name)
result.type = enumName
result.plainName = enumName
result.isInlineDeclaration = true
Expand Down
30 changes: 15 additions & 15 deletions lib/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const fs = require('fs').promises
const { readFileSync } = require('fs')
const { printEnum, stringifyNamedEnum, stringifyAnonymousEnum, propertyToAnonymousEnumName } = require('./components/enum')
const { printEnum, propertyToInlineEnumName, stringifyEnumImplementation } = require('./components/enum')
const path = require('path')

const AUTO_GEN_NOTE = "// This is an automatically generated file. Please do not change its contents manually!"
Expand Down Expand Up @@ -104,8 +104,8 @@ class SourceFile extends File {
this.events = { buffer: new Buffer(), fqs: []}
/** @type {Buffer} */
this.types = new Buffer()
/** @type {{ buffer: Buffer, fqs: {name: string, fq: string, property?: string}[]}} */
this.enums = { buffer: new Buffer(), fqs: [] }
/** @type {{ buffer: Buffer, data: {kvs: [string[]], name: string, fq: string, property?: string}[]}} */
this.enums = { buffer: new Buffer(), data: [] }
/** @type {{ buffer: Buffer }} */
this.inlineEnums = { buffer: new Buffer() }
/** @type {Buffer} */
Expand Down Expand Up @@ -226,28 +226,28 @@ class SourceFile extends File {
* @param {string} fq fully qualified name of the enum (entity name within CSN)
* @param {string} name local name of the enum
* @param {[string, string][]} kvs list of key-value pairs
* @param {string} [property] property to which the enum is attached.
* If given, the enum is considered to be an anonymous inline definition of an enum.
* @param {string} [property] property to which the enum is attached.
* If given, the enum is considered to be an inline definition of an enum.
* If not, it is considered to be regular, named enum.
*/
addEnum(fq, name, kvs) {
this.enums.fqs.push({ name, fq })
this.enums.data.push({ name, fq, kvs })
printEnum(this.enums.buffer, name, kvs)
}

/**
* Adds an anonymous enum to this file.
* Adds an inline enum to this file.
* @param {string} entityCleanName name of the entity the enum is attached to without namespace
* @param {string} entityFqName name of the entity the enum is attached to with namespace
*
* @param {string} propertyName property to which the enum is attached.
* @param {[string, string][]} kvs list of key-value pairs
* If given, the enum is considered to be an anonymous inline definition of an enum.
* If given, the enum is considered to be an inline definition of an enum.
* If not, it is considered to be regular, named enum.
*
* @example
* ```js
* addAnonymousEnum('Books.genre', 'Books', 'genre', [['horror','horror']])
* addInlineEnum('Books.genre', 'Books', 'genre', [['horror','horror']])
* ```
* generates
* ```js
Expand All @@ -266,13 +266,14 @@ class SourceFile extends File {
* }
* ```
*/
addAnonymousEnum(entityCleanName, entityFqName, propertyName, kvs) {
this.enums.fqs.push({
addInlineEnum(entityCleanName, entityFqName, propertyName, kvs) {
this.enums.data.push({
name: entityFqName,
property: propertyName,
kvs,
fq: `${entityCleanName}.${propertyName}`
})
printEnum(this.inlineEnums.buffer, propertyToAnonymousEnumName(entityCleanName, propertyName), kvs, {export: false})
printEnum(this.inlineEnums.buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false})
}

/**
Expand Down Expand Up @@ -410,9 +411,7 @@ class SourceFile extends File {
.concat(['// actions'])
.concat(this.actions.names.map(name => `module.exports.${name} = '${name}'`))
.concat(['// enums'])
.concat(this.enums.fqs.map(({name, fq, property}) => property
? stringifyAnonymousEnum(name, fq, property)
: stringifyNamedEnum(name, fq)))
.concat(this.enums.data.map(({name, kvs}) => stringifyEnumImplementation(name, kvs)))
.join('\n') + '\n'
}
}
Expand Down Expand Up @@ -608,6 +607,7 @@ const writeout = async (root, sources) =>
} catch (err) {
// eslint-disable-next-line no-console
console.error(`Could not create parent directory ${dir}: ${err}.`)
console.error(err.stack)
}
return dir
})
Expand Down
8 changes: 4 additions & 4 deletions lib/visitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = r
const { Resolver } = require('./components/resolver')
const { Logger } = require('./logging')
const { docify } = require('./components/wrappers')
const { csnToEnum, propertyToAnonymousEnumName, isInlineEnumType } = require('./components/enum')
const { csnToEnumPairs, propertyToInlineEnumName, isInlineEnumType } = require('./components/enum')

/** @typedef {import('./file').File} File */
/** @typedef {{ entity: String }} Context */
Expand Down Expand Up @@ -180,8 +180,8 @@ class Visitor {

buffer.indent()
for (const e of enums) {
buffer.add(`static ${e.name} = ${propertyToAnonymousEnumName(clean, e.name)}`)
file.addAnonymousEnum(clean, name, e.name, csnToEnum(e, {unwrapVals: true}))
buffer.add(`static ${e.name} = ${propertyToInlineEnumName(clean, e.name)}`)
file.addInlineEnum(clean, name, e.name, csnToEnumPairs(e, {unwrapVals: true}))
}
buffer.add('static actions: {')
buffer.indent()
Expand Down Expand Up @@ -341,7 +341,7 @@ class Visitor {
const ns = this.resolver.resolveNamespace(name.split('.'))
const file = this.getNamespaceFile(ns)
if ('enum' in type) {
file.addEnum(name, clean, csnToEnum(type))
file.addEnum(name, clean, csnToEnumPairs(type))
} else {
// alias
file.addType(name, clean, this.resolver.resolveAndRequire(type, file).typeName)
Expand Down
67 changes: 67 additions & 0 deletions testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
When contributing to cds-typer, providing a suitable set of unit tests is highly appreciated.

Most tests in the current suites follow the same structure:

1. define a model that exposes certain traits of CDS we want to the cds-typer against
2. generate the types for this model using cds-typer
3. parse the resulting files into a TypeScript AST
4. inspect the AST to confirm the generated code meets all expectations

The following section explores the process of writing new tests in detail.

## Writing a Test Model
Let's assume you want to support some feature "`X`" from CDS.
To make sure `X` can be tested in isolation you will start with creating a test model in `test/unit/files/X/model.cds` with a minimal model that focuses on `X`. Make sure the model is as brief and self-contained as possible.

Also, create a Jest testfile `test/X.test.js`. You can transfer the general structure from one of the existing test files.


## Programmatically Generating Types for Testing
For most tests you will compile the test model _once_ and then test several assumptions about the generated files through multiple test cases. The compilation of your model can be trigger through the `compileFromFile` method from the cds-typer core module. To make sure the tests run smoothly on all operating systems as well as in the CI pipeline, you should use the following utility functions:

`util.locations.unit.files(m)` will resolve the passed model `m` from the unit test directory to avoid any path confusion
`util.locations.testOutput(m)` creates a path for a temporary directory for the test suite `m` to generate files into


```ts
const { compileFromFile } = require('../../lib/compile')
const { ASTWrapper } = require('../ast')
const { locations } = require('../util')

const paths = await compileFromFile(locations.unit.files('X/model.cds'), {
outputDirectory: locations.testOutput('X')
})
```

## Parsing Generated Types for Further Inspection
Calling `compileFromFile` will return a list of paths pointing to the files that were generated in the process. Note that there are certainly more elegant ways to retrieve one particular file than using a hard coded index.

Once you have determined the file you want to inspect, you can wrap them in an `ASTWrapper`. There are two wrappers available:

### `ASTWrapper` for TypeScript Files
This is the wrapper you are mainly working with for testing. It uses the native TypeScript compiler under the hood to load a given `.ts` file and parse it into an abstract syntax tree (AST). The wrapper class simplifies the structure of the original TS AST, which is quite verbose. This makes certain nodes significantly more accessible by removing some layers of indirection of enriching nodes with useful information.
You can produce such a wrapped AST as follows:

```js
let astw
beforeAll(async () => {
const paths = await cds2ts.compileFromFile(locations.unit.files('X/model.cds'), { ... })
astw = new ASTWrapper(path.join(paths[1], 'index.ts'))
}
```

The downside of this process is that the wrapped AST only contains the nodes that are explicitly visited!
If you are contributing a feature that causes cds-typer to emit any TypeScript construct that has not been emitted before, chances are that the corresponding TypeScript AST nodes are simply ignored by the wrapper class.
In that case you have to adjust the wrapper accordingly.

### `JSASTWrapper` for JavaScript Files

cds-typer mainly emits TypeScript files, accompanied by a thin JavaScript wrapper around `cds.entities(...)` to make sure all imports work during runtime as well. The way these JavaScript files are generated will rarely change. But if you contribute a feature that causes a change in the JavaScript files, you should test them as well. For them you can use the `JSASTWrapper`.

```js
let ast
beforeAll(async () => {
const paths = await cds2ts.compileFromFile(locations.unit.files('X/model.cds'), { ... })
const jsastw = new JSASTWrapper(code)
}
```