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

feat/refactor: use only one CSN flavor + other improvements #356

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
3 changes: 2 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ module.exports = [
ecmaVersion: 'latest',
sourceType: 'commonjs',
globals: {
...require('globals').node
...require('globals').node,
jest: true
}
},
files: ['**/*.js'],
Expand Down
5 changes: 2 additions & 3 deletions lib/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const writeJsConfig = path => {

/**
* Compiles a CSN object to Typescript types.
* @param {{xtended: import('./typedefs').resolver.CSN, inferred: import('./typedefs').resolver.CSN}} csn - csn tuple
* @param {import('./typedefs').resolver.CSN} csn - csn tuple
*/
const compileFromCSN = async csn => {

Expand All @@ -56,9 +56,8 @@ const compileFromCSN = async csn => {
*/
const compileFromFile = async inputFile => {
const paths = typeof inputFile === 'string' ? normalize(inputFile) : inputFile.map(f => normalize(f))
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})
return compileFromCSN(inferred)
}

module.exports = {
Expand Down
250 changes: 103 additions & 147 deletions lib/csn.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
const annotation = '@odata.draft.enabled'
const { LOG } = require('./logging')

const DRAFT_ENABLED_ANNO = '@odata.draft.enabled'
/** @type {string[]} */
const draftEnabledEntities = []

/** @typedef {import('./typedefs').resolver.CSN} CSN */
/** @typedef {import('./typedefs').resolver.EntityCSN} EntityCSN */
Expand Down Expand Up @@ -40,9 +44,9 @@ const isUnresolved = entity => entity._unresolved === true
const isCsnAny = entity => entity?.constructor?.name === 'any'

/**
* @param {EntityCSN} entity - the entity
* @param {string} fq - the fqn of an entity
*/
const isDraftEnabled = entity => entity['@odata.draft.enabled'] === true
const isDraftEnabled = fq => draftEnabledEntities.includes(fq)

/**
* @param {EntityCSN} entity - the entity
Expand Down Expand Up @@ -87,183 +91,144 @@ const getProjectionTarget = entity => isProjection(entity)
? entity.projection?.from?.ref?.[0]
: undefined

class DraftUnroller {
/** @type {Set<string>} */
#positives = new Set()
/** @type {{[key: string]: boolean}} */
#draftable = {}
/** @type {{[key: string]: string}} */
#projections = {}
class DraftEnabledEntityCollector {
/** @type {EntityCSN[]} */
#entities = []
#draftRoots = []
/** @type {string[]} */
#serviceNames = []
/** @type {CSN | undefined} */
#csn
set csn(c) {
this.#csn = c
if (c === undefined) return
this.#entities = Object.values(c.definitions)
this.#projections = this.#entities.reduce((pjs, entity) => {
if (isProjection(entity)) {
// @ts-ignore - we know that entity is a projection here
pjs[entity.name] = getProjectionTarget(entity)
}
return pjs
}, {})
}
get csn() { return this.#csn }
#compileError = false

/**
* @param {EntityCSN | string} entityOrFq - entity to set draftable annotation for.
* @param {boolean} value - whether the entity is draftable.
* @returns {string[]}
*/
#setDraftable(entityOrFq, value) {
const entity = typeof entityOrFq === 'string'
? this.#getDefinition(entityOrFq)
: entityOrFq
if (!entity) return // inline definition -- not found in definitions
entity[annotation] = value
this.#draftable[entity.name] = value
if (value) {
this.#positives.add(entity.name)
} else {
this.#positives.delete(entity.name)
}
#getServiceNames() {
return Object.values(this.#csn?.definitions ?? {}).filter(d => d.kind === 'service').map(d => d.name)
}

/**
* @param {EntityCSN | string} entityOrFq - entity to look draftability up for.
* @returns {boolean}
* @returns {EntityCSN[]}
*/
#getDraftable(entityOrFq) {
const entity = typeof entityOrFq === 'string'
? this.#getDefinition(entityOrFq)
: entityOrFq
// assert(typeof entity !== 'string')
const name = entity?.name ?? entityOrFq
// @ts-expect-error - .name not being present means entityOrFq is a string, so name is always a string and therefore a valid index
return this.#draftable[name] ??= this.#propagateInheritance(entity)
#collectDraftRoots() {
return Object.values(this.#csn?.definitions ?? {}).filter(
d => isEntity(d) && this.#isDraftEnabled(d) && this.#isPartOfAnyService(d.name)
)
}

/**
* FIXME: could use EntityRepository here
* @param {string} name - name of the entity.
* @returns {EntityCSN}
* @param {string} entityName - entity to check
* @returns {boolean} `true` if entity is part an service
*/
// @ts-expect-error - poor man's #getDefinitionOrThrow. We are always sure name is a valid key
#getDefinition(name) { return this.csn?.definitions[name] }

/**
* Propagate draft annotations through inheritance (includes).
* The latest annotation through the inheritance chain "wins".
* Annotations on the entity itself are always queued last, so they will always be decisive over ancestors.
* @param {EntityCSN | undefined} entity - entity to pull draftability from its parents.
*/
#propagateInheritance(entity) {
if (!entity) return
/** @type {(boolean | undefined)[]} */
const annotations = (entity.includes ?? []).map(parent => this.#getDraftable(parent))
annotations.push(entity[annotation])
this.#setDraftable(entity, annotations.filter(a => a !== undefined).at(-1) ?? false)
#isPartOfAnyService(entityName) {
return this.#serviceNames.some(s => entityName.startsWith(s))
}

/**
* Propagate draft-enablement through projections.
* Collect all entities that are transitively reachable via compositions from `entity` into `draftNodes`.
* Check that no entity other than the root node has `@odata.draft.enabled`
* @param {EntityCSN} entity -
* @param {string} entityName -
* @param {EntityCSN} rootEntity - root entity where composition traversal started.
* @param {Record<string,EntityCSN>} draftEntities - Dictionary of entitys
*/
#propagateProjections() {
/**
* @param {string} from - entity to propagate draftability from.
* @param {string} to - entity to propagate draftability to.
*/
const propagate = (from, to) => {
do {
this.#setDraftable(to, this.#getDraftable(to) || this.#getDraftable(from))
from = to
to = this.#projections[to]
} while (to)
}
#collectDraftNodesInto(entity, entityName, rootEntity, draftEntities) {
draftEntities[entityName] = entity

for (let [projection, target] of Object.entries(this.#projections)) {
propagate(projection, target)
propagate(target, projection)
for (const elem of Object.values(entity.elements ?? {})) {
if (!elem.target || elem.type !== 'cds.Composition') continue

const draftNode = this.#csn?.definitions[elem.target]
const draftNodeName = elem.target

if (!draftNode) {
throw new Error(`Expecting target to be resolved: ${JSON.stringify(elem, null, 2)}`)
}

if (!this.#isPartOfAnyService(draftNodeName)) {
LOG.warn(`Ignoring draft node for composition target ${draftNodeName} because it is not part of a service`)
continue
}

if (draftNode !== rootEntity && this.#isDraftEnabled(draftNode)) {
this.#compileError = true
LOG.error(`Composition in draft-enabled entity can't lead to another entity with "@odata.draft.enabled" (in entity: "${entityName}"/element: ${elem.name})!`)
delete draftEntities[draftNodeName]
continue
}

if (!this.#isDraftEnabled(draftNode) && !draftEntities[draftNodeName]) {
this.#collectDraftNodesInto(draftNode, draftNodeName, rootEntity, draftEntities)
}
}
}

/**
* If an entity E is draftable and contains any composition of entities,
* then those entities also become draftable. Recursively.
* @param {EntityCSN} entity - entity to propagate all compositions from.
* @param {EntityCSN} entity - entity to check
* @returns {boolean}
*/
#propagateCompositions(entity) {
if (!this.#getDraftable(entity)) return

for (const comp of Object.values(entity.compositions ?? {})) {
const target = this.#getDefinition(comp.target)
const current = this.#getDraftable(target)
if (!current) {
this.#setDraftable(target, true)
this.#propagateCompositions(target)
}
}
#isDraftEnabled(entity) {
return entity[DRAFT_ENABLED_ANNO] === true
}

/** @param {CSN} csn - the full csn */
unroll(csn) {
this.csn = csn
run(csn) {
if (!csn) return

// inheritance
for (const entity of this.#entities) {
this.#propagateInheritance(entity)
}
this.#csn = csn
this.#serviceNames = this.#getServiceNames()
this.#draftRoots = this.#collectDraftRoots()

// transitivity through compositions
// we have to do this in a second pass, as we only now know which entities are draft-enables themselves
for (const entity of this.#entities) {
this.#propagateCompositions(entity)
}
for (const draftRoot of this.#draftRoots) {
/** @type {Record<string,EntityCSN>} */
const draftEntities = {}
this.#collectDraftNodesInto(draftRoot, draftRoot.name, draftRoot, draftEntities)

this.#propagateProjections()
for (const draftNode of Object.values(draftEntities)) {
draftEnabledEntities.push(draftNode.name)
}
}
if (this.#compileError) throw new Error('Compilation of model failed')
}
}

// note to self: following doc uses @ homoglyph instead of @, as the latter apparently has special semantics in code listings
/**
* We are unrolling the @odata.draft.enabled annotations into related entities manually.
* This includes three scenarios:
* We collect all entities that are draft enabled.
* (@see `@sap/cds-compiler/lib/transform/draft/db.js#generateDraft`)
*
* (a) aspects via `A: B`, where `B` is draft enabled.
* Note that when an entity extends two other entities of which one has drafts enabled and
* one has not, then the one that is later in the list of mixins "wins":
* This includes thwo scenarios:
* - (a) Entities that are part of a service and have the annotation @odata.draft.enabled
* - (b) Entities that are draft enabled propagate this property down through compositions.
* NOTE: The compositions themselves must not be draft enabled, otherwise no draft entity will be generated for them
* @param {any} csn - the entity
* @example
* ```ts
* @odata.draft.enabled true
* entity T {}
* @odata.draft.enabled false
* entity F {}
* entity A: T,F {} // draft not enabled
* entity B: F,T {} // draft enabled
* ```
* (a)
* ```cds
* // service.cds
* service MyService {
* @odata.draft.enabled true
* entity A {}
*
* (b) Draft enabled projections make the entity we project on draft enabled.
* @example
* ```ts
* @odata.draft.enabled: true
* entity A as projection on B {}
* entity B {} // draft enabled
* @odata.draft.enabled true
* entity B {}
* }
* ```
*
* (c) Entities that are draft enabled propagate this property down through compositions:
*
* ```ts
* @odata.draft.enabled: true
* entity A {
* b: Composition of B
* @example
* (b)
* ```cds
* // service.cds
* service MyService {
* @odata.draft.enabled: true
* entity A {
* b: Composition of B
* }
* entity B {} // draft enabled
* }
* entity B {} // draft enabled
* ```
*/
function unrollDraftability(csn) {
new DraftUnroller().unroll(csn)
function collectDraftEnabledEntities(csn) {
new DraftEnabledEntityCollector().run(csn)
}

/**
Expand Down Expand Up @@ -320,15 +285,6 @@ function propagateForeignKeys(csn) {
}
}

/**
*
* @param {any} csn - complete csn
*/
function amendCSN(csn) {
unrollDraftability(csn)
propagateForeignKeys(csn)
}

/**
* @param {EntityCSN} entity - the entity
*/
Expand All @@ -349,7 +305,7 @@ const getProjectionAliases = entity => {
}

module.exports = {
amendCSN,
collectDraftEnabledEntities,
isView,
isProjection,
isViewOrProjection,
Expand Down
Loading
Loading