Query Constructor
A SELECT assembled a clause at a time — sources with a join button, columns, filter, then order, group and limit — with the finished statement underneath. Reads both ways: paste a SELECT and the blocks fill in. Values compile to bound parameters and identifiers are allow-listed against the schema.
src/components/ui/query-constructor.tsxAbout
From the component's own source, where it was written.
A SQL SELECT assembled a clause at a time, and readable back out of a statement you paste.
The counterpart to QueryEditor: that one is a console for people who already know SQL; this is for people who know the *question* and not the dialect. It reads both ways — paste a statement and the blocks fill in, change a block and the statement follows — because a builder that only compiles is a dead end: you can construct a query but never open the one you already have.
**One block per decision.** Sources, columns, filter, then the three small ones that shape the result. Each is a titled card holding only its own controls, so the query is read as a short list of choices rather than a long form — and each block carries its own action, which is why joining a table is a button on *Sources* rather than a control floating between clauses.
**It follows the schema.** Every picker offers only columns of the tables in play, qualified as table.column and annotated with their type; joining a table brings its columns into the pool, and removing one takes its columns out of the filter, the ordering and the grouping with it rather than leaving a statement that names a table it no longer joins. A references on a column is a join the schema already knows about, so it is offered as one click.
**Values become parameters and identifiers are allow-listed** — see @/lib/sql-select, which owns the grammar and is tested without a DOM. That holds for pasted SQL too: a literal in a pasted WHERE becomes a bound parameter when it compiles back, and a table you did not declare is dropped rather than carried through.
**The WHERE clause is a SegmentBuilder** — the same nested-precedence problem, so the same component rather than two answers to what A or B and C means.
Install
npx astralyx-ui add query-constructor
Usage
import { QueryConstructor } from '@/components/ui/query-constructor'
<QueryConstructor
tables={schema}
dialect="postgres"
functions={[{ name: 'lower', families: ['string'] }]}
onCompile={(sql, params) => preview(sql, params)}
/>Composer
Change the props and the source below follows.
Sources
Columns
4 of 27Filter
Order
Group
Limit
Query
SELECT "users"."email", "users"."country", "plans"."name", "users"."seats"
FROM "users"
INNER JOIN "plans" ON "users"."plan_id" = "plans"."id"
WHERE "users"."seats" >= $1 AND "users"."trial" = FALSE AND ("users"."country" = $2 OR "users"."country" = $3)
ORDER BY "users"."seats" DESC
LIMIT $4;params: ["5", "DE", "FR", 100]
<QueryConstructor
tables={schema}
functions={functions}
dialect="postgres"
onCompile={(sql, params) => preview(sql, params)}
/>Examples
Team users in DE or FR, joined to plans
<QueryConstructor tables={schema} value={query} onChange={setQuery} />API
Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.
QueryConstructor
Extends Omit<ComponentProps<'div'>, 'onChange'>.
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultValue | string | number | readonly string[] | — | — |
| tables | QueryTable[] | required | { name, columns } where columns are SchemaColumn — the same type [[schema-table]] takes, so one schema description feeds both. |
| value | SelectQuery | — | — |
| onChange | (query: SelectQuery) => void | — | — |
| onCompile | (sql: string, params: unknown[]) => void | — | Fires when the statement changes, keyed on the output rather than the callback identity so an inline arrow does not loop. |
| dialect | SqlDialect | 'postgres' | One switch for everything that differs: quoting ("x", ` x , [x]), placeholders ($1, ?, @p1`), boolean literals versus 1 and 0, and the operator list. A provider switcher sits with the statement because it is the statement it changes. |
| defaultDialect | SqlDialect | — | Starting provider when dialect is not controlled. |
| dialects | SqlDialect[] | ['postgres', 'mysql', 'sqlite', 'mssql'] | Providers offered in the switcher. Omit the switcher with a single one. |
| onDialectChange | (dialect: SqlDialect) => void | — | — |
| functions | SqlFunction[] | [] | How a function gets used: declare { name, families?, returns?, dialects? } and it is offered as fn(table.column) wherever a column can go, in the columns, the filter, the ordering. An undeclared name cannot reach the statement — the same allow-list that protects table names. |
| preview | boolean | true | Hide the generated SQL. |
| importable | boolean | true | Hide the paste-a-statement panel. |
| defaultLimit | number | — | — |
| label | string | 'Query' | — |
| runLabel | ReactNode | — | — |
| onRun | (sql: string, params: unknown[]) => void | — | — |
| values | bound parameters | — | A value never enters the SQL string — it is emitted as $1 (or ?) and returned in params. That is what makes the output safe to execute. |
| is | same two rules | — | A literal in a pasted WHERE becomes a bound parameter when it compiles back, and a table you did not declare is dropped with a reason rather than carried through. |
Types
QueryTable
A SQL SELECT assembled a clause at a time, and readable back out of a statement you paste.
| Field | Type | Description |
|---|---|---|
| name | string | — |
| columns | SchemaColumn[] | — |
| label? | ReactNode | — |
QueryJoin
| Field | Type | Description |
|---|---|---|
| id | string | — |
| type | 'inner' | 'left' | — |
| table | string | Dropping a join also clears that table from the columns, the filter, the ordering and the grouping, rather than leaving a statement that names a table it no longer joins. |
| leftColumn | string | — |
| rightColumn | string | — |
Query
| Field | Type | Description |
|---|---|---|
| from | string | — |
| joins | SqlJoin[] | A references on a column is a join the schema already knows about, so it is suggested rather than retyped. |
| select | string[] | Qualified columns. Empty means every column. |
| where | SqlGroup | The same nested-precedence problem, so the same component — solving it twice would give two answers to "what does A or B and C mean". |
| groupBy | string[] | — |
| orderBy | SqlSort[] | — |
| limit? | number | — |
Behaviour
| Topic | Detail | Description |
|---|---|---|
| vs QueryEditor | who it is for | The editor is for people who know SQL and want a console. This is for people who know the question and not the dialect. They are meant to sit beside each other, and the generated statement pastes straight into the editor. |
| identifiers | allow-listed | No driver can parameterise a table or column name, so the defence is a list: every identifier is looked up in the schema you passed and anything absent is dropped. A name cannot reach the query unless it was already in your schema. |
| follows the schema | columns are scoped | Pickers only offer columns of the tables in play, qualified as table.column. Changing the source table clears the choices that named the old one instead of leaving a query that will not run. |
| what it will not build | stated | Sub-queries, CTEs, window functions and unions. Past a certain complexity a builder is slower than typing, which is what the editor is for. |
| reads both ways | paste a SELECT | The Paste SQL panel parses a statement back into the controls, seeded with the current one so it doubles as "edit this as text". A builder that only compiles is a dead end — you can construct a query but never open the one you already have. |
| the parser | @/lib/sql-select | Tokeniser plus recursive descent over the same subset it emits, so a compile/parse round trip is lossless. Bare columns are qualified against the tables in scope, and a AND b OR c is read as (a AND b) OR c rather than flattened into something that means something else. |
| one block per decision | sources, columns, filter… | Each clause is a titled card holding only its own controls, and each carries its own action — which is why joining a table is a button on Sources rather than a control floating between clauses. Order, group and limit sit in a row, because three small choices do not each deserve a full-width band. |
| IN | a list, each bound | Compiles to IN ($1, $2, $3) with one placeholder per item — the list is bound, never spliced. SegmentBuilder grew a many arity for it, so "is one of" is finally a list input rather than a single select. |
| provider-only operators | offered where they exist | ILIKE and ~ on PostgreSQL, REGEXP and SOUNDS LIKE on MySQL, GLOB on SQLite. Opening a query on a provider that lacks one drops that condition and says which operator and which provider. |
| SQL Server has no LIMIT | reported, not faked | It compiles to OFFSET 0 ROWS FETCH NEXT @p1 ROWS ONLY, which is a syntax error without an ORDER BY — so a limit with no ordering is reported rather than emitted. |