Skip to main content

surql/schema/
table.rs

1//! Table schema definitions.
2//!
3//! Port of `surql/schema/table.py`. Exposes the [`TableDefinition`] value
4//! object together with the supporting enums ([`TableMode`], [`IndexType`],
5//! [`MTreeDistanceType`], [`HnswDistanceType`], [`MTreeVectorType`]) and the
6//! [`IndexDefinition`] / [`EventDefinition`] structs. Each definition renders
7//! the corresponding `DEFINE` statement via `to_surql`.
8
9use std::collections::BTreeMap;
10use std::fmt::Write as _;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Result, SurqlError};
15
16use super::fields::FieldDefinition;
17
18/// Table schema mode.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "UPPERCASE")]
21pub enum TableMode {
22    /// Strict schema — fields must be declared up-front.
23    Schemafull,
24    /// Flexible schema — fields are added on write.
25    Schemaless,
26    /// Drop mode — server treats writes as no-ops.
27    Drop,
28}
29
30impl TableMode {
31    /// Render as SurrealQL keyword (`SCHEMAFULL` / `SCHEMALESS` / `DROP`).
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::Schemafull => "SCHEMAFULL",
35            Self::Schemaless => "SCHEMALESS",
36            Self::Drop => "DROP",
37        }
38    }
39}
40
41impl std::fmt::Display for TableMode {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str(self.as_str())
44    }
45}
46
47/// Index type supported by `DEFINE INDEX`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "UPPERCASE")]
50pub enum IndexType {
51    /// UNIQUE index.
52    Unique,
53    /// Full-text SEARCH index (with ASCII analyzer by default).
54    Search,
55    /// Plain b-tree style index.
56    Standard,
57    /// MTREE vector similarity index.
58    Mtree,
59    /// HNSW vector similarity index.
60    Hnsw,
61}
62
63impl IndexType {
64    /// Render as SurrealQL keyword (matching the Python enum values).
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Self::Unique => "UNIQUE",
68            Self::Search => "SEARCH",
69            Self::Standard => "INDEX",
70            Self::Mtree => "MTREE",
71            Self::Hnsw => "HNSW",
72        }
73    }
74}
75
76impl std::fmt::Display for IndexType {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.write_str(self.as_str())
79    }
80}
81
82/// Distance metric for MTREE vector indexes.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "UPPERCASE")]
85pub enum MTreeDistanceType {
86    /// Cosine distance.
87    Cosine,
88    /// Euclidean (L2) distance.
89    Euclidean,
90    /// Manhattan (L1) distance.
91    Manhattan,
92    /// Minkowski distance.
93    Minkowski,
94}
95
96impl MTreeDistanceType {
97    /// Render as SurrealQL keyword.
98    pub fn as_str(self) -> &'static str {
99        match self {
100            Self::Cosine => "COSINE",
101            Self::Euclidean => "EUCLIDEAN",
102            Self::Manhattan => "MANHATTAN",
103            Self::Minkowski => "MINKOWSKI",
104        }
105    }
106}
107
108impl std::fmt::Display for MTreeDistanceType {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.write_str(self.as_str())
111    }
112}
113
114/// Distance metric for HNSW vector indexes (superset of [`MTreeDistanceType`]).
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "UPPERCASE")]
117pub enum HnswDistanceType {
118    /// Chebyshev distance.
119    Chebyshev,
120    /// Cosine distance.
121    Cosine,
122    /// Euclidean distance.
123    Euclidean,
124    /// Hamming distance.
125    Hamming,
126    /// Jaccard distance.
127    Jaccard,
128    /// Manhattan distance.
129    Manhattan,
130    /// Minkowski distance.
131    Minkowski,
132    /// Pearson correlation distance.
133    Pearson,
134}
135
136impl HnswDistanceType {
137    /// Render as SurrealQL keyword.
138    pub fn as_str(self) -> &'static str {
139        match self {
140            Self::Chebyshev => "CHEBYSHEV",
141            Self::Cosine => "COSINE",
142            Self::Euclidean => "EUCLIDEAN",
143            Self::Hamming => "HAMMING",
144            Self::Jaccard => "JACCARD",
145            Self::Manhattan => "MANHATTAN",
146            Self::Minkowski => "MINKOWSKI",
147            Self::Pearson => "PEARSON",
148        }
149    }
150}
151
152impl std::fmt::Display for HnswDistanceType {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.write_str(self.as_str())
155    }
156}
157
158/// Numeric type for vector components in MTREE/HNSW indexes.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[serde(rename_all = "UPPERCASE")]
161pub enum MTreeVectorType {
162    /// 64-bit float.
163    F64,
164    /// 32-bit float.
165    F32,
166    /// 64-bit integer.
167    I64,
168    /// 32-bit integer.
169    I32,
170    /// 16-bit integer.
171    I16,
172}
173
174impl MTreeVectorType {
175    /// Render as SurrealQL keyword.
176    pub fn as_str(self) -> &'static str {
177        match self {
178            Self::F64 => "F64",
179            Self::F32 => "F32",
180            Self::I64 => "I64",
181            Self::I32 => "I32",
182            Self::I16 => "I16",
183        }
184    }
185}
186
187impl std::fmt::Display for MTreeVectorType {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.write_str(self.as_str())
190    }
191}
192
193/// Immutable index definition describing one or more columns of a table.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct IndexDefinition {
196    /// Index name.
197    pub name: String,
198    /// Columns participating in the index.
199    pub columns: Vec<String>,
200    /// Index kind.
201    #[serde(rename = "type", default = "IndexDefinition::default_type")]
202    pub index_type: IndexType,
203    /// MTREE/HNSW dimension.
204    #[serde(skip_serializing_if = "Option::is_none", default)]
205    pub dimension: Option<u32>,
206    /// MTREE distance metric.
207    #[serde(skip_serializing_if = "Option::is_none", default)]
208    pub distance: Option<MTreeDistanceType>,
209    /// MTREE/HNSW vector component type.
210    #[serde(skip_serializing_if = "Option::is_none", default)]
211    pub vector_type: Option<MTreeVectorType>,
212    /// HNSW-specific distance metric.
213    #[serde(skip_serializing_if = "Option::is_none", default)]
214    pub hnsw_distance: Option<HnswDistanceType>,
215    /// HNSW exploration factor during construction.
216    #[serde(skip_serializing_if = "Option::is_none", default)]
217    pub efc: Option<u32>,
218    /// HNSW maximum bidirectional links per node.
219    #[serde(skip_serializing_if = "Option::is_none", default)]
220    pub m: Option<u32>,
221}
222
223impl IndexDefinition {
224    fn default_type() -> IndexType {
225        IndexType::Standard
226    }
227
228    /// Build a minimal [`IndexDefinition`] with only name and columns.
229    pub fn new<I, S>(name: impl Into<String>, columns: I) -> Self
230    where
231        I: IntoIterator<Item = S>,
232        S: Into<String>,
233    {
234        Self {
235            name: name.into(),
236            columns: columns.into_iter().map(Into::into).collect(),
237            index_type: IndexType::Standard,
238            dimension: None,
239            distance: None,
240            vector_type: None,
241            hnsw_distance: None,
242            efc: None,
243            m: None,
244        }
245    }
246
247    /// Set the index kind.
248    pub fn with_type(mut self, index_type: IndexType) -> Self {
249        self.index_type = index_type;
250        self
251    }
252
253    /// Validate the index definition.
254    ///
255    /// Returns [`SurqlError::Validation`] when the name or column list is
256    /// empty, or when vector-index fields are missing required members.
257    pub fn validate(&self) -> Result<()> {
258        if self.name.is_empty() {
259            return Err(SurqlError::Validation {
260                reason: "Index name cannot be empty".into(),
261            });
262        }
263        if self.columns.is_empty() {
264            return Err(SurqlError::Validation {
265                reason: format!("Index {:?} must have at least one column", self.name),
266            });
267        }
268        if matches!(self.index_type, IndexType::Mtree | IndexType::Hnsw) && self.dimension.is_none()
269        {
270            return Err(SurqlError::Validation {
271                reason: format!("Vector index {:?} requires a dimension", self.name),
272            });
273        }
274        Ok(())
275    }
276
277    /// Render the `DEFINE INDEX` statement for this index on the given table.
278    pub fn to_surql(&self, table: &str) -> String {
279        self.to_surql_with_options(table, false)
280    }
281
282    /// Render with optional `IF NOT EXISTS` clause.
283    pub fn to_surql_with_options(&self, table: &str, if_not_exists: bool) -> String {
284        let ine = if if_not_exists { " IF NOT EXISTS" } else { "" };
285        match self.index_type {
286            IndexType::Mtree => {
287                let field = self.columns.first().map_or("", String::as_str);
288                let dim = self.dimension.unwrap_or(0);
289                let mut sql = format!(
290                    "DEFINE INDEX{ine} {name} ON TABLE {table} COLUMNS {field} MTREE DIMENSION {dim}",
291                    ine = ine,
292                    name = self.name,
293                    table = table,
294                    field = field,
295                    dim = dim,
296                );
297                if let Some(d) = self.distance {
298                    write!(sql, " DIST {}", d.as_str()).expect("writing to String cannot fail");
299                }
300                if let Some(vt) = self.vector_type {
301                    write!(sql, " TYPE {}", vt.as_str()).expect("writing to String cannot fail");
302                }
303                sql.push(';');
304                sql
305            }
306            IndexType::Hnsw => {
307                let field = self.columns.first().map_or("", String::as_str);
308                let dim = self.dimension.unwrap_or(0);
309                let mut sql = format!(
310                    "DEFINE INDEX{ine} {name} ON TABLE {table} COLUMNS {field} HNSW DIMENSION {dim}",
311                    ine = ine,
312                    name = self.name,
313                    table = table,
314                    field = field,
315                    dim = dim,
316                );
317                if let Some(d) = self.hnsw_distance {
318                    write!(sql, " DIST {}", d.as_str()).expect("writing to String cannot fail");
319                }
320                if let Some(vt) = self.vector_type {
321                    write!(sql, " TYPE {}", vt.as_str()).expect("writing to String cannot fail");
322                }
323                if let Some(efc) = self.efc {
324                    write!(sql, " EFC {efc}").expect("writing to String cannot fail");
325                }
326                if let Some(m) = self.m {
327                    write!(sql, " M {m}").expect("writing to String cannot fail");
328                }
329                sql.push(';');
330                sql
331            }
332            _ => {
333                let columns = self.columns.join(", ");
334                let mut sql = format!(
335                    "DEFINE INDEX{ine} {name} ON TABLE {table} COLUMNS {columns}",
336                    ine = ine,
337                    name = self.name,
338                    table = table,
339                    columns = columns,
340                );
341                match self.index_type {
342                    IndexType::Unique => sql.push_str(" UNIQUE"),
343                    IndexType::Search => sql.push_str(" SEARCH ANALYZER ascii"),
344                    _ => {}
345                }
346                sql.push(';');
347                sql
348            }
349        }
350    }
351}
352
353/// Immutable event definition (`DEFINE EVENT`).
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct EventDefinition {
356    /// Event name.
357    pub name: String,
358    /// SurrealQL `WHEN` condition expression.
359    pub condition: String,
360    /// SurrealQL `THEN` action.
361    pub action: String,
362}
363
364impl EventDefinition {
365    /// Construct a new [`EventDefinition`].
366    pub fn new(
367        name: impl Into<String>,
368        condition: impl Into<String>,
369        action: impl Into<String>,
370    ) -> Self {
371        Self {
372            name: name.into(),
373            condition: condition.into(),
374            action: action.into(),
375        }
376    }
377
378    /// Validate that the event is not missing required pieces.
379    pub fn validate(&self) -> Result<()> {
380        if self.name.is_empty() {
381            return Err(SurqlError::Validation {
382                reason: "Event name cannot be empty".into(),
383            });
384        }
385        if self.condition.is_empty() {
386            return Err(SurqlError::Validation {
387                reason: format!("Event {:?} must have a condition", self.name),
388            });
389        }
390        if self.action.is_empty() {
391            return Err(SurqlError::Validation {
392                reason: format!("Event {:?} must have an action", self.name),
393            });
394        }
395        Ok(())
396    }
397
398    /// Render the `DEFINE EVENT` statement.
399    pub fn to_surql(&self, table: &str) -> String {
400        self.to_surql_with_options(table, false)
401    }
402
403    /// Render with optional `IF NOT EXISTS` clause.
404    pub fn to_surql_with_options(&self, table: &str, if_not_exists: bool) -> String {
405        let ine = if if_not_exists { " IF NOT EXISTS" } else { "" };
406        format!(
407            "DEFINE EVENT{ine} {name} ON TABLE {table} WHEN {cond} THEN {act};",
408            ine = ine,
409            name = self.name,
410            table = table,
411            cond = self.condition,
412            act = self.action,
413        )
414    }
415}
416
417/// Immutable table schema.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct TableDefinition {
420    /// Table name.
421    pub name: String,
422    /// Schema mode.
423    #[serde(default = "TableDefinition::default_mode")]
424    pub mode: TableMode,
425    /// Field definitions.
426    #[serde(default)]
427    pub fields: Vec<FieldDefinition>,
428    /// Index definitions.
429    #[serde(default)]
430    pub indexes: Vec<IndexDefinition>,
431    /// Event definitions.
432    #[serde(default)]
433    pub events: Vec<EventDefinition>,
434    /// Per-action permissions map.
435    #[serde(skip_serializing_if = "Option::is_none", default)]
436    pub permissions: Option<BTreeMap<String, String>>,
437    /// Whether this table is marked for deletion.
438    #[serde(default)]
439    pub drop: bool,
440}
441
442impl TableDefinition {
443    fn default_mode() -> TableMode {
444        TableMode::Schemafull
445    }
446
447    /// Construct a new [`TableDefinition`] in `SCHEMAFULL` mode.
448    pub fn new(name: impl Into<String>) -> Self {
449        Self {
450            name: name.into(),
451            mode: TableMode::Schemafull,
452            fields: Vec::new(),
453            indexes: Vec::new(),
454            events: Vec::new(),
455            permissions: None,
456            drop: false,
457        }
458    }
459
460    /// Set the schema mode.
461    pub fn with_mode(mut self, mode: TableMode) -> Self {
462        self.mode = mode;
463        self
464    }
465
466    /// Append field definitions.
467    pub fn with_fields<I>(mut self, fields: I) -> Self
468    where
469        I: IntoIterator<Item = FieldDefinition>,
470    {
471        self.fields.extend(fields);
472        self
473    }
474
475    /// Append index definitions.
476    pub fn with_indexes<I>(mut self, indexes: I) -> Self
477    where
478        I: IntoIterator<Item = IndexDefinition>,
479    {
480        self.indexes.extend(indexes);
481        self
482    }
483
484    /// Append event definitions.
485    pub fn with_events<I>(mut self, events: I) -> Self
486    where
487        I: IntoIterator<Item = EventDefinition>,
488    {
489        self.events.extend(events);
490        self
491    }
492
493    /// Replace per-action permissions.
494    pub fn with_permissions<I, K, V>(mut self, permissions: I) -> Self
495    where
496        I: IntoIterator<Item = (K, V)>,
497        K: Into<String>,
498        V: Into<String>,
499    {
500        self.permissions = Some(
501            permissions
502                .into_iter()
503                .map(|(k, v)| (k.into(), v.into()))
504                .collect(),
505        );
506        self
507    }
508
509    /// Mark the table for deletion.
510    pub fn with_drop(mut self, drop: bool) -> Self {
511        self.drop = drop;
512        self
513    }
514
515    /// Validate the table and its contained definitions.
516    pub fn validate(&self) -> Result<()> {
517        if self.name.is_empty() {
518            return Err(SurqlError::Validation {
519                reason: "Table name cannot be empty".into(),
520            });
521        }
522        for field in &self.fields {
523            field.validate()?;
524        }
525        for index in &self.indexes {
526            index.validate()?;
527        }
528        for event in &self.events {
529            event.validate()?;
530        }
531        Ok(())
532    }
533
534    /// Render just the `DEFINE TABLE` statement.
535    pub fn to_surql(&self) -> String {
536        self.to_surql_with_options(false)
537    }
538
539    /// Render the `DEFINE TABLE` statement with optional `IF NOT EXISTS`.
540    pub fn to_surql_with_options(&self, if_not_exists: bool) -> String {
541        let ine = if if_not_exists { " IF NOT EXISTS" } else { "" };
542        format!(
543            "DEFINE TABLE{ine} {name} {mode};",
544            ine = ine,
545            name = self.name,
546            mode = self.mode.as_str(),
547        )
548    }
549
550    /// Render every statement required to create this table.
551    ///
552    /// Returns the `DEFINE TABLE` line followed by each contained field,
553    /// index, event, and permission statement.
554    pub fn to_surql_all(&self) -> Vec<String> {
555        self.to_surql_all_with_options(false)
556    }
557
558    /// Render every statement with optional `IF NOT EXISTS`.
559    pub fn to_surql_all_with_options(&self, if_not_exists: bool) -> Vec<String> {
560        let mut out =
561            Vec::with_capacity(1 + self.fields.len() + self.indexes.len() + self.events.len());
562        out.push(self.to_surql_with_options(if_not_exists));
563        for field in &self.fields {
564            out.push(field.to_surql_with_options(&self.name, if_not_exists));
565        }
566        for index in &self.indexes {
567            out.push(index.to_surql_with_options(&self.name, if_not_exists));
568        }
569        for event in &self.events {
570            out.push(event.to_surql_with_options(&self.name, if_not_exists));
571        }
572        if let Some(perms) = &self.permissions {
573            for (action, rule) in perms {
574                out.push(format!(
575                    "DEFINE FIELD PERMISSIONS FOR {action} ON TABLE {name} WHERE {rule};",
576                    action = action.to_uppercase(),
577                    name = self.name,
578                    rule = rule,
579                ));
580            }
581        }
582        out
583    }
584}
585
586/// Functional constructor mirroring `surql.schema.table.table_schema`.
587pub fn table_schema(name: impl Into<String>) -> TableDefinition {
588    TableDefinition::new(name)
589}
590
591/// Build a standard index.
592pub fn index<I, S>(name: impl Into<String>, columns: I) -> IndexDefinition
593where
594    I: IntoIterator<Item = S>,
595    S: Into<String>,
596{
597    IndexDefinition::new(name, columns)
598}
599
600/// Build a `UNIQUE` index.
601pub fn unique_index<I, S>(name: impl Into<String>, columns: I) -> IndexDefinition
602where
603    I: IntoIterator<Item = S>,
604    S: Into<String>,
605{
606    IndexDefinition::new(name, columns).with_type(IndexType::Unique)
607}
608
609/// Build a full-text `SEARCH` index.
610pub fn search_index<I, S>(name: impl Into<String>, columns: I) -> IndexDefinition
611where
612    I: IntoIterator<Item = S>,
613    S: Into<String>,
614{
615    IndexDefinition::new(name, columns).with_type(IndexType::Search)
616}
617
618/// Build an MTREE vector index.
619pub fn mtree_index(
620    name: impl Into<String>,
621    column: impl Into<String>,
622    dimension: u32,
623    distance: MTreeDistanceType,
624    vector_type: MTreeVectorType,
625) -> IndexDefinition {
626    IndexDefinition {
627        name: name.into(),
628        columns: vec![column.into()],
629        index_type: IndexType::Mtree,
630        dimension: Some(dimension),
631        distance: Some(distance),
632        vector_type: Some(vector_type),
633        hnsw_distance: None,
634        efc: None,
635        m: None,
636    }
637}
638
639/// Build an HNSW vector index.
640///
641/// `efc` and `m` are optional tuning parameters; when omitted, the server
642/// defaults are used.
643pub fn hnsw_index(
644    name: impl Into<String>,
645    column: impl Into<String>,
646    dimension: u32,
647    distance: HnswDistanceType,
648    vector_type: MTreeVectorType,
649    efc: Option<u32>,
650    m: Option<u32>,
651) -> IndexDefinition {
652    IndexDefinition {
653        name: name.into(),
654        columns: vec![column.into()],
655        index_type: IndexType::Hnsw,
656        dimension: Some(dimension),
657        distance: None,
658        vector_type: Some(vector_type),
659        hnsw_distance: Some(distance),
660        efc,
661        m,
662    }
663}
664
665/// Build an [`EventDefinition`].
666pub fn event(
667    name: impl Into<String>,
668    condition: impl Into<String>,
669    action: impl Into<String>,
670) -> EventDefinition {
671    EventDefinition::new(name, condition, action)
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use crate::schema::fields::{int_field, string_field};
678
679    #[test]
680    fn table_mode_strings() {
681        assert_eq!(TableMode::Schemafull.as_str(), "SCHEMAFULL");
682        assert_eq!(TableMode::Schemaless.as_str(), "SCHEMALESS");
683        assert_eq!(TableMode::Drop.as_str(), "DROP");
684    }
685
686    #[test]
687    fn table_mode_display() {
688        assert_eq!(format!("{}", TableMode::Schemafull), "SCHEMAFULL");
689    }
690
691    #[test]
692    fn table_mode_serializes_uppercase() {
693        let json = serde_json::to_string(&TableMode::Schemaless).unwrap();
694        assert_eq!(json, "\"SCHEMALESS\"");
695    }
696
697    #[test]
698    fn index_type_strings() {
699        assert_eq!(IndexType::Unique.as_str(), "UNIQUE");
700        assert_eq!(IndexType::Standard.as_str(), "INDEX");
701        assert_eq!(IndexType::Mtree.as_str(), "MTREE");
702        assert_eq!(IndexType::Hnsw.as_str(), "HNSW");
703    }
704
705    #[test]
706    fn mtree_distance_display() {
707        assert_eq!(format!("{}", MTreeDistanceType::Cosine), "COSINE");
708    }
709
710    #[test]
711    fn hnsw_distance_display() {
712        assert_eq!(format!("{}", HnswDistanceType::Chebyshev), "CHEBYSHEV");
713    }
714
715    #[test]
716    fn mtree_vector_type_display() {
717        assert_eq!(format!("{}", MTreeVectorType::F32), "F32");
718    }
719
720    #[test]
721    fn table_to_surql_schemafull() {
722        let t = table_schema("user");
723        assert_eq!(t.to_surql(), "DEFINE TABLE user SCHEMAFULL;");
724    }
725
726    #[test]
727    fn table_to_surql_schemaless() {
728        let t = table_schema("log").with_mode(TableMode::Schemaless);
729        assert_eq!(t.to_surql(), "DEFINE TABLE log SCHEMALESS;");
730    }
731
732    #[test]
733    fn table_to_surql_if_not_exists() {
734        let t = table_schema("user");
735        assert_eq!(
736            t.to_surql_with_options(true),
737            "DEFINE TABLE IF NOT EXISTS user SCHEMAFULL;"
738        );
739    }
740
741    #[test]
742    fn table_to_surql_all_includes_fields() {
743        let t = table_schema("user").with_fields([
744            string_field("name").build_unchecked().unwrap(),
745            int_field("age").build_unchecked().unwrap(),
746        ]);
747        let stmts = t.to_surql_all();
748        assert_eq!(stmts[0], "DEFINE TABLE user SCHEMAFULL;");
749        assert!(stmts
750            .iter()
751            .any(|s| s.contains("DEFINE FIELD name ON TABLE user TYPE string")));
752        assert!(stmts
753            .iter()
754            .any(|s| s.contains("DEFINE FIELD age ON TABLE user TYPE int")));
755    }
756
757    #[test]
758    fn table_to_surql_all_includes_unique_index() {
759        let t = table_schema("user").with_indexes([unique_index("email_idx", ["email"])]);
760        let stmts = t.to_surql_all();
761        assert!(stmts
762            .iter()
763            .any(|s| s == "DEFINE INDEX email_idx ON TABLE user COLUMNS email UNIQUE;"));
764    }
765
766    #[test]
767    fn table_to_surql_all_includes_event() {
768        let t = table_schema("user").with_events([event(
769            "email_changed",
770            "$before.email != $after.email",
771            "CREATE audit_log",
772        )]);
773        let stmts = t.to_surql_all();
774        assert!(stmts
775            .iter()
776            .any(|s| s.starts_with("DEFINE EVENT email_changed ON TABLE user")));
777    }
778
779    #[test]
780    fn table_permissions_render_upper() {
781        let t = table_schema("user").with_permissions([("select", "$auth.id = id")]);
782        let stmts = t.to_surql_all();
783        assert!(stmts
784            .iter()
785            .any(|s| s.contains("FOR SELECT") && s.contains("$auth.id = id")));
786    }
787
788    #[test]
789    fn index_new_defaults_to_standard() {
790        let idx = index("title_idx", ["title"]);
791        assert_eq!(idx.index_type, IndexType::Standard);
792    }
793
794    #[test]
795    fn unique_index_to_surql() {
796        let idx = unique_index("email_idx", ["email"]);
797        assert_eq!(
798            idx.to_surql("user"),
799            "DEFINE INDEX email_idx ON TABLE user COLUMNS email UNIQUE;"
800        );
801    }
802
803    #[test]
804    fn standard_index_to_surql() {
805        let idx = index("title_idx", ["title"]);
806        assert_eq!(
807            idx.to_surql("post"),
808            "DEFINE INDEX title_idx ON TABLE post COLUMNS title;"
809        );
810    }
811
812    #[test]
813    fn search_index_to_surql() {
814        let idx = search_index("content_search", ["title", "content"]);
815        assert_eq!(
816            idx.to_surql("post"),
817            "DEFINE INDEX content_search ON TABLE post COLUMNS title, content SEARCH ANALYZER ascii;"
818        );
819    }
820
821    #[test]
822    fn mtree_index_to_surql() {
823        let idx = mtree_index(
824            "embedding_idx",
825            "embedding",
826            1536,
827            MTreeDistanceType::Cosine,
828            MTreeVectorType::F32,
829        );
830        let sql = idx.to_surql("doc");
831        assert!(sql.contains(
832            "DEFINE INDEX embedding_idx ON TABLE doc COLUMNS embedding MTREE DIMENSION 1536"
833        ));
834        assert!(sql.contains("DIST COSINE"));
835        assert!(sql.contains("TYPE F32"));
836    }
837
838    #[test]
839    fn hnsw_index_to_surql_with_efc_m() {
840        let idx = hnsw_index(
841            "feat_idx",
842            "features",
843            128,
844            HnswDistanceType::Cosine,
845            MTreeVectorType::F32,
846            Some(500),
847            Some(16),
848        );
849        let sql = idx.to_surql("doc");
850        assert!(sql.contains("HNSW DIMENSION 128"));
851        assert!(sql.contains("DIST COSINE"));
852        assert!(sql.contains("TYPE F32"));
853        assert!(sql.contains("EFC 500"));
854        assert!(sql.contains("M 16"));
855    }
856
857    #[test]
858    fn hnsw_index_without_efc_m_omits_them() {
859        let idx = hnsw_index(
860            "feat_idx",
861            "features",
862            64,
863            HnswDistanceType::Euclidean,
864            MTreeVectorType::F64,
865            None,
866            None,
867        );
868        let sql = idx.to_surql("doc");
869        assert!(!sql.contains("EFC"));
870        assert!(!sql.contains("M 12"));
871    }
872
873    #[test]
874    fn index_to_surql_if_not_exists() {
875        let idx = unique_index("email_idx", ["email"]);
876        assert_eq!(
877            idx.to_surql_with_options("user", true),
878            "DEFINE INDEX IF NOT EXISTS email_idx ON TABLE user COLUMNS email UNIQUE;"
879        );
880    }
881
882    #[test]
883    fn event_to_surql() {
884        let ev = event(
885            "email_changed",
886            "$before.email != $after.email",
887            "CREATE audit_log SET user = $value.id",
888        );
889        assert_eq!(
890            ev.to_surql("user"),
891            "DEFINE EVENT email_changed ON TABLE user WHEN $before.email != $after.email \
892             THEN CREATE audit_log SET user = $value.id;"
893        );
894    }
895
896    #[test]
897    fn event_to_surql_if_not_exists() {
898        let ev = event("n", "true", "do");
899        assert!(ev
900            .to_surql_with_options("t", true)
901            .starts_with("DEFINE EVENT IF NOT EXISTS n ON TABLE t"));
902    }
903
904    #[test]
905    fn event_validate_rejects_empty() {
906        assert!(event("", "c", "a").validate().is_err());
907        assert!(event("n", "", "a").validate().is_err());
908        assert!(event("n", "c", "").validate().is_err());
909    }
910
911    #[test]
912    fn index_validate_rejects_empty_name() {
913        let mut idx = unique_index("x", ["a"]);
914        idx.name = String::new();
915        assert!(idx.validate().is_err());
916    }
917
918    #[test]
919    fn index_validate_rejects_empty_columns() {
920        let idx = IndexDefinition::new("x", Vec::<String>::new()).with_type(IndexType::Unique);
921        assert!(idx.validate().is_err());
922    }
923
924    #[test]
925    fn index_validate_mtree_requires_dimension() {
926        let mut idx = IndexDefinition::new("x", ["v"]).with_type(IndexType::Mtree);
927        assert!(idx.validate().is_err());
928        idx.dimension = Some(64);
929        assert!(idx.validate().is_ok());
930    }
931
932    #[test]
933    fn index_validate_hnsw_requires_dimension() {
934        let idx = IndexDefinition::new("x", ["v"]).with_type(IndexType::Hnsw);
935        assert!(idx.validate().is_err());
936    }
937
938    #[test]
939    fn table_validate_rejects_empty_name() {
940        assert!(table_schema("").validate().is_err());
941    }
942
943    #[test]
944    fn table_validate_propagates_field_errors() {
945        let t = table_schema("user").with_fields([FieldDefinition::new(
946            "1bad",
947            crate::schema::fields::FieldType::String,
948        )]);
949        assert!(t.validate().is_err());
950    }
951
952    #[test]
953    fn table_statement_order_defines_table_first() {
954        let t = table_schema("user")
955            .with_fields([string_field("name").build_unchecked().unwrap()])
956            .with_indexes([unique_index("name_idx", ["name"])]);
957        let stmts = t.to_surql_all();
958        assert!(stmts[0].starts_with("DEFINE TABLE"));
959    }
960
961    #[test]
962    fn minimal_table_returns_single_statement() {
963        let t = table_schema("empty");
964        assert_eq!(t.to_surql_all().len(), 1);
965    }
966
967    #[test]
968    fn table_definition_clone_eq() {
969        let t1 = table_schema("user").with_mode(TableMode::Schemafull);
970        let t2 = t1.clone();
971        assert_eq!(t1, t2);
972    }
973}