Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9218d80
Two-level test entity
eric-pSAP Nov 20, 2025
5d6e5b6
Tests for two-level entities
eric-pSAP Nov 20, 2025
33b1351
Multi-level deleting tests
eric-pSAP Nov 25, 2025
2ade026
Merge branch 'main' into multi-level-bug
eric-pSAP Nov 25, 2025
c2ede55
Update null checks to use not.toBeNull()
eric-pSAP Nov 25, 2025
0b14716
Fix bug of child entity seen as attachments
eric-pSAP Nov 27, 2025
417ce6d
Merge conflict resolution
eric-pSAP Nov 27, 2025
0bf95e4
Merge branch 'main' into multi-level-bug
eric-pSAP Nov 27, 2025
ebde99f
Adjustment to nesting
eric-pSAP Nov 27, 2025
8eb8505
Merge branch 'multi-level-bug' of https://github.com/cap-js/attachmen…
eric-pSAP Nov 27, 2025
3b1d0a6
Re-add attachments to model
eric-pSAP Nov 27, 2025
1138793
Re-add recursion to hasAttachmentsComposition
eric-pSAP Nov 28, 2025
c24ef20
Re-add recursion for attachmentCompositions
eric-pSAP Nov 28, 2025
b4b86f9
Fix delete/update for hybrid
eric-pSAP Dec 2, 2025
2f23f15
Merge branch 'multi-level-bug' of https://github.com/cap-js/attachmen…
eric-pSAP Dec 2, 2025
4f49be4
Fix local delete/update
eric-pSAP Dec 2, 2025
9edc0db
Merge branch 'main' into multi-level-bug
eric-pSAP Dec 2, 2025
a38c955
Cancel function fixed
eric-pSAP Dec 2, 2025
724d20f
Refactor traversing function
eric-pSAP Dec 4, 2025
01f4a13
Remove CANCEL handlers
eric-pSAP Dec 4, 2025
08be81a
Switch from dot path to array
eric-pSAP Dec 5, 2025
d12cd8d
Merge branch 'main' into multi-level-bug
eric-pSAP Dec 5, 2025
e85d3a7
Refactor csn helper function
eric-pSAP Dec 5, 2025
23ef5f2
Merge branch 'multi-level-bug' of https://github.com/cap-js/attachmen…
eric-pSAP Dec 5, 2025
005984a
Adding up__ values for many-to-one compositions
eric-pSAP Dec 5, 2025
43863a3
Condition for adding up__ key
eric-pSAP Dec 9, 2025
33cce77
Draft cancel and many-to-one composition tests
eric-pSAP Dec 9, 2025
f4ac4c2
Merge branch 'main' into multi-level-bug
eric-pSAP Dec 9, 2025
0559b19
Test query fix
eric-pSAP Dec 9, 2025
42afa35
Merge branch 'multi-level-bug' of https://github.com/cap-js/attachmen…
eric-pSAP Dec 9, 2025
cb6e06f
Test fix composition size
eric-pSAP Dec 9, 2025
a2048ad
Restructure inline function
eric-pSAP Dec 10, 2025
0c3c3b4
Merge branch 'main' into multi-level-bug
eric-pSAP Dec 10, 2025
25d3ff8
Move function
eric-pSAP Dec 10, 2025
940bcdc
Additional many-to-one composition tests
eric-pSAP Dec 10, 2025
abc374d
Comment feedback
eric-pSAP Dec 10, 2025
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
23 changes: 15 additions & 8 deletions lib/csn-runtime-extension.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
const cds = require('@sap/cds')
const { LinkedDefinitions } = require("@sap/cds/lib/core/linked-csn")

function collectAttachments(ent, resultSet = [], path = []) {
if (!ent.compositions) return resultSet
for (const ele of Object.keys(ent.compositions)) {
const target = ent.compositions[ele]._target
const newPath = [...path, ele]
if (target?.["@_is_media_data"]) {
resultSet.push(newPath)
}
if (target && target !== ent) collectAttachments(target, resultSet, newPath)
}
return resultSet
}

Object.defineProperty(cds.builtin.classes.entity.prototype, '_attachments', {
get() {
const entity = this;
return {
get hasAttachmentsComposition() {
return entity.compositions && Object.keys(entity.compositions).some(ele => entity.compositions[ele]._target?.["@_is_media_data"] || entity.compositions[ele]._target?._attachments.hasAttachmentsComposition)
return entity.compositions && Object.keys(entity.compositions).some(ele => entity.compositions[ele]._target?.["@_is_media_data"] || entity.compositions[ele]._target?._attachments?.hasAttachmentsComposition)
},
get attachmentCompositions() {
const resultSet = new LinkedDefinitions()
if (!entity.compositions) return resultSet
for (const ele of Object.keys(entity.compositions).filter(ele => entity.compositions[ele]._target?.["@_is_media_data"] || entity.compositions[ele]._target?._attachments.hasAttachmentsComposition)) {
resultSet[ele] = entity.compositions[ele]
};
return resultSet;
return collectAttachments(entity)
},
get isAttachmentsEntity() {
return !!entity?.["@_is_media_data"]
Expand Down
14 changes: 13 additions & 1 deletion lib/generic-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,21 @@ const objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
* Prepares the attachment data before creation
* @param {import('@sap/cds').Request} req - The request object
*/
function onPrepareAttachment(req) {
async function onPrepareAttachment(req) {
if (!req.target?._attachments.isAttachmentsEntity) return;

const hasUpKey = Object.keys(req.data).some(key => key.startsWith("up__"))

if (!hasUpKey) {
const mySubject = { ...req.subject, ref: req.subject.ref.slice(0, -1) }
const parentKeys = Object.keys(cds.infer.target({SELECT: {from: mySubject}}).keys)
const parentRecord = await SELECT.one.from(mySubject).columns(parentKeys)

for (const key of parentKeys) {
req.data[`up__${key}`] = parentRecord[key]
}
}

req.data.url = isMultitenacyEnabled && objectStoreKind === "shared"
? `${req.tenant}_${req.data.url}`
: cds.utils.uuid()
Expand Down
11 changes: 11 additions & 0 deletions lib/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,16 @@ function sizeInBytes(size, target) {
return value * multipliers[unit]
}

function traverseEntity(root, path) {
let current = root
for (const part of path) {
if (!current.elements || !current.elements[part]) return undefined
current = current.elements[part]._target
if (!current) return undefined
}
return current
}

module.exports = {
fetchToken,
getObjectStoreCredentials,
Expand All @@ -392,5 +402,6 @@ module.exports = {
fetchObjectStoreBinding,
validateServiceManagerCredentials,
checkMimeTypeMatch,
traverseEntity,
MAX_FILE_SIZE
}
13 changes: 0 additions & 13 deletions lib/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,6 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
return AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)(res, req)
})

// case: attachments uploaded in draft and draft is discarded
this.before(["CANCEL"], async function collectDiscardedAttachmentsForDraftEnabled(req) {
if (!req.target?.actives || !req.target?._attachments.hasAttachmentsComposition) return;
const AttachmentsSrv = await cds.connect.to("attachments")
return AttachmentsSrv.attachDraftDiscardDeletionData.bind(AttachmentsSrv)(req)
})
this.after(["CANCEL"], async function deleteCollectedDiscardedAttachmentsForDraftEnabled(res, req) {
// Check for actives to make sure it is the draft entity
if (!req.target?.actives || !req.target?._attachments.hasAttachmentsComposition) return;
const AttachmentsSrv = await cds.connect.to("attachments")
return AttachmentsSrv.deleteAttachmentsWithKeys.bind(AttachmentsSrv)(res, req)
})


this.prepend(() =>
this.on(
Expand Down
84 changes: 35 additions & 49 deletions srv/basic.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const cds = require('@sap/cds')
const LOG = cds.log('attachments')
const { computeHash } = require('../lib/helper')
const { computeHash, traverseEntity } = require('../lib/helper')

class AttachmentsService extends cds.Service {

Expand Down Expand Up @@ -160,9 +160,14 @@ class AttachmentsService extends cds.Service {
return
}
await Promise.all(
Object.keys(req.target._attachments.attachmentCompositions).map(attachmentsEle =>
this.draftSaveHandler(req.target.elements[attachmentsEle]._target)(res, req)
)
req.target._attachments.attachmentCompositions.map(attachmentsEle =>{
const target = traverseEntity(req.target, attachmentsEle)
if (!target) {
LOG.error(`Could not resolve target for attachment composition: ${attachmentsEle}`)
return
}
return this.draftSaveHandler(target)(res, req)
})
)
}.bind(this))
}
Expand Down Expand Up @@ -195,8 +200,6 @@ class AttachmentsService extends cds.Service {
return result?.status
}



/**
* Registers attachment handlers for the given service and entity
* @param {*} records - The records to process
Expand All @@ -213,13 +216,31 @@ class AttachmentsService extends cds.Service {
})
}

/**
* Traverses nested data by a given path array.
* @param {Object} root - The root object or array to traverse.
* @param {Array} path - The array of keys representing the path.
* @returns {*} - The value found at the path, or [] if not found.
*/
traverseDataByPath(root, path) {
let current = root
for (let i = 0; i < path.length; i++) {
const part = path[i]
if (Array.isArray(current)) {
return current.flatMap(item => this.traverseDataByPath(item, path.slice(i)))
}
if (!current || !(part in current)) return []
current = current[part]
}
return current
}

/**
* Registers attachment handlers for the given service and entity
* @param {import('@sap/cds').Request} req - The request object
*/
async attachDeletionData(req) {
const attachmentCompositions = Object.keys(req?.target?.associations)
.filter(assoc => req?.target?.associations[assoc]._target['@_is_media_data'])
const attachmentCompositions = req?.target?._attachments.attachmentCompositions
if (attachmentCompositions.length > 0) {
const diffData = await req.diff()
if (!diffData || Object.keys(diffData).length === 0) {
Expand All @@ -228,17 +249,15 @@ class AttachmentsService extends cds.Service {
const queries = []
const queryTargets = []
for (const attachmentsComp of attachmentCompositions) {
let deletedAttachments = []
diffData[attachmentsComp]?.forEach(object => {
if (object._op === "delete") {
deletedAttachments.push(object.ID)
}
})
const leaf = this.traverseDataByPath(diffData, attachmentsComp)
const deletedAttachments = Array.isArray(leaf) ? leaf.filter(obj => obj._op === "delete").map(obj => obj.ID) : []

const entityTarget = traverseEntity(req.target, attachmentsComp)
if (deletedAttachments.length) {
queries.push(
SELECT.from(req.target.associations[attachmentsComp]._target).columns("url").where({ ID: { in: [...deletedAttachments] } })
SELECT.from(entityTarget).columns("url").where({ ID: { in: [...deletedAttachments] } })
)
queryTargets.push(req.target.associations[attachmentsComp]._target.name)
queryTargets.push(entityTarget.name)
}
}
if (queries.length > 0) {
Expand Down Expand Up @@ -296,39 +315,6 @@ class AttachmentsService extends cds.Service {
}
}

/**
* Add draft discard deletion data to the request
* @param {import('@sap/cds').Request} req - The request object
*/
async attachDraftDiscardDeletionData(req) {
const parentEntity = req.target.name.split('.').slice(0, -1).join('.')
const draftEntity = cds.model.definitions[`${parentEntity}.attachments.drafts`]
const activeEntity = cds.model.definitions[`${parentEntity}.attachments`]
if (!draftEntity || !activeEntity) return

const whereXpr = []
for (const foreignKey of activeEntity.keys['up_']._foreignKeys) {
if (whereXpr.length) {
whereXpr.push('and')
}
whereXpr.push(
{ ref: [foreignKey.parentElement.name] },
'=',
{ val: req.data[foreignKey.childElement.name] }
)
}

const attachmentsToDelete = await this.getAttachmentsToDelete({
draftEntity,
activeEntity,
whereXpr
})

if (attachmentsToDelete.length > 0) {
req.attachmentsToDelete = attachmentsToDelete
}
}

/**
* Deletes a file from the database. Does not delete metadata
* @param {string} url - The url of the file to delete
Expand Down
45 changes: 45 additions & 0 deletions tests/incidents-app/app/incidents/annotations.cds
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,48 @@ annotate service.Incidents.conversation with @(
Label : '{i18n>Message}',
},]
);

annotate service.Test with @(
UI.SemanticKey : [name],
UI.LineItem : [
{
Value : name,
Label : 'Name',
},
]
);

annotate service.TestDetails with @(
UI.SemanticKey : [description],
UI.LineItem : [
{
Value : description,
Label : 'Description',
},
]
);

annotate service.Test with @(
UI.Facets : [
{
$Type : 'UI.ReferenceFacet',
Label : 'Details',
Target : 'details/@UI.LineItem'
},
{
$Type : 'UI.ReferenceFacet',
Label : 'Attachments',
Target : 'attachments/@UI.LineItem'
}
]
);

annotate service.TestDetails with @(
UI.Facets : [
{
$Type : 'UI.ReferenceFacet',
Label : 'Attachments',
Target : 'attachments/@UI.LineItem'
}
]
);
16 changes: 16 additions & 0 deletions tests/incidents-app/db/attachments.cds
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,19 @@ extend my.Customers with {
extend my.SampleRootWithComposedEntity with {
attachments : Composition of many Attachments;
}

extend my.Test with {
attachments: Composition of many Attachments;
}

extend my.TestDetails with {
attachments: Composition of many Attachments;
}

extend my.NonDraftTest with {
attachments: Composition of many Attachments;
}

extend my.SingleTestDetails with {
attachments : Composition of many Attachments;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ID,name,singledetails_ID
33333333-3333-3333-3333-333333333333,Test Parent 1,877fc800-337e-495e-a497-c1a95fbfe6a3
44444444-4444-4444-4444-444444444444,Test Parent 2,877fc800-337e-495e-a497-c1a95fbfe6a3
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ID,abc
877fc800-337e-495e-a497-c1a95fbfe6a3,My fancy text
e1ab7f04-40d3-434a-a638-c14d40f2b52c,My fancy text
d899ed80-7418-4d58-9772-b23db76fee8f,My fancy text
f774c1a0-f9a6-45d5-be48-99cf8df388a2,My fancy text
c5f57897-0c64-41f4-a303-dfad518a1512,My fancy text
0fb9eda6-2989-4e14-be17-bf0da71b5f55,My fancy text
27b34131-ffaa-4086-8e95-f1ed92f37768,My fancy text
3 changes: 3 additions & 0 deletions tests/incidents-app/db/data/sap.capire.incidents-Test.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ID,name
11111111-1111-1111-1111-111111111111,Test Parent 1
22222222-2222-2222-2222-222222222222,Test Parent 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ID;test_ID;description
aaaaaaa1-aaaa-aaaa-aaaa-aaaaaaaaaaa1;11111111-1111-1111-1111-111111111111;Detail 1 for Test 1
aaaaaaa2-aaaa-aaaa-aaaa-aaaaaaaaaaa2;11111111-1111-1111-1111-111111111111;Detail 2 for Test 1
bbbbbbb1-bbbb-bbbb-bbbb-bbbbbbbbbbb1;22222222-2222-2222-2222-222222222222;Detail 1 for Test 2
21 changes: 21 additions & 0 deletions tests/incidents-app/db/schema.cds
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,24 @@ entity SampleRootWithComposedEntity {
key sampleID : String;
key gjahr : Integer;
}

entity Test : cuid, managed {
key ID : String;
name : String;
details : Composition of many TestDetails on details.test = $self;
}

entity TestDetails : cuid, managed {
test : Association to Test;
description : String;
}

entity NonDraftTest : cuid, managed {
key ID : String;
name : String;
singledetails : Composition of one SingleTestDetails;
}

entity SingleTestDetails : cuid {
abc: String;
}
9 changes: 9 additions & 0 deletions tests/incidents-app/srv/services.cds
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ service ProcessorService {

@odata.draft.enabled
entity SampleRootWithComposedEntity as projection on my.SampleRootWithComposedEntity;

@odata.draft.enabled
entity Test as projection on my.Test;

entity TestDetails as projection on my.TestDetails;

entity NonDraftTest as projection on my.NonDraftTest;

entity SingleTestDetails as projection on my.SingleTestDetails;
}

/**
Expand Down
Loading