From 1702aa4fba4fa1fe7680a87f96518bcb90f10a00 Mon Sep 17 00:00:00 2001 From: 9bany Date: Mon, 7 Sep 2026 18:08:28 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20close=20the=20OLAP=20P2=20tier=20?= =?UTF-8?q?=E2=80=94=20rename/exchange,=20numbers(),=20JSON,=20and=2013=20?= =?UTF-8?q?named=20refusals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P2 row of the OLAP surface had nothing in it: 0 of 22 distinct entries implemented, and 16 of them not even refused by name — they fell off the end of the grammar, which `error.rs` exists to prevent. Most of P2 has an answer here already, under a different spelling. So this closes the tier by answering each row rather than by implementing each row: 4 built, 15 refused by name, 3 deferred with the blocker written down. Built: - `ALTER TABLE t RENAME TO u` and `EXCHANGE TABLES a AND b`. Below the catalog a table is reached by an interned `TableId`, so a name is one record and changing it moves no data. `Catalog::rename_table` already existed, tested, with no caller outside tests; `sql_no_rename` meanwhile claimed nothing below renames one, which was false for a table. The refusal narrows to the half that is true — a field's name is what routes a fact to a bitmap. An exchange is two inserts into one map in one transaction, so no moment exists where either name resolves to nothing. It is the step `sql_no_alter_column` and `sql_no_alter_engine` have been prescribing without providing, and it is its own undo. - `SELECT * FROM numbers(n)`. A `Shown` and not a query, for the reason `system.*` is one: no plan produces these rows. It carries its own ceiling and refuses past it rather than clamping — `SETTINGS` binds a query and never reaches here, so there is no `EXPLAIN` line that would report a clamp. - `JSONExtractString/Int/Float/Raw` and `JSONHas` over a `TEXT` column. No `JSON` type and no new `Datum` shape: a keyed column holds a string, and these read a key out of it on the way into a cell. One level of key, hand-rolled scanner, no dependency — the argument `sql_no_regex` already makes. - `runningDifference(x)`, as a window function rather than a scalar: it needs the order the difference is taken along. Refused by name, each with the local spelling in its sentence: `sql_no_sample`, `sql_no_overwrite`, `sql_no_copy_into`, `sql_no_table_function`, `sql_no_temporary_table`, `sql_no_distribution`, `sql_computed_column`, `sql_composite_type`, `sql_enum_type`, `sql_no_array_join`, `sql_array_function`, `sql_no_sequence_function`, `sql_numbers_too_large`. `Refused::ALL` goes 84 -> 97. Four existing sentences get extended: `sql_no_alter_column` and `sql_no_alter_engine` can now name the exchange as the last step, `sql_unknown_column_type` maps `IPv4` to `UINT(32)` and `UUID`/`IPv6` to `TEXT` rather than reporting them absent, and `sql_no_analyze` separates "no cost model to feed" from "no channel carrying measurements back". DDL travels through its own wire enum, so the two schema statements take tags 15 and 16 in `wire/fact.rs`. No `Plan` variant and no merge arm: `big-plan`, `big-cluster/src/merge.rs` and `big-exec` are untouched. Two documents were already wrong and are corrected here. `crates/big-sql/readme.md` still listed window functions, select-list arithmetic and `LIKE` as refused, stale since M2.5 and M4. `docs/sql-testing.md` carried four stale numbers: 40 `Refused` variants, ~405 corpus cases, three gates where there are seven, and one excused refusal where there are nine. Deferred with the blocker named rather than left as an omission: materialized views, time travel `AS OF` (the pager has a real copy-on-write snapshot registry, but nothing above it creates or opens one, and a snapshot carries `txn_id` rather than a timestamp), and `EXPLAIN ANALYZE`. One commit rather than one per phase: the phases share `error.rs`, `lib.rs`, `parse/mod.rs` and `query.rs`, so a split along them would not compile at the intermediate steps. --- crates/big-cluster/src/ddl.rs | 25 ++ crates/big-cluster/src/query.rs | 50 ++++ crates/big-cluster/src/wire/fact.rs | 32 ++ crates/big-cluster/tests/logic/schema.test | 80 +++++ crates/big-cluster/tests/logic/system.test | 26 ++ crates/big-cluster/tests/logic/windows.test | 30 ++ crates/big-db/src/catalog.rs | 35 +++ crates/big-db/src/db/schema.rs | 49 +++ crates/big-db/tests/db.rs | 49 +++ crates/big-embed/src/introspect.rs | 16 + crates/big-embed/src/lib.rs | 18 ++ crates/big-embed/src/result/json.rs | 298 +++++++++++++++++++ crates/big-embed/src/result/mod.rs | 1 + crates/big-embed/src/result/scalar.rs | 34 ++- crates/big-embed/src/result/window.rs | 15 +- crates/big-sql/readme.md | 77 ++++- crates/big-sql/src/ddl.rs | 44 +++ crates/big-sql/src/error.rs | 245 ++++++++++++++- crates/big-sql/src/explain.rs | 9 + crates/big-sql/src/lib.rs | 51 +++- crates/big-sql/src/parse/alter.rs | 48 +++ crates/big-sql/src/parse/create.rs | 25 ++ crates/big-sql/src/parse/insert.rs | 6 + crates/big-sql/src/parse/item.rs | 39 ++- crates/big-sql/src/parse/mod.rs | 22 ++ crates/big-sql/src/parse/select.rs | 34 ++- crates/big-sql/src/parse/tablefn.rs | 123 ++++++++ crates/big-sql/src/scalar.rs | 53 ++++ crates/big-sql/src/shape.rs | 15 +- crates/big-sql/src/show.rs | 37 ++- crates/big-sql/tests/gates.rs | 4 + crates/big-sql/tests/testdata/ddl.test | 43 +++ crates/big-sql/tests/testdata/refusals.test | 170 ++++++++++- crates/big-sql/tests/testdata/scalar.test | 85 ++++++ crates/big-sql/tests/testdata/system.test | 96 ++++++ crates/big-sql/tests/testdata/window.test | 41 +++ crates/big-sql/tests/translate/introspect.rs | 36 +++ crates/big-sql/tests/translate/schema.rs | 65 +++- docs/sql-testing.md | 35 ++- docs/versioning.md | 8 + 40 files changed, 2116 insertions(+), 53 deletions(-) create mode 100644 crates/big-embed/src/result/json.rs create mode 100644 crates/big-sql/src/parse/tablefn.rs diff --git a/crates/big-cluster/src/ddl.rs b/crates/big-cluster/src/ddl.rs index 019dc4c..27a63d1 100644 --- a/crates/big-cluster/src/ddl.rs +++ b/crates/big-cluster/src/ddl.rs @@ -190,6 +190,25 @@ impl Cluster

{ self.ddl(&Ddl::TruncateTable { table: table.to_string() }) } + /// `ALTER TABLE ... RENAME TO`: the table keeps everything but its name, everywhere. + /// + /// `Ok(false)` means there was no such table at the leader. It has to reach every node for + /// the reason a `TRUNCATE` does and then some: a node still holding the old name answers a + /// statement written against the new one with "unknown table", and a node holding both + /// would answer two different questions with one table's data. + pub fn rename_table(&self, table: &str, to: &str) -> Result { + self.ddl(&Ddl::RenameTable { table: table.to_string(), to: to.to_string() }).map(|n| n == 1) + } + + /// `EXCHANGE TABLES a AND b`: the two names swap everywhere, or the change is partial. + /// + /// `Ok(false)` means one of them was not there at the leader, and then nothing moved on any + /// node - the leader refuses before the fan-out begins, which is the whole reason the + /// decision is made in one place. + pub fn exchange_tables(&self, a: &str, b: &str) -> Result { + self.ddl(&Ddl::ExchangeTables { a: a.to_string(), b: b.to_string() }).map(|n| n == 1) + } + /// Retention, cluster-wide: drop a time-quantum field's index before an instant. /// /// **This is the spelling `big-bin`'s offline tool said belonged with the other DDL.** Its @@ -313,6 +332,12 @@ pub fn apply_ddl(api: &Api

, op: &Ddl) -> big_embed::Resul // leader has already ruled on whether it exists, so a peer without it is a peer that // never had the data this is removing. Ddl::TruncateTable { table } => api.truncate_table(table)?.unwrap_or(0), + // A table that is not here counts as nothing renamed, for the reason a truncate does: + // the leader has already ruled on whether it exists, and a peer without it has no name + // to change. The same holds for an exchange, where a peer missing either table has + // neither name to swap. + Ddl::RenameTable { table, to } => u64::from(api.rename_table(table, to)?), + Ddl::ExchangeTables { a, b } => u64::from(api.exchange_tables(a, b)?), Ddl::DropViewsBefore { table, field, unix_seconds } => { api.drop_days_before(table, field, *unix_seconds)? as u64 } diff --git a/crates/big-cluster/src/query.rs b/crates/big-cluster/src/query.rs index 45d65b9..a5b107b 100644 --- a/crates/big-cluster/src/query.rs +++ b/crates/big-cluster/src/query.rs @@ -98,6 +98,17 @@ impl Cluster

{ big_embed::SqlDdl::TruncateTable { database, table, if_exists } => { ("fragments", self.sql_truncate_table(&qualified(database, table), *if_exists)?) } + // The new name is qualified here rather than at the parser, which took it bare: the + // database a rename lands in is the one the table is already in, and that is a fact + // about this statement's own table ref rather than about the word that was typed. + big_embed::SqlDdl::RenameTable { database, table, to } => ( + "renamed", + self.sql_rename_table(&qualified(database, table), &qualified(database, to))?, + ), + big_embed::SqlDdl::ExchangeTables { database, a, b } => ( + "exchanged", + self.sql_exchange_tables(&qualified(database, a), &qualified(database, b))?, + ), big_embed::SqlDdl::CreateView { database, name, body, or_replace, if_not_exists } => ( "view", self.create_view(&qualified(database, name), body, *or_replace, *if_not_exists)? @@ -661,6 +672,41 @@ impl Cluster

{ self.truncate_table(table) } + /// `ALTER TABLE ... RENAME TO`, answering with 1. + /// + /// Both halves are judged here, before the leader is asked, because both are questions the + /// schema already answers and a fan-out that failed half way through would answer neither: + /// a table that is not there is `UnknownTable`, and a new name somebody already holds is + /// refused rather than silently overwriting them. + fn sql_rename_table(&self, table: &str, to: &str) -> Result { + let schema = self.schema(); + if !schema.iter().any(|t| t.name == table) { + return Err(local(big_db::DbError::UnknownTable(table.to_string()))); + } + // A rename onto the name it already has is the change it describes: none. It is let + // through rather than short-circuited, so the count a client sees does not depend on + // whether they happened to pick the current name. + if table != to && schema.iter().any(|t| t.name == to) { + return Err(local(big_db::DbError::NameTaken(to.to_string()))); + } + self.rename_table(table, to).map(u64::from) + } + + /// `EXCHANGE TABLES a AND b`, answering with 1. + /// + /// Both tables have to exist, and the failure names the one that does not: an exchange + /// against a typo would otherwise report "nothing exchanged", which reads exactly like a + /// swap that happened to be a no-op. + fn sql_exchange_tables(&self, a: &str, b: &str) -> Result { + let schema = self.schema(); + for name in [a, b] { + if !schema.iter().any(|t| t.name == name) { + return Err(local(big_db::DbError::UnknownTable(name.to_string()))); + } + } + self.exchange_tables(a, b).map(u64::from) + } + /// `DELETE FROM t WHERE ...`: select across every owner, then clear. /// /// **Two steps, and the order is the whole of the design.** The predicate is planned, fanned @@ -1089,6 +1135,10 @@ impl Cluster

{ })) } big_embed::SqlShown::Roles => Ok(big_embed::introspect::show_roles(&self.api.roles())), + // Reads nothing - not this node's catalog, not another node's. Which is why it is + // answered here beside the listings rather than fanned out: there is no share of it + // any owner could hold. + big_embed::SqlShown::Numbers { n } => Ok(big_embed::introspect::numbers(*n)), // A bare `SHOW GRANTS` is about the caller's own role, which is why it needs no // privilege: reading what you hold tells you nothing you could not find out by // trying. A `Trusted` caller holds everything and has no role to name, so it gets diff --git a/crates/big-cluster/src/wire/fact.rs b/crates/big-cluster/src/wire/fact.rs index e28f12d..f266e66 100644 --- a/crates/big-cluster/src/wire/fact.rs +++ b/crates/big-cluster/src/wire/fact.rs @@ -238,6 +238,26 @@ pub enum Ddl { field: String, unix_seconds: i64, }, + /// `ALTER TABLE ... RENAME TO`: one catalog record, no data. + /// + /// Both names qualified, and the database in them is the same one - a rename that moved a + /// table is not a change this carries. The **new name travels rather than being derived**, + /// for the reason [`Ddl::DropViewsBefore`] sends an instant rather than a day name: two + /// nodes deriving it separately is two chances to derive it differently. + RenameTable { + table: String, + to: String, + }, + /// `EXCHANGE TABLES a AND b`: the two names swap which table they resolve to. + /// + /// A variant of its own rather than two renames, and for the reason [`Ddl::TruncateTable`] + /// is not a drop and a create: two renames need a name nobody holds to pass through, and a + /// peer that applied one of them and not the other would disagree with every other node + /// about what both names mean. One operation everywhere, or it is not the same operation. + ExchangeTables { + a: String, + b: String, + }, CreateDatabase { name: String, }, @@ -326,6 +346,16 @@ impl Ddl { put_u8(&mut out, 13); put_str(&mut out, table); } + Self::RenameTable { table, to } => { + put_u8(&mut out, 15); + put_str(&mut out, table); + put_str(&mut out, to); + } + Self::ExchangeTables { a, b } => { + put_u8(&mut out, 16); + put_str(&mut out, a); + put_str(&mut out, b); + } Self::DropTable { table } => { put_u8(&mut out, 4); put_str(&mut out, table); @@ -422,6 +452,8 @@ impl Ddl { 10 => Self::CreateRole { role: r.str()? }, 11 => Self::DropRole { role: r.str()? }, 13 => Self::TruncateTable { table: r.str()? }, + 15 => Self::RenameTable { table: r.str()?, to: r.str()? }, + 16 => Self::ExchangeTables { a: r.str()?, b: r.str()? }, 14 => Self::DropViewsBefore { table: r.str()?, field: r.str()?, diff --git a/crates/big-cluster/tests/logic/schema.test b/crates/big-cluster/tests/logic/schema.test index cbf9ca1..b64d34b 100644 --- a/crates/big-cluster/tests/logic/schema.test +++ b/crates/big-cluster/tests/logic/schema.test @@ -365,3 +365,83 @@ error ALTER TABLE visits DROP DAYS BEFORE '2026-01-02' ON nope ---- error: unknown_field + +## `RENAME TO` and `EXCHANGE TABLES`: catalog records, against real records. +# +# The claim worth running here rather than only in translation is that the *data* follows the +# table and not the name. Both statements move an interned id between two names and rewrite +# nothing below the catalog, so what a name resolves to afterwards is the only thing that +# changed - and that is not visible until there are facts on both sides to tell apart. + +statement +CREATE TABLE live (amount UINT(32)) + +statement +INSERT INTO live (_record_id, amount) VALUES (1, 10), (2, 20) + +statement +CREATE TABLE rebuilt (amount UINT(32)) + +statement +INSERT INTO rebuilt (_record_id, amount) VALUES (1, 700) + +query +SELECT sum(amount) FROM live +---- +sum +30 + +# One change, and afterwards each name reads the other table's facts. +statement +EXCHANGE TABLES live AND rebuilt + +query +SELECT sum(amount) FROM live +---- +sum +700 + +query +SELECT sum(amount) FROM rebuilt +---- +sum +30 + +# Running it again is the undo - which is what makes it safe to reach for during a rebuild. +statement +EXCHANGE TABLES live AND rebuilt + +query +SELECT sum(amount) FROM live +---- +sum +30 + +# A rename keeps everything but the name, including the facts and the fields. +statement +ALTER TABLE rebuilt RENAME TO archive + +query +SELECT sum(amount) FROM archive +---- +sum +700 + +error +SELECT sum(amount) FROM rebuilt +---- +error: unknown_table + +# Renaming onto a name somebody already holds would leave one of the two unreachable, so it is +# refused rather than overwriting them. +error +ALTER TABLE archive RENAME TO live +---- +error: name_taken + +# An exchange against a name that is not there is `unknown_table` and not "nothing exchanged", +# which would read exactly like a swap that happened to be a no-op. +error +EXCHANGE TABLES live AND typo +---- +error: unknown_table diff --git a/crates/big-cluster/tests/logic/system.test b/crates/big-cluster/tests/logic/system.test index 3bacf4e..5f99de7 100644 --- a/crates/big-cluster/tests/logic/system.test +++ b/crates/big-cluster/tests/logic/system.test @@ -111,3 +111,29 @@ error SELECT * FROM system.tables ORDER BY name ---- error: sql_system_clause + +## `numbers(n)`: rows from nothing. +# +# Worth running against a live node rather than only in translation, because the claim is that +# it needs no table, no fact and no plan - which is only visible when something actually answers. + +query +SELECT * FROM numbers(4) +---- +number +0 +1 +2 +3 + +query +SELECT number FROM numbers(1) +---- +number +0 + +# Zero rows is an answer, with the header still there. +query +SELECT * FROM numbers(0) +---- +number diff --git a/crates/big-cluster/tests/logic/windows.test b/crates/big-cluster/tests/logic/windows.test index 3e9fc88..4b1bf70 100644 --- a/crates/big-cluster/tests/logic/windows.test +++ b/crates/big-cluster/tests/logic/windows.test @@ -136,3 +136,33 @@ SELECT amount, row_number() OVER (ORDER BY amount DESC) FROM tx LIMIT 2 amount row_number 100 6 900 1 + +## `runningDifference(x)`: the difference against the previous row of the partition. +# +# Worth running here rather than only in translation because the claim is about the *partition* +# being whole: the previous row is the previous row of every shard's contribution merged, not of +# the share one node happened to hold. A per-node difference would restart at each shard, and the +# first row of each would be blank instead of just the first row overall. + +query +SELECT amount, runningDifference(amount) OVER (ORDER BY amount) FROM tx +---- +amount runningDifference +100 NULL +900 200 +500 200 +700 200 +200 100 +300 100 + +# Partitioned, so each partition's first row is the blank one. +query +SELECT country, amount, runningDifference(amount) OVER (PARTITION BY country ORDER BY amount) FROM tx +---- +country amount runningDifference +GB 100 NULL +US 900 700 +GB 500 200 +FR 700 NULL +US 200 NULL +GB 300 200 diff --git a/crates/big-db/src/catalog.rs b/crates/big-db/src/catalog.rs index ca63735..b9fce33 100644 --- a/crates/big-db/src/catalog.rs +++ b/crates/big-db/src/catalog.rs @@ -867,6 +867,41 @@ impl Catalog { Ok(true) } + /// Swaps which table each of two names resolves to. + /// + /// `Ok(false)` means one of them was not there, and then nothing moved: a swap that renamed + /// half of itself would leave one name pointing at data the other used to hold. + /// + /// **Not three [`Self::rename_table`] calls through a temporary name**, which is the obvious + /// way to write it and the wrong one. A temporary name is a name that may already have an + /// owner, and it is three edits with two moments in between where a name resolves somewhere + /// nobody asked for. This is two `insert`s over ids already read, so no intermediate state + /// exists to be observed or to be left behind by a failure. + /// + /// Nothing below the catalog moves, for the reason a rename moves nothing: fragments, row + /// keys, field ids and grants are all keyed by [`TableId`], and an id is exactly what is + /// staying put here. A name is the only thing either table had that the other now has. + pub fn exchange_tables(&mut self, database: DatabaseId, a: &str, b: &str) -> Result { + let by_name = self.table_ids.entry(database).or_default(); + let (Some(&ia), Some(&ib)) = (by_name.get(a), by_name.get(b)) else { + return Ok(false); + }; + // Exchanging a table with itself is the change it describes: none. Answered as done + // rather than refused, because the statement is satisfiable and already satisfied. + if ia == ib { + return Ok(true); + } + by_name.insert(a.to_string(), ib); + by_name.insert(b.to_string(), ia); + if let Some(t) = self.tables.get_mut(&ia) { + t.name = b.to_string(); + } + if let Some(t) = self.tables.get_mut(&ib) { + t.name = a.to_string(); + } + Ok(true) + } + /// Removes a table, its fields, its row keys and its fragment metadata. /// /// Returns every `FragmentKey` that was registered under it, because those are exactly the diff --git a/crates/big-db/src/db/schema.rs b/crates/big-db/src/db/schema.rs index fb34140..63e09e4 100644 --- a/crates/big-db/src/db/schema.rs +++ b/crates/big-db/src/db/schema.rs @@ -280,6 +280,55 @@ impl Db

{ Ok(Some(dropped)) } + /// Gives a table a different name. Nothing else about it moves. + /// + /// `Ok(false)` means there was no such table; renaming onto a name already taken is an error + /// rather than an overwrite. See [`crate::catalog::Catalog::rename_table`] for why this costs + /// one record: everything below the catalog is keyed by [`TableId`], and the id does not + /// change. In particular the **row keys stay**, for the reason a truncate keeps them - a row + /// id interned under this table is still interned under this table. + pub fn rename_table<'a>(&self, name: impl Into>, to: &str) -> Result { + let name = name.into(); + let mut w = self.write(); + let database = w.catalog.database_of(name)?; + if !w.catalog.rename_table(database, name.table, to)? { + return Ok(false); + } + w.commit()?; + Ok(true) + } + + /// Swaps the names of two tables in one transaction. + /// + /// `Ok(false)` means one of them was not there, and then neither moved. + /// + /// **The atomic half of a rebuild.** Building a replacement table and putting it in place is + /// otherwise a drop and a rename with a window in between where the name resolves to nothing; + /// this closes the window, and it leaves the old table under the other name so the rebuild + /// can be undone by running the same statement again. + /// + /// Both names are in one database, because a swap that also moved a table across databases + /// would be two changes wearing one name - the same line [`Self::rename_table`] draws. + pub fn exchange_tables<'a>( + &self, + a: impl Into>, + b: impl Into>, + ) -> Result { + let (a, b) = (a.into(), b.into()); + let mut w = self.write(); + let database = w.catalog.database_of(a)?; + // Judged here rather than in the catalog, which is handed one database id and could not + // see the difference: two names in two databases are two tables this cannot swap. + if w.catalog.database_of(b)? != database { + return Err(DbError::UnknownTable(b.to_string())); + } + if !w.catalog.exchange_tables(database, a.table, b.table)? { + return Ok(false); + } + w.commit()?; + Ok(true) + } + /// Removes one field of a table, its row keys, and the pages holding it. /// Drops every per-day view of a time quantum field older than `unix_seconds`, and returns /// how many fragments went. diff --git a/crates/big-db/tests/db.rs b/crates/big-db/tests/db.rs index 970a37d..734b3ff 100644 --- a/crates/big-db/tests/db.rs +++ b/crates/big-db/tests/db.rs @@ -1391,3 +1391,52 @@ fn a_view_body_past_the_ceiling_is_refused() { assert_eq!(e.unwrap_err().code(), "view_too_long"); assert_eq!(c.saved_queries().count(), 0); } + +/// **An exchange swaps two names and moves no data**, which is what makes it a rebuild's last +/// step rather than a copy. +/// +/// The assertion that matters is the one about fragment roots: the pages each table's facts live +/// on are the same pages afterwards, because a fragment is addressed by an interned table id and +/// an id is exactly what does not move here. +#[test] +fn exchanging_two_tables_swaps_names_and_rewrites_nothing() { + let d = db(); + d.create_table("other").unwrap(); + d.create_field("other", "amount", FieldKind::Int, 37).unwrap(); + let mut w = d.write(); + w.set_int("other", "amount", 1, 111).unwrap(); + w.set_int("tx", "amount", 9, 777).unwrap(); + w.commit().unwrap(); + + let ids = (d.catalog().lookup("tx").unwrap().id, d.catalog().lookup("other").unwrap().id); + let field = d.catalog().field(ids.0, "amount").expect("field exists").id; + let key = FragmentKey::new(ids.0, field, 0, 0); + let root_before = d.store().roots().get(&key).expect("the write created a fragment"); + + assert!(d.exchange_tables("tx", "other").unwrap()); + + // The names now resolve to each other's tables... + assert_eq!(d.catalog().lookup("tx").unwrap().id, ids.1); + assert_eq!(d.catalog().lookup("other").unwrap().id, ids.0); + // ...so the values follow the data, not the name. + assert_eq!(d.read().get_int("tx", "amount", 1).unwrap(), Some(111)); + assert_eq!(d.read().get_int("other", "amount", 9).unwrap(), Some(777)); + // And nothing was rewritten to do it. + assert_eq!(d.store().roots().get(&key), Some(root_before), "no data pages rewritten"); + + // Running it again is the undo, which is the property that makes it safe to reach for. + assert!(d.exchange_tables("tx", "other").unwrap()); + assert_eq!(d.read().get_int("tx", "amount", 9).unwrap(), Some(777)); +} + +/// A table that is not there means nothing moved - not half a swap. +#[test] +fn exchanging_against_a_missing_table_moves_neither() { + let d = Db::in_memory().unwrap(); + d.create_table("a").unwrap(); + let before = d.catalog().lookup("a").unwrap().id; + + assert!(!d.exchange_tables("a", "typo").unwrap()); + assert_eq!(d.catalog().lookup("a").unwrap().id, before); + assert!(d.catalog().lookup("typo").is_none()); +} diff --git a/crates/big-embed/src/introspect.rs b/crates/big-embed/src/introspect.rs index 8182d92..fdf4a57 100644 --- a/crates/big-embed/src/introspect.rs +++ b/crates/big-embed/src/introspect.rs @@ -197,6 +197,22 @@ pub fn show_databases(names: &[String], tables: &[TableInfo]) -> ResultSet { } } +/// `SELECT * FROM numbers(n)`: the integers `0 .. n`, under one column. +/// +/// **The one answer in this module that reads nothing at all** - not the catalog, not a fragment, +/// not a grant. It is here rather than in the query path because it is the same kind of thing +/// every other function here is: rows built at the coordinator, from something already in hand, +/// with no plan behind them and nothing to merge. +/// +/// Bounded before it arrives: `big_sql::MAX_NUMBERS` is checked in the parser, so `n` here is +/// already a count this is willing to allocate. +pub fn numbers(n: u64) -> ResultSet { + ResultSet { + columns: vec!["number".to_string()], + rows: (0..n).map(|i| vec![Datum::Int(i128::from(i))]).collect(), + } +} + /// `SHOW ROLES`: one row per role. /// /// The reserved role is prepended rather than stored, exactly as `default` appears in diff --git a/crates/big-embed/src/lib.rs b/crates/big-embed/src/lib.rs index e2b2dae..ce03f6f 100644 --- a/crates/big-embed/src/lib.rs +++ b/crates/big-embed/src/lib.rs @@ -1179,6 +1179,24 @@ impl Api

{ Ok(self.db.truncate_table(table)?) } + /// Gives a table a different name, moving nothing else. + /// + /// `Ok(false)` means there was no such table. See [`big_db::Db::rename_table`]: a name is a + /// key in the catalog and an id is what everything below resolves by, so this costs one + /// record whatever the table holds. + pub fn rename_table(&self, table: &str, to: &str) -> Result { + Ok(self.db.rename_table(table, to)?) + } + + /// Swaps the names of two tables in one transaction. + /// + /// `Ok(false)` means one of them was not there, and then neither moved. See + /// [`big_db::Db::exchange_tables`] - this is the step that makes a rebuild atomic, and it + /// leaves the old table under the other name rather than dropping it. + pub fn exchange_tables(&self, a: &str, b: &str) -> Result { + Ok(self.db.exchange_tables(a, b)?) + } + /// Drops a time-quantum field's index for every period wholly before an instant. /// /// **It drops the index, not the records**, and the difference is the whole reason this is diff --git a/crates/big-embed/src/result/json.rs b/crates/big-embed/src/result/json.rs new file mode 100644 index 0000000..3b82554 --- /dev/null +++ b/crates/big-embed/src/result/json.rs @@ -0,0 +1,298 @@ +// Copyright 2026 Bany +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! One key out of a JSON document held in a keyed column. +//! +//! # Why this is written here rather than reached for +//! +//! It is a few hundred bytes of scanning, and what it replaces is a dependency. The argument is +//! the one `sql_no_regex` makes about a regex engine: the work happens inside the render loop, +//! once per row per call, and a general parser brings a compile step, a class of pathological +//! input, and a version to track - to answer a question that is "find this key at the top level". +//! +//! # What it does not do +//! +//! **One level, and the key is a name rather than a path.** A nested value comes back from +//! `JSONExtractRaw` as the text it was written as, which the next call reads a key out of in +//! turn; that is the composition a path syntax would otherwise buy, without a second grammar to +//! keep in step with somebody else's. +//! +//! **Malformed input is absent, not an error.** A column of documents is a column somebody else +//! filled, and one bad row is not a reason to fail the statement it appears in - the same +//! decision `Datum::Null` already represents everywhere else here. There is nothing this can +//! return for "the eleventh row was truncated" that a client could act on differently. + +/// The raw text of `key`'s value in `doc`, or `None`. +/// +/// Whitespace-tolerant, escape-aware inside strings, and brace/bracket-balanced so a nested +/// object's own keys are skipped rather than matched. It scans the top-level object once and +/// stops at the first match: a document with a key twice answers with the first, which is what +/// every reader that streams does. +pub fn raw<'a>(doc: &'a str, key: &str) -> Option<&'a str> { + let b = doc.as_bytes(); + let mut i = skip_ws(b, 0); + if b.get(i) != Some(&b'{') { + return None; + } + i = skip_ws(b, i + 1); + while i < b.len() && b[i] != b'}' { + // A member is a quoted name, a colon, then a value. Anything else and the document is + // not one this can read, which is the same answer as a key that is not there. + let (name, next) = string_at(b, i)?; + i = skip_ws(b, next); + if b.get(i) != Some(&b':') { + return None; + } + i = skip_ws(b, i + 1); + let end = value_end(b, i)?; + // A member whose value is empty - `{"a":` truncated, or `{"a": , ...}` - is where the + // document stopped making sense, so the search stops with it rather than handing back + // the empty string. An empty string is a value somebody could have written, and telling + // it apart from this afterwards is not possible. + let value = doc[i..end].trim(); + if value.is_empty() { + return None; + } + if name == key { + return Some(value); + } + i = skip_ws(b, end); + // A comma continues the object; a brace ends it. A document that does neither has run + // out of shape, so there is nothing left to search. + match b.get(i) { + Some(b',') => i = skip_ws(b, i + 1), + Some(b'}') => return None, + _ => return None, + } + } + None +} + +/// The same value with a string's quotes and escapes taken off, for the text-shaped call. +/// +/// A non-string value comes back as it was written - `JSONExtractString(d, 'n')` over `{"n":5}` +/// is `"5"` - because the alternative is absence, and a number a caller asked to see as text is +/// a number they can see. +pub fn text(doc: &str, key: &str) -> Option { + let v = raw(doc, key)?; + if v.as_bytes().first() == Some(&b'"') { + let (s, _) = string_at(v.as_bytes(), 0)?; + return Some(s); + } + Some(v.to_string()) +} + +/// The first index at or after `i` that is not JSON whitespace. +fn skip_ws(b: &[u8], mut i: usize) -> usize { + while i < b.len() && matches!(b[i], b' ' | b'\t' | b'\n' | b'\r') { + i += 1; + } + i +} + +/// The string starting at `i`, unescaped, and the index just past its closing quote. +fn string_at(b: &[u8], i: usize) -> Option<(String, usize)> { + if b.get(i) != Some(&b'"') { + return None; + } + let mut out = String::new(); + let mut i = i + 1; + while i < b.len() { + match b[i] { + b'"' => return Some((out, i + 1)), + b'\\' => { + let c = *b.get(i + 1)?; + match c { + b'n' => out.push('\n'), + b't' => out.push('\t'), + b'r' => out.push('\r'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + // `\uXXXX`, including the surrogate pair a character past the basic plane + // is written as. A lone or malformed one leaves the document unreadable + // rather than producing a replacement character, because a replacement + // character is a value somebody would then have to tell apart from a real + // one. + b'u' => { + let (ch, used) = unicode_at(b, i + 2)?; + out.push(ch); + i += used; + } + // `\"`, `\\`, `\/` and anything else stand for themselves. + other => out.push(char::from(other)), + } + i += 2; + } + _ => { + // Multi-byte UTF-8 is copied whole rather than byte by byte, so a character is + // never split across two pushes. + let rest = core::str::from_utf8(&b[i..]).ok()?; + let ch = rest.chars().next()?; + out.push(ch); + i += ch.len_utf8(); + } + } + } + None +} + +/// The character `\uXXXX` at `i` spells, and how many bytes past `i` it used. +fn unicode_at(b: &[u8], i: usize) -> Option<(char, usize)> { + let hex = |at: usize| -> Option { + let s = core::str::from_utf8(b.get(at..at + 4)?).ok()?; + u32::from_str_radix(s, 16).ok() + }; + let first = hex(i)?; + // A high surrogate is only half a character, and the other half is the next escape. + if (0xD800..0xDC00).contains(&first) { + if b.get(i + 4) != Some(&b'\\') || b.get(i + 5) != Some(&b'u') { + return None; + } + let second = hex(i + 6)?; + if !(0xDC00..0xE000).contains(&second) { + return None; + } + let c = 0x1_0000 + ((first - 0xD800) << 10) + (second - 0xDC00); + return Some((char::from_u32(c)?, 10)); + } + Some((char::from_u32(first)?, 4)) +} + +/// The index just past the value starting at `i`. +/// +/// Nesting is counted rather than parsed: what this needs is where the value ends, and a scan +/// that tracks depth and knows not to count a brace inside a string gets that without deciding +/// whether what it skipped was well formed. +fn value_end(b: &[u8], i: usize) -> Option { + let mut depth = 0usize; + let mut i = i; + while i < b.len() { + match b[i] { + b'"' => { + let (_, next) = string_at(b, i)?; + i = next; + if depth == 0 { + return Some(i); + } + continue; + } + b'{' | b'[' => depth += 1, + b'}' | b']' => { + // A closer at depth zero belongs to the object this member is in, so the value + // ended before it - which is the scalar case, handled below. + if depth == 0 { + return Some(i); + } + depth -= 1; + if depth == 0 { + return Some(i + 1); + } + } + b',' if depth == 0 => return Some(i), + _ => {} + } + i += 1; + } + (depth == 0).then_some(b.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_top_level_key_comes_back_as_written_and_as_text() { + let d = r#"{"a": "x", "n": 5, "f": 1.5, "t": true}"#; + assert_eq!(raw(d, "a"), Some(r#""x""#)); + assert_eq!(text(d, "a").as_deref(), Some("x")); + assert_eq!(raw(d, "n"), Some("5")); + assert_eq!(raw(d, "f"), Some("1.5")); + assert_eq!(raw(d, "t"), Some("true")); + assert_eq!(raw(d, "nope"), None); + } + + /// A nested value comes back whole, which is what makes one level compose into two. + #[test] + fn a_nested_value_is_the_text_it_was_written_as() { + let d = r#"{"o": {"b": 2}, "l": [1, {"x": 3}], "after": 9}"#; + assert_eq!(raw(d, "o"), Some(r#"{"b": 2}"#)); + assert_eq!(raw(d, "l"), Some(r#"[1, {"x": 3}]"#)); + // The key after a nested value is still found, which is the thing depth counting buys. + assert_eq!(raw(d, "after"), Some("9")); + // Two calls are a path. + assert_eq!(raw(raw(d, "o").unwrap(), "b"), Some("2")); + } + + /// A brace or a colon inside a string is not structure. + #[test] + fn punctuation_inside_a_string_is_not_structure() { + let d = r#"{"a": "}{,:", "b": 1}"#; + assert_eq!(text(d, "a").as_deref(), Some("}{,:")); + assert_eq!(raw(d, "b"), Some("1")); + // A key whose name contains the one being looked for is not a match. + assert_eq!(raw(r#"{"abc": 1}"#, "a"), None); + } + + #[test] + fn escapes_come_off_for_text_and_stay_on_for_raw() { + let d = r#"{"a": "line\nbreak \"q\" é 😀"}"#; + assert_eq!(text(d, "a").as_deref(), Some("line\nbreak \"q\" é 😀")); + assert!(raw(d, "a").unwrap().contains("\\n")); + } + + /// Malformed input is absent rather than an error, and never a panic - which is the property + /// that matters when the documents were written by somebody else. + #[test] + fn a_broken_document_is_absent_and_not_a_panic() { + for d in [ + "", + "{", + "{\"a\"", + "{\"a\":", + r#"{"a": "unterminated"#, + r#"{"a" 1}"#, + "[1,2,3]", + "null", + r#"{"a": "\u00"#, + r#"{"a": "\ud83d"}"#, + ] { + assert_eq!(raw(d, "a"), None, "{d}"); + assert_eq!(text(d, "a"), None, "{d}"); + } + } + + /// **A member that was complete before the document was cut off still answers.** + /// + /// The scan stops at the key it was asked for, so it never reaches the missing brace - and + /// making it reach one would turn every lookup of a first key into a walk of the whole + /// document to prove something the caller did not ask about. What it will not do is invent + /// a value for a member that was itself cut off, which is the case above. + #[test] + fn a_member_completed_before_a_truncation_is_still_read() { + assert_eq!(raw(r#"{"a": {"b": 1}"#, "a"), Some(r#"{"b": 1}"#)); + assert_eq!(raw(r#"{"a": 1, "b": "#, "a"), Some("1")); + // But the member that was cut off is absent, not empty. + assert_eq!(raw(r#"{"a": 1, "b": "#, "b"), None); + } + + /// Every prefix of a good document, which is the shape a truncated row arrives in. + #[test] + fn no_prefix_of_a_document_panics() { + let d = r#"{"a": "x", "o": {"b": [1, 2]}, "n": -1.5e3}"#; + for i in 0..d.len() { + let _ = raw(&d[..i], "a"); + let _ = text(&d[..i], "o"); + } + } +} diff --git a/crates/big-embed/src/result/mod.rs b/crates/big-embed/src/result/mod.rs index 9863fd5..faed9ab 100644 --- a/crates/big-embed/src/result/mod.rs +++ b/crates/big-embed/src/result/mod.rs @@ -27,6 +27,7 @@ //! it directly. mod group; +mod json; mod num; mod scalar; mod window; diff --git a/crates/big-embed/src/result/scalar.rs b/crates/big-embed/src/result/scalar.rs index b193397..f66cb9f 100644 --- a/crates/big-embed/src/result/scalar.rs +++ b/crates/big-embed/src/result/scalar.rs @@ -40,7 +40,7 @@ //! here answers `Null` for a `Null` argument. The exceptions are the ones whose whole job is to //! be about absence - `coalesce`, `ifNull`, and a `CASE` whose condition is false. -use super::{fixed, Datum}; +use super::{fixed, json, Datum}; use big_civil::Unit; use big_plan::Literal; use big_sql::scalar::{BinOp, Func, Scalar, UnOp}; @@ -168,7 +168,12 @@ pub(super) fn compare_numbers(l: &Datum, r: &Datum) -> Option Datum { +/// Two cells under an operator. +/// +/// `pub(super)` for `runningDifference`, which is a subtraction of two of a column's values and +/// has no business being a second one: a decimal's scales meet here, and two places doing that +/// is two places for them to meet differently. +pub(super) fn binary(op: BinOp, l: &Datum, r: &Datum) -> Datum { match op { // Short-circuiting is not observable here - both sides are already evaluated, and // neither can fail or cost anything - so these are written as the boolean they are. @@ -364,6 +369,31 @@ fn computed(func: Func, args: &[Datum]) -> Datum { let some = |o: Option| o.unwrap_or(Datum::Null); match func { + // ---- json --------------------------------------------------------------------- + // + // A document that cannot be read, and a key that is not in one that can, are the same + // answer: absent. See `super::json` for why that is the honest one rather than an error. + Func::JsonExtractRaw => { + some(text(0).and_then(|d| json::raw(&d, &text(1)?).map(|v| Datum::Text(v.to_string())))) + } + Func::JsonExtractString => { + some(text(0).and_then(|d| json::text(&d, &text(1)?).map(Datum::Text))) + } + // Parsed out of the raw text rather than out of the unescaped string, so that a number + // written as a number is read as one and a number written in quotes is not. + Func::JsonExtractInt => some(text(0).and_then(|d| { + json::raw(&d, &text(1)?)?.parse::().ok().map(|v| Datum::Int(i128::from(v))) + })), + Func::JsonExtractFloat => some( + text(0).and_then(|d| json::raw(&d, &text(1)?)?.parse::().ok().map(Datum::Real)), + ), + // 1 and 0, because a cell here has no boolean among its shapes - which is also what + // ClickHouse answers with. + Func::JsonHas => some( + text(0) + .map(|d| Datum::Int(i128::from(text(1).and_then(|k| json::raw(&d, &k)).is_some()))), + ), + // ---- arithmetic --------------------------------------------------------------- Func::Abs => match args.first() { Some(Datum::Int(v)) => Datum::Int(v.abs()), diff --git a/crates/big-embed/src/result/window.rs b/crates/big-embed/src/result/window.rs index a113ef8..807bc2c 100644 --- a/crates/big-embed/src/result/window.rs +++ b/crates/big-embed/src/result/window.rs @@ -29,8 +29,9 @@ //! total - see `sql_window_frame`. use super::num::Num; -use super::{order_by_keys, Datum, Row}; +use super::{order_by_keys, scalar, Datum, Row}; use big_exec::Projected; +use big_sql::scalar::BinOp; use big_sql::{Frame, Selected, Selection, Units, WinFunc}; /// Fills every window column of a projection, in place. @@ -182,6 +183,18 @@ fn values( true => read(i + step), false => Datum::Null, }, + // `x - lag(x, 1)`, and that is the definition rather than an implementation of + // one: the first row of a partition has nothing behind it, so it is absent - + // not zero, which would read as "no change" where the truth is "no previous + // row". A subtraction and not a fold, so it is exact on a decimal for the + // reason every other subtraction here is. + WinFunc::RunningDifference => match i.checked_sub(1) { + Some(j) => match (read(i), read(j)) { + (Datum::Null, _) | (_, Datum::Null) => Datum::Null, + (now, before) => scalar::binary(BinOp::Sub, &now, &before), + }, + None => Datum::Null, + }, WinFunc::FirstValue => read(0), // **The partition's last row, which is the whole point of having no frame.** // Under the standard's default frame this would be the current row, which is a diff --git a/crates/big-sql/readme.md b/crates/big-sql/readme.md index 17a0da6..b046262 100644 --- a/crates/big-sql/readme.md +++ b/crates/big-sql/readme.md @@ -222,6 +222,50 @@ this empties. Postgres's `RESTART IDENTITY` and `CASCADE` are not read — there restart, because a record id is an address rather than a counter, and nothing references a table for a cascade to follow. +## Renaming a table, and swapping two + +```sql +ALTER TABLE t RENAME TO u +EXCHANGE TABLES live AND rebuilt +``` + +**One catalog record each, and no data moves.** Below the catalog a table is reached by an +interned `TableId` — fragments, row keys, field ids and grants are all keyed by it — so a name is +a key in one map and changing it costs what changing a key costs. That is the whole difference +from a *field* name, which is what routes a fact to a bitmap and is still refused +(`sql_no_rename`). + +An exchange is two inserts into that map inside one transaction, so there is no moment where +either name resolves to nothing. It is the step every "create a second table and copy into it" +was missing: `sql_no_alter_column` and `sql_no_alter_engine` both prescribe that rebuild, and +until this existed the way to put the copy in place was a drop and a rename with a window in +between. It is also its own undo — the old table is still there, under the other name. + +A rename demands `ALTER` on the table and `CREATE` in the database, because it takes one name +away and puts another there. An exchange demands `DROP` on **both**: afterwards either table's +data is unreachable under the name it was written to, which is what `DROP` guards, and demanding +it on both is what stops a caller swapping their own table against one they may only read. + +The new name is bare and both tables are in one database. A rename that also moved a table is two +changes wearing one name, and it is a syntax error rather than a refusal — there is nothing to +write instead of it. + +## Rows from nothing + +```sql +SELECT * FROM numbers(10) +``` + +**A `Shown`, not a query**, for the reason `system.*` is one: no plan produces these rows, so +there is nothing to fan out and nothing to merge. The fork happens on a lookahead to the `FROM` +before the select list is read, because `*` means the one column here and the record id over a +table — and a bare `numbers` with no bracket is still an ordinary table name. + +It carries its own ceiling, `MAX_NUMBERS`, and **refuses past it rather than clamping** +(`sql_numbers_too_large`). A `SETTINGS` value is clamped silently, which is bearable only because +`EXPLAIN` prints the figure actually applied; a `Shown` has no such line, so a quiet ceiling here +would be a different answer wearing the right shape. + ## The catalog, under `system.` ```sql @@ -323,10 +367,11 @@ or a condition that is not one equality between the table being joined in and on (`sql_join_filter`); and `avg` or a select of joined values over a join — a pair of records has no identity this engine stores. -**Everything else that has no set operation behind it** — subqueries, CTEs, `INTERSECT`, -`EXCEPT`, window functions, arithmetic in the select list, `LIKE`, `IS NULL` and `= NULL`. A -plain `UNION` and `UNION DISTINCT` have their own code (`sql_union`): removing duplicate rows -here would mean comparing rendered strings. +**Everything else that has no set operation behind it** — a CTE whose body is a select, +`INTERSECT`, `EXCEPT`, `IS NULL` and `= NULL`. A subquery is answered in exactly one shape, +`IN (SELECT _record_id FROM …)`, which is a semi-join and therefore an intersection. A plain +`UNION` and `UNION DISTINCT` have their own code (`sql_union`): removing duplicate rows here +would mean comparing rendered strings. **The computation this dialect has no evaluator for**, each named by what was asked for rather than by the evaluator that is missing: `CASE WHEN`, `if` and `coalesce` choose between two @@ -335,8 +380,28 @@ condition selects a *set*, and `count(*) FILTER (WHERE …)` is how one statemen several. `CAST`, `toInt64` and `toString` convert between representations that do not convert: a keyed column is a string in a dictionary and an integer column is bit planes. `argMin`, `argMax`, `stddev`, `varPop` and `corr` need each record's value revisited against a running total, and -this engine holds bits at `(row, record)` rather than values to revisit. A `UUID`, `JSON` or -`BLOB` column names nothing this engine stores (`sql_unknown_column_type`). +this engine holds bits at `(row, record)` rather than values to revisit. A `JSON` or `BLOB` +column names nothing this engine stores (`sql_unknown_column_type`) — though a document held in +a `TEXT` column is read with `JSONExtractString(c, 'k')` and its family. Three names in that +refusal *map* rather than missing, and the sentence says so: `IPv4` is a `UINT(32)`, and `UUID` +and `IPv6` are 128 bits where a bit-sliced value stops at 64, so they are `TEXT` — interned once +and compared as bitmaps, but without a number's ordering. + +**The P2 tier of the OLAP surface**, each with the local spelling in its sentence rather than the +news that a feature is absent. `SAMPLE` (`sql_no_sample`): there is no rank-select over a +container, and the units that are cheap to skip whole are windows of write order rather than +random subsets. `INSERT OVERWRITE` (`sql_no_overwrite`): a truncate and an insert, which nothing +wraps in one transaction — so two statements, or `EXCHANGE TABLES` for an atomic replacement. +`COPY INTO` (`sql_no_copy_into`): loading is `POST /table/{t}/import`. `CREATE TEMPORARY TABLE` +(`sql_no_temporary_table`): there is no session for one to be temporary to. `PARTITION BY` and +`DISTRIBUTED BY` (`sql_no_distribution`): a shard is `record_id >> 20`, computed rather than +declared. `MATERIALIZED` and `ALIAS` columns (`sql_computed_column`): one is a view, the other a +materialised one. `Array`, `Map`, `Tuple` (`sql_composite_type`) and `ARRAY JOIN` +(`sql_no_array_join`) and the `array*` functions (`sql_array_function`): a keyed column already +holds many values per record, so `has(c, x)` is `c = 'x'` and one row per element is +`GROUP BY c`. `Enum8` (`sql_enum_type`): `MUTEX` is that storage already. `neighbor`, +`windowFunnel` and `retention` (`sql_no_sequence_function`). And `s3`, `url`, `file` and +`generateRandom` (`sql_no_table_function`), where `numbers(n)` is the one that works. **The two type names it understands and declines**, which is why neither shares a code with the names above — those were not recognised, and these were. `Nullable(T)` (`sql_nullable_type`): a diff --git a/crates/big-sql/src/ddl.rs b/crates/big-sql/src/ddl.rs index 5159bd9..5de81aa 100644 --- a/crates/big-sql/src/ddl.rs +++ b/crates/big-sql/src/ddl.rs @@ -144,6 +144,48 @@ pub enum Ddl { /// [`Ddl::DropTable`] takes it for. if_exists: bool, }, + /// `ALTER TABLE RENAME TO `: the table keeps everything but its name. + /// + /// **The one `ALTER` that changes a table rather than its fields**, and it is here rather + /// than in [`Alter`] for that reason: the others compose in a list against one table, and + /// two renames of one table in one statement would be a list where only the last entry + /// meant anything. + /// + /// It costs one catalog record. Everything below the catalog is keyed by an interned table + /// id - fragments, row keys, field ids, grants - and the id is exactly what does not change, + /// so no fact is rewritten and no row id is reissued. That is why this is a rename and not + /// the create-copy-drop that [`crate::Refused::Rename`] still prescribes for a *field*, + /// whose name is what routes a fact to a bitmap. + /// + /// One database: `ALTER TABLE a.t RENAME TO b.t` is a move as well as a rename, which is two + /// changes under one name, so the new name is bare. + RenameTable { + /// `sales` in `sales.orders`. `None` means the request's default database. + database: Option, + table: String, + /// The new name, unqualified - it lands in the database the table is already in. + to: String, + }, + /// `EXCHANGE TABLES AND `: the two names swap which table they resolve to. + /// + /// **The statement that makes a rebuild atomic**, which is what the rest of this surface + /// has been telling people to do without giving them the last step. `ALTER TABLE ... MODIFY + /// COLUMN` and `ALTER TABLE ... ENGINE` are both refused with "create a second table and + /// copy into it" ([`crate::Refused::AlterKind`], [`crate::Refused::AlterEngine`]), and until + /// this existed the way to put the copy in place was a drop and a rename with a window + /// between them where the name resolved to nothing. + /// + /// Two `insert`s into one map inside one transaction, so there is no such window - and the + /// old table is still there under the other name, which makes the statement its own undo. + /// + /// Both names in one database, for the reason [`Self::RenameTable`] takes a bare new name. + ExchangeTables { + /// `sales` in `sales.orders`. `None` means the request's default database, and it is + /// the database **both** tables are read in. + database: Option, + a: String, + b: String, + }, /// `CREATE [OR REPLACE] VIEW [IF NOT EXISTS] AS