Skip to content

Commit

Permalink
feat: assoc-like calc elements after exists predicate (#831)
Browse files Browse the repository at this point in the history
as documented in #830, assoc-like calculated elements are re-written by
the compiler to include the calculation directives in their
on-conditions. Hence, we can allow those assoc-like calculated elements
everywhere, where unmanaged associations can be used:

1. `exists` predicate
2. scoped queries
3. nested projections

this PR implements 1. and 2.
  • Loading branch information
patricebender authored Oct 15, 2024
1 parent 0041ec0 commit 05f7d75
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 23 deletions.
21 changes: 9 additions & 12 deletions db-service/lib/cqn4sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const cds = require('@sap/cds')

const infer = require('./infer')
const { computeColumnsToBeSearched } = require('./search')
const { prettyPrintRef, isCalculatedOnRead } = require('./utils')
const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement } = require('./utils')

/**
* For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
Expand Down Expand Up @@ -805,7 +805,7 @@ function cqn4sql(originalQuery, model) {
const subqueryBase = {}
for (const [key, value] of Object.entries(column)) {
if (!(key in { ref: true, expand: true })) {
subqueryBase[key] = value;
subqueryBase[key] = value
}
}
const subquery = {
Expand Down Expand Up @@ -1361,20 +1361,17 @@ function cqn4sql(originalQuery, model) {

const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
next.alias = as
if (next.definition.value) {
throw new Error(
`Calculated elements cannot be used in “exists” predicates in: “exists ${tokenStream[i + 1].ref
.map(idOnly)
.join('.')}”`,
)
}
if (!next.definition.target) {
let type = next.definition.type
if (isCalculatedElement(next.definition)) {
// try to infer the type at the leaf for better error message
const { $refLinks } = next.definition.value
type = $refLinks?.at(-1).definition.type || 'expression'
}
throw new Error(
`Expecting path “${tokenStream[i + 1].ref
.map(idOnly)
.join('.')}” following “EXISTS” predicate to end with association/composition, found “${
next.definition.type
}”`,
.join('.')}” following “EXISTS” predicate to end with association/composition, found “${type}”`,
)
}
const { definition: fkSource } = next
Expand Down
8 changes: 6 additions & 2 deletions db-service/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,15 @@ function prettyPrintRef(ref, model = null) {
* @returns {boolean} - Returns true if the definition is calculated on read, otherwise false.
*/
function isCalculatedOnRead(def) {
return def?.value && !def.value.stored && !def.on
return isCalculatedElement(def) && !def.value.stored && !def.on
}
function isCalculatedElement(def) {
return def?.value
}

// export the function to be used in other modules
module.exports = {
prettyPrintRef,
isCalculatedOnRead
isCalculatedOnRead,
isCalculatedElement
}
1 change: 1 addition & 0 deletions db-service/test/bookshop/db/schema.cds
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ entity Receipt {

entity Authors : managed, Person {
books : Association to many Books on books.author = $self;
booksWithALotInStock = books[stock > 100];
}
entity AuthorsUnmanagedBooks : managed, Person {
books : Association to many Books on books.coAuthor_ID_unmanaged = ID;
Expand Down
17 changes: 10 additions & 7 deletions db-service/test/cqn4sql/calculated-elements.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -701,19 +701,23 @@ describe('Unfolding calculated elements in select list', () => {
expect(query).to.deep.equal(expected)
})

it('exists cannot leverage calculated elements', () => {
it('exists cannot leverage calculated elements which ends in string', () => {
// at the leaf of a where exists path, there must be an association
// calc elements can't end in an association, hence this does not work, yet.
expect(() => cqn4sql(CQL`SELECT from booksCalc.Books { ID } where exists youngAuthorName`, model)).to.throw(
`Calculated elements cannot be used in “exists” predicates in: “exists youngAuthorName”`,
`Expecting path “youngAuthorName” following “EXISTS” predicate to end with association/composition, found “cds.String”`,
)
})
it('exists cannot leverage calculated elements which is an expression', () => {
// at the leaf of a where exists path, there must be an association
expect(() => cqn4sql(CQL`SELECT from booksCalc.Books { ID } where exists authorFullName`, model)).to.throw(
`Expecting path “authorFullName” following “EXISTS” predicate to end with association/composition, found “expression”`,
)
})
it('exists cannot leverage calculated elements w/ path expressions', () => {
// at the leaf of a where exists path, there must be an association
// calc elements can't end in an association, hence this does not work, yet.
expect(() =>
cqn4sql(CQL`SELECT from booksCalc.Books { ID } where exists author.books.youngAuthorName`, model),
).to.throw('Calculated elements cannot be used in “exists” predicates in: “exists author.books.youngAuthorName”')
).to.throw('Expecting path “author.books.youngAuthorName” following “EXISTS” predicate to end with association/composition, found “cds.String”')
})

it('exists cannot leverage calculated elements in CASE', () => {
Expand All @@ -727,12 +731,11 @@ describe('Unfolding calculated elements in select list', () => {
}`,
model,
),
).to.throw('Calculated elements cannot be used in “exists” predicates in: “exists youngAuthorName”')
).to.throw('Expecting path “youngAuthorName” following “EXISTS” predicate to end with association/composition, found “cds.String”')
})

it('scoped query cannot leverage calculated elements', () => {
// at the leaf of a where exists path, there must be an association
// calc elements can't end in an association, hence this does not work, yet.
expect(() => cqn4sql(CQL`SELECT from booksCalc.Books:youngAuthorName { ID }`, model)).to.throw(
'Query source must be a an entity or an association',
)
Expand Down
52 changes: 50 additions & 2 deletions db-service/test/cqn4sql/where-exists.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ const cqn4sql = require('../../lib/cqn4sql')
const cds = require('@sap/cds')
const { expect } = cds.test


/**
* @TODO Review the mean tests and verify, that the resulting cqn 4 sql is valid.
* Especially w.r.t. to table aliases and bracing.
Expand Down Expand Up @@ -135,6 +134,16 @@ describe('EXISTS predicate in where', () => {
AND Authors.name = 'Horst'
`)
})
it('exists predicate is followed by association-like calculated element', () => {
let query = cqn4sql(
CQL`SELECT from bookshop.Authors { ID } WHERE exists booksWithALotInStock and name = 'Horst'`,
model,
)
expect(query).to.deep.equal(CQL`SELECT from bookshop.Authors as Authors { Authors.ID }
WHERE exists ( select 1 from bookshop.Books as booksWithALotInStock where ( booksWithALotInStock.author_ID = Authors.ID ) and ( booksWithALotInStock.stock > 100 ) )
AND Authors.name = 'Horst'
`)
})
})
describe('wrapped in expression', () => {
it('exists predicate in xpr combined with infix filter', () => {
Expand Down Expand Up @@ -547,6 +556,31 @@ describe('EXISTS predicate in where', () => {
}
`)
})
it('exists in case with two branches both are association-like calculated element', () => {
let query = cqn4sql(
CQL`SELECT from bookshop.Authors
{ ID,
case when exists booksWithALotInStock[price > 10 or price < 20] then 1
when exists booksWithALotInStock[price > 100 or price < 120] then 2
end as descr
}`,
model,
)
expect(query).to.deep.equal(CQL`SELECT from bookshop.Authors as Authors
{ Authors.ID,
case
when exists
(
select 1 from bookshop.Books as booksWithALotInStock where ( booksWithALotInStock.author_ID = Authors.ID ) and ( booksWithALotInStock.stock > 100 ) and ( booksWithALotInStock.price > 10 or booksWithALotInStock.price < 20 )
) then 1
when exists
(
select 1 from bookshop.Books as booksWithALotInStock2 where ( booksWithALotInStock2.author_ID = Authors.ID ) and ( booksWithALotInStock2.stock > 100 ) and ( booksWithALotInStock2.price > 100 or booksWithALotInStock2.price < 120 )
) then 2
end as descr
}
`)
})
})

describe('association has structured keys', () => {
Expand Down Expand Up @@ -840,6 +874,13 @@ describe('Scoped queries', () => {
SELECT 1 from bookshop.Authors as Authors where Authors.ID = books.author_ID
)`)
})
it('handles FROM path with backlink association for association-like calculated element', () => {
let query = cqn4sql(CQL`SELECT from bookshop.Authors:booksWithALotInStock {booksWithALotInStock.ID}`, model)
expect(query).to.deep
.equal(CQL`SELECT from bookshop.Books as booksWithALotInStock {booksWithALotInStock.ID} WHERE EXISTS (
SELECT 1 from bookshop.Authors as Authors where ( Authors.ID = booksWithALotInStock.author_ID ) and ( booksWithALotInStock.stock > 100 )
)`)
})

it('handles FROM path with unmanaged composition and prepends source side alias', () => {
let query = cqn4sql(CQL`SELECT from bookshop.Books:texts { locale }`, model)
Expand Down Expand Up @@ -907,7 +948,6 @@ describe('Scoped queries', () => {
) AND author.ID = 150`)
})


// (SMW) TODO msg not good -> filter in general is ok for assoc with multiple FKS,
// only shortcut notation is not allowed
// TODO: message can include the fix: `write ”<key> = 42” explicitly`
Expand Down Expand Up @@ -962,6 +1002,14 @@ describe('Scoped queries', () => {
)
)`)
})
it('handles paths with two associations, first is association-like calculated element', () => {
let query = cqn4sql(CQL`SELECT from bookshop.Authors:booksWithALotInStock.genre {genre.ID}`, model)
expect(query).to.deep.equal(CQL`SELECT from bookshop.Genres as genre {genre.ID} WHERE EXISTS (
SELECT 1 from bookshop.Books as booksWithALotInStock where booksWithALotInStock.genre_ID = genre.ID and EXISTS (
SELECT 1 from bookshop.Authors as Authors where ( Authors.ID = booksWithALotInStock.author_ID ) and ( booksWithALotInStock.stock > 100 )
)
)`)
})

it('handles paths with two associations (mean alias)', () => {
let query = cqn4sql(CQL`SELECT from bookshop.Authors:books.genre as books {books.ID}`, model)
Expand Down

0 comments on commit 05f7d75

Please sign in to comment.