Skip to main content

radixdb_orm/
ir.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{DataTypeDescriptor, ResultColumnDescriptor};
6
7pub const ORM_IR_VERSION: &str = "radixdb.orm.v1";
8
9#[derive(Debug, thiserror::Error)]
10pub enum IrError {
11    #[error("unsupported ORM IR version '{0}'")]
12    UnsupportedVersion(String),
13    #[error("ORM IR kind mismatch: envelope is {envelope:?}, operation is {operation:?}")]
14    KindMismatch { envelope: IrKind, operation: IrKind },
15    #[error("ORM IR JSON error: {0}")]
16    Json(#[from] serde_json::Error),
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum IrKind {
22    Catalog,
23    Ddl,
24    Select,
25    Insert,
26    Upsert,
27    Update,
28    Delete,
29    Explain,
30    Transaction,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct IrDocument {
35    pub ir: String,
36    pub kind: IrKind,
37    pub payload: Operation,
38}
39
40impl IrDocument {
41    pub fn new(payload: Operation) -> Self {
42        Self {
43            ir: ORM_IR_VERSION.to_string(),
44            kind: payload.kind(),
45            payload,
46        }
47    }
48
49    pub fn validate(&self) -> Result<(), IrError> {
50        if self.ir != ORM_IR_VERSION {
51            return Err(IrError::UnsupportedVersion(self.ir.clone()));
52        }
53        let operation = self.payload.kind();
54        if self.kind != operation {
55            return Err(IrError::KindMismatch {
56                envelope: self.kind,
57                operation,
58            });
59        }
60        Ok(())
61    }
62
63    pub fn to_json(&self) -> Result<String, IrError> {
64        self.validate()?;
65        Ok(serde_json::to_string(self)?)
66    }
67
68    pub fn to_pretty_json(&self) -> Result<String, IrError> {
69        self.validate()?;
70        Ok(serde_json::to_string_pretty(self)?)
71    }
72
73    pub fn from_json(json: &str) -> Result<Self, IrError> {
74        let document: Self = serde_json::from_str(json)?;
75        document.validate()?;
76        Ok(document)
77    }
78
79    /// Serialize a log-safe form. Typed values keep their public type tag but
80    /// their payload is replaced; executable JSON is never used as log JSON.
81    pub fn to_redacted_json(&self) -> Result<String, IrError> {
82        self.validate()?;
83        let mut value = serde_json::to_value(self)?;
84        redact_typed_values(&mut value);
85        Ok(serde_json::to_string(&value)?)
86    }
87}
88
89fn redact_typed_values(value: &mut serde_json::Value) {
90    match value {
91        serde_json::Value::Array(values) => {
92            for value in values {
93                redact_typed_values(value);
94            }
95        }
96        serde_json::Value::Object(object) => {
97            let is_typed_value = object
98                .get("type")
99                .and_then(serde_json::Value::as_str)
100                .is_some_and(|tag| {
101                    matches!(
102                        tag,
103                        "integer"
104                            | "float"
105                            | "text"
106                            | "boolean"
107                            | "timestamp"
108                            | "date"
109                            | "json"
110                            | "uuid"
111                            | "bytes"
112                            | "decimal"
113                            | "vector"
114                    )
115                });
116            if is_typed_value && object.contains_key("value") {
117                object.insert(
118                    "value".to_string(),
119                    serde_json::Value::String("<redacted>".to_string()),
120                );
121                return;
122            }
123            for value in object.values_mut() {
124                redact_typed_values(value);
125            }
126        }
127        _ => {}
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(tag = "node", rename_all = "snake_case")]
133// This is the stable, language-neutral JSON root. Boxing selected variants
134// would leak an allocation-driven Rust detail into every constructor without
135// improving the wire representation, so the size trade-off is intentional.
136#[allow(clippy::large_enum_variant)]
137pub enum Operation {
138    Catalog { operation: CatalogOperation },
139    Ddl { operation: DdlOperation },
140    Select { query: Select },
141    Insert { statement: Insert },
142    Upsert { statement: Upsert },
143    Update { statement: Update },
144    Delete { statement: Delete },
145    Explain { statement: Explain },
146    Transaction { statement: TransactionOperation },
147}
148
149impl Operation {
150    pub fn kind(&self) -> IrKind {
151        match self {
152            Self::Catalog { .. } => IrKind::Catalog,
153            Self::Ddl { .. } => IrKind::Ddl,
154            Self::Select { .. } => IrKind::Select,
155            Self::Insert { .. } => IrKind::Insert,
156            Self::Upsert { .. } => IrKind::Upsert,
157            Self::Update { .. } => IrKind::Update,
158            Self::Delete { .. } => IrKind::Delete,
159            Self::Explain { .. } => IrKind::Explain,
160            Self::Transaction { .. } => IrKind::Transaction,
161        }
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "operation", rename_all = "snake_case")]
167pub enum CatalogOperation {
168    ListTables,
169    DescribeTable { table: String },
170    DescribeDatabase,
171    ShowIndexes { table: String },
172}
173
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(tag = "type", content = "value", rename_all = "snake_case")]
176pub enum TypedValue {
177    Null(DataTypeDescriptor),
178    Integer(i64),
179    Float(FloatValue),
180    Text(String),
181    Boolean(bool),
182    /// UTC instant in canonical RFC 3339 form.
183    Timestamp(String),
184    /// Civil date and time without an offset.
185    CivilTimestamp(String),
186    /// Civil time of day without an offset.
187    Time(String),
188    Date(String),
189    Json(serde_json::Value),
190    Uuid(String),
191    Bytes(String),
192    Decimal(String),
193    Vector(Vec<f32>),
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
197#[serde(untagged)]
198pub enum FloatValue {
199    Number(f64),
200    Special(FloatSpecial),
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum FloatSpecial {
206    Nan,
207    PositiveInfinity,
208    NegativeInfinity,
209}
210
211impl From<f64> for FloatValue {
212    fn from(value: f64) -> Self {
213        if value.is_nan() {
214            Self::Special(FloatSpecial::Nan)
215        } else if value == f64::INFINITY {
216            Self::Special(FloatSpecial::PositiveInfinity)
217        } else if value == f64::NEG_INFINITY {
218            Self::Special(FloatSpecial::NegativeInfinity)
219        } else {
220            Self::Number(value)
221        }
222    }
223}
224
225impl FloatValue {
226    pub fn as_f64(self) -> f64 {
227        match self {
228            Self::Number(value) => value,
229            Self::Special(FloatSpecial::Nan) => f64::NAN,
230            Self::Special(FloatSpecial::PositiveInfinity) => f64::INFINITY,
231            Self::Special(FloatSpecial::NegativeInfinity) => f64::NEG_INFINITY,
232        }
233    }
234}
235
236impl TypedValue {
237    pub fn data_type(&self) -> DataTypeDescriptor {
238        match self {
239            Self::Null(data_type) => data_type.clone(),
240            Self::Integer(_) => DataTypeDescriptor::Integer,
241            Self::Float(_) => DataTypeDescriptor::Float,
242            Self::Text(_) => DataTypeDescriptor::Text { max_chars: None },
243            Self::Boolean(_) => DataTypeDescriptor::Boolean,
244            Self::Timestamp(_) => DataTypeDescriptor::Timestamp,
245            Self::CivilTimestamp(_) => DataTypeDescriptor::CivilTimestamp,
246            Self::Time(_) => DataTypeDescriptor::Time,
247            Self::Date(_) => DataTypeDescriptor::Date,
248            Self::Json(_) => DataTypeDescriptor::Json,
249            Self::Uuid(_) => DataTypeDescriptor::Uuid,
250            Self::Bytes(_) => DataTypeDescriptor::Bytes,
251            Self::Decimal(_) => DataTypeDescriptor::Decimal {
252                precision: None,
253                scale: None,
254            },
255            Self::Vector(values) => DataTypeDescriptor::Vector {
256                dimensions: u16::try_from(values.len()).unwrap_or(u16::MAX),
257            },
258        }
259    }
260
261    /// Decode the language-neutral DECIMAL text form into RadixDB's exact
262    /// coefficient/precision/scale tuple. SDK transports use this one parser
263    /// so embedded and TCP execution cannot disagree about admission.
264    pub fn decimal_parts(&self) -> Result<(i128, u8, u8), TypedValueError> {
265        match self {
266            Self::Decimal(value) => parse_decimal_literal(value),
267            _ => Err(TypedValueError::ExpectedDecimal),
268        }
269    }
270}
271
272#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
273pub enum TypedValueError {
274    #[error("expected a DECIMAL typed value")]
275    ExpectedDecimal,
276    #[error("invalid DECIMAL literal '{0}'")]
277    InvalidDecimal(String),
278    #[error("DECIMAL precision must be in 1..=38")]
279    DecimalPrecision,
280    #[error("DECIMAL scale must not exceed precision")]
281    DecimalScale,
282    #[error("DECIMAL coefficient exceeds declared precision")]
283    DecimalCoefficient,
284}
285
286pub fn parse_decimal_literal(value: &str) -> Result<(i128, u8, u8), TypedValueError> {
287    let value = value.trim();
288    let (negative, unsigned) = match value.as_bytes().first() {
289        Some(b'-') => (true, &value[1..]),
290        Some(b'+') => (false, &value[1..]),
291        _ => (false, value),
292    };
293    let mut parts = unsigned.split('.');
294    let integer = parts.next().unwrap_or_default();
295    let fractional = parts.next().unwrap_or_default();
296    if parts.next().is_some()
297        || integer.is_empty()
298        || !integer.bytes().all(|byte| byte.is_ascii_digit())
299        || !fractional.bytes().all(|byte| byte.is_ascii_digit())
300    {
301        return Err(TypedValueError::InvalidDecimal(value.to_string()));
302    }
303
304    let digits = format!("{integer}{fractional}");
305    let precision = u8::try_from(digits.len()).map_err(|_| TypedValueError::DecimalPrecision)?;
306    let scale = u8::try_from(fractional.len()).map_err(|_| TypedValueError::DecimalPrecision)?;
307    if precision == 0 || precision > 38 {
308        return Err(TypedValueError::DecimalPrecision);
309    }
310    if scale > precision {
311        return Err(TypedValueError::DecimalScale);
312    }
313
314    let mut unscaled = digits
315        .parse::<i128>()
316        .map_err(|_| TypedValueError::DecimalCoefficient)?;
317    if negative {
318        unscaled = unscaled
319            .checked_neg()
320            .ok_or(TypedValueError::DecimalCoefficient)?;
321    }
322    let coefficient_digits = if unscaled == 0 {
323        1
324    } else {
325        unscaled.unsigned_abs().ilog10() as usize + 1
326    };
327    if coefficient_digits > usize::from(precision) {
328        return Err(TypedValueError::DecimalCoefficient);
329    }
330    Ok((unscaled, precision, scale))
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct ColumnRef {
335    pub relation: Option<String>,
336    pub name: String,
337}
338
339impl ColumnRef {
340    pub fn new(name: impl Into<String>) -> Self {
341        Self {
342            relation: None,
343            name: name.into(),
344        }
345    }
346
347    pub fn qualified(relation: impl Into<String>, name: impl Into<String>) -> Self {
348        Self {
349            relation: Some(relation.into()),
350            name: name.into(),
351        }
352    }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "snake_case")]
357pub enum UnaryOperator {
358    Not,
359    Negate,
360    Positive,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(rename_all = "snake_case")]
365pub enum BinaryOperator {
366    Eq,
367    Ne,
368    Lt,
369    Lte,
370    Gt,
371    Gte,
372    And,
373    Or,
374    Xor,
375    Add,
376    Subtract,
377    Multiply,
378    Divide,
379    Modulo,
380    Like,
381    NotLike,
382    Glob,
383    Regexp,
384    IsDistinctFrom,
385    IsNotDistinctFrom,
386}
387
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389#[serde(tag = "node", rename_all = "snake_case")]
390pub enum Expression {
391    Column {
392        column: ColumnRef,
393    },
394    Literal {
395        value: TypedValue,
396    },
397    Star {
398        relation: Option<String>,
399    },
400    Unary {
401        operator: UnaryOperator,
402        expression: Box<Expression>,
403    },
404    Binary {
405        left: Box<Expression>,
406        operator: BinaryOperator,
407        right: Box<Expression>,
408    },
409    Function {
410        name: String,
411        arguments: Vec<Expression>,
412    },
413    Aggregate {
414        name: String,
415        arguments: Vec<Expression>,
416        distinct: bool,
417        filter: Option<Box<Expression>>,
418        order_by: Vec<OrderBy>,
419    },
420    Window {
421        function: Box<Expression>,
422        specification: WindowSpecification,
423    },
424    Cast {
425        expression: Box<Expression>,
426        data_type: DataTypeDescriptor,
427    },
428    Case {
429        operand: Option<Box<Expression>>,
430        branches: Vec<CaseBranch>,
431        otherwise: Option<Box<Expression>>,
432    },
433    IsNull {
434        expression: Box<Expression>,
435        negated: bool,
436    },
437    Between {
438        expression: Box<Expression>,
439        lower: Box<Expression>,
440        upper: Box<Expression>,
441        negated: bool,
442    },
443    InList {
444        expression: Box<Expression>,
445        values: Vec<Expression>,
446        negated: bool,
447    },
448    InSubquery {
449        expression: Box<Expression>,
450        query: Box<Select>,
451        negated: bool,
452    },
453    Exists {
454        query: Box<Select>,
455        negated: bool,
456    },
457    ScalarSubquery {
458        query: Box<Select>,
459    },
460    Tuple {
461        values: Vec<Expression>,
462    },
463    /// Read-only RadixDB navigation path. `root` is a table alias and every
464    /// path segment is an authoritative reference/column identifier.
465    Navigation {
466        root: String,
467        path: Vec<String>,
468    },
469    Grouping {
470        expressions: Vec<Expression>,
471    },
472}
473
474impl Expression {
475    pub fn column(name: impl Into<String>) -> Self {
476        Self::Column {
477            column: ColumnRef::new(name),
478        }
479    }
480
481    pub fn literal(value: TypedValue) -> Self {
482        Self::Literal { value }
483    }
484}
485
486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
487pub struct CaseBranch {
488    pub when: Expression,
489    pub then: Expression,
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum SortDirection {
495    Asc,
496    Desc,
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
500#[serde(rename_all = "snake_case")]
501pub enum NullPlacement {
502    First,
503    Last,
504}
505
506#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
507pub struct OrderBy {
508    pub expression: Expression,
509    pub direction: SortDirection,
510    pub nulls: Option<NullPlacement>,
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum WindowFrameUnit {
516    Rows,
517    Range,
518}
519
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521#[serde(tag = "bound", content = "offset", rename_all = "snake_case")]
522pub enum WindowFrameBound {
523    UnboundedPreceding,
524    Preceding(u64),
525    CurrentRow,
526    Following(u64),
527    UnboundedFollowing,
528}
529
530#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
531pub struct WindowFrame {
532    pub unit: WindowFrameUnit,
533    pub start: WindowFrameBound,
534    pub end: Option<WindowFrameBound>,
535}
536
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
538pub struct WindowSpecification {
539    pub name: Option<String>,
540    pub partition_by: Vec<Expression>,
541    pub order_by: Vec<OrderBy>,
542    pub frame: Option<WindowFrame>,
543}
544
545#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
546pub struct NamedWindow {
547    pub name: String,
548    pub specification: WindowSpecification,
549}
550
551#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552pub struct Projection {
553    pub expression: Expression,
554    pub alias: Option<String>,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
558#[serde(rename_all = "snake_case")]
559pub enum JoinKind {
560    Inner,
561    Left,
562    Right,
563    Full,
564    Cross,
565}
566
567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
568#[serde(tag = "node", rename_all = "snake_case")]
569pub enum Relation {
570    Table {
571        name: String,
572        alias: Option<String>,
573    },
574    Cte {
575        name: String,
576        alias: Option<String>,
577    },
578    Derived {
579        query: Box<Select>,
580        alias: String,
581    },
582    Values {
583        rows: Vec<Vec<Expression>>,
584        alias: String,
585        columns: Vec<String>,
586    },
587    Join {
588        left: Box<Relation>,
589        right: Box<Relation>,
590        kind: JoinKind,
591        on: Option<Expression>,
592    },
593}
594
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
596#[serde(tag = "grouping", rename_all = "snake_case")]
597pub enum Grouping {
598    Expressions { expressions: Vec<Expression> },
599    Rollup { expressions: Vec<Expression> },
600    Cube { expressions: Vec<Expression> },
601    Sets { sets: Vec<Vec<Expression>> },
602}
603
604#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
605pub struct CommonTableExpression {
606    pub name: String,
607    pub columns: Vec<String>,
608    pub query: Box<Select>,
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum SetOperator {
614    Union,
615    UnionAll,
616    Intersect,
617    Except,
618}
619
620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
621pub struct SetArm {
622    pub operator: SetOperator,
623    pub query: Box<Select>,
624}
625
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
627pub struct Select {
628    pub ctes: Vec<CommonTableExpression>,
629    pub recursive: bool,
630    pub distinct: bool,
631    pub distinct_on: Vec<Expression>,
632    pub projection: Vec<Projection>,
633    pub from: Option<Relation>,
634    pub filter: Option<Expression>,
635    pub group_by: Option<Grouping>,
636    pub having: Option<Expression>,
637    pub windows: Vec<NamedWindow>,
638    pub set_operations: Vec<SetArm>,
639    pub order_by: Vec<OrderBy>,
640    pub limit: Option<u64>,
641    pub offset: Option<u64>,
642    pub expected_result_shape: Vec<ResultColumnDescriptor>,
643}
644
645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
646pub struct Assignment {
647    pub column: String,
648    pub value: Expression,
649}
650
651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
652pub struct Insert {
653    pub table: String,
654    pub columns: Vec<String>,
655    pub rows: Vec<Vec<Expression>>,
656    pub source: Option<Box<Select>>,
657    pub returning: Vec<Projection>,
658}
659
660#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
661pub struct Upsert {
662    pub insert: Insert,
663    pub conflict_columns: Vec<String>,
664    pub assignments: Vec<Assignment>,
665}
666
667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
668pub struct Update {
669    pub table: String,
670    pub alias: Option<String>,
671    pub assignments: Vec<Assignment>,
672    pub from: Option<Relation>,
673    pub filter: Option<Expression>,
674    pub returning: Vec<Projection>,
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678pub struct Delete {
679    pub table: String,
680    pub alias: Option<String>,
681    pub using: Option<Relation>,
682    pub filter: Option<Expression>,
683    pub all_rows: bool,
684    pub returning: Vec<Projection>,
685}
686
687#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
688pub struct Explain {
689    pub analyze: bool,
690    pub operation: Box<Operation>,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(tag = "operation", rename_all = "snake_case")]
695pub enum TransactionOperation {
696    Begin,
697    Commit,
698    Rollback,
699    Savepoint { name: String },
700    RollbackToSavepoint { name: String },
701    ReleaseSavepoint { name: String },
702}
703
704#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
705pub struct ColumnDefinition {
706    pub name: String,
707    pub data_type: DataTypeDescriptor,
708    pub nullable: bool,
709    pub primary_key: bool,
710    pub unique: bool,
711    pub auto_increment: bool,
712    pub default: Option<Expression>,
713    pub check: Option<Expression>,
714    pub reference: Option<ReferenceDefinition>,
715    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
716    pub extensions: BTreeMap<String, serde_json::Value>,
717}
718
719#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
720pub struct ReferenceDefinition {
721    pub table: String,
722    pub column: String,
723    pub on_delete: crate::ForeignKeyActionDescriptor,
724    pub on_update: crate::ForeignKeyActionDescriptor,
725}
726
727#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
728#[serde(tag = "constraint", rename_all = "snake_case")]
729pub enum ConstraintDefinitionIr {
730    PrimaryKey {
731        columns: Vec<String>,
732    },
733    Unique {
734        columns: Vec<String>,
735    },
736    ForeignKey {
737        columns: Vec<String>,
738        referenced_table: String,
739        referenced_columns: Vec<String>,
740        on_delete: crate::ForeignKeyActionDescriptor,
741        on_update: crate::ForeignKeyActionDescriptor,
742    },
743    Check {
744        expression: Expression,
745    },
746}
747
748#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
749pub struct IndexDefinition {
750    pub name: String,
751    pub table: String,
752    pub columns: Vec<String>,
753    pub unique: bool,
754    #[serde(default)]
755    pub if_not_exists: bool,
756    pub method: Option<String>,
757    pub predicate: Option<Expression>,
758    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
759    pub options: BTreeMap<String, TypedValue>,
760}
761
762#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
763#[serde(tag = "operation", rename_all = "snake_case")]
764pub enum AlterTableAction {
765    AddColumn { column: ColumnDefinition },
766    ModifyColumn { column: ColumnDefinition },
767    DropColumn { column: String },
768    RenameColumn { from: String, to: String },
769    RenameTable { to: String },
770    AddConstraint { constraint: ConstraintDefinitionIr },
771    DropConstraint { name: String, if_exists: bool },
772}
773
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
775#[serde(tag = "operation", rename_all = "snake_case")]
776pub enum DdlOperation {
777    CreateTable {
778        table: String,
779        if_not_exists: bool,
780        columns: Vec<ColumnDefinition>,
781        constraints: Vec<ConstraintDefinitionIr>,
782    },
783    CreateTableAs {
784        table: String,
785        if_not_exists: bool,
786        query: Box<Select>,
787    },
788    AlterTable {
789        table: String,
790        action: AlterTableAction,
791    },
792    DropTable {
793        table: String,
794        if_exists: bool,
795    },
796    TruncateTable {
797        table: String,
798    },
799    CreateIndex {
800        index: IndexDefinition,
801    },
802    DropIndex {
803        table: String,
804        index: String,
805        if_exists: bool,
806    },
807    AlterIndex {
808        index: String,
809        new_name: String,
810    },
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    #[test]
818    fn document_round_trip_is_versioned_kind_checked_and_redacted() {
819        let document = IrDocument::new(Operation::Select {
820            query: Select {
821                projection: vec![Projection {
822                    expression: Expression::literal(TypedValue::Text("secret".to_string())),
823                    alias: Some("value".to_string()),
824                }],
825                ..Select::default()
826            },
827        });
828        let json = document.to_json().unwrap();
829        assert_eq!(IrDocument::from_json(&json).unwrap(), document);
830        let redacted = document.to_redacted_json().unwrap();
831        assert!(!redacted.contains("secret"));
832        assert!(redacted.contains("<redacted>"));
833
834        let mut wrong = document.clone();
835        wrong.kind = IrKind::Delete;
836        assert!(wrong.to_json().is_err());
837
838        let unknown = json.replace(ORM_IR_VERSION, "radixdb.orm.v999");
839        assert!(matches!(
840            IrDocument::from_json(&unknown),
841            Err(IrError::UnsupportedVersion(_))
842        ));
843    }
844}