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

fix: reject unmanaged associations in infix filter #601

Merged
merged 7 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions db-service/lib/infer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,15 @@ function infer(originalQuery, model) {
// only fk access in infix filter
const nextStep = ref[1]?.id || ref[1]
// no unmanaged assoc in infix filter path
if (!expandOrExists && e.on)
throw new Error(
`"${e.name}" in path "${arg.ref.map(idOnly).join('.')}" must not be an unmanaged association`,
)
if (!expandOrExists && e.on) {
const err = `Unexpected unmanaged association “${e.name}” in filter expression of “${$baseLink.definition.name}”`
throw new Error(err)
}
// no non-fk traversal in infix filter
if (!expandOrExists && nextStep && !isForeignKeyOf(nextStep, e))
throw new Error(`Only foreign keys of "${e.name}" can be accessed in infix filter`)
throw new Error(
`Only foreign keys of “${e.name}” can be accessed in infix filter, but found “${nextStep}”`,
)
}
arg.$refLinks.push({ definition: e, target: definition })
// filter paths are flattened
Expand Down Expand Up @@ -615,11 +617,10 @@ function infer(originalQuery, model) {
if (!column.$refLinks[i].definition.target || danglingFilter)
throw new Error('A filter can only be provided when navigating along associations')
if (!column.expand) Object.defineProperty(column, 'isJoinRelevant', { value: true })
// books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
let skipJoinsForFilter = inExists
let skipJoinsForFilter = false
step.where.forEach(token => {
if (token === 'exists') {
// no joins for infix filters along `exists <path>`
// books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
skipJoinsForFilter = true
} else if (token.ref || token.xpr) {
inferQueryElement(token, false, column.$refLinks[i], {
Expand Down Expand Up @@ -698,13 +699,15 @@ function infer(originalQuery, model) {
// only fk access in infix filter
const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
// no unmanaged assoc in infix filter path
if (!inExists && assoc.on)
throw new Error(
`"${assoc.name}" in path "${column.ref.map(idOnly).join('.')}" must not be an unmanaged association`,
)
if (!inExists && assoc.on) {
const err = `Unexpected unmanaged association “${assoc.name}” in filter expression of “${$baseLink.definition.name}”`
throw new Error(err)
}
// no non-fk traversal in infix filter in non-exists path
if (nextStep && !assoc.on && !isForeignKeyOf(nextStep, assoc))
throw new Error(`Only foreign keys of "${assoc.name}" can be accessed in infix filter, not "${nextStep}"`)
throw new Error(
`Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${nextStep}”`,
)
}
}
})
Expand Down
13 changes: 11 additions & 2 deletions db-service/test/cds-infer/negative.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,18 @@ describe('negative', () => {
describe('infix filters', () => {
it('rejects non fk traversal in infix filter in from', () => {
expect(() => _inferred(CQL`SELECT from bookshop.Books[author.name = 'Kurt']`, model)).to.throw(
/Only foreign keys of "author" can be accessed in infix filter/,
/Only foreign keys of author can be accessed in infix filter, but found “name”/,
)
})
it('rejects non fk traversal in infix filter in where exists', () => {
let query = CQL`SELECT from bookshop.Books where exists author.books[author.name = 'John Doe']`
expect(() => _inferred(query)).to.throw(/Only foreign keys of “author” can be accessed in infix filter, but found “name”/,) // revisit: better error location ""bookshop.Books:author"
})
it('rejects unmanaged traversal in infix filter in where exists', () => {
let query = CQL`SELECT from bookshop.Books where exists author.books[coAuthorUnmanaged.name = 'John Doe']`
expect(() => _inferred(query)).to.throw(/Unexpected unmanaged association “coAuthorUnmanaged” in filter expression of “books”/,) // revisit: better error location ""bookshop.Books:author"
})

it('rejects non fk traversal in infix filter in column', () => {
expect(() =>
_inferred(
Expand All @@ -403,7 +412,7 @@ describe('negative', () => {
}`,
model,
),
).to.throw(/Only foreign keys of "author" can be accessed in infix filter/)
).to.throw(/Only foreign keys of author can be accessed in infix filter/)
})
})

Expand Down
5 changes: 3 additions & 2 deletions db-service/test/cqn4sql/expand.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,14 @@ describe('Unfold expands on associations to special subselects', () => {
// - they can return multiple rows
it('rejects unmanaged association in infix filter of expand path', () => {
expect(() => cqn4sql(CQL`SELECT from bookshop.Books { author[books.title = 'foo'] { name } }`, model)).to.throw(
/"books" in path "books.title" must not be an unmanaged association/,
/Unexpected unmanaged association “books” in filter expression of “author”/,
)

})
it('rejects non-fk access in infix filter of expand path', () => {
expect(() =>
cqn4sql(CQL`SELECT from bookshop.EStrucSibling { self[sibling.struc1 = 'foo'] { ID } }`, model),
).to.throw(/Only foreign keys of "sibling" can be accessed in infix filter/)
).to.throw(/Only foreign keys of sibling can be accessed in infix filter/)
})
it('unfold expand, one field', () => {
const q = CQL`SELECT from bookshop.Books {
Expand Down
6 changes: 3 additions & 3 deletions db-service/test/cqn4sql/where-exists.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,15 +218,15 @@ describe('EXISTS predicate in where', () => {
CQL`SELECT from bookshop.Authors { ID } WHERE EXISTS books[dedication.addressee.name = 'Hasso']`,
model,
),
).to.throw('Only foreign keys of "addressee" can be accessed in infix filter')
).to.throw('Only foreign keys of addressee can be accessed in infix filter')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or " ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i prefer to open and to close

the unicode quotes are also used in the compiler

})
it('MUST fail if following managed assoc in filter', () => {
expect(() =>
cqn4sql(
CQL`SELECT from bookshop.Authors { ID, books[dedication.addressee.name = 'Hasso'].dedication.addressee.name as Hasso }`,
model,
),
).to.throw('Only foreign keys of "addressee" can be accessed in infix filter')
).to.throw('Only foreign keys of addressee can be accessed in infix filter')
})

it('MUST handle simple where exists with multiple association and also with $self backlink', () => {
Expand Down Expand Up @@ -719,7 +719,7 @@ describe('EXISTS predicate in infix filter', () => {
`
expect(() => {
cqn4sql(q, cds.compile.for.nodejs(JSON.parse(JSON.stringify(model))))
}).to.throw(/Only foreign keys of "participant" can be accessed in infix filter/)
}).to.throw(/Only foreign keys of participant can be accessed in infix filter/)
})
})

Expand Down
42 changes: 39 additions & 3 deletions test/compliance/SELECT.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
const assert = require('assert')

const chaiAsPromised = require('chai-as-promised');

Check warning on line 3 in test/compliance/SELECT.test.js

View workflow job for this annotation

GitHub Actions / Node.js 18

'chaiAsPromised' is assigned a value but never used

Check warning on line 3 in test/compliance/SELECT.test.js

View workflow job for this annotation

GitHub Actions / Node.js 18

'chaiAsPromised' is assigned a value but never used

Check warning on line 3 in test/compliance/SELECT.test.js

View workflow job for this annotation

GitHub Actions / HANA Node.js 18

'chaiAsPromised' is assigned a value but never used
const cds = require('../cds.js')

// Set cds.root before requiring cds.Service as it resolves and caches package.json
// Call default cds.test API

describe('SELECT', () => {
// chai.use(chaiAsPromised)
const { data, expect } = cds.test(__dirname + '/resources')
data.autoIsolation(true)

Expand Down Expand Up @@ -180,7 +182,7 @@
const nulls = length => new Array(length).fill().map((_, i) => ({ as: `null${i}`, val: null }))
const cqn = {
SELECT: {
from: { ref: ['complex.Authors'] },
from: { ref: ['complex.associations.Authors'] },
columns: [{ ref: ['ID'] }, { ref: ['name'] }, { ref: ['books'], expand: ['*', ...nulls(197)] }]
},
}
Expand All @@ -194,7 +196,7 @@
const nulls = length => new Array(length).fill().map((_, i) => ({ as: `null${i}`, val: null }))
const cqn = {
SELECT: {
from: { ref: ['complex.Books'] },
from: { ref: ['complex.associations.Books'] },
columns: [{ ref: ['ID'] }, { ref: ['title'] }, { ref: ['author'], expand: ['*', ...nulls(198)] }]
},
}
Expand Down Expand Up @@ -250,6 +252,40 @@
assert.strictEqual(timestampMatches.length, 1, 'Ensure that the dateTime column matches the timestamp value')
})

test('exists path expression', async () => {
const cqn = {
SELECT: {
from: { ref: ["complex.associations.Books"] },
where: [
"exists",
{
ref: [
"author",
{ id: "books", where: [{ ref: ["author", "name"] }, "=", { val: "Emily" }] }]
}
]
}
}
expect(cds.run(cqn)).to.eventually.be.rejectedWith('Only foreign keys of “author” can be accessed in infix filter, but found “name”');
})

test('exists path expression (unmanaged)', async () => {
const cqn = {
SELECT: {
from: { ref: ["complex.associations.unmanaged.Books"] },
where: [
"exists",
{
ref: [
"author",
{ id: "books", where: [{ ref: ["author", "name"] }, "=", { val: "Emily" }] }]
}
]
}
}
expect(cds.run(cqn)).to.eventually.be.rejectedWith('Unexpected unmanaged association “author” in filter expression of “books”');
})

test.skip('ref select', async () => {
// Currently not possible as cqn4sql does not recognize where.ref.id: 'basic.projection.globals' as an external source
const cqn = {
Expand Down Expand Up @@ -454,7 +490,7 @@

describe('count', () => {
test('count is preserved with .map', async () => {
const query = SELECT.from('complex.Authors')
const query = SELECT.from('complex.associations.Authors')
query.SELECT.count = true
const result = await query
assert.strictEqual(result.$count, 1)
Expand Down
2 changes: 1 addition & 1 deletion test/compliance/UPDATE.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const cds = require('../cds.js')
const Books = 'complex.Books'
const Books = 'complex.associations.Books'

describe('UPDATE', () => {
describe('entity', () => {
Expand Down
10 changes: 5 additions & 5 deletions test/compliance/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ describe('affected rows', () => {
const { expect } = cds.test(__dirname + '/resources')

test('Delete returns affected rows', async () => {
const affectedRows = await DELETE.from('complex.Books').where('ID = 4712')
const affectedRows = await DELETE.from('complex.associations.Books').where('ID = 4712')
expect(affectedRows).to.be.eq(0)
})

test('Insert returns affected rows and InsertResult', async () => {
const insert = INSERT.into('complex.Books').entries({ ID: 5 })
const insert = INSERT.into('complex.associations.Books').entries({ ID: 5 })
const affectedRows = await cds.db.run(insert)
// affectedRows is an InsertResult, so we need to do lose comparison here, as strict will not work due to InsertResult
expect(affectedRows == 1).to.be.eq(true)
Expand All @@ -21,14 +21,14 @@ const { expect } = cds.test(__dirname + '/resources')
})

test('Update returns affected rows', async () => {
const { count } = await SELECT.one`count(*)`.from('complex.Books')
const { count } = await SELECT.one`count(*)`.from('complex.associations.Books')

const affectedRows = await UPDATE.entity('complex.Books').data({title: 'Book'})
const affectedRows = await UPDATE.entity('complex.associations.Books').data({title: 'Book'})
expect(affectedRows).to.be.eq(count)
})

test('Upsert returns affected rows', async () => {
const affectedRows = await UPSERT.into('complex.Books').entries({ID: 9999999, title: 'Book'})
const affectedRows = await UPSERT.into('complex.associations.Books').entries({ID: 9999999, title: 'Book'})
expect(affectedRows).to.be.eq(1)
})
})
13 changes: 13 additions & 0 deletions test/compliance/resources/db/complex/associations.cds
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace complex.associations;

entity Books {
key ID : Integer;
title : String(111);
author : Association to Authors;
}

entity Authors {
key ID : Integer;
name : String(111);
books : Association to many Books on books.author = $self;
}
14 changes: 14 additions & 0 deletions test/compliance/resources/db/complex/associationsUnmanaged.cds
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace complex.associations.unmanaged;

entity Books {
key ID : Integer;
title : String(111);
author_ID: Integer;
author : Association to Authors on author.ID = $self.author_ID;
}

entity Authors {
key ID : Integer;
name : String(111);
books : Association to many Books on books.author = $self;
}
13 changes: 2 additions & 11 deletions test/compliance/resources/db/complex/index.cds
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
namespace complex;

entity Books {
key ID : Integer;
title : String(111);
author : Association to Authors;
}

entity Authors {
key ID : Integer;
name : String(111);
books : Association to many Books on books.author = $self;
}
using from './associations';
using from './associationsUnmanaged';
2 changes: 1 addition & 1 deletion test/compliance/strictMode.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const cds = require('../cds.js')
const Books = 'complex.Books'
const Books = 'complex.associations.Books'

describe('strict mode', () => {
beforeAll(() => {
Expand Down
Loading