UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

409 lines (359 loc) 16 kB
'use strict'; const { forAllQueries, forEachDefinition, walkCsnPath } = require('../../../model/csnUtils'); const { setProp } = require('../../../base/model'); const { getHelpers } = require('./utils'); /** * Turn a `exists assoc[filter = 100]` into a `exists (select 1 as dummy from assoc.target where <assoc on condition> and assoc.target.filter = 100)`. * * Sample: select * from E where exists assoc[filter=100] * * E: assoc with target F, id as key * F: id as key, filter: Integer * * For a managed association `assoc`: * - For each of the foreign keys, create <assoc.target, assoc.target.key.ref> = <query source, assoc name, assoc.target.key.ref> * * Given the sample above: * - F.id = E.assoc.id -> which will later on be translated to the real foreign key E.assoc_id * * The final subselect looks like (select 1 as dummy from F where F.id = E.assoc.id and filter = 100). * * For an unmanaged association: * - For each part of the on-condition, we check: * + Is it part of the target side: <assoc>.<path> is turned into <assoc.target>.<path> * + Is it part of the source side: <path> is turned into <query source>.<path> - a leading $self is stripped-off * + Is it something else: Don't touch it, leave as is * * Given that `assoc` from above has the on-condition assoc.id = id, we would generate the following: * - F.id = E.id * * The final subselect looks like (select 1 as dummy from E where F.id = E.id and filter = 100). * * For a $self backlink: * - For $self = <assoc>.<another-assoc>, we do the following for each foreign key of <another-assoc> * + <assoc>.<another-assoc>.<fk> -> <assoc.target>.<another-assoc>.<fk> * + Afterwards, we get the corresponding key from the source side: <query-source>.<fk> * + And turn this into a comparison: <assoc.target>.<another-assoc>.<fk> = <query-source>.<fk> * * So for the sample above, given an on-condition like $self = assoc.backToE, we would generate: * - F.backToE.id = E.id * * The final subselect looks like (select 1 as dummy from E where F.backToE.id = E.id and filter = 100). * * @param {CSN.Model} csn * @param {CSN.Options} options * @param {Function} error * @param {Function} inspectRef * @param {Function} initDefinition * @param {Function} dropDefinitionCache */ function handleExists( csn, options, error, inspectRef, initDefinition, dropDefinitionCache ) { const { getBase, firstLinkIsEntityOrQuerySource, getFirstAssoc, translateManagedAssocToWhere, getQuerySources, translateUnmanagedAssocToWhere, } = getHelpers(csn, inspectRef, error); const generatedExists = new WeakMap(); forEachDefinition(csn, (artifact, artifactName) => { // drop cache: Otherwise, the projection/query hack below won't work, because csnRefs // thinks that the artifact was already initialized (including all queries). dropDefinitionCache(artifact); if (artifact.projection) // do the same hack we do for the other stuff... artifact.query = { SELECT: artifact.projection }; if (artifact.query) { forAllQueries(artifact.query, function handleExistsQuery(query, path) { if (!generatedExists.has(query)) { const toProcess = []; // Collect all expressions we need to process here if (query.SELECT?.where?.length > 1) toProcess.push([ path.slice(0, -1), path.concat('where') ]); if (query.SELECT?.having?.length > 1) toProcess.push([ path.slice(0, -1), path.concat('having') ]); if (query.SELECT?.columns) toProcess.push([ path.slice(0, -1), path.concat('columns') ]); if (query.SELECT?.from.on) toProcess.push([ path.slice(0, -1), path.concat([ 'from', 'on' ]) ]); for (const [ , exprPath ] of toProcess) { const expr = nestExists(exprPath); walkCsnPath(csn, exprPath.slice(0, -1))[exprPath[exprPath.length - 1]] = expr; } while (toProcess.length > 0) { const [ queryPath, exprPath ] = toProcess.pop(); // Re-init caches for this artifact dropDefinitionCache(artifact); initDefinition(artifact); // leftovers can happen with nested exists - we then need to drill down into the created SELECT // to check for further exists const { result, leftovers } = processExists(queryPath, exprPath); walkCsnPath(csn, exprPath.slice(0, -1))[exprPath[exprPath.length - 1]] = result; leftovers.reverse(); toProcess.push(...leftovers); // any leftovers - schedule for further processing } // Make sure we leave csnRefs usable dropDefinitionCache(artifact); initDefinition(artifact); } }, [ 'definitions', artifactName, 'query' ]); } if (artifact.projection) { // undo our hack artifact.projection = artifact.query.SELECT; delete artifact.query; } }); /** * Get the index of the first association that is found - starting the * search at the given startIndex. * * @param {number} startIndex Where to start searching * @param {object[]} links links for a ref, produced by inspectRef * @returns {number|null} Null if no association was found */ function getFirstAssocIndex( startIndex, links ) { for (let i = startIndex; i < links.length; i++) { if (links[i] && links[i].art && links[i].art.target) return i; } return null; } /** * For a given ref-array, this function is called for the first assoc-ref in the array. * * It then runs over the rest of the array and puts all other steps in the first assocs filter. * If the rest contains another assoc, we put all following things into that assocs filter and * add the sub-assoc to the previous assoc filter. * * Or in other words: * - exists toF[1=1].toG[1=1].toH[1=1] is found * - we get called with toF[1=1].toG[1=1].toH[1=1] * - we return toF[1=1 and exists toG[1=1 and exists toH[1=1]]] * * @param {number} startIndex The index of the thing AFTER _main in the ref-array * @param {string|object} startAssoc The path step that is the first assoc * @param {Array} startRest Any path steps after startAssoc * @param {CSN.Path} path to the overall ref where _main is contained * @returns {Array} Return the now-nested ref-array */ function nestFilters( startIndex, startAssoc, startRest, path ) { let revert; if (!startAssoc.where) { // initialize first filter if not present if (typeof startAssoc === 'string') { startAssoc = { id: startAssoc, where: [], }; revert = () => { startAssoc = startAssoc.id; }; } else { startAssoc.where = []; revert = () => { delete startAssoc.where; }; } } const stack = [ [ null, startAssoc, startRest, startIndex ] ]; const { links } = inspectRef(path); while (stack.length > 0) { // previous: to nest "up" if the previous assoc did not originally have a filter // assoc: the assoc path step // rest: path steps after assoc // index: index of after-assoc in the overall ref-array - so we know where to start looking for the next assoc const workPackage = stack.pop(); const [ previous, , rest, index ] = workPackage; let [ , assoc, , ] = workPackage; const firstAssocIndex = getFirstAssocIndex(index, links); const head = rest.slice(0, firstAssocIndex - index); const nextAssoc = rest[firstAssocIndex - index]; const tail = rest.slice(firstAssocIndex - index + 1); const hasAssoc = nextAssoc !== undefined; if (!assoc.where && hasAssoc) { // no existing filter - and there is stuff we need to nest afterwards if (typeof assoc === 'string') { assoc = { id: assoc, where: [], }; // We need to "hook" this into the previous filter. // Since we create a new object, we don't have a handy reference we can just manipulate if (previous) previous.where[previous.where.length - 1] = { ref: [ assoc ] }; } else { assoc.where = []; } } else if (assoc.where && assoc.where.length > 0 && (hasAssoc || rest.length > 0)) { assoc.where.push('and'); } // merge with existing filter if (hasAssoc) assoc.where.push('exists', { ref: [ ...head, nextAssoc ] }); else if (rest.length > 0) assoc.where.push({ ref: rest }); if (hasAssoc) stack.push([ assoc, nextAssoc, tail, firstAssocIndex ]); } // Seems like we did not have anything to nest into the filter - then kill it if (startAssoc.where.length === 0 && revert !== undefined) revert(); return startAssoc; } /** * Walk to the expr using the given path and scan it for the "exists" + "ref" pattern. * If such a pattern is found, nest association steps therein into filters. * * @param {CSN.Path} exprPath * @returns {Array} */ function nestExists( exprPath ) { const expr = walkCsnPath(csn, exprPath); for (let i = 0; i < expr.length; i++) { if (i < expr.length - 1 && expr[i] === 'exists' && expr[i + 1].ref) { i++; const current = expr[i]; const { ref, head, tail, } = getFirstAssoc(current, exprPath.concat(i)); const newThing = [ ...head, nestFilters(head.length + 1, ref, tail, exprPath.concat([ i ])) ]; expr[i].ref = newThing; } } return expr; } /** * Process the given expr of the given query and translate a `EXISTS assoc` into a `EXISTS (subquery)`. Also, return paths to things we need to process in a second step. * * @param {CSN.Path} queryPath Path to the query-object * @param {CSN.Path} exprPath Path to the expression-array to process * @returns {{result: TokenStream, leftovers: Array[]}} result: A new token stream expression - the same as expr, but with the expanded EXISTS, leftovers: path-tuples to further subqueries to process. */ function processExists( queryPath, exprPath ) { const toContinue = []; const newExpr = []; const query = walkCsnPath(csn, queryPath); const expr = walkCsnPath(csn, exprPath); const queryBase = query.SELECT.from.ref ? (query.SELECT.from.as || query.SELECT.from.ref) : null; const sources = getQuerySources(query.SELECT); for (let i = 0; i < expr.length; i++) { if (i < expr.length - 1 && expr[i] === 'exists' && expr[i + 1].ref) { i++; const current = expr[i]; const isPrefixedWithTableAlias = firstLinkIsEntityOrQuerySource({}, exprPath.concat(i)); const base = getBase(queryBase, isPrefixedWithTableAlias, current, exprPath.concat(i)); const { root, ref } = getFirstAssoc(current, exprPath.concat(i)); const subselect = getSubselect(root.target, ref, sources); const target = subselect.SELECT.from.as; // use subquery alias as target - prevent shadowing const extension = root.keys ? translateManagedAssocToWhere(root, target, isPrefixedWithTableAlias, base, current) : translateUnmanagedAssocToWhere(root, target, isPrefixedWithTableAlias, base, current); if (options.tenantDiscriminator) { const targetEntity = csn.definitions[root.target]; if (!targetEntity['@cds.tenant.independent']) { subselect.SELECT.where.push( { ref: [ target, 'tenant' ] }, '=', { ref: [ base, 'tenant' ] }, 'AND' ); } } // TODO: add tenant comparison here ? if (extension.length > 3) { // make on-condition part sub-xpr to ensure precedence is kept subselect.SELECT.where.push({ xpr: extension }); } else { subselect.SELECT.where.push(...extension); } newExpr.push('exists'); if (ref?.where) { const remappedWhere = remapExistingWhere(target, ref.where, exprPath, current); subselect.SELECT.where.push('and'); if (remappedWhere.length > 3) subselect.SELECT.where.push( { xpr: remappedWhere } ); else subselect.SELECT.where.push( ...remappedWhere ); } newExpr.push(subselect); toContinue.push([ exprPath.concat(newExpr.length - 1), exprPath.concat([ newExpr.length - 1, 'SELECT', 'where' ]) ]); } else { // Drill down into other places that might contain a `EXISTS <assoc>` if (expr[i].xpr) { const { result, leftovers } = processExists(queryPath, exprPath.concat([ i, 'xpr' ])); expr[i].xpr = result; toContinue.push(...leftovers); } if (expr[i].args && Array.isArray(expr[i].args)) { const { result, leftovers } = processExists(queryPath, exprPath.concat([ i, 'args' ])); expr[i].args = result; toContinue.push(...leftovers); } newExpr.push(expr[i]); } } return { result: newExpr, leftovers: toContinue }; } /** * Build an initial subselect for the final `EXISTS <subselect>`. * * @param {string} target The target of `EXISTS <assoc>` - will be selected from * @param {string|object} assocRef The ref "being" the association * @param {object} _sources Object containing the names of the query sources of the current query * @returns {CSN.Query} */ function getSubselect( target, assocRef, _sources ) { let subselectAlias = `_${ assocRef.id ? assocRef.id : assocRef }_exists`; while (_sources[subselectAlias]) subselectAlias = `_${ subselectAlias }`; const subselect = { SELECT: { // use alias to prevent shadowing of upper-level table alias from: { ref: [ target ], as: subselectAlias }, columns: [ { val: 1, as: 'dummy' } ], where: [], }, }; if (assocRef.args) // copy named arguments subselect.SELECT.from.ref = [ { id: target, args: assocRef.args } ]; setProp(subselect.SELECT.from, '_art', csn.definitions[target]); setProp(subselect.SELECT.from, '_links', [ { idx: 0, art: csn.definitions[target] } ]); // Because the generated things don't have _links, _art etc. set // We could also make getParent more robust to calculate the links JIT if they are missing generatedExists.set(subselect, true); const nonEnumElements = Object.create(null); nonEnumElements.dummy = { type: 'cds.Integer', }; setProp(subselect.SELECT, 'elements', nonEnumElements); return subselect; } /** * If the assoc-base for EXISTS <assoc> has a filter, we need to merge this filter into the WHERE-clause of the subquery. * * This function does this by adding the assoc target before all the refs so that the refs are resolvable in the WHERE. * * This function also rejects $self paths in filter conditions. * * @param {string} target * @param {TokenStream} where * @param {CSN.Path} path path to the part, used if error needs to be thrown * @param {CSN.Artifact} parent the host of the `where`, used if error needs to be thrown * * @returns {TokenStream} where The input-where with the refs transformed to absolute ones */ function remapExistingWhere( target, where, path, parent ) { return where.map((part) => { if (part.$scope === '$self') { error('ref-unexpected-self', path, { '#': 'exists-filter', elemref: parent, id: part.ref[0] }); } else if (part.ref && part.$scope !== '$magic') { part.ref = [ target, ...part.ref ]; return part; } return part; }); } } module.exports = handleExists; /** * @typedef {Token[]} TokenStream Array of tokens. */ /** * @typedef {string|object} Token Could be an object or a string - strings are usually operators. */