Skip to content

Commit 260a42a

Browse files
committed
ogar-from-ruff: project Rails schema fields at lift (falsifier #1 gap-close)
Closes GAP-1/3/4 + one cosmetic found by the falsifier #1 parity probe (WorkPackage, 18 hand-written schema columns) against the Ruby lift. GAP-1 (lib.rs) — `lift_model_with_language` only ever called `project_odoo_fields` for `Language::Python`; Rails' `Model::fields` (the D-AR-3.5 physical schema stratum populated by `ruff_ruby_spo::extract_app_with_schema`) never reached `Class` at all. Added `project_rails_fields` (called for `Language::Ruby`), mirroring `project_odoo_fields`'s relational/scalar/computed-field split, plus: - `not_null` wiring onto `AttributeOptions::required` (`Some(true)` -> `Some(true)`; `None` -> the explicit `Some(false)`, since the schema stratum is total knowledge). - FK-dedup (`is_fk_shadowed_by_association`): a scalar `<name>_id` column is skipped when the model already declares an association named `<name>` — the physical FK column and the declared `belongs_to`/`has_many` are the SAME relation seen from two strata; the canon keeps the AR spelling (`<name>: ToOne<X>`), not the ORM spelling (`<name>_id: OgInt`). The literal `id` primary key is never shadowed. GAP-3 (emit.rs, `og_scalar_type`) — added `"string"`/`"bigint"` to the existing `OgStr`/`OgInt` arms (the Rails migration-DSL spelling of Odoo's `char`/`integer`), and `"decimal"` to the `monetary` -> `OgMoney` arm. No dedicated `OgDecimal` wrapper exists in the consumer contract today, so `decimal` shares `OgMoney` (the closest existing Decimal-backed wrapper) rather than falling back to `OgScalar`; a future `OgDecimal` should take over this arm without touching `monetary`. GAP-4 (emit.rs, `emit_rust`) — an attribute whose `AttributeOptions::required == Some(false)` now emits wrapped in `Option<...>`; any other value (`Some(true)`, or `None` — the Odoo path today, which never sets `required`) emits the bare type unchanged. Confirmed zero drift on the Odoo path with a dedicated test (`lift_model_python_never_sets_required_zero_drift`) plus the existing Odoo emit tests, all unchanged and green. COSMETIC (emit.rs, all three emitters' doc-comment lines) — the generated doc line computed "concept" as `facet_classid() as u16`, which reads the LOW (app/render-prefix) half; post canon-high-flip the concept is the HIGH half. Routed through `ogar_vocab::app::{concept_of, app_of}` and print both explicitly: `classid `0x01020001` (concept `0x0102`, app `0x0001`)`. Dependency note: `Field::not_null` (D-AR-3.5) does not exist at the previously-pinned ruff rev `61ce2b49`. Bumped `ogar-from-ruff` and `ogar-from-rails` (lockstep, per their existing comment) to `c514b96d38d764bb16ac070fb2fdbd13477f41bc` — the pushed tip of `AdaWorldAPI/ruff@claude/odoo-rs-transcode-lf8ya5` carrying the schema stratum this fix reads. Still the AdaWorldAPI fork, no crates.io fallback, no coordinate substitution. Tests: replaced the now-obsolete `lift_model_ruby_does_not_project_fields` (pinned the pre-fix bug) with `lift_model_ruby_projects_schema_stratum_fields_with_types`, `lift_model_ruby_fk_scalar_deduped_against_declared_association_id_kept`, `lift_model_ruby_wires_not_null_to_required`, and the zero-drift proof `lift_model_python_never_sets_required_zero_drift` in lib.rs; added `emits_rust_struct_with_typed_and_optional_schema_fields_for_rails` and `emit_rust_doc_line_prints_concept_high_and_app_low` in emit.rs. `cargo test -p ogar-from-ruff -p ogar-vocab -p ogar-from-rails --lib`: 53 + 96 + 1 passed (17 ignored, require real source checkouts), 0 failed. `cargo clippy -p ogar-from-ruff --all-targets -- -D warnings`: clean. Falsifier #1 probe tally (WorkPackage, scratchpad parity-probe/): pre-fix: attributes.len=0, associations.len=9 (0/18 schema columns reached the lifted Class at all) post-fix: attributes.len=10, associations.len=9 — all 10 remaining attributes typed (id/subject:bigint,string ->OgInt,OgStr; description/start_date/due_date/estimated_hours/done_ratio -> Option<OgStr|OgDate|OgDate|OgFloat|OgInt>; lock_version/created_at/updated_at -> bare OgInt/OgDateTime); 8 FK columns (project_id/type_id/status_id/priority_id/ author_id/assigned_to_id/responsible_id/parent_id) correctly deduped into their matching `ToOne<X>` associations. 18/18 raw schema columns typed (zero OgScalar fallback) — exceeds the ~16+/18 estimate. Board: docs/DISCOVERY-MAP.md D-PARITY-PROBE-WP-1 (falsifier #1 measured, [G], re-runnable probe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9qw3LYDprZi4uts2m57iq
1 parent a14bcc8 commit 260a42a

5 files changed

Lines changed: 428 additions & 41 deletions

File tree

crates/ogar-from-rails/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,8 @@ ogar-vocab = { path = "../ogar-vocab" }
2121
ogar-from-ruff = { path = "../ogar-from-ruff" }
2222
# Same exact ruff rev as ogar-from-ruff (they share ruff_spo_triplet
2323
# internally; a rev mismatch double-checks-out ruff). Bump in lockstep.
24-
ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", rev = "61ce2b490fc3c432d36c44eceed08125f838b405" }
24+
# Bumped to `c514b96d38d764bb16ac070fb2fdbd13477f41bc` alongside
25+
# ogar-from-ruff (falsifier #1 gap-close / D-PARITY-PROBE-WP-1) — see that
26+
# crate's Cargo.toml for why.
27+
ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", rev = "c514b96d38d764bb16ac070fb2fdbd13477f41bc" }
2528
serde = { workspace = true, optional = true }

crates/ogar-from-ruff/Cargo.toml

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,18 @@ ogar-vocab = { path = "../ogar-vocab" }
1717
# ruff pinned to an exact rev (not floating `branch = "main"`): this crate
1818
# reads `ruff_spo_triplet::Model::inherits`, added in ruff PR #40. With no
1919
# committed Cargo.lock, a floating branch could resolve `main` to a rev
20-
# WITHOUT that field and fail to compile. `61ce2b49` is ruff main at the #40
21-
# merge. Both ruff crates MUST share one rev (they depend on ruff_spo_triplet
22-
# internally; a mismatch double-checks-out ruff). Bump in lockstep with
23-
# ogar-from-rails.
24-
ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", rev = "61ce2b490fc3c432d36c44eceed08125f838b405" }
25-
ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", rev = "61ce2b490fc3c432d36c44eceed08125f838b405" }
20+
# WITHOUT that field and fail to compile. Both ruff crates MUST share one
21+
# rev (they depend on ruff_spo_triplet internally; a mismatch double-checks-
22+
# out ruff). Bump in lockstep with ogar-from-rails.
23+
#
24+
# `c514b96d38d764bb16ac070fb2fdbd13477f41bc` (bumped from `61ce2b49`,
25+
# falsifier #1 gap-close / D-PARITY-PROBE-WP-1): tip of
26+
# `AdaWorldAPI/ruff@claude/odoo-rs-transcode-lf8ya5` at the time of this
27+
# bump, carrying the D-AR-3.5 schema stratum (`Field::not_null`,
28+
# `ruff_ruby_spo::extract_app_with_schema`) this crate's
29+
# `project_rails_fields` reads. `61ce2b49` predates that field entirely
30+
# (`Field` has no `not_null` at that rev) and cannot compile this crate's
31+
# Rails schema-field projection.
32+
ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", rev = "c514b96d38d764bb16ac070fb2fdbd13477f41bc" }
33+
ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", rev = "c514b96d38d764bb16ac070fb2fdbd13477f41bc" }
2634
serde = { workspace = true, optional = true }

crates/ogar-from-ruff/src/emit.rs

Lines changed: 170 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,31 @@
2323
//! re-implementation — the layer-1 / layer-2 story of substrate doc §1.6.
2424
//!
2525
//! Scalar attributes emit a **concrete wrapper type** mapped from the Odoo
26-
//! constructor carried on `Attribute::type_name` (the ruff `field_type`
27-
//! predicate): `char`/`text`/`html` → `OgStr`, `integer` → `OgInt`,
28-
//! `float` → `OgFloat`, `monetary` → `OgMoney` (Decimal-backed), `boolean` →
29-
//! `OgBool`, `date`/`datetime` → `OgDate`/`OgDateTime`, `binary` → `OgBytes`,
26+
//! constructor OR the Rails migration-DSL column type — both arrive on
27+
//! `Attribute::type_name` (the ruff `field_type` predicate is shared by both
28+
//! producers): `char`/`text`/`html`/`string` → `OgStr`, `integer`/`bigint` →
29+
//! `OgInt`, `float` → `OgFloat`, `monetary`/`decimal` → `OgMoney`
30+
//! (Decimal-backed — `decimal` shares the wrapper pending a dedicated
31+
//! `OgDecimal`, see [`og_scalar_type`]'s doc), `boolean` → `OgBool`,
32+
//! `date`/`datetime` → `OgDate`/`OgDateTime`, `binary` → `OgBytes`,
3033
//! `selection` → `OgSelection`, `json` → `OgJson`; an untyped/unknown column
3134
//! falls back to the generic `OgScalar`. See [`og_scalar_type`].
3235
//!
36+
//! An attribute whose `AttributeOptions::required == Some(false)` (Rails
37+
//! schema-stratum columns project this explicitly — see
38+
//! `ogar-from-ruff::project_rails_fields`) emits wrapped in `Option<...>`
39+
//! in [`emit_rust`] — nullable-at-the-database IS optional-in-Rust. Any
40+
//! other `required` value (`Some(true)`, or `None` — the Odoo path today,
41+
//! which never sets `required`) emits the bare wrapper type unchanged, so
42+
//! the Odoo emit output is bit-for-bit stable across this change.
43+
//!
3344
//! Field identifiers that collide with a target-language reserved word are
3445
//! escaped (see [`escape_ident`]) so the emitted source compiles — e.g. an
3546
//! Odoo field named `type` / `ref` becomes `r#type` / `r#ref` in Rust and
3647
//! `@type` / `@ref` in C#. Python source field names cannot be keywords (the
3748
//! Odoo source would not parse), so Python emit needs no escaping.
3849
50+
use ogar_vocab::app::{app_of, concept_of};
3951
use ogar_vocab::{Association, AssociationKind};
4052

4153
use crate::mint::CompiledClass;
@@ -55,19 +67,32 @@ fn assoc_target(assoc: &Association) -> (String, bool) {
5567
(target, is_many)
5668
}
5769

58-
/// Map an Odoo field constructor (lowercased, carried on `Attribute::type_name`
59-
/// as the ruff `field_type` predicate) to the consumer wrapper-contract scalar
60-
/// type. Shared by all three emitters — the type NAMES are identical across
70+
/// Map an Odoo field constructor OR a Rails migration-DSL column type
71+
/// (lowercased, both carried on `Attribute::type_name` as the shared ruff
72+
/// `field_type` predicate) to the consumer wrapper-contract scalar type.
73+
/// Shared by all three emitters — the type NAMES are identical across
6174
/// languages (§1.6). `None` (type not captured) and any unrecognised
6275
/// constructor fall back to the generic `OgScalar`, so the emit is always
63-
/// well-typed. `monetary` → `OgMoney` is Decimal-backed (the ERP money
64-
/// doctrine), never a float.
76+
/// well-typed.
77+
///
78+
/// `monetary`/`decimal` → `OgMoney` is Decimal-backed (the ERP money
79+
/// doctrine), never a float. There is no dedicated `OgDecimal` wrapper in
80+
/// the consumer contract today — `decimal` (Rails `t.decimal`, arbitrary
81+
/// precision, not necessarily currency) shares `OgMoney` as the closest
82+
/// existing Decimal-backed wrapper rather than falling back to the
83+
/// untyped `OgScalar`; a future `OgDecimal` should take over this arm
84+
/// without touching `monetary`.
85+
///
86+
/// `string`/`bigint` are the Rails migration-DSL column-type tokens
87+
/// (`ruff_ruby_spo::schema::COLUMN_TYPES`) for what Odoo spells
88+
/// `char`/… and `integer` respectively — same wrapper, different source
89+
/// vocabulary.
6590
fn og_scalar_type(type_name: Option<&str>) -> &'static str {
6691
match type_name {
67-
Some("char" | "text" | "html") => "OgStr",
68-
Some("integer") => "OgInt",
92+
Some("char" | "text" | "html" | "string") => "OgStr",
93+
Some("integer" | "bigint") => "OgInt",
6994
Some("float") => "OgFloat",
70-
Some("monetary") => "OgMoney",
95+
Some("monetary" | "decimal") => "OgMoney",
7196
Some("boolean") => "OgBool",
7297
Some("date") => "OgDate",
7398
Some("datetime") => "OgDateTime",
@@ -258,10 +283,11 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
258283
let mut out = String::new();
259284

260285
out.push_str(&format!(
261-
"/// Rail class `{}` — classid `0x{:08X}` (concept `0x{:04X}`).\n",
286+
"/// Rail class `{}` — classid `0x{:08X}` (concept `0x{:04X}`, app `0x{:04X}`).\n",
262287
cc.class.name,
263288
cc.facet.facet_classid(),
264-
cc.facet.facet_classid() as u16,
289+
concept_of(cc.facet.facet_classid()),
290+
app_of(cc.facet.facet_classid()),
265291
));
266292
out.push_str(&format!(
267293
"pub const {}_CLASSID: u32 = 0x{:08X};\n\n",
@@ -271,10 +297,21 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
271297

272298
out.push_str(&format!("pub struct {ty} {{\n"));
273299
for attr in &cc.class.attributes {
300+
let base_ty = og_scalar_type(attr.type_name.as_deref());
301+
// `required == Some(false)` is the ONLY nullability signal that
302+
// wraps in `Option<...>` — the Rails schema stratum sets this
303+
// explicitly (see `project_rails_fields`). `Some(true)` and `None`
304+
// (the Odoo path today, which never sets `required`) both emit the
305+
// bare wrapper type unchanged, so Odoo's emit output is bit-for-bit
306+
// stable across this change (GAP-4, falsifier #1 gap-close).
307+
let field_ty = if attr.options.required == Some(false) {
308+
format!("Option<{base_ty}>")
309+
} else {
310+
base_ty.to_string()
311+
};
274312
out.push_str(&format!(
275-
" pub {}: {},\n",
313+
" pub {}: {field_ty},\n",
276314
escape_ident(&attr.name, Lang::Rust),
277-
og_scalar_type(attr.type_name.as_deref()),
278315
));
279316
}
280317
for assoc in &cc.class.associations {
@@ -322,10 +359,11 @@ pub fn emit_csharp(cc: &CompiledClass) -> String {
322359
let mut out = String::new();
323360

324361
out.push_str(&format!(
325-
"/// <summary>Rail class <c>{}</c> — classid 0x{:08X} (concept 0x{:04X}).</summary>\n",
362+
"/// <summary>Rail class <c>{}</c> — classid 0x{:08X} (concept 0x{:04X}, app 0x{:04X}).</summary>\n",
326363
cc.class.name,
327364
cc.facet.facet_classid(),
328-
cc.facet.facet_classid() as u16,
365+
concept_of(cc.facet.facet_classid()),
366+
app_of(cc.facet.facet_classid()),
329367
));
330368
out.push_str(&format!("public sealed record {ty}\n{{\n"));
331369
out.push_str(&format!(
@@ -380,10 +418,11 @@ pub fn emit_python(cc: &CompiledClass) -> String {
380418
out.push_str("@dataclass\n");
381419
out.push_str(&format!("class {ty}:\n"));
382420
out.push_str(&format!(
383-
" \"\"\"Rail class `{}` — classid 0x{:08X} (concept 0x{:04X}).\"\"\"\n",
421+
" \"\"\"Rail class `{}` — classid 0x{:08X} (concept 0x{:04X}, app 0x{:04X}).\"\"\"\n",
384422
cc.class.name,
385423
cc.facet.facet_classid(),
386-
cc.facet.facet_classid() as u16,
424+
concept_of(cc.facet.facet_classid()),
425+
app_of(cc.facet.facet_classid()),
387426
));
388427
out.push_str(&format!(
389428
" CLASSID: ClassVar[int] = 0x{:08X}\n",
@@ -627,6 +666,117 @@ mod tests {
627666
);
628667
}
629668

669+
// ───── D-AR-3.5 schema-stratum projection at emit time (falsifier #1 gap-close) ─────
670+
//
671+
// `Model::fields` (physical schema) is separate from the `attributes` /
672+
// `associations` AR-DSL vectors exercised by `work_package_rail_graph`
673+
// above. This fixture carries both, plus an FK column shadowed by a
674+
// declared association, so the emit-level gap-close (GAP-3 type table +
675+
// GAP-4 nullability wrapping) is proven end-to-end through
676+
// `compile_graph_ruby` -> `emit_rust`, not just at the `lift_model`
677+
// level (see `lib.rs`'s `lift_model_ruby_*` tests for the lift-level
678+
// proof).
679+
680+
fn work_package_schema_graph() -> ModelGraph {
681+
let mut m = Model::new("WorkPackage");
682+
m.associations.push(AssocDecl {
683+
kind: AssocKind::BelongsTo,
684+
name: "project".to_string(),
685+
options: vec![],
686+
});
687+
m.associations.push(AssocDecl {
688+
kind: AssocKind::HasMany,
689+
name: "time_entries".to_string(),
690+
options: vec![],
691+
});
692+
m.fields.push(Field {
693+
name: "id".to_string(),
694+
field_type: Some("bigint".to_string()),
695+
not_null: Some(true),
696+
..Default::default()
697+
});
698+
m.fields.push(Field {
699+
name: "subject".to_string(),
700+
field_type: Some("string".to_string()),
701+
not_null: Some(true),
702+
..Default::default()
703+
});
704+
m.fields.push(Field {
705+
name: "description".to_string(),
706+
field_type: Some("text".to_string()),
707+
not_null: None,
708+
..Default::default()
709+
});
710+
// Shadowed by the `project` association above — must NOT double-emit.
711+
m.fields.push(Field {
712+
name: "project_id".to_string(),
713+
field_type: Some("bigint".to_string()),
714+
not_null: Some(true),
715+
..Default::default()
716+
});
717+
let mut g = ModelGraph::new("openproject");
718+
g.models.push(m);
719+
g
720+
}
721+
722+
/// **(c)** GAP-3 (Rails `bigint`/`string` reach the same `og_scalar_type`
723+
/// table Odoo's `integer`/`char` do) + GAP-4 (`required == Some(false)`
724+
/// wraps in `Option<...>`; `Some(true)` stays bare) + the FK-dedup from
725+
/// GAP-1, all proven together through the real `compile_graph_ruby` ->
726+
/// `emit_rust` path.
727+
#[test]
728+
fn emits_rust_struct_with_typed_and_optional_schema_fields_for_rails() {
729+
use crate::mint::compile_graph_ruby;
730+
use ogar_vocab::ports::OpenProjectPort;
731+
732+
let cc = &compile_graph_ruby::<OpenProjectPort>(&work_package_schema_graph())[0];
733+
let rust = emit_rust(cc);
734+
735+
// bigint -> OgInt (GAP-3); not_null Some(true) -> required Some(true)
736+
// -> bare type (GAP-4).
737+
assert!(rust.contains("pub id: OgInt,"), "got:\n{rust}");
738+
// string -> OgStr (GAP-3); not_null Some(true) -> bare type (GAP-4).
739+
assert!(rust.contains("pub subject: OgStr,"), "got:\n{rust}");
740+
// text -> OgStr; not_null None -> required Some(false) -> Option<...> (GAP-4).
741+
assert!(
742+
rust.contains("pub description: Option<OgStr>,"),
743+
"got:\n{rust}"
744+
);
745+
// project_id is FK-deduped: the `project` association already
746+
// carries the relation, so the scalar column must not double-emit.
747+
assert!(
748+
!rust.contains("project_id:"),
749+
"project_id must be shadowed by the project association:\n{rust}"
750+
);
751+
assert!(
752+
rust.contains("pub project: ToOne<Project>,"),
753+
"got:\n{rust}"
754+
);
755+
assert!(
756+
rust.contains("pub time_entries: ToMany<TimeEntries>,"),
757+
"got:\n{rust}"
758+
);
759+
}
760+
761+
/// **(d)** cosmetic gap-close: the emitted doc line must print the TRUE
762+
/// concept half (`0x0102`, the CANON high half since the 2026-07-02
763+
/// canon-high flip) and the app half (`0x0001`, OpenProject's render
764+
/// prefix) separately — not `facet_classid() as u16` (which reads the
765+
/// LOW/app half and mislabels it "concept").
766+
#[test]
767+
fn emit_rust_doc_line_prints_concept_high_and_app_low() {
768+
use crate::mint::compile_graph_ruby;
769+
use ogar_vocab::ports::OpenProjectPort;
770+
771+
let cc = &compile_graph_ruby::<OpenProjectPort>(&work_package_schema_graph())[0];
772+
let rust = emit_rust(cc);
773+
774+
assert!(
775+
rust.contains("classid `0x01020001` (concept `0x0102`, app `0x0001`)"),
776+
"got:\n{rust}"
777+
);
778+
}
779+
630780
#[test]
631781
fn emits_csharp_record_with_wrapper_contract_types() {
632782
let cc = &compile_graph_python::<OdooPort>(&account_move_graph())[0];

0 commit comments

Comments
 (0)