DynamicDataService

DynamicDataService is the host-managed service for reading and writing dynamic model tables (mt_<modelCode>). It enforces tenant scope, parameterizes every query (no string concatenation, no injection surface), applies soft-delete semantics, and emits ab_data_change_log rows. Plugins should call this service rather than raw JDBC or MyBatis mappers.

For list/grid queries reaching the front-end, the matching HTTP endpoint is GET /api/dynamic/{pageKey}/list — use the same parameter contract on both sides to avoid drift (red line §5).

Concept

Every model with autoPublishModels:true has a mt_<modelCode> table created by SchemaManagementServiceImpl. Each row carries a database IDENTITY auto-increment id (BIGINT, primary key) and a ULID pid (VARCHAR(32), NOT NULL UNIQUE) — id is generated by the database on insert, pid is populated by the platform's UniqueIdGenerator on insert. Multi-tenant data is scoped by tenant_id; the service injects the filter from MetaContext.

For background workers without an HTTP MetaContext, see BackgroundDataAccessor SPI.

Interface signature

package com.auraboot.framework.meta.service;

import com.auraboot.framework.meta.dto.*;
import java.util.List;
import java.util.Map;

public interface DynamicDataService {

    /** Paginated list — used by /api/dynamic/{pageKey}/list. */
    PaginationResult<Map<String, Object>> list(String modelCode, DynamicQueryRequest request);

    /** Read a single row by recordId (pid); returns null when not found. */
    Map<String, Object> getById(String modelCode, String recordId);

    /** Create; returns the full created record (including system fields). */
    Map<String, Object> create(String modelCode, Map<String, Object> data);

    /** Update by recordId; returns the full updated record. */
    Map<String, Object> update(String modelCode, String recordId, Map<String, Object> data);

    /** Delete by recordId. */
    void delete(String modelCode, String recordId);

    /** Group / aggregate — used by DataProviderExtension and dashboards. */
    Map<String, Object> aggregate(String modelCode, AggregateRequest aggregateRequest);
}

Only the most commonly used methods are shown above. The full interface also includes batchCreate / batchUpdate / batchDelete, executeCustomQuery, getStats, relation operations (getRelationData / createRelations / removeRelations), validate, getFieldOptions, import/export, and saveWithRelations — see DynamicDataService.java in the repo for the source of truth.

DynamicQueryRequest is the pagination DTO (@Data @Builder):

public class DynamicQueryRequest {
    private Integer pageNum;                 // page number
    private Integer pageSize;                // page size
    private List<QueryCondition> conditions; // query conditions
    private List<SortField> sortFields;      // sort fields (multi-field)
    private String keyword;                  // search keyword
    private Map<String, Object> extraParams; // extra params
    private String viewId;                   // SavedView pid (optional)
    private Long cursor;                     // keyset pagination cursor (optional)
}

QueryCondition uses the Operator enum (no .eq()-style static factories); valid operators are: EQ / NE / GT / GE / LT / LE / LIKE / NOT_LIKE / IN / NOT_IN / IS_NULL / IS_NOT_NULL / BETWEEN / NOT_BETWEEN.

Implementation example

A scheduled rollup that aggregates monthly closed-won revenue per sales rep and writes into a summary model:

package com.acme.crm.rollup;

import com.auraboot.framework.meta.service.DynamicDataService;
import com.auraboot.framework.meta.dto.DynamicQueryRequest;
import com.auraboot.framework.meta.dto.QueryCondition;
import com.auraboot.framework.meta.dto.QueryCondition.Operator;
import com.auraboot.framework.meta.dto.PaginationResult;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;

@Component
public class MonthlyRevenueRollup {

    @Autowired private DynamicDataService data;

    @Scheduled(cron = "0 30 0 1 * *") // 1st of month, 00:30
    public void run() {
        LocalDate firstOfPrev = LocalDate.now().minusMonths(1).withDayOfMonth(1);
        LocalDate firstOfThis = firstOfPrev.plusMonths(1);

        DynamicQueryRequest q = DynamicQueryRequest.builder()
            .pageSize(500)
            .conditions(List.of(
                QueryCondition.builder().fieldName("status").operator(Operator.EQ).value("closed_won").build(),
                QueryCondition.builder().fieldName("close_date").operator(Operator.GE).value(firstOfPrev).build(),
                QueryCondition.builder().fieldName("close_date").operator(Operator.LT).value(firstOfThis).build()
            ))
            .build();

        PaginationResult<Map<String, Object>> res = data.list("crm_deal", q);
        Map<Object, Double> byRep = res.getRecords().stream().collect(
            groupingBy(r -> r.get("owner_id"),
                       summingDouble(r -> ((Number) r.get("amount")).doubleValue())));

        byRep.forEach((rep, total) -> data.create("crm_revenue_rollup", Map.of(
            "month", firstOfPrev,
            "owner_id", rep,
            "total", total
        )));
    }
}

Registration

Host-managed singleton bean — @Autowired it. There is no SPI; this is a service interface only. The implementation lives in auraboot/platform (com.auraboot.framework.meta.service.impl.DynamicDataServiceImpl).

Common pitfalls

  • Guessing parameter names. The front-end REST pagination (GET /api/dynamic/{pageKey}/list) uses query params pageNum / pageSize / filters / keyword / sortField / sortOrder (filters is a JSON string of QueryCondition[]); at the service layer these become DynamicQueryRequest's conditions / sortFields. Verify against DynamicController (red line §5).
  • Raw JDBC for "performance". Bypasses tenant filter, soft-delete, and change-log emission. Never do this — see red line §15 (e.g. the Long.parseLong(ULID) incident).
  • Confusing id and pid. id is a database IDENTITY auto-increment BIGINT; pid is a ULID String (VARCHAR(32)). The recordId argument to the service's getById / update / delete is the pid. Long.parseLong(pid) crashes. Use the right type.
  • Writing inside a background worker without tenant context. mt_<modelCode>'s tenant_id is NOT NULL, but Kafka consumers / scheduled tasks run outside the request context with no implicit tenant on the thread — a missing tenant fails the write. Use BackgroundDataAccessor, which takes an explicit tenantId per call (e.g. create(long tenantId, String modelCode, Map<String,Object> data)); for idempotent inserts use tryCreate, which returns Optional.empty() on a unique-constraint conflict.

Related