Native SQL reports#

Annotation-driven queries compile great SELECTs for CRUD, but reporting is usually where hand-written SQL earns its keep — aggregates, window functions, complex joins. Publish those as first-class endpoints with NativeQueryHandler.

A reporting endpoint#

@Component
@CrudMapping("/v1/reports/user-sales")
public class UserSalesReportHandler implements NativeQueryHandler {

    @Autowired private AuthProvider auth;

    @Override
    public int aclCheck(FilterCriteria criteria, HandlerContext ctx) {
        return userProvider.hasRole("REPORT_VIEW") ? 0 : -1;
    }

    @Override
    public void preProcess(FilterCriteria criteria, HandlerContext ctx) {
        if (criteria.getSimpleCriteria().get("since") == null) {
            criteria.addCriteria("since", Instant.now().minus(30, ChronoUnit.DAYS).toString());
        }
    }

    @Override
    public NativeQuery getQuery(FilterCriteria criteria, HandlerContext ctx) {
        String sql = """
            SELECT u.id, u.name, SUM(o.amount) AS total
            FROM users u JOIN orders o ON o.user_id = u.id
            WHERE o.created_at >= ?
            GROUP BY u.id, u.name
        """;
        NativeQuery q = new NativeQuery(sql, criteria.getSimpleCriteria().get("since"));
        q.setCountQuery("""
            SELECT COUNT(DISTINCT u.id)
            FROM users u JOIN orders o ON o.user_id = u.id
            WHERE o.created_at >= ?
        """);
        q.setFetchSize(500);
        return q;
    }

    @Override
    public Tuple onQueryResult(Tuple tuple, Action action) {
        BigDecimal total = (BigDecimal) tuple.get("total");
        if (total != null) tuple.set("total", total.setScale(2, RoundingMode.HALF_UP));
        return tuple;
    }
}

Guidelines#

  • Use positional ? placeholders. Bind in order with the vararg constructor or addParams; Palmyra does not substitute named parameters.
  • Set countQuery when pagination matters. A naive COUNT(*) over a GROUP BY / DISTINCT query is usually wrong — supply the right count query explicitly.
  • Tune fetchSize for wide reports. The default is 100; bump it for reports that return hundreds of thousands of rows to reduce round trips.
  • Keep onQueryResult cheap. It runs per row inside the reactive stream — no database calls from here.

See also#

  • NativeQueryHandler — full method table.
  • NativeQuery — every constructor / setter, including limitFirst() and setOffset / setLimit for pagination.