Turn JSON into secure, multi-platform SQL. Store queries in databases, hydrate them with parameterized data, and execute with table and action allowlists and injection protection. Perfect for report builders, dynamic dashboards, and user-defined data views.
Built on QueryBuilder (qb) | Editor Demo | Schema & Types
- 30-Second Install
- Features at a Glance
- Configuration
- Return Formats — Array, Tabular, Query, Struct
- QBML Schema Reference — Selects, Wheres, Joins, CTEs, Unions, Subqueries
- Lock Methods
- Object-Form Arguments
- Parameters & Dynamic Queries
- Conditional Actions
- Executors
- Security
- QBML Editor — Monaco + Vue component
- API Reference
- Examples
- Testing & Contributing
- Credits
Requirements: Lucee 5.3+ or Adobe ColdFusion 2021+, ColdBox 6+, qb 13+
box install qbmlPoint to a config file in config/ColdBox.cfc (or pass {} for zero-config defaults):
moduleSettings = {
qbml : { configPath : "config.qbml" }
};Inject and run your first query:
property name="qbml" inject="QBML@qbml";
var users = qbml.execute([
{ "from": "users" },
{ "select": ["id", "name", "email"] },
{ "where": ["status", "active"] },
{ "orderBy": ["name", "asc"] },
{ "get": true }
]);That's it. QBML defaults to no restrictions and "array" return format. See Configuration for security controls and Return Formats for output options.
| Category | Capabilities |
|---|---|
| Query Language | 49 base actions covering all QB select methods, plus object-form arguments |
| Return Formats | Array (default), Tabular (compact + types), Query (native CFML), Struct (keyed lookups) |
| Parameters | $param references, string template interpolation ($name$), conditional clauses |
| Security | Table/action/executor allowlist & blocklist, SQL injection protection, input validation |
| Advanced SQL | CTEs, recursive CTEs, subqueries, unions, 7 join types, lock methods |
| Configuration | Config file with environment overrides, table aliases, query defaults |
| Editor | Monaco-powered Vue component — autocomplete, 50+ snippets, validation, hover docs |
| Frontend | Browser detabulator (JS/TS), Quasar QTable integration, TypeScript types |
QBML supports a dedicated config file (recommended) or inline moduleSettings using the same keys.
Create config/qbml.cfc in your application:
component {
function configure() {
return {
// Access control: mode + list. Wildcards supported.
// mode: "none" (all allowed), "allow" (only list), "block" (all except list)
tables : { mode : "none", list : [] },
// tables : { mode : "allow", list : [ "users", "orders", "reporting.*" ] },
// tables : { mode : "block", list : [ "sys.*", "*.passwords" ] },
actions : { mode : "none", list : [] },
// actions : { mode : "block", list : [ "*Raw" ] },
executors : { mode : "none", list : [] },
// executors : { mode : "allow", list : [ "get", "first", "count", "exists", "paginate" ] },
// Friendly table aliases
aliases : {
// accts : "dbo.tbl_accounts",
// txns : "finance.transactions"
},
// Query defaults
defaults : {
timeout : 30,
maxRows : 10000,
datasource : "",
returnFormat : "array" // "array", "tabular", "query", or ["struct", "columnKey"]
},
// Read-only credentials (optional)
credentials : {
username : "",
password : ""
},
debug : false
};
}
// Environment-specific overrides (optional)
function development() {
return { debug : true };
}
function production() {
return {
// tables : { mode : "allow", list : [ "users", "orders", "products" ] }
};
}
function testing() {
return {
tables : { mode : "none", list : [] },
actions : { mode : "none", list : [] },
executors : { mode : "none", list : [] },
debug : false
};
}
}Then reference it in config/ColdBox.cfc:
moduleSettings = {
qbml : { configPath : "config.qbml" }
};Each access control setting (tables, actions, executors) uses a { mode, list } structure:
| Mode | Behavior |
|---|---|
none |
All allowed (empty list ignored) |
allow |
Only items matching list patterns allowed |
block |
All allowed except items matching list patterns |
Wildcards are supported: reporting.*, *.audit_log, *Raw
For development or trusted environments with no restrictions:
moduleSettings = {
qbml : {}
};QBML supports four return formats for get, paginate, and simplePaginate:
| Format | Syntax | Returns | Best For |
|---|---|---|---|
| Array | "array" (default) |
[{ id: 1, name: "Alice" }, ...] |
General use |
| Tabular | "tabular" |
{ columns, rows } with type metadata |
API bandwidth, QTable |
| Query | "query" |
Native CFML query object | Legacy integration |
| Struct | ["struct", key] |
{ "1": { ... }, "2": { ... } } |
Lookups, translation maps |
All formats accept tuple syntax:
["array"]is equivalent to"array". Struct requires tuple syntax since it needs a columnKey parameter.
Priority Order: Execute options > Query definition > Config defaults
Set the default in config:
defaults : {
returnFormat : "tabular" // All queries return tabular by default
// returnFormat : ["struct", "id"] // All queries return struct keyed by id
}Override per-query:
{ "get": { "returnFormat": "tabular" } }
{ "get": { "returnFormat": ["struct", "id"] } }
{ "paginate": { "page": 1, "maxRows": 25, "returnFormat": ["struct", "orderId"] } }Override at runtime:
qbml.execute( query, { returnFormat : "query" } );
qbml.execute( query, { returnFormat : [ "struct", "id" ] } );Returns results as a struct keyed by a column value — ideal for translation maps, lookup tables, and master-detail relationships where you need fast key-based access.
Full rows keyed by id:
{ "get": { "returnFormat": ["struct", "id"] } }{
"1": { "id": 1, "username": "alice", "email": "[email protected]" },
"2": { "id": 2, "username": "bob", "email": "[email protected]" }
}Translation map — single valueKey returns scalar values:
{ "get": { "returnFormat": ["struct", "code", ["label"]] } }{ "US": "United States", "CA": "Canada", "MX": "Mexico" }Partial rows — multiple valueKeys return a subset of columns:
{ "get": { "returnFormat": ["struct", "id", ["username", "email"]] } }{
"1": { "username": "alice", "email": "[email protected]" },
"2": { "username": "bob", "email": "[email protected]" }
}With pagination — results is a struct, pagination metadata unchanged:
{ "paginate": { "page": 1, "maxRows": 25, "returnFormat": ["struct", "orderId"] } }{
"pagination": { "page": 1, "maxRows": 25, "totalRecords": 150, "totalPages": 6 },
"results": {
"1001": { "orderId": 1001, "total": 59.99, "status": "shipped" },
"1002": { "orderId": 1002, "total": 124.50, "status": "pending" }
}
}Duplicate keys: If multiple rows share the same columnKey value, the last row wins. Use a unique column (primary key, code, etc.) for predictable results.
Validation: If
columnKeyor anyvalueKeysentry doesn't match a column in the result set, aQBML.InvalidColumnKeyorQBML.InvalidValueKeyerror is thrown with a message listing the available columns.
Compact columnar format with type metadata — ideal for bandwidth-sensitive APIs and Quasar QTable integration:
{
"columns": [
{ "name": "id", "type": "integer" },
{ "name": "name", "type": "varchar" },
{ "name": "created_at", "type": "datetime" }
],
"rows": [
[1, "Alice", "2024-01-15T10:30:00Z"],
[2, "Bob", "2024-01-16T14:22:00Z"]
]
}With pagination:
{
"pagination": { "page": 1, "maxRows": 25, "totalRecords": 150, "totalPages": 6 },
"results": { "columns": [...], "rows": [...] }
}Detected Types: integer, bigint, decimal, varchar, boolean, datetime, uuid, object, array
For browser-side tabular conversion (detabulate, QTable helpers, TypeScript types), see schemas/README.md.
QBML queries are JSON arrays where each element is an action object:
[
{ "from": "users" },
{ "select": ["id", "name", "email"] },
{ "where": ["status", "active"] },
{ "orderBy": ["name", "asc"] },
{ "limit": 100 },
{ "get": true }
]{ "from": "tableName" }
{ "from": "tableName alias" }
{ "fromSub": "alias", "query": [...] }
{ "fromRaw": "custom_table_expression" }{ "select": ["col1", "col2"] }
{ "select": "*" }
{ "addSelect": ["col3", "col4"] }
{ "distinct": true }
{ "selectRaw": "COUNT(*) as total, SUM(amount) as sum_amount" }
{ "subSelect": "orderCount", "query": [...] }// Basic comparisons
{ "where": ["column", "value"] }
{ "where": ["column", "<>", "value"] }
{ "andWhere": ["column", ">", 10] }
{ "orWhere": ["column", "like", "%test%"] }
// IN clauses
{ "whereIn": ["status", ["active", "pending"]] }
{ "whereNotIn": ["status", ["deleted"]] }
{ "orWhereIn": ["type", [1, 2, 3]] }
// BETWEEN
{ "whereBetween": ["amount", 100, 500] }
{ "whereNotBetween": ["date", "2024-01-01", "2024-12-31"] }
// LIKE
{ "whereLike": ["name", "%john%"] }
{ "whereNotLike": ["email", "%spam%"] }
// NULL checks
{ "whereNull": "deleted_at" }
{ "whereNotNull": "verified_at" }
// Column comparisons
{ "whereColumn": ["created_at", "updated_at"] }
{ "whereColumn": ["total", ">", "subtotal"] }
// Raw expressions with bindings
{ "whereRaw": ["YEAR(created_at) = ?", [2024]] }Group conditions with parentheses by passing an array of clause objects:
{
"where": [
{ "where": ["status", "active"] },
{ "orWhere": ["role", "admin"] }
]
}Generates: WHERE (status = 'active' OR role = 'admin')
// Simple joins
{ "join": ["orders", "users.id", "=", "orders.user_id"] }
{ "leftJoin": ["profiles", "users.id", "=", "profiles.user_id"] }
{ "rightJoin": ["departments", "users.dept_id", "=", "departments.id"] }
{ "crossJoin": "statuses" }
// Complex join conditions
{
"leftJoin": "orders",
"on": [
{ "on": ["users.id", "=", "orders.user_id"] },
{ "andOn": ["orders.status", "=", "active"] }
]
}
// Join with subquery
{
"joinSub": "recent_orders",
"query": [
{ "from": "orders" },
{ "where": ["created_at", ">", "2024-01-01"] }
],
"on": [
{ "on": ["users.id", "=", "recent_orders.user_id"] }
]
}{ "groupBy": ["status", "type"] }
{ "having": ["count", ">", 5] }
{ "havingRaw": ["SUM(amount) > ?", [1000]] }{ "orderBy": ["name", "asc"] }
{ "orderBy": "name" } // Defaults to "asc"
{ "orderByDesc": "created_at" }
{ "orderByAsc": "id" }
{ "orderByRaw": "FIELD(status, 'pending', 'active', 'closed')" }
{ "reorder": true } // Clear previous orders{ "limit": 100 }
{ "offset": 50 }
{ "forPage": [2, 25] } // Page 2, 25 per page[
{
"with": "active_users",
"query": [
{ "from": "users" },
{ "where": ["status", "active"] }
]
},
{ "from": "active_users" },
{ "select": ["*"] },
{ "get": true }
]Chain multiple CTEs by adding more with actions — each can reference previously defined CTEs.
[
{ "from": "customers" },
{ "select": ["name", "email"] },
{
"union": true,
"query": [
{ "from": "suppliers" },
{ "select": ["name", "email"] }
]
},
{ "get": true }
]// EXISTS
{
"whereExists": true,
"query": [
{ "from": "orders" },
{ "whereColumn": ["orders.user_id", "users.id"] }
]
}
// Scalar subquery in SELECT
{
"subSelect": "order_count",
"query": [
{ "from": "orders" },
{ "selectRaw": "COUNT(*)" },
{ "whereColumn": ["orders.user_id", "users.id"] }
]
}
// Derived table (FROM subquery)
{
"fromSub": "recent_orders",
"query": [
{ "from": "orders" },
{ "where": ["created_at", ">", "2024-01-01"] }
]
}Control row-level locking for transactional queries:
{ "lockForUpdate": true }
{ "sharedLock": true }
{ "noLock": true }
{ "clearLock": true }
{ "lock": "custom_lock_expression" }lockForUpdate accepts an optional boolean for skipLocked:
{ "lockForUpdate": true } // Default (no skip)
{ "lockForUpdate": false } // skipLocked = falseAs an alternative to positional arrays, use named keys for readability:
// Array form
{ "where": ["status", "=", "active"] }
// Object form (equivalent)
{ "where": { "column": "status", "operator": "=", "value": "active" } }Object form works with all combinator prefixes (and/or) and negation (not) variants.
| Action | Object Keys |
|---|---|
where |
{ column, operator?, value } |
whereIn |
{ column, values } |
whereBetween |
{ column, start, end } |
whereLike |
{ column, value } |
whereNull |
{ column } |
whereColumn |
{ first, operator?, second } |
join |
{ table, first, operator?, second } |
orderBy |
{ column, direction? } |
having |
{ column, operator?, value } |
forPage |
{ page, size } |
limit / offset |
{ value } |
select / groupBy |
{ columns } or { column } |
from |
{ table } or { name } |
*Raw |
{ sql, bindings? } |
Example with joins and ordering:
[
{ "from": { "table": "users" } },
{ "leftJoin": { "table": "orders", "first": "users.id", "operator": "=", "second": "orders.user_id" } },
{ "where": { "column": "status", "value": "active" } },
{ "orderBy": { "column": "name", "direction": "asc" } },
{ "get": true }
]QBML supports runtime parameters that enable dynamic query building without string interpolation. This is especially useful for dataviewer-style applications where queries are stored in a database and parameters are injected at execution time.
Use { "$param": "paramName" } to reference a parameter value anywhere in your query:
var query = [
{ "from": "users" },
{ "select": ["id", "name", "email"] },
{ "whereIn": ["accountID", { "$param": "accountIDs" }] },
{ "get": true }
];
var results = qbml.execute( query, { params: { accountIDs: [1, 2, 3] } } );Works in any value position:
{ "whereIn": ["status", { "$param": "statuses" }] }
{ "where": ["accountID", { "$param": "accountID" }] }
{ "whereBetween": ["orderDate", { "$param": "startDate" }, { "$param": "endDate" }] }For LIKE patterns and string composition, use $paramName$ syntax to embed values directly in strings:
{ "whereLike": ["name", "%$filter$%"] }
{ "whereLike": ["email", "$domain$%"] }
{ "where": ["sku", "like", "$category$-$year$-%"] }qbml.execute( query, { params: { filter: "john" } } );
// Generates: WHERE name LIKE '%john%'
qbml.execute( query, { params: { category: "ELEC", year: "2024" } } );
// Generates: WHERE sku LIKE 'ELEC-2024-%'Note: Only simple values (strings, numbers) are interpolated. Arrays and structs are left unchanged. Missing params leave the $paramName$ placeholder in place.
The real power comes from combining $param with when conditions. Skip clauses entirely when parameters are empty:
{
"when": { "param": "accountIDs", "notEmpty": true },
"whereIn": ["accountID", { "$param": "accountIDs" }]
}If accountIDs is empty, the entire whereIn clause is skipped — no WHERE 0 = 1!
| Condition | Description |
|---|---|
{ "param": "name", "notEmpty": true } |
True if param is not empty array/string |
{ "param": "name", "isEmpty": true } |
True if param is empty or missing |
{ "param": "name", "hasValue": true } |
True if param exists with any value |
{ "param": "name", "gt": value } |
param > value |
{ "param": "name", "gte": value } |
param >= value |
{ "param": "name", "lt": value } |
param < value |
{ "param": "name", "lte": value } |
param <= value |
{ "param": "name", "eq": value } |
param == value |
{ "param": "name", "neq": value } |
param != value |
Store this query in your database and execute with any combination of filters:
[
{ "from": "transactions" },
{ "select": ["id", "amount", "type", "accountID", "transactionDate"] },
{
"when": { "param": "accountIDs", "notEmpty": true },
"whereIn": ["accountID", { "$param": "accountIDs" }]
},
{
"when": {
"and": [
{ "param": "startDate", "hasValue": true },
{ "param": "endDate", "hasValue": true }
]
},
"whereBetween": ["transactionDate", { "$param": "startDate" }, { "$param": "endDate" }]
},
{
"when": { "param": "minAmount", "gt": 0 },
"where": ["amount", ">=", { "$param": "minAmount" }]
},
{
"when": { "param": "types", "notEmpty": true },
"whereIn": ["type", { "$param": "types" }]
},
{ "orderByDesc": "transactionDate" },
{ "paginate": { "page": 1, "maxRows": 100 } }
]// All filters
var results = qbml.execute( storedQuery, {
params: {
accountIDs: [1, 2, 3],
startDate: "2024-01-01",
endDate: "2024-12-31",
minAmount: 100,
types: ["credit", "debit"]
}
});
// No filters — returns all transactions
var results = qbml.execute( storedQuery, { params: {} });Apply actions conditionally based on runtime data:
{
"when": "hasValues",
"whereIn": ["status", ["active", "pending"]]
}If the array is empty, the whereIn is skipped entirely (no WHERE 0 = 1).
| Condition | Description |
|---|---|
"hasValues" / "notEmpty" |
True if any array argument is not empty |
"isEmpty" |
True if any array argument IS empty |
{ "notEmpty": 2 } |
Check specific arg index (1-based) |
{ "gt": [1, 5] } |
args[1] > 5 |
{ "gte": [1, 5] } |
args[1] >= 5 |
{ "lt": [1, 5] } |
args[1] < 5 |
{ "lte": [1, 5] } |
args[1] <= 5 |
{ "eq": [1, "value"] } |
args[1] == "value" |
{ "neq": [1, "value"] } |
args[1] != "value" |
{ "and": [...] } |
All conditions must be true |
{ "or": [...] } |
Any condition must be true |
{ "not": condition } |
Negate the condition |
{
"when": "hasValues",
"whereIn": ["accountID", []],
"else": { "where": ["status", "active"] }
}Executors determine how the query runs and what it returns:
{ "get": true } // Array of structs
{ "first": true } // Single struct or null
{ "find": [123] } // Find by ID
{ "find": [123, "user_id"] } // Find by custom column
{ "value": "name" } // Single column value
{ "values": "id" } // Array of single column values
{ "count": true } // Count of rows
{ "count": "id" } // Count of specific column
{ "sum": "amount" } // Sum of column
{ "avg": "price" } // Average of column
{ "min": "created_at" } // Minimum value
{ "max": "updated_at" } // Maximum value
{ "exists": true } // Boolean exists check
{ "paginate": { "page": 1, "maxRows": 25 } } // Paginated results
{ "simplePaginate": { "page": 1, "maxRows": 25 } } // Simple pagination (no count)
{ "toSQL": true } // SQL string (no execution)Pass execution options with the executor:
{
"get": true,
"datasource": "reporting",
"timeout": 60
}Return format can also be set per-executor — see Return Formats.
QBML includes multiple security layers:
- Allowlist mode: Only explicitly listed tables are accessible
- Blocklist mode: All tables except those listed are accessible
- Wildcard patterns:
"reporting.*","*.audit_log" - Table aliases automatically resolved and validated
- Raw expressions validated against dangerous patterns
- Blocks:
DROP,DELETE,TRUNCATE,INSERT,UPDATE,EXEC,--,/*,xp_,WAITFOR, etc. - All values parameterized through QB
- Table and column names validated
- CTE aliases automatically allowed in their query scope
- Subqueries recursively validated
QBML includes a production-ready Monaco-powered JSON editor component for Vue 3 / Quasar. Schema-driven autocomplete, 50+ snippets, real-time validation, and pinnable hover tooltips with qb documentation links.
- Autocomplete — schema-driven suggestions for all QBML actions and their arguments
- 50+ Snippets — full query templates and individual clauses, organized by category in a toolbar dropdown
- Real-time Validation — JSON Schema validation with clickable error navigation
- Pinnable Hover Tooltips — rich markdown with code examples and links to qb docs
- Toolbar — undo/redo, format, compact, sort keys, expand/collapse all
- Progressive Enhancement — works as a generic JSON editor, enhanced with QBML schema
- v-model + Events — string v-model,
validationandreadyevents, exposed methods
<template>
<MonacoJsonEditor
v-model="query"
v-bind="getEditorProps('qbml')"
title="QBML Query"
height="500px"
/>
</template>
<script setup>
import { ref } from "vue";
import MonacoJsonEditor from "src/components/MonacoJsonEditor.vue";
import { useJsonSchema } from "src/composables/useJsonSchema";
const { getEditorProps } = useJsonSchema();
const query = ref('[\n { "from": "users" },\n { "select": ["*"] },\n { "get": true }\n]');
</script>Full documentation: schemas/README.md | Demo app: qbml-editor-quasar/
// Inject
property name="qbml" inject="QBML@qbml";
// Execute a query
var results = qbml.execute( queryArray, options );
// Execute with parameters
var results = qbml.execute( queryArray, {
params: { accountIDs: [1, 2, 3], status: "active" }
});
// Build without executing (returns QB instance)
var qbInstance = qbml.build( queryArray );
// Build with parameters
var qbInstance = qbml.build( queryArray, { accountIDs: [1, 2, 3] } );
// Get SQL string
var sql = qbml.toSQL( queryArray );
// Get SQL string with parameters resolved
var sql = qbml.toSQL( queryArray, { accountIDs: [1, 2, 3] } );
// Resolve $param references in a value (utility method)
var resolved = qbml.resolveParamRefs( value, params );For the ReturnFormat service API, browser-side detabulator functions, and QTable helpers, see schemas/README.md.
function buildReport( required struct filters ) {
var query = [
{ "from": "orders o" },
{ "leftJoin": ["customers c", "o.customer_id", "=", "c.id"] },
{ "select": ["o.id", "o.total", "c.name as customer_name", "o.created_at"] }
];
// Add filters conditionally
if ( len( filters.status ?: "" ) ) {
query.append( { "where": ["o.status", filters.status] } );
}
if ( len( filters.startDate ?: "" ) ) {
query.append( { "where": ["o.created_at", ">=", filters.startDate] } );
}
if ( len( filters.endDate ?: "" ) ) {
query.append( { "where": ["o.created_at", "<=", filters.endDate] } );
}
query.append( { "orderByDesc": "o.created_at" } );
query.append( { "paginate": { "page": filters.page ?: 1, "maxRows": 50 } } );
return qbml.execute( query );
}// Query stored in database
var storedQuery = deserializeJSON( queryRecord.definition );
// Execute with runtime parameters merged
storedQuery.append( { "where": ["tenant_id", currentTenantID] } );
return qbml.execute( storedQuery );function list( event, rc, prc ) {
var query = [
{ "from": "products" },
{ "select": ["id", "name", "price", "stock"] },
{ "where": ["is_active", 1] },
{ "orderBy": ["name", "asc"] },
{ "paginate": {
"page": rc.page ?: 1,
"maxRows": 100,
"returnFormat": "tabular"
} }
];
return qbml.execute( query );
}var paged = qbml.execute([
{ "from": "orders" },
{ "select": ["id", "total", "created_at"] },
{ "paginate": { "page": 1, "maxRows": 25 } }
]);
// Get SQL without executing
var sql = qbml.toSQL([
{ "from": "users" },
{ "where": ["role", "admin"] }
]);# Install dependencies
box install
# Run tests
box testbox run- Fork the repository
- Create a feature branch
- Write tests for new functionality
- Submit a pull request
MIT License - see LICENSE file for details.
QBML is built on top of the excellent QueryBuilder (qb) module created by Eric Peterson at Ortus Solutions.
Key Ortus Tools Used:
- qb - Fluent query builder for CFML (by Eric Peterson)
- TestBox - BDD/TDD testing framework
- CommandBox - CLI and package manager
Created by John Wilson
Soli Deo Gloria