Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
866728e
Append suffix of duplicate file names
eric-pSAP Feb 27, 2026
25d9604
Merge branch 'main' into fileSuffixCount
eric-pSAP Feb 27, 2026
78be463
Merge conflict resolution
eric-pSAP Feb 27, 2026
b4b4f9c
Bot comments
eric-pSAP Feb 27, 2026
2a8540a
Multiple attachment deep insert handling
eric-pSAP Feb 27, 2026
5c8a2bc
Adjust prettier for isNotLocal
eric-pSAP Mar 2, 2026
bc80009
Merge branch 'main' into fileSuffixCount
eric-pSAP Mar 2, 2026
1fe2f7d
Merge branch 'main' into fileSuffixCount
eric-pSAP Mar 4, 2026
350408a
Refactoring to limit to only one efficient call to the database and m…
eric-pSAP Mar 4, 2026
c5689de
Merge branch 'fileSuffixCount' of https://github.com/cap-js/attachmen…
eric-pSAP Mar 4, 2026
e6d3e87
Prettier
eric-pSAP Mar 4, 2026
92c441d
HANA funciton fix
eric-pSAP Mar 4, 2026
b38fbd3
HANA funciton fix
eric-pSAP Mar 4, 2026
64f376a
HANA funciton adjustment
eric-pSAP Mar 4, 2026
1690738
HANA funciton adjustment
eric-pSAP Mar 4, 2026
8bbe2ec
Revert tableName change
eric-pSAP Mar 5, 2026
9f1c6a7
Old scan status message rewording
eric-pSAP Mar 6, 2026
4f50b1e
Merge branch 'main' into fileSuffixCount
eric-pSAP Mar 11, 2026
cc24195
Merge branch 'main' into fileSuffixCount
eric-pSAP Mar 12, 2026
3e4b719
HANA column name fixes
eric-pSAP Mar 13, 2026
3a70594
Comment adjustments
eric-pSAP Mar 17, 2026
426bfa0
Tests for composite keys
eric-pSAP Mar 19, 2026
472d03e
Added local postgres testing
eric-pSAP Mar 19, 2026
b12912f
Prettier
eric-pSAP Mar 19, 2026
6a58130
Merge branch 'main' into fileSuffixCount
eric-pSAP Mar 19, 2026
f009d32
Update attachments.test.js
schiwekM Mar 19, 2026
8f0055c
Fix composite key bug fix
eric-pSAP Mar 20, 2026
6f5ff61
Fix comment
eric-pSAP Mar 20, 2026
a6b121c
Merge branch 'fileSuffixCount' of https://github.com/cap-js/attachmen…
eric-pSAP Mar 20, 2026
8b6b21e
Restore previous test and add new test
eric-pSAP Mar 20, 2026
2eb36e8
Description fix
eric-pSAP Mar 20, 2026
658a465
Adjust
schiwekM Mar 20, 2026
f3f3b2a
Update helper.js
schiwekM Mar 20, 2026
4bc5035
Remove unused parameter
eric-pSAP Mar 20, 2026
ab6aea8
Waiter test adjustment
eric-pSAP Mar 20, 2026
34f884b
Prettier
eric-pSAP Mar 20, 2026
248b7c8
Revert
eric-pSAP Mar 20, 2026
4a0e3bc
Local pg testing
eric-pSAP Mar 23, 2026
595290b
Update helper.js
schiwekM Mar 24, 2026
01afc42
Update helper.js
schiwekM Mar 24, 2026
30dab23
Merge branch 'main' into fileSuffixCount
eric-pSAP Mar 24, 2026
4b15c17
Remove unneeded Postgress changes
eric-pSAP Mar 24, 2026
431f831
Merge resolution
eric-pSAP Mar 24, 2026
3617439
Postgres query fix
eric-pSAP Mar 25, 2026
5b46e34
Bug fix where waiter detected clean status from draft for active
eric-pSAP Mar 25, 2026
814d6d7
Merge branch 'main' into fileSuffixCount
eric-pSAP Mar 25, 2026
1765431
CHANGELOG update
eric-pSAP Mar 25, 2026
8f990a4
Revert testUtils
eric-pSAP Mar 25, 2026
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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).

### Added

- Emit the following security events on the attachments service: - AttachmentDownloadRejected, AttachmentSizeExceeded AttachmentUploadRejected
- If `@cap-js/audit-logging` is installed automatically trigger audit logs for the security events
- Emit the following security events on the attachments service: - AttachmentDownloadRejected, AttachmentSizeExceeded AttachmentUploadRejected.
- If `@cap-js/audit-logging` is installed automatically trigger audit logs for the security events.
- Duplicate file names to a single attachment entity are automatically assigned a distinguishing suffix.
- Local testing using a Postgres database now possible.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion _i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ InvalidContentLengthHeader=The 'Content-Length' header value '{contentLength}' i
AttachmentAlreadyExistsCannotBeOverwritten=Attachment {0} already exists and cannot be overwritten.
MaximumAmountExceeded=Cannot upload more than {0} attachments!
MinimumAmountNotFulfilled=At least {0} attachments must be uploaded!
UnableToDownloadAttachmentScanStatusExpired=The previous scan was more than 3 days ago, please wait for the attachment to be rescanned.
UnableToDownloadAttachmentScanStatusExpired=The previous scan was more than 3 days ago, please wait for the attachment to be rescanned.
214 changes: 214 additions & 0 deletions lib/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const crypto = require("crypto")
const stream = require("stream/promises")
const cds = require("@sap/cds")
const LOG = cds.log("attachments")
const { extname } = require("path")

/**
* Validates the presence of required Service Manager credentials
Expand Down Expand Up @@ -514,6 +515,218 @@ function sizeInBytes(size, target) {
return value * multipliers[unit]
}

/**
* Builds a single string key from an ordered list of values for Map lookups.
* @param {Array} values - Array of values to join
* @returns {string}
*/
function makeTupleKey(values) {
return values.map(String).join("\x00")
}

/**
* Builds a CQN xpr for parent key matching.
* Single key: up__ID in (val1, val2, ...)
* Composite key: (up__sampleID = val1 and up__gjahr = val2) or (...)
* @param {string[]} parentKeys - The parent key column names
* @param {object[]} tuples - Array of parent key value objects
* @returns {object} CQN xpr object
*/
function buildParentCondition(parentKeys, tuples) {
if (parentKeys.length === 1) {
const key = parentKeys[0]
return {
xpr: [
{ ref: [key] },
"in",
{ list: tuples.map((t) => ({ val: t[key] })) },
],
}
}
const xpr = []
for (const tuple of tuples) {
if (xpr.length > 0) xpr.push("or")
xpr.push("(")
for (let i = 0; i < parentKeys.length; i++) {
if (i > 0) xpr.push("and")
xpr.push({ ref: [parentKeys[i]] }, "=", { val: tuple[parentKeys[i]] })
}
xpr.push(")")
}
return { xpr }
}

/**
* Builds a CQN xpr for filename matching conditions.
* (filename = 'sample.pdf' or filename like 'sample-%.pdf') or ...
* @param {Set<string>} incomingFilenames - Set of filenames to match
* @returns {object} CQN xpr object
*/
function buildFilenameCondition(incomingFilenames) {
const { extname, basename } = require("path")
const xpr = []
for (const filename of incomingFilenames) {
if (xpr.length > 0) xpr.push("or")
const base = basename(filename, extname(filename))
const ext = extname(filename)
xpr.push(
"(",
{ ref: ["filename"] },
"=",
{ val: filename },
"or",
{ ref: ["filename"] },
"like",
{ val: `${base}-%${ext}` },
")",
)
}
return { xpr }
}

/**
* Builds a DB-specific ORDER BY expression to extract the numeric suffix from filenames.
* These use DB-native functions that cannot be abstracted by cds.ql, so they remain
* per-dialect. The rest of the query (SELECT, WHERE, columns) is fully DB-agnostic.
* @param {string} dbKind - The database kind ('hana', 'postgres', 'sqlite')
* @returns {string|null} Raw SQL expression string, or null if unsupported
*/
function buildSuffixOrderBy(dbKind) {
if (dbKind === "hana") {
return cds.ql
.expr`LPAD(SUBSTRING_REGEXPR('([0-9]+)(?!.*[0-9])' IN filename), 10, '0')`
} else if (dbKind === "postgres") {
return cds.ql
.expr`LPAD(COALESCE(NULLIF(REGEXP_REPLACE(filename, '.*-(\\d+)\\..*', '\\1'), filename), '0'), 10, '0')`
} else if (dbKind === "sqlite") {
return cds.ql
.expr`CAST(SUBSTR(filename, INSTR(filename, '-') + 1, INSTR(SUBSTR(filename, INSTR(filename, '-') + 1), '.') - 1) AS cds.Integer)`
}
return null
}

/**
* Handles duplicate filename checks and renaming for a batch of attachments.
* Performs a single database query to fetch all potential duplicates for all incoming files,
* then renames them as needed by appending a numerical suffix.
* @param {Array<object>} entries - The array of attachment data from the request.
* @param {import('@sap/cds').Entity} attachmentTarget - The target attachment entity.
* @param {Array<string>} parentKeys - The names of the parent key columns (e.g., ['up__ID']).
*/
async function handleDuplicates(entries, attachmentTarget, parentKeys) {
if (!Array.isArray(parentKeys)) parentKeys = [parentKeys]

const parentTupleMap = new Map()
const incomingFilenames = new Set()

// Collect parent IDs and incoming filenames from the request data
for (const entry of entries) {
if (entry.attachments && Array.isArray(entry.attachments)) {
// Deep insert case
const values = parentKeys.map((k) => entry[k.replace(/^up__/, "")])
parentTupleMap.set(
makeTupleKey(values),
Object.fromEntries(parentKeys.map((k, i) => [k, values[i]])),
)
for (const att of entry.attachments) {
if (att.filename) {
incomingFilenames.add(att.filename)
}
}
} else if (entry.filename) {
// Single attachment case
const values = parentKeys.map((k) => entry[k])
parentTupleMap.set(
makeTupleKey(values),
Object.fromEntries(parentKeys.map((k, i) => [k, values[i]])),
)
incomingFilenames.add(entry.filename)
}
}

if (parentTupleMap.size === 0 || incomingFilenames.size === 0) {
return
}

const tuples = [...parentTupleMap.values()]
const db = await cds.connect.to("db")

const query = SELECT.from(attachmentTarget)
.columns("filename", ...parentKeys)
.where([
buildParentCondition(parentKeys, tuples),
"and",
buildFilenameCondition(incomingFilenames),
])
.orderBy("filename")

const suffixOrderSql = buildSuffixOrderBy(db.kind)
if (suffixOrderSql) {
query.SELECT.orderBy.push({ xpr: [suffixOrderSql], sort: "desc" })
}

const allExistingAttachments = await query

// Group existing filenames by compound parent key
const existingFilenamesByParent = allExistingAttachments.reduce(
(acc, att) => {
const compoundKey = makeTupleKey(parentKeys.map((k) => att[k]))
if (!acc[compoundKey]) acc[compoundKey] = new Set()
acc[compoundKey].add(att.filename)
return acc
},
{},
)

for (const entry of entries) {
if (entry.attachments) {
// Deep insert: build key from parent's own fields (no up__ prefix)
const values = parentKeys.map((k) => entry[k.replace(/^up__/, "")])
const tupleKey = makeTupleKey(values)
const filenames = existingFilenamesByParent[tupleKey] || new Set()
for (const attachment of entry.attachments) {
parentKeys.forEach((k, i) => {
attachment[k] = values[i]
})
renameFile(attachment, filenames)
filenames.add(attachment.filename)
}
} else if (entry[parentKeys[0]]) {
// Single attachment: build key from up__ fields directly
const tupleKey = makeTupleKey(parentKeys.map((k) => entry[k]))
const filenames = existingFilenamesByParent[tupleKey] || new Set()
renameFile(entry, filenames)
filenames.add(entry.filename)
}
}
}

/**
* Renames a filename by appending a numerical suffix (e.g., 'file-1.txt') and modifies the data object.
* @param {object} data - The attachment data object, which will be modified.
* @param {Set<string>} existingFilenames - A set of filenames to check for duplicates against.
*/
function renameFile(data, existingFilenames) {
if (!data.filename || !existingFilenames) {
return
}

if (existingFilenames.has(data.filename)) {
const ext = extname(data.filename)
const basename =
ext.length > 0 ? data.filename.slice(0, -ext.length) : data.filename
let counter = 1
let newFilename

do {
newFilename = `${basename}-${counter}${ext}`
counter++
} while (existingFilenames.has(newFilename))

data.filename = newFilename
}
}

function traverseEntity(root, path) {
let current = root
for (const part of path) {
Expand Down Expand Up @@ -624,5 +837,6 @@ module.exports = {
MAX_FILE_SIZE,
inferTargetCAP8,
getAttachmentKind,
handleDuplicates,
createSizeCheckHandler,
}
18 changes: 16 additions & 2 deletions lib/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ const {
validateAttachmentMimeType,
validateAndInsertAttachmentFromDBHandler,
} = require("./generic-handlers")
const { inferTargetCAP8, getAttachmentKind } = require("./helper")
const {
inferTargetCAP8,
getAttachmentKind,
handleDuplicates,
} = require("./helper")
require("./csn-runtime-extension")
const LOG = cds.log("attachments")

Expand Down Expand Up @@ -126,7 +130,17 @@ cds.ApplicationService.handle_attachments = cds.service.impl(async function () {
this.before("PUT", validateAttachmentSize)
this.before("POST", validateAttachmentMimeType)
this.before("NEW", onPrepareAttachment)
this.before("CREATE", (req) => {
this.before("CREATE", async (req) => {
const entries = Array.isArray(req.data) ? req.data : [req.data]
const attachmentTarget =
req.target.elements.attachments?._target || req.target
const parentKeyColumn = Object.keys(attachmentTarget.elements).filter((k) =>
k.startsWith("up__"),
)
if (parentKeyColumn.length > 0) {
await handleDuplicates(entries, attachmentTarget, parentKeyColumn)
}

return onPrepareAttachment(req)
Comment thread
eric-pSAP marked this conversation as resolved.
})

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
},
"devDependencies": {
"@cap-js/cds-test": ">=0",
"@cap-js/cds-types": "^0.16.0",
"@cap-js/hana": "^2.7.0",
"@cap-js/sqlite": "^2",
"eslint": "^10",
"@eslint/js": "^10",
"eslint": "^10",
"express": "^4.18.2",
"husky": "^9.1.7"
},
Expand Down
2 changes: 1 addition & 1 deletion tests/incidents-app/db/attachments.cds
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ using {sap.capire.incidents as my} from './schema';
using {Attachments} from '@cap-js/attachments';

extend my.Incidents with {
@Validation.MaxItems: 2
@Validation.MaxItems: 3
attachments : Composition of many Attachments;
@attachments.disable_facet
@Validation.MaxItems : (urgency.code = 'H' ? 2 : 3)
Expand Down
Loading