Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/specta-zod/src/lib.rs
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" },
};
195 changes: 195 additions & 0 deletions crates/specta-zod/src/tinybase.rs
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");
}
}
90 changes: 89 additions & 1 deletion crates/specta-zod/src/zod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}
Expand Down Expand Up @@ -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 +283
Copy link
Contributor Author

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)] or Option<T>), the generated Zod schema doesn't include the z.preprocess((val) => val ?? undefined, ...) wrapper that handles null values.

Click to expand

Details

For MappingSessionParticipant.source in crates/store-types/src/types.rs:98-100:

/// @zod.schema(participantSourceSchema.optional())
#[specta(optional)]
pub source: Option<String>,

The old generated output was:

source: z.preprocess(
  (val) => val ?? undefined,
  participantSourceSchema.optional(),
),

The new generated output is:

source: participantSourceSchema.optional(),

The field_type function at lines 278-283 returns early when @zod.schema is present without checking if the field needs the z.preprocess wrapper for handling null values.

Impact

If the data source contains null values for this field (e.g., from a database or API), the Zod validation will fail because null is not a valid value for participantSourceSchema.optional() (which only accepts undefined or valid enum values).

Recommendation: When @zod.schema is used and the field is optional (field.optional() or is_nullable), wrap the schema in z.preprocess((val) => val ?? undefined, ...) before returning.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
Comment on lines +278 to +284
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing z.preprocess wrapper for optional fields when using @zod.schema. When a field has both #[specta(optional)] and @zod.schema(), the code outputs the schema directly without the z.preprocess((val) => val ?? undefined, ...) wrapper that other optional fields receive. This causes inconsistent behavior.

Impact: The mappingSessionParticipantSchema.source field will not handle null values the same way as the original schema, potentially causing runtime validation errors.

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
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(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(());
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


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)?;
}
Expand Down
15 changes: 15 additions & 0 deletions crates/store-types/Cargo.toml
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" }
Loading
Loading