Skip to content

Commit

Permalink
Merge branch 'main' into feat/static-name-property
Browse files Browse the repository at this point in the history
  • Loading branch information
daogrady authored Oct 10, 2023
2 parents 1ba378e + c124ce3 commit 102bcf5
Show file tree
Hide file tree
Showing 10 changed files with 158 additions and 58 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
### Changed

### Added
- Autoexposed entities in services are now also generated
- Each generated class now contains their original fully qualified name in a static `.name` property
- Inline enums that are defined as literal type of properties are now supported as well (note: this feature is experimental. The location to which enums are generated might change in the future!)

### Fixed
- Fixed an error when an entity uses `type of` on a property they have inherited from another entity
- Fixed an error during draftability propagation when defining compositions on types that are declared inline

### Removed
- `compileFromCSN` is no longer part of the package's API

## Version 0.10.0 - 2023-09-21

### Changed
Expand Down
10 changes: 5 additions & 5 deletions lib/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ const writeJsConfig = (path, logger) => {
*/
const compileFromFile = async (inputFile, parameters) => {
const paths = typeof inputFile === 'string' ? normalize(inputFile) : inputFile.map(f => normalize(f))
const csn = await cds.linked(await cds.load(paths, { docs: true, flavor: 'xtended' }))
return compileFromCSN(csn, parameters)
const xtended = await cds.linked(await cds.load(paths, { docs: true, flavor: 'xtended' }))
const inferred = await cds.linked(await cds.load(paths, { docs: true }))
return compileFromCSN({xtended, inferred}, parameters)
}

/**
* Compiles a CSN object to Typescript types.
* @param csn {CSN}
* @param {{xtended: CSN, inferred: CSN}} csn
* @param parameters {CompileParameters} path to root directory for all generated files, min log level
*/
const compileFromCSN = async (csn, parameters) => {
Expand All @@ -69,6 +70,5 @@ const compileFromCSN = async (csn, parameters) => {
}

module.exports = {
compileFromFile,
compileFromCSN,
compileFromFile
}
31 changes: 24 additions & 7 deletions lib/components/enum.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* ```
*
* @param {Buffer} buffer Buffer to write into
* @param {string} name local name of the enum
* @param {string} name local name of the enum, i.e. the name under which it should be created in the .ts file
* @param {[string, string][]} kvs list of key-value pairs
*/
function printEnum(buffer, name, kvs, options = {}) {
Expand Down Expand Up @@ -66,12 +66,11 @@ const csnToEnum = ({enum: enm, type}, options = {}) => {
}

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

// if the type is in csn.definitions, then it's actually referring
// to an external enum. Those are handled elsewhere.
/**
* A type is considered to be an inline enum, iff it has a `.enum` property
* _and_ its type is a CDS primitive, i.e. it is not contained in `cds.definitions`.
Expand All @@ -83,9 +82,27 @@ const propertyToInlineEnumName = (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]))`

/**
* @param {string} name
* @param {string} fq
* @returns {string}
*/
const stringifyNamedEnum = (name, fq) => stringifyEnumImplementation(name, `cds.model.definitions['${fq}'].enum`)
/**
* @param {string} name
* @param {string} fq
* @param {string} property
* @returns {string}
*/
const stringifyAnonymousEnum = (name, fq, property) => stringifyEnumImplementation(fq, `cds.model.definitions['${name}'].elements.${property}.enum`)

module.exports = {
printEnum,
csnToEnum,
propertyToInlineEnumName,
isInlineEnumType
propertyToAnonymousEnumName,
isInlineEnumType,
stringifyNamedEnum,
stringifyAnonymousEnum
}
7 changes: 4 additions & 3 deletions lib/components/resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const util = require('../util')
const { Buffer, SourceFile, Path, Library, baseDefinitions } = require("../file")
const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('./wrappers')
const { StructuredInlineDeclarationResolver } = require("./inline")
const { isInlineEnumType, propertyToInlineEnumName } = require('./enum')
const { isInlineEnumType, propertyToInlineEnumName, propertyToAnonymousEnumName } = require('./enum')

/** @typedef {{ cardinality?: { max?: '*' | number } }} EntityCSN */
/** @typedef {{ definitions?: Object<string, EntityCSN> }} CSN */
Expand Down Expand Up @@ -64,7 +64,7 @@ const Builtins = {
}

class Resolver {
get csn() { return this.visitor.csn }
get csn() { return this.visitor.csn.inferred }

/** @param {Visitor} visitor */
constructor(visitor) {
Expand Down Expand Up @@ -107,6 +107,7 @@ class Resolver {
* @returns {string} the entity name without leading namespace.
*/
trimNamespace(p) {
// TODO: we might want to cache this
// start on right side, go up while we have an entity at hand
// we cant start on left side, as that clashes with undefined entities like "sap"
const parts = p.split('.')
Expand Down Expand Up @@ -361,7 +362,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 = propertyToInlineEnumName(cleanEntityName, element.name)
const enumName = propertyToAnonymousEnumName(cleanEntityName, element.name)
result.type = enumName
result.plainName = enumName
result.isInlineDeclaration = true
Expand Down
53 changes: 47 additions & 6 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 } = require('./components/enum')
const { printEnum, stringifyNamedEnum, stringifyAnonymousEnum, propertyToAnonymousEnumName } = 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,7 +104,7 @@ class SourceFile extends File {
this.events = { buffer: new Buffer(), fqs: []}
/** @type {Buffer} */
this.types = new Buffer()
/** @type {{ buffer: Buffer, fqs: {name: string, fq: string}[]}} */
/** @type {{ buffer: Buffer, fqs: {name: string, fq: string, property?: string}[]}} */
this.enums = { buffer: new Buffer(), fqs: [] }
/** @type {{ buffer: Buffer }} */
this.inlineEnums = { buffer: new Buffer() }
Expand Down Expand Up @@ -221,17 +221,56 @@ class SourceFile extends File {

/**
* Adds an enum to this file.
* @param {string} fq fully qualified name of the enum
* @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.
* If not, it is considered to be regular, named enum.
*/
addEnum(fq, name, kvs) {
this.enums.fqs.push({ name, fq })
printEnum(this.enums.buffer, name, kvs)
}

addInlineEnum(name, kvs) {
printEnum(this.inlineEnums.buffer, name, kvs, {export: false})
/**
* Adds an anonymous 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 not, it is considered to be regular, named enum.
*
* @example
* ```js
* addAnonymousEnum('Books.genre', 'Books', 'genre', [['horror','horror']])
* ```
* generates
* ```js
* // index.js
* module.exports.Books.genre = F(cds.model.definitions['Books'].elements.genre.enum)
* // F(...) is a function that maps a CSN enum to a more convenient style
* ```
* and also
* ```ts
* // index.ts
* const Books_genre = { horror: 'horror' }
* type Books_genre = 'horror'
* class Book {
* static genre = Books_genre
* genre: Books_genre
* }
* ```
*/
addAnonymousEnum(entityCleanName, entityFqName, propertyName, kvs) {
this.enums.fqs.push({
name: entityFqName,
property: propertyName,
fq: `${entityCleanName}.${propertyName}`
})
printEnum(this.inlineEnums.buffer, propertyToAnonymousEnumName(entityCleanName, propertyName), kvs, {export: false})
}

/**
Expand Down Expand Up @@ -352,7 +391,9 @@ class SourceFile extends File {
.concat(['// actions'])
.concat(this.actions.names.map(name => `module.exports.${name} = '${name}'`))
.concat(['// enums'])
.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]))`))
.concat(this.enums.fqs.map(({name, fq, property}) => property
? stringifyAnonymousEnum(name, fq, property)
: stringifyNamedEnum(name, fq)))
.join('\n') + '\n'
}
}
Expand Down
47 changes: 29 additions & 18 deletions lib/visitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = r
const { Resolver } = require('./components/resolver')
const { Logger } = require('./logging')
const { docify } = require('./components/wrappers')
const { csnToEnum, propertyToInlineEnumName, isInlineEnumType } = require('./components/enum')
const { csnToEnum, propertyToAnonymousEnumName, isInlineEnumType } = require('./components/enum')

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

/**
* @param csn root CSN
* @param {{xtended: CSN, inferred: CSN}} csn root CSN
* @param {VisitorOptions} options
*/
constructor(csn, options = {}, logger = new Logger()) {
amendCSN(csn)
amendCSN(csn.xtended)
this.options = { ...defaults, ...options }
this.logger = logger
this.csn = csn
Expand Down Expand Up @@ -97,13 +97,30 @@ class Visitor {
* Visits all definitions within the CSN definitions.
*/
visitDefinitions() {
for (const [name, entity] of Object.entries(this.csn.definitions)) {
for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) {
if (entity._unresolved === true) {
this.logger.error(`Skipping unresolved entity: ${JSON.stringify(entity)}`)
} else {
this.visitEntity(name, entity)
}
}
// FIXME: optimise
// We are currently working with two flavours of CSN:
// xtended, as it is as close as possible to an OOP class hierarchy
// inferred, as it contains information missing in xtended
// This is less than optimal and has to be revisited at some point!
const handledKeys = new Set(Object.keys(this.csn.xtended.definitions))
// we are looking for autoexposed entities in services
const missing = Object.entries(this.csn.inferred.definitions).filter(([key]) => !key.endsWith('.texts') &&!handledKeys.has(key))
for (const [name, entity] of missing) {
// instead of using the definition from inferred CSN, we refer to the projected entity from xtended CSN instead.
// The latter contains the CSN fixes (propagated foreign keys, etc) and none of the localised fields we don't handle yet.
if (entity.projection) {
this.visitEntity(name, this.csn.xtended.definitions[entity.projection.from.ref[0]])
} else {
this.logger.error(`Expecting an autoexposed projection within a service. Skipping ${name}`)
}
}
}

/**
Expand Down Expand Up @@ -142,29 +159,22 @@ class Visitor {
// lookup in cds.definitions can fail for inline structs.
// We don't really have to care for this case, as keys from such structs are _not_ propagated to
// the containing entity.
for (const [kname, kelement] of Object.entries(this.csn.definitions[element.target]?.keys ?? {})) {
for (const [kname, kelement] of Object.entries(this.csn.xtended.definitions[element.target]?.keys ?? {})) {
this.visitElement(`${ename}_${kname}`, kelement, file, buffer)
}
}

// store inline enums for later handling, as they have to go into one common "static elements" wrapper
if (isInlineEnumType(element, this.csn)) {
if (isInlineEnumType(element, this.csn.xtended)) {
enums.push(element)
}
}

if (enums.length) {
buffer.add('static elements = {')
buffer.indent()
for (const e of enums) {
const enumName = propertyToInlineEnumName(clean, e.name)
file.addInlineEnum(enumName, csnToEnum(e, {unwrapVals: false}))
buffer.add(`${e.name}: { enum: ${enumName} },`)
}
buffer.outdent()
buffer.add('}')
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 actions: {')
buffer.indent()
for (const [aname, action] of Object.entries(entity.actions ?? {})) {
Expand All @@ -178,6 +188,7 @@ class Visitor {
)
}
buffer.outdent()
buffer.outdent()
buffer.add('}') // end of actions

buffer.outdent()
Expand Down Expand Up @@ -234,7 +245,7 @@ class Visitor {
`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.`
)
}
if (singular in this.csn.definitions) {
if (singular in this.csn.xtended.definitions) {
this.logger.error(
`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.`
)
Expand Down
18 changes: 18 additions & 0 deletions test/unit/autoexpose.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict'

const fs = require('fs').promises
const path = require('path')
const { ASTWrapper, checkFunction, check } = require('../ast')
const { locations, cds2ts } = require('../util')

const dir = locations.testOutput('autoexpose_test')

describe('Autoexpose', () => {
beforeEach(async () => await fs.unlink(dir).catch(_ => {}))

test('Autoexposed Composition Target Present in Service', async () => {
const paths = await cds2ts('autoexpose/service.cds', { outputDirectory: dir, inlineDeclarations: 'structured' })
const ast = new ASTWrapper(path.join(paths[1], 'index.ts')).tree
expect(ast.find(n => n.name === 'Books')).toBeTruthy()
})
})
Loading

0 comments on commit 102bcf5

Please sign in to comment.