diff --git a/Cargo.lock b/Cargo.lock index ba49997d2f..19349414b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15385,6 +15385,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "store-types" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "specta", + "specta-zod", +] + [[package]] name = "str_inflector" version = "0.12.0" diff --git a/crates/specta-zod/src/lib.rs b/crates/specta-zod/src/lib.rs index 3b0b1a1f1d..1eed204f4d 100644 --- a/crates/specta-zod/src/lib.rs +++ b/crates/specta-zod/src/lib.rs @@ -1,5 +1,7 @@ mod error; +mod tinybase; mod zod; pub use error::Error; +pub use tinybase::TinyBase; pub use zod::Zod; diff --git a/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__simple_struct.snap b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__simple_struct.snap new file mode 100644 index 0000000000..cf821a34c3 --- /dev/null +++ b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__simple_struct.snap @@ -0,0 +1,10 @@ +--- +source: crates/specta-zod/src/tinybase.rs +assertion_line: 165 +expression: output +--- +export const simple_structTinybaseSchema = { + name: { type: "string" }, + age: { type: "number" }, + active: { type: "boolean" }, +}; diff --git a/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_array.snap b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_array.snap new file mode 100644 index 0000000000..1b159f94ac --- /dev/null +++ b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_array.snap @@ -0,0 +1,9 @@ +--- +source: crates/specta-zod/src/tinybase.rs +assertion_line: 181 +expression: output +--- +export const with_arrayTinybaseSchema = { + tags: { type: "string" }, + scores: { type: "string" }, +}; diff --git a/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_optional.snap b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_optional.snap new file mode 100644 index 0000000000..292f77e4d0 --- /dev/null +++ b/crates/specta-zod/src/snapshots/specta_zod__tinybase__tests__with_optional.snap @@ -0,0 +1,10 @@ +--- +source: crates/specta-zod/src/tinybase.rs +assertion_line: 173 +expression: output +--- +export const with_optionalTinybaseSchema = { + required: { type: "string" }, + optional: { type: "string" }, + optional_num: { type: "number" }, +}; diff --git a/crates/specta-zod/src/tinybase.rs b/crates/specta-zod/src/tinybase.rs new file mode 100644 index 0000000000..6302dd4d95 --- /dev/null +++ b/crates/specta-zod/src/tinybase.rs @@ -0,0 +1,195 @@ +use std::{borrow::Cow, fmt::Write, path::Path}; + +use specta::{ + TypeCollection, + datatype::{DataType, NamedDataType, PrimitiveType, StructFields}, +}; + +use crate::Error; + +pub struct TinyBase { + pub header: Cow<'static, str>, +} + +impl Default for TinyBase { + fn default() -> Self { + Self { + header: r#"import type { TablesSchema, ValuesSchema } from "tinybase/with-schemas"; + +import type { InferTinyBaseSchema } from "./shared"; + +"# + .into(), + } + } +} + +impl TinyBase { + pub fn new() -> Self { + Self::default() + } + + pub fn header(mut self, header: impl Into>) -> Self { + self.header = header.into(); + self + } + + pub fn export(&self, types: &TypeCollection) -> Result { + let mut output = self.header.to_string(); + + for (_, ndt) in types { + export_named_datatype(&mut output, ndt)?; + output.push('\n'); + } + + Ok(output) + } + + pub fn export_to(&self, path: impl AsRef, types: &TypeCollection) -> Result<(), Error> { + let content = self.export(types)?; + std::fs::write(path, content)?; + Ok(()) + } +} + +fn export_named_datatype(s: &mut String, ndt: &NamedDataType) -> Result<(), Error> { + let name = ndt.name(); + + match &ndt.inner { + DataType::Struct(st) => { + if let StructFields::Named(named) = st.fields() { + let fields: Vec<_> = named + .fields() + .iter() + .filter(|(_, f)| f.ty().is_some()) + .collect(); + + write!( + s, + "export const {}TinybaseSchema = {{\n", + to_snake_case(name) + )?; + + for (i, (field_name, field)) in fields.iter().enumerate() { + let ty = field.ty().unwrap(); + let tinybase_type = datatype_to_tinybase_type(ty); + + if i > 0 { + s.push_str(",\n"); + } + write!(s, " {}: {{ type: \"{}\" }}", field_name, tinybase_type)?; + } + + if !fields.is_empty() { + s.push(','); + } + s.push_str("\n};\n"); + } + } + _ => {} + } + + Ok(()) +} + +fn datatype_to_tinybase_type(dt: &DataType) -> &'static str { + match dt { + DataType::Primitive(p) => primitive_to_tinybase_type(p), + DataType::Nullable(inner) => datatype_to_tinybase_type(inner), + DataType::List(_) => "string", + DataType::Map(_) => "string", + DataType::Struct(_) => "string", + DataType::Enum(_) => "string", + DataType::Tuple(_) => "string", + DataType::Reference(_) => "string", + _ => "string", + } +} + +fn primitive_to_tinybase_type(p: &PrimitiveType) -> &'static str { + use PrimitiveType::*; + + match p { + i8 | i16 | i32 | u8 | u16 | u32 | f32 | f64 | usize | isize | i64 | u64 | i128 | u128 => { + "number" + } + bool => "boolean", + String | char => "string", + } +} + +fn to_snake_case(name: &str) -> String { + let mut result = String::new(); + + for (i, c) in name.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use specta::Type; + + #[derive(Type, Serialize, Deserialize)] + struct SimpleStruct { + name: String, + age: i32, + active: bool, + } + + #[derive(Type, Serialize, Deserialize)] + struct WithOptional { + required: String, + optional: Option, + optional_num: Option, + } + + #[derive(Type, Serialize, Deserialize)] + struct WithArray { + tags: Vec, + scores: Vec, + } + + #[test] + fn test_simple_struct() { + let mut types = TypeCollection::default(); + types.register::(); + let output = TinyBase::new().header("").export(&types).unwrap(); + insta::assert_snapshot!(output); + } + + #[test] + fn test_with_optional() { + let mut types = TypeCollection::default(); + types.register::(); + let output = TinyBase::new().header("").export(&types).unwrap(); + insta::assert_snapshot!(output); + } + + #[test] + fn test_with_array() { + let mut types = TypeCollection::default(); + types.register::(); + let output = TinyBase::new().header("").export(&types).unwrap(); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snake_case_conversion() { + assert_eq!(to_snake_case("SimpleStruct"), "simple_struct"); + assert_eq!(to_snake_case("MyStruct"), "my_struct"); + assert_eq!(to_snake_case("ABC"), "a_b_c"); + assert_eq!(to_snake_case("already_snake"), "already_snake"); + } +} diff --git a/crates/specta-zod/src/zod.rs b/crates/specta-zod/src/zod.rs index 95504bb56f..70ff2a3e88 100644 --- a/crates/specta-zod/src/zod.rs +++ b/crates/specta-zod/src/zod.rs @@ -10,6 +10,41 @@ use specta::{ use crate::Error; +#[derive(Debug, Default)] +struct ZodFieldAttrs { + default: Option, + schema: Option, + json: Option, +} + +fn parse_zod_attrs(docs: &str) -> ZodFieldAttrs { + let mut attrs = ZodFieldAttrs::default(); + + for line in docs.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("@zod.") { + if let Some(value) = rest + .strip_prefix("default(") + .and_then(|s| s.strip_suffix(")")) + { + attrs.default = Some(value.to_string()); + } else if let Some(value) = rest + .strip_prefix("schema(") + .and_then(|s| s.strip_suffix(")")) + { + attrs.schema = Some(value.to_string()); + } else if let Some(value) = rest.strip_prefix("json(").and_then(|s| s.strip_suffix(")")) + { + attrs.json = Some(value.to_string()); + } else if rest == "json" { + attrs.json = Some(String::new()); + } + } + } + + attrs +} + pub struct Zod { pub header: Cow<'static, str>, } @@ -237,10 +272,63 @@ fn field_type(s: &mut String, types: &TypeCollection, field: &Field) -> Result<( return Ok(()); }; - if field.optional() { + let attrs = parse_zod_attrs(field.docs()); + let is_nullable = matches!(ty, DataType::Nullable(_)); + + if let Some(schema) = &attrs.schema { + s.push_str(schema); + if let Some(default) = &attrs.default { + write!(s, ".default({})", default)?; + } + return Ok(()); + } + + if let Some(json_inner) = &attrs.json { + let is_optional = field.optional() || is_nullable; + if is_optional { + s.push_str("z.preprocess((val) => val ?? undefined, jsonObject("); + if json_inner.is_empty() { + if is_nullable { + if let DataType::Nullable(inner) = ty { + datatype(s, types, inner, true)?; + } + } else { + datatype(s, types, ty, true)?; + } + } else { + s.push_str(json_inner); + } + s.push_str(").optional())"); + } else { + s.push_str("jsonObject("); + if json_inner.is_empty() { + datatype(s, types, ty, true)?; + } else { + s.push_str(json_inner); + } + s.push(')'); + } + if let Some(default) = &attrs.default { + write!(s, ".default({})", default)?; + } + return Ok(()); + } + + if field.optional() && !is_nullable { s.push_str("z.preprocess((val) => val ?? undefined, "); datatype(s, types, ty, false)?; s.push_str(".optional())"); + } else if let Some(default) = &attrs.default { + if is_nullable { + write!(s, "z.preprocess((val) => val ?? {}, ", default)?; + if let DataType::Nullable(inner) = ty { + datatype(s, types, inner, false)?; + } + s.push(')'); + } else { + datatype(s, types, ty, false)?; + write!(s, ".default({})", default)?; + } } else { datatype(s, types, ty, false)?; } diff --git a/crates/store-types/Cargo.toml b/crates/store-types/Cargo.toml new file mode 100644 index 0000000000..11b32c690e --- /dev/null +++ b/crates/store-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "store-types" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +specta = { workspace = true, features = ["derive"] } +specta-zod = { path = "../specta-zod" } + +[build-dependencies] +serde = { workspace = true, features = ["derive"] } +specta = { workspace = true, features = ["derive"] } +specta-zod = { path = "../specta-zod" } diff --git a/crates/store-types/build.rs b/crates/store-types/build.rs new file mode 100644 index 0000000000..414493f23d --- /dev/null +++ b/crates/store-types/build.rs @@ -0,0 +1,118 @@ +use specta::TypeCollection; +use specta_zod::Zod; + +include!("src/types.rs"); + +fn main() { + let mut types = TypeCollection::default(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::(); + types.register::