Skip to main content
TQL is the query language used to write ontology queries in TextQL. It is a typed SQL templating language that lets you define reusable, parameterized queries with a small expression layer on top. Use TQL when you want to expose a stable query surface — one that accepts typed parameters, enforces business logic, and always produces consistent SQL.
Parameter descriptions are shown in the UI. When you add -- comments above a param declaration, they appear as help text in the ontology query panel. Use them to document expected formats, example values, and allowed filter keys.

Two File Shapes

TQL files come in two forms.

Plain SQL Template

Use this when the template is essentially SQL with a few direct parameters.
params {
  -- Country code. Examples: "US", "CA", "BR"
  country: String = "US"

  -- Inclusive lower bound in ISO 8601 format.
  created_after: Timestamp?
}

SELECT *
FROM visits
WHERE country = ${country}
  AND created_at >= ${created_after}
In a plain body, ${...} interpolates a param name directly into SQL.

Expression Body

Use this when you need branching, reusable fragments, matchSet, or semantic-view logic.
params {
  include_region: Bool = false
}

let
  select_expr = if include_region then sql"region, COUNT(*)" else sql"COUNT(*)"
  group_clause = if include_region then sql"GROUP BY region" else sql""
in sql''
  SELECT ${select_expr}
  FROM orders
  ${group_clause}
''
An expression body must evaluate to a SqlFragment using sql"..." or sql'' ... ''.

Params

Params are declared in a params { ... } block — one per line, no commas.
params {
  -- Country code.
  country: String = "US"

  -- Inclusive lower bound in ISO 8601 timestamp format.
  created_after: Timestamp?

  -- Metrics to include.
  metrics: Set<"revenue" | "order_count">

  -- Optional filters.
  filters: List<FilterInput> = []
}

Supported Types

TQL TypeJSON ShapeNotes
IntintegerNon-integer numbers are rejected
FloatnumberIntegers also accepted
Stringstring
Boolboolean
DatestringDocument the expected format in -- comments
TimestampstringSame note as Date
Set<"...">array of stringsValues validated against allowed labels
List<T>arrayElements validated recursively
FilterInputobjectUsed inside List<FilterInput> for dynamic filtering

Nullability and Defaults

  • ? marks a nullable param. Omitting it resolves to null.
  • Non-nullable params without a default are required.
  • Defaults are supported for scalars and empty lists/sets.
params {
  limit: Int = 100
  include_test: Bool = false
  country: String = "US"
  dimensions: Set<"buyer" | "seller"> = []
  filters: List<FilterInput> = []
}

Param Descriptions

-- comments directly above a param declaration are exposed by inspect and shown in the UI as help text.
params {
  -- Filter by country code. Examples: "US", "CA", "BR"
  country: String = "US"

  -- Filters to apply. Allowed keys: buyer_name (equals, like), created_at (gte, lte, between)
  filters: List<FilterInput> = []
}

Expressions

let Bindings

let
  left = sql"a"
  right = sql"b"
  cols = concatSep ", " [left, right]
in sql"SELECT ${cols}"
Bindings are evaluated in order. Duplicate names are rejected.

Conditionals

if isEmpty select_dims
then select_metrics
else sql"${select_dims}, ${select_metrics}"

Records and Field Access

let
  buyer = { expr = sql"b.name", join = sql"JOIN buyers b ON t.buyer_id = b.id" }
in sql"SELECT ${buyer.expr}"

SQL Fragments

Use sql"..." for short fragments and sql'' ... '' for multiline SQL.
sql''
  SELECT ${select_expr}
  FROM orders o
  ${where_clause}
''
Critical rule: never quote interpolations yourself.
-- Correct
WHERE country = ${country}

-- Wrong — TQL rejects this
WHERE country = '${country}'
Nullable params interpolate as NULL, not as an omitted clause. Use if for optional predicates:
let
  where_clause =
    if created_after == null
    then sql""
    else sql"WHERE created_at >= ${created_after}"
List interpolation already adds parentheses:
-- Correct
WHERE id IN ${ids}

-- Wrong — double parentheses
WHERE id IN (${ids})

Builtins

matchSet

The core tool for set-driven SQL structure. Maps each label in a Set<"..."> param to a SQL fragment or record.
metric_frags = matchSet metrics {
  "revenue" -> sql"SUM(t.revenue) AS revenue"
  "order_count" -> sql"COUNT(*) AS order_count"
}
Output order follows the authored arm order, not the caller’s order. Repeated values are deduped.

filterWhere

Lowers user-supplied FilterInput values against an authored filter registry.
filterables = [
  { key = "buyer_name", expr = sql"buyer_u.name", ops = ["equals", "like"] }
, { key = "created_at", expr = sql"t.created_at", ops = ["gte", "lte", "between"] }
]
where_clause = wrap "WHERE " "" (filterWhere filterables filters)
Supported operators include: equals, not_equals, gt, gte, lt, lte, like, starts_with, ends_with, between, in, not_in, is_null, is_not_null.

concatSep

Concatenates a list of SqlFragment values with a separator. Empty fragments are skipped.
select_dims = concatSep ", " (map (\d -> d.expr) dim_entries)

wrap

Adds a prefix and suffix to a fragment, but only if the fragment is non-empty.
where_clause = wrap "WHERE " "" predicates
group_clause = wrap "GROUP BY " "" group_cols

isEmpty

Returns true for empty fragments, strings, sets, lists, and null.

map, any, contains

map (\d -> d.expr) dim_entries          -- apply function to each item
any (map (\d -> d == "seller") dims)    -- true if any element is true
contains dimensions "buyer"             -- true if set/list contains value

Semantic-View Pattern

The recommended pattern for reusable query surfaces combines metrics, dimensions, and filters params with matchSet and filterWhere.
params {
  metrics: Set<"revenue" | "gmv" | "order_count">
  dimensions: Set<"buyer" | "seller" | "category"> = []
  filters: List<FilterInput> = []
}

let
  metric_frags = matchSet metrics {
    "revenue" -> sql"SUM(t.revenue) AS revenue"
    "gmv" -> sql"SUM(t.gmv) AS gmv"
    "order_count" -> sql"COUNT(*) AS order_count"
  }
  dim_entries = matchSet dimensions {
    "buyer" -> { expr = sql"buyer_u.name", join = sql"JOIN users buyer_u ON t.buyer_user_id = buyer_u.id" }
    "seller" -> { expr = sql"seller_u.name", join = sql"JOIN users seller_u ON t.seller_user_id = seller_u.id" }
    "category" -> { expr = sql"p.category", join = sql"JOIN products p ON t.product_id = p.id" }
  }
  filterables = [
    { key = "buyer_name", expr = sql"buyer_u.name", ops = ["equals", "like"] }
  , { key = "created_at", expr = sql"t.created_at", ops = ["gte", "lte", "between"] }
  ]
  joins = concatSep " " (map (\d -> d.join) dim_entries)
  select_dims = concatSep ", " (map (\d -> d.expr) dim_entries)
  select_metrics = concatSep ", " metric_frags
  select_expr = if isEmpty select_dims then select_metrics else sql"${select_dims}, ${select_metrics}"
  group_clause = wrap "GROUP BY " "" select_dims
  where_clause = wrap "WHERE " "" (filterWhere filterables filters)
in sql''
  SELECT ${select_expr}
  FROM transactions t
  ${joins}
  ${where_clause}
  ${group_clause}
''
This pattern gives callers a compact, stable API while keeping all SQL structure in authored code.

Common Gotchas

Use = for param defaults, let bindings, and record fields. Use == / != for expression equality comparisons.
Nullable params interpolate as NULL — they do not remove SQL clauses automatically. Use if or isEmpty for optional predicates.
SELECT ${metrics} fails. Use matchSet or contains to work with set values.
The separator and prefix/suffix must be authored string literals, not params.
In a plain SQL body, ${...} only interpolates a param name. For computed fragments like ${select_expr}, switch to expression-style TQL with sql"...".

Authoring Checklist

Before saving a new .tql file:
  • Params are in params { ... }, one per line, no commas
  • String defaults use double quotes
  • Equality tests use == / !=
  • Optional clauses use if or isEmpty
  • Interpolations are not wrapped in SQL quotes
  • List interpolations are not wrapped in extra parentheses
  • Semantic-view metrics have stable AS ... aliases
  • filters comments document allowed keys and operators