Chenile Query and JDBC Query Catalog
Chenile Query exposes a named, metadata-governed read API over MyBatis. A query has two independent parts:
| Part | Purpose | Example |
|---|---|---|
| MyBatis statement | Defines the SQL and returned columns | Student.getAll |
| Query metadata | Defines the public query name, allowed filters, sorting, pagination, and ACLs | students |
The optional JDBC Query Catalog loads those parts from a deployment-managed database at application startup. It is useful when operations teams need to promote approved query definitions without repackaging an application. It does not provide a runtime editing API or hot reload.
Request flow and tenant model
The optional chenile-query-controller exposes POST /q/{queryName}. It resolves the request tenant from
Chenile’s ContextContainer, selects that tenant’s query datasource, resolves the query metadata, and executes
its MyBatis statement. The usual HTTP integration places x-chenile-tenant-id into the context.
POST /q/students + tenant header
-> QueryTenantResolver
-> tenant QueryMetadata, then base QueryMetadata
-> tenant datasource
-> MyBatis statement named by QueryMetadata.id
-> SearchResponse
The tenant is required unless query.defaultTenantId is configured. A missing tenant without a default fails
the request; it never silently chooses an arbitrary datasource.
query:
defaultTenantId: tenant1
datasources:
tenant1:
jdbcUrl: jdbc:postgresql://db.example/tenant1_query
username: query_reader
password: ${TENANT1_QUERY_PASSWORD}
tenant2:
jdbcUrl: jdbc:postgresql://db.example/tenant2_query
username: query_reader
password: ${TENANT2_QUERY_PASSWORD}
Datasource entries are passed to HikariCP. Configure the JDBC driver and pool properties appropriate to the
application’s database. The configured tenant id must exist in query.datasources; routing does not fall back
from one tenant database to another.
Classpath configuration
Classpath files remain the default and are the simplest option for a query shipped with the application. Configure one or more comma-separated Spring resource patterns.
query:
mapperFiles: classpath*:org/example/query/mapper/*.xml
definitionFiles: classpath*:org/example/query/mapper/*.json
pagination:
countQueryEnabled: true
Each mapper uses normal MyBatis XML. The mapper namespace and statement id form the metadata id.
<mapper namespace="Student">
<select id="getAll" resultType="map">
select id, name, branch, percentage from student
</select>
</mapper>
Definition files contain a JSON array. name is the externally visible path name; id is the internal MyBatis
statement. Only declare columns that callers may filter or sort.
[
{
"name": "students",
"id": "Student.getAll",
"paginated": true,
"acls": ["QUERY_STUDENTS"],
"columnMetadata": {
"name": {
"name": "name",
"columnType": "Text",
"filterable": true,
"likeQuery": true,
"sortable": true
},
"percentage": {
"name": "percentage",
"columnType": "Number",
"filterable": true,
"sortable": true
}
}
}
]
filterable, likeQuery, containsQuery, betweenQuery, and sortable are allow-list controls, not UI hints.
The service uses them to reject unsupported request operations. A column can also set columnName when its SQL
name differs from the API field name. countQueryEnabled on a definition overrides the global pagination setting;
when counts are disabled, the service detects a next page by fetching one extra row.
Tenant-specific query definitions
Keep a base definition for the common behavior. Add a second definition with the same name and a tenantId
only when that tenant needs a different statement or query behavior.
[
{
"name": "students",
"tenantId": "tenant1",
"id": "tenant1.Student.getAll",
"paginated": true,
"columnMetadata": {
"name": { "name": "name", "columnType": "Text", "filterable": true }
}
}
]
For tenant tenant1, this definition replaces the base definition named students. Every other tenant continues
to use the base definition. Statement names are global within the MyBatis factory, so tenant-specific SQL must use
a tenant-qualified mapper namespace such as tenant1.Student; do not try to load a separate mapper set per tenant.
JDBC Query Catalog
Enable the catalog after the application supplies a dedicated, read-only DataSource bean. The catalog datasource
is for configuration only; it is separate from tenant query datasources.
query:
catalog:
jdbc:
enabled: true
dataSource: queryCatalogDataSource
baseScope: __base__
When enabled, query.mapperFiles and query.definitionFiles are optional. They may still be configured during a
migration: catalog rows override matching packaged configuration, while unrelated packaged entries remain available.
When the catalog is disabled, query.definitionFiles is required.
Apply the framework’s chenile-query-catalog-schema.sql using Liquibase, Flyway, or the application’s migration
tool. The runtime account needs only SELECT; use a separate reviewed migration or administration process for
updates.
Catalog tables and scope
chenile_query_mapper_source contains global mapper XML. Its primary key is namespace, and the namespace
column must exactly match the root <mapper namespace="…"> value. There is deliberately no tenant scope on this
table because one MyBatis SqlSessionFactory loads all mapper statements.
chenile_query_definition_source contains one JSON object per row, not an array. Its primary key is
(scope_key, query_name):
scope_key |
Meaning |
|---|---|
__base__ (or configured baseScope) |
Base definition for every tenant |
exact tenant id, for example tenant1 |
Override for that tenant only |
The query_name column must match JSON name. The JSON tenantId, if supplied, must match the tenant scope;
the catalog sets it from scope_key. enabled=false makes a row unavailable at the next restart.
An illustrative catalog definition row is:
insert into chenile_query_definition_source
(scope_key, query_name, definition_json, checksum)
values
('tenant1', 'students',
'{"name":"students","id":"tenant1.Student.getAll","paginated":true}',
'sha256-of-approved-definition');
Merge and validation rules
- A database mapper replaces a classpath mapper only when they have the same namespace.
- A database definition replaces a classpath definition only when both have the same external name in the same base or tenant scope.
- All enabled database mapper namespaces must be unique, and their XML must be valid.
- Every database definition must reference a statement that exists after classpath and catalog mapper loading.
- Invalid XML or JSON, mismatched names/scopes, unavailable catalog datasource, or an unresolved statement fails application startup. This prevents a partially configured query service from serving traffic.
Catalog data is read once during startup. Change it through versioned SQL or a controlled administration process, then restart or roll out a new application instance. There is no polling, scheduler, write endpoint, or hot reload.
Request example and operational checks
POST /q/students
x-chenile-tenant-id: tenant1
Content-Type: application/json
{
"pageNum": 1,
"numRowsInPage": 25,
"filters": { "name": "Ada" },
"sortCriteria": [{ "name": "name", "ascendingOrder": true }]
}
The exact request shape is SearchRequest; it also supports systemFilters, selected fields, hidden columns,
customVariables, countOnly, and canned-report options. Treat customVariables as trusted application inputs:
they are not a substitute for declaring user-filterable metadata.
Before release, verify the following for each changed query:
- The tenant header selects the intended datasource and the intended base or tenant definition.
- The metadata statement id exists and produces the columns named by the metadata.
- Allowed filters and sorts work; unsupported fields are rejected.
- Pagination behavior matches the configured count-query policy.
- A catalog rollout starts cleanly with the production read-only account and rejects intentionally invalid data in a non-production environment.
For a non-MyBatis backend, register a QueryExecutionProvider bean and set query.provider to that provider’s
name. Query metadata, tenant resolution, and the controller contract remain the same.