Skip to main content

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 ValueInferred OperatorExample
StringCONTAIN by default"code": "ORD"
NumberEQUAL"id": 1001
BooleanEQUAL"finished": false
Array of stringsIN"status": ["NEW", "SHIPPED"]
Array of objects with idIN"customer": [{"id": 1}, {"id": 2}]
Array with 2 valuesBETWEEN 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 as IN with 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 Date objects 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.


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.