Skip to main content

Create a custom query

When the wizard doesn't fit — analytics, reports, multi-table joins, stored-procedure calls, anything that doesn't map 1-1 to a single table — write the query by hand under the Unclassified tab.

This page walks the editor form field by field, with the verified shape from the QueryDef schema.


Where it lives​

Settings → Connectors → pick a connector → mode bar: Unclassified → + Add query.

The page prompts for a name, creates an empty custom query, opens its editor.


The editor form​

Settings · Connectors · [connectors.crm] · Unclassified← Backmonthly_revenue✎ Rename⎘ Clone🗑 Deletetypecustom ▾writableSELECT only when off — flip on for INSERT / UPDATE / DELETE / CALLsqlSELECT date_trunc('month', invoice_date) AS month, SUM(total_ht) AS revenueFROM invoicesWHERE invoice_date BETWEEN :from_date AND :to_dateGROUP BY 1 ORDER BY 1paramsfrom_datePeriod startdefault:2026-01-01+ Add paramlabel · description(Advanced)Monthly revenue · Aggregated invoices total per month, period filter on invoice_date

Field by field​

name​

Read-only in the editor. The name is set when you click + Add query and is the permission key for the query — sql:<connector>:<name>. Rename via the Rename button at the top (cross-file — every screen / menu / dictionary reference is updated in one pass; refuses to run on unsaved local edits).

Naming rules:

RuleWhy
Letters, digits, underscores.TOML key, URL segment and permission string in one go.
Starts with a letter or underscore.Same.
Avoid the _get / _put / _post / _delete suffixes.The Tables tab would pick it up and try to group it into a CRUD set.

Naming conventions that scale well:

PatternExample
<entity>_<action>customer_balance, invoice_send
<verb>_<noun>get_top_clients, mark_paid
report_<topic>report_monthly_revenue, report_overdue_invoices

type​

Dropdown — drives the tab classification. For a hand-written standalone query, keep custom. The other values move the query elsewhere on the page:

ValueTab
customUnclassified (this page).
tableTables (only useful when the name matches the <base>_<crud> convention).
sequenceSequences.
lookupLookups.

Switching type doesn't rewrite the query — only the page it's filed under. For sequences and lookups you usually want the Scaffold modal anyway, which writes the dictionary entry alongside.

writable​

Boolean toggle.

StateAllowed
Off (default)SELECT and CTE-only statements.
OnINSERT / UPDATE / DELETE / CALL / MERGE / DDL — anything that mutates the database.

Leave it off for read queries — the safety gate refuses non-SELECT execution otherwise. The CRUD Wizard already sets it for _put / _post / _delete; on a hand-written query you flip it yourself when needed.

sql​

The SQL editor accepts a single statement (the common case) or a per-dialect map — see Per-dialect SQL variants.

Three features the editor surfaces:

FeatureWhat it does
Syntax highlightingKeywords coloured; mismatched brackets flagged.
AutocompletionTables and columns from the connector's pool. Hit Ctrl+Space to invoke.
:name placeholdersAny :identifier in the SQL is recognised as a bind parameter — declare it in the params block below.

A few rules from how the engine binds:

  • Use :name for parameters, never literal string interpolation. The engine binds them through the SQLAlchemy driver — '%' || :search || '%' is the LIKE pattern you want, not '%' + search + '%'.
  • Reserved suffix _ORIGINAL is used by the dialog Save for UPDATE WHERE matching (see Create from a database table for the convention).
  • A single statement per query. For chained statements, use a CALL to a stored procedure or split into multiple Liberty queries.

params​

The list of declared parameters — every :placeholder in the SQL gets one row. Each entry carries:

FieldWhat it does
nameThe placeholder name (without the colon). Matched case-insensitively.
labelWhat the form input shows above the field. Falls back to name when empty.
defaultPre-filled value. Blank means the caller can omit it (the engine binds SQL NULL).

Adding rows here doesn't change the SQL — it just tells screens and the AI assistant how to render an input for each placeholder. Skip this block when the query has no :placeholders; the engine accepts undeclared params at runtime (a column dialog can bind any :column_name it sees).

For binding values from other screens / sources (a row click, a parent filter, a chained action), see Parameter binding.

label (Advanced)​

Short name shown in dropdowns elsewhere — the screen designer, the menu target picker, the action editor. Defaults to the query name.

description (Advanced)​

Longer text. Shows on the Tables list under the base name, and as a tooltip when the operator hovers the query in the screen designer's read-query picker.


Top-bar actions​

The buttons at the top of the single-query editor:

ActionWhat it doesNotes
← BackReturns to the Unclassified list (no save).Unsaved edits stay in the page-level dirty state — Save on the top toolbar persists them.
✎ RenameCross-file rename.Refuses on unsaved local edits; updates every reference.
⎘ ClonePer-query duplicate — prompts for new name.Default suggestion <name>_copy. See Clone a query or a connector.
🗑 DeleteRemoves the query from the connector.A confirm modal first.

Save and reload​

The page-level Save at the top of the Connectors page commits the connector file and triggers a hot reload. The new query is callable immediately at /api/sql/<connector>/<name> (with the bound params as query-string values for GET, JSON body for writable POST).


Verifying the query works​

There's no dedicated Test query tab on the SQL connector — the test path is one of three:

PathWhen
Use it from a screen.When the next step is building the screen anyway.
Hit the REST endpoint directly.For a quick smoke test — GET /api/sql/<connector>/<query>?from_date=2026-01-01&to_date=2026-01-31.
Ask the AI assistant."Show me the result of monthly_revenue for last month" — the assistant will run the query and return rows. Requires ai:chat and sql:<connector>:<query> permissions.

The REST path is the fastest debug — error messages come back verbatim, and you can pass params via URL.


When to use this vs the wizard​

Use the wizardUse the custom editor
The table exists in the DB and you want the standard CRUD shapes.The query joins multiple tables.
You want consistent :NAME_ORIGINAL wiring on UPDATE.The query is a GROUP BY aggregate.
You want a _delete keyed on the primary key.The query returns a derived column the table doesn't have.
The query is a stored-procedure call.
The query is a write that's not a simple UPDATE / INSERT / DELETE (a MERGE, a TRUNCATE).

What's next​