spg_sql/ast.rs
1//! AST for the PG-dialect subset SPG accepts in v0.2.
2//!
3//! `Display` is implemented so that for any AST `a` produced by [`crate::parser`],
4//! re-parsing `format!("{a}")` yields a structurally equal AST. Binary and
5//! unary operators always emit parentheses to remove any precedence
6//! ambiguity — round-trip safety wins over prettiness.
7
8use alloc::boxed::Box;
9use alloc::format;
10use alloc::string::{String, ToString};
11use alloc::vec::Vec;
12use core::fmt;
13
14#[derive(Debug, Clone, PartialEq)]
15#[allow(clippy::large_enum_variant)] // Statement::Select dominates; Boxing would touch every match site
16pub enum Statement {
17 Select(SelectStatement),
18 CreateTable(CreateTableStatement),
19 /// v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
20 /// [WITH SCHEMA <s>] [VERSION <v>] [CASCADE]` accepted as a
21 /// no-op so PG dumps that include extension declarations
22 /// (notably `pgvector`) load against SPG without splitting
23 /// init scripts. mailrs migration follow-up F3.
24 CreateExtension(String),
25 /// v7.9.27 — PG `DO $$ … $$ [LANGUAGE plpgsql];` block. SPG
26 /// has no PL/pgSQL; engine returns CommandOk no-op so
27 /// `pg_dump` output with idempotent DO migrations loads
28 /// against SPG without splitting scripts. The lexer
29 /// consumes the dollar-quoted body into a discarded
30 /// Token::String. mailrs migration follow-up H1.
31 DoBlock,
32 CreateIndex(CreateIndexStatement),
33 Insert(InsertStatement),
34 /// v4.4 — `UPDATE <table> SET col=expr [, ...] [WHERE cond]`.
35 Update(UpdateStatement),
36 /// v4.4 — `DELETE FROM <table> [WHERE cond]`.
37 Delete(DeleteStatement),
38 Begin,
39 Commit,
40 Rollback,
41 /// `SAVEPOINT <name>` — push a named savepoint onto the active TX's
42 /// stack so a later `ROLLBACK TO <name>` can undo just the work
43 /// since this point.
44 Savepoint(String),
45 /// `ROLLBACK TO [SAVEPOINT] <name>` — restore catalog state to the
46 /// named savepoint and discard later savepoints. Does not end the
47 /// transaction.
48 RollbackToSavepoint(String),
49 /// `RELEASE [SAVEPOINT] <name>` — discard a savepoint without
50 /// rolling back. Keeps the work done since then.
51 ReleaseSavepoint(String),
52 /// `SHOW TABLES` — return the list of tables in the catalog.
53 ShowTables,
54 /// `SHOW COLUMNS FROM <table>` — return one row per column with
55 /// its declared name / type / nullability.
56 ShowColumns(String),
57 /// `CREATE USER 'name' WITH PASSWORD 'pw' ROLE 'admin'` (v4.1).
58 /// Role is optional; defaults to `readonly` when omitted.
59 CreateUser(CreateUserStatement),
60 /// `DROP USER 'name'` (v4.1).
61 DropUser(String),
62 /// `SHOW USERS` (v4.1) — admin-only listing of (name, role).
63 ShowUsers,
64 /// v4.26 — `EXPLAIN [ANALYZE] <select>`. The engine returns a
65 /// single-column text table describing the rewritten plan tree
66 /// for `inner`. `analyze` triggers an actual exec to attach
67 /// observed row counts and elapsed micros to each node.
68 Explain(ExplainStatement),
69 /// v6.0.4 — `ALTER INDEX <name> REBUILD [WITH (encoding = ...)]`.
70 /// Synchronous rebuild of an NSW index. With the optional
71 /// encoding clause, every stored cell at the indexed column is
72 /// also re-encoded through `coerce_value` before the new graph
73 /// builds.
74 AlterIndex(AlterIndexStatement),
75 /// v6.7.2 — `ALTER TABLE <name> SET <setting> = <value>`.
76 /// The only setting in v6.7.2 is `hot_tier_bytes`, which
77 /// overrides the global `SPG_HOT_TIER_BYTES` freezer trigger
78 /// for the named table.
79 AlterTable(AlterTableStatement),
80 /// v6.1.2 — `CREATE PUBLICATION <name> [FOR ALL TABLES]`.
81 /// The catalog row lives in `spg_publications`. Publisher-side
82 /// WAL filtering arrives in v6.1.5.
83 CreatePublication(CreatePublicationStatement),
84 /// v6.1.2 — `DROP PUBLICATION <name>`. PG-compatible silent
85 /// no-op when the publication does not exist.
86 DropPublication(String),
87 /// v6.1.3 — `SHOW PUBLICATIONS`. Returns one row per
88 /// publication ordered by name with `(name, scope_summary,
89 /// table_count)` columns. The scope summary is the human-
90 /// readable form `ALL TABLES` / `FOR TABLE …` / `FOR ALL
91 /// TABLES EXCEPT …`; `table_count` is `NULL` for the
92 /// `AllTables` scope and the table-list length otherwise.
93 ShowPublications,
94 /// v6.1.4 — `CREATE SUBSCRIPTION <name> CONNECTION '<conn>'
95 /// PUBLICATION <pub_name> [, <pub_name> …]`. Catalog lands
96 /// in `spg_subscriptions`; when the subscription is
97 /// `enabled = true` (default) the server spawns a
98 /// background worker that connects to `conn` and drains the
99 /// requested publication(s) into the local engine.
100 CreateSubscription(CreateSubscriptionStatement),
101 /// v6.1.4 — `DROP SUBSCRIPTION <name>`. Like DROP
102 /// PUBLICATION, silent no-op when absent. Stops the
103 /// associated worker thread before removing the row.
104 DropSubscription(String),
105 /// v6.1.4 — `SHOW SUBSCRIPTIONS`. Returns one row per
106 /// subscription ordered by name with `(name, conn_str,
107 /// publications, enabled, last_received_pos)`.
108 ShowSubscriptions,
109 /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
110 /// Blocks until the local server's apply position reaches
111 /// `<pos>` or `<ms>` elapses. Server-layer command: the
112 /// engine refuses it (`EngineError::Unsupported`) since
113 /// `lag_state` lives in `spg-server`'s `ServerState`.
114 WaitForWalPosition {
115 pos: u64,
116 /// `None` → wait forever; `Some(ms)` → return after `ms`
117 /// milliseconds even if the target isn't reached.
118 timeout_ms: Option<u64>,
119 },
120 /// v6.2.0 — `ANALYZE [<table>]`. Bare form walks every user
121 /// table; `ANALYZE <name>` re-stats just one. Populates
122 /// `spg_statistic` with per-column null_frac + n_distinct +
123 /// 100-bucket equi-depth histogram.
124 Analyze(Option<String>),
125 /// v6.7.3 — `COMPACT COLD SEGMENTS`. Walks every user table's
126 /// BTree-cold indices and merges small cold-tier segments
127 /// (size below `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, default
128 /// 4 MiB) into a single larger segment per (table, index).
129 /// `WHERE` predicate filtering on which tables to compact is
130 /// carved out of v6.7.3 (per V6_7_DESIGN.md STABILITY entry);
131 /// v6.7.3 only supports the bare form.
132 CompactColdSegments,
133}
134
135/// v6.1.4 — `CREATE SUBSCRIPTION` AST node. v6.1.4 ships a
136/// single fixed-shape DDL; the WITH-clause options PG supports
137/// (`enabled`, `slot_name`, `streaming`, `binary`) are out of
138/// scope for v6.1.4 — `enabled` defaults to true and there are
139/// no other knobs to set in v6.1.x.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct CreateSubscriptionStatement {
142 pub name: String,
143 /// Connection string in PG keyword=value form (e.g.
144 /// `host=127.0.0.1 port=20002`). v6.1.4 only consumes the
145 /// `host` and `port` fields; the rest is reserved for
146 /// future v6.1.x options.
147 pub conn_str: String,
148 /// One or more publications on the remote side. Order is
149 /// preserved verbatim from the DDL; the worker requests them
150 /// in this order. v6.1.4 records the list; v6.1.5
151 /// publisher-side filtering enforces it.
152 pub publications: Vec<String>,
153}
154
155/// v6.1.2 — `CREATE PUBLICATION` AST node. The `scope` field uses
156/// the [`PublicationScope`] shape. v6.1.2 only accepted
157/// `AllTables`; v6.1.3 unlocks the `ForTables` / `AllTablesExcept`
158/// variants by flipping the parser gate (no AST migration).
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct CreatePublicationStatement {
161 pub name: String,
162 pub scope: PublicationScope,
163}
164
165/// v6.1.2 — Which tables a publication covers. v6.1.3 (this commit)
166/// flips the parser gate for the `ForTables` / `AllTablesExcept`
167/// variants — the on-disk shape, snapshot serialisation, and the
168/// AST round-trip Display path were already in place in v6.1.2
169/// so this is a parser-only widening.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum PublicationScope {
172 AllTables,
173 ForTables(Vec<String>),
174 AllTablesExcept(Vec<String>),
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct AlterIndexStatement {
179 pub name: String,
180 pub target: AlterIndexTarget,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum AlterIndexTarget {
185 /// `REBUILD [WITH (encoding = <enc>)]`. `encoding = None`
186 /// rebuilds the existing graph in place without touching the
187 /// column encoding; `Some(enc)` re-encodes every cell first.
188 Rebuild { encoding: Option<VecEncoding> },
189}
190
191/// v6.7.2 — `ALTER TABLE t SET <setting> = <value>`. v6.7.2 ships
192/// the single `hot_tier_bytes` setting; later v6.7.x sub-versions
193/// can add more SET subjects without changing the dispatch shape.
194#[derive(Debug, Clone, PartialEq)]
195pub struct AlterTableStatement {
196 pub name: String,
197 pub target: AlterTableTarget,
198}
199
200#[derive(Debug, Clone, PartialEq)]
201pub enum AlterTableTarget {
202 /// Per-table hot-tier byte budget override. The freezer
203 /// reads this before falling back to `SPG_HOT_TIER_BYTES`.
204 SetHotTierBytes(u64),
205 /// v7.6.8 — `ALTER TABLE t ADD CONSTRAINT name FOREIGN KEY
206 /// (cols) REFERENCES parent[(pcols)] [ON DELETE/UPDATE …]`.
207 /// Engine validates existing rows against the new constraint
208 /// before installing it.
209 AddForeignKey(ForeignKeyConstraint),
210 /// v7.6.8 — `ALTER TABLE t DROP CONSTRAINT name`. Removes the
211 /// constraint by user-supplied name; raises if no FK with that
212 /// name exists on the table.
213 DropForeignKey(String),
214}
215
216#[derive(Debug, Clone, PartialEq)]
217pub struct ExplainStatement {
218 pub analyze: bool,
219 pub inner: Box<SelectStatement>,
220 /// v6.8.3 — `EXPLAIN (SUGGEST) <SELECT>` enables the index
221 /// advisor pass: after the regular plan tree, the engine
222 /// emits one suggestion line per column referenced in the
223 /// query's WHERE / JOIN that has no covering index on the
224 /// owning table.
225 pub suggest: bool,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct CreateUserStatement {
230 pub name: String,
231 pub password: String,
232 /// One of `admin` / `readwrite` / `readonly`. Stored verbatim from
233 /// the parser; the engine validates against `Role::parse` so a
234 /// typo lands as a runtime error with a clear message rather than
235 /// a parse failure.
236 pub role: String,
237}
238
239#[derive(Debug, Clone, PartialEq)]
240pub struct CreateIndexStatement {
241 pub name: String,
242 pub table: String,
243 pub column: String,
244 /// Optional `USING <method>` clause. v2.0 recognises `hnsw` (NSW
245 /// graph for vector kNN); unspecified is the default B-tree index.
246 pub method: IndexMethod,
247 /// `IF NOT EXISTS` — engine returns `CommandOk` no-op when the
248 /// index name already exists, instead of raising `DuplicateIndex`.
249 pub if_not_exists: bool,
250 /// v6.8.0 — `INCLUDE (col1, col2, …)` columns. Identifies the
251 /// non-key columns the planner should treat as "covered" by
252 /// this index when checking whether a query can run as an
253 /// index-only scan. Empty when no `INCLUDE` clause was given.
254 pub included_columns: Vec<String>,
255 /// v6.8.1 — `WHERE <expr>` partial-index predicate. Only rows
256 /// for which `<expr>` evaluates truthy enter the index;
257 /// queries whose `WHERE` clause's canonical Display form
258 /// matches this expression's Display form can be served by the
259 /// partial index. Stored as a parsed `Expr` so the engine
260 /// re-uses the existing evaluation path; storage persists the
261 /// Display form on the catalog snapshot.
262 pub partial_predicate: Option<Expr>,
263 /// v6.8.2 — expression-based index. When `Some(expr)`, the
264 /// index key is the result of `expr` evaluated on each row
265 /// (e.g. `CREATE INDEX … (lower(name))`). The `column`
266 /// field still names the *primary* column the expression
267 /// touches so existing planner shortcuts that resolve a
268 /// column position stay valid. `None` = plain
269 /// column-reference index (the legacy shape).
270 pub expression: Option<Expr>,
271 /// v7.9.14 — extra column names after the leading column in a
272 /// multi-column `CREATE INDEX … (a, b, c)`. mailrs F2. The
273 /// planner today still only uses the leading column for index
274 /// seeks; the extras are tracked verbatim so the same DDL
275 /// round-trips through WAL replay + catalog snapshot, and so
276 /// the engine can emit a clear warning at INDEX CREATE time
277 /// that only the leading column is currently honoured.
278 /// Composite BTree index keys land in v7.10.
279 pub extra_columns: Vec<String>,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum IndexMethod {
284 /// Default — B-tree over `IndexKey`. Used for equality / range
285 /// lookups on scalar columns.
286 BTree,
287 /// `USING hnsw` — NSW graph for kNN over a vector column.
288 Hnsw,
289 /// v6.7.1 — `USING brin` — Block Range INdex. Per-segment
290 /// metadata that records (min_key, max_key) for each page in a
291 /// cold-tier segment, on the indexed column. The optimizer
292 /// can use these summaries to skip pages whose range does NOT
293 /// overlap a query's WHERE predicate. BRIN indexes carry no
294 /// in-memory data — the summaries live in the segment v2
295 /// envelope's sidecar. Created via the standard
296 /// `CREATE INDEX … USING brin (col)` syntax.
297 Brin,
298}
299
300#[derive(Debug, Clone, PartialEq)]
301pub struct CreateTableStatement {
302 pub name: String,
303 pub columns: Vec<ColumnDef>,
304 /// `IF NOT EXISTS` — engine returns `CommandOk` no-op when the
305 /// table name already exists, instead of raising `DuplicateTable`.
306 pub if_not_exists: bool,
307 /// v7.6.0 — table-level `FOREIGN KEY (...) REFERENCES ...`
308 /// constraints. Column-level `REFERENCES` (single-column inline
309 /// form) is normalised into this vec at parse time so the engine
310 /// sees one uniform list.
311 pub foreign_keys: Vec<ForeignKeyConstraint>,
312 /// v7.9.18 — table-level constraints: `PRIMARY KEY (a, b)` and
313 /// `UNIQUE (a, b, ...)`. mailrs migration follow-up G1 + G6.
314 /// Engine resolves each into a BTree index named after the
315 /// constraint's leading column at CREATE TABLE time; INSERT
316 /// path enforces composite uniqueness via row scan on the
317 /// leading column index.
318 pub table_constraints: Vec<TableConstraint>,
319}
320
321/// v7.9.18 — table-level constraint at the end of a CREATE TABLE
322/// column list. Either a composite PRIMARY KEY or a UNIQUE
323/// (single- or multi-column).
324#[derive(Debug, Clone, PartialEq)]
325pub enum TableConstraint {
326 /// `PRIMARY KEY (col1, col2, ...)`. Implies NOT NULL on each
327 /// referenced column. Engine builds a BTree index named
328 /// `<table>_pkey` and enforces composite uniqueness on INSERT.
329 PrimaryKey {
330 name: Option<String>,
331 columns: Vec<String>,
332 },
333 /// `UNIQUE (col1, col2, ...)`. Engine builds a BTree index
334 /// named `<table>_<leading_col>_key` (single-column) or
335 /// `<table>_<leading_col>_<…>_key` (composite) and enforces
336 /// uniqueness on INSERT.
337 Unique {
338 name: Option<String>,
339 columns: Vec<String>,
340 },
341}
342
343#[derive(Debug, Clone, PartialEq)]
344pub struct ColumnDef {
345 pub name: String,
346 pub ty: ColumnTypeName,
347 pub nullable: bool,
348 /// `DEFAULT <expr>` literal supplied at CREATE TABLE. Engine
349 /// evaluates this once (with an empty row) and caches the resulting
350 /// `Value` on the column schema.
351 pub default: Option<Expr>,
352 /// MySQL-style `AUTO_INCREMENT` — the engine maintains a counter
353 /// per such column and fills the slot when INSERT leaves it
354 /// unbound (omitted from a column-list INSERT or explicitly NULL).
355 pub auto_increment: bool,
356 /// v7.9.13 — inline `PRIMARY KEY` column constraint. mailrs
357 /// migration follow-up F1. Implies `NOT NULL`. Engine creates
358 /// an implicit BTree index named `<table>_pkey` over this
359 /// column at CREATE TABLE time, satisfying the parent-side
360 /// index requirement for any FOREIGN KEY pointing at it.
361 pub is_primary_key: bool,
362}
363
364/// v7.6.0 — A single FOREIGN KEY constraint. Both column-level
365/// `REFERENCES` and table-level `FOREIGN KEY (...) REFERENCES ...`
366/// parse into this shape — the column-level form has a single-entry
367/// `columns` / `parent_columns`.
368#[derive(Debug, Clone, PartialEq)]
369pub struct ForeignKeyConstraint {
370 /// Optional `CONSTRAINT <name>` prefix. Engine ignores the name
371 /// today but parses + stores it so a future ALTER TABLE DROP
372 /// CONSTRAINT can target by name (v7.6.8).
373 pub name: Option<String>,
374 /// Local columns participating in the FK (≥ 1).
375 pub columns: Vec<String>,
376 /// Referenced parent table.
377 pub parent_table: String,
378 /// Referenced parent columns. Must have the same arity as
379 /// `columns`; engine validates parent has a PK / UNIQUE index
380 /// on exactly this column set (v7.6.1).
381 pub parent_columns: Vec<String>,
382 /// `ON DELETE` action. Defaults to `Restrict` if absent.
383 pub on_delete: FkAction,
384 /// `ON UPDATE` action. Defaults to `Restrict` if absent.
385 pub on_update: FkAction,
386}
387
388/// v7.6.0 — Referential action for `ON DELETE` / `ON UPDATE`.
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub enum FkAction {
391 /// Reject the parent mutation if any child row references it.
392 /// SQL spec default; SPG default when no clause is given.
393 Restrict,
394 /// Recursively propagate the parent's delete / update to the
395 /// child rows. Same TX.
396 Cascade,
397 /// Set the child FK column(s) to NULL. Requires the FK columns
398 /// to be NULL-able.
399 SetNull,
400 /// Set the child FK column(s) to their declared DEFAULT.
401 /// Requires the child column(s) to have DEFAULT.
402 SetDefault,
403 /// SQL spec `NO ACTION` (deferred check). SPG treats this as
404 /// `Restrict` because the single-writer model has no deferred
405 /// constraint window; the keyword is accepted for compatibility.
406 NoAction,
407}
408
409/// In-cell encoding for a `VECTOR(N)` column. v6.0.1 added the
410/// optional `USING <encoding>` clause; omitting it keeps the
411/// pre-v6 `F32` default. `Sq8` quantises each cell to a per-vector
412/// affine `(min, max, [u8; dim])` triple (4× compression). `F16`
413/// (v6.0.3, DDL keyword `HALF`) stores each element as IEEE-754
414/// binary16 (2× compression, ~3 decimal digits of precision).
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
416pub enum VecEncoding {
417 /// IEEE-754 binary32. Pre-v6 default; matches pgvector's
418 /// uncompressed `vector` type wire / storage layout.
419 #[default]
420 F32,
421 /// v6.0.1 SQ8 — per-vector affine 8-bit quantisation. See
422 /// `spg_storage::quantize::Sq8Vector` for the math + recall
423 /// envelope (≥ 0.95 on Gaussian / unit-sphere corpora at
424 /// dim ≥ 32).
425 Sq8,
426 /// v6.0.3 halfvec — IEEE-754 binary16 (half-precision)
427 /// per-element. DDL keyword `HALF` (pgvector convention).
428 /// Bit-exact dequantise to f32 at the storage layer; no
429 /// rerank pass needed for kNN search.
430 F16,
431}
432
433impl fmt::Display for VecEncoding {
434 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435 match self {
436 Self::F32 => f.write_str("F32"),
437 Self::Sq8 => f.write_str("SQ8"),
438 // pgvector convention: DDL keyword is `HALF`, not `F16`.
439 Self::F16 => f.write_str("HALF"),
440 }
441 }
442}
443
444/// SQL-level type names. The mapping to the storage runtime's `DataType`
445/// happens in `spg-engine` — keeping `spg-sql` free of storage deps.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum ColumnTypeName {
448 SmallInt,
449 Int,
450 BigInt,
451 Float,
452 Text,
453 /// `VARCHAR(N)` — TEXT capped at N Unicode characters.
454 Varchar(u32),
455 /// `CHAR(N)` — TEXT right-padded with spaces to exactly N characters.
456 Char(u32),
457 Bool,
458 /// pgvector fixed-dimension `VECTOR(N)`. v6.0.1 added the
459 /// `USING <encoding>` clause; omitting it surfaces as
460 /// `encoding = VecEncoding::F32` (the pre-v6 default).
461 Vector {
462 dim: u32,
463 encoding: VecEncoding,
464 },
465 /// `NUMERIC` / `NUMERIC(p)` / `NUMERIC(p, s)` — exact decimal.
466 /// Bare `NUMERIC` and `NUMERIC(p)` both surface with `scale=0`.
467 Numeric(u8, u8),
468 /// `DATE` — calendar day, no time-of-day component.
469 Date,
470 /// `TIMESTAMP` / `MySQL` `DATETIME` — instant with microsecond
471 /// precision.
472 Timestamp,
473 /// v7.9.2 `TIMESTAMPTZ` / `TIMESTAMP WITH TIME ZONE`. SPG
474 /// stores all timestamps as UTC microseconds-since-epoch and
475 /// does not carry per-row offset (PG's internal representation
476 /// is the same — TZ is a display convention). The distinction
477 /// from `TIMESTAMP` exists for the PG-wire layer to advertise
478 /// OID 1184 so sqlx-style clients decode into
479 /// `chrono::DateTime<Utc>` instead of `NaiveDateTime`.
480 Timestamptz,
481 /// v4.9 `JSON` — text-backed JSON document. No parse-time
482 /// validation; the engine round-trips the literal verbatim.
483 /// PG OID 114 on the wire.
484 Json,
485 /// v7.9.0 `JSONB` — same storage shape as Json, advertised as
486 /// PG OID 3802 on the wire so sqlx-style binary-typed clients
487 /// decode without a custom type registration.
488 Jsonb,
489}
490
491impl fmt::Display for ColumnTypeName {
492 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493 match self {
494 Self::SmallInt => f.write_str("SMALLINT"),
495 Self::Int => f.write_str("INT"),
496 Self::BigInt => f.write_str("BIGINT"),
497 Self::Float => f.write_str("FLOAT"),
498 Self::Text => f.write_str("TEXT"),
499 Self::Varchar(n) => write!(f, "VARCHAR({n})"),
500 Self::Char(n) => write!(f, "CHAR({n})"),
501 Self::Bool => f.write_str("BOOL"),
502 Self::Vector { dim, encoding } => match encoding {
503 VecEncoding::F32 => write!(f, "VECTOR({dim})"),
504 VecEncoding::Sq8 => write!(f, "VECTOR({dim}) USING SQ8"),
505 VecEncoding::F16 => write!(f, "VECTOR({dim}) USING HALF"),
506 },
507 Self::Json => f.write_str("JSON"),
508 Self::Jsonb => f.write_str("JSONB"),
509 Self::Numeric(p, s) => {
510 if *s == 0 {
511 write!(f, "NUMERIC({p})")
512 } else {
513 write!(f, "NUMERIC({p}, {s})")
514 }
515 }
516 Self::Date => f.write_str("DATE"),
517 Self::Timestamp => f.write_str("TIMESTAMP"),
518 Self::Timestamptz => f.write_str("TIMESTAMPTZ"),
519 }
520 }
521}
522
523/// `UPDATE <table> SET col = expr [, ...] [WHERE cond]`. v4.4 — the
524/// engine evaluates `expr` per matched row in the table's row order
525/// and rewrites cells in place. Indexed columns are dropped + re-
526/// inserted into the affected B-tree on each row change.
527#[derive(Debug, Clone, PartialEq)]
528pub struct UpdateStatement {
529 pub table: String,
530 pub assignments: Vec<(String, Expr)>,
531 pub where_: Option<Expr>,
532 /// v7.9.4 — `RETURNING <projection>`. None = no RETURNING
533 /// clause (legacy CommandComplete path). Some = engine
534 /// evaluates the projection over each mutated row and
535 /// streams the result as a Rows QueryResult.
536 pub returning: Option<Vec<SelectItem>>,
537}
538
539/// `DELETE FROM <table> [WHERE cond]`. v4.4 — removes matched rows
540/// from the active catalog and prunes them from every index.
541#[derive(Debug, Clone, PartialEq)]
542pub struct DeleteStatement {
543 pub table: String,
544 pub where_: Option<Expr>,
545 /// v7.9.4 — `RETURNING <projection>`.
546 pub returning: Option<Vec<SelectItem>>,
547}
548
549#[derive(Debug, Clone, PartialEq)]
550pub struct InsertStatement {
551 pub table: String,
552 /// Optional column list — `INSERT INTO t (a, b) VALUES (...)`. When
553 /// `None`, every tuple is positional and must match the table arity.
554 /// When `Some`, the engine maps each tuple slot to the named column and
555 /// fills the rest with NULL (must be nullable).
556 pub columns: Option<Vec<String>>,
557 /// One or more `(expr, expr, ...)` tuples — the multi-row VALUES form.
558 /// v1.3+ accepts `INSERT INTO t VALUES (a), (b)`.
559 pub rows: Vec<Vec<Expr>>,
560 /// v7.9.7 — `ON CONFLICT (cols) DO { NOTHING | UPDATE SET … }`
561 /// upsert clause. None = legacy INSERT (conflict raises a
562 /// DuplicateKey error). mailrs migration blocker #2.
563 pub on_conflict: Option<OnConflictClause>,
564 /// v7.9.4 — `RETURNING <projection>`.
565 pub returning: Option<Vec<SelectItem>>,
566}
567
568/// v7.9.7 — INSERT upsert clause: `ON CONFLICT (target) DO action`.
569#[derive(Debug, Clone, PartialEq)]
570pub struct OnConflictClause {
571 /// Local columns that identify the conflict (must match a
572 /// UNIQUE / PRIMARY KEY index on the target table). Empty
573 /// list means the user wrote `ON CONFLICT DO …` without a
574 /// target — engine picks the table's first BTree index by
575 /// convention.
576 pub target_columns: Vec<String>,
577 /// The action on conflict.
578 pub action: OnConflictAction,
579}
580
581/// v7.9.7 — action on conflict.
582#[derive(Debug, Clone, PartialEq)]
583pub enum OnConflictAction {
584 /// `DO NOTHING` — INSERT proceeds for non-conflicting rows,
585 /// silently skips conflicting ones.
586 Nothing,
587 /// `DO UPDATE SET col = expr [, …] [WHERE cond]`. `assignments`
588 /// may reference `EXCLUDED.col` to read the incoming row's
589 /// value (engine wires `EXCLUDED` as a virtual table).
590 Update {
591 assignments: Vec<(String, Expr)>,
592 where_: Option<Expr>,
593 },
594}
595
596#[derive(Debug, Clone, PartialEq)]
597pub struct SelectStatement {
598 /// v4.11: `WITH name AS (SELECT ...) [, ...]` common-table
599 /// expressions, materialised once at query start before the
600 /// body SELECT runs. Empty for a regular SELECT. Non-recursive
601 /// only — no `WITH RECURSIVE` for v4.x.
602 pub ctes: Vec<Cte>,
603 pub distinct: bool,
604 pub items: Vec<SelectItem>,
605 pub from: Option<FromClause>,
606 pub where_: Option<Expr>,
607 pub group_by: Option<Vec<Expr>>,
608 /// v6.4.1 — `GROUP BY ALL` shortcut: when true, the planner
609 /// expands `group_by` to every non-aggregate SELECT-list item
610 /// before the executor runs. Mutually exclusive with an
611 /// explicit `group_by` list (the parser sets exactly one).
612 pub group_by_all: bool,
613 /// `HAVING <expr>` — filter applied *after* `GROUP BY` aggregation.
614 /// Supports aggregate calls (e.g. `HAVING count(*) > 1`); the
615 /// aggregate executor resolves them through the same synthetic
616 /// schema used for the SELECT items.
617 pub having: Option<Expr>,
618 /// UNION / UNION ALL chain. Empty for a plain SELECT. Each peer is
619 /// itself a `SelectStatement` with `order_by = None` and `limit =
620 /// None` (the parser enforces that — ORDER BY / LIMIT belong to the
621 /// top of the chain).
622 pub unions: Vec<(UnionKind, SelectStatement)>,
623 /// v6.4.0 — multi-key ORDER BY. Empty `Vec` means no ORDER BY.
624 /// Keys are matched left-to-right: first key decides, ties break
625 /// to the second, etc.
626 pub order_by: Vec<OrderBy>,
627 /// `LIMIT <n>` — bound on row output. `n` is an integer
628 /// literal **or** (v7.9.24) a placeholder `$N` resolved
629 /// against the prepared-statement Bind values. mailrs
630 /// migration follow-up H2.
631 pub limit: Option<LimitExpr>,
632 /// `OFFSET <n>` — drop the first `n` rows after ORDER BY but
633 /// before LIMIT (so `LIMIT 10 OFFSET 5` keeps rows 6..=15).
634 pub offset: Option<LimitExpr>,
635}
636
637/// v7.9.24 — LIMIT / OFFSET value. Integer literal at parse
638/// time or a placeholder `$N` resolved during extended-query
639/// Bind. mailrs migration follow-up H2.
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub enum LimitExpr {
642 /// `LIMIT 10` — value known at parse time.
643 Literal(u32),
644 /// `LIMIT $N` — the 1-based parameter index, resolved against
645 /// the bind values when the prepared statement executes.
646 Placeholder(u16),
647}
648
649impl fmt::Display for LimitExpr {
650 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
651 match self {
652 Self::Literal(n) => write!(f, "{n}"),
653 Self::Placeholder(n) => write!(f, "${n}"),
654 }
655 }
656}
657
658impl LimitExpr {
659 /// Convenience for the simple-query path where no placeholders
660 /// can possibly exist. Returns the literal value or `None` if
661 /// this is a placeholder (caller must surface as Unsupported).
662 pub fn as_literal(self) -> Option<u32> {
663 match self {
664 Self::Literal(n) => Some(n),
665 Self::Placeholder(_) => None,
666 }
667 }
668}
669
670/// v7.9.24 — extract LIMIT / OFFSET as a `u32` literal. After
671/// the engine's `substitute_placeholders` pass these are
672/// always Literal; in the simple-query path a Placeholder
673/// shape returns None (executor surfaces as
674/// "LIMIT/OFFSET ${n} requires prepared-statement binding").
675impl SelectStatement {
676 #[must_use]
677 pub fn limit_literal(&self) -> Option<u32> {
678 self.limit.and_then(LimitExpr::as_literal)
679 }
680 #[must_use]
681 pub fn offset_literal(&self) -> Option<u32> {
682 self.offset.and_then(LimitExpr::as_literal)
683 }
684}
685
686#[derive(Debug, Clone, PartialEq)]
687pub struct Cte {
688 pub name: String,
689 pub body: SelectStatement,
690 /// v4.22: `WITH RECURSIVE` — set when the WITH clause had the
691 /// RECURSIVE keyword. Applies to every CTE in the clause per
692 /// PG semantics. A non-recursive body in a RECURSIVE WITH is
693 /// allowed; the engine just runs it once.
694 pub recursive: bool,
695 /// v4.22: optional `WITH name(a, b, c)` column-name list. When
696 /// non-empty, these override the body's output column names
697 /// position-by-position; the engine errors out if the count
698 /// doesn't match the body's projection width.
699 pub column_overrides: Vec<String>,
700}
701
702#[derive(Debug, Clone, PartialEq)]
703pub struct OrderBy {
704 pub expr: Expr,
705 /// `false` = ASC (default), `true` = DESC.
706 pub desc: bool,
707}
708
709#[derive(Debug, Clone, Copy, PartialEq, Eq)]
710pub enum UnionKind {
711 /// `UNION` — dedupes the combined set.
712 Distinct,
713 /// `UNION ALL` — concatenates without dedup.
714 All,
715}
716
717#[derive(Debug, Clone, PartialEq)]
718pub enum SelectItem {
719 Wildcard,
720 Expr { expr: Expr, alias: Option<String> },
721}
722
723#[derive(Debug, Clone, PartialEq)]
724pub struct TableRef {
725 pub name: String,
726 pub alias: Option<String>,
727 /// v6.10.2 — `AS OF SEGMENT '<id>'` cold-tier time-travel.
728 /// When `Some(id)`, the scan restricts to rows that live in
729 /// segment `<id>` only — useful for forensic inspection of a
730 /// specific freezer-emitted segment without exposing the hot
731 /// tier. `AS OF TIMESTAMP <ts>` (PG-flavoured time travel)
732 /// is STABILITY carve-out for v6.10 — needs the freezer to
733 /// stamp each segment with a wall-clock at creation time.
734 pub as_of_segment: Option<u32>,
735}
736
737/// FROM clause shape. v1.10 accepts a primary table plus a flat list of
738/// joined peers — `FROM a [, b]* [INNER|LEFT] JOIN c ON expr ...`. The
739/// joins evaluate left-associatively in nested-loop order.
740#[derive(Debug, Clone, PartialEq)]
741pub struct FromClause {
742 pub primary: TableRef,
743 pub joins: Vec<FromJoin>,
744}
745
746#[derive(Debug, Clone, PartialEq)]
747pub struct FromJoin {
748 pub kind: JoinKind,
749 pub table: TableRef,
750 /// Required for INNER/LEFT; must be `None` for CROSS / comma-list.
751 pub on: Option<Expr>,
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755pub enum JoinKind {
756 Inner,
757 Left,
758 Cross,
759}
760
761#[derive(Debug, Clone, PartialEq)]
762pub enum Expr {
763 Literal(Literal),
764 Column(ColumnName),
765 /// v6.1.1 — `$N` parameter placeholder for the extended query
766 /// protocol. The number is 1-based per PostgreSQL convention.
767 /// Evaluation looks up `params[N-1]` from the prepared-statement
768 /// bind buffer; out-of-range indices raise a runtime error
769 /// (same shape as a column-not-found miss).
770 Placeholder(u16),
771 Binary {
772 lhs: Box<Expr>,
773 op: BinOp,
774 rhs: Box<Expr>,
775 },
776 Unary {
777 op: UnOp,
778 expr: Box<Expr>,
779 },
780 /// PG-style `expr::TYPE` cast. v1.3 supports VECTOR, INT, BIGINT, FLOAT,
781 /// TEXT, BOOL targets; engine coerces at evaluation time.
782 Cast {
783 expr: Box<Expr>,
784 target: CastTarget,
785 },
786 /// Postfix `IS NULL` / `IS NOT NULL`. Returns BOOL.
787 IsNull {
788 expr: Box<Expr>,
789 negated: bool,
790 },
791 /// Function call `name(args...)`. v1.4 supports a small built-in set
792 /// (length, upper, lower, abs, coalesce); unknown names error at eval
793 /// time so the parser stays open for v1.5 aggregates.
794 FunctionCall {
795 name: String,
796 args: Vec<Expr>,
797 },
798 /// SQL `LIKE` predicate. `pattern` evaluates to text at runtime;
799 /// wildcards are `%` (any run) and `_` (one char), backslash escapes
800 /// the next char (so `\%` matches a literal `%`).
801 Like {
802 expr: Box<Expr>,
803 pattern: Box<Expr>,
804 negated: bool,
805 },
806 /// v4.12 window function call: `name(args) OVER (PARTITION BY
807 /// ... ORDER BY ...)`. Supports `ROW_NUMBER` / `RANK` /
808 /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
809 /// `AVG` / `COUNT` / `MIN` / `MAX`. The window frame defaults to "entire partition" for
810 /// unordered windows and "from start of partition through
811 /// current row" for ordered windows — no explicit ROWS /
812 /// RANGE clause in v4.12 MVP.
813 WindowFunction {
814 name: String,
815 args: Vec<Expr>,
816 partition_by: Vec<Expr>,
817 order_by: Vec<(Expr, bool /* desc */)>,
818 /// v4.20 explicit frame. `None` means "use the default":
819 /// whole-partition when unordered, running aggregate from
820 /// partition start through current row when ordered.
821 frame: Option<WindowFrame>,
822 /// v6.4.2 — `IGNORE NULLS` / `RESPECT NULLS` modifier on
823 /// LAG / LEAD / FIRST_VALUE / LAST_VALUE. Default is
824 /// `Respect` (PG / ANSI default — NULLs participate). Other
825 /// window functions ignore this flag.
826 null_treatment: NullTreatment,
827 },
828 /// v4.10 scalar subquery — `(SELECT ...)` used in expression
829 /// position. Must return exactly one row × one column at eval
830 /// time; the engine errors out otherwise. Uncorrelated only —
831 /// the inner SELECT cannot reference outer columns.
832 ScalarSubquery(Box<SelectStatement>),
833 /// v4.10 `[NOT] EXISTS (SELECT ...)`. Returns Bool. Inner
834 /// projection is ignored; only row-count matters.
835 Exists {
836 subquery: Box<SelectStatement>,
837 negated: bool,
838 },
839 /// v4.10 `expr [NOT] IN (SELECT ...)`. Inner SELECT must
840 /// project exactly one column; membership is tested by Eq
841 /// against each row's value (NULL handling follows ANSI:
842 /// NULL ∈ list ⇒ NULL ; otherwise present ⇒ true).
843 InSubquery {
844 expr: Box<Expr>,
845 subquery: Box<SelectStatement>,
846 negated: bool,
847 },
848 /// `EXTRACT(<field> FROM <source>)` — pull an integer component
849 /// out of a `DATE` or `TIMESTAMP`. Parsed as its own AST node
850 /// because the `FROM` keyword is what separates the two halves,
851 /// not a comma.
852 Extract {
853 field: ExtractField,
854 source: Box<Expr>,
855 },
856}
857
858/// v6.4.2 — null treatment on `LAG` / `LEAD` / `FIRST_VALUE` /
859/// `LAST_VALUE`. PG / ANSI default is `Respect` — NULLs participate
860/// in the offset walk. `Ignore` causes the function to skip NULL
861/// values in the argument expression, returning the next non-NULL.
862#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
863pub enum NullTreatment {
864 #[default]
865 Respect,
866 Ignore,
867}
868
869/// v4.20 explicit window frame: `ROWS|RANGE BETWEEN <bound> AND
870/// <bound>`. `end` is `None` for the shorthand "ROWS <bound>"
871/// where end implicitly = CURRENT ROW.
872#[derive(Debug, Clone, PartialEq, Eq)]
873pub struct WindowFrame {
874 pub kind: FrameKind,
875 pub start: FrameBound,
876 pub end: Option<FrameBound>,
877}
878
879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
880pub enum FrameKind {
881 Rows,
882 Range,
883}
884
885#[derive(Debug, Clone, PartialEq, Eq)]
886pub enum FrameBound {
887 UnboundedPreceding,
888 OffsetPreceding(u64),
889 CurrentRow,
890 OffsetFollowing(u64),
891 UnboundedFollowing,
892}
893
894impl fmt::Display for FrameBound {
895 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
896 match self {
897 Self::UnboundedPreceding => f.write_str("UNBOUNDED PRECEDING"),
898 Self::OffsetPreceding(n) => write!(f, "{n} PRECEDING"),
899 Self::CurrentRow => f.write_str("CURRENT ROW"),
900 Self::OffsetFollowing(n) => write!(f, "{n} FOLLOWING"),
901 Self::UnboundedFollowing => f.write_str("UNBOUNDED FOLLOWING"),
902 }
903 }
904}
905
906#[derive(Debug, Clone, Copy, PartialEq, Eq)]
907pub enum ExtractField {
908 Year,
909 Month,
910 Day,
911 Hour,
912 Minute,
913 Second,
914 Microsecond,
915}
916
917impl fmt::Display for ExtractField {
918 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
919 f.write_str(match self {
920 Self::Year => "YEAR",
921 Self::Month => "MONTH",
922 Self::Day => "DAY",
923 Self::Hour => "HOUR",
924 Self::Minute => "MINUTE",
925 Self::Second => "SECOND",
926 Self::Microsecond => "MICROSECOND",
927 })
928 }
929}
930
931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub enum CastTarget {
933 Int,
934 BigInt,
935 Float,
936 Text,
937 Bool,
938 Vector,
939 Date,
940 Timestamp,
941 /// v7.9.25 — `::INTERVAL` and `::TIMESTAMPTZ`. mailrs follow-up
942 /// H3a. Engine reuses the existing runtime-interval / timestamp
943 /// paths (parse the text input, return the matching Value).
944 Interval,
945 Timestamptz,
946 /// v7.9.25 — `::JSON` and `::JSONB`. SPG already has both
947 /// types (v7.9.0); the cast just routes Text→Json with the
948 /// requested OID for the wire layer.
949 Json,
950 Jsonb,
951 /// v7.9.26 — `::regtype` / `::regclass`. Parsed for PG dump
952 /// compatibility; engine surfaces as Unsupported with a
953 /// hint to use `SHOW TABLES` or `spg_table_ddl`. mailrs F3b.
954 RegType,
955 RegClass,
956}
957
958impl fmt::Display for CastTarget {
959 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
960 f.write_str(match self {
961 Self::Int => "int",
962 Self::BigInt => "bigint",
963 Self::Float => "float",
964 Self::Text => "text",
965 Self::Bool => "bool",
966 Self::Vector => "vector",
967 Self::Interval => "interval",
968 Self::Timestamptz => "timestamptz",
969 Self::Json => "json",
970 Self::Jsonb => "jsonb",
971 Self::RegType => "regtype",
972 Self::RegClass => "regclass",
973 Self::Date => "date",
974 Self::Timestamp => "timestamp",
975 })
976 }
977}
978
979#[derive(Debug, Clone, PartialEq)]
980pub enum Literal {
981 Integer(i64),
982 Float(f64),
983 String(String),
984 Bool(bool),
985 Null,
986 /// pgvector-style array literal, e.g. `[1, 2.5, -3]`.
987 Vector(Vec<f32>),
988 /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — calendar-aware span.
989 /// Split into a months part (because a month is not a fixed number of
990 /// days) and a microseconds part (everything sub-month). `text` keeps
991 /// the original spelling so Display round-trips byte-for-byte.
992 Interval {
993 months: i32,
994 micros: i64,
995 text: String,
996 },
997}
998
999#[derive(Debug, Clone, PartialEq, Eq)]
1000pub struct ColumnName {
1001 pub qualifier: Option<String>,
1002 pub name: String,
1003}
1004
1005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1006pub enum BinOp {
1007 Or,
1008 And,
1009 Eq,
1010 NotEq,
1011 /// v7.9.27b — PG `a IS DISTINCT FROM b` / `a IS NOT DISTINCT
1012 /// FROM b`. NULL-safe equality: NULL IS NOT DISTINCT FROM
1013 /// NULL → true, NULL IS DISTINCT FROM NULL → false. The
1014 /// non-NULL behaviour matches `<>` / `=` exactly. Common in
1015 /// PG-style JOIN ON predicates and pg_dump output.
1016 IsDistinctFrom,
1017 IsNotDistinctFrom,
1018 Lt,
1019 LtEq,
1020 Gt,
1021 GtEq,
1022 Add,
1023 Sub,
1024 Mul,
1025 Div,
1026 /// pgvector L2 (Euclidean) distance `<->`. Defined for two vector
1027 /// operands of equal dimension; engine returns `Value::Float(d)`.
1028 L2Distance,
1029 /// pgvector inner-product `<#>` — returns `-Σ aᵢ bᵢ` so "smaller =
1030 /// more similar" remains true (matches pgvector's published convention).
1031 InnerProduct,
1032 /// pgvector cosine distance `<=>` — `1 - (a·b)/(|a| |b|)`.
1033 CosineDistance,
1034 /// SQL string concatenation `||`. NULL propagates.
1035 Concat,
1036 /// v4.14 `json -> key` — element access by string key (object)
1037 /// or integer index (array). Returns a JSON value.
1038 JsonGet,
1039 /// v4.14 `json ->> key` — same access, returns the result as
1040 /// TEXT (unwraps a top-level JSON string; renders other scalars
1041 /// as their canonical text).
1042 JsonGetText,
1043 /// v6.4.5 `json #> path_text` — walk the path encoded as a PG
1044 /// text array literal like `'{a,0,b}'`. Returns JSON.
1045 JsonGetPath,
1046 /// v6.4.5 `json #>> path_text` — same walk, returns TEXT.
1047 JsonGetPathText,
1048 /// v6.4.5 `json @> sub_json` — containment. Returns BOOL; true
1049 /// when every key/value in `sub_json` is structurally present in
1050 /// the left side. Matches PG semantics (top-level + recursive).
1051 JsonContains,
1052}
1053
1054#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1055pub enum UnOp {
1056 Not,
1057 Neg,
1058}
1059
1060// --- Display impls (round-trip-safe) --------------------------------------
1061
1062impl fmt::Display for Statement {
1063 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1064 match self {
1065 Self::Select(s) => s.fmt(f),
1066 Self::CreateTable(s) => s.fmt(f),
1067 Self::CreateIndex(s) => s.fmt(f),
1068 Self::Insert(s) => s.fmt(f),
1069 Self::Update(s) => s.fmt(f),
1070 Self::Delete(s) => s.fmt(f),
1071 Self::Begin => f.write_str("BEGIN"),
1072 Self::Commit => f.write_str("COMMIT"),
1073 Self::Rollback => f.write_str("ROLLBACK"),
1074 Self::Savepoint(n) => write!(f, "SAVEPOINT {}", quote_ident(n)),
1075 Self::RollbackToSavepoint(n) => write!(f, "ROLLBACK TO SAVEPOINT {}", quote_ident(n)),
1076 Self::ReleaseSavepoint(n) => write!(f, "RELEASE SAVEPOINT {}", quote_ident(n)),
1077 Self::ShowTables => f.write_str("SHOW TABLES"),
1078 Self::ShowColumns(t) => write!(f, "SHOW COLUMNS FROM {}", quote_ident(t)),
1079 Self::CreateUser(s) => write!(
1080 f,
1081 "CREATE USER {} WITH PASSWORD '<redacted>' ROLE '{}'",
1082 quote_ident(&s.name),
1083 s.role
1084 ),
1085 Self::DropUser(n) => write!(f, "DROP USER {}", quote_ident(n)),
1086 Self::ShowUsers => f.write_str("SHOW USERS"),
1087 Self::ShowPublications => f.write_str("SHOW PUBLICATIONS"),
1088 Self::ShowSubscriptions => f.write_str("SHOW SUBSCRIPTIONS"),
1089 Self::CreateSubscription(s) => {
1090 write!(
1091 f,
1092 "CREATE SUBSCRIPTION {} CONNECTION '{}' PUBLICATION ",
1093 quote_ident(&s.name),
1094 s.conn_str.replace('\'', "''")
1095 )?;
1096 for (i, p) in s.publications.iter().enumerate() {
1097 if i > 0 {
1098 f.write_str(", ")?;
1099 }
1100 write!(f, "{}", quote_ident(p))?;
1101 }
1102 Ok(())
1103 }
1104 Self::DropSubscription(name) => {
1105 write!(f, "DROP SUBSCRIPTION {}", quote_ident(name))
1106 }
1107 Self::WaitForWalPosition { pos, timeout_ms } => {
1108 write!(f, "WAIT FOR WAL POSITION {pos}")?;
1109 if let Some(ms) = timeout_ms {
1110 write!(f, " WITH TIMEOUT {ms}")?;
1111 }
1112 Ok(())
1113 }
1114 Self::Analyze(None) => f.write_str("ANALYZE"),
1115 Self::Analyze(Some(t)) => write!(f, "ANALYZE {}", quote_ident(t)),
1116 Self::CompactColdSegments => f.write_str("COMPACT COLD SEGMENTS"),
1117 Self::Explain(e) => {
1118 if e.suggest {
1119 write!(f, "EXPLAIN (SUGGEST) {}", e.inner)
1120 } else if e.analyze {
1121 write!(f, "EXPLAIN ANALYZE {}", e.inner)
1122 } else {
1123 write!(f, "EXPLAIN {}", e.inner)
1124 }
1125 }
1126 Self::AlterIndex(a) => {
1127 write!(f, "ALTER INDEX {} ", quote_ident(&a.name))?;
1128 match a.target {
1129 AlterIndexTarget::Rebuild { encoding } => {
1130 f.write_str("REBUILD")?;
1131 if let Some(enc) = encoding {
1132 write!(f, " WITH (encoding = {enc})")?;
1133 }
1134 Ok(())
1135 }
1136 }
1137 }
1138 Self::AlterTable(a) => {
1139 write!(f, "ALTER TABLE {} ", quote_ident(&a.name))?;
1140 match &a.target {
1141 AlterTableTarget::SetHotTierBytes(n) => {
1142 write!(f, "SET hot_tier_bytes = {n}")
1143 }
1144 AlterTableTarget::AddForeignKey(fk) => write!(f, "ADD {fk}"),
1145 AlterTableTarget::DropForeignKey(name) => {
1146 write!(f, "DROP CONSTRAINT {}", quote_ident(name))
1147 }
1148 }
1149 }
1150 Self::CreatePublication(p) => {
1151 write!(f, "CREATE PUBLICATION {}", quote_ident(&p.name))?;
1152 match &p.scope {
1153 PublicationScope::AllTables => f.write_str(" FOR ALL TABLES"),
1154 PublicationScope::ForTables(ts) => {
1155 f.write_str(" FOR TABLE ")?;
1156 for (i, t) in ts.iter().enumerate() {
1157 if i > 0 {
1158 f.write_str(", ")?;
1159 }
1160 write!(f, "{}", quote_ident(t))?;
1161 }
1162 Ok(())
1163 }
1164 PublicationScope::AllTablesExcept(ts) => {
1165 f.write_str(" FOR ALL TABLES EXCEPT ")?;
1166 for (i, t) in ts.iter().enumerate() {
1167 if i > 0 {
1168 f.write_str(", ")?;
1169 }
1170 write!(f, "{}", quote_ident(t))?;
1171 }
1172 Ok(())
1173 }
1174 }
1175 }
1176 Self::CreateExtension(name) => {
1177 write!(f, "CREATE EXTENSION IF NOT EXISTS {}", quote_ident(name))
1178 }
1179 Self::DoBlock => f.write_str("DO $$ /* SPG no-op */ $$"),
1180 Self::DropPublication(name) => {
1181 write!(f, "DROP PUBLICATION {}", quote_ident(name))
1182 }
1183 }
1184 }
1185}
1186
1187impl fmt::Display for CreateIndexStatement {
1188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1189 f.write_str("CREATE INDEX ")?;
1190 if self.if_not_exists {
1191 f.write_str("IF NOT EXISTS ")?;
1192 }
1193 write!(
1194 f,
1195 "{} ON {} ",
1196 quote_ident(&self.name),
1197 quote_ident(&self.table)
1198 )?;
1199 match self.method {
1200 IndexMethod::Hnsw => f.write_str("USING hnsw ")?,
1201 IndexMethod::Brin => f.write_str("USING brin ")?,
1202 IndexMethod::BTree => {}
1203 }
1204 if let Some(expr) = &self.expression {
1205 write!(f, "({})", expr)?;
1206 } else {
1207 write!(f, "({})", quote_ident(&self.column))?;
1208 }
1209 if !self.included_columns.is_empty() {
1210 f.write_str(" INCLUDE (")?;
1211 for (i, c) in self.included_columns.iter().enumerate() {
1212 if i > 0 {
1213 f.write_str(", ")?;
1214 }
1215 write!(f, "{}", quote_ident(c))?;
1216 }
1217 f.write_str(")")?;
1218 }
1219 if let Some(pred) = &self.partial_predicate {
1220 write!(f, " WHERE {}", pred)?;
1221 }
1222 Ok(())
1223 }
1224}
1225
1226impl fmt::Display for CreateTableStatement {
1227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1228 f.write_str("CREATE TABLE ")?;
1229 if self.if_not_exists {
1230 f.write_str("IF NOT EXISTS ")?;
1231 }
1232 write!(f, "{} (", quote_ident(&self.name))?;
1233 for (i, col) in self.columns.iter().enumerate() {
1234 if i > 0 {
1235 f.write_str(", ")?;
1236 }
1237 write!(f, "{col}")?;
1238 }
1239 // v7.6.0 — render FK constraints in table-level form, after
1240 // the column list. WAL replay round-trips through Display, so
1241 // every FK must serialise here for replay to reconstruct the
1242 // schema bit-for-bit.
1243 for fk in &self.foreign_keys {
1244 f.write_str(", ")?;
1245 write!(f, "{fk}")?;
1246 }
1247 f.write_str(")")
1248 }
1249}
1250
1251impl fmt::Display for ForeignKeyConstraint {
1252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1253 if let Some(name) = &self.name {
1254 write!(f, "CONSTRAINT {} ", quote_ident(name))?;
1255 }
1256 f.write_str("FOREIGN KEY (")?;
1257 for (i, c) in self.columns.iter().enumerate() {
1258 if i > 0 {
1259 f.write_str(", ")?;
1260 }
1261 f.write_str("e_ident(c))?;
1262 }
1263 write!(f, ") REFERENCES {}", quote_ident(&self.parent_table))?;
1264 if !self.parent_columns.is_empty() {
1265 f.write_str(" (")?;
1266 for (i, c) in self.parent_columns.iter().enumerate() {
1267 if i > 0 {
1268 f.write_str(", ")?;
1269 }
1270 f.write_str("e_ident(c))?;
1271 }
1272 f.write_str(")")?;
1273 }
1274 // Only render non-default actions to keep Display output
1275 // close to user input. SPG's default is RESTRICT (matches
1276 // SQL spec).
1277 if self.on_delete != FkAction::Restrict {
1278 write!(f, " ON DELETE {}", self.on_delete)?;
1279 }
1280 if self.on_update != FkAction::Restrict {
1281 write!(f, " ON UPDATE {}", self.on_update)?;
1282 }
1283 Ok(())
1284 }
1285}
1286
1287impl fmt::Display for FkAction {
1288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1289 match self {
1290 Self::Restrict => f.write_str("RESTRICT"),
1291 Self::Cascade => f.write_str("CASCADE"),
1292 Self::SetNull => f.write_str("SET NULL"),
1293 Self::SetDefault => f.write_str("SET DEFAULT"),
1294 Self::NoAction => f.write_str("NO ACTION"),
1295 }
1296 }
1297}
1298
1299impl fmt::Display for ColumnDef {
1300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1301 write!(f, "{} {}", quote_ident(&self.name), self.ty)?;
1302 if let Some(d) = &self.default {
1303 write!(f, " DEFAULT {d}")?;
1304 }
1305 if self.auto_increment {
1306 f.write_str(" AUTO_INCREMENT")?;
1307 }
1308 if !self.nullable {
1309 f.write_str(" NOT NULL")?;
1310 }
1311 Ok(())
1312 }
1313}
1314
1315impl fmt::Display for InsertStatement {
1316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1317 write!(f, "INSERT INTO {}", quote_ident(&self.table))?;
1318 if let Some(cols) = &self.columns {
1319 f.write_str(" (")?;
1320 for (i, c) in cols.iter().enumerate() {
1321 if i > 0 {
1322 f.write_str(", ")?;
1323 }
1324 f.write_str("e_ident(c))?;
1325 }
1326 f.write_str(")")?;
1327 }
1328 f.write_str(" VALUES ")?;
1329 for (ri, row) in self.rows.iter().enumerate() {
1330 if ri > 0 {
1331 f.write_str(", ")?;
1332 }
1333 f.write_str("(")?;
1334 for (i, v) in row.iter().enumerate() {
1335 if i > 0 {
1336 f.write_str(", ")?;
1337 }
1338 write!(f, "{v}")?;
1339 }
1340 f.write_str(")")?;
1341 }
1342 Ok(())
1343 }
1344}
1345
1346impl fmt::Display for UpdateStatement {
1347 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1348 write!(f, "UPDATE {} SET ", quote_ident(&self.table))?;
1349 for (i, (col, expr)) in self.assignments.iter().enumerate() {
1350 if i > 0 {
1351 f.write_str(", ")?;
1352 }
1353 write!(f, "{} = {expr}", quote_ident(col))?;
1354 }
1355 if let Some(w) = &self.where_ {
1356 write!(f, " WHERE {w}")?;
1357 }
1358 Ok(())
1359 }
1360}
1361
1362impl fmt::Display for DeleteStatement {
1363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1364 write!(f, "DELETE FROM {}", quote_ident(&self.table))?;
1365 if let Some(w) = &self.where_ {
1366 write!(f, " WHERE {w}")?;
1367 }
1368 Ok(())
1369 }
1370}
1371
1372impl fmt::Display for SelectStatement {
1373 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1374 write_bare_select(self, f)?;
1375 for (kind, peer) in &self.unions {
1376 f.write_str(match kind {
1377 UnionKind::Distinct => " UNION ",
1378 UnionKind::All => " UNION ALL ",
1379 })?;
1380 write_bare_select(peer, f)?;
1381 }
1382 if !self.order_by.is_empty() {
1383 f.write_str(" ORDER BY ")?;
1384 for (i, o) in self.order_by.iter().enumerate() {
1385 if i > 0 {
1386 f.write_str(", ")?;
1387 }
1388 write!(f, "{}", o.expr)?;
1389 if o.desc {
1390 f.write_str(" DESC")?;
1391 }
1392 }
1393 }
1394 if let Some(n) = &self.limit {
1395 write!(f, " LIMIT {n}")?;
1396 }
1397 if let Some(o) = &self.offset {
1398 write!(f, " OFFSET {o}")?;
1399 }
1400 Ok(())
1401 }
1402}
1403
1404fn write_bare_select(s: &SelectStatement, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1405 f.write_str("SELECT ")?;
1406 if s.distinct {
1407 f.write_str("DISTINCT ")?;
1408 }
1409 write_bare_select_body(s, f)
1410}
1411
1412fn write_bare_select_body(s: &SelectStatement, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1413 for (i, item) in s.items.iter().enumerate() {
1414 if i > 0 {
1415 f.write_str(", ")?;
1416 }
1417 write!(f, "{item}")?;
1418 }
1419 if let Some(t) = &s.from {
1420 write!(f, " FROM {t}")?;
1421 }
1422 if let Some(e) = &s.where_ {
1423 write!(f, " WHERE {e}")?;
1424 }
1425 if let Some(gs) = &s.group_by {
1426 f.write_str(" GROUP BY ")?;
1427 for (i, g) in gs.iter().enumerate() {
1428 if i > 0 {
1429 f.write_str(", ")?;
1430 }
1431 write!(f, "{g}")?;
1432 }
1433 }
1434 if let Some(h) = &s.having {
1435 write!(f, " HAVING {h}")?;
1436 }
1437 Ok(())
1438}
1439
1440impl fmt::Display for SelectItem {
1441 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1442 match self {
1443 Self::Wildcard => f.write_str("*"),
1444 Self::Expr { expr, alias } => {
1445 write!(f, "{expr}")?;
1446 if let Some(a) = alias {
1447 write!(f, " AS {}", quote_ident(a))?;
1448 }
1449 Ok(())
1450 }
1451 }
1452 }
1453}
1454
1455impl fmt::Display for FromClause {
1456 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1457 write!(f, "{}", self.primary)?;
1458 for j in &self.joins {
1459 match j.kind {
1460 JoinKind::Inner => write!(f, " INNER JOIN {}", j.table)?,
1461 JoinKind::Left => write!(f, " LEFT JOIN {}", j.table)?,
1462 JoinKind::Cross => write!(f, " CROSS JOIN {}", j.table)?,
1463 }
1464 if let Some(on) = &j.on {
1465 write!(f, " ON {on}")?;
1466 }
1467 }
1468 Ok(())
1469 }
1470}
1471
1472impl fmt::Display for TableRef {
1473 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1474 write!(f, "{}", quote_ident(&self.name))?;
1475 if let Some(a) = &self.alias {
1476 write!(f, " AS {}", quote_ident(a))?;
1477 }
1478 Ok(())
1479 }
1480}
1481
1482impl fmt::Display for ColumnName {
1483 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1484 if let Some(q) = &self.qualifier {
1485 write!(f, "{}.{}", quote_ident(q), quote_ident(&self.name))
1486 } else {
1487 write!(f, "{}", quote_ident(&self.name))
1488 }
1489 }
1490}
1491
1492impl fmt::Display for Expr {
1493 #[allow(clippy::too_many_lines)]
1494 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1495 match self {
1496 Self::Literal(l) => write!(f, "{l}"),
1497 Self::Column(c) => write!(f, "{c}"),
1498 Self::Placeholder(n) => write!(f, "${n}"),
1499 Self::Binary { lhs, op, rhs } => write!(f, "({lhs} {op} {rhs})"),
1500 Self::Unary { op, expr } => match op {
1501 UnOp::Not => write!(f, "(NOT {expr})"),
1502 UnOp::Neg => write!(f, "(-{expr})"),
1503 },
1504 Self::Cast { expr, target } => write!(f, "({expr}::{target})"),
1505 Self::IsNull { expr, negated } => {
1506 if *negated {
1507 write!(f, "({expr} IS NOT NULL)")
1508 } else {
1509 write!(f, "({expr} IS NULL)")
1510 }
1511 }
1512 Self::FunctionCall { name, args } => {
1513 write!(f, "{name}(")?;
1514 for (i, a) in args.iter().enumerate() {
1515 if i > 0 {
1516 f.write_str(", ")?;
1517 }
1518 write!(f, "{a}")?;
1519 }
1520 f.write_str(")")
1521 }
1522 Self::Like {
1523 expr,
1524 pattern,
1525 negated,
1526 } => {
1527 if *negated {
1528 write!(f, "({expr} NOT LIKE {pattern})")
1529 } else {
1530 write!(f, "({expr} LIKE {pattern})")
1531 }
1532 }
1533 Self::Extract { field, source } => write!(f, "EXTRACT({field} FROM {source})"),
1534 Self::WindowFunction {
1535 name,
1536 args,
1537 partition_by,
1538 order_by,
1539 frame,
1540 null_treatment: _,
1541 } => {
1542 write!(f, "{name}(")?;
1543 for (i, a) in args.iter().enumerate() {
1544 if i > 0 {
1545 f.write_str(", ")?;
1546 }
1547 write!(f, "{a}")?;
1548 }
1549 f.write_str(") OVER (")?;
1550 if !partition_by.is_empty() {
1551 f.write_str("PARTITION BY ")?;
1552 for (i, p) in partition_by.iter().enumerate() {
1553 if i > 0 {
1554 f.write_str(", ")?;
1555 }
1556 write!(f, "{p}")?;
1557 }
1558 }
1559 if !order_by.is_empty() {
1560 if !partition_by.is_empty() {
1561 f.write_str(" ")?;
1562 }
1563 f.write_str("ORDER BY ")?;
1564 for (i, (e, desc)) in order_by.iter().enumerate() {
1565 if i > 0 {
1566 f.write_str(", ")?;
1567 }
1568 write!(f, "{e}")?;
1569 if *desc {
1570 f.write_str(" DESC")?;
1571 }
1572 }
1573 }
1574 if let Some(fr) = frame {
1575 if !partition_by.is_empty() || !order_by.is_empty() {
1576 f.write_str(" ")?;
1577 }
1578 let k = match fr.kind {
1579 FrameKind::Rows => "ROWS",
1580 FrameKind::Range => "RANGE",
1581 };
1582 if let Some(end) = &fr.end {
1583 write!(f, "{k} BETWEEN {} AND {}", fr.start, end)?;
1584 } else {
1585 write!(f, "{k} {}", fr.start)?;
1586 }
1587 }
1588 f.write_str(")")
1589 }
1590 Self::ScalarSubquery(s) => write!(f, "({s})"),
1591 Self::Exists { subquery, negated } => {
1592 if *negated {
1593 write!(f, "NOT EXISTS ({subquery})")
1594 } else {
1595 write!(f, "EXISTS ({subquery})")
1596 }
1597 }
1598 Self::InSubquery {
1599 expr,
1600 subquery,
1601 negated,
1602 } => {
1603 if *negated {
1604 write!(f, "({expr} NOT IN ({subquery}))")
1605 } else {
1606 write!(f, "({expr} IN ({subquery}))")
1607 }
1608 }
1609 }
1610 }
1611}
1612
1613impl fmt::Display for Literal {
1614 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1615 match self {
1616 Self::Integer(n) => write!(f, "{n}"),
1617 Self::Float(x) => {
1618 let s = format!("{x}");
1619 // Default Display for an integral f64 (e.g. 1.0) emits "1",
1620 // which would round-trip back to Integer. Force a dot.
1621 if s.contains('.') || s.contains('e') || s.contains('E') {
1622 f.write_str(&s)
1623 } else {
1624 write!(f, "{s}.0")
1625 }
1626 }
1627 Self::String(s) => {
1628 f.write_str("'")?;
1629 for c in s.chars() {
1630 if c == '\'' {
1631 f.write_str("''")?;
1632 } else {
1633 write!(f, "{c}")?;
1634 }
1635 }
1636 f.write_str("'")
1637 }
1638 Self::Bool(b) => f.write_str(if *b { "TRUE" } else { "FALSE" }),
1639 Self::Null => f.write_str("NULL"),
1640 Self::Vector(v) => {
1641 f.write_str("[")?;
1642 for (i, x) in v.iter().enumerate() {
1643 if i > 0 {
1644 f.write_str(", ")?;
1645 }
1646 let s = format!("{x}");
1647 // Mirror Float Display: force a dot so re-parse stays
1648 // numerically literal.
1649 if s.contains('.') || s.contains('e') || s.contains('E') {
1650 f.write_str(&s)?;
1651 } else {
1652 write!(f, "{s}.0")?;
1653 }
1654 }
1655 f.write_str("]")
1656 }
1657 Self::Interval { text, .. } => {
1658 f.write_str("INTERVAL '")?;
1659 for c in text.chars() {
1660 if c == '\'' {
1661 f.write_str("''")?;
1662 } else {
1663 write!(f, "{c}")?;
1664 }
1665 }
1666 f.write_str("'")
1667 }
1668 }
1669 }
1670}
1671
1672impl fmt::Display for BinOp {
1673 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1674 f.write_str(match self {
1675 Self::Or => "OR",
1676 Self::And => "AND",
1677 Self::Eq => "=",
1678 Self::NotEq => "<>",
1679 Self::IsDistinctFrom => "IS DISTINCT FROM",
1680 Self::IsNotDistinctFrom => "IS NOT DISTINCT FROM",
1681 Self::Lt => "<",
1682 Self::LtEq => "<=",
1683 Self::Gt => ">",
1684 Self::GtEq => ">=",
1685 Self::Add => "+",
1686 Self::Sub => "-",
1687 Self::Mul => "*",
1688 Self::Div => "/",
1689 Self::L2Distance => "<->",
1690 Self::InnerProduct => "<#>",
1691 Self::CosineDistance => "<=>",
1692 Self::Concat => "||",
1693 Self::JsonGet => "->",
1694 Self::JsonGetText => "->>",
1695 Self::JsonGetPath => "#>",
1696 Self::JsonGetPathText => "#>>",
1697 Self::JsonContains => "@>",
1698 })
1699 }
1700}
1701
1702/// Quote `s` as a PG double-quoted identifier when required (keyword,
1703/// non-folded case, leading digit, embedded non-`[A-Za-z0-9_]`, empty).
1704/// Otherwise return it as-is. Returns an owned `String` to keep the call site
1705/// uniform.
1706fn quote_ident(s: &str) -> String {
1707 let needs_quote = match s.chars().next() {
1708 None => true,
1709 Some(c) if !c.is_ascii_alphabetic() && c != '_' => true,
1710 _ => {
1711 s.chars().any(|c| !(c.is_ascii_alphanumeric() || c == '_'))
1712 || s.chars().any(|c| c.is_ascii_uppercase())
1713 || is_keyword(s)
1714 }
1715 };
1716 if !needs_quote {
1717 return s.to_string();
1718 }
1719 let mut out = String::with_capacity(s.len() + 2);
1720 out.push('"');
1721 for c in s.chars() {
1722 if c == '"' {
1723 out.push_str("\"\"");
1724 } else {
1725 out.push(c);
1726 }
1727 }
1728 out.push('"');
1729 out
1730}
1731
1732fn is_keyword(s: &str) -> bool {
1733 matches!(
1734 &*s.to_ascii_lowercase(),
1735 "select"
1736 | "from"
1737 | "where"
1738 | "as"
1739 | "null"
1740 | "true"
1741 | "false"
1742 | "and"
1743 | "or"
1744 | "not"
1745 | "create"
1746 | "table"
1747 | "insert"
1748 | "into"
1749 | "values"
1750 | "index"
1751 | "on"
1752 | "begin"
1753 | "commit"
1754 | "rollback"
1755 | "is"
1756 | "between"
1757 | "in"
1758 | "like"
1759 | "group"
1760 | "distinct"
1761 | "union"
1762 | "all"
1763 | "join"
1764 | "inner"
1765 | "left"
1766 | "cross"
1767 | "outer"
1768 | "default"
1769 | "savepoint"
1770 | "release"
1771 | "to"
1772 | "having"
1773 | "show"
1774 | "extract"
1775 | "offset"
1776 | "asc"
1777 | "desc"
1778 | "interval"
1779 )
1780}
1781
1782#[cfg(test)]
1783mod tests {
1784 use super::*;
1785 use alloc::vec;
1786
1787 #[test]
1788 fn integer_literal_renders_without_dot() {
1789 assert_eq!(Literal::Integer(42).to_string(), "42");
1790 }
1791
1792 #[test]
1793 fn integral_float_keeps_dot() {
1794 assert_eq!(Literal::Float(1.0).to_string(), "1.0");
1795 assert_eq!(Literal::Float(1.5).to_string(), "1.5");
1796 assert_eq!(Literal::Float(2.5e-3).to_string(), "0.0025");
1797 }
1798
1799 #[test]
1800 fn string_literal_doubles_quote() {
1801 assert_eq!(Literal::String("it's".into()).to_string(), "'it''s'");
1802 }
1803
1804 #[test]
1805 fn bool_and_null_render_uppercase() {
1806 assert_eq!(Literal::Bool(true).to_string(), "TRUE");
1807 assert_eq!(Literal::Bool(false).to_string(), "FALSE");
1808 assert_eq!(Literal::Null.to_string(), "NULL");
1809 }
1810
1811 #[test]
1812 fn binary_op_always_parenthesised() {
1813 let e = Expr::Binary {
1814 lhs: Box::new(Expr::Literal(Literal::Integer(1))),
1815 op: BinOp::Add,
1816 rhs: Box::new(Expr::Literal(Literal::Integer(2))),
1817 };
1818 assert_eq!(e.to_string(), "(1 + 2)");
1819 }
1820
1821 #[test]
1822 fn select_star_from_table() {
1823 let s = SelectStatement {
1824 items: vec![SelectItem::Wildcard],
1825 from: Some(FromClause {
1826 primary: TableRef {
1827 name: "users".into(),
1828 alias: None,
1829 as_of_segment: None,
1830 },
1831 joins: vec![],
1832 }),
1833 where_: None,
1834 group_by: None,
1835 group_by_all: false,
1836 having: None,
1837 unions: vec![],
1838 order_by: Vec::new(),
1839 limit: None,
1840 offset: None,
1841 distinct: false,
1842 ctes: vec![],
1843 };
1844 assert_eq!(s.to_string(), "SELECT * FROM users");
1845 }
1846
1847 #[test]
1848 fn quote_ident_for_uppercase_and_keyword() {
1849 assert_eq!(quote_ident("foo"), "foo");
1850 assert_eq!(quote_ident("Foo"), "\"Foo\"");
1851 assert_eq!(quote_ident("select"), "\"select\"");
1852 assert_eq!(quote_ident(""), "\"\"");
1853 assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
1854 }
1855}