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.tsx

About

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

from
planson

Columns

4 of 27

Filter

Order

Group

Limit

Query

sql
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]

Props
tsx
<QueryConstructor
  tables={schema}
  functions={functions}
  dialect="postgres"
  onCompile={(sql, params) => preview(sql, params)}
/>

Examples

Team users in DE or FR, joined to plans

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'>.

PropTypeDefaultDescription
defaultValuestring | number | readonly string[]——
tablesQueryTable[]required{ name, columns } where columns are SchemaColumn — the same type [[schema-table]] takes, so one schema description feeds both.
valueSelectQuery——
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.
dialectSqlDialect'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.
defaultDialectSqlDialect—Starting provider when dialect is not controlled.
dialectsSqlDialect[]['postgres', 'mysql', 'sqlite', 'mssql']Providers offered in the switcher. Omit the switcher with a single one.
onDialectChange(dialect: SqlDialect) => void——
functionsSqlFunction[][]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.
previewbooleantrueHide the generated SQL.
importablebooleantrueHide the paste-a-statement panel.
defaultLimitnumber——
labelstring'Query'—
runLabelReactNode——
onRun(sql: string, params: unknown[]) => void——
valuesbound 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.
issame 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.

FieldTypeDescription
namestring—
columnsSchemaColumn[]—
label?ReactNode—

QueryJoin

FieldTypeDescription
idstring—
type'inner' | 'left'—
tablestringDropping 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.
leftColumnstring—
rightColumnstring—

Query

FieldTypeDescription
fromstring—
joinsSqlJoin[]A references on a column is a join the schema already knows about, so it is suggested rather than retyped.
selectstring[]Qualified columns. Empty means every column.
whereSqlGroupThe same nested-precedence problem, so the same component — solving it twice would give two answers to "what does A or B and C mean".
groupBystring[]—
orderBySqlSort[]—
limit?number—

Behaviour

TopicDetailDescription
vs QueryEditorwho it is forThe 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.
identifiersallow-listedNo 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 schemacolumns are scopedPickers 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 buildstatedSub-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 wayspaste a SELECTThe 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-selectTokeniser 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 decisionsources, 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.
INa list, each boundCompiles 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 operatorsoffered where they existILIKE 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 LIMITreported, not fakedIt 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.