Skip to main content

surql/schema/
fields.rs

1//! Field schema definitions.
2//!
3//! Port of `surql/schema/fields.py`. Provides the [`FieldType`] enum,
4//! [`FieldDefinition`] struct, and a family of builder helpers that construct
5//! immutable field descriptors used by table and edge schemas.
6//!
7//! Each [`FieldDefinition`] renders a SurrealQL `DEFINE FIELD` statement via
8//! [`FieldDefinition::to_surql`].
9
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12use std::sync::OnceLock;
13
14use regex::Regex;
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Result, SurqlError};
18use crate::types::check_reserved_word;
19
20fn field_name_part_regex() -> &'static Regex {
21    static RE: OnceLock<Regex> = OnceLock::new();
22    RE.get_or_init(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").expect("valid regex"))
23}
24
25/// SurrealDB field types supported by `DEFINE FIELD`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum FieldType {
29    /// `string`
30    String,
31    /// `int`
32    Int,
33    /// `float`
34    Float,
35    /// `bool`
36    Bool,
37    /// `datetime`
38    Datetime,
39    /// `duration`
40    Duration,
41    /// `decimal`
42    Decimal,
43    /// `number`
44    Number,
45    /// `object`
46    Object,
47    /// `array`
48    Array,
49    /// `record`
50    Record,
51    /// `geometry`
52    Geometry,
53    /// `any`
54    Any,
55}
56
57impl FieldType {
58    /// Render the type as SurrealQL keyword.
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::String => "string",
62            Self::Int => "int",
63            Self::Float => "float",
64            Self::Bool => "bool",
65            Self::Datetime => "datetime",
66            Self::Duration => "duration",
67            Self::Decimal => "decimal",
68            Self::Number => "number",
69            Self::Object => "object",
70            Self::Array => "array",
71            Self::Record => "record",
72            Self::Geometry => "geometry",
73            Self::Any => "any",
74        }
75    }
76}
77
78impl std::fmt::Display for FieldType {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.write_str(self.as_str())
81    }
82}
83
84/// Immutable field definition for table schemas.
85///
86/// Represents a single field in a SurrealDB table schema along with its
87/// constraints, defaults, and permissions.
88///
89/// ## Examples
90///
91/// ```
92/// use surql::schema::{FieldDefinition, FieldType};
93///
94/// let email = FieldDefinition::new("email", FieldType::String);
95/// assert_eq!(email.to_surql("user"), "DEFINE FIELD email ON TABLE user TYPE string;");
96/// ```
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct FieldDefinition {
99    /// Field name (supports dot notation for nested fields).
100    pub name: String,
101    /// Field type.
102    #[serde(rename = "type")]
103    pub field_type: FieldType,
104    /// Optional SurrealQL assertion expression.
105    #[serde(skip_serializing_if = "Option::is_none", default)]
106    pub assertion: Option<String>,
107    /// Optional default value expression.
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub default: Option<String>,
110    /// Optional computed-value expression.
111    #[serde(skip_serializing_if = "Option::is_none", default)]
112    pub value: Option<String>,
113    /// Optional per-action permission rules keyed by action name.
114    #[serde(skip_serializing_if = "Option::is_none", default)]
115    pub permissions: Option<BTreeMap<String, String>>,
116    /// Whether the field is read-only after creation.
117    #[serde(default)]
118    pub readonly: bool,
119    /// Whether the field allows flexible schema.
120    #[serde(default)]
121    pub flexible: bool,
122}
123
124impl FieldDefinition {
125    /// Construct a new [`FieldDefinition`] with only the required members.
126    ///
127    /// Other members default to empty/false and can be set via chainable
128    /// `with_*` setters.
129    pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
130        Self {
131            name: name.into(),
132            field_type,
133            assertion: None,
134            default: None,
135            value: None,
136            permissions: None,
137            readonly: false,
138            flexible: false,
139        }
140    }
141
142    /// Set the assertion expression.
143    pub fn with_assertion(mut self, assertion: impl Into<String>) -> Self {
144        self.assertion = Some(assertion.into());
145        self
146    }
147
148    /// Set the default value expression.
149    pub fn with_default(mut self, default: impl Into<String>) -> Self {
150        self.default = Some(default.into());
151        self
152    }
153
154    /// Set the computed-value expression.
155    pub fn with_value(mut self, value: impl Into<String>) -> Self {
156        self.value = Some(value.into());
157        self
158    }
159
160    /// Attach per-action permissions.
161    pub fn with_permissions<I, K, V>(mut self, permissions: I) -> Self
162    where
163        I: IntoIterator<Item = (K, V)>,
164        K: Into<String>,
165        V: Into<String>,
166    {
167        self.permissions = Some(
168            permissions
169                .into_iter()
170                .map(|(k, v)| (k.into(), v.into()))
171                .collect(),
172        );
173        self
174    }
175
176    /// Mark the field as read-only.
177    pub fn readonly(mut self, readonly: bool) -> Self {
178        self.readonly = readonly;
179        self
180    }
181
182    /// Mark the field as flexible.
183    pub fn flexible(mut self, flexible: bool) -> Self {
184        self.flexible = flexible;
185        self
186    }
187
188    /// Validate the field definition against SurrealDB identifier rules.
189    ///
190    /// Returns [`SurqlError::Validation`] for an empty name, empty segments,
191    /// or segments that contain invalid characters.
192    pub fn validate(&self) -> Result<()> {
193        validate_field_name(&self.name)
194    }
195
196    /// Render the `DEFINE FIELD` statement for this field on the given table.
197    ///
198    /// ## Examples
199    ///
200    /// ```
201    /// use surql::schema::{FieldDefinition, FieldType};
202    ///
203    /// let f = FieldDefinition::new("email", FieldType::String)
204    ///     .with_assertion("string::is::email($value)");
205    /// assert_eq!(
206    ///     f.to_surql("user"),
207    ///     "DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);",
208    /// );
209    /// ```
210    pub fn to_surql(&self, table: &str) -> String {
211        self.to_surql_with_options(table, false)
212    }
213
214    /// Render with optional `IF NOT EXISTS` clause.
215    pub fn to_surql_with_options(&self, table: &str, if_not_exists: bool) -> String {
216        let ine = if if_not_exists { " IF NOT EXISTS" } else { "" };
217        let mut sql = format!(
218            "DEFINE FIELD{ine} {name} ON TABLE {table} TYPE {ty}",
219            ine = ine,
220            name = self.name,
221            table = table,
222            ty = self.field_type.as_str(),
223        );
224        if let Some(assertion) = &self.assertion {
225            write!(sql, " ASSERT {}", assertion).expect("writing to String cannot fail");
226        }
227        if let Some(default) = &self.default {
228            write!(sql, " DEFAULT {}", default).expect("writing to String cannot fail");
229        }
230        if let Some(value) = &self.value {
231            write!(sql, " VALUE {}", value).expect("writing to String cannot fail");
232        }
233        if self.readonly {
234            sql.push_str(" READONLY");
235        }
236        if self.flexible {
237            sql.push_str(" FLEXIBLE");
238        }
239        sql.push(';');
240        sql
241    }
242}
243
244/// Validate a field name against SurrealDB identifier rules.
245///
246/// Supports dot-notation for nested fields (for example `address.city`). Each
247/// segment must match `[a-zA-Z_][a-zA-Z0-9_]*`.
248///
249/// ## Examples
250///
251/// ```
252/// use surql::schema::fields::validate_field_name;
253///
254/// assert!(validate_field_name("email").is_ok());
255/// assert!(validate_field_name("address.city").is_ok());
256/// assert!(validate_field_name("").is_err());
257/// assert!(validate_field_name("1bad").is_err());
258/// ```
259pub fn validate_field_name(name: &str) -> Result<()> {
260    if name.is_empty() {
261        return Err(SurqlError::Validation {
262            reason: "Field name cannot be empty".into(),
263        });
264    }
265    let regex = field_name_part_regex();
266    for part in name.split('.') {
267        if part.is_empty() {
268            return Err(SurqlError::Validation {
269                reason: format!("Invalid field name {name:?}: empty segment"),
270            });
271        }
272        if !regex.is_match(part) {
273            return Err(SurqlError::Validation {
274                reason: format!(
275                    "Invalid field name {name:?}: segment {part:?} must contain only \
276                     alphanumeric characters and underscores, and cannot start with a digit"
277                ),
278            });
279        }
280    }
281    Ok(())
282}
283
284/// Build a [`FieldDefinition`] with named parameters, mirroring
285/// `surql.schema.fields.field`.
286///
287/// The field name is validated eagerly; reserved-word collisions surface as
288/// an optional warning message returned alongside the definition so the
289/// caller can relay it through `tracing::warn!` or their own logger.
290///
291/// ## Examples
292///
293/// ```
294/// use surql::schema::fields::{field, FieldType};
295///
296/// let (f, warning) = field("name", FieldType::String).build().unwrap();
297/// assert_eq!(f.field_type, FieldType::String);
298/// assert!(warning.is_none());
299/// ```
300pub fn field(name: impl Into<String>, field_type: FieldType) -> FieldBuilder {
301    FieldBuilder::new(name.into(), field_type)
302}
303
304/// Chainable builder used by [`field`] and the typed helpers.
305#[derive(Debug, Clone)]
306pub struct FieldBuilder {
307    inner: FieldDefinition,
308}
309
310impl FieldBuilder {
311    fn new(name: String, field_type: FieldType) -> Self {
312        Self {
313            inner: FieldDefinition::new(name, field_type),
314        }
315    }
316
317    /// Set the assertion expression.
318    pub fn assertion(mut self, assertion: impl Into<String>) -> Self {
319        self.inner.assertion = Some(assertion.into());
320        self
321    }
322
323    /// Set the default value expression.
324    pub fn default(mut self, default: impl Into<String>) -> Self {
325        self.inner.default = Some(default.into());
326        self
327    }
328
329    /// Set the computed-value expression.
330    pub fn value(mut self, value: impl Into<String>) -> Self {
331        self.inner.value = Some(value.into());
332        self
333    }
334
335    /// Attach per-action permissions.
336    pub fn permissions<I, K, V>(mut self, permissions: I) -> Self
337    where
338        I: IntoIterator<Item = (K, V)>,
339        K: Into<String>,
340        V: Into<String>,
341    {
342        self.inner.permissions = Some(
343            permissions
344                .into_iter()
345                .map(|(k, v)| (k.into(), v.into()))
346                .collect(),
347        );
348        self
349    }
350
351    /// Set the read-only flag.
352    pub fn readonly(mut self, readonly: bool) -> Self {
353        self.inner.readonly = readonly;
354        self
355    }
356
357    /// Set the flexible flag.
358    pub fn flexible(mut self, flexible: bool) -> Self {
359        self.inner.flexible = flexible;
360        self
361    }
362
363    /// Finalise the builder, returning the field and an optional reserved-word
364    /// warning message for the caller to log.
365    pub fn build(self) -> Result<(FieldDefinition, Option<String>)> {
366        self.inner.validate()?;
367        let warning = check_reserved_word(&self.inner.name, false);
368        Ok((self.inner, warning))
369    }
370
371    /// Finalise the builder and discard any reserved-word warning.
372    pub fn build_unchecked(self) -> Result<FieldDefinition> {
373        self.inner.validate()?;
374        Ok(self.inner)
375    }
376}
377
378/// Convenience constructor for a `string` field.
379pub fn string_field(name: impl Into<String>) -> FieldBuilder {
380    field(name, FieldType::String)
381}
382
383/// Convenience constructor for an `int` field.
384pub fn int_field(name: impl Into<String>) -> FieldBuilder {
385    field(name, FieldType::Int)
386}
387
388/// Convenience constructor for a `float` field.
389pub fn float_field(name: impl Into<String>) -> FieldBuilder {
390    field(name, FieldType::Float)
391}
392
393/// Convenience constructor for a `bool` field.
394pub fn bool_field(name: impl Into<String>) -> FieldBuilder {
395    field(name, FieldType::Bool)
396}
397
398/// Convenience constructor for a `datetime` field.
399pub fn datetime_field(name: impl Into<String>) -> FieldBuilder {
400    field(name, FieldType::Datetime)
401}
402
403/// Convenience constructor for an `array` field.
404pub fn array_field(name: impl Into<String>) -> FieldBuilder {
405    field(name, FieldType::Array)
406}
407
408/// Convenience constructor for an `object` field.
409///
410/// Objects default to `flexible = true` to match `surql.schema.fields.object_field`.
411pub fn object_field(name: impl Into<String>) -> FieldBuilder {
412    field(name, FieldType::Object).flexible(true)
413}
414
415/// Convenience constructor for a `record` field.
416///
417/// When `table` is `Some`, an assertion is attached that constrains the
418/// referenced record table. An explicit `assertion` chained afterwards is
419/// composed using `AND` (mirroring the Python behaviour).
420pub fn record_field(name: impl Into<String>, table: Option<&str>) -> FieldBuilder {
421    let mut builder = field(name, FieldType::Record);
422    if let Some(target) = table {
423        builder.inner.assertion = Some(format!("$value.table = \"{target}\""));
424    }
425    builder
426}
427
428/// Convenience constructor for a computed field.
429///
430/// Computed fields are always read-only; the Python implementation hard-codes
431/// `readonly=True`, so this helper does the same.
432pub fn computed_field(
433    name: impl Into<String>,
434    value: impl Into<String>,
435    field_type: FieldType,
436) -> FieldBuilder {
437    field(name, field_type).value(value).readonly(true)
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn field_type_as_str_matches_lowercase() {
446        assert_eq!(FieldType::String.as_str(), "string");
447        assert_eq!(FieldType::Datetime.as_str(), "datetime");
448        assert_eq!(FieldType::Any.as_str(), "any");
449    }
450
451    #[test]
452    fn field_type_display_matches_as_str() {
453        assert_eq!(format!("{}", FieldType::Int), "int");
454    }
455
456    #[test]
457    fn field_type_serializes_lowercase() {
458        let json = serde_json::to_string(&FieldType::Datetime).unwrap();
459        assert_eq!(json, "\"datetime\"");
460    }
461
462    #[test]
463    fn field_type_deserializes_lowercase() {
464        let ft: FieldType = serde_json::from_str("\"bool\"").unwrap();
465        assert_eq!(ft, FieldType::Bool);
466    }
467
468    #[test]
469    fn new_sets_defaults() {
470        let f = FieldDefinition::new("email", FieldType::String);
471        assert_eq!(f.name, "email");
472        assert_eq!(f.field_type, FieldType::String);
473        assert!(f.assertion.is_none());
474        assert!(!f.readonly);
475        assert!(!f.flexible);
476    }
477
478    #[test]
479    fn to_surql_minimal() {
480        let f = FieldDefinition::new("email", FieldType::String);
481        assert_eq!(
482            f.to_surql("user"),
483            "DEFINE FIELD email ON TABLE user TYPE string;"
484        );
485    }
486
487    #[test]
488    fn to_surql_with_assertion() {
489        let f = FieldDefinition::new("email", FieldType::String)
490            .with_assertion("string::is::email($value)");
491        assert_eq!(
492            f.to_surql("user"),
493            "DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);"
494        );
495    }
496
497    #[test]
498    fn to_surql_with_default() {
499        let f = FieldDefinition::new("created_at", FieldType::Datetime).with_default("time::now()");
500        assert_eq!(
501            f.to_surql("event"),
502            "DEFINE FIELD created_at ON TABLE event TYPE datetime DEFAULT time::now();"
503        );
504    }
505
506    #[test]
507    fn to_surql_readonly_flexible() {
508        let f = FieldDefinition::new("meta", FieldType::Object)
509            .readonly(true)
510            .flexible(true);
511        assert_eq!(
512            f.to_surql("user"),
513            "DEFINE FIELD meta ON TABLE user TYPE object READONLY FLEXIBLE;"
514        );
515    }
516
517    #[test]
518    fn to_surql_with_value_expression() {
519        let f = FieldDefinition::new("full", FieldType::String).with_value("string::concat(a,b)");
520        assert!(f.to_surql("t").contains("VALUE string::concat(a,b)"));
521    }
522
523    #[test]
524    fn to_surql_if_not_exists() {
525        let f = FieldDefinition::new("name", FieldType::String);
526        assert_eq!(
527            f.to_surql_with_options("user", true),
528            "DEFINE FIELD IF NOT EXISTS name ON TABLE user TYPE string;"
529        );
530    }
531
532    #[test]
533    fn validate_rejects_empty_name() {
534        let f = FieldDefinition::new("", FieldType::String);
535        assert!(f.validate().is_err());
536    }
537
538    #[test]
539    fn validate_rejects_bad_leading_digit() {
540        let f = FieldDefinition::new("1bad", FieldType::String);
541        assert!(f.validate().is_err());
542    }
543
544    #[test]
545    fn validate_allows_dot_nested() {
546        let f = FieldDefinition::new("address.city", FieldType::String);
547        assert!(f.validate().is_ok());
548    }
549
550    #[test]
551    fn validate_rejects_empty_segment() {
552        let f = FieldDefinition::new("address..city", FieldType::String);
553        assert!(f.validate().is_err());
554    }
555
556    #[test]
557    fn builder_string_field() {
558        let (f, _) = string_field("email").build().unwrap();
559        assert_eq!(f.field_type, FieldType::String);
560    }
561
562    #[test]
563    fn builder_int_field_with_assertion() {
564        let (f, _) = int_field("age").assertion("$value >= 0").build().unwrap();
565        assert_eq!(f.field_type, FieldType::Int);
566        assert_eq!(f.assertion.as_deref(), Some("$value >= 0"));
567    }
568
569    #[test]
570    fn builder_float_field() {
571        let (f, _) = float_field("price").build().unwrap();
572        assert_eq!(f.field_type, FieldType::Float);
573    }
574
575    #[test]
576    fn builder_bool_field_with_default() {
577        let (f, _) = bool_field("active").default("true").build().unwrap();
578        assert_eq!(f.field_type, FieldType::Bool);
579        assert_eq!(f.default.as_deref(), Some("true"));
580    }
581
582    #[test]
583    fn builder_datetime_field_readonly() {
584        let (f, _) = datetime_field("created_at")
585            .default("time::now()")
586            .readonly(true)
587            .build()
588            .unwrap();
589        assert!(f.readonly);
590        assert_eq!(f.default.as_deref(), Some("time::now()"));
591    }
592
593    #[test]
594    fn builder_array_field() {
595        let (f, _) = array_field("tags").default("[]").build().unwrap();
596        assert_eq!(f.field_type, FieldType::Array);
597    }
598
599    #[test]
600    fn builder_object_field_defaults_flexible() {
601        let (f, _) = object_field("metadata").build().unwrap();
602        assert_eq!(f.field_type, FieldType::Object);
603        assert!(f.flexible);
604    }
605
606    #[test]
607    fn builder_record_field_with_table() {
608        let (f, _) = record_field("author", Some("user")).build().unwrap();
609        assert_eq!(f.field_type, FieldType::Record);
610        assert_eq!(f.assertion.as_deref(), Some(r#"$value.table = "user""#),);
611    }
612
613    #[test]
614    fn builder_record_field_no_table() {
615        let (f, _) = record_field("link", None).build().unwrap();
616        assert!(f.assertion.is_none());
617    }
618
619    #[test]
620    fn builder_computed_field_is_readonly() {
621        let (f, _) = computed_field("full", "a + b", FieldType::String)
622            .build()
623            .unwrap();
624        assert!(f.readonly);
625        assert_eq!(f.value.as_deref(), Some("a + b"));
626    }
627
628    #[test]
629    fn builder_rejects_invalid_name() {
630        let err = string_field("1bad").build().unwrap_err();
631        assert!(matches!(err, SurqlError::Validation { .. }));
632    }
633
634    #[test]
635    fn builder_flags_reserved_word() {
636        let (_f, warning) = string_field("select").build().unwrap();
637        assert!(warning.is_some());
638    }
639
640    #[test]
641    fn builder_permissions_are_stored() {
642        let (f, _) = string_field("name")
643            .permissions([("select", "true")])
644            .build()
645            .unwrap();
646        assert_eq!(
647            f.permissions
648                .as_ref()
649                .unwrap()
650                .get("select")
651                .map(String::as_str),
652            Some("true")
653        );
654    }
655
656    #[test]
657    fn validate_field_name_helper() {
658        assert!(validate_field_name("ok").is_ok());
659        assert!(validate_field_name("ok.nested").is_ok());
660        assert!(validate_field_name("").is_err());
661        assert!(validate_field_name("bad seg").is_err());
662    }
663}