cds-caching 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,12 +26,12 @@ cds-caching is specifically designed for efficient caching, not data replication
26
26
  ### Key Features
27
27
 
28
28
  * **Flexible Key-Value Store** – Store and retrieve data using simple key-based access.
29
- * **CachingService** – A CALESI-compliant cds.Service implementation with an intuitive API for seamless integration.
29
+ * **CachingService** – A cds.Service implementation with an intuitive API for seamless integration into CAP.
30
30
  * **Event Handling** – Monitor and react to cache events, such as before/after storage and retrieval.
31
31
  * **CAP-specific Caching** – Effortlessly cache CQN queries or CAP cds.Requests using code or the @cache annotation.
32
32
  * **TTL Support** – Automatically manage data expiration with configurable time-to-live (TTL) settings.
33
33
  * **Tag Support** – Use dynamic tags for flexible cache invalidation options.
34
- * **Pluggable Storage Options** – Choose between in-memory caching or Redis.
34
+ * **Pluggable Storage Options** – Choose between in-memory caching, SQLite or Redis.
35
35
  * **Compression** – Compress cached data to save memory using LZ4 or GZIP.
36
36
  * **Integrated Statistics** – Monitor cache performance with hit rates, latencies, and more.
37
37
 
@@ -74,13 +74,20 @@ For more control, you can specify additional options:
74
74
  "caching": {
75
75
  "impl": "cds-caching",
76
76
  "namespace": "my::app::caching",
77
- "store": "in-memory", // "in-memory" or "redis"
77
+ "store": "in-memory", // "in-memory" or "sqlite" or "redis"
78
78
  "compression": "lz4", // "lz4" or "gzip"
79
- "credentials": { // if store is redis
79
+ "credentials": { // if store is redis or sqlite
80
+
81
+ // Redis specific
80
82
  "host": "localhost",
81
83
  "port": 6379,
82
84
  "password": "optional",
83
85
  "url": "redis://..." // Alternative: Redis connection URI
86
+
87
+ // SQLite specific
88
+ "url": "sqlite://./cache.sqlite"
89
+ "table": "cache",
90
+ "busyTimeout": 10000
84
91
  },
85
92
  "statistics": {
86
93
  "enabled": true,
@@ -93,6 +100,104 @@ For more control, you can specify additional options:
93
100
  }
94
101
  ```
95
102
 
103
+ ### Real-World Usage and Deployment
104
+
105
+
106
+ #### Storage Options
107
+
108
+ cds-caching provides 3 storage options:
109
+
110
+ ##### In-Memory Cache (for development / small-scale uses)
111
+ - Simple and fast, but not persistent
112
+ - Not suitable for production since Node.js runtime memory is limited
113
+ - Data is lost when the application restarts
114
+ - Memory on SAP BTP Cloud Foundry is limited (up to 16 GB) and produces costs
115
+
116
+ ##### SQLite (for medium-size use uses)
117
+ - Data is stored in local SQLite database
118
+ - Data is persited next to SAP BTP application with disk-quota up to 10 GB
119
+ - Cache will be removed after each deployment to SAP BTP
120
+ - No distributed cache between application instances (horizontal scaling)
121
+
122
+ ##### Redis Cache (recommended for production)
123
+ - Persistent and supports distributed caching
124
+ - Works across multiple app instances, making it ideal for scalable applications
125
+ - Available on SAP BTP via hyperscaler options (e.g., AWS, Azure, Google Cloud)
126
+ - Even trial accounts provide Redis access
127
+
128
+ #### Redis Development Setup
129
+
130
+ ##### Running Redis Locally via Docker
131
+ For local development, Redis can be quickly set up using Docker. A simple docker-compose configuration provides a lightweight caching environment:
132
+
133
+ 1. Create a `docker-compose.yml` file:
134
+ ```yaml
135
+ services:
136
+ redis:
137
+ image: redis:latest
138
+ container_name: local-redis
139
+ ports:
140
+ - "6379:6379"
141
+ ```
142
+
143
+ 2. Run Redis with:
144
+ ```bash
145
+ docker compose up -d
146
+ ```
147
+
148
+ 3. Modify the `package.json` configuration to connect to the local Redis instance:
149
+ ```json
150
+ {
151
+ "cds": {
152
+ "requires": {
153
+ "caching": {
154
+ "impl": "cds-caching",
155
+ "namespace": "myCache",
156
+ "store": "redis",
157
+ "[development]": {
158
+ "credentials": {
159
+ "host": "localhost",
160
+ "port": 6379
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ Now, caching will be handled by Redis instead of in-memory storage during development.
170
+
171
+ #### Production Deployment on SAP BTP
172
+
173
+ ![Redis on SAP BTP](./docs/caching-btp.png)
174
+
175
+ For production deployments on SAP BTP, Redis can be provisioned as a managed service through the Redis on SAP BTP hyperscaler option. An instance can be provisioned via trial or even as a Free Tier to explore the service. However, for production scenarios the size of the Redis instance should match your caching requirements.
176
+
177
+ To bind Redis to your CAP application on SAP BTP, add the following configuration in `mta.yaml`. This will automatically create the service instance and bind your application to it. Since the credentials will automatically be fetched by CAP, make sure to maintain the service-tags to match the kind property of your cds-caching service(s) in the package.json:
178
+
179
+ ```yaml
180
+ modules:
181
+ - name: cap-app-srv
182
+ # ... other module configuration ...
183
+ requires:
184
+ - name: redis-cache
185
+
186
+ resources:
187
+ - name: redis-cache
188
+ type: org.cloudfoundry.managed-service
189
+ parameters:
190
+ service: redis-cache
191
+ service-plan: trial
192
+ service-tags:
193
+ # Must match the kind property in the package.json
194
+ - cds-caching
195
+ ```
196
+
197
+ > 👉 **Tip**: There is a detailed [blog series on Redis in SAP BTP](https://community.sap.com/t5/technology-blogs-by-sap/redis-on-sap-btp-understanding-service-entitlements-and-metrics/ba-p/13738371) explaining how to set up Redis and connect via SSH for local/hybrid testing, as this is by default not possible.
198
+
199
+
200
+
96
201
  ### Usage Patterns
97
202
 
98
203
  The caching service provides a flexible API for caching data in CAP applications. Here are the key usage patterns:
@@ -146,11 +251,12 @@ const result = await cache.run(query, db)
146
251
 
147
252
  This will transparently cache the result of the query and return the cached result if available for all further requests.
148
253
 
149
- #### 3. Request-Level Caching
254
+ #### 3. RemoteService Request-Level Caching
150
255
 
151
- Cache entire CAP requests with context awareness (e.g. user, tenant, locale, etc.), which is useful for caching slow remote service calls. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
256
+ Cache entire CAP requests with context awareness (e.g. user, tenant, locale, etc.), which is useful for caching slow remote service calls or even application services. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
152
257
 
153
258
  ```javascript
259
+ // Cache the requests to an exposed external entity
154
260
  this.on('READ', BusinessPartners, async (req, next) => {
155
261
  const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
156
262
  let value = await cache.get(req)
@@ -165,17 +271,45 @@ this.on('READ', BusinessPartners, async (req, next) => {
165
271
  Alternatively use read-through caching via the `run` method to let the caching service handle the caching transparently:
166
272
 
167
273
  ```javascript
168
- const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
169
- const result = await cache.run(req, bupa)
274
+ this.on('READ', BusinessPartners, async (req, next) => {
275
+ const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
276
+ return await cache.run(req, bupa)
277
+ })
278
+
170
279
  ```
171
280
 
172
281
  This will transparently cache the result of the request and return the cached result if available for all further requests.
173
282
 
174
- #### 4. Declarative Caching with Annotations
283
+ ### 4. ApplicationService Request-Level Caching
284
+
285
+
286
+ > Caching an entire entity should be used with caution, as it will cache all permutations of requests ($filter, $expand, $orderby, etc.) on the entity, which will lead to a huge number of cache entries. Use this only for entities where you can guarantee a low number of different queries.
287
+
175
288
 
176
- Use annotations to enable caching on service entities or OData functions. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
289
+ But not only external services can be cached, it's also possible to cache requests against an ApplicationService.
290
+ Here, you should make use of the [`prepend`](https://cap.cloud.sap/docs/node.js/core-services#srv-prepend) function, to register the `on` handler before the default handler. Thus, it is possible to first check for the cache entries and only execute the default behavior if necessary.
177
291
 
178
- **Caching an entire entity should be used with caution, as it will cache all permutations of requests ($filter, $expand, $orderby, etc.) on the entity, which will lead to a huge number of cache entries. Use this only for entities where you can guarantee a low number of different queries.**
292
+
293
+ ```javascript
294
+ class MyService extends cds.ApplicationService {
295
+ async init() {
296
+
297
+ // Read-through caching for the full entity
298
+ this.prepend(() => {
299
+ const { MyEntity } = this.entities;
300
+ this.on('READ', MyEntity, async (req, next) => {
301
+ const cache = cds.connect.to("caching");
302
+ return cache.run(req, next);
303
+ });
304
+ });
305
+ return super.init()
306
+ }
307
+ }
308
+ ```
309
+
310
+ #### 5. ApplicationService Request-Level Caching with Annotations
311
+
312
+ Alternatively to doing this via code, you can use annotations to enable caching on service entities or OData functions. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
179
313
 
180
314
  ```
181
315
  service MyService {
@@ -266,7 +400,49 @@ const result = await cache.exec("key", async () => {
266
400
  })
267
401
  ```
268
402
 
269
- #### 2. Tag-Based
403
+ #### 2. Key-Based
404
+
405
+ Key-based invalidation is a way to invalidate cache entries based on a specific key.
406
+
407
+ ```javascript
408
+ await cache.delete("key")
409
+ ```
410
+
411
+ Keys are critical for cache invalidation. To allow custom key management, you can override the auto-generated key. This option is available for all essential methods (e.g cache.set, cache.run, cache.send, cache.createKey) and for the annotations.
412
+
413
+ ```javascript
414
+ // No key override given, string will just be used as keys
415
+ await cache.set('key', 'value') // key: key
416
+
417
+ // No key override given, objects will be smartly hashed
418
+ await cache.set(SELECT.from(Foo)) // key: bd3f3690d3e96a569bd89d9e207a89af
419
+
420
+ // Automatically build the key for retrieval/deletion
421
+ cache.createKey(SELECT.from(Foo)) // key: bd3f3690d3e96a569bd89d9e207a89af
422
+
423
+ // Override and use your own key based on a fixed value
424
+ await cache.set(SELECT.from(Foo, 1), { key: { value: "foo:1" } })
425
+
426
+ // Override and only for requests, use request context information
427
+ await cache.run(req, remoteService, { key: { template: "mykey:{tenant}:{user}:{locale}:{hash}" } })
428
+
429
+ // This requests will be cached for all users and for each locale
430
+ await cache.set(req, remoteService, { key: { template: "mykey:{user}:{locale}:{hash}" } })
431
+ ```
432
+
433
+ Overriding keys support the following configuration options:
434
+ - `value` – generates a static value
435
+ - `prefix` – will add this piece at the beginning
436
+ - `suffix` - will ad this piece at the end
437
+ - `template` - will set a value filled with placeholders, available placeholders are (only relevant for cds.Requests):
438
+ - `{user}`: The current user
439
+ - `{tenant}`: The current tenant
440
+ - `{locale}`: The current locale
441
+ - `{hash}`: The hash of the request query/params/data/path/etc.
442
+
443
+ With well-structured keys, invalidating cache entries becomes a lot easier. However, for more complex scenarios tags provide an even more effective solution, as tags can automatically be created based on the cached data.
444
+
445
+ #### 3. Tag-Based
270
446
 
271
447
  Tags are a way to invalidate cache entries based on a specific tag. Tags need to be provided explicitly when storing a value in the cache and are supported for all cache methods (e.g. `set`, `run`, `send`, `wrap`, `exec`).
272
448
  Tags can be provided as an array of strings or as an array of objects with the following properties:
@@ -434,7 +610,8 @@ The following properties are accepted:
434
610
  | Property | Description | Example |
435
611
  | ------------- | ------------- | ----------
436
612
  | ttl | Time-to-live in milliseconds. | `1000`
437
- | tags | Array of tags to associate with the value. Tags can be dynamic based on the given value (see chapter Cache Invalidation Strategies) | `[{template: 'user-{user}', value: '123'}]`
613
+ | key | Key override for the cache for full control over the key management (see chapter Cache Invalidation Strategies) | `{template: 'user-{user}', value: '123'}`
614
+ | tags | Array of tags to associate with the value. Tags can be dynamic based on the stored cache data (see chapter Cache Invalidation Strategies) | `[{template: 'user-{user}', value: '123'}]`
438
615
 
439
616
  ---
440
617
 
@@ -572,7 +749,7 @@ The result of the function.
572
749
 
573
750
  ---
574
751
 
575
- ### `await cache.iterator()`
752
+ ### `await cache.iterator() : AsyncIterator<{ key: string, value: { value: any, tags: string[], timestamp: number } }>`
576
753
 
577
754
  Returns an iterator over all cache entries.
578
755
 
@@ -582,6 +759,36 @@ An iterator over all cache entries.
582
759
 
583
760
  ---
584
761
 
762
+ ### `await cache.tags(key: any) : string[]`
763
+
764
+ Returns the tags for a given key.
765
+
766
+ #### `key: any`
767
+
768
+ The key to get the tags for. The key handling is the same as for the `createKey` method.
769
+
770
+ #### Returns
771
+
772
+ An array of tags. If the key does not exist, an empty array is returned.
773
+
774
+ ---
775
+
776
+ ### `await cache.metadata(key: any) : { tags: string[], timestamp: number } | undefined`
777
+
778
+ Returns the metadata for a given key.
779
+
780
+ #### `key: any`
781
+
782
+ The key to get the metadata for. The key handling is the same as for the `createKey` method.
783
+
784
+ #### Returns
785
+
786
+ An object containing the metadata for the given key or `undefined` if the key does not exist. The metadata object contains the following properties:
787
+
788
+ - `tags`: An array of tags.
789
+ - `timestamp`: The timestamp of the cache entry.
790
+
791
+ ---
585
792
 
586
793
  ### Contributing
587
794
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cds-caching",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A caching plugin for SAP CAP applications supporting Redis",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,17 +41,16 @@
41
41
  "dependencies": {
42
42
  "@keyv/compress-gzip": "^2.0.2",
43
43
  "@keyv/compress-lz4": "^1.0.0",
44
- "@keyv/redis": "^4.2.0",
45
- "keyv": "^5.2.3"
44
+ "@keyv/redis": "^4.3.1",
45
+ "@keyv/sqlite": "^4.0.1",
46
+ "keyv": "^5.3.1"
46
47
  },
47
48
  "devDependencies": {
48
- "chai": "^4.5.0",
49
- "chai-as-promised": "^7.1.2",
50
- "chai-subset": "^1.6.0",
51
- "eslint": "^8.57.0",
52
- "husky": "^9.0.11",
49
+ "eslint": "^9.21.0",
50
+ "husky": "^9.1.7",
53
51
  "jest": "^29.7.0",
54
- "release-it": "^18.1.2"
52
+ "release-it": "^18.1.2",
53
+ "@cap-js/cds-test": "^0.2.0"
55
54
  },
56
55
  "engines": {
57
56
  "node": ">=16"
@@ -1,6 +1,7 @@
1
1
  const cds = require('@sap/cds');
2
2
  const { Keyv } = require('keyv');
3
3
  const { default: KeyvRedis } = require('@keyv/redis');
4
+ const { default: KeyvSqlite } = require('@keyv/sqlite');
4
5
  const { default: KeyvLz4 } = require('@keyv/compress-lz4');
5
6
  const { default: KeyvGzip } = require('@keyv/compress-gzip');
6
7
  const crypto = require('crypto');
@@ -20,30 +21,45 @@ class CachingService extends cds.Service {
20
21
  this.options = this.options || {
21
22
  store: null,
22
23
  compression: null,
23
- credentials: {
24
- }
24
+ credentials: { }
25
25
  };
26
26
 
27
+ let store;
28
+
29
+ switch (this.options.store) {
30
+ case "sqlite":
31
+ store = new KeyvSqlite({
32
+ url: this.options.credentials?.url,
33
+ table: this.options.credentials?.table || 'cache',
34
+ busyTimeout: this.options.credentials?.busyTimeout || 10000
35
+ });
36
+ break;
37
+ case "redis":
38
+ store = new KeyvRedis({
39
+ ...this.options.credentials,
40
+ // Redis, Hyperscaler Option on BTP provides a URI
41
+ ...(this.options.credentials?.uri ? { url: this.options.credentials?.uri } : {}),
42
+ });
43
+
44
+ cds.once("shutdown", async () => {
45
+ if (this.cache.store?.disconnect) {
46
+ await this.cache.store.disconnect().catch((err) => {
47
+ this.LOG._error && this.LOG.error('Error disconnecting from Redis', err);
48
+ });
49
+ }
50
+ });
51
+ break;
52
+ default:
53
+ store = new Map();
54
+ break;
55
+ }
56
+
27
57
  let cacheOptions = {
28
58
  namespace: this.options.namespace || this.name,
29
- ...(this.options.store === "redis" ? { store: new KeyvRedis({
30
- ...this.options.credentials,
31
- // Redis, Hyperscaler Option on BTP provides a URI
32
- ...(this.options.credentials?.uri ? { url: this.options.credentials?.uri } : {}),
33
- }) } : {}),
59
+ store: store,
34
60
  compression: this.options.compression === "lz4" ? new KeyvLz4() : this.options.compression === "gzip" ? new KeyvGzip() : undefined
35
61
  }
36
62
 
37
- if (this.options.store === "redis") {
38
-
39
- cds.once("shutdown", async () => {
40
- if (this.cache.store?.disconnect) {
41
- await this.cache.store.disconnect().catch((err) => {
42
- this.LOG._error && this.LOG.error('Error disconnecting from Redis', err);
43
- });
44
- }
45
- });
46
- }
47
63
 
48
64
  this.cache = new Keyv(cacheOptions);
49
65
  this.LOG._info && this.LOG.info(`Caching service initialized with namespace ${cacheOptions.namespace}`);
@@ -218,8 +234,9 @@ class CachingService extends cds.Service {
218
234
  req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
219
235
  req.cacheKey = this.createKey(req, req.cacheOptions.key);
220
236
  req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
221
- if (await this.has(req.cacheKey)) {
222
- return this.get(req.cacheKey);
237
+ const cachedValue = await this.get(req.cacheKey);
238
+ if (cachedValue) {
239
+ return cachedValue;
223
240
  }
224
241
  const response = await next();
225
242
  req.cacheOptions.tags = this.resolveTags(req.cacheOptions.tags, response, req.params);
@@ -230,7 +247,7 @@ class CachingService extends cds.Service {
230
247
  const srv = arguments[1];
231
248
 
232
249
  if (query.SELECT) {
233
-
250
+
234
251
  let options = {
235
252
  ttl: 0,
236
253
  tags: [],
@@ -277,7 +294,7 @@ class CachingService extends cds.Service {
277
294
  async set(key, value, options = {}) {
278
295
  const wrappedValue = {
279
296
  value,
280
- tags: options.tags || [],
297
+ tags: this.resolveTags(options.tags, value, options.params) || [],
281
298
  timestamp: Date.now()
282
299
  };
283
300
  await this.send('SET', {
@@ -330,7 +347,7 @@ class CachingService extends cds.Service {
330
347
  // Iterators
331
348
  async *iterator() {
332
349
  for await (const [key, value] of this.cache.iterator()) {
333
- if (typeof value === "string") {
350
+ if (typeof value === "string") {
334
351
  yield [key, JSON.parse(value)];
335
352
  } else {
336
353
  yield [key, value];
@@ -346,85 +363,77 @@ class CachingService extends cds.Service {
346
363
  * @returns {string[]} Array of resolved tags
347
364
  */
348
365
  resolveTags(tagConfigs = [], data, params = {}) {
349
- let resolvedTags = [];
350
-
351
- // Handle no data/params case
352
- if (!data && !params) {
353
- // Only process static tags when no sources are present
354
- return tagConfigs
355
- .filter(config => config.value)
356
- .map(config => config.value);
357
- }
366
+ // Handle empty/invalid configs
367
+ if (!tagConfigs?.length) return [];
358
368
 
359
- // Convert data to array if single object
360
- const dataArray = data ? (Array.isArray(data) ? data : [data]) : [];
369
+ // Convert data to array if single object or string
370
+ const dataArray = !data ? [] :
371
+ Array.isArray(data) ? data :
372
+ typeof data === 'string' ? [data] : [data];
361
373
 
362
374
  // Process each tag configuration
363
- tagConfigs.forEach(config => {
375
+ const resolvedTags = tagConfigs.flatMap(config => {
376
+ // Handle string tags
377
+ if (typeof config === 'string') {
378
+ return [config];
379
+ }
380
+
381
+ // Handle invalid/empty config objects
382
+ if (!config || typeof config !== 'object') {
383
+ return [];
384
+ }
385
+
386
+ // Handle static value tags
364
387
  if (config.value) {
365
- // Static tag
366
- resolvedTags.push(config.value);
367
- } else if (config.data) {
368
- // Dynamic tags from data
369
- dataArray.forEach(item => {
370
- if (typeof config.data === "string") {
371
- // Single data configuration
372
- const dataValue = item[config.data];
373
- if (dataValue) {
374
- const tag = [
375
- config.prefix,
376
- dataValue,
377
- config.suffix
378
- ].filter(Boolean).join('');
379
- resolvedTags.push(tag);
380
- }
381
- } else if (Array.isArray(config.data)) {
382
- // Multiple data configuration
383
- const dataValues = config.data
384
- .map(data => item[data])
385
- .filter(Boolean);
386
-
387
- if (dataValues.length > 0) {
388
- const combinedValue = dataValues.join(config.separator || ':');
389
- const tag = [
390
- config.prefix,
391
- combinedValue,
392
- config.suffix
393
- ].filter(Boolean).join('');
394
- resolvedTags.push(tag);
395
- }
396
- }
397
- });
398
- } else if (config.param) {
399
- // Dynamic tags from params
400
- if (typeof config.param === "string") {
401
- // Single param configuration
402
- const paramValue = params[config.param];
403
- if (paramValue) {
404
- const tag = [
405
- config.prefix,
406
- paramValue,
407
- config.suffix
408
- ].filter(Boolean).join('');
409
- resolvedTags.push(tag);
410
- }
411
- } else if (Array.isArray(config.param)) {
412
- // Multiple params configuration
413
- const paramValues = config.param
414
- .map(param => params[param])
388
+ const tag = [
389
+ config.prefix,
390
+ config.value,
391
+ config.suffix
392
+ ].filter(Boolean).join('');
393
+ return [tag];
394
+ }
395
+
396
+ // Handle data-based tags
397
+ if (config.data && dataArray.length) {
398
+ return dataArray.flatMap(item => {
399
+ if (typeof item !== 'object') return [];
400
+
401
+ const dataFields = Array.isArray(config.data) ? config.data : [config.data];
402
+ const values = dataFields
403
+ .map(field => item[field])
415
404
  .filter(Boolean);
416
405
 
417
- if (paramValues.length > 0) {
418
- const combinedValue = paramValues.join(config.separator || ':');
419
- const tag = [
420
- config.prefix,
421
- combinedValue,
422
- config.suffix
423
- ].filter(Boolean).join('');
424
- resolvedTags.push(tag);
425
- }
426
- }
406
+ if (!values.length) return [];
407
+
408
+ const value = values.join(config.separator || ':');
409
+ const tag = [
410
+ config.prefix,
411
+ value,
412
+ config.suffix
413
+ ].filter(Boolean).join('');
414
+ return [tag];
415
+ });
416
+ }
417
+
418
+ // Handle param-based tags
419
+ if (config.param) {
420
+ const paramFields = Array.isArray(config.param) ? config.param : [config.param];
421
+ const values = paramFields
422
+ .map(field => params[field])
423
+ .filter(Boolean);
424
+
425
+ if (!values.length) return [];
426
+
427
+ const value = values.join(config.separator || ':');
428
+ const tag = [
429
+ config.prefix,
430
+ value,
431
+ config.suffix
432
+ ].filter(Boolean).join('');
433
+ return [tag];
427
434
  }
435
+
436
+ return [];
428
437
  });
429
438
 
430
439
  // Remove duplicates
@@ -451,13 +460,13 @@ class CachingService extends cds.Service {
451
460
  case "Request":
452
461
  case "NoaRequest":
453
462
 
454
- return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
455
- req: keyOrObject,
456
- params: keyOrObject.params,
457
- data: keyOrObject.data,
458
- locale: keyOrObject.locale,
459
- user: keyOrObject.user.id,
460
- tenant: keyOrObject.tenant
463
+ return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
464
+ req: keyOrObject,
465
+ params: keyOrObject.params,
466
+ data: keyOrObject.data,
467
+ locale: keyOrObject.locale,
468
+ user: keyOrObject.user.id,
469
+ tenant: keyOrObject.tenant
461
470
  });
462
471
  case "cds.ql":
463
472
  if (keyOrObject.SELECT) {