Skip to content

Local Analytics over Replicated Data ​

This is the payoff page for replication. Once remote data lives in the local DB, the full power of SQL is on the table: aggregations, joins, grouping, distinct, window functions — everything that OData simply can't express against a delegated entity.

When to use this pattern ​

  • You need analytical queries (counts, sums, averages, top-N by group) over remote data.
  • You need to join remote reference data against fully local tables for reports and dashboards.
  • The remote service is a transactional system that can't handle analytical workload, or has quotas, or has no OData $apply support.

The setup ​

Replicate the remote entity once with a schedule:

cds
using { ProviderService as remote } from '../srv/external/ProviderService';

@federation.replicate: { delta: { field: 'modifiedAt' } }
entity ReplicatedProducts as projection on remote.Products {
    ID    as productId,
    name  as productName,
    category,
    price as unitPrice,
    currency
};

// Plain local entity — stored in the same DB
entity Reviews {
    key ID        : UUID;
        product   : Association to ReplicatedProducts;
        rating    : Integer;
        comment   : String(500);
        author    : String(100);
        createdAt : Timestamp;
}

Both tables live side-by-side in SQLite or HANA. product is a managed association; CAP materializes product_productId on the Reviews table.

Query capabilities unlocked ​

Aggregations / GROUP BY ​

javascript
const avgByCategory = await SELECT
    .from('ReplicatedProducts')
    .columns('category', { sum: 'unitPrice', as: 'total' }, { count: 1, as: 'n' })
    .groupBy('category');

Against a delegate this fails — CAP's cqn2odata rejects .groupBy for remote services. Against a replicated table it's a plain SQL GROUP BY.

Joins to fully local tables ​

javascript
const ratings = await SELECT
    .from('ReplicatedProducts as p')
    .columns('p.category', 'p.productName', { avg: 'r.rating', as: 'avgRating' })
    .join('Reviews as r').on('r.product_productId =', { ref: ['p.productId'] })
    .groupBy('p.category', 'p.productName')
    .having({ 'avg(r.rating)': { '>=': 4 } });

This is an ordinary SQL JOIN — both tables are in the same DB. Doing the same against a delegate would require fetching all products over the wire, all reviews locally, and joining in application memory.

$apply over OData ​

Fiori Elements analytics cards and the $apply query option work directly:

http
GET /consumer/ReplicatedProducts?$apply=groupby((category),aggregate(unitPrice with sum as total))

CAP translates $apply to GROUP BY SQL on the local table.

DISTINCT ​

javascript
const categories = await SELECT.distinct.from('ReplicatedProducts').columns('category');

SELECT.distinct is explicitly rejected for delegated entities — OData has no DISTINCT keyword. On replicated data it's trivial.

LIKE and other SQL-native operators ​

javascript
const widgets = await SELECT.from('ReplicatedProducts').where({
    productName: { like: '%Widget%' }
});

OData has no like keyword so this fails against a delegate; contains(...) has to be used instead. On replicated data the full SQL operator set is available.

Joins spanning remote and local in one query ​

The most impactful win is cross-domain analytics. Average rating per replicated product category, filtered to only products with at least three reviews, sorted by rating:

sql
SELECT
    p.category,
    COUNT(DISTINCT p.productId) AS products,
    AVG(r.rating)               AS avgRating
FROM   ReplicatedProducts p
  JOIN Reviews r ON r.product_productId = p.productId
GROUP BY p.category
HAVING  COUNT(r.ID) >= 3
ORDER BY avgRating DESC;

Expressed as CAP-native CQL it stays under ten lines; no plugin intervention needed — it's just SQL against the local DB.

Under the hood ​

The replicated entity is just a CAP entity with @cds.persistence.table and @cds.persistence.skip: false (the annotation scanner sets these automatically for @federation.replicate). At runtime it looks identical to any other local table to the CAP query engine. The only difference is that data lands there via the replication pipeline (READ → MAP → WRITE) instead of application writes.

For the pipeline internals, see Service Query Execution.

Gotchas ​

  • Freshness depends on schedule. Analytics are only as up-to-date as the last replication run. Add a schedule: '*/5 * * * *' option and/or trigger manual runs via the management service. See First Replication for delta sync setup.
  • Replicated entities read like any local table. Once the @federation.replicate entity is exposed in a service, GET /consumer/ReplicatedCustomers and CQL (cds.run(SELECT.from(...))) both work — after replication it is an ordinary local table with @cds.persistence.skip: false, indistinguishable from a plain local entity at query time.
  • Storage costs — you're copying remote data. Consider column restriction in the projection ({ ID as productId, name as productName, ... }) to only replicate what you actually need.
  • Schema evolution — when the remote adds a field, the local schema does not auto-update. Redeploy the replicated entity to include the new column, then trigger a full sync (mode: 'full') to backfill.
  • Consistency windows — if you replicate on a 5-minute schedule and take an analytical snapshot at 12:00:03, half your data may be from the 11:55 run and half from the 12:00 run if they overlap. The plugin's concurrency guard prevents simultaneous runs of the same replication, but not cross-replication drift.

See also ​

Released under the MIT License.