Skip to main content

spiral/
table_edit.rs

1//! Row updates and schema changes, each written as a table's next version.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use arrow_array::RecordBatch;
7use arrow_schema::{DataType, FieldRef, SchemaRef};
8use futures::{StreamExt as _, TryStreamExt as _, future, stream};
9
10use crate::registration::register;
11use crate::scandal::api::{
12    AddColumn, ArrowDataType, ArrowField, ColumnPath as WireColumnPath, MoveColumn, RenameColumn,
13    SchemaChange as WireSchemaChange, WidenType,
14};
15use crate::scandal_backend::{RowEdit, input_error};
16use crate::table_write::ROW_ID_COLUMN;
17use crate::{BatchStream, Error, Expression, NamedExpression, ScanOptions, Table};
18
19/// Rows updated in a table, and the version with their new values.
20#[derive(Clone, Debug)]
21pub struct UpdatedRows {
22    /// The version with the new values; the original table when no row matched.
23    pub table: Table,
24    /// How many visible rows were updated.
25    pub rows: u64,
26}
27
28/// A top-level column, or a field nested inside one, named by its segments.
29#[derive(Clone, Debug, Eq, Hash, PartialEq)]
30pub struct ColumnPath(Vec<String>);
31
32impl ColumnPath {
33    /// The field reached by `segments`: a top-level column, then a field of it, and so on.
34    pub fn nested(segments: impl IntoIterator<Item = impl Into<String>>) -> Self {
35        Self(segments.into_iter().map(Into::into).collect())
36    }
37
38    /// The path's segments, from the top-level column.
39    pub fn segments(&self) -> &[String] {
40        &self.0
41    }
42
43    fn into_wire(self) -> Result<Vec<String>, Error> {
44        if self.0.is_empty() || self.0.iter().any(String::is_empty) {
45            return Err(Error::invalid("a column path has nonempty segments"));
46        }
47        Ok(self.0)
48    }
49}
50
51impl From<&str> for ColumnPath {
52    fn from(column: &str) -> Self {
53        Self(vec![column.to_owned()])
54    }
55}
56
57impl From<String> for ColumnPath {
58    fn from(column: String) -> Self {
59        Self(vec![column])
60    }
61}
62
63/// One change to a table's schema. Changes write metadata only, and older versions keep their
64/// schema. Arbor's rules apply: an added column or field is nullable, `NOT NULL` may relax but
65/// not tighten, numbers widen only without loss, and partition and sort source fields cannot be
66/// dropped.
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub enum SchemaChange {
69    /// Add a nullable `field` after the column or field `after`, or last when absent.
70    AddColumn {
71        field: FieldRef,
72        after: Option<ColumnPath>,
73    },
74    /// Drop the column or field at `path`.
75    DropColumn { path: ColumnPath },
76    /// Rename the column or field at `path` to `name`, keeping its values.
77    RenameColumn { path: ColumnPath, name: String },
78    /// Let the column or field at `path` hold nulls.
79    SetNullable { path: ColumnPath },
80    /// Change the type of the column or field at `path` to `data_type`, converting without loss,
81    /// such as `int32` to `int64`.
82    WidenType {
83        path: ColumnPath,
84        data_type: DataType,
85    },
86    /// Move the column or field at `path` after `after`, or first when absent.
87    MoveColumn {
88        path: ColumnPath,
89        after: Option<ColumnPath>,
90    },
91}
92
93impl SchemaChange {
94    /// The contract form of this change, which names paths and carries Arrow JSON.
95    pub(crate) fn into_wire(self) -> Result<WireSchemaChange, Error> {
96        Ok(match self {
97            Self::AddColumn { field, after } => {
98                if !field.is_nullable() {
99                    return Err(Error::invalid(format!(
100                        "added column `{}` must be nullable",
101                        field.name()
102                    )));
103                }
104                WireSchemaChange::AddColumn(AddColumn {
105                    field: ArrowField(serde_json::to_value(field.as_ref()).map_err(Error::arrow)?),
106                    after: after.map(ColumnPath::into_wire).transpose()?,
107                })
108            }
109            Self::DropColumn { path } => WireSchemaChange::DropColumn(WireColumnPath {
110                path: path.into_wire()?,
111            }),
112            Self::RenameColumn { path, name } => WireSchemaChange::RenameColumn(RenameColumn {
113                path: path.into_wire()?,
114                name,
115            }),
116            Self::SetNullable { path } => WireSchemaChange::SetNullable(WireColumnPath {
117                path: path.into_wire()?,
118            }),
119            Self::WidenType { path, data_type } => WireSchemaChange::WidenType(WidenType {
120                path: path.into_wire()?,
121                data_type: ArrowDataType(serde_json::to_value(&data_type).map_err(Error::arrow)?),
122            }),
123            Self::MoveColumn { path, after } => WireSchemaChange::MoveColumn(MoveColumn {
124                path: path.into_wire()?,
125                after: after.map(ColumnPath::into_wire).transpose()?,
126            }),
127        })
128    }
129}
130
131impl Table {
132    /// This version's Arrow schema. It plans a scan but reads no rows.
133    pub async fn schema(&self) -> Result<SchemaRef, Error> {
134        Ok(self.scan(ScanOptions::default()).await?.schema())
135    }
136
137    /// Write the next version of this table with `changes` applied to its schema, in order,
138    /// without writing data files. Every change is checked against this version before
139    /// anything is written; with no changes the result is this table.
140    pub async fn alter_schema(
141        &self,
142        changes: impl IntoIterator<Item = SchemaChange>,
143    ) -> Result<Table, Error> {
144        let changes = changes
145            .into_iter()
146            .map(SchemaChange::into_wire)
147            .collect::<Result<Vec<_>, _>>()?;
148        if changes.is_empty() {
149            return Ok(self.clone());
150        }
151        let write = self
152            .connection
153            .tables()?
154            .begin_edit(self.identity(), changes)
155            .await?;
156        let result = register(&self.connection.arbor, write.prepare().await?).await?;
157        self.successor(result)
158    }
159
160    /// Write the next version of this table with the values of `set` in the visible rows that
161    /// match `filter`, or in every row without a filter, recorded as patches beside the data.
162    ///
163    /// Each assignment names an existing column and must evaluate to its stored type; nothing
164    /// is cast. Rows keep their row IDs. When no row matches, nothing is written and the result
165    /// holds this table.
166    pub async fn patch_rows(
167        &self,
168        filter: Option<Expression>,
169        set: Vec<NamedExpression>,
170    ) -> Result<UpdatedRows, Error> {
171        self.update_rows(filter, set).await
172    }
173
174    /// Scan the matching rows' row IDs and new values, and write them as patches.
175    async fn update_rows(
176        &self,
177        filter: Option<Expression>,
178        set: Vec<NamedExpression>,
179    ) -> Result<UpdatedRows, Error> {
180        if set.is_empty() {
181            return Err(Error::invalid("an update assigns at least one column"));
182        }
183        let scan = self
184            .scan(ScanOptions {
185                projection: Some(set),
186                filter,
187                row_id_column: Some(ROW_ID_COLUMN.to_owned()),
188                ..Default::default()
189            })
190            .await?;
191        let schema = scan.schema();
192        let mut batches = scan.into_stream();
193        let Some(first) = first_rows(&mut batches).await? else {
194            return Ok(UpdatedRows {
195                table: self.clone(),
196                rows: 0,
197            });
198        };
199        let rows = Arc::new(AtomicU64::new(first.num_rows() as u64));
200        let counted = Arc::clone(&rows);
201        let input = stream::once(future::ready(Ok(first)))
202            .chain(batches.inspect_ok(move |batch| {
203                counted.fetch_add(batch.num_rows() as u64, Ordering::Relaxed);
204            }))
205            .map_err(input_error)
206            .boxed();
207        let mut write = self.writer().await?;
208        write.edit_rows(RowEdit::Patch, schema, input).await?;
209        Ok(UpdatedRows {
210            table: write.finish().await?,
211            rows: rows.load(Ordering::Relaxed),
212        })
213    }
214}
215
216/// The first batch of `batches` that holds rows, or `None` when none does.
217pub(crate) async fn first_rows(batches: &mut BatchStream) -> Result<Option<RecordBatch>, Error> {
218    while let Some(batch) = batches.next_batch().await {
219        let batch = batch?;
220        if batch.num_rows() > 0 {
221            return Ok(Some(batch));
222        }
223    }
224    Ok(None)
225}
226
227#[cfg(test)]
228mod tests {
229    use arrow_schema::Field;
230    use serde_json::json;
231
232    use super::*;
233
234    #[test]
235    fn schema_changes_name_paths_and_carry_arrow_json() {
236        let added = SchemaChange::AddColumn {
237            field: Arc::new(Field::new("score", DataType::Float32, true)),
238            after: Some("label".into()),
239        }
240        .into_wire()
241        .unwrap();
242        let WireSchemaChange::AddColumn(added) = added else {
243            panic!("an added column stays an added column");
244        };
245        assert_eq!(added.after, Some(vec!["label".to_owned()]));
246        assert_eq!(added.field.0["name"], json!("score"));
247        assert_eq!(added.field.0["nullable"], json!(true));
248
249        let widened = SchemaChange::WidenType {
250            path: ColumnPath::nested(["pose", "x"]),
251            data_type: DataType::Float64,
252        }
253        .into_wire()
254        .unwrap();
255        assert_eq!(
256            widened,
257            WireSchemaChange::WidenType(WidenType {
258                path: vec!["pose".to_owned(), "x".to_owned()],
259                data_type: ArrowDataType(json!("Float64")),
260            })
261        );
262
263        let required = SchemaChange::AddColumn {
264            field: Arc::new(Field::new("score", DataType::Float32, false)),
265            after: None,
266        };
267        assert!(required.into_wire().is_err());
268        let unnamed = SchemaChange::DropColumn {
269            path: ColumnPath::nested(Vec::<String>::new()),
270        };
271        assert!(unnamed.into_wire().is_err());
272    }
273}