Eliminate N+1 queries with CQL expand, add CDS index annotations, and use EXPLAIN PLAN in HANA.
$expandA CAP app that works fine with 1000 rows grinds to a halt at 1 million. Most performance problems are predictable: missing indexes, unbounded queries, N+1 reads. Fixing these is cheap before production; expensive after.
entity Orders {
key ID : UUID;
status : String;
buyer_ID : UUID;
createdAt : Timestamp;
region : String;
// Secondary indexes — generated by cds build for HANA
@index buyer_ID; // most common filter field
@index { region, status }; // composite index for common combined filter
@index createdAt; // for date-range queries
}
Generated HANA artifact:
-- gen/db/src/gen/Orders.hdbindex
INDEX "ORDERS_BUYER_IDX" ON "ORDERS" ("BUYER_ID");
INDEX "ORDERS_REGION_STATUS_IDX" ON "ORDERS" ("REGION", "STATUS");
INDEX "ORDERS_CREATEDAT_IDX" ON "ORDERS" ("CREATED_AT");
HANA Cloud tables are column-store by default. Secondary indexes help most for equality and range filters on low-cardinality columns. High-cardinality columns (UUIDs) benefit less from indexes.
// WRONG — fires 1 SELECT for orders + N SELECTs for each buyer
const orders = await SELECT.from('Orders')
for (const o of orders) {
o.buyerName = (await SELECT.one.from('Users').where({ ID: o.buyer_ID })).name
}
// CORRECT — one query with expand
const orders = await SELECT.from('Orders').columns(o => {
o.ID, o.status,
o.buyer(u => { u.ID, u.name })
})
// Cap default allows unlimited expand depth — dangerous in production
// Limit in handler
this.before('READ', 'Orders', (req) => {
const expandDepth = countExpands(req.query)
if (expandDepth > 2) return req.reject(400, 'Maximum 2 levels of $expand allowed')
})
function countExpands (query) {
// Simple depth counter
let depth = 0
const str = JSON.stringify(query)
str.replace(/"expand"/g, () => depth++)
return depth
}
this.before('READ', 'Products', (req) => {
// Remove LargeString columns from list queries
const columns = req.query.SELECT?.columns
if (columns) {
req.query.SELECT.columns = columns.filter(c =>
!['description', 'specifications', 'manual'].includes(c.ref?.[0])
)
}
})
this.before('READ', 'Orders', (req) => {
const MAX_PAGE_SIZE = 100
if (!req.query.SELECT?.limit?.rows?.val || req.query.SELECT.limit.rows.val > MAX_PAGE_SIZE) {
req.query.limit(MAX_PAGE_SIZE)
}
})
-- Run in HANA Database Explorer to diagnose slow queries
EXPLAIN PLAN FOR
SELECT * FROM ORDERS WHERE STATUS = 'DRAFT' AND REGION = 'EMEA';
SELECT * FROM "SYS"."EXPLAIN_PLAN_TABLE" WHERE SESSION_USER = CURRENT_USER
ORDER BY STATEMENT_NAME DESC LIMIT 50;
Look for:
- Full table scan on large tables → add an index
- Nested loop join on non-indexed foreign key → add index on FK column
- Hash join on sorted data → add ORDER BY to leverage existing sort
// .cdsrc.json
{
"requires": {
"db": {
"[production]": {
"kind": "hana",
"pool": {
"max": 10, // max connections
"min": 2, // kept-alive connections
"acquireTimeoutMs": 5000, // fail fast if no connection available
"idleTimeoutMs": 30000 // release idle connections
}
}
}
}
}
// Enable query logging
// In .cdsrc.json:
{ "log": { "levels": { "db": "debug" } } }
// Or per-request in handler
this.before('READ', 'Orders', (req) => {
console.time('READ Orders')
})
this.after('READ', 'Orders', (data, req) => {
console.timeEnd('READ Orders')
console.log('Returned', data.length, 'rows')
})
□ All FK columns used in JOIN or WHERE have secondary indexes
□ No unbounded queries — always enforce $top / limit()
□ Large text columns excluded from list projections
□ $expand depth limited to 2–3 levels max
□ Connection pool tuned for your expected concurrency
□ Slow queries identified with EXPLAIN PLAN before go-live
□ Batch operations use native SQL for > 10K rows
@index buyer_ID to your Orders entity and rebuildSELECT.from().columns() with expanded buyer@index field on a CDS entity generates a HANA secondary indexreq.query.limit() in a BEFORE handler