-
Notifications
You must be signed in to change notification settings - Fork 537
Add TinyBase schema generation to specta-zod and create store-types crate #3491
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
621bee9
3859a3f
7907331
6dad2b9
fd2dc27
012042c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| mod error; | ||
| mod tinybase; | ||
| mod zod; | ||
|
|
||
| pub use error::Error; | ||
| pub use tinybase::TinyBase; | ||
| pub use zod::Zod; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Cow<'static, str>>) -> Self { | ||
| self.header = header.into(); | ||
| self | ||
| } | ||
|
|
||
| pub fn export(&self, types: &TypeCollection) -> Result<String, Error> { | ||
| 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<Path>, 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<String>, | ||
| optional_num: Option<i32>, | ||
| } | ||
|
|
||
| #[derive(Type, Serialize, Deserialize)] | ||
| struct WithArray { | ||
| tags: Vec<String>, | ||
| scores: Vec<i32>, | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_simple_struct() { | ||
| let mut types = TypeCollection::default(); | ||
| types.register::<SimpleStruct>(); | ||
| let output = TinyBase::new().header("").export(&types).unwrap(); | ||
| insta::assert_snapshot!(output); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_with_optional() { | ||
| let mut types = TypeCollection::default(); | ||
| types.register::<WithOptional>(); | ||
| let output = TinyBase::new().header("").export(&types).unwrap(); | ||
| insta::assert_snapshot!(output); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_with_array() { | ||
| let mut types = TypeCollection::default(); | ||
| types.register::<WithArray>(); | ||
| 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"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,6 +10,41 @@ use specta::{ | |||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| use crate::Error; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| #[derive(Debug, Default)] | ||||||||||||||||||||||||||||||||||||||||||
| struct ZodFieldAttrs { | ||||||||||||||||||||||||||||||||||||||||||
| default: Option<String>, | ||||||||||||||||||||||||||||||||||||||||||
| schema: Option<String>, | ||||||||||||||||||||||||||||||||||||||||||
| json: Option<String>, | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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(()); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+278
to
+284
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing Impact: The Fix: if let Some(schema) = &attrs.schema {
if field.optional() && !is_nullable {
s.push_str("z.preprocess((val) => val ?? undefined, ");
s.push_str(schema);
s.push(')'));
} else {
s.push_str(schema);
}
if let Some(default) = &attrs.default {
write!(s, ".default({})", default)?;
}
return Ok(());
}
Suggested change
Spotted by Graphite Agent |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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)?; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Missing z.preprocess wrapper when @zod.schema is used with optional fields
When a field has
@zod.schema(...)annotation and is also optional (via#[specta(optional)]orOption<T>), the generated Zod schema doesn't include thez.preprocess((val) => val ?? undefined, ...)wrapper that handlesnullvalues.Click to expand
Details
For
MappingSessionParticipant.sourceincrates/store-types/src/types.rs:98-100:The old generated output was:
The new generated output is:
The
field_typefunction at lines 278-283 returns early when@zod.schemais present without checking if the field needs thez.preprocesswrapper for handlingnullvalues.Impact
If the data source contains
nullvalues for this field (e.g., from a database or API), the Zod validation will fail becausenullis not a valid value forparticipantSourceSchema.optional()(which only acceptsundefinedor valid enum values).Recommendation: When
@zod.schemais used and the field is optional (field.optional()oris_nullable), wrap the schema inz.preprocess((val) => val ?? undefined, ...)before returning.Was this helpful? React with 👍 or 👎 to provide feedback.