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(deep): prevent false unique constraint errors and combine delete queries #781

Merged
merged 49 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
2887ca0
fix(deep): prevent false unique constraint errors
David-Kunz Aug 21, 2024
aa73162
single loop
David-Kunz Aug 29, 2024
180cfba
single delete statement
David-Kunz Aug 29, 2024
5672f40
simpler queries for single keys
David-Kunz Aug 29, 2024
f0f1862
simpler
David-Kunz Aug 29, 2024
fa7cfec
.
David-Kunz Aug 29, 2024
c64d4a7
simpler
David-Kunz Aug 29, 2024
57f3e89
use virtual instead of IsActiveEntity
David-Kunz Aug 29, 2024
6f15817
Merge branch 'main' into no-false-unique-constraint-errors
David-Kunz Aug 29, 2024
58f9c53
added test
David-Kunz Aug 29, 2024
dcc5536
.
David-Kunz Aug 29, 2024
e0e0a70
.
David-Kunz Aug 29, 2024
466d355
.
David-Kunz Aug 29, 2024
e79376b
.
David-Kunz Aug 29, 2024
3160f05
.
David-Kunz Aug 29, 2024
cd841c9
.
David-Kunz Aug 29, 2024
43b0504
.
David-Kunz Aug 29, 2024
b605995
.
David-Kunz Aug 29, 2024
f05b224
.
David-Kunz Aug 29, 2024
b7b9837
Update db-service/lib/deep-queries.js
David-Kunz Aug 30, 2024
52e6d91
mv cds.test
David-Kunz Aug 30, 2024
7802d5e
Update test/compliance/UPDATE.test.js
David-Kunz Aug 30, 2024
9c6a519
no single key difference
David-Kunz Aug 30, 2024
e3895e7
.
David-Kunz Aug 30, 2024
d1a6874
Merge branch 'main' into no-false-unique-constraint-errors
David-Kunz Aug 30, 2024
dd35333
.
David-Kunz Aug 30, 2024
194b888
.
David-Kunz Aug 30, 2024
97c0c74
.
David-Kunz Aug 30, 2024
2673110
.
David-Kunz Aug 30, 2024
1e5c1d4
.
David-Kunz Aug 30, 2024
7f72386
.
David-Kunz Aug 30, 2024
fff3ed4
.bug
David-Kunz Aug 30, 2024
8e19a2e
use ROOT tag
David-Kunz Sep 2, 2024
7189dfd
Merge branch 'main' into testing
David-Kunz Sep 2, 2024
99bcef2
.
David-Kunz Sep 2, 2024
e39b8fa
explanation
David-Kunz Sep 2, 2024
43012c3
explanation
David-Kunz Sep 2, 2024
ec5326b
bug needs to be fixed
David-Kunz Sep 2, 2024
b552a8f
simplify
David-Kunz Sep 2, 2024
6b75734
Merge branch 'main' into no-false-unique-constraint-errors
David-Kunz Sep 2, 2024
770deff
.
David-Kunz Sep 2, 2024
a9290dd
.
David-Kunz Sep 2, 2024
758fa4c
.
David-Kunz Sep 2, 2024
537874c
Merge branch 'main' into no-false-unique-constraint-errors
David-Kunz Sep 2, 2024
c44ba9d
Merge branch 'main' into no-false-unique-constraint-errors
johannes-vogel Sep 3, 2024
288007d
.
David-Kunz Sep 3, 2024
d01f666
.
David-Kunz Sep 3, 2024
87007ac
.
David-Kunz Sep 3, 2024
94a40e6
.
David-Kunz Sep 3, 2024
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
113 changes: 62 additions & 51 deletions db-service/lib/deep-queries.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const cds = require('@sap/cds')
const { _target_name4 } = require('./SQLService')
const InsertResult = require('../lib/InsertResults')

const ROOT = Symbol('root')

// REVISIT: remove old path with cds^8
let _compareJson
Expand Down Expand Up @@ -45,20 +46,22 @@ async function onDeep(req, next) {
if (query.UPDATE && !beforeData.length) return 0

const queries = getDeepQueries(query, beforeData, target)
const res = await Promise.all(queries.map(query => {
if (query.INSERT) return this.onINSERT({ query })
if (query.UPDATE) return this.onUPDATE({ query })
if (query.DELETE) return this.onSIMPLE({ query })
}))
return (
beforeData.length ||
new InsertResult(query, [
{
changes: Array.isArray(req.data) ? req.data.length : 1,
...(res[0]?.results[0]?.lastInsertRowid ? { lastInsertRowid: res[0].results[0].lastInsertRowid } : {}),
},
])
)

// first delete, then update, then insert because of potential unique constraints:
// - deletes never trigger unique constraints, but can prevent them -> execute first
// - updates can trigger and prevent unique constraints -> execute second
// - inserts can only trigger unique constraints -> execute last
await Promise.all(Array.from(queries.deletes.values()).map(query => this.onSIMPLE({ query })))
await Promise.all(queries.updates.map(query => this.onUPDATE({ query })))

const rootQuery = queries.inserts.get(ROOT)
queries.inserts.delete(ROOT)
const [rootResult] = await Promise.all([
rootQuery && this.onINSERT({ query: rootQuery }),
...Array.from(queries.inserts.values()).map(query => this.onINSERT({ query })),
])

return beforeData.length ?? rootResult
}

const hasDeep = (q, target) => {
Expand Down Expand Up @@ -195,7 +198,7 @@ const getDeepQueries = (query, dbData, target) => {
diff = [diff]
}

return _getDeepQueries(diff, target, true)
return _getDeepQueries(diff, target)
}

const _hasManagedElements = target => {
Expand All @@ -205,16 +208,19 @@ const _hasManagedElements = target => {
/**
* @param {unknown[]} diff
* @param {import('@sap/cds/apis/csn').Definition} target
* @param {boolean} [root=false]
* @returns {import('@sap/cds/apis/cqn').Query[]}
* @param {Map<String, Object>} deletes
patricebender marked this conversation as resolved.
Show resolved Hide resolved
* @param {Map<String, Object>} inserts
* @param {Object[]} updates
* @param {boolean} [root=true]
* @returns {Object|Boolean}
*/
const _getDeepQueries = (diff, target, root = false) => {
const queries = []

const _getDeepQueries = (diff, target, deletes = new Map(), inserts = new Map(), updates = [], root = true) => {
// flag to determine if queries were created
let dirty = false
for (const diffEntry of diff) {
if (diffEntry === undefined) continue
const subQueries = []

let childrenDirty = false
for (const prop in diffEntry) {
// handle deep operations

Expand All @@ -224,9 +230,12 @@ const _getDeepQueries = (diff, target, root = false) => {
delete diffEntry[prop]
} else if (target.compositions?.[prop]) {
const arrayed = Array.isArray(propData) ? propData : [propData]
arrayed.forEach(subEntry => {
subQueries.push(..._getDeepQueries([subEntry], target.elements[prop]._target))
})
childrenDirty =
arrayed
.map(subEntry =>
_getDeepQueries([subEntry], target.elements[prop]._target, deletes, inserts, updates, false),
)
.some(a => a) || childrenDirty
delete diffEntry[prop]
} else if (diffEntry[prop] === undefined) {
// restore current behavior, if property is undefined, not part of payload
Expand All @@ -242,12 +251,32 @@ const _getDeepQueries = (diff, target, root = false) => {
delete diffEntry._old
}

// first calculate subqueries and rm their properties, then build root query
if (op === 'create') {
queries.push(INSERT.into(target).entries(diffEntry))
dirty = true
const id = root ? ROOT : target.name
const insert = inserts.get(id)
if (insert) {
insert.INSERT.entries.push(diffEntry)
} else {
const q = INSERT.into(target).entries(diffEntry)
inserts.set(id, q)
}
} else if (op === 'delete') {
queries.push(DELETE.from(target).where(diffEntry))
} else if (op === 'update' || (op === undefined && (root || subQueries.length) && _hasManagedElements(target))) {
dirty = true
const keys = cds.utils
.Object_keys(target.keys)
.filter(key => !target.keys[key].virtual && !target.keys[key].isAssociation)

const keyVals = keys.map(k => ({ val: diffEntry[k] }))
const currDelete = deletes.get(target.name)
if (currDelete) currDelete.DELETE.where[2].list.push({ list: keyVals })
else {
const left = { list: keys.map(k => ({ ref: [k] })) }
const right = { list: [{ list: keyVals }] }
deletes.set(target.name, DELETE.from(target).where([left, 'in', right]))
}
} else if (op === 'update' || (op === undefined && (root || childrenDirty) && _hasManagedElements(target))) {
dirty = true
// TODO do we need the where here?
const keys = target.keys
const cqn = UPDATE(target).with(diffEntry)
Expand All @@ -259,34 +288,16 @@ const _getDeepQueries = (diff, target, root = false) => {
delete diffEntry[key]
}
cqn.with(diffEntry)
queries.push(cqn)
updates.push(cqn)
}

for (const q of subQueries) queries.push(q)
}

const insertQueries = new Map()

return queries.map(q => {
// Merge all INSERT statements for each target
if (q.INSERT) {
const target = q.target
if (insertQueries.has(target)) {
insertQueries.get(target).INSERT.entries.push(...q.INSERT.entries)
return
} else {
insertQueries.set(target, q)
}
}
Object.defineProperty(q, handledDeep, { value: true })
return q
})
.filter(a => a)
return root ? { updates, inserts, deletes } : dirty
}

module.exports = {
onDeep,
getDeepQueries,
getExpandForDeep,
hasDeep,
getDeepQueries, // only for testing
getExpandForDeep, // only for testing
}
14 changes: 11 additions & 3 deletions db-service/test/deep/deep.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -784,20 +784,28 @@ describe('test deep query generation', () => {
],
},
])
const deepQueries = getDeepQueries(query, [], model.definitions.Root)
const { inserts, updates, deletes } = getDeepQueries(query, [], model.definitions.Root)

const expectedInserts = [
INSERT.into(model.definitions.Root)
.entries([{ ID: 1 }, { ID: 2 }, { ID: 3 }]),
INSERT.into(model.definitions.Child)
.entries([{ ID: 1 }, { ID: 2 }, { ID: 3 }, { ID: 4 }, { ID: 5 }, { ID: 6 }, { ID: 7 }, { ID: 8 }, { ID: 9 }]),
.entries([{ ID: 1 }, { ID: 2 }, { ID: 3 }, { ID: 4 }, { ID: 6 }, { ID: 7 }, { ID: 5 }, { ID: 9 }, { ID: 8 }]),
INSERT.into(model.definitions.SubChild)
.entries([{ ID: 10 }, { ID: 11 }, { ID: 12 }, { ID: 13 }]),
]

const insertsArray = Array.from(inserts.values())
const updatesArray = Array.from(updates)
const deletesArray = Array.from(deletes.values())

expectedInserts.forEach(insert => {
expect(deepQueries).toContainEqual(insert)
expect(insertsArray).toContainEqual(insert)
})

expect(updatesArray.length).toBe(0)
expect(deletesArray.length).toBe(0)

})

test('backlink keys are properly propagated', async () => {
Expand Down
124 changes: 95 additions & 29 deletions test/compliance/UPDATE.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const cds = require('../cds.js')
const Books = 'complex.associations.Books'
const BooksUnique = 'complex.uniques.Books'

describe('UPDATE', () => {
describe('entity', () => {
Expand All @@ -14,39 +15,104 @@ describe('UPDATE', () => {
})
})

describe('where', () => {
describe('with database', () => {
cds.test(__dirname + '/resources')
test('flat with or on key', async () => {
const entires = [
{
ID: 5,
title: 'foo',
},
{
ID: 6,
title: 'bar',
},
]

const insert = await cds.run(INSERT.into(Books).entries(entires))
expect(insert.affectedRows).toEqual(2)

const update = await cds.run(
UPDATE.entity(Books)
.set({
title: 'foo',
})
.where({
ID: 5,
or: {
describe('where', () => {
test('flat with or on key', async () => {
const insert = await cds.run(
INSERT.into(Books).entries([
{
ID: 5,
title: 'foo',
},
{
ID: 6,
title: 'bar',
},
}),
)
expect(update).toEqual(2)
]),
)
expect(insert.affectedRows).toEqual(2)

const update = await cds.run(
UPDATE.entity(Books)
.set({
title: 'foo',
})
.where({
ID: 5,
or: {
ID: 6,
},
}),
)
expect(update).toEqual(2)
})
test.skip('missing', () => {
throw new Error('not supported')
})
})
test.skip('missing', () => {
throw new Error('not supported')

describe('uniques in deep updates', () => {
test('2nd level unique constraints ', async () => {
// number must be unique for each book

await cds.tx(async tx => {
await tx.run(DELETE.from(BooksUnique).where({ ID: 1 }))
await expect(
tx.run(
INSERT.into(BooksUnique).entries([
{
ID: 1,
title: 'foo',
pages: [
{
ID: 1,
number: 1,
},
{
ID: 2,
number: 1, // unique constraint violation
},
],
},
{
ID: 2,
title: 'bar',
},
]),
),
).rejects.toBeTruthy()
})

await cds.tx(async tx => {
await tx.run(DELETE.from(BooksUnique).where({ ID: 1 }))
const data = {
ID: 1,
title: 'foo',
pages: [
{
ID: 1,
number: 1,
},
{
ID: 2,
number: 2,
},
],
}
await tx.run(INSERT.into(BooksUnique).entries([data]))

// Create new entries with conflicting numbers
data.pages[0].ID = 3
data.pages[1].ID = 4
await tx.run(UPDATE(BooksUnique).data(data)) // first, old entries are deleted, so no violation

data.pages[0].ID = 5
data.pages[0].number = 1 // would fail without the update below first
data.pages[1].number = 999
await tx.run(UPDATE(BooksUnique).data(data))
})
})
})
})
})
1 change: 1 addition & 0 deletions test/compliance/resources/db/complex/index.cds
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ namespace complex;
using from './computed';
using from './associations';
using from './associationsUnmanaged';
using from './uniques';
14 changes: 14 additions & 0 deletions test/compliance/resources/db/complex/uniques.cds
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace complex.uniques;

entity Books {
key ID : Integer;
title : String(111);
pages : Composition of many Pages on pages.book = $self;
}

@assert.unique: { number: [number, book] }
entity Pages {
key ID : Integer;
book : Association to Books;
number : Integer;
}