diff --git a/CHANGELOG.md b/CHANGELOG.md index 50dfe263..2bcc32fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -### Added +### Added +- dedicated classes for inline compositions +- dedicated text-classes for entities with `localized` elements + ### Changed - prefixed builtin types like `Promise` and `Record` with `globalThis.`, to allow using names of builtin types for entities without collisions ### Deprecated diff --git a/lib/csn.js b/lib/csn.js index 9993b712..66f9f037 100644 --- a/lib/csn.js +++ b/lib/csn.js @@ -1,4 +1,5 @@ const { LOG } = require('./logging') +const { annotations } = require('./util') const DRAFT_ENABLED_ANNO = '@odata.draft.enabled' /** @type {string[]} */ @@ -292,6 +293,30 @@ function propagateForeignKeys(csn) { } } +/** + * Clears "correct" singular/plural annotations from inferred model + * copies the ones from the xtended model. + * + * This is done to prevent potential duplicate class names because of annotation propagation. + * @param {{inferred: CSN, xtended: CSN}} csn - CSN models + */ +function propagateInflectionAnnotations(csn) { + const singularAnno = annotations.singular[0] + const pluralAnno = annotations.plural[0] + for (const [name, def] of Object.entries(csn.inferred.definitions)) { + const xtendedDef = csn.xtended.definitions[name] + // we keep the annotations from definition specific to the inferred model (e.g. inline compositions) + if (!xtendedDef) continue + + // clear annotations from inferred definition + if (Object.hasOwn(def, singularAnno)) delete def[singularAnno] + if (Object.hasOwn(def, pluralAnno)) delete def[pluralAnno] + // transfer annotation from xtended if existing + if (Object.hasOwn(xtendedDef, singularAnno)) def[singularAnno] = xtendedDef[singularAnno] + if (Object.hasOwn(xtendedDef, pluralAnno)) def[pluralAnno] = xtendedDef[pluralAnno] + } +} + /** * @param {EntityCSN} entity - the entity */ @@ -326,5 +351,6 @@ module.exports = { getProjectionAliases, getViewTarget, propagateForeignKeys, + propagateInflectionAnnotations, isCsnAny } diff --git a/lib/file.js b/lib/file.js index 4f0ada50..ce35733e 100644 --- a/lib/file.js +++ b/lib/file.js @@ -257,6 +257,7 @@ class SourceFile extends File { if (!(name in this.namespaces)) { const buffer = new Buffer() buffer.closed = false + buffer.namespace = name buffer.add(`export namespace ${name} {`) buffer.indent() this.namespaces[name] = buffer @@ -286,6 +287,8 @@ class SourceFile extends File { * @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 + * @param {Buffer} [buffer] - if buffer is of subnamespace the enum will be added there, + * otherwise to the inline enums of the file * @param {string[]} doc - the enum docs * If given, the enum is considered to be an inline definition of an enum. * If not, it is considered to be regular, named enum. @@ -310,16 +313,32 @@ class SourceFile extends File { * } * ``` */ - addInlineEnum(entityCleanName, entityFqName, propertyName, kvs, doc=[]) { + addInlineEnum(entityCleanName, entityFqName, propertyName, kvs, buffer, doc=[]) { + const namespacedEntity = [buffer?.namespace, entityCleanName].filter(Boolean).join('.') this.enums.data.push({ - name: `${entityCleanName}.${propertyName}`, + name: `${namespacedEntity}.${propertyName}`, property: propertyName, kvs, - fq: `${entityCleanName}.${propertyName}` + fq: `${namespacedEntity}.${propertyName}` }) - const entityProxy = this.entityProxies[entityCleanName] ?? (this.entityProxies[entityCleanName] = []) + const entityProxy = this.entityProxies[namespacedEntity] ?? (this.entityProxies[namespacedEntity] = []) entityProxy.push(propertyName) - printEnum(this.inlineEnums.buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false}, doc) + + // REVISIT: find a better way to do this??? + const printEnumToBuffer = (/** @type {Buffer} */buffer) => printEnum(buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false}, doc) + + if (buffer?.namespace) { + const tempBuffer = new Buffer() + // we want to put the enums on class level + tempBuffer.indent() + printEnumToBuffer(tempBuffer) + + // we want to write the enums at the beginning of the namespace + const [first,...rest] = buffer.parts + buffer.parts = [first, ...tempBuffer.parts, ...rest] + } else { + printEnumToBuffer(this.inlineEnums.buffer) + } } /** @@ -490,12 +509,15 @@ class SourceFile extends File { return { singularRhs: `createEntityProxy(['${namespace}', '${original}'], { target: { is_singular: true }${customPropsStr} })`, - pluralRhs: `createEntityProxy(['${namespace}', '${original}'])`, + pluralRhs: `createEntityProxy(['${namespace}', '${original}'], { target: { is_singular: false }})`, } } else { + // standard entity: csn.Books + // inline entity: csn['Books.texts'] + const csnAccess = original.includes('.') ? `csn['${original}']` : `csn.${original}` return { - singularRhs: `{ is_singular: true, __proto__: csn.${original} }`, - pluralRhs: `csn.${original}` + singularRhs: `{ is_singular: true, __proto__: ${csnAccess} }`, + pluralRhs: csnAccess } } } @@ -589,6 +611,11 @@ class Buffer { * @type {boolean} */ this.closed = false + /** + * Required for inline enums of inline compositions or text entities + * @type {string | undefined} + */ + this.namespace = undefined } /** diff --git a/lib/printers/javascript.js b/lib/printers/javascript.js index 80c61278..1d22016d 100644 --- a/lib/printers/javascript.js +++ b/lib/printers/javascript.js @@ -84,12 +84,14 @@ class ESMPrinter extends JavaScriptPrinter { /** @type {JavaScriptPrinter['printDeconstructedImport']} */ printDeconstructedImport (imports, from) { - return `import { ${imports.join(', ')} } from '${from}'` + return `import { ${imports.join(', ')} } from '${from}/index.js'` } /** @type {JavaScriptPrinter['printExport']} */ printExport (name, value) { - return `export const ${name} = ${value}` + return name.includes('.') + ? `${name} = ${value}` + : `export const ${name} = ${value}` } /** @type {JavaScriptPrinter['printDefaultExport']} */ diff --git a/lib/resolution/entity.js b/lib/resolution/entity.js index cf2367a8..0b5b30b6 100644 --- a/lib/resolution/entity.js +++ b/lib/resolution/entity.js @@ -61,6 +61,22 @@ class EntityInfo { /** @type {import('../typedefs').resolver.EntityCSN | undefined} */ #csn + /** @type {Set | undefined} */ + #inheritedElements + + /** @returns set of inherited elements (e.g. ID of aspect cuid) */ + get inheritedElements() { + if (this.#inheritedElements) return this.#inheritedElements + this.#inheritedElements = new Set() + for (const parentName of this.csn.includes ?? []) { + const parent = this.#repository.getByFq(parentName) + for (const element of Object.keys(parent?.csn?.elements ?? {})) { + this.#inheritedElements.add(element) + } + } + return this.#inheritedElements + } + /** @returns the **inferred** csn for this entity. */ get csn () { return this.#csn ??= this.#resolver.csn.definitions[this.fullyQualifiedName] diff --git a/lib/resolution/resolver.js b/lib/resolution/resolver.js index ba2f6f17..68d622be 100644 --- a/lib/resolution/resolver.js +++ b/lib/resolution/resolver.js @@ -24,7 +24,7 @@ const { configuration } = require('../config') /** @typedef {{typeName: string, typeInfo: TypeResolveInfo & { inflection: Inflection }}} ResolveAndRequireInfo */ class Resolver { - get csn() { return this.visitor.csn.inferred } + get csn() { return this.visitor.csn } /** @param {Visitor} visitor - the visitor */ constructor(visitor) { @@ -165,11 +165,13 @@ class Resolver { */ const isPropertyOf = (property, entity) => property && Object.hasOwn(entity?.elements ?? {}, property) - const defs = this.visitor.csn.inferred.definitions + const defs = this.visitor.csn.definitions + + // check if name is already an entity, then we do not have a property access, but a nested entity + if (defs[p]?.kind === 'entity') return [] + // assume parts to contain [Namespace, Service, Entity1, Entity2, Entity3, property1, property2] - /** @type {string} */ - // @ts-expect-error - nope, we know there is at least one element - let qualifier = parts.shift() + let qualifier = /** @type {string} */ (parts.shift()) // find first entity from left (Entity1) while ((!defs[qualifier] || !isEntity(defs[qualifier])) && parts.length) { qualifier += `.${parts.shift()}` @@ -240,6 +242,8 @@ class Resolver { } else { // TODO: make sure the resolution still works. Currently, we only cut off the namespace! plural = util.getPluralAnnotation(typeInfo.csn) ?? typeInfo.plainName + // remove leading entity name + if (plural.includes('.')) plural = last(plural) singular = util.getSingularAnnotation(typeInfo.csn) ?? util.singular4(typeInfo.csn, true) // util.singular4(typeInfo.csn, true) // can not use `plural` to honor possible @singular annotation // don't slice off namespace if it isn't part of the inflected name. @@ -311,18 +315,6 @@ class Resolver { } else { let { singular, plural } = targetTypeInfo.typeInfo.inflection - // FIXME: super hack!! - // Inflection currently does not retain the scope of the entity. - // But we can't just fix it in inflection(...), as that would break several other things - // So we bandaid-fix it back here, as it is the least intrusive place -- but this should get fixed asap! - if (target.type) { - const untangled = this.visitor.entityRepository.getByFqOrThrow(target.type) - const scope = untangled.scope.join('.') - if (scope && !singular.startsWith(scope)) { - singular = `${scope}.${singular}` - } - } - typeName = cardinality > 1 ? toMany(plural) : toOne(this.visitor.isSelfReference(target) ? 'this' : singular) @@ -370,8 +362,14 @@ class Resolver { // handle typeof (unless it has already been handled above) const target = element.target?.name ?? element.type?.ref?.join('.') ?? element.type if (target && !typeInfo.isDeepRequire) { - const { propertyAccess } = this.visitor.entityRepository.getByFq(target) ?? {} - if (propertyAccess?.length) { + const { propertyAccess, scope } = this.visitor.entityRepository.getByFq(target) ?? {} + if (scope?.length) { + // update inflections with proper prefix, e.g. Books.text, Books.texts + typeInfo.inflection = { + singular: [...scope, typeInfo.inflection?.singular].join('.'), + plural: [...scope, typeInfo.inflection?.plural].join('.') + } + } else if (propertyAccess?.length) { const element = target.slice(0, -propertyAccess.join('.').length - 1) const access = this.visitor.inlineDeclarationResolver.getTypeLookup(propertyAccess) // singular, as we have to access the property of the entity @@ -452,6 +450,7 @@ class Resolver { const cardinality = getMaxCardinality(element) + /** @type {TypeResolveInfo} */ const result = { isBuiltin: false, // will be rectified in the corresponding handlers, if needed isInlineDeclaration: false, @@ -569,7 +568,7 @@ class Resolver { * @returns @see resolveType */ resolveTypeName(t, into) { - const result = into ?? {} + const result = into ?? /** @type {TypeResolveInfo} */({}) const path = t.split('.') const builtin = this.builtinResolver.resolveBuiltin(path) if (builtin === undefined) { diff --git a/lib/typedefs.d.ts b/lib/typedefs.d.ts index 60a63eba..37f27dd3 100644 --- a/lib/typedefs.d.ts +++ b/lib/typedefs.d.ts @@ -13,7 +13,7 @@ export module resolver { compositions?: { target: string }[] doc?: string, elements?: { [key: string]: EntityCSN } - key?: string // custom!! + key?: boolean // custom!! keys?: { [key:string]: any } kind: string, includes?: string[] @@ -25,6 +25,8 @@ export module resolver { target?: string, type: string | ref, name: string, + '@singular'?: string, + '@plural'?: string, '@odata.draft.enabled'?: boolean // custom! _unresolved?: boolean isRefNotNull?: boolean // custom! diff --git a/lib/util.js b/lib/util.js index decdcef3..f4a4ce66 100644 --- a/lib/util.js +++ b/lib/util.js @@ -15,10 +15,10 @@ if (process.version.startsWith('v14')) { const last = /\w+$/ -const annotations = { +const annotations = /** @type {const} */ ({ singular: ['@singular'], plural: ['@plural'], -} +}) /** * Converts a camelCase string to snake_case. diff --git a/lib/visitor.js b/lib/visitor.js index 9d211354..53cd5892 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -1,8 +1,6 @@ 'use strict' -const util = require('./util') - -const { isView, isUnresolved, propagateForeignKeys, collectDraftEnabledEntities, isDraftEnabled, isType, isProjection, getMaxCardinality, isViewOrProjection, isEnum, isEntity } = require('./csn') +const { propagateForeignKeys, propagateInflectionAnnotations, collectDraftEnabledEntities, isDraftEnabled, isType, getMaxCardinality, isViewOrProjection, isEnum, isEntity } = require('./csn') // eslint-disable-next-line no-unused-vars const { SourceFile, FileRepository, Buffer, Path } = require('./file') const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline') @@ -44,11 +42,12 @@ class Visitor { * @param {{xtended: CSN, inferred: CSN}} csn - root CSN */ constructor(csn) { - propagateForeignKeys(csn.xtended) propagateForeignKeys(csn.inferred) - // has to be executed on the inferred model as autoexposed entities are not included in the xtended csn + propagateInflectionAnnotations(csn) collectDraftEnabledEntities(csn.inferred) - this.csn = csn + + // xtendend csn not required after this point -> continue with inferred + this.csn = csn.inferred /** @type {Context[]} **/ this.contexts = [] @@ -74,41 +73,8 @@ class Visitor { * Visits all definitions within the CSN definitions. */ visitDefinitions() { - for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) { - if (isView(entity)) { - this.visitEntity(name, this.csn.inferred.definitions[name]) - } else if (isProjection(entity) || !isUnresolved(entity)) { - this.visitEntity(name, entity) - } else { - LOG.warn(`Skipping unresolved entity: ${name}`) - } - } - // 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) { - const targetName = entity.projection.from.ref[0] - // FIXME: references to types of entity properties may be missing from xtendend flavour (see #103) - // this should be revisted once we settle on a single flavour. - const target = this.csn.xtended.definitions[targetName] ?? this.csn.inferred.definitions[targetName] - if (target.kind !== 'type') { - // skip if the target is a property, like in: - // books: Association to many Author.books ... - // as this would result in a type definition that - // name-clashes with the actual declaration of Author - this.visitEntity(name, target) - } - } else { - LOG.error(`Expecting an autoexposed projection within a service. Skipping ${name}`) - } + for (const [name, entity] of Object.entries(this.csn.definitions)) { + this.visitEntity(name, entity) } } @@ -119,15 +85,8 @@ class Visitor { * @returns {[string, object][]} array of key name and key element pairs */ #keys(fq) { - // FIXME: this is actually pretty bad, as not only have to propagate keys through - // both flavours of CSN (see constructor), but we are now also collecting them from - // both flavours and deduplicating them. - // xtended contains keys that have been inherited from parents - // inferred contains keys from queried entities (thing `entity Foo as select from Bar`, where Bar has keys) - // So we currently need them both. return Object.entries({ - ...this.csn.inferred.definitions[fq]?.keys ?? {}, - ...this.csn.xtended.definitions[fq]?.keys ?? {} + ...this.csn.definitions[fq]?.keys ?? {} }) } @@ -233,7 +192,7 @@ class Visitor { // FIXME: replace with resolution/entity::asIdentifier const toLocalIdent = ({ns, clean, fq}) => { // types are not inflected, so don't change those to singular - const csn = this.csn.inferred.definitions[fq] + const csn = this.csn.definitions[fq] const ident = isType(csn) ? clean : this.resolver.inflect({csn, plainName: clean}).singular @@ -263,6 +222,7 @@ class Visitor { .reverse() // reverse so that own aspect A is applied before extensions B,C: B(C(A(Entity))) .reduce((wrapped, ancestor) => `${asIdentifier({info: ancestor, wrapper: name => `_${name}Aspect`, relative: file.path})}(${wrapped})`, 'Base') + const inheritedElements = !isViewOrProjection(entity) ? info.inheritedElements : null this.contexts.push({ entity: fq }) // CLASS ASPECT @@ -274,10 +234,7 @@ class Visitor { const resolverOptions = { forceInlineStructs: isEntity(entity) && configuration.inlineDeclarations === 'flat'} for (let [ename, element] of Object.entries(entity.elements ?? [])) { - if (element.target && /\.texts?/.test(element.target)) { - LOG.warn(`referring to .texts property in ${fq}. This is currently not supported and will be ignored.`) - continue - } + if (inheritedElements?.has(ename)) continue this.visitElement({name: ename, element, file, buffer, resolverOptions}) // make foreign keys explicit @@ -292,7 +249,8 @@ class Visitor { LOG.error(`Attempting to generate a foreign key reference called '${foreignKey}' in type definition for entity ${fq}. But a property of that name is already defined explicitly. Consider renaming that property.`) } else { const kelement = Object.assign(Object.create(originalKeyElement), { - isRefNotNull: !!element.notNull || !!element.key + isRefNotNull: !!element.notNull || !!element.key, + key: element.key }) this.visitElement({name: foreignKey, element: kelement, file, buffer, resolverOptions}) } @@ -301,7 +259,7 @@ class Visitor { } // store inline enums for later handling, as they have to go into one common "static elements" wrapper - if (isInlineEnumType(element, this.csn.xtended)) { + if (isInlineEnumType(element, this.csn)) { enums.push(element) } } @@ -314,7 +272,7 @@ class Visitor { initialiser: propertyToInlineEnumName(clean, e.name), isStatic: true, })) - file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}), eDoc) + file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}), buffer, eDoc) } if ('kind' in entity) { @@ -357,7 +315,7 @@ class Visitor { */ #printEntity(fq, entity) { const info = this.entityRepository.getByFqOrThrow(fq) - const { namespace: ns, entityName: clean, inflection } = info + const { namespace: ns, entityName: clean, inflection, scope } = info const file = this.fileRepository.getNamespaceFile(ns) let { singular, plural } = inflection @@ -373,7 +331,7 @@ class Visitor { // as types are not inflected, their singular will always clash and there is also no plural for them anyway -> skip // if the user defined their entities in singular form we would also have a false positive here -> skip const namespacedSingular = `${ns.asNamespace()}.${singular}` - if (!isType(entity) && namespacedSingular !== fq && namespacedSingular in this.csn.xtended.definitions) { + if (!isType(entity) && namespacedSingular !== fq && namespacedSingular in this.csn.definitions) { LOG.error( `Derived singular '${singular}' for your entity '${fq}', already exists. The resulting types will be erronous. Consider using '@singular:'/ '@plural:' annotations in your model or move the offending declarations into different namespaces to resolve this collision.` ) @@ -386,20 +344,15 @@ class Visitor { ? file.getSubNamespace(this.resolver.trimNamespace(parent.name)) : file.classes - // we can't just use "singular" here, as it may have the subnamespace removed: - // "Books.text" is just "text" in "singular". Within the inflected exports we need - // to have Books.texts = Books.text, so we derive the singular once more without cutting off the ns. - // Directly deriving it from the plural makes sure we retain any parent namespaces of kind "entity", - // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN. - // edge case: @singular annotation present. singular4 will take care of that. - file.addInflection(util.singular4(entity, true), plural, clean) - - // in case of projections `entity` is empty -> retrieve from inferred csn where the actual properties are rolled out - const target = isProjection(entity) || isView(entity) - ? this.csn.inferred.definitions[fq] - : entity + if (scope?.length > 0) { + /** @param {string} n - name of entity */ + const scoped = n => [...scope, n].join('.') + file.addInflection(scoped(singular), scoped(plural), scoped(clean)) + } else { + file.addInflection(singular, plural, clean) + } - this.#aspectify(fq, target, buffer, { cleanName: singular }) + this.#aspectify(fq, entity, buffer, { cleanName: singular }) buffer.add(overrideNameProperty(singular, entity.name)) buffer.add(`Object.defineProperty(${singular}, 'is_singular', { value: true })`) @@ -516,7 +469,7 @@ class Visitor { if (isEnum(type) && !isReferenceType(type) && this.resolver.builtinResolver.resolveBuiltin(type.type)) { file.addEnum(fq, entityName, csnToEnumPairs(type), docify(type.doc)) } else { - const isEnumReference = typeof type.type === 'string' && isEnum(this.csn.inferred.definitions[type?.type]) + const isEnumReference = typeof type.type === 'string' && isEnum(this.csn.definitions[type?.type]) // alias file.addType(fq, entityName, this.resolver.resolveAndRequire(type, file).typeName, isEnumReference) } diff --git a/test/ast.js b/test/ast.js index 8b7b6155..0947788b 100644 --- a/test/ast.js +++ b/test/ast.js @@ -103,10 +103,17 @@ function visitCallExpression(node) { * @returns {ModuleDeclaration} */ function visitModuleDeclaration(node) { + const nodeNames = [node.name] + let body = node.body + // consider nested modules + while (!body.statements) { + nodeNames.push(body.name) + body = body.body + } return { nodeType: kinds.ModuleDeclaration, - name: visit(node.name), - body: node.body.statements.map(visit) + name: nodeNames.map(n => visit(n)).join('.'), + body: body.statements.map(visit) } } @@ -326,9 +333,12 @@ class ASTWrapper { return this.tree.filter(n => n.nodeType === kinds.ImportDeclaration) } - /** @returns {FunctionDeclaration[]} */ - getAspectFunctions() { - return this.tree.filter(n => n.nodeType === kinds.FunctionDeclaration + /** + * @param {object[]} [tree] - tree to be used + * @returns {FunctionDeclaration[]} + */ + getAspectFunctions(tree) { + return (Array.isArray(tree) ? tree : this.tree).filter(n => n.nodeType === kinds.FunctionDeclaration && n.body.length === 1 && n.body[0].nodeType === kinds.ClassExpression) } @@ -371,17 +381,23 @@ class ASTWrapper { // .filter(n => n.heritage?.at(0)?.subtypes?.at(0)?.keyword === keywords.ExpressionWithTypeArguments) // } - /** @returns {ClassDeclaration[]} */ - getInlineClassDeclarations() { + /** + * @param {object[]} [tree] - tree to be used + * @returns {ClassDeclaration[]} + */ + getInlineClassDeclarations(tree) { // this is total bogus, as its the same as getAspects... - return this.tree + return (Array.isArray(tree) ? tree : this.tree) .filter(n => n.nodeType === kinds.FunctionDeclaration) .map(fn => ({...fn.body[0], name: fn.name })) } - /** @returns {ClassExpression[]} */ - getAspects() { - return this.getAspectFunctions().map(({name, body}) => ({...body[0], name})) + /** + * @param {object[]} [tree] - tree to be used + * @returns {ClassExpression[]} + */ + getAspects(tree) { + return this.getAspectFunctions(tree).map(({name, body}) => ({...body[0], name})) } getAspect(name) { @@ -398,8 +414,14 @@ class ASTWrapper { } exists(clazz, property, type, typeArg) { - const entities = this.getInlineClassDeclarations().concat(this.getAspects()) - const clz = entities.find(c => c.name === clazz) + const getEntities = clazz => { + let tree = this.tree + const module = clazz.split('.').slice(0, -1).join('.') + if (module) tree = this.getModuleDeclaration(module).body + return this.getInlineClassDeclarations(tree).concat(this.getAspects(tree)) + } + const entities = getEntities(clazz) + const clz = entities.find(c => c.name === clazz.split('.').at(-1)) if (!clz) throw Error(`no class with name ${clazz}`) if (!property) return true @@ -427,8 +449,13 @@ class JSASTWrapper { this.program = acorn.parse(code, { ecmaVersion: 'latest'}) } - exportsAre(expected) { - if (expected.length < this.getExports().length) throw new Error(`there are more actual than expected exports. Expected ${expected.length}, found ${this.getExports().length}`) + /** + * @param {[string,string][]} expected - expected export as tuples of `[, ]` + * @param {'enum' | 'entity'} [exportType] - the type of export + */ + exportsAre(expected, exportType = 'entity') { + const exports = this.getExports()?.filter(e => e.type === exportType) + if (expected.length < exports.length) throw new Error(`there are more actual than expected exports. Expected ${expected.length}, found ${exports.length}`) for (const [lhs, rhs] of expected) { if (!this.hasExport(lhs, rhs)) throw new Error(`missing export module.exports.${lhs} = ${rhs}`) } @@ -455,30 +482,75 @@ class JSASTWrapper { if (!customProps.every(c => propKeys.includes(c))) throw new Error('not all expected custom props found in argument') } + getExport(name) { + return this.getExports().find(e => e.lhs === name) + } + hasExport(lhs, rhs) { return this.getExports().find(exp => exp.lhs === lhs && (exp.rhs === rhs || exp.rhs.name === rhs)) } /** - * @returns {{ lhs: string, rhs: string | { singular: boolean, name: string }}} + * Collects modules export in the following formats + * - `module.exports.A = createEntityProxy(['namespace', 'A'], { target: { is_singular: true }})` + * - `module.exports.A.sub = createEntityProxy(['namespace', 'A.sub'], { target: { is_singular: true }})` + * - `module.exports.A = { is_singular: true, __proto__: csn.A }` + * - `module.exports.A.sub = csn['A.sub']` + * - `module.exports.A.sub = { is_singular: true, __proto__: csn['A.sub']}` + * - `module.exports.A.type = { a: 'a', b: 'b', c: 'c' } + * @returns {{ lhs: string, rhs: string | { singular: boolean, name: string } | Record, type: 'entity' | 'enum', proxyArgs?: any[]}[]} */ getExports() { - const processObjectLiteral = ({properties}) => ({ - singular: properties.find(p => p.key.name === 'is_singular' && p.value.value), - name: properties.find(p => p.key.name === '__proto__')?.value.property.name - }) + const processRhsObjLiteral = ({ properties }) => { + const proto = properties.find(p => p.key.name === '__proto__') + return { + singular: !!properties.find(p => p.key.name === 'is_singular' && p.value.value), + name: proto?.value.property.name ?? proto?.value.property.value, + } + } + const isLhsCsnExport = obj => { + if (!obj || !('object' in obj) || !('property' in obj)) return false + return (obj.object?.name === 'module' && obj.property?.name === 'exports') || isLhsCsnExport(obj.object) + } + const getLhsExportId = (obj, expPath = []) => { + if (obj?.property?.name && obj.property.name !== 'exports') expPath.push(obj.property.name) + if (obj?.object?.name && obj.object.name !== 'module') expPath.push(obj.object.name) + if (obj.object?.object) return getLhsExportId(obj.object, expPath) + return expPath.reverse().join('.') + } return this.exports ??= this.program.body.filter(node => { if (node.type !== 'ExpressionStatement') return false if (node.expression.left.type !== 'MemberExpression') return false - const { object, property } = node.expression.left.object - return object.name === 'module' && property.name === 'exports' + if ( + !node.expression.right.property?.name && + !node.expression.right.property?.value && + !node.expression.right.arguments && + !node.expression.right.properties + ) return false + return isLhsCsnExport(node.expression.left.object) }).map(node => { - return { - lhs: node.expression.left.property.name, - rhs: this.proxyExports - ? node.expression.right.arguments?.[0].elements[1].value - : node.expression.right.property?.name ?? processObjectLiteral(node.expression.right), - proxyArgs: node.expression.right.arguments + const { left, right } = node.expression + const exportId = getLhsExportId(left) + if (node.expression.operator === '??=') { + return { + lhs: exportId, + type: 'enum', + rhs: right.properties?.reduce((a, c) => { + a[c.key.name] = c.value.value + return a + }, {}), + } + } else { + return { + lhs: exportId, + type: 'entity', + rhs: this.proxyExports + ? right.arguments?.[0].elements[1].value // proxy function arg -> 'A.sub' + : right.property?.name ?? // csn.A + right.property?.value ?? // csn['A.sub'] + processRhsObjLiteral(right), // {__proto__: csn.A} | {__proto__: csn['A.sub']} + proxyArgs: right.arguments, + } } }) } diff --git a/test/unit/entitiesproxy.test.js b/test/unit/entitiesproxy.test.js index b383f6a5..e7762672 100644 --- a/test/unit/entitiesproxy.test.js +++ b/test/unit/entitiesproxy.test.js @@ -27,6 +27,8 @@ describe('Compilation/Runtime - with Entities Proxies', () => { jsw.exportsAre([ ['Book', 'Books'], ['Books', 'Books'], + ['Books.text', 'Books.texts'], + ['Books.texts', 'Books.texts'], ['Author', 'Authors'], ['Authors', 'Authors'], ['Genre', 'Genres'], diff --git a/test/unit/files/inflection/model.cds b/test/unit/files/inflection/model.cds index 2758078a..0be8a9aa 100644 --- a/test/unit/files/inflection/model.cds +++ b/test/unit/files/inflection/model.cds @@ -32,6 +32,8 @@ entity C { } +entity CSub : C {} + @UI.HeaderInfo.TypeName: 'OneD' @UI.HeaderInfo.TypeNamePlural: 'ManyDs' @singular: 'OneSingleD' @@ -39,6 +41,8 @@ entity D { } +entity DSub : D {} + entity Referer { // annotated a: Association to Bazes; diff --git a/test/unit/files/inlinecompositions/model.cds b/test/unit/files/inlinecompositions/model.cds new file mode 100644 index 00000000..bbe4bd29 --- /dev/null +++ b/test/unit/files/inlinecompositions/model.cds @@ -0,0 +1,59 @@ +using { + cuid, + sap.common.CodeList +} from '@sap/cds/common'; + + +namespace inl_comp; + +entity Genres : CodeList { + key code : String enum { + Fiction; + } +} + +@singular: 'PEditor' +@plural : 'PEditors' +aspect Editors : cuid { + name : String; +} + + +@singular: 'Bestseller' +@plural : 'Bestsellers' +entity Books : cuid { + title : String; + genre : Association to Genres; + publishers : Composition of many { + key ID : UUID; + name : String; + type : String enum { + self; + independent; + }; + // will get inflections from aspect + intEditors : Composition of many Editors; + // will get inflections from aspect + extEditors : Composition of many Editors; + offices : Composition of many { + key ID : UUID; + city : String; + zipCode : String; + size : String enum { + small; + medium; + large; + } + } + } +} + +// overwrite annotations from aspect to avoid name duplicates +annotate Books.publishers.extEditors with @singular: 'EEditor' + @plural : 'EEditors'; + + +service CatService { + // autoexposed inline compositions will have the same names as in the schema + entity Books as projection on inl_comp.Books; +} diff --git a/test/unit/files/inlinecompositions/model.ts b/test/unit/files/inlinecompositions/model.ts new file mode 100644 index 00000000..329c0a9d --- /dev/null +++ b/test/unit/files/inlinecompositions/model.ts @@ -0,0 +1,51 @@ +import cds from '@sap/cds' +import * as CatSrv from '#cds-models/inl_comp/CatService' +import { Genre, Books, Bestsellers } from '#cds-models/inl_comp' + +export class InlineCompService extends cds.ApplicationService { + override async init() { + // Checks on model entities + const office: Books.publishers.office = { + zipCode: '4934', + up__ID: '', + up__up__ID: '', + up_: { + name: '', + }, + size: Books.publishers.office.size.large, + } + const intEditor: Books.publishers.PEditor = { name: 'editor 1' } + const extEditors: Books.publishers.EEditors = [{ name: 'editor 2' }] + const publisher: Books.publisher = { + ID: '134', + name: 'Publisher1', + intEditors: [{name: 'Int. Editor 1'}, {name: 'Int. Editor 2'}], + extEditors: [{name: 'Ext. Editor 1'}], + offices: [{ + city: 'A', + zipCode: '4934', + size: Books.publishers.office.size.large + }] + } + const bestseller: Bestsellers = [{ genre_code: Genre.code.Fiction }] + + // Checks on Service entities + const book: CatSrv.Book = { + ID: '493493', + title: 'Book 1', + genre_code: CatSrv.Genre.code.Fiction, + publishers: [{ + ID: '134', + name: 'Publisher1', + intEditors: [{name: 'Int. Editor 1'}, {name: 'Int. Editor 2'}], + extEditors: [{name: 'Ext. Editor 1'}], + offices: [{ + city: 'A', + zipCode: '4934', + size: Books.publishers.office.size.large + }] + }] + } + return super.init() + } +} diff --git a/test/unit/files/localized/model.cds b/test/unit/files/localized/model.cds new file mode 100644 index 00000000..bc21c51c --- /dev/null +++ b/test/unit/files/localized/model.cds @@ -0,0 +1,8 @@ +using {cuid} from '@sap/cds/common.cds'; + +namespace localized_model; + +entity Books : cuid { + title : localized String; + authorName : String; +} diff --git a/test/unit/files/localized/model.ts b/test/unit/files/localized/model.ts new file mode 100644 index 00000000..cb2637df --- /dev/null +++ b/test/unit/files/localized/model.ts @@ -0,0 +1,19 @@ +import cds from '@sap/cds' +import { Books } from '#cds-models/localized_model' + +const bookText: Books.text = { + title: 'Book A', + locale: 'de' +} + +const book: Books = [{ + title: 'Book A', + authorName: 'John', + localized: { + title: 'Book A - default', + }, + texts: [ + { title: 'Book A - english', locale: 'en' }, + { title: 'Book A - deutsch', locale: 'de' }, + ], +}] diff --git a/test/unit/inlinecompositions.test.js b/test/unit/inlinecompositions.test.js new file mode 100644 index 00000000..8ccf62a2 --- /dev/null +++ b/test/unit/inlinecompositions.test.js @@ -0,0 +1,109 @@ +'use strict' + +const path = require('path') +const { describe, test, expect } = require('@jest/globals') +const { JSASTWrapper } = require('../ast') +const { locations, prepareUnitTest } = require('../util') + +/** + * @typedef {import('../ast').JSASTWrapper} JSASTWrapper + */ + +describe('Inline compositions', () => { + + test.each([ + ['with entities proxy', { useEntitiesProxy: true }], + ['with direct csn export', {}], + ])('Test exports > %s', async (_, typerOptions) => { + const paths = ( + await prepareUnitTest('inlinecompositions/model.cds', locations.testOutput('inlinecompositions_test'), { + typerOptions, + }) + ).paths + const jsw = await JSASTWrapper.initialise( + path.join(paths[1], 'index.js'), + typerOptions?.useEntitiesProxy ?? false + ) + jsw.exportsAre([ + ['Genre', 'Genres'], + ['Genres', 'Genres'], + ['Bestseller', 'Books'], + ['Bestsellers', 'Books'], + ['Books', 'Books'], + ['Genres.text', 'Genres.texts'], + ['Genres.texts', 'Genres.texts'], + ['Books.publisher', 'Books.publishers'], + ['Books.publishers', 'Books.publishers'], + ['Books.publishers.PEditor', 'Books.publishers.intEditors'], + ['Books.publishers.PEditors', 'Books.publishers.intEditors'], + ['Books.publishers.intEditors', 'Books.publishers.intEditors'], + ['Books.publishers.EEditor', 'Books.publishers.extEditors'], + ['Books.publishers.EEditors', 'Books.publishers.extEditors'], + ['Books.publishers.extEditors', 'Books.publishers.extEditors'], + ['Books.publishers.office', 'Books.publishers.offices'], + ['Books.publishers.offices', 'Books.publishers.offices'], + ]) + expect(jsw.getExport('Genre.code').rhs).toEqual({ Fiction: 'Fiction' }) + expect(jsw.getExport('Genres.text.code')?.rhs).toEqual({ Fiction: 'Fiction' }) + expect(jsw.getExport('Books.publisher.type')?.rhs).toEqual({ self: 'self', independent: 'independent' }) + expect(jsw.getExport('Books.publishers.office.size')?.rhs).toEqual({ + small: 'small', + medium: 'medium', + large: 'large', + }) + + if (typerOptions?.useEntitiesProxy) { + jsw.hasProxyExport('Genres.text', ['code']) + jsw.hasProxyExport('Books.publisher', ['type']) + jsw.hasProxyExport('Books.publishers.office', ['size']) + } + }) + + test.each([ + ['with entities proxy', { useEntitiesProxy: true }], + ['with direct csn export', {}], + ])('Test service exports > %s', async (_, typerOptions) => { + const paths = ( + await prepareUnitTest('inlinecompositions/model.cds', locations.testOutput('inlinecompositions_test'), { + typerOptions, + }) + ).paths + const jsw = await JSASTWrapper.initialise( + path.join(paths[2], 'index.js'), + typerOptions?.useEntitiesProxy ?? false + ) + jsw.exportsAre([ + ['Genre', 'Genres'], + ['Genres', 'Genres'], + ['Book', 'Books'], + ['Books', 'Books'], + ['Books', 'Books'], + ['Genres.text', 'Genres.texts'], + ['Genres.texts', 'Genres.texts'], + ['Books.publisher', 'Books.publishers'], + ['Books.publishers', 'Books.publishers'], + ['Books.publishers.PEditor', 'Books.publishers.intEditors'], + ['Books.publishers.PEditors', 'Books.publishers.intEditors'], + ['Books.publishers.intEditors', 'Books.publishers.intEditors'], + ['Books.publishers.EEditor', 'Books.publishers.extEditors'], + ['Books.publishers.EEditors', 'Books.publishers.extEditors'], + ['Books.publishers.extEditors', 'Books.publishers.extEditors'], + ['Books.publishers.office', 'Books.publishers.offices'], + ['Books.publishers.offices', 'Books.publishers.offices'], + ]) + expect(jsw.getExport('Genre.code').rhs).toEqual({ Fiction: 'Fiction' }) + expect(jsw.getExport('Genres.text.code')?.rhs).toEqual({ Fiction: 'Fiction' }) + expect(jsw.getExport('Books.publisher.type')?.rhs).toEqual({ self: 'self', independent: 'independent' }) + expect(jsw.getExport('Books.publishers.office.size')?.rhs).toEqual({ + small: 'small', + medium: 'medium', + large: 'large', + }) + + if (typerOptions?.useEntitiesProxy) { + jsw.hasProxyExport('Genres.text', ['code']) + jsw.hasProxyExport('Books.publisher', ['type']) + jsw.hasProxyExport('Books.publishers.office', ['size']) + } + }) +}) \ No newline at end of file diff --git a/test/unit/output.test.js b/test/unit/output.test.js index b5118214..e5829e5d 100644 --- a/test/unit/output.test.js +++ b/test/unit/output.test.js @@ -18,6 +18,8 @@ describe('Compilation', () => { jsw.exportsAre([ ['Book', 'Books'], ['Books', 'Books'], + ['Books.text', 'Books.texts'], + ['Books.texts', 'Books.texts'], ['Author', 'Authors'], ['Authors', 'Authors'], ['Genre', 'Genres'], @@ -184,11 +186,15 @@ describe('Compilation', () => { ['A_', 'A'], ['A', 'A'], ['C', 'C'], + ['CSub', 'CSub'], + ['CSub_', 'CSub'], ['LotsOfCs', 'C'], ['OneSingleD', 'D'], ['D', 'D'], + ['DSub', 'DSub'], + ['DSub_', 'DSub'], ['Referer', 'Referer'], - ['Referer_', 'Referer'] + ['Referer_', 'Referer'], ]) // ...are currently exceptions where both singular _and_ plural // are annotated and the original name is used as an export on top of that. @@ -207,6 +213,8 @@ describe('Compilation', () => { '_CAspect', '_OneSingleDAspect', '_RefererAspect', + '_CSubAspect', + '_DSubAspect' ] expect(aspects.length).toBe(expected.length) expect(aspects.map(({name}) => name)).toEqual(expect.arrayContaining(expected)) @@ -230,7 +238,11 @@ describe('Compilation', () => { 'D', 'D', 'Referer', - 'Referer_' + 'Referer_', + 'DSub', + 'DSub_', + 'CSub', + 'CSub_' ] expect(fns.map(({name}) => name)).toEqual(expect.arrayContaining(expected)) expect(fns.length).toBe(expected.length) diff --git a/test/unit/references.test.js b/test/unit/references.test.js index 52c693de..9fde582f 100644 --- a/test/unit/references.test.js +++ b/test/unit/references.test.js @@ -5,6 +5,7 @@ const { check } = require('../ast') const { locations, prepareUnitTest } = require('../util') describe('References', () => { + /** @type {import('../ast').ASTWrapper} */ let astw beforeAll(async () => astw = (await prepareUnitTest('references/model.cds', locations.testOutput('references_test'))).astw) @@ -24,27 +25,29 @@ describe('References', () => { m.type.name === 'many' && m.type.args[0].name === 'Foo_' )).toBeTruthy() - expect(astw.exists('_BarAspect', 'assoc_one_first_key', m => check.isNullable(m.type, [st => check.isKeyOf(st, check.isString)]))).toBeTruthy() - expect(astw.exists('_BarAspect', 'assoc_one_second_key', m => check.isNullable(m.type, [st => check.isKeyOf(st, check.isString)]))).toBeTruthy() - expect(astw.exists('_BarAspect', 'assoc_one_ID', m => check.isNullable(m.type, [st => check.isKeyOf(st, check.isString)]))).toBeTruthy() + expect(astw.exists('_BarAspect', 'assoc_one_first_key', m => check.isNullable(m.type, [st => check.isString(st)]))).toBeTruthy() + expect(astw.exists('_BarAspect', 'assoc_one_second_key', m => check.isNullable(m.type, [st => check.isString(st)]))).toBeTruthy() + expect(astw.exists('_BarAspect', 'assoc_one_ID', m => check.isNullable(m.type, [st => check.isString(st)]))).toBeTruthy() }) test('Inline', async () => { expect(astw.exists('_BarAspect', 'inl_comp_one', m => { const comp = m.type.subtypes[0] - const [a] = comp.args[0].members + const type = comp.args[0] return check.isNullable(m.type) && comp.name === 'of' - && a.name === 'a' - && check.isNullable(a.type, [check.isString]) + && type.full === 'Bar.inl_comp_one' })).toBeTruthy() + expect(astw.exists('Bar._inl_comp_oneAspect', 'a', m => check.isNullable(m.type))).toBeTruthy() + expect(astw.exists('Bar._inl_comp_oneAspect', 'ID', m => check.isKeyOf(m.type, check.isString))).toBeTruthy() + expect(astw.exists('Bar._inl_comp_oneAspect', 'up__id', m => check.isKeyOf(m.type, check.isNumber))).toBeTruthy() expect(astw.exists('_BarAspect', 'inl_comp_many', m => { const [arr] = m.type.args - return m.type.name === 'many' - && arr.name === 'Array' - && arr.args[0].members[0].name === 'a' - && check.isNullable(arr.args[0].members[0].type, [check.isString]) + return m.type.name === 'many' && arr.full === 'Bar.inl_comp_many_' })).toBeTruthy() + expect(astw.exists('Bar._inl_comp_manyAspect', 'a', m => check.isNullable(m.type))).toBeTruthy() + expect(astw.exists('Bar._inl_comp_manyAspect', 'up__id', m => check.isKeyOf(m.type, check.isNumber))).toBeTruthy() + // inline ID is not propagated into the parent entity expect(() => astw.exists('_BarAspect', 'inl_comp_one_ID')).toThrow(Error) }) diff --git a/test/unit/typeof.test.js b/test/unit/typeof.test.js index 49641fd6..ba2f1050 100644 --- a/test/unit/typeof.test.js +++ b/test/unit/typeof.test.js @@ -14,12 +14,13 @@ describe('Typeof Syntax', () => { test('Deep Required', async () => { const astw = (await prepareUnitTest('typeof/deep.cds', locations.testOutput('typeof_deep'))).astw expect(astw.exists('_UserRoleAspect', 'users', - m => check.isTypeReference(m.type) && check.isIndexedAccessType(m.type.args.at(0)) && check.isLiteral(m.type.args.at(0).indexType, 'roles') + m => check.isTypeReference(m.type) && check.isTypeReference(m.type.args.at(0), 'Users.roles') )).toBeTruthy() expect(astw.exists('_UserRoleGroupAspect', 'users', m => check.isNullable(m.type, [ - st => check.isTypeReference(st) && check.isIndexedAccessType(st.args[0]) && check.isLiteral(st.args[0].indexType, 'roleGroups') - ]))).toBeTruthy() + st => check.isTypeReference(st.args.at(0), 'Users.roleGroup') + ]) + )).toBeTruthy() }) test('Structured', async () => {