mod diff;
mod encode;
pub mod ensure;
mod export;
mod sql_types;
mod version_meta;
pub use diff::{additive_ops, layout_diff, AdditiveOp, LayoutDiff, SafeTweak};
pub use encode::{
coerce_for_storage, decode_sql_cell, field_by_name, field_names_excluding_id,
fields_from_content, row_from_columns, split_record_fields, sql_bind_text,
validate_write_types,
};
pub use ensure::{
ensure_typed_table_for, ensure_typed_tables_from_registry, sync_typed_table_for,
sync_typed_tables_from_registry,
};
pub use export::{
postgres_add_column, postgres_drop_default, postgres_set_default, postgres_set_not_null,
postgres_set_nullable, sqlite_add_column, surreal_add_field, to_ddl, to_layout_json,
DdlDialect,
};
pub use sql_types::{logical_type_to_storage, FieldStorage, SqlColumnType, SurrealFieldType};
pub use version_meta::{desired_schema_version, version_stamp_matches, SCHEMA_META_TABLE};
use crate::error::{Error, Result};
use crate::safe_ident::assert_safe_ident;
use crate::schema::SchemaRegistry;
use crate::schema_api::Schema;
use crate::ttl::EXPIRE_AT_FIELD;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutField {
pub name: String,
pub storage: FieldStorage,
pub primary_key: bool,
pub nullable: bool,
pub unique: bool,
pub indexed: bool,
pub default: Option<String>,
pub record_table: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageLayout {
pub table: String,
pub fields: Vec<LayoutField>,
}
impl StorageLayout {
pub fn from_schema(schema: &Schema) -> Result<Self> {
assert_safe_ident(&schema.name)?;
let mut fields = Vec::with_capacity(schema.fields.len().saturating_add(1));
let mut saw_id = false;
for f in &schema.fields {
assert_safe_ident(&f.name)?;
let storage = logical_type_to_storage(&f.field_type)?;
if f.name == "id" {
saw_id = true;
}
let record_table = record_table_from_field_type(&f.field_type)
.or_else(|| f.fk.as_ref().map(|fk| fk.ref_table.clone()));
fields.push(LayoutField {
name: f.name.clone(),
storage,
primary_key: f.primary || f.name == "id",
nullable: f.nullable && !f.primary && f.name != "id",
unique: f.unique,
indexed: f.indexed,
default: f.default.clone(),
record_table,
});
}
if !saw_id {
fields.insert(
0,
LayoutField {
name: "id".into(),
storage: FieldStorage::String,
primary_key: true,
nullable: false,
unique: true,
indexed: false,
default: None,
record_table: None,
},
);
}
if schema.ttl.is_some()
&& !fields.iter().any(|f| f.name == EXPIRE_AT_FIELD)
&& assert_safe_ident(EXPIRE_AT_FIELD).is_ok()
{
fields.push(LayoutField {
name: EXPIRE_AT_FIELD.into(),
storage: FieldStorage::String,
primary_key: false,
nullable: true,
unique: false,
indexed: true,
default: None,
record_table: None,
});
}
Ok(Self {
table: schema.name.clone(),
fields,
})
}
pub fn from_registry_table(table: &str) -> Result<Self> {
let schema = SchemaRegistry::global()
.get_full_schema(table)
.ok_or_else(|| Error::Internal(format!("SchemaRegistry missing entry for {table}")))?;
Self::from_schema(schema)
}
pub fn resolve_for_write(table: &str, content: &serde_json::Value) -> Result<Self> {
if let Some(schema) = SchemaRegistry::global().get_full_schema(table) {
let mut layout = Self::from_schema(schema)?;
layout.merge_content_fields(content)?;
return Ok(layout);
}
Self::from_content_keys(table, content)
}
pub fn from_content_keys(table: &str, content: &serde_json::Value) -> Result<Self> {
assert_safe_ident(table)?;
let mut fields = vec![LayoutField {
name: "id".into(),
storage: FieldStorage::String,
primary_key: true,
nullable: false,
unique: true,
indexed: false,
default: None,
record_table: None,
}];
if let Some(obj) = content.as_object() {
for key in obj.keys() {
if key == "id" {
continue;
}
assert_safe_ident(key)?;
let storage = storage_from_json_value(&obj[key]);
fields.push(LayoutField {
name: key.clone(),
storage,
primary_key: false,
nullable: true,
unique: false,
indexed: false,
default: None,
record_table: None,
});
}
}
Ok(Self {
table: table.to_string(),
fields,
})
}
fn merge_content_fields(&mut self, content: &serde_json::Value) -> Result<()> {
let Some(obj) = content.as_object() else {
return Ok(());
};
for key in obj.keys() {
if key == "id" || self.fields.iter().any(|f| f.name == *key) {
continue;
}
assert_safe_ident(key)?;
self.fields.push(LayoutField {
name: key.clone(),
storage: storage_from_json_value(&obj[key]),
primary_key: false,
nullable: true,
unique: false,
indexed: false,
default: None,
record_table: None,
});
}
Ok(())
}
#[must_use]
pub fn field_names(&self) -> Vec<&str> {
self.fields.iter().map(|f| f.name.as_str()).collect()
}
pub fn data_fields(&self) -> impl Iterator<Item = &LayoutField> {
self.fields.iter().filter(|f| !f.primary_key)
}
pub fn to_ddl(&self, engine_id: &str) -> Result<String> {
export::to_ddl(self, engine_id)
}
pub fn to_layout_json(&self) -> Result<serde_json::Value> {
export::to_layout_json(self)
}
}
fn storage_from_json_value(v: &serde_json::Value) -> FieldStorage {
match v {
serde_json::Value::Bool(_) => FieldStorage::Boolean,
serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => FieldStorage::Integer,
serde_json::Value::Number(_) => FieldStorage::Decimal,
serde_json::Value::String(_) => FieldStorage::String,
_ => FieldStorage::Json,
}
}
fn record_table_from_field_type(field_type: &str) -> Option<String> {
let t = field_type.trim();
let rest = t.strip_prefix("record<")?;
let end = rest.find('>')?;
let table = rest[..end].trim();
if table.is_empty() || !table.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
Some(table.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evaluator::DEFAULT_IN_MEMORY;
use crate::schema_api::{SchemaField, SchemaMeta, SchemaPrivacy};
use crate::KnownEngines;
fn stub_schema(fields: Vec<SchemaField>) -> Schema {
Schema {
name: "account_email".into(),
version: "0.1.0".into(),
databases: vec!["default".into()],
database_evaluator: &DEFAULT_IN_MEMORY,
privacy: SchemaPrivacy {
read: "public".into(),
write: "public".into(),
},
policies: None,
fields,
edges: vec![],
connections: vec![],
side_effects: vec![],
iters: vec![],
composite_key: vec![],
traits: vec![],
ttl: None,
ownership: None,
meta: SchemaMeta {
retention: "365 days".into(),
row_count: 0,
owner: "system".into(),
description: None,
},
}
}
#[test]
fn layout_maps_integer_and_string() {
let schema = stub_schema(vec![
SchemaField {
name: "id".into(),
field_type: "string".into(),
primary: true,
nullable: false,
indexed: false,
unique: false,
default: None,
fk: None,
validations: vec![],
policies: None,
encrypted: false,
enum_variants: vec![],
enum_type: None,
model_path: None,
},
SchemaField {
name: "address".into(),
field_type: "string".into(),
primary: false,
nullable: false,
indexed: false,
unique: true,
default: None,
fk: None,
validations: vec![],
policies: None,
encrypted: false,
enum_variants: vec![],
enum_type: None,
model_path: None,
},
SchemaField {
name: "value".into(),
field_type: "integer".into(),
primary: false,
nullable: true,
indexed: false,
unique: false,
default: None,
fk: None,
validations: vec![],
policies: None,
encrypted: false,
enum_variants: vec![],
enum_type: None,
model_path: None,
},
]);
let layout = StorageLayout::from_schema(&schema).expect("layout");
assert_eq!(layout.table, "account_email");
let addr = layout.fields.iter().find(|f| f.name == "address").unwrap();
assert!(addr.unique);
assert_eq!(addr.storage, FieldStorage::String);
let val = layout.fields.iter().find(|f| f.name == "value").unwrap();
assert_eq!(val.storage, FieldStorage::Integer);
let ddl = layout.to_ddl(KnownEngines::SQLITE).expect("ddl");
assert!(ddl.contains("\"address\" TEXT"));
assert!(ddl.contains("\"value\" INTEGER"));
assert!(!ddl.contains("body"));
}
#[test]
fn rejects_unsafe_table() {
let mut schema = stub_schema(vec![]);
schema.name = "bad;drop".into();
assert!(StorageLayout::from_schema(&schema).is_err());
}
#[test]
fn additive_diff_adds_missing_field() {
let desired = StorageLayout {
table: "t".into(),
fields: vec![
LayoutField {
name: "id".into(),
storage: FieldStorage::String,
primary_key: true,
nullable: false,
unique: true,
indexed: false,
default: None,
record_table: None,
},
LayoutField {
name: "a".into(),
storage: FieldStorage::String,
primary_key: false,
nullable: true,
unique: false,
indexed: false,
default: None,
record_table: None,
},
LayoutField {
name: "b".into(),
storage: FieldStorage::Integer,
primary_key: false,
nullable: true,
unique: false,
indexed: false,
default: None,
record_table: None,
},
],
};
let live = StorageLayout {
table: "t".into(),
fields: desired.fields[..2].to_vec(),
};
let diff = additive_ops(&desired, &live).expect("diff");
assert_eq!(diff.ops.len(), 1);
match &diff.ops[0] {
AdditiveOp::AddField(f) => assert_eq!(f.name, "b"),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn additive_diff_refuses_destructive() {
let desired = StorageLayout {
table: "t".into(),
fields: vec![LayoutField {
name: "id".into(),
storage: FieldStorage::String,
primary_key: true,
nullable: false,
unique: true,
indexed: false,
default: None,
record_table: None,
}],
};
let live = StorageLayout {
table: "t".into(),
fields: vec![
desired.fields[0].clone(),
LayoutField {
name: "orphan".into(),
storage: FieldStorage::String,
primary_key: false,
nullable: true,
unique: false,
indexed: false,
default: None,
record_table: None,
},
],
};
let err = additive_ops(&desired, &live).expect_err("destructive");
assert!(matches!(err, Error::Validation(_)));
}
}