findByJson / findWithJsonExpr Dynamic Query Guide
Overview
TeaQL supports dynamic query construction from a JSON expression.
In generated request code this is commonly exposed as:
Q.orders().findWithJsonExpr(params)
In the core runtime, the JSON string is parsed and merged into the request through:
BaseRequest.internalFindWithJsonExpr(String jsonNodeExpr)
The dynamic JSON expression can add:
- Field filters
- Chain-field filters such as
customer.name - Ordering
- Offset/limit
- Page/page-size
This is useful when a frontend or generic admin API needs flexible search without writing one Java method for every possible filter combination.
Runtime Flow
The runtime flow is:
JSON string
-> DynamicSearchHelper.jsonFromString(...)
-> DynamicSearchHelper.mergeClauses(...)
-> addJsonFilter(...)
-> addJsonOrderBy(...)
-> addJsonLimiter(...)
-> addJsonPager(...)
-> request.executeForList(userContext)
In BaseService, dynamic search actions also use this mechanism:
if (ObjectUtil.isNotEmpty(parameter)) {
baseRequest.internalFindWithJsonExpr(parameter);
}
That means generated service endpoints can accept JSON search parameters and merge them into the generated request.
Basic Usage
Java
public WebResponse searchOrder(UserContext ctx, String params) {
SmartList<Order> orders = Q.orders()
.selectAll()
.findWithJsonExpr(params)
.filterByMerchant(((CustomUserContext) ctx).getMerchant())
.orderByIdDescending()
.executeForList(ctx);
return WebResponse.of(orders);
}
JSON
{
"status": "SHIPPED",
"code": "ORD-2026",
"_orderBy": {
"field": "id",
"useAsc": false
},
"_page": 1,
"_pageSize": 20
}
Resulting Intent
status = SHIPPED
code contains ORD-2026
order by id desc
page 1, page size 20
JSON Filter Rules
findWithJsonExpr only applies a JSON field as a filter when the field is known to the current request.
Example:
{
"code": "ORD",
"status": "NEW"
}
Java request:
Q.orders()
.findWithJsonExpr(params)
.executeForList(userContext);
The dynamic helper checks whether code and status are fields of Order. Unknown fields are ignored for self-field filtering.
Value Type to Operator Rules
TeaQL infers the operator from the JSON value type.
| JSON Value | Inferred Operator | Example |
|---|---|---|
| String | CONTAIN by default | "code": "ORD" |
| Number | EQUAL | "id": 1001 |
| Boolean | EQUAL | "finished": false |
| Array of strings | IN | "status": ["NEW", "SHIPPED"] |
Array of objects with id | IN | "customer": [{"id": 1}, {"id": 2}] |
| Array with 2 values | BETWEEN for range-style values | "createTime": [1714521600000, 1717200000000] |
"__is_null__" | IS_NULL | "shipTime": "__is_null__" |
"__is_not_null__" | IS_NOT_NULL | "shipTime": "__is_not_null__" |
Important detail:
- Normal string values are treated as
CONTAIN. - A single-value string array such as
"name": ["product name"]is treated asINwith one value. In practice, this behaves like exact equality for that value. __is_null__and__is_not_null__are special string values.- Date/time fields convert array values into
Dateobjects when the target field is a date/time field.
String Contains
JSON
{
"code": "ORD-2026"
}
Meaning
Q.orders().whichCode(Operator.CONTAIN, "ORD-2026")
Use this for fuzzy search boxes.
Exact Numeric and Boolean Filters
JSON
{
"id": 1001,
"finished": false
}
Meaning
Q.orders()
.filterById(1001L)
.filterByFinished(false)
Numbers and booleans are treated as exact matches.
IN Filters
JSON
{
"status": ["NEW", "SHIPPED", "DELIVERED"]
}
Meaning
Q.orders()
.whichStatus(Operator.IN, List.of("NEW", "SHIPPED", "DELIVERED"))
This is useful for multi-select filters.
For exact matching with one string value, pass a single-value array:
{
"name": ["product name"]
}
This is parsed as IN with one value:
Q.products().whichName(Operator.IN, "product name")
For normal data, this is equivalent to:
name = product name
Object Reference Filters
If the field is an object reference, JSON objects can provide id.
JSON
{
"customer": {
"id": 10001
}
}
Meaning
The JSON value is unwrapped to the id:
Q.orders().filterByCustomer(Customer.refer(10001L))
For multiple references:
{
"customer": [
{"id": 10001},
{"id": 10002}
]
}
This is treated as an IN filter.
Null Checks
Is Null
{
"shipTime": "__is_null__"
}
Is Not Null
{
"shipTime": "__is_not_null__"
}
These values map to TeaQL Operator.IS_NULL and Operator.IS_NOT_NULL.
Range and Date/Time Filters
For date/time fields, a two-value array can be used as a range.
JSON
{
"createTime": [1714521600000, 1717200000000]
}
Meaning
Q.orders().whichCreateTime(Operator.BETWEEN, startDate, endDate)
The helper unwraps date/time values into Date objects when the target field is a date/time field.
Use millisecond timestamps when passing date/time values through JSON.
Ordering
Use _orderBy.
Single Text Field
{
"_orderBy": "id"
}
This means:
order by id desc
The single text form defaults to descending because the helper calls:
addOrderBy(baseRequest, fieldValue.asText(), false)
Object Form
{
"_orderBy": {
"field": "createTime",
"useAsc": false
}
}
Multiple Order Rules
{
"_orderBy": [
{
"field": "status",
"useAsc": true
},
{
"field": "createTime",
"useAsc": false
}
]
}
Ordering only applies when the field is one of the current request's self fields.
Offset and Size
Use _start and _size for low-level offset/limit control.
JSON
{
"_start": 40,
"_size": 20
}
Meaning
offset 40
limit 20
This is useful for admin grids or infinite scrolling.
Page and Page Size
Use _page and _pageSize for page-based pagination.
JSON
{
"_page": 3,
"_pageSize": 20
}
Meaning
page 3, page size 20
offset = (3 - 1) * 20
The runtime calculates offset using the page number and request size.
Chain Field Filters
Dynamic JSON supports chain fields such as:
{
"customer.name": "Alice"
}
This means the filter is applied to the child request for customer.
Important Rule
The parent request must already contain the child sub-request.
Correct:
SmartList<Order> orders = Q.orders()
.selectCustomer()
.findWithJsonExpr("""
{
"customer.name": "Alice"
}
""")
.executeForList(userContext);
Better when customizing selected customer fields:
SmartList<Order> orders = Q.orders()
.selectCustomer(Q.customers().selectName())
.findWithJsonExpr(params)
.executeForList(userContext);
If the request has no customer sub-request, a chain-field filter cannot be attached safely.
Chain Field on Nested Collections
Example model:
Order
-> orderLineList
-> product
JSON:
{
"orderLineList.product.name": "Nitrogen"
}
Java:
SmartList<Order> orders = Q.orders()
.selectOrderLineList(
Q.orderLines()
.selectProduct(Q.products().selectName()))
.findWithJsonExpr(params)
.executeForList(userContext);
The chain path must match the selected request path:
orderLineList -> product -> name
Combine Fixed Q Conditions with Dynamic JSON
The recommended pattern is:
Q.orders()
.selectAll()
.findWithJsonExpr(params)
.filterByMerchant(userContext.getMerchant())
.whichVersionGreaterThan(0)
.orderByIdDescending()
.executeForList(userContext);
Use fixed Q conditions for rules that the user must not control:
- Tenant filter
- Merchant filter
- Permission scope
- Version > 0
- Soft-delete rules
- Security boundaries
Use JSON for user-driven filters:
- Keyword search
- Status selection
- Date range
- Sorting
- Pagination
Example: Search Employee
This pattern is used in real TeaQL services:
public WebResponse searchEmployee(UserContext ctx, String params) {
Merchant merchant = ((CustomUserContext) ctx).getMerchant();
SmartList<Employee> employees = Q.employees()
.selectAll()
.selectEmployeePermissionList()
.selectEmployeeCertificationList()
.findWithJsonExpr(params)
.filterByMerchant(merchant)
.executeForList(ctx);
return WebResponse.of(employees);
}
What is fixed:
- Merchant scope
- Selected child lists
What is dynamic:
- Search filters from
params - Sorting
- Paging
Example: Search Polymorphic Reports
public WebResponse searchSubTaskReport(UserContext ctx, String params) {
Merchant merchant = ((CustomUserContext) ctx).getMerchant();
SmartList<SubTaskReport> reports = Q.subTaskReports()
.selectAll()
.tryEnhanceChildren()
.enhanceChild(
Q.checkReports()
.selectCheckStandard()
.returnType(CheckReport.class)
.selectCheckReportItemList())
.enhanceChild(Q.fillReports().selectProduct())
.enhanceChild(
Q.analysisReports()
.selectProduct()
.selectStandard()
.selectAnalysisReportItemList(
Q.analysisReportItems()
.selectType()
.selectMeasureUnit()
.selectComponent()))
.findWithJsonExpr(params)
.filterByMerchant(merchant)
.orderByIdDescending()
.count()
.executeForList(ctx);
return WebResponse.of(reports);
}
This combines:
- Polymorphic enhancement
- Nested child selection
- Dynamic JSON search
- Tenant/merchant protection
- Default ordering
- Count support
Example JSON for an Admin Grid
{
"name": "Alice",
"status": ["ACTIVE", "LOCKED"],
"merchant": {
"id": 10001
},
"_orderBy": [
{
"field": "status",
"useAsc": true
},
{
"field": "id",
"useAsc": false
}
],
"_page": 1,
"_pageSize": 20
}
Recommended Java:
Q.employees()
.selectAll()
.findWithJsonExpr(params)
.filterByMerchant(userContext.getMerchant())
.executeForList(userContext);
Even if the JSON contains a merchant field, the server still applies the trusted merchant scope from UserContext.
Building JSON Dynamically on the Frontend
A frontend can build JSON from a search form:
const params = {
name: form.keyword || undefined,
status: form.statusList,
createTime: form.start && form.end ? [form.start.getTime(), form.end.getTime()] : undefined,
_orderBy: {
field: form.sortField || "id",
useAsc: form.sortDirection === "asc",
},
_page: page,
_pageSize: pageSize,
};
const cleaned = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== "")
);
Send JSON.stringify(cleaned) as the params argument.
Backend Builder Pattern
When building dynamic JSON on the backend, keep it typed at the boundary:
public class OrderSearchParams {
private String keyword;
private List<String> status;
private Long startTime;
private Long endTime;
private Integer page;
private Integer pageSize;
// getters and setters
}
Convert it to JSON only at the findWithJsonExpr boundary:
Map<String, Object> json = new LinkedHashMap<>();
json.put("code", request.getKeyword());
json.put("status", request.getStatus());
json.put("createTime", List.of(request.getStartTime(), request.getEndTime()));
json.put("_page", request.getPage());
json.put("_pageSize", request.getPageSize());
String params = JSONUtil.toJsonStr(json);
return Q.orders()
.selectAll()
.findWithJsonExpr(params)
.filterByMerchant(userContext.getMerchant())
.executeForList(userContext);
Security Rules
Dynamic JSON should not become a permission bypass.
Always keep these filters in Java:
.filterByMerchant(userContext.getMerchant())
.whichVersionGreaterThan(0)
Do not let JSON decide:
- Tenant
- Merchant
- Current user scope
- Permission boundaries
- Soft-delete behavior
- System-only fields
Use JSON for search convenience, not for trust decisions.
Validation Rules
Before calling findWithJsonExpr, validate:
- JSON string is not too large
- Page size is within allowed limits
- Sort field is allowed for the current screen
- Chain fields are allowed for the current API
- Date ranges are reasonable
- User is allowed to search the target entity
Example:
public String normalizeOrderSearchParams(String params) {
JsonNode json = new ObjectMapper().readTree(params);
int pageSize = json.path("_pageSize").asInt(20);
if (pageSize > 200) {
throw new IllegalArgumentException("Page size is too large");
}
return params;
}
Parse with your project JSON library before passing the string to TeaQL.
Common Mistakes
1. Expecting Strings to Mean Exact Match
JSON:
{
"code": "ORD"
}
This means CONTAIN, not exact equality.
For exact business filters, prefer generated Q methods:
Q.orders().filterByCode("ORD-1001")
2. Using Chain Fields Without Selecting the Child Request
Problem:
Q.orders()
.findWithJsonExpr("{\"customer.name\":\"Alice\"}")
.executeForList(ctx);
Safer:
Q.orders()
.selectCustomer(Q.customers().selectName())
.findWithJsonExpr("{\"customer.name\":\"Alice\"}")
.executeForList(ctx);
3. Letting JSON Override Tenant Scope
Problem:
Q.orders()
.findWithJsonExpr(params)
.executeForList(ctx);
Safer:
Q.orders()
.findWithJsonExpr(params)
.filterByMerchant(ctx.getMerchant())
.executeForList(ctx);
4. Unbounded Page Size
Always cap _size and _pageSize at the API boundary.
Recommended API Shape
Controller:
@PostMapping("/orders/search")
public WebResponse searchOrders(
@TQLContext CustomUserContext userContext,
@RequestBody String params) {
return orderService.searchOrders(userContext, params);
}
Service:
public WebResponse searchOrders(CustomUserContext userContext, String params) {
String normalized = OrderSearchPolicy.normalize(params, userContext);
SmartList<Order> orders = Q.orders()
.selectAll()
.selectCustomer(Q.customers().selectName())
.findWithJsonExpr(normalized)
.filterByMerchant(userContext.getMerchant())
.orderByIdDescending()
.executeForList(userContext);
return WebResponse.of(orders);
}
Policy:
public class OrderSearchPolicy {
public static String normalize(String params, CustomUserContext userContext) {
// Validate page size, allowed fields, allowed sort, and request size.
return params;
}
}
Summary
findWithJsonExpr is best used as a dynamic search layer on top of generated TeaQL requests.
Use it for:
- User-driven filters
- Admin search forms
- Dynamic sorting
- Pagination
- Chain-field filters when the child request is selected
Keep fixed business and security rules in Java:
- Tenant and merchant scope
- Permission boundaries
- Soft-delete and version rules
- Required graph shape
- Default ordering
This gives teams dynamic query flexibility without losing TeaQL's strongly typed domain model and UserContext-based runtime control.