Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changepacks/changepack_log_ZNL3OcXgV7tpTmN1Q93Z0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"Cargo.toml":"Patch"},"note":"Fix required issue, Support Set","date":"2026-02-18T16:13:52.003330500Z"}
1 change: 1 addition & 0 deletions .changepacks/changepack_log_sBCzXuxu_PPwqRXNIWHS6.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"Cargo.toml":"Patch"},"note":"Add omit_default to schema_type!","date":"2026-02-18T16:50:22.753492900Z"}
6 changes: 3 additions & 3 deletions Cargo.lock

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

53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,52 @@ schema_type!(UserDTO from User, rename_all = "camelCase");
// Available: "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", etc.
```

### Omit Fields with Database Defaults (`omit_default`)

Automatically omit fields that have database-level defaults — perfect for create DTOs where the database handles `id`, `created_at`, etc.:

```rust
#[derive(DeriveEntityModel)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)] // ← has default (auto-increment)
pub id: i32,
pub title: String,
pub content: String,
#[sea_orm(default_value = "NOW()")] // ← has default (SQL function)
pub created_at: DateTimeWithTimeZone,
}

// Omits `id` (primary_key) and `created_at` (default_value) automatically
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);
// Generated struct only has: title, content
```

`omit_default` detects fields with:
- `#[sea_orm(primary_key)]` — auto-increment / generated IDs
- `#[sea_orm(default_value = "...")]` — SQL defaults like `NOW()`, `gen_random_uuid()`, literals

Can be combined with other parameters:

```rust
// omit_default + add extra fields
schema_type!(CreateItemRequest from Model, omit_default, add = [("tags": Vec<String>)]);
```

### Database Defaults in OpenAPI

Fields with database defaults automatically get `default` values in the generated OpenAPI schema:

| SeaORM Attribute | OpenAPI Default |
|-----------------|-----------------|
| `primary_key` (Uuid) | `"00000000-0000-0000-0000-000000000000"` |
| `primary_key` (i32/i64) | `0` |
| `default_value = "NOW()"` | `"1970-01-01T00:00:00+00:00"` |
| `default_value = "gen_random_uuid()"` | `"00000000-0000-0000-0000-000000000000"` |
| `default_value = "true"` | `true` (literal passthrough) |

> **Note:** `required` is determined solely by nullability (`Option<T>`). Fields with defaults are still `required` unless they are `Option<T>`.

### SeaORM Integration

`schema_type!` has first-class support for SeaORM models with relations:
Expand Down Expand Up @@ -434,6 +480,7 @@ When `multipart` is enabled:
| `rename_all` | Serde rename strategy: `rename_all = "camelCase"` |
| `ignore` | Skip Schema derive (bare keyword, no value) |
| `multipart` | Derive `TryFromMultipart` instead of serde (bare keyword) |
| `omit_default` | Auto-omit fields with DB defaults: `primary_key`, `default_value` (bare keyword) |

---

Expand Down Expand Up @@ -547,6 +594,12 @@ This automatically:
| `Vec<T>` | `array` with items |
| `Option<T>` | nullable T |
| `HashMap<K, V>` | `object` with additionalProperties |
| `BTreeSet<T>`, `HashSet<T>` | `array` with `uniqueItems: true` |
| `Uuid` | `string` with `format: uuid` |
| `Decimal` | `string` with `format: decimal` |
| `NaiveDate` | `string` with `format: date` |
| `NaiveTime` | `string` with `format: time` |
| `DateTime`, `DateTimeWithTimeZone` | `string` with `format: date-time` |
| `FieldData<NamedTempFile>` | `string` with `format: binary` |
| Custom struct | `$ref` to components/schemas |

Expand Down
21 changes: 20 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,14 @@ pub struct User { id: u32, name: String }
| `f32`, `f64` | `number` | |
| `bool` | `boolean` | |
| `Vec<T>` | `array` + items | |
| `BTreeSet<T>`, `HashSet<T>` | `array` + items + `uniqueItems: true` | Set types |
| `Option<T>` | T (nullable context) | Parent marks as optional |
| `HashMap<K,V>` | `object` + additionalProperties | |
| `Uuid` | `string` + `format: uuid` | |
| `Decimal` | `string` + `format: decimal` | |
| `NaiveDate` | `string` + `format: date` | |
| `NaiveTime` | `string` + `format: time` | |
| `DateTime`, `DateTimeWithTimeZone` | `string` + `format: date-time` | |
| `FieldData<NamedTempFile>` | `string` + `format: binary` | File upload field |
| `()` | empty response | 204 No Content |
| Custom struct | `$ref` | Must derive Schema |
Expand Down Expand Up @@ -112,7 +118,7 @@ pub struct UserResponse {
#[serde(rename = "fullName")] // ✅ Respected
name: String, // → "fullName" in JSON Schema

#[serde(default)] // ✅ Marks as optional in schema
#[serde(default)] // ✅ Recognized (does NOT affect `required` — only Option<T> does)
bio: Option<String>,

#[serde(skip)] // ✅ Excluded from schema
Expand Down Expand Up @@ -188,6 +194,7 @@ npx @apidevtools/swagger-cli validate openapi.json
**Primary Parameters (USE THESE):**
- `pick = [...]` - Allowlist: include ONLY these fields
- `omit = [...]` - Denylist: exclude these fields
- `omit_default` - Auto-omit fields with DB defaults (primary_key, default_value)

**Advanced Parameters (USE SPARINGLY):**
- `partial` - For PATCH endpoints only
Expand Down Expand Up @@ -232,6 +239,12 @@ schema_type!(UserPatch from crate::models::user::Model, partial);
// Partial updates (specific fields only)
schema_type!(UserPatch from crate::models::user::Model, partial = ["name", "email"]);

// Auto-omit fields with DB defaults (primary_key, default_value = "...")
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);

// Combine omit_default with add
schema_type!(CreateItemRequest from crate::models::item::Model, omit_default, add = [("tags": Vec<String>)]);

// Custom serde rename strategy
schema_type!(UserSnakeCase from crate::models::user::Model, rename_all = "snake_case");

Expand Down Expand Up @@ -322,6 +335,7 @@ Json(model.into()) // Easy conversion!
|-----------|-------------|---------|
| `pick` | Include only these fields | `pick = ["name", "email"]` |
| `omit` | Exclude these fields | `omit = ["password"]` |
| `omit_default` | Auto-omit fields with DB defaults | `omit_default` (bare keyword) |

**Situational (Use When Needed):**

Expand Down Expand Up @@ -379,6 +393,10 @@ vespera::schema_type!(Schema from Model, name = "MemoSchema");

**Circular Reference Handling:** Automatically detected and handled by inlining fields.

**Database Defaults in OpenAPI:** Fields with `#[sea_orm(default_value = "...")]` or `#[sea_orm(primary_key)]` automatically get `default` values in the generated OpenAPI schema. SQL functions like `NOW()` and `gen_random_uuid()` are mapped to type-appropriate defaults.

**Required Logic:** `required` is determined **solely by nullability** (`Option<T>`). Fields with `#[serde(default)]` or `#[serde(skip_serializing_if)]` are still `required` unless they are `Option<T>`.

### Complete Example

```rust
Expand Down Expand Up @@ -477,6 +495,7 @@ tempfile = "3" # For NamedTempFile file uploads
```rust
// ✅ RECOMMENDED PATTERNS
schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]);
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);
schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]);
schema_type!(UserListItem from crate::models::user::Model, pick = ["id", "name"]);

Expand Down
2 changes: 1 addition & 1 deletion crates/vespera_macro/src/parser/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ fn is_known_type(
// Check for generic types like Vec<T>, Option<T> - recursively check inner type
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
match ident_str.as_str() {
"Vec" | "Option" => {
"Vec" | "HashSet" | "BTreeSet" | "Option" => {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
return is_known_type(inner_ty, known_schemas, struct_definitions);
}
Expand Down
59 changes: 22 additions & 37 deletions crates/vespera_macro/src/parser/schema/struct_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ use vespera_core::schema::{Schema, SchemaRef, SchemaType};

use super::{
serde_attrs::{
extract_default, extract_doc_comment, extract_field_rename, extract_flatten,
extract_rename_all, extract_skip, extract_skip_serializing_if, rename_field,
strip_raw_prefix,
extract_doc_comment, extract_field_rename, extract_flatten, extract_rename_all,
extract_skip, rename_field, strip_raw_prefix,
},
type_schema::parse_type_to_schema_ref,
};
Expand Down Expand Up @@ -104,36 +103,21 @@ pub fn parse_struct_to_schema(
}
}

// Check for default attribute
let has_default = extract_default(&field.attrs).is_some();

// Check for skip_serializing_if attribute
let has_skip_serializing_if = extract_skip_serializing_if(&field.attrs);
// Required is determined solely by nullability (Option<T>).
// Fields with #[serde(default)] still have defaults applied in
// openapi_generator, but that does NOT affect required status.
let is_optional = matches!(
field_type,
Type::Path(type_path)
if type_path
.path
.segments
.first()
.is_some_and(|s| s.ident == "Option")
);

// If default or skip_serializing_if is present, mark field as optional (not required)
// and set default value if it's a simple default (not a function)
if has_default || has_skip_serializing_if {
// For default = "function_name", we'll handle it in openapi_generator
// For now, just mark as optional
if let SchemaRef::Inline(ref mut _schema) = schema_ref {
// Default will be set later in openapi_generator if it's a function
// For simple default, we could set it here, but serde handles it
}
} else {
// Check if field is Option<T>
let is_optional = matches!(
field_type,
Type::Path(type_path)
if type_path
.path
.segments
.first()
.is_some_and(|s| s.ident == "Option")
);

if !is_optional {
required.push(field_name.clone());
}
if !is_optional {
required.push(field_name.clone());
}

properties.insert(field_name, schema_ref);
Expand Down Expand Up @@ -283,14 +267,15 @@ mod tests {
}

// Test struct with default and skip_serializing_if
// Required is determined solely by nullability (Option<T>), not by defaults.
#[test]
fn test_parse_struct_to_schema_with_default_fields() {
let struct_item: syn::ItemStruct = syn::parse_str(
r#"
struct Config {
required_field: i32,
#[serde(default)]
optional_with_default: String,
with_default: String,
#[serde(skip_serializing_if = "Option::is_none")]
maybe_skip: Option<i32>,
}
Expand All @@ -300,14 +285,14 @@ mod tests {
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
let props = schema.properties.as_ref().unwrap();
assert!(props.contains_key("required_field"));
assert!(props.contains_key("optional_with_default"));
assert!(props.contains_key("with_default"));
assert!(props.contains_key("maybe_skip"));

let required = schema.required.as_ref().unwrap();
assert!(required.contains(&"required_field".to_string()));
// Fields with default should NOT be required
assert!(!required.contains(&"optional_with_default".to_string()));
// Fields with skip_serializing_if should NOT be required
// Non-nullable fields are always required, even with #[serde(default)]
assert!(required.contains(&"with_default".to_string()));
// Option<T> fields are not required (nullable)
assert!(!required.contains(&"maybe_skip".to_string()));
}

Expand Down
Loading