use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::OnceLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::error::{Result, SurqlError};
use crate::types::check_reserved_word;
fn field_name_part_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").expect("valid regex"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
String,
Int,
Float,
Bool,
Datetime,
Duration,
Decimal,
Number,
Object,
Array,
Record,
Geometry,
Any,
}
impl FieldType {
pub fn as_str(self) -> &'static str {
match self {
Self::String => "string",
Self::Int => "int",
Self::Float => "float",
Self::Bool => "bool",
Self::Datetime => "datetime",
Self::Duration => "duration",
Self::Decimal => "decimal",
Self::Number => "number",
Self::Object => "object",
Self::Array => "array",
Self::Record => "record",
Self::Geometry => "geometry",
Self::Any => "any",
}
}
}
impl std::fmt::Display for FieldType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldDefinition {
pub name: String,
#[serde(rename = "type")]
pub field_type: FieldType,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub assertion: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub permissions: Option<BTreeMap<String, String>>,
#[serde(default)]
pub readonly: bool,
#[serde(default)]
pub flexible: bool,
}
impl FieldDefinition {
pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
Self {
name: name.into(),
field_type,
assertion: None,
default: None,
value: None,
permissions: None,
readonly: false,
flexible: false,
}
}
pub fn with_assertion(mut self, assertion: impl Into<String>) -> Self {
self.assertion = Some(assertion.into());
self
}
pub fn with_default(mut self, default: impl Into<String>) -> Self {
self.default = Some(default.into());
self
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub fn with_permissions<I, K, V>(mut self, permissions: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.permissions = Some(
permissions
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
);
self
}
pub fn readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
pub fn flexible(mut self, flexible: bool) -> Self {
self.flexible = flexible;
self
}
pub fn validate(&self) -> Result<()> {
validate_field_name(&self.name)
}
pub fn to_surql(&self, table: &str) -> String {
self.to_surql_with_options(table, false)
}
pub fn to_surql_with_options(&self, table: &str, if_not_exists: bool) -> String {
let ine = if if_not_exists { " IF NOT EXISTS" } else { "" };
let mut sql = format!(
"DEFINE FIELD{ine} {name} ON TABLE {table} TYPE {ty}",
ine = ine,
name = self.name,
table = table,
ty = self.field_type.as_str(),
);
if let Some(assertion) = &self.assertion {
write!(sql, " ASSERT {}", assertion).expect("writing to String cannot fail");
}
if let Some(default) = &self.default {
write!(sql, " DEFAULT {}", default).expect("writing to String cannot fail");
}
if let Some(value) = &self.value {
write!(sql, " VALUE {}", value).expect("writing to String cannot fail");
}
if self.readonly {
sql.push_str(" READONLY");
}
if self.flexible {
sql.push_str(" FLEXIBLE");
}
sql.push(';');
sql
}
}
pub fn validate_field_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(SurqlError::Validation {
reason: "Field name cannot be empty".into(),
});
}
let regex = field_name_part_regex();
for part in name.split('.') {
if part.is_empty() {
return Err(SurqlError::Validation {
reason: format!("Invalid field name {name:?}: empty segment"),
});
}
if !regex.is_match(part) {
return Err(SurqlError::Validation {
reason: format!(
"Invalid field name {name:?}: segment {part:?} must contain only \
alphanumeric characters and underscores, and cannot start with a digit"
),
});
}
}
Ok(())
}
pub fn field(name: impl Into<String>, field_type: FieldType) -> FieldBuilder {
FieldBuilder::new(name.into(), field_type)
}
#[derive(Debug, Clone)]
pub struct FieldBuilder {
inner: FieldDefinition,
}
impl FieldBuilder {
fn new(name: String, field_type: FieldType) -> Self {
Self {
inner: FieldDefinition::new(name, field_type),
}
}
pub fn assertion(mut self, assertion: impl Into<String>) -> Self {
self.inner.assertion = Some(assertion.into());
self
}
pub fn default(mut self, default: impl Into<String>) -> Self {
self.inner.default = Some(default.into());
self
}
pub fn value(mut self, value: impl Into<String>) -> Self {
self.inner.value = Some(value.into());
self
}
pub fn permissions<I, K, V>(mut self, permissions: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.inner.permissions = Some(
permissions
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
);
self
}
pub fn readonly(mut self, readonly: bool) -> Self {
self.inner.readonly = readonly;
self
}
pub fn flexible(mut self, flexible: bool) -> Self {
self.inner.flexible = flexible;
self
}
pub fn build(self) -> Result<(FieldDefinition, Option<String>)> {
self.inner.validate()?;
let warning = check_reserved_word(&self.inner.name, false);
Ok((self.inner, warning))
}
pub fn build_unchecked(self) -> Result<FieldDefinition> {
self.inner.validate()?;
Ok(self.inner)
}
}
pub fn string_field(name: impl Into<String>) -> FieldBuilder {
field(name, FieldType::String)
}
pub fn int_field(name: impl Into<String>) -> FieldBuilder {
field(name, FieldType::Int)
}
pub fn float_field(name: impl Into<String>) -> FieldBuilder {
field(name, FieldType::Float)
}
pub fn bool_field(name: impl Into<String>) -> FieldBuilder {
field(name, FieldType::Bool)
}
pub fn datetime_field(name: impl Into<String>) -> FieldBuilder {
field(name, FieldType::Datetime)
}
pub fn array_field(name: impl Into<String>) -> FieldBuilder {
field(name, FieldType::Array)
}
pub fn object_field(name: impl Into<String>) -> FieldBuilder {
field(name, FieldType::Object).flexible(true)
}
pub fn record_field(name: impl Into<String>, table: Option<&str>) -> FieldBuilder {
let mut builder = field(name, FieldType::Record);
if let Some(target) = table {
builder.inner.assertion = Some(format!("$value.table = \"{target}\""));
}
builder
}
pub fn computed_field(
name: impl Into<String>,
value: impl Into<String>,
field_type: FieldType,
) -> FieldBuilder {
field(name, field_type).value(value).readonly(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_type_as_str_matches_lowercase() {
assert_eq!(FieldType::String.as_str(), "string");
assert_eq!(FieldType::Datetime.as_str(), "datetime");
assert_eq!(FieldType::Any.as_str(), "any");
}
#[test]
fn field_type_display_matches_as_str() {
assert_eq!(format!("{}", FieldType::Int), "int");
}
#[test]
fn field_type_serializes_lowercase() {
let json = serde_json::to_string(&FieldType::Datetime).unwrap();
assert_eq!(json, "\"datetime\"");
}
#[test]
fn field_type_deserializes_lowercase() {
let ft: FieldType = serde_json::from_str("\"bool\"").unwrap();
assert_eq!(ft, FieldType::Bool);
}
#[test]
fn new_sets_defaults() {
let f = FieldDefinition::new("email", FieldType::String);
assert_eq!(f.name, "email");
assert_eq!(f.field_type, FieldType::String);
assert!(f.assertion.is_none());
assert!(!f.readonly);
assert!(!f.flexible);
}
#[test]
fn to_surql_minimal() {
let f = FieldDefinition::new("email", FieldType::String);
assert_eq!(
f.to_surql("user"),
"DEFINE FIELD email ON TABLE user TYPE string;"
);
}
#[test]
fn to_surql_with_assertion() {
let f = FieldDefinition::new("email", FieldType::String)
.with_assertion("string::is::email($value)");
assert_eq!(
f.to_surql("user"),
"DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);"
);
}
#[test]
fn to_surql_with_default() {
let f = FieldDefinition::new("created_at", FieldType::Datetime).with_default("time::now()");
assert_eq!(
f.to_surql("event"),
"DEFINE FIELD created_at ON TABLE event TYPE datetime DEFAULT time::now();"
);
}
#[test]
fn to_surql_readonly_flexible() {
let f = FieldDefinition::new("meta", FieldType::Object)
.readonly(true)
.flexible(true);
assert_eq!(
f.to_surql("user"),
"DEFINE FIELD meta ON TABLE user TYPE object READONLY FLEXIBLE;"
);
}
#[test]
fn to_surql_with_value_expression() {
let f = FieldDefinition::new("full", FieldType::String).with_value("string::concat(a,b)");
assert!(f.to_surql("t").contains("VALUE string::concat(a,b)"));
}
#[test]
fn to_surql_if_not_exists() {
let f = FieldDefinition::new("name", FieldType::String);
assert_eq!(
f.to_surql_with_options("user", true),
"DEFINE FIELD IF NOT EXISTS name ON TABLE user TYPE string;"
);
}
#[test]
fn validate_rejects_empty_name() {
let f = FieldDefinition::new("", FieldType::String);
assert!(f.validate().is_err());
}
#[test]
fn validate_rejects_bad_leading_digit() {
let f = FieldDefinition::new("1bad", FieldType::String);
assert!(f.validate().is_err());
}
#[test]
fn validate_allows_dot_nested() {
let f = FieldDefinition::new("address.city", FieldType::String);
assert!(f.validate().is_ok());
}
#[test]
fn validate_rejects_empty_segment() {
let f = FieldDefinition::new("address..city", FieldType::String);
assert!(f.validate().is_err());
}
#[test]
fn builder_string_field() {
let (f, _) = string_field("email").build().unwrap();
assert_eq!(f.field_type, FieldType::String);
}
#[test]
fn builder_int_field_with_assertion() {
let (f, _) = int_field("age").assertion("$value >= 0").build().unwrap();
assert_eq!(f.field_type, FieldType::Int);
assert_eq!(f.assertion.as_deref(), Some("$value >= 0"));
}
#[test]
fn builder_float_field() {
let (f, _) = float_field("price").build().unwrap();
assert_eq!(f.field_type, FieldType::Float);
}
#[test]
fn builder_bool_field_with_default() {
let (f, _) = bool_field("active").default("true").build().unwrap();
assert_eq!(f.field_type, FieldType::Bool);
assert_eq!(f.default.as_deref(), Some("true"));
}
#[test]
fn builder_datetime_field_readonly() {
let (f, _) = datetime_field("created_at")
.default("time::now()")
.readonly(true)
.build()
.unwrap();
assert!(f.readonly);
assert_eq!(f.default.as_deref(), Some("time::now()"));
}
#[test]
fn builder_array_field() {
let (f, _) = array_field("tags").default("[]").build().unwrap();
assert_eq!(f.field_type, FieldType::Array);
}
#[test]
fn builder_object_field_defaults_flexible() {
let (f, _) = object_field("metadata").build().unwrap();
assert_eq!(f.field_type, FieldType::Object);
assert!(f.flexible);
}
#[test]
fn builder_record_field_with_table() {
let (f, _) = record_field("author", Some("user")).build().unwrap();
assert_eq!(f.field_type, FieldType::Record);
assert_eq!(f.assertion.as_deref(), Some(r#"$value.table = "user""#),);
}
#[test]
fn builder_record_field_no_table() {
let (f, _) = record_field("link", None).build().unwrap();
assert!(f.assertion.is_none());
}
#[test]
fn builder_computed_field_is_readonly() {
let (f, _) = computed_field("full", "a + b", FieldType::String)
.build()
.unwrap();
assert!(f.readonly);
assert_eq!(f.value.as_deref(), Some("a + b"));
}
#[test]
fn builder_rejects_invalid_name() {
let err = string_field("1bad").build().unwrap_err();
assert!(matches!(err, SurqlError::Validation { .. }));
}
#[test]
fn builder_flags_reserved_word() {
let (_f, warning) = string_field("select").build().unwrap();
assert!(warning.is_some());
}
#[test]
fn builder_permissions_are_stored() {
let (f, _) = string_field("name")
.permissions([("select", "true")])
.build()
.unwrap();
assert_eq!(
f.permissions
.as_ref()
.unwrap()
.get("select")
.map(String::as_str),
Some("true")
);
}
#[test]
fn validate_field_name_helper() {
assert!(validate_field_name("ok").is_ok());
assert!(validate_field_name("ok.nested").is_ok());
assert!(validate_field_name("").is_err());
assert!(validate_field_name("bad seg").is_err());
}
}