Fluent DSL Guide
This page explains how backend Java services use the Geelato Framework Fluent DSL through MetaFactory.
Sample projects:
- Repository: geelato-hello-example
- ORM integration sample: geelato-sample-orm
Goalโ
- provide a Java-style metadata CRUD entry for backend developers
- avoid direct MQL JSON construction while still reusing the existing
MetaQLManager + SqlManager + Daoexecution kernel
Position in the ORM Systemโ
In the current ORM system, the Fluent DSL is the backend Java API. Its boundary relative to other parts is:
- ORM annotations declare entity metadata
- MQL serves frontend and platform-side JSON protocol access
- the Fluent DSL serves backend Java CRUD and lightweight advanced querying
- events, dynamic datasource, and query/filter fill SPI inject platform rules into the execution path
So the Fluent DSL is not a string wrapper around MQL. It is the Java-facing ORM entry for service code.
When to Use Itโ
- when backend services need metadata query, insert, update, or delete by entity name or entity class
- when the service wants to reuse built-in capabilities such as dynamic datasource switching, view parameters, and
$ctx/$fn/$parentvalue references
When Not to Use Itโ
- when frontend pages already go through the platform data API based on
MetaController + MQL - when an existing service already works well with
BaseService + entity classand does not need metadata-driven unification - when the query has clearly become SQL-first and is better maintained as final SQL or MyBatis mapping
Entry Pointsโ
By entity name:
List<Map<String, Object>> users = MetaFactory.query("User")
.select(new String[]{"id", "name", "mobilePhone"})
.where(Filter.eq("delStatus", 0))
.order(Order.desc("updateAt"))
.list();
By entity class:
List<Map<String, Object>> users = MetaFactory.query(User.class)
.select(new String[]{"id", "name"})
.page(1, 20)
.list();
Query Examplesโ
Single row:
Map<String, Object> user = MetaFactory.query("User")
.where(Filter.eq("id", "1912345678901234567"))
.one();
Paged query:
PageResult<Map<String, Object>> page = MetaFactory.query("User")
.select(new String[]{"id", "name", "updateAt"})
.where(Filter.like("name", "zhang"))
.order(Order.desc("updateAt"))
.page(1, 10)
.page();
Single-column query:
List<String> ids = MetaFactory.query("User")
.select(new String[]{"id"})
.where(Filter.eq("delStatus", 0))
.oneColumn(String.class);
Result types โ auto unwrap and typed overloads:
When wrapperResult(...) is not set and each result row has exactly one column, list()/one()/page() return the bare column value instead of wrapping it in a Map:
List<String> names = MetaFactory.query("User")
.select(new String[]{"name"})
.where(Filter.eq("delStatus", 0))
.list();
Multi-column results are unchanged (still Map<String, Object>), and bare values are not type-converted. Java type erasure prevents a no-arg list() from knowing the declared element type, so the inference is based on the result-set shape; use the typed overloads list(Class)/one(Class) when you need conversion or object mapping:
| Target type | Behavior |
|---|---|
| Simple types (String, numbers, Boolean, Date, java.time, ...) | requires a single column and converts the value (for example Long to Integer/String) |
Map.class | returns row maps as-is |
| Custom classes (DTO/entity) | maps each row to an object instance, including snake_case columns to camelCase fields (email_address to emailAddress) |
List<Integer> ages = MetaFactory.sql("select age from platform_user where del_status = ?")
.param(0)
.list(Integer.class);
List<UserOrgDto> orgs = MetaFactory.sql("select o.id, o.name, oru.default_org as defaultOrg from platform_org_r_user oru ...")
.param("U1001")
.list(UserOrgDto.class);
These rules apply to list()/one() of MetaFactory.sql(...), MetaFactory.query(...), and MetaFactory.procedure(...), as well as to page() records; oneColumn(Class) remains available for executor-level single-column queries. When wrapperResult(...) is set it takes precedence; to keep row maps for a single-column query, use .wrapperResult(row -> row).
Complex wrapping still uses wrapperResult(...):
List<UserSimpleDto> users = MetaFactory.query("User")
.where(Filter.eq("delStatus", 0))
.wrapperResult(row -> new UserSimpleDto(
String.valueOf(row.get("id")),
maskMobilePhone(String.valueOf(row.get("mobilePhone")))
))
.list();
Write Examplesโ
Insert:
String userId = MetaFactory.insert("User")
.value("name", "test user")
.value("mobilePhone", "13800000000")
.save();
Default fields are filled before save:
- inserts align with the current MQL rules and fill fields such as
createAt,creator,creatorName,tenantCode,buId,deptId,updateAt,updater,updaterName, anddeleteAt - updates fill
updateAt,updater, andupdaterName - the behavior comes from the built-in default filler and can be customized by overriding
SaveDefaultValueFiller
Update:
String userId = MetaFactory.update("User")
.value("id", "1912345678901234567")
.value("name", "new name")
.save();
Delete:
int affected = MetaFactory.delete("User")
.where(Filter.eq("id", "1912345678901234567"))
.delete();
Object-Based Convenience Overloadsโ
Besides building field by field with .value(field, value), MetaFactory also offers overloads that accept an entity instance directly: they pre-fill SQL from the entity's non-null properties, so you do not have to assemble field values by hand. To switch the data source, chain .useDataSource(...) on the returned builder as usual.
Convention: only non-
nullmapped properties of the entity instance are collected;nullfields are skipped (inserts fall back to defaults, updates never overwrite existing columns). The entity must be a JavaBean with getters/setters (e.g. extendIdEntityand add accessors).
Save directly (one-liner, auto insert-or-update):
User user = new User();
user.setName("demo user");
user.setMobilePhone("13800000000");
// blank id -> insert, PK generated by the framework
String id = MetaFactory.save(user);
user.setId(id);
user.setName("new name");
// non-blank id -> update by PK
MetaFactory.save(user);
Build from an entity instance (returns a builder, chainable):
// insert builder
MetaFactory.insert(user).useDataSource("portal").save();
// update builder (when id is present, where(PK) is appended automatically)
MetaFactory.update(user).useDataSource("portal").save();
// query by example: non-null fields become conditions
List<Map<String, Object>> rows = MetaFactory.query(user)
.order(Order.desc("createAt"))
.list();
Notes:
save(entity)is the only one-liner that executes immediately and returns the PK; the other overloads return builders, consistent withinsert(Class)/update(Class)/query(Class)- PK detection: blank id (
nullor empty string) -> insert; non-blank id -> update by PK - The object overloads are fully equivalent to the field-name versions under the hood, reusing the same default-field fill, UID generation, and dynamic data source support
Advanced Capabilitiesโ
Referenced field join:
List<Map<String, Object>> orders = MetaFactory.query("Order")
.select(new String[]{"id", "code"})
.selectRef("userId->name", "userName")
.list();
Custom join:
List<Map<String, Object>> rows = MetaFactory.query("Order")
.as("o")
.select(new String[]{"id", "code"})
.selectExpr("u.name", "userName")
.leftJoin("User", "u", on -> on.eqField("userId", "u.id"))
.groupBy("id", "code", "u.name")
.havingSql("count(*) > 0")
.page(1, 20)
.page();
Dynamic datasource:
List<Map<String, Object>> rows = MetaFactory.query("DevDbConnect")
.useDataSource("portal")
.page(1, 10)
.list();
Skip injected filters:
// Skip injected filters for this query (tenant isolation, data permission, etc. are not appended).
// Only for trusted scenarios such as system back office or scheduled tasks;
// it risks cross-tenant / unauthorized reads.
List<Map<String, Object>> rows = MetaFactory.query("platform_user")
.disableInjectFilter()
.list();
disableInjectFilter() skips all injectors for this query. If an injector (such as tenant isolation) must never be bypassed by a single query, override FluentQueryFilterInjector.isForceInject() to return true in its implementation; then disableInjectFilter() no longer affects that injector. See Query Filter and Save Field Fill SPI.
View parameters:
List<Map<String, Object>> rows = MetaFactory.query("SomeViewEntity")
.viewParams(Map.of("customerId", "C001"))
.page(1, 10)
.list();
Context and function value references:
String id = MetaFactory.insert("Notice")
.value("creator", ValueRefs.ctx("userId"))
.value("createAt", ValueRefs.fnNowDateTime())
.save();
MySQL procedure:
List<Map<String, Object>> rows = MetaFactory.procedure("proc_query_user_orders")
.in("userId", "U1001")
.in("status", 1)
.useDataSource("portal")
.list();
Native SQL passthrough:
List<Map<String, Object>> rows = MetaFactory.sql("select id, name from platform_user where del_status = ?")
.param(0)
.useDataSource("portal")
.list();
Parent-child nested save:
String parentId = MetaFactory.insert("App")
.value("name", "demo-app")
.child("AppVersion", child -> child
.value("appId", ValueRefs.parent("id"))
.value("code", "v1"))
.save();
Join Notesโ
selectRef(...)fits the case where the main entity already has foreign-key metadata- the framework can derive the
left joinautomatically, so the service code does not need to write the join condition - if you omit an explicit alias in
selectRef(...), the result column uses the remote field name by default
List<Map<String, Object>> orders = MetaFactory.query("Order")
.select(new String[]{"id", "code", "amount"})
.selectRef("userId->name", "userName")
.selectRef("userId->mobilePhone", "userMobile")
.where(Filter.eq("delStatus", 0))
.order(Order.desc("updateAt"))
.list();
- custom join fits cases without foreign-key metadata or cases that need explicit join control
- use a stable alias convention, such as
.as("o")for the main table and short aliases such asuordfor joined tables - keep aliases consistent across
selectExpr(...),groupBy(...), andhavingSql(...)
List<Map<String, Object>> rows = MetaFactory.query("Order")
.as("o")
.select(new String[]{"id", "code"})
.selectExpr("u.name", "userName")
.selectExpr("d.name", "deptName")
.leftJoin("User", "u", on -> on.eqField("o.userId", "u.id"))
.leftJoin("Dept", "d", on -> on.eqField("u.deptId", "d.id"))
.groupBy("id", "code", "u.name", "d.name")
.havingSql("count(*) > 0")
.page(1, 20)
.page();
Procedure Notesโ
- current procedure support is intentionally lightweight and mainly targets MySQL-style
INparameters plus one result set - parameters are added in call order through
.in(name, value) - the
nameis mainly for readability; runtime binding is positional
Map<String, Object> row = MetaFactory.procedure("proc_query_user_orders")
.in("userId", "U1001")
.in("status", 1)
.one();
Object mapping works through the typed overloads, and complex wrapping still uses wrapperResult(...):
List<OrderSimpleDto> rows = MetaFactory.procedure("proc_query_user_orders")
.in("userId", "U1001")
.in("status", 1)
.list(OrderSimpleDto.class);
List<OrderSimpleDto> masked = MetaFactory.procedure("proc_query_user_orders")
.in("userId", "U1001")
.in("status", 1)
.wrapperResult(row -> {
OrderSimpleDto dto = new OrderSimpleDto();
dto.setId(String.valueOf(row.get("id")));
dto.setCode(String.valueOf(row.get("code")));
return dto;
})
.list();
Native SQL Notesโ
- use
MetaFactory.sql(...)when the service already owns the final SQL and only wants to reuse the execution chain - this path provides datasource switching, parameter binding, and terminal execution methods
- it does not add metadata-aware joins, default audit fields, or expression conversion
- single-column results are auto-unwrapped to bare values, and
list(Class)/one(Class)support typed conversion and DTO mapping (see Query Examples)
Map<String, Object> row = MetaFactory.sql("select id, name from platform_user where id = ?")
.param("U1001")
.one();
List<String> emails = MetaFactory.sql("select email_address from platform_user_email_account where user_id = ?")
.param("U1001")
.list();
Long total = MetaFactory.sql("select count(1) from platform_user where del_status = ?")
.param(0)
.queryForObject(Long.class);
int affected = MetaFactory.sql("update platform_notice set status = ? where id = ?")
.params("read", "N1001")
.execute();
Debugging Tipsโ
- use
toSql()to inspect the generated SQL or procedure call - use
toCountSql()when checking pagination count issues - for join issues, verify aliases and foreign-key metadata first
- for procedure issues, verify parameter order, datasource choice, and result-set shape first
- for native SQL, verify the SQL itself, placeholder count, and terminal method choice first
Recommended Boundaryโ
- use Fluent DSL first for standard single-table CRUD, light references, and lightweight aggregation
- use
selectRef(...)first when foreign-key metadata already exists - use explicit join only when the metadata path is not enough
- use
MetaFactory.sql(...)when the team intentionally owns the SQL text - keep MQL, SQL Key, or MyBatis for very complex filtering, recursive SQL, window functions, or multi-result-set procedures