Skip to main content

kdl_codegen/emit/
surrealql.rs

1//! SurrealQL emitter — renders [`ir::Schema`] into SurrealDB schema DDL.
2//!
3//! Unlike the Rust / TypeScript / Zod emitters (which produce application-layer
4//! types and validators), this emitter produces **database schema** —
5//! `DEFINE TABLE` / `DEFINE FIELD` statements. The protocol dialect
6//! (`channel` / `request` / `event`) has no database representation, so this
7//! emitter consumes the **data and entity dialects only**; a protocol-only
8//! schema yields just the header.
9//!
10//! ## Type mapping decisions (派生 todo `mem_1Cb5kAE5aAqYimBRfBnzVj`)
11//!
12//! - **struct**: an embedded value type — a `DEFINE TABLE ... SCHEMAFULL`
13//!   with no explicit `TYPE`. A struct reference is rendered as an embedded
14//!   `object` is *not* used; instead a named struct becomes a `record<table>`
15//!   link, matching the prior behaviour.
16//! - **record**: a first-class entity — `DEFINE TABLE <t> TYPE NORMAL
17//!   SCHEMAFULL`. Each `record` is one table; its `id` is the table id.
18//! - **relation**: a graph edge — `DEFINE TABLE <t> TYPE RELATION IN <from>
19//!   OUT <to> SCHEMAFULL`. A `unique=#true` relation also emits a
20//!   `DEFINE INDEX ... UNIQUE` on `(in, out)`.
21//! - **enum**: SurrealDB has no enum type. An enum-typed field becomes
22//!   `string` with an `ASSERT $value INSIDE [...]` clause listing the variants.
23//! - **`link<Record>`**: a `record<table>` link.
24//! - **literal / literal union**: `'a' | 'b'` becomes `string` with an
25//!   `ASSERT $value INSIDE ['a', 'b']` clause.
26//! - **`object` + `flexible=#true`**: rendered as `FLEXIBLE TYPE object`
27//!   (schemaless nested object).
28//! - **optional**: a non-required field is wrapped in `option<T>`.
29//!
30//! ## Tier 2 — description / constraints
31//!
32//! - **description**: a `COMMENT '...'` clause on the `DEFINE TABLE` (for a
33//!   type definition) and on the `DEFINE FIELD` (for a field description).
34//! - **constraints**: emitted as `ASSERT` conditions —
35//!   - `min` / `max` → `$value >= N` / `$value <= M`,
36//!   - `min_length` / `max_length` → `string::len($value)` for a string and
37//!     `array::len($value)` for an array,
38//!   - `pattern` → `string::matches($value, '<pattern>')` (SurrealDB's regex
39//!     match function).
40//!
41//!   Every condition — the type-derived value-set (`$value INSIDE [...]`) and
42//!   the constraint conditions — is joined into one `ASSERT` clause with `AND`.
43
44use std::collections::HashMap;
45
46use crate::Emitter;
47use crate::ir;
48
49use super::case::to_snake_case;
50
51/// The SurrealQL code generation target.
52#[derive(Debug, Default, Clone, Copy)]
53pub struct SurrealQlEmitter;
54
55impl SurrealQlEmitter {
56    /// Create a new [`SurrealQlEmitter`].
57    pub fn new() -> Self {
58        Self
59    }
60}
61
62impl Emitter for SurrealQlEmitter {
63    fn emit(&self, schema: &ir::Schema) -> String {
64        // Map enum name → variants, for `ASSERT $value INSIDE [...]` rendering.
65        let enums: HashMap<&str, &[String]> = schema
66            .types
67            .iter()
68            .filter_map(|t| match t {
69                ir::TypeDef::Enum { name, variants, .. } => {
70                    Some((name.as_str(), variants.as_slice()))
71                }
72                ir::TypeDef::Struct { .. } => None,
73            })
74            .collect();
75
76        let mut code = String::from(HEADER);
77        // data dialect — `struct` tables (embedded value types).
78        for ty in &schema.types {
79            if let ir::TypeDef::Struct {
80                name,
81                description,
82                fields,
83            } = ty
84            {
85                code.push('\n');
86                code.push_str(&render_table(
87                    name,
88                    description.as_deref(),
89                    fields,
90                    TableKind::Struct,
91                    &enums,
92                ));
93            }
94        }
95        // entity dialect — `record` tables.
96        for record in &schema.records {
97            code.push('\n');
98            code.push_str(&render_table(
99                &record.name,
100                record.description.as_deref(),
101                &record.fields,
102                TableKind::Record,
103                &enums,
104            ));
105        }
106        // entity dialect — `relation` (edge) tables.
107        for relation in &schema.relations {
108            code.push('\n');
109            code.push_str(&render_relation(relation, &enums));
110        }
111        code
112    }
113}
114
115/// Which flavour of `DEFINE TABLE` to emit.
116#[derive(Clone, Copy)]
117enum TableKind {
118    /// A `struct` — an embedded value type. No explicit `TYPE` clause, to
119    /// keep the prior behaviour byte-stable.
120    Struct,
121    /// A `record` — a first-class entity. `TYPE NORMAL`.
122    Record,
123}
124
125/// Fixed header block.
126const HEADER: &str = "\
127-- Auto-generated SurrealDB schema
128-- DO NOT EDIT MANUALLY
129";
130
131/// Render a `COMMENT '...'` clause from an optional description, or the empty
132/// string. The description is single-quoted with `'` and `\` escaped.
133fn comment_clause(description: Option<&str>) -> String {
134    match description {
135        Some(text) => {
136            let escaped = text.replace('\\', "\\\\").replace('\'', "\\'");
137            format!(" COMMENT '{escaped}'")
138        }
139        None => String::new(),
140    }
141}
142
143/// Render one `struct` / `record` as a `DEFINE TABLE` plus its `DEFINE FIELD`s.
144fn render_table(
145    name: &str,
146    description: Option<&str>,
147    fields: &[ir::Field],
148    kind: TableKind,
149    enums: &HashMap<&str, &[String]>,
150) -> String {
151    let table = to_snake_case(name);
152    let type_clause = match kind {
153        TableKind::Struct => "",
154        TableKind::Record => "TYPE NORMAL ",
155    };
156    let mut out = format!(
157        "DEFINE TABLE {table} {type_clause}SCHEMAFULL{};\n",
158        comment_clause(description)
159    );
160    for field in fields {
161        out.push_str(&render_field(&table, field, enums));
162    }
163    out
164}
165
166/// Render one `relation` as a `DEFINE TABLE ... TYPE RELATION` plus its edge
167/// `DEFINE FIELD`s and, when `unique`, a `DEFINE INDEX ... UNIQUE` on
168/// `(in, out)`.
169fn render_relation(relation: &ir::Relation, enums: &HashMap<&str, &[String]>) -> String {
170    let table = to_snake_case(&relation.name);
171    let in_t = to_snake_case(&relation.from);
172    let out_t = to_snake_case(&relation.to);
173    let mut out = format!(
174        "DEFINE TABLE {table} TYPE RELATION IN {in_t} OUT {out_t} SCHEMAFULL{};\n",
175        comment_clause(relation.description.as_deref())
176    );
177    for field in &relation.fields {
178        out.push_str(&render_field(&table, field, enums));
179    }
180    if relation.unique {
181        out.push_str(&format!(
182            "DEFINE INDEX {table}_unique_edge ON {table} FIELDS in, out UNIQUE;\n"
183        ));
184    }
185    out
186}
187
188/// Render one `DEFINE FIELD` statement.
189///
190/// The `ASSERT` clause combines the type-derived condition (enum / literal
191/// value-set) with any [`ir::Constraints`] conditions, joined by `AND` so a
192/// constrained enum field still enforces both its value set and its bounds.
193fn render_field(table: &str, field: &ir::Field, enums: &HashMap<&str, &[String]>) -> String {
194    let (base, ty_assert) = ty_to_surql(&field.ty, enums);
195    let full = if field.required {
196        base
197    } else {
198        format!("option<{base}>")
199    };
200    // `flexible=#true` on an `object` field → schemaless nested object.
201    let flexible = if field.flexible && is_object_ty(&field.ty) {
202        "FLEXIBLE "
203    } else {
204        ""
205    };
206    let mut line = format!(
207        "DEFINE FIELD {} ON {table} {flexible}TYPE {full}",
208        field.name
209    );
210    // Collect every ASSERT condition: the type-derived one first, then the
211    // constraint-derived ones; join with `AND`.
212    let mut conditions: Vec<String> = Vec::new();
213    conditions.extend(ty_assert);
214    conditions.extend(constraint_conditions(&field.ty, &field.constraints));
215    if !conditions.is_empty() {
216        let joined = conditions.join(" AND ");
217        // An optional field may hold NONE. Guard the assertion with
218        // `$value = NONE OR …` so an absent value always passes — SurrealDB's
219        // idiom for a constrained `option<T>` field. `AND` binds tighter than
220        // `OR`, so no parentheses are needed.
221        let assert = if field.required {
222            joined
223        } else {
224            format!("$value = NONE OR {joined}")
225        };
226        line.push_str(&format!(" ASSERT {assert}"));
227    }
228    if let Some(default) = &field.default {
229        line.push_str(&format!(" DEFAULT {}", surql_default(&field.ty, default)));
230    }
231    line.push_str(&comment_clause(field.description.as_deref()));
232    line.push_str(";\n");
233    line
234}
235
236/// Render the SurrealQL `ASSERT` conditions for a field's [`ir::Constraints`].
237///
238/// - `min` / `max` → `$value >= N` / `$value <= M` (numeric range).
239/// - `min_length` / `max_length` → `string::len($value) >= N` for a string,
240///   `array::len($value) >= N` for an array.
241/// - `pattern` → `string::matches($value, '<pattern>')` — SurrealDB's regex
242///   match function.
243fn constraint_conditions(ty: &ir::Ty, c: &ir::Constraints) -> Vec<String> {
244    let mut out: Vec<String> = Vec::new();
245    if let Some(min) = c.min {
246        out.push(format!("$value >= {min}"));
247    }
248    if let Some(max) = c.max {
249        out.push(format!("$value <= {max}"));
250    }
251    // Length bounds apply to strings and arrays via the matching `*::len`.
252    let len_fn = match ty {
253        ir::Ty::Array(_) => Some("array::len"),
254        ir::Ty::Primitive(ir::Prim::String) => Some("string::len"),
255        _ => None,
256    };
257    if let Some(len_fn) = len_fn {
258        if let Some(min) = c.min_length {
259            out.push(format!("{len_fn}($value) >= {min}"));
260        }
261        if let Some(max) = c.max_length {
262            out.push(format!("{len_fn}($value) <= {max}"));
263        }
264    }
265    if let Some(pattern) = &c.pattern {
266        let escaped = pattern.replace('\\', "\\\\").replace('\'', "\\'");
267        out.push(format!("string::matches($value, '{escaped}')"));
268    }
269    out
270}
271
272/// Whether a type is the `object` primitive (so `flexible=` applies).
273fn is_object_ty(ty: &ir::Ty) -> bool {
274    matches!(ty, ir::Ty::Primitive(ir::Prim::Json))
275}
276
277/// Render a field default for SurrealQL. String-ish types are single-quoted;
278/// numeric / boolean defaults are passed through verbatim.
279fn surql_default(ty: &ir::Ty, raw: &str) -> String {
280    let quote = matches!(
281        ty,
282        ir::Ty::Primitive(ir::Prim::String)
283            | ir::Ty::Primitive(ir::Prim::Datetime)
284            | ir::Ty::Literal(_)
285            | ir::Ty::Named(_)
286    ) || matches!(ty, ir::Ty::Union(members)
287        if members.iter().all(|m| matches!(m, ir::Ty::Literal(_))));
288    if quote {
289        format!("'{raw}'")
290    } else {
291        raw.to_string()
292    }
293}
294
295/// Map an [`ir::Ty`] to a SurrealQL type, plus an optional `ASSERT` *condition*
296/// (used to constrain enum-typed and literal-union fields to their value set).
297///
298/// The returned condition has no `ASSERT` prefix — [`render_field`] joins it
299/// with any constraint conditions via `AND` and prefixes `ASSERT` once.
300fn ty_to_surql(ty: &ir::Ty, enums: &HashMap<&str, &[String]>) -> (String, Option<String>) {
301    match ty {
302        ir::Ty::Primitive(p) => (prim_to_surql(*p).to_string(), None),
303        ir::Ty::Array(inner) => {
304            let (inner_ty, _) = ty_to_surql(inner, enums);
305            (format!("array<{inner_ty}>"), None)
306        }
307        ir::Ty::Named(name) => match enums.get(name.as_str()) {
308            // enum → string constrained by an ASSERT condition.
309            Some(variants) => (
310                "string".to_string(),
311                Some(inside_condition(variants.iter().map(String::as_str))),
312            ),
313            // struct → a record link to that struct's table.
314            None => (format!("record<{}>", to_snake_case(name)), None),
315        },
316        // a `link<Record>` → a SurrealDB record link.
317        ir::Ty::Link(name) => (format!("record<{}>", to_snake_case(name)), None),
318        // a bare literal → string constrained to that single value.
319        ir::Ty::Literal(value) => (
320            "string".to_string(),
321            Some(inside_condition(std::iter::once(value.as_str()))),
322        ),
323        ir::Ty::Union(members) => {
324            // A union of string literals → `string` with an `INSIDE [...]`.
325            if let Some(values) = literal_union_values(members) {
326                (
327                    "string".to_string(),
328                    Some(inside_condition(values.iter().map(String::as_str))),
329                )
330            } else {
331                // A non-literal union → a SurrealDB union type
332                // (`record<x> | string` etc.). Members mapping to the same
333                // type are de-duplicated; per-member `ASSERT`s are dropped
334                // (a mixed union has no single value set).
335                let mut parts: Vec<String> = Vec::new();
336                for m in members {
337                    let (t, _) = ty_to_surql(m, enums);
338                    if !parts.contains(&t) {
339                        parts.push(t);
340                    }
341                }
342                (parts.join(" | "), None)
343            }
344        }
345    }
346}
347
348/// If every union member is a [`ir::Ty::Literal`], return their values.
349fn literal_union_values(members: &[ir::Ty]) -> Option<Vec<String>> {
350    members
351        .iter()
352        .map(|m| match m {
353            ir::Ty::Literal(v) => Some(v.clone()),
354            _ => None,
355        })
356        .collect()
357}
358
359/// Build a `$value INSIDE ['a', 'b', ...]` condition. `INSIDE` is SurrealDB's
360/// canonical set-membership operator (matching the convention in production
361/// creo schemas).
362fn inside_condition<'a>(values: impl Iterator<Item = &'a str>) -> String {
363    let list: Vec<String> = values.map(|v| format!("'{v}'")).collect();
364    format!("$value INSIDE [{}]", list.join(", "))
365}
366
367/// Map an [`ir::Prim`] to its SurrealQL type.
368fn prim_to_surql(p: ir::Prim) -> &'static str {
369    match p {
370        ir::Prim::String => "string",
371        ir::Prim::Int => "int",
372        ir::Prim::Float => "float",
373        ir::Prim::Bool => "bool",
374        ir::Prim::Datetime => "datetime",
375        ir::Prim::Json => "object",
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
384        ir::Field {
385            name: name.to_string(),
386            ty,
387            required,
388            flexible: false,
389            default: None,
390            description: None,
391            constraints: ir::Constraints::default(),
392        }
393    }
394
395    #[test]
396    fn emits_header() {
397        let out = SurrealQlEmitter::new().emit(&ir::Schema::default());
398        assert!(out.contains("-- Auto-generated SurrealDB schema"));
399    }
400
401    #[test]
402    fn struct_becomes_define_table_and_fields() {
403        let schema = ir::Schema {
404            types: vec![ir::TypeDef::Struct {
405                name: "User".to_string(),
406                description: None,
407                fields: vec![
408                    field("id", ir::Ty::Primitive(ir::Prim::String), true),
409                    field("age", ir::Ty::Primitive(ir::Prim::Int), true),
410                ],
411            }],
412            protocol: None,
413            ..Default::default()
414        };
415        let out = SurrealQlEmitter::new().emit(&schema);
416        assert!(out.contains("DEFINE TABLE user SCHEMAFULL;"));
417        assert!(out.contains("DEFINE FIELD id ON user TYPE string;"));
418        assert!(out.contains("DEFINE FIELD age ON user TYPE int;"));
419    }
420
421    #[test]
422    fn optional_field_becomes_option_type() {
423        let schema = ir::Schema {
424            types: vec![ir::TypeDef::Struct {
425                name: "User".to_string(),
426                description: None,
427                fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
428            }],
429            protocol: None,
430            ..Default::default()
431        };
432        let out = SurrealQlEmitter::new().emit(&schema);
433        assert!(out.contains("DEFINE FIELD nick ON user TYPE option<string>;"));
434    }
435
436    #[test]
437    fn enum_reference_becomes_string_with_assert() {
438        let schema = ir::Schema {
439            types: vec![
440                ir::TypeDef::Struct {
441                    name: "User".to_string(),
442                    description: None,
443                    fields: vec![field("role", ir::Ty::Named("Role".to_string()), true)],
444                },
445                ir::TypeDef::Enum {
446                    name: "Role".to_string(),
447                    description: None,
448                    variants: vec!["admin".to_string(), "member".to_string()],
449                },
450            ],
451            protocol: None,
452            ..Default::default()
453        };
454        let out = SurrealQlEmitter::new().emit(&schema);
455        assert!(out.contains(
456            "DEFINE FIELD role ON user TYPE string ASSERT $value INSIDE ['admin', 'member'];"
457        ));
458    }
459
460    #[test]
461    fn struct_reference_becomes_record_link() {
462        let schema = ir::Schema {
463            types: vec![
464                ir::TypeDef::Struct {
465                    name: "Post".to_string(),
466                    description: None,
467                    fields: vec![field("author", ir::Ty::Named("User".to_string()), true)],
468                },
469                ir::TypeDef::Struct {
470                    name: "User".to_string(),
471                    description: None,
472                    fields: vec![field("id", ir::Ty::Primitive(ir::Prim::String), true)],
473                },
474            ],
475            protocol: None,
476            ..Default::default()
477        };
478        let out = SurrealQlEmitter::new().emit(&schema);
479        assert!(out.contains("DEFINE FIELD author ON post TYPE record<user>;"));
480    }
481
482    #[test]
483    fn array_and_primitive_mapping() {
484        let schema = ir::Schema {
485            types: vec![ir::TypeDef::Struct {
486                name: "T".to_string(),
487                description: None,
488                fields: vec![
489                    field("f", ir::Ty::Primitive(ir::Prim::Float), true),
490                    field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
491                    field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
492                    field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
493                    field(
494                        "tags",
495                        ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
496                        true,
497                    ),
498                ],
499            }],
500            protocol: None,
501            ..Default::default()
502        };
503        let out = SurrealQlEmitter::new().emit(&schema);
504        assert!(out.contains("TYPE float;"));
505        assert!(out.contains("TYPE bool;"));
506        assert!(out.contains("TYPE datetime;"));
507        assert!(out.contains("TYPE object;"));
508        assert!(out.contains("TYPE array<string>;"));
509    }
510
511    #[test]
512    fn protocol_only_schema_yields_header_only() {
513        // The protocol dialect has no DB representation — a protocol-only
514        // schema produces just the header, no DEFINE statements.
515        let schema = ir::Schema {
516            types: vec![],
517            records: vec![],
518            relations: vec![],
519            protocol: Some(ir::Protocol {
520                name: "p".to_string(),
521                version: "1.0.0".to_string(),
522                namespace: None,
523                description: None,
524                channels: vec![],
525            }),
526        };
527        let out = SurrealQlEmitter::new().emit(&schema);
528        assert!(out.contains("-- Auto-generated SurrealDB schema"));
529        assert!(!out.contains("DEFINE TABLE"));
530    }
531
532    // -------------------------------------------------------------------------
533    // Tier 1 — record / relation / link / union / flexible / default
534    // -------------------------------------------------------------------------
535
536    #[test]
537    fn record_becomes_define_table_type_normal() {
538        let schema = ir::Schema {
539            records: vec![ir::Record {
540                name: "Atlas".to_string(),
541                description: None,
542                id_strategy: ir::IdStrategy::Uuidv7,
543                fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
544            }],
545            ..Default::default()
546        };
547        let out = SurrealQlEmitter::new().emit(&schema);
548        assert!(out.contains("DEFINE TABLE atlas TYPE NORMAL SCHEMAFULL;"));
549        assert!(out.contains("DEFINE FIELD name ON atlas TYPE string;"));
550    }
551
552    #[test]
553    fn struct_table_keeps_no_type_clause() {
554        // A `struct` must stay byte-stable with the pre-Tier-1 output:
555        // `DEFINE TABLE <t> SCHEMAFULL;` with no `TYPE`.
556        let schema = ir::Schema {
557            types: vec![ir::TypeDef::Struct {
558                name: "GeoPoint".to_string(),
559                description: None,
560                fields: vec![field("lat", ir::Ty::Primitive(ir::Prim::Float), true)],
561            }],
562            ..Default::default()
563        };
564        let out = SurrealQlEmitter::new().emit(&schema);
565        assert!(out.contains("DEFINE TABLE geo_point SCHEMAFULL;"));
566        assert!(!out.contains("TYPE NORMAL"));
567    }
568
569    #[test]
570    fn relation_becomes_define_table_type_relation_with_index() {
571        let schema = ir::Schema {
572            relations: vec![ir::Relation {
573                name: "derivedFrom".to_string(),
574                description: None,
575                from: "Memory".to_string(),
576                to: "Memory".to_string(),
577                unique: true,
578                fields: vec![field(
579                    "confidence",
580                    ir::Ty::Primitive(ir::Prim::Float),
581                    false,
582                )],
583            }],
584            ..Default::default()
585        };
586        let out = SurrealQlEmitter::new().emit(&schema);
587        assert!(
588            out.contains(
589                "DEFINE TABLE derived_from TYPE RELATION IN memory OUT memory SCHEMAFULL;"
590            )
591        );
592        assert!(out.contains("DEFINE FIELD confidence ON derived_from TYPE option<float>;"));
593        assert!(out.contains(
594            "DEFINE INDEX derived_from_unique_edge ON derived_from FIELDS in, out UNIQUE;"
595        ));
596    }
597
598    #[test]
599    fn non_unique_relation_omits_index() {
600        let schema = ir::Schema {
601            relations: vec![ir::Relation {
602                name: "tagged".to_string(),
603                description: None,
604                from: "Note".to_string(),
605                to: "Tag".to_string(),
606                unique: false,
607                fields: vec![],
608            }],
609            ..Default::default()
610        };
611        let out = SurrealQlEmitter::new().emit(&schema);
612        assert!(out.contains("TYPE RELATION IN note OUT tag"));
613        assert!(!out.contains("DEFINE INDEX"));
614    }
615
616    #[test]
617    fn link_field_becomes_record_link() {
618        let schema = ir::Schema {
619            records: vec![ir::Record {
620                name: "Atlas".to_string(),
621                description: None,
622                id_strategy: ir::IdStrategy::Uuidv7,
623                fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
624            }],
625            ..Default::default()
626        };
627        let out = SurrealQlEmitter::new().emit(&schema);
628        assert!(out.contains("DEFINE FIELD parent ON atlas TYPE option<record<atlas>>;"));
629    }
630
631    #[test]
632    fn literal_union_becomes_string_with_assert() {
633        let schema = ir::Schema {
634            records: vec![ir::Record {
635                name: "Doc".to_string(),
636                description: None,
637                id_strategy: ir::IdStrategy::Uuidv7,
638                fields: vec![field(
639                    "visibility",
640                    ir::Ty::Union(vec![
641                        ir::Ty::Literal("public".to_string()),
642                        ir::Ty::Literal("private".to_string()),
643                    ]),
644                    true,
645                )],
646            }],
647            ..Default::default()
648        };
649        let out = SurrealQlEmitter::new().emit(&schema);
650        assert!(out.contains(
651            "DEFINE FIELD visibility ON doc TYPE string ASSERT $value INSIDE ['public', 'private'];"
652        ));
653    }
654
655    #[test]
656    fn flexible_object_field_emits_flexible_keyword() {
657        let mut f = field("metadata", ir::Ty::Primitive(ir::Prim::Json), true);
658        f.flexible = true;
659        let schema = ir::Schema {
660            records: vec![ir::Record {
661                name: "Atlas".to_string(),
662                description: None,
663                id_strategy: ir::IdStrategy::Uuidv7,
664                fields: vec![f],
665            }],
666            ..Default::default()
667        };
668        let out = SurrealQlEmitter::new().emit(&schema);
669        assert!(out.contains("DEFINE FIELD metadata ON atlas FLEXIBLE TYPE object;"));
670    }
671
672    #[test]
673    fn default_value_is_quoted_for_string_types() {
674        let mut f = field("visibility", ir::Ty::Primitive(ir::Prim::String), true);
675        f.default = Some("private".to_string());
676        let mut g = field("count", ir::Ty::Primitive(ir::Prim::Int), true);
677        g.default = Some("0".to_string());
678        let schema = ir::Schema {
679            records: vec![ir::Record {
680                name: "Doc".to_string(),
681                description: None,
682                id_strategy: ir::IdStrategy::Uuidv7,
683                fields: vec![f, g],
684            }],
685            ..Default::default()
686        };
687        let out = SurrealQlEmitter::new().emit(&schema);
688        assert!(out.contains("DEFAULT 'private'"), "string default quoted");
689        assert!(out.contains("DEFAULT 0"), "numeric default unquoted");
690    }
691
692    // -------------------------------------------------------------------------
693    // Tier 2 — description -> COMMENT, constraints -> ASSERT, value-set -> INSIDE
694    // -------------------------------------------------------------------------
695
696    #[test]
697    fn record_and_field_descriptions_become_comment_clauses() {
698        let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
699        content.description = Some("Memory content text".to_string());
700        let schema = ir::Schema {
701            records: vec![ir::Record {
702                name: "Memory".to_string(),
703                description: Some("User memory".to_string()),
704                id_strategy: ir::IdStrategy::Uuidv7,
705                fields: vec![content],
706            }],
707            ..Default::default()
708        };
709        let out = SurrealQlEmitter::new().emit(&schema);
710        assert!(
711            out.contains("DEFINE TABLE memory TYPE NORMAL SCHEMAFULL COMMENT 'User memory';"),
712            "table COMMENT; got: {out}"
713        );
714        assert!(
715            out.contains(
716                "DEFINE FIELD content ON memory TYPE string COMMENT 'Memory content text';"
717            ),
718            "field COMMENT; got: {out}"
719        );
720    }
721
722    #[test]
723    fn numeric_constraints_become_assert_range() {
724        let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
725        f.constraints = ir::Constraints {
726            min: Some(0),
727            max: Some(1),
728            ..Default::default()
729        };
730        let schema = ir::Schema {
731            types: vec![ir::TypeDef::Struct {
732                name: "T".to_string(),
733                description: None,
734                fields: vec![f],
735            }],
736            ..Default::default()
737        };
738        let out = SurrealQlEmitter::new().emit(&schema);
739        assert!(
740            out.contains(
741                "DEFINE FIELD confidence ON t TYPE float ASSERT $value >= 0 AND $value <= 1;"
742            ),
743            "got: {out}"
744        );
745    }
746
747    #[test]
748    fn string_length_and_pattern_constraints_become_assert() {
749        let mut f = field("name", ir::Ty::Primitive(ir::Prim::String), true);
750        f.constraints = ir::Constraints {
751            min_length: Some(1),
752            max_length: Some(32),
753            pattern: Some("^[a-z]+$".to_string()),
754            ..Default::default()
755        };
756        let schema = ir::Schema {
757            types: vec![ir::TypeDef::Struct {
758                name: "T".to_string(),
759                description: None,
760                fields: vec![f],
761            }],
762            ..Default::default()
763        };
764        let out = SurrealQlEmitter::new().emit(&schema);
765        assert!(
766            out.contains(
767            "ASSERT string::len($value) >= 1 AND string::len($value) <= 32 AND string::matches($value, '^[a-z]+$')"
768            ),
769            "string length + pattern ASSERT; got: {out}"
770        );
771    }
772
773    #[test]
774    fn array_length_constraint_uses_array_len() {
775        let mut f = field(
776            "tags",
777            ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
778            true,
779        );
780        f.constraints = ir::Constraints {
781            min_length: Some(2),
782            ..Default::default()
783        };
784        let schema = ir::Schema {
785            types: vec![ir::TypeDef::Struct {
786                name: "T".to_string(),
787                description: None,
788                fields: vec![f],
789            }],
790            ..Default::default()
791        };
792        let out = SurrealQlEmitter::new().emit(&schema);
793        assert!(out.contains("ASSERT array::len($value) >= 2"), "got: {out}");
794    }
795
796    #[test]
797    fn enum_value_set_and_constraint_assert_are_anded() {
798        // A constrained enum field keeps both the value-set INSIDE check and
799        // the constraint ASSERT, joined by AND.
800        let mut f = field("role", ir::Ty::Named("Role".to_string()), true);
801        f.constraints = ir::Constraints {
802            pattern: Some("^[a-z]+$".to_string()),
803            ..Default::default()
804        };
805        let schema = ir::Schema {
806            types: vec![
807                ir::TypeDef::Struct {
808                    name: "User".to_string(),
809                    description: None,
810                    fields: vec![f],
811                },
812                ir::TypeDef::Enum {
813                    name: "Role".to_string(),
814                    description: None,
815                    variants: vec!["admin".to_string(), "member".to_string()],
816                },
817            ],
818            ..Default::default()
819        };
820        let out = SurrealQlEmitter::new().emit(&schema);
821        assert!(
822            out.contains(
823                "ASSERT $value INSIDE ['admin', 'member'] AND string::matches($value, '^[a-z]+$')"
824            ),
825            "value-set AND constraint; got: {out}"
826        );
827    }
828
829    #[test]
830    fn assert_uses_inside_not_in() {
831        // Regression: the canonical SurrealDB set operator is INSIDE.
832        let schema = ir::Schema {
833            records: vec![ir::Record {
834                name: "Doc".to_string(),
835                description: None,
836                id_strategy: ir::IdStrategy::Uuidv7,
837                fields: vec![field(
838                    "visibility",
839                    ir::Ty::Union(vec![
840                        ir::Ty::Literal("public".to_string()),
841                        ir::Ty::Literal("private".to_string()),
842                    ]),
843                    true,
844                )],
845            }],
846            ..Default::default()
847        };
848        let out = SurrealQlEmitter::new().emit(&schema);
849        assert!(out.contains("ASSERT $value INSIDE ['public', 'private']"));
850        assert!(!out.contains("$value IN ["), "no legacy IN operator");
851    }
852}