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
20 changes: 20 additions & 0 deletions crates/jerrycan/src/platform/design.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,26 @@ pub struct Entity {
pub fields: Vec<Field>,
}

impl Entity {
/// Whether this entity's `id` primary key is SERVER-ASSIGNED (issue #302). True
/// when `id` is OMITTED (a synthetic auto-increment pk is added) OR declared as
/// `integer` — in both the DB assigns the pk (`BIGSERIAL`/autoincrement), so it is
/// dropped from the create body (`#[serde(default)]` in the Model/`{Entity}Request`,
/// excluded from the OpenAPI create schema, `readOnly` on the response component),
/// inserted as `ActiveValue::NotSet`, and NOT posted by the generated create probe.
/// False for a declared `string`/`uuid` id — that is CLIENT-SUPPLIED (the client
/// sends it, the handler `Set`s it, the probe posts it). Contract:
/// docs/ai/00-designing.md:155-165 promises `omit id` and `declare id:integer`
/// behave identically; this is the single discriminator all four surfaces route
/// through (mirrors the DDL's `Some(t) if t != FieldType::Integer` pk branch).
pub fn id_is_server_assigned(&self) -> bool {
match self.fields.iter().find(|f| f.name == "id") {
None => true,
Some(f) => f.field_type == FieldType::Integer,
}
}
}

#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down
100 changes: 84 additions & 16 deletions crates/jerrycan/src/platform/genroute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1420,12 +1420,13 @@ pub(crate) fn model_rs_db(m: &ModuleDesign, design: &Design, auth: bool) -> Opti
let snake = Design::to_snake(&e.name);
let table = design.table_name(&e.name);
let key = key_rust_type(e);
// The synthetic pk surfaces as a visible `id` field so POST bodies may
// omit it (`#[serde(default)]`); a declared id has no default.
let id_default = if declared_id(e).is_some() {
""
} else {
// A server-assigned pk (synthetic OR a declared `integer`, issue #302)
// surfaces as a visible `id` field POST bodies MAY omit (`#[serde(default)]`)
// — the DB assigns it; a client-supplied `string`/`uuid` pk has no default.
let id_default = if e.id_is_server_assigned() {
" #[serde(default)]\n"
} else {
""
};

let mut fields = String::new();
Expand Down Expand Up @@ -1587,10 +1588,13 @@ fn request_dto_rs(e: &Entity, design: &Design, for_update: bool) -> String {
format!("{entity}Request")
};
let key = key_rust_type(e);
let id_default = if declared_id(e).is_some() {
""
} else {
// A server-assigned pk (synthetic OR a declared `integer`, issue #302) is dropped
// from the create body — `#[serde(default)]` so a client MAY omit it and the DB
// assigns it; a client-supplied `string`/`uuid` pk stays required (no default).
let id_default = if e.id_is_server_assigned() {
" #[serde(default)]\n"
} else {
""
};
// Identity fk is dropped only under auth (#34 injects the session user's id);
// a path-redundant parent fk (#53b) is dropped regardless of auth.
Expand Down Expand Up @@ -1831,8 +1835,9 @@ fn model_field_names(e: &Entity) -> Vec<String> {
}

/// ActiveModel field assignments, one per line. `item` is consumed, so values
/// move in (no clones). A synthetic pk (no declared `id`) is `NotSet` so the
/// DB assigns the autoincrement id; a declared pk is `Set` from the item.
/// move in (no clones). A server-assigned pk (synthetic OR a declared `integer`,
/// issue #302) is `NotSet` so the DB assigns the autoincrement id; a client-supplied
/// `string`/`uuid` pk is `Set` from the item.
/// `with_id == false` omits the id line (the update path sets it explicitly).
fn active_sets(e: &Entity, with_id: bool) -> String {
let indent = " ";
Expand All @@ -1842,10 +1847,10 @@ fn active_sets(e: &Entity, with_id: bool) -> String {
if !with_id {
continue;
}
if declared_id(e).is_some() {
out.push_str(&format!("{indent}id: Set(item.id),\n"));
} else {
if e.id_is_server_assigned() {
out.push_str(&format!("{indent}id: sea_orm::ActiveValue::NotSet,\n"));
} else {
out.push_str(&format!("{indent}id: Set(item.id),\n"));
}
} else {
// A keyword field is a raw identifier on both the ActiveModel field
Expand Down Expand Up @@ -1890,10 +1895,10 @@ fn active_sets_pinning(e: &Entity, with_id: bool, pin_col: &str, pin_expr: &str)
if !with_id {
continue;
}
if declared_id(e).is_some() {
out.push_str(&format!("{indent}id: Set(item.id),\n"));
} else {
if e.id_is_server_assigned() {
out.push_str(&format!("{indent}id: sea_orm::ActiveValue::NotSet,\n"));
} else {
out.push_str(&format!("{indent}id: Set(item.id),\n"));
}
} else {
let ident = rust_ident(&name);
Expand Down Expand Up @@ -8736,6 +8741,69 @@ pub(crate) mod tests {
assert_eq!(pg.matches("\"id\"").count(), 1, "{pg}");
}

/// #302: `omit id` and `declare id:integer` must behave IDENTICALLY — both are a
/// SERVER-assigned pk (the DB autoincrements it). So a declared integer id is
/// dropped from the create body (`#[serde(default)]` on the Model AND the
/// `{Entity}Request`, so a client MAY omit it) and the insert leaves it `NotSet`.
/// A declared `string`/`uuid` id is CLIENT-supplied: required in the body, `Set`
/// from the item. Guards the four-surface agreement at its genroute source.
#[test]
fn create_body_drops_a_server_assigned_id_keeps_a_client_supplied_one() {
let id_field = |ty: FieldType| Field {
name: "id".into(),
field_type: ty,
required: true,
unique: false,
index: false,
values: None,
default: None,
min: None,
max: None,
min_len: None,
max_len: None,
write_only: false,
reserve_against: None,
};

// Declared INTEGER id → server-assigned, identical to a synthetic pk.
let mut mi = todos();
mi.entities[0]
.fields
.insert(0, id_field(FieldType::Integer));
let ei = &mi.entities[0];
let dto_i = request_dto_rs(ei, &demo(), false);
assert!(
dto_i.contains("#[serde(default)]\n pub id: i64,"),
"integer id is droppable from the {{Entity}}Request body: {dto_i}"
);
let model_i = model_rs_db(&mi, &demo(), false).unwrap();
assert!(
model_i.contains("#[serde(default)]\n pub id: i64,"),
"the Model (plain create body) also drops the integer id: {model_i}"
);
assert!(
active_sets(ei, true).contains("id: sea_orm::ActiveValue::NotSet,"),
"the insert leaves a server-assigned id NotSet: {}",
active_sets(ei, true)
);

// Declared UUID id → client-supplied, unchanged (required, Set from the item).
let mut mu = todos();
mu.entities[0].fields.insert(0, id_field(FieldType::Uuid));
let eu = &mu.entities[0];
let dto_u = request_dto_rs(eu, &demo(), false);
assert!(
dto_u.contains(" pub id: String,")
&& !dto_u.contains("#[serde(default)]\n pub id:"),
"a uuid id stays required client input (no serde default): {dto_u}"
);
assert!(
active_sets(eu, true).contains("id: Set(item.id),"),
"the insert Sets a client-supplied id: {}",
active_sets(eu, true)
);
}

/// Text ids (uuid/string) are the pk with their declared type, and the
/// whole generated surface keys on String — repo signatures, the insert
/// return (sqlite has no last_insert_id for text pks), and Path extractors.
Expand Down
58 changes: 48 additions & 10 deletions crates/jerrycan/src/platform/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,19 @@ fn walk_schemas(design: &Design, m: &ModuleDesign, schemas: &mut serde_json::Map
required.push(Value::String("id".into()));
}
for f in &e.fields {
// #302: a declared `integer` id is SERVER-assigned (DB autoincrement, `NotSet`
// on insert) — mark it `readOnly` + required so the component (which doubles as
// the plain CREATE body when no `{Entity}Request` is minted) documents it as
// response-only, never client-sent, byte-identical to the synthetic pk above. A
// declared `string`/`uuid` id is client-supplied and rides the normal loop.
if f.name == "id" && e.id_is_server_assigned() {
properties.insert(
"id".into(),
json!({ "type": "integer", "format": "int64", "readOnly": true }),
);
required.push(Value::String("id".into()));
continue;
}
// #274: in db mode an optional field is `Option<T>` — it serializes `None`
// as an explicit `null` and accepts `null` — so its schema must admit null.
// Memory-mode optional fields are bare `T` with `#[serde(default)]` (a
Expand Down Expand Up @@ -414,11 +427,14 @@ fn request_schema(design: &Design, e: &Entity, for_update: bool) -> Value {
// `now`-default timestamp (#110) is server-owned AND set-once, so it is dropped
// from BOTH request schemas (immutable on update). The extra clause is inert for
// every non-`now` field — designs without the sentinel stay byte-identical.
for f in e
.fields
.iter()
.filter(|f| (for_update || f.default.is_none()) && !Design::field_is_now_default(f))
{
for f in e.fields.iter().filter(|f| {
// #302: a server-assigned (synthetic/declared-`integer`) id is out of the create
// body — excluded here exactly as `request_dto_rs` drops it. A client-supplied
// `string`/`uuid` id stays (required client input).
!(f.name == "id" && e.id_is_server_assigned())
&& (for_update || f.default.is_none())
&& !Design::field_is_now_default(f)
}) {
// #274: the db request DTO types an optional field as `Option<T>` (it accepts
// `null`), so its schema must admit null. `request_schema` is only built in db
// mode (gated at the call sites in `walk_schemas`), so this needs no db-check.
Expand Down Expand Up @@ -867,6 +883,9 @@ mod tests {
{ "name": "Widget", "fields": [{ "name": "label", "type": "string" }] },
{ "name": "Gadget", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "label", "type": "string" } ]},
{ "name": "Token", "fields": [
{ "name": "id", "type": "string" },
{ "name": "label", "type": "string" } ]} ],
"endpoints": [
{ "operation_id": "get_widget", "method": "GET", "path": "/{id}",
Expand All @@ -888,13 +907,16 @@ mod tests {
.any(|v| v == "id"),
"synthetic id is required: {widget}"
);
// Declared-id entity: `id` present exactly once (from its field, not doubled),
// and NOT readOnly — a declared id is client-supplied.
// #302: a declared INTEGER id is SERVER-assigned — identical to the synthetic
// pk above: integer, `readOnly`, required (present in the response, never sent
// on the plain-body create). `id` present exactly once (from its field, not
// doubled with a synthetic pk).
let gadget = &d["components"]["schemas"]["Gadget"];
assert_eq!(gadget["properties"]["id"]["type"], "integer");
assert!(
gadget["properties"]["id"].get("readOnly").is_none(),
"a declared id is client-supplied, never readOnly: {gadget}"
assert_eq!(gadget["properties"]["id"]["format"], "int64");
assert_eq!(
gadget["properties"]["id"]["readOnly"], true,
"a declared integer id is server-assigned, readOnly like the synthetic pk: {gadget}"
);
assert_eq!(
gadget["required"]
Expand All @@ -906,6 +928,22 @@ mod tests {
1,
"declared id is not duplicated: {gadget}"
);
// #302: a declared STRING/uuid id is CLIENT-supplied — present, required, and
// NOT readOnly (the client sends the pk on create).
let token = &d["components"]["schemas"]["Token"];
assert_eq!(token["properties"]["id"]["type"], "string");
assert!(
token["properties"]["id"].get("readOnly").is_none(),
"a declared string id is client-supplied, never readOnly: {token}"
);
assert!(
token["required"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "id"),
"client-supplied id is required: {token}"
);
// Memory mode: no synthetic id → the component stays fields-only.
const MEM: &str = r#"{
"name": "memsynth", "contract_version": 1, "dependencies": [],
Expand Down
Loading
Loading