Skip to content

Commit 6bcdfb3

Browse files
authored
Merge pull request #86 from dev-five-git/required-issue
Required issue
2 parents 0afce96 + 0a5edac commit 6bcdfb3

17 files changed

Lines changed: 940 additions & 339 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"Cargo.toml":"Patch"},"note":"Fix required issue, Support Set","date":"2026-02-18T16:13:52.003330500Z"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"Cargo.toml":"Patch"},"note":"Add omit_default to schema_type!","date":"2026-02-18T16:50:22.753492900Z"}

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,52 @@ schema_type!(UserDTO from User, rename_all = "camelCase");
363363
// Available: "camelCase", "snake_case", "PascalCase", "SCREAMING_SNAKE_CASE", etc.
364364
```
365365

366+
### Omit Fields with Database Defaults (`omit_default`)
367+
368+
Automatically omit fields that have database-level defaults — perfect for create DTOs where the database handles `id`, `created_at`, etc.:
369+
370+
```rust
371+
#[derive(DeriveEntityModel)]
372+
#[sea_orm(table_name = "posts")]
373+
pub struct Model {
374+
#[sea_orm(primary_key)] // ← has default (auto-increment)
375+
pub id: i32,
376+
pub title: String,
377+
pub content: String,
378+
#[sea_orm(default_value = "NOW()")] // ← has default (SQL function)
379+
pub created_at: DateTimeWithTimeZone,
380+
}
381+
382+
// Omits `id` (primary_key) and `created_at` (default_value) automatically
383+
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);
384+
// Generated struct only has: title, content
385+
```
386+
387+
`omit_default` detects fields with:
388+
- `#[sea_orm(primary_key)]` — auto-increment / generated IDs
389+
- `#[sea_orm(default_value = "...")]` — SQL defaults like `NOW()`, `gen_random_uuid()`, literals
390+
391+
Can be combined with other parameters:
392+
393+
```rust
394+
// omit_default + add extra fields
395+
schema_type!(CreateItemRequest from Model, omit_default, add = [("tags": Vec<String>)]);
396+
```
397+
398+
### Database Defaults in OpenAPI
399+
400+
Fields with database defaults automatically get `default` values in the generated OpenAPI schema:
401+
402+
| SeaORM Attribute | OpenAPI Default |
403+
|-----------------|-----------------|
404+
| `primary_key` (Uuid) | `"00000000-0000-0000-0000-000000000000"` |
405+
| `primary_key` (i32/i64) | `0` |
406+
| `default_value = "NOW()"` | `"1970-01-01T00:00:00+00:00"` |
407+
| `default_value = "gen_random_uuid()"` | `"00000000-0000-0000-0000-000000000000"` |
408+
| `default_value = "true"` | `true` (literal passthrough) |
409+
410+
> **Note:** `required` is determined solely by nullability (`Option<T>`). Fields with defaults are still `required` unless they are `Option<T>`.
411+
366412
### SeaORM Integration
367413

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

438485
---
439486

@@ -547,6 +594,12 @@ This automatically:
547594
| `Vec<T>` | `array` with items |
548595
| `Option<T>` | nullable T |
549596
| `HashMap<K, V>` | `object` with additionalProperties |
597+
| `BTreeSet<T>`, `HashSet<T>` | `array` with `uniqueItems: true` |
598+
| `Uuid` | `string` with `format: uuid` |
599+
| `Decimal` | `string` with `format: decimal` |
600+
| `NaiveDate` | `string` with `format: date` |
601+
| `NaiveTime` | `string` with `format: time` |
602+
| `DateTime`, `DateTimeWithTimeZone` | `string` with `format: date-time` |
550603
| `FieldData<NamedTempFile>` | `string` with `format: binary` |
551604
| Custom struct | `$ref` to components/schemas |
552605

SKILL.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ pub struct User { id: u32, name: String }
3939
| `f32`, `f64` | `number` | |
4040
| `bool` | `boolean` | |
4141
| `Vec<T>` | `array` + items | |
42+
| `BTreeSet<T>`, `HashSet<T>` | `array` + items + `uniqueItems: true` | Set types |
4243
| `Option<T>` | T (nullable context) | Parent marks as optional |
4344
| `HashMap<K,V>` | `object` + additionalProperties | |
45+
| `Uuid` | `string` + `format: uuid` | |
46+
| `Decimal` | `string` + `format: decimal` | |
47+
| `NaiveDate` | `string` + `format: date` | |
48+
| `NaiveTime` | `string` + `format: time` | |
49+
| `DateTime`, `DateTimeWithTimeZone` | `string` + `format: date-time` | |
4450
| `FieldData<NamedTempFile>` | `string` + `format: binary` | File upload field |
4551
| `()` | empty response | 204 No Content |
4652
| Custom struct | `$ref` | Must derive Schema |
@@ -112,7 +118,7 @@ pub struct UserResponse {
112118
#[serde(rename = "fullName")] // ✅ Respected
113119
name: String, // → "fullName" in JSON Schema
114120

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

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

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

242+
// Auto-omit fields with DB defaults (primary_key, default_value = "...")
243+
schema_type!(CreatePostRequest from crate::models::post::Model, omit_default);
244+
245+
// Combine omit_default with add
246+
schema_type!(CreateItemRequest from crate::models::item::Model, omit_default, add = [("tags": Vec<String>)]);
247+
235248
// Custom serde rename strategy
236249
schema_type!(UserSnakeCase from crate::models::user::Model, rename_all = "snake_case");
237250

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

326340
**Situational (Use When Needed):**
327341

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

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

396+
**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.
397+
398+
**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>`.
399+
382400
### Complete Example
383401

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

crates/vespera_macro/src/parser/parameters.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ fn is_known_type(
316316
// Check for generic types like Vec<T>, Option<T> - recursively check inner type
317317
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
318318
match ident_str.as_str() {
319-
"Vec" | "Option" => {
319+
"Vec" | "HashSet" | "BTreeSet" | "Option" => {
320320
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
321321
return is_known_type(inner_ty, known_schemas, struct_definitions);
322322
}

crates/vespera_macro/src/parser/schema/struct_schema.rs

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ use vespera_core::schema::{Schema, SchemaRef, SchemaType};
1010

1111
use super::{
1212
serde_attrs::{
13-
extract_default, extract_doc_comment, extract_field_rename, extract_flatten,
14-
extract_rename_all, extract_skip, extract_skip_serializing_if, rename_field,
15-
strip_raw_prefix,
13+
extract_doc_comment, extract_field_rename, extract_flatten, extract_rename_all,
14+
extract_skip, rename_field, strip_raw_prefix,
1615
},
1716
type_schema::parse_type_to_schema_ref,
1817
};
@@ -104,36 +103,21 @@ pub fn parse_struct_to_schema(
104103
}
105104
}
106105

107-
// Check for default attribute
108-
let has_default = extract_default(&field.attrs).is_some();
109-
110-
// Check for skip_serializing_if attribute
111-
let has_skip_serializing_if = extract_skip_serializing_if(&field.attrs);
106+
// Required is determined solely by nullability (Option<T>).
107+
// Fields with #[serde(default)] still have defaults applied in
108+
// openapi_generator, but that does NOT affect required status.
109+
let is_optional = matches!(
110+
field_type,
111+
Type::Path(type_path)
112+
if type_path
113+
.path
114+
.segments
115+
.first()
116+
.is_some_and(|s| s.ident == "Option")
117+
);
112118

113-
// If default or skip_serializing_if is present, mark field as optional (not required)
114-
// and set default value if it's a simple default (not a function)
115-
if has_default || has_skip_serializing_if {
116-
// For default = "function_name", we'll handle it in openapi_generator
117-
// For now, just mark as optional
118-
if let SchemaRef::Inline(ref mut _schema) = schema_ref {
119-
// Default will be set later in openapi_generator if it's a function
120-
// For simple default, we could set it here, but serde handles it
121-
}
122-
} else {
123-
// Check if field is Option<T>
124-
let is_optional = matches!(
125-
field_type,
126-
Type::Path(type_path)
127-
if type_path
128-
.path
129-
.segments
130-
.first()
131-
.is_some_and(|s| s.ident == "Option")
132-
);
133-
134-
if !is_optional {
135-
required.push(field_name.clone());
136-
}
119+
if !is_optional {
120+
required.push(field_name.clone());
137121
}
138122

139123
properties.insert(field_name, schema_ref);
@@ -283,14 +267,15 @@ mod tests {
283267
}
284268

285269
// Test struct with default and skip_serializing_if
270+
// Required is determined solely by nullability (Option<T>), not by defaults.
286271
#[test]
287272
fn test_parse_struct_to_schema_with_default_fields() {
288273
let struct_item: syn::ItemStruct = syn::parse_str(
289274
r#"
290275
struct Config {
291276
required_field: i32,
292277
#[serde(default)]
293-
optional_with_default: String,
278+
with_default: String,
294279
#[serde(skip_serializing_if = "Option::is_none")]
295280
maybe_skip: Option<i32>,
296281
}
@@ -300,14 +285,14 @@ mod tests {
300285
let schema = parse_struct_to_schema(&struct_item, &HashSet::new(), &HashMap::new());
301286
let props = schema.properties.as_ref().unwrap();
302287
assert!(props.contains_key("required_field"));
303-
assert!(props.contains_key("optional_with_default"));
288+
assert!(props.contains_key("with_default"));
304289
assert!(props.contains_key("maybe_skip"));
305290

306291
let required = schema.required.as_ref().unwrap();
307292
assert!(required.contains(&"required_field".to_string()));
308-
// Fields with default should NOT be required
309-
assert!(!required.contains(&"optional_with_default".to_string()));
310-
// Fields with skip_serializing_if should NOT be required
293+
// Non-nullable fields are always required, even with #[serde(default)]
294+
assert!(required.contains(&"with_default".to_string()));
295+
// Option<T> fields are not required (nullable)
311296
assert!(!required.contains(&"maybe_skip".to_string()));
312297
}
313298

0 commit comments

Comments
 (0)