diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 7ffe317..1bde71b 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -7,6 +7,24 @@ --- +## 2026-06-30 — E-KEEP-AR-REMOVE-ORM — the consumer open-heart op KEEPS ActiveRecord and removes the ORM; OGAR is named after AR + +**Status:** FRAMING (`[G]` for the name origin + the keep/remove split, operator-stated 2026-06-30; the convergence wiring it implies is `[H]`, gated per the OP assessment). Corrects a session inversion (mine) that read the consumer pivot as "castrate the hand-rolled Rails ActiveRecord betrayal" — **backwards**. + +**The correction (operator):** *"We don't remove Active Record — that's exactly what we keep. We just do an open-heart operation to remove the ORM and wire pure AR on Rails. Hence OGAR (Open Graph Active Record) — the name was literally inspired by AR in OpenProject."* + +- **KEEP — the ActiveRecord pattern.** A class IS a record + behavior (associations / validations / callbacks / STI). This is the domain model and it is literally what OGAR's `Class`/`ClassView` represents. **OGAR = Open Graph Active Record**, named after AR in OpenProject. The ClassView IS the active record. +- **REMOVE — the ORM.** The hand-rolled persistence plumbing between AR and the DB. In `openproject-nexgen-rs` that is `op-db`'s hand-typed SQL repos + `FromRow` rows (9009 LOC) and `op-api`'s hand-mapped row→DTO (8592 LOC). An ORM re-implemented by hand IS the "betrayal" — **never AR itself.** +- **WIRE — "pure AR on Rails":** the AR domain model backed **directly by the OGAR graph** (classid-keyed node + ClassView = the record; persistence = the graph via OGAR emit), no ORM intermediary. The "open-heart operation": excise the ORM organ, keep the AR heart, re-plumb AR onto OGAR. + +**Redmine-as-root.** OpenProject is a Redmine→ChiliProject→OpenProject fork; Redmine's cleaner ancestral AR (ERB **fieldview**) defines the canonical ClassView, OP (which accreted the hand-rolled ORM on top) converges onto it. **fieldview/erb → classview/askama** = the AR view layer becomes the OGAR ClassView rendered via askama — the render *skin* over the AR *substance* ("ice caking"). The hi/lo classid split already encodes it: lo-u16 = shared concept (Redmine≡OP, machine-checked 26/26), hi-u16 = per-app render skin. + +**Guard (do not re-invert):** the operation is ORM-out / AR-in. Removing or re-deriving the ActiveRecord domain model is the inversion this entry exists to prevent. AR stays throughout; only the ORM and (later, gated) the SPO-corpus/native emit paths are subtractive — additive-then-subtractive, never the reverse. + +**Cross-ref:** the 6-agent assessment (RESONATES/QUALIFIED) + the concrete additive increment (OGAR `compile_graph_ruby` ~15 LOC → OP `ogar-emit` Stage-B alongside the ORM → Redmine↔OP convergence pin) is captured in `openproject-nexgen-rs/.claude/handovers/2026-06-30-1200-op-redmine-ogar-convergence-assessment.md`. Doctrine home: `docs/OGAR-CONSUMER-BEST-PRACTICES.md` (classid-is-address / magic-at-resolution); `docs/OGAR-TRANSPILE-SUBSTRATE.md` (the 85/15 pull-in/pull-back). The OGAR-side keystone gap: a Rails `compile_graph_ruby` (today only `compile_graph_python` exists in `crates/ogar-from-ruff/src/mint.rs`). + +--- + ## 2026-06-30 — E-ACCIDENTAL-IMPERATIVE — the hand-rolled residue = accidentally-imperative (AR verbs on AR targets, no declarative home) ∪ essentially-foreign; the body pass TRIAGES, it does not decompile **Status:** CONJECTURE (`[H]` — the split is operator-reasoned + grounded in the Odoo↔Rails asymmetry; the ratio is unmeasured pending the body pass). Operator-directed 2026-06-30. Builds on E-FUNCTION-CATALOG. **Update 2026-06-30:** the F17 prerequisite SHIPPED — ruff now captures per-function `writes`/`calls` (AdaWorldAPI/ruff @ `claude/odoo-rs-transcode-lf8ya5`, commit `dd70588`): `ruff_spo_triplet::Function` gains `writes`+`calls`, the closed predicate vocab gains `writes_field` (Authoritative) + `calls` (Inferred), and the `ruff_ruby_spo` body walker populates both from the Rails AST (closed `AR_MUTATORS` set; `self.x=`→write, mutator dispatch→call, else read). The "NOT writes" blocker in the Falsifier below is **cleared**; F17 is RUNNABLE — the body-triage probe is the next deliverable. Ratio still unmeasured. diff --git a/crates/ogar-from-ruff/src/emit.rs b/crates/ogar-from-ruff/src/emit.rs index 6a2468b..d573066 100644 --- a/crates/ogar-from-ruff/src/emit.rs +++ b/crates/ogar-from-ruff/src/emit.rs @@ -323,13 +323,33 @@ fn pascal_case(name: &str) -> String { .collect() } -/// `account_move` → `ACCOUNT_MOVE` (for the `*_CLASSID` const name). +/// `account_move` → `ACCOUNT_MOVE`; `WorkPackage` → `WORK_PACKAGE` (for the +/// `*_CLASSID` const name). Splits on `.`/`_` (Odoo's dotted/underscored +/// names) AND on a lower→upper case transition (Rails' bare PascalCase class +/// names carry no separator at all — `screaming_snake` must still find the +/// word boundary, or every Rails-sourced const collapses to one run-on word +/// like `WORKPACKAGE_CLASSID`). Does not split consecutive uppercase runs +/// (acronyms): `HTTPServer` → `HTTPSERVER` — no Rails/Odoo class name in the +/// corpus is acronym-prefixed, so this is a deliberately narrow rule, not a +/// general camelCase tokenizer. fn screaming_snake(name: &str) -> String { - name.split(['.', '_']) - .filter(|seg| !seg.is_empty()) - .map(str::to_uppercase) - .collect::>() - .join("_") + let mut out = String::new(); + let mut prev_lower = false; + for ch in name.chars() { + if ch == '.' || ch == '_' { + if !out.is_empty() && !out.ends_with('_') { + out.push('_'); + } + prev_lower = false; + continue; + } + if ch.is_uppercase() && prev_lower { + out.push('_'); + } + out.extend(ch.to_uppercase()); + prev_lower = ch.is_lowercase(); + } + out } #[cfg(test)] @@ -337,7 +357,9 @@ mod tests { use super::*; use crate::mint::compile_graph_python; use ogar_vocab::ports::OdooPort; - use ruff_spo_triplet::{Field, Function, Model, ModelGraph}; + use ruff_spo_triplet::{ + AssocDecl, AssocKind, AttrDecl, AttrKind, Field, Function, Model, ModelGraph, + }; fn account_move_graph() -> ModelGraph { let mut m = Model::new("account_move"); @@ -383,6 +405,7 @@ mod tests { reads: Vec::new(), raises: Vec::new(), traverses: Vec::new(), + ..Default::default() }); let mut g = ModelGraph::new("odoo"); g.models.push(m); @@ -419,6 +442,79 @@ mod tests { assert!(rust.contains("// computed: amount_total <- _compute_amount(line_ids.balance)")); } + // ───── Rails (compile_graph_ruby) — the convergence proof ───── + // + // The pull-back codegen leg (emit_rust/csharp/python) was, before this + // session, only ever exercised on an Odoo-lifted `CompiledClass`. This + // fixture proves the SAME emitters run unmodified on a Rails-lifted one + // (compile_graph_ruby, ruff#38 + this crate's `mint::compile_graph_ruby`), + // closing the "unproven on Rails" gap named in the OP+Redmine convergence + // handover (openproject-nexgen-rs .claude/handovers/ + // 2026-06-30-1200-op-redmine-ogar-convergence-assessment.md §4 step 2). + + fn work_package_rail_graph() -> ModelGraph { + let mut m = Model::new("WorkPackage"); + m.attributes.push(AttrDecl { + kind: AttrKind::Attribute, + name: "estimated_hours".to_string(), + options: vec![("type".to_string(), "integer".to_string())], + }); + // No "type" option → the OgScalar fallback path, same as Odoo's + // `narration` case. + m.attributes.push(AttrDecl { + kind: AttrKind::Attribute, + name: "subject".to_string(), + options: vec![], + }); + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "project".to_string(), + options: vec![("class_name".to_string(), "\"Project\"".to_string())], + }); + m.associations.push(AssocDecl { + kind: AssocKind::HasMany, + name: "time_entries".to_string(), + options: vec![], + }); + let mut g = ModelGraph::new("openproject"); + g.models.push(m); + g + } + + #[test] + fn emits_rust_struct_for_rails_lifted_class() { + use crate::mint::compile_graph_ruby; + use ogar_vocab::ports::OpenProjectPort; + + let cc = &compile_graph_ruby::(&work_package_rail_graph())[0]; + let rust = emit_rust(cc); + + assert!( + rust.contains("pub const WORK_PACKAGE_CLASSID: u32 = 0x00010102;"), + "got:\n{rust}" + ); // exercises the screaming_snake PascalCase fix below + assert!(rust.contains("pub struct WorkPackage {")); + // Rails `attribute :estimated_hours, :integer` -> OgInt (the same + // og_scalar_type table Odoo's `integer` constructor maps through — + // shared vocabulary across producers, per §1.6). + assert!(rust.contains("pub estimated_hours: OgInt,"), "got:\n{rust}"); + // Untyped attribute -> OgScalar fallback. + assert!(rust.contains("pub subject: OgScalar,"), "got:\n{rust}"); + // belongs_to with class_name override -> ToOne, not + // ToOne from the (singular) relation name by coincidence — + // assert the class_name path specifically by using a relation name + // that would pascal_case differently if class_name were ignored. + assert!( + rust.contains("pub project: ToOne,"), + "got:\n{rust}" + ); + // has_many, no class_name -> pascal_case(time_entries) = TimeEntries. + assert!( + rust.contains("pub time_entries: ToMany,"), + "got:\n{rust}" + ); + } + #[test] fn emits_csharp_record_with_wrapper_contract_types() { let cc = &compile_graph_python::(&account_move_graph())[0]; @@ -535,6 +631,19 @@ mod tests { assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE"); } + #[test] + fn screaming_snake_splits_bare_pascal_case_rails_names() { + // Rails class names carry no separator at all (no dots, no + // underscores) — screaming_snake must find the word boundary from + // case alone, or every Rails const collapses to one run-on word. + assert_eq!(screaming_snake("WorkPackage"), "WORK_PACKAGE"); + assert_eq!(screaming_snake("TimeEntry"), "TIME_ENTRY"); + // Already-snake input is unaffected (the original behaviour). + assert_eq!(screaming_snake("account.move.line"), "ACCOUNT_MOVE_LINE"); + // A single PascalCase word with no internal boundary stays whole. + assert_eq!(screaming_snake("Project"), "PROJECT"); + } + #[test] fn og_scalar_type_maps_odoo_constructors() { assert_eq!(og_scalar_type(Some("char")), "OgStr"); diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 1378f61..ae69f6e 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -1334,12 +1334,14 @@ mod tests { reads: vec!["status".to_string()], raises: Vec::new(), traverses: Vec::new(), + ..Default::default() }); m.functions.push(Function { name: "close!".to_string(), reads: Vec::new(), raises: vec!["ArgumentError".to_string()], traverses: Vec::new(), + ..Default::default() }); m } diff --git a/crates/ogar-from-ruff/src/mint.rs b/crates/ogar-from-ruff/src/mint.rs index d007600..8008255 100644 --- a/crates/ogar-from-ruff/src/mint.rs +++ b/crates/ogar-from-ruff/src/mint.rs @@ -34,7 +34,7 @@ use ogar_vocab::Class; use ruff_spo_address::{mint_with_classid, Facet, Mint}; use ruff_spo_triplet::{expand, ModelGraph}; -use crate::lift_model_graph_python; +use crate::{lift_model_graph, lift_model_graph_python}; /// A class compiled to its rail-shaped, language-agnostic form: the lifted /// schema ([`Class`]) plus its 16-byte address ([`Facet`]). This is what a @@ -83,6 +83,32 @@ pub fn compile_graph_python(graph: &ModelGraph) -> Vec(graph: &ModelGraph) -> Vec { + let mint = mint_graph::

(graph); + lift_model_graph(graph) + .into_iter() + .map(|class| { + let node = format!("{}:{}", graph.namespace, class.name); + let facet = mint + .facet(&node) + .unwrap_or_else(|| Facet::from_parts(classid_for_node::

(&node), [0; 6], [0; 6])); + CompiledClass { class, facet } + }) + .collect() +} + /// Resolve a node IRI's full render classid via port `P`. /// /// The IRI is `:` or `:.`; members inherit @@ -110,8 +136,9 @@ fn model_of(node: &str) -> &str { #[cfg(test)] mod tests { use super::*; - use ogar_vocab::ports::{OdooPort, OpenProjectPort}; - use ruff_spo_triplet::{Field, Function, Model}; + use ogar_vocab::Language; + use ogar_vocab::ports::{OdooPort, OpenProjectPort, RedminePort}; + use ruff_spo_triplet::{AssocDecl, AssocKind, Field, Function, Model}; // A representative `account.move` `ModelGraph`, constructed directly (the // source→ModelGraph parse is `ruff_python_spo`'s job, tested there). Carries @@ -146,6 +173,7 @@ mod tests { reads: Vec::new(), raises: Vec::new(), traverses: Vec::new(), + ..Default::default() }); let mut g = ModelGraph::new("odoo"); g.models.push(m); @@ -223,4 +251,87 @@ mod tests { let facet = mint.facet("odoo:ir_cron").expect("node mints"); assert_eq!(facet.facet_classid(), 0, "unmapped -> bootstrap address"); } + + // ───── compile_graph_ruby (Rails: the OP/Redmine convergence path) ───── + + /// A representative `WorkPackage` `ModelGraph`, constructed directly (the + /// source→ModelGraph parse is `ruff_ruby_spo`'s job, tested there). + /// Carries an association so the schema-arm projects something besides + /// a bare name. + fn work_package_graph(namespace: &str, model_name: &str) -> ModelGraph { + let mut m = Model::new(model_name); + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "project".to_string(), + options: Vec::new(), + }); + m.functions.push(Function { + name: "update_status".to_string(), + ..Default::default() + }); + let mut g = ModelGraph::new(namespace); + g.models.push(m); + g + } + + #[test] + fn compile_graph_ruby_stamps_language_ruby_not_python() { + // The bug compile_graph_python's own doc-comment warns against: + // calling the Python lift on a Rails graph mis-stamps the producer + // language. compile_graph_ruby must route through `lift_model_graph` + // (Language::Ruby), never `lift_model_graph_python`. + let graph = work_package_graph("openproject", "WorkPackage"); + let compiled = compile_graph_ruby::(&graph); + assert_eq!(compiled.len(), 1); + assert_eq!(compiled[0].class.language, Language::Ruby); + } + + #[test] + fn openproject_work_package_compiles_to_project_work_item_rail_class() { + let graph = work_package_graph("openproject", "WorkPackage"); + let compiled = compile_graph_ruby::(&graph); + assert_eq!(compiled.len(), 1); + let cc = &compiled[0]; + + assert_eq!(cc.class.name, "WorkPackage"); + assert!( + !cc.class.associations.is_empty(), + "belongs_to :project projects into an association" + ); + + // OpenProject prefix 0x0001 | project_work_item concept 0x0102. + assert_eq!(cc.facet.facet_classid(), 0x0001_0102); + assert_eq!(cc.facet.facet_classid() & 0xFFFF, 0x0102, "shared concept"); + assert_eq!( + (cc.facet.facet_classid() >> 16) as u16, + OpenProjectPort::APP_PREFIX, + "OpenProject render prefix", + ); + } + + #[test] + fn openproject_and_redmine_compile_to_the_same_concept_different_render_skin() { + // The literal "one canonical concept, two render skins" proof: the + // SAME Rails-shaped ModelGraph (WorkPackage / Issue) minted through + // each fork's port converges on the identical low-u16 concept and + // diverges only on the high-u16 app-render prefix. + let op_graph = work_package_graph("openproject", "WorkPackage"); + let rm_graph = work_package_graph("redmine", "Issue"); + + let op_compiled = compile_graph_ruby::(&op_graph); + let rm_compiled = compile_graph_ruby::(&rm_graph); + + let op_id = op_compiled[0].facet.facet_classid(); + let rm_id = rm_compiled[0].facet.facet_classid(); + + assert_eq!(op_id & 0xFFFF, rm_id & 0xFFFF, "shared concept converges"); + assert_eq!(op_id & 0xFFFF, 0x0102, "project_work_item"); + assert_ne!( + (op_id >> 16) as u16, + (rm_id >> 16) as u16, + "render prefixes diverge (OpenProject vs Redmine skin)" + ); + assert_eq!((op_id >> 16) as u16, OpenProjectPort::APP_PREFIX); + assert_eq!((rm_id >> 16) as u16, RedminePort::APP_PREFIX); + } } diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 110067e..cd74e0f 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -38,11 +38,12 @@ use serde::{Deserialize, Serialize}; /// `pub enum` / `pub struct` in this module: the OGAR vocabulary is /// expected to evolve over time, and every base type is forward- /// compatible-by-construction. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum Language { /// Ruby ActiveRecord (`class Foo < ApplicationRecord`). + #[default] Ruby, /// Python — covers Django ORM and Odoo `models.Model`. Python, @@ -305,7 +306,7 @@ pub struct MethodDecl { /// Method kind — distinguishes overrides from helpers from plain /// methods. The producer determines kind from decorator + name /// inspection; see `docs/ODOO-TRANSCODING.md` §13. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum MethodKind { @@ -318,37 +319,27 @@ pub enum MethodKind { /// Odoo's bulk-create override. ApiModelCreateMulti, /// Plain instance method, no special semantics. + #[default] Instance, } -impl Default for MethodKind { - fn default() -> Self { - Self::Instance - } -} - /// Recordset semantics — Odoo methods can bind to a record (single), /// a recordset (the default for most methods), or be class-level /// (`@api.model`). Captured for cross-language consumers that /// project to per-record vs per-collection APIs. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum RecordSemantics { /// Single-record context. Record, /// Recordset (Odoo default for most methods). + #[default] Recordset, /// Class-level (`@api.model` or no `self`). ClassLevel, } -impl Default for RecordSemantics { - fn default() -> Self { - Self::Recordset - } -} - // ───────────────────────────────────────────────────────────────────── // Sprint 3 — Action vocabulary with SPO + TeKaMoLo grammar // (per docs/ADAPTERS-AND-ACTORS.md + brutal-review cycle 3 fixes) @@ -368,7 +359,6 @@ impl Default for RecordSemantics { #[derive(Debug, Clone, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] - pub struct ActionDef { /// Stable identity for the action declaration (e.g. /// `ogit-erp/sale.order::action_def::action_confirm`). @@ -446,7 +436,7 @@ impl EnterEffect { /// Disposition when a `KausalSpec::StateGuard` is not satisfied — the Modal /// sub-property for the Rubicon statem lowering (OGAR-AST-CONTRACT §6). /// `#[non_exhaustive]` per the vocabulary forward-compat convention. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum GuardFailurePolicy { @@ -454,15 +444,10 @@ pub enum GuardFailurePolicy { /// transition. Lowers to `Transition::Postpone`. Postponable, /// Hard failure — `Pending → Failed` (the default). + #[default] Reject, } -impl Default for GuardFailurePolicy { - fn default() -> Self { - Self::Reject - } -} - /// A runtime invocation of an `ActionDef` — one per (S, P, O, context) /// tuple. Captures the actual subject (which user / cron / cascade /// fired this), provenance for tracing, and lifecycle state. @@ -509,13 +494,14 @@ pub struct ActionInvocation { } /// Subject of a business action — who/what initiated it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum ActionSubject { /// A human user (UI button click, RPC call from authenticated user). User, /// Internal system trigger (no specific user). + #[default] System, /// Scheduled (`ir.cron`, Rails `Whenever`). Cron, @@ -525,19 +511,14 @@ pub enum ActionSubject { Cascade, } -impl Default for ActionSubject { - fn default() -> Self { - Self::System - } -} - /// Temporal context — when does the action happen relative to its /// trigger. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum TemporalSpec { /// Synchronous, on-call. + #[default] Immediate, /// Queued, async background. Deferred, @@ -548,20 +529,15 @@ pub enum TemporalSpec { OnCommit, } -impl Default for TemporalSpec { - fn default() -> Self { - Self::Immediate - } -} - /// Modal context — how is the action performed. /// Per B3 YAGNI: dropped `Requires` (no v1 consumer); kept Idempotent /// because it gates the dedup mechanism in `ActionInvocation`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum ModalSpec { /// Synchronous, blocking. + #[default] Sync, /// Fire-and-forget. Async, @@ -571,12 +547,6 @@ pub enum ModalSpec { Atomic, } -impl Default for ModalSpec { - fn default() -> Self { - Self::Sync - } -} - /// Causal precondition — what triggered this action. **Sum type** per /// B1 review fix. Producers populate one variant; the runtime guard /// evaluator dispatches on the variant. @@ -630,11 +600,12 @@ pub struct LokalSpec { /// Lifecycle state of an `ActionInvocation`. /// Per B2 production-blocker #3: explicit state machine prevents /// the silent-gap problem (action started, didn't complete, no record). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum ActionState { /// Emitted but not yet processed by the callcenter. + #[default] Pending, /// Successfully processed; effects committed. Committed, @@ -645,12 +616,6 @@ pub enum ActionState { Cancelled, } -impl Default for ActionState { - fn default() -> Self { - Self::Pending - } -} - // ───────────────────────────────────────────────────────────────────── // Sprint 3 constructors (per #[non_exhaustive] convention) // ───────────────────────────────────────────────────────────────────── @@ -719,11 +684,12 @@ impl KausalSpec { /// Rails `belongs_to`/`has_one`/`has_many`/`has_and_belongs_to_many`, /// Odoo `Many2one`/`One2many`/`Many2many` (Odoo collapses `has_one` into /// `One2many` constrained to 1). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub enum AssociationKind { /// Owning side of a 1:N — the FK lives on this class's table. + #[default] BelongsTo, /// Non-owning side of a 1:1. HasOne, @@ -801,12 +767,6 @@ pub struct Association { pub delegate: Option, } -impl Default for AssociationKind { - fn default() -> Self { - Self::BelongsTo - } -} - /// An enum-backed column declaration. /// /// The `source` field captures three Odoo cases (static / computed / @@ -1543,8 +1503,8 @@ pub mod class_ids { /// Promoted Phase-3 from the cross-axis identity gap surfaced in odoo-rs /// PR #14: the alignment table seeds `product.template → schema:Product` /// + BillingCore (0x61); this id is the OGAR-side identity that closes - /// the same axis. `OdooPort` carries `product.template` and - /// `product.product` as aliases of `PRODUCT`. + /// the same axis. `OdooPort` carries `product.template` and + /// `product.product` as aliases of `PRODUCT`. pub const PRODUCT: u16 = 0x0207; /// `accounting_account` (`0x0208`) — general-ledger account (SKR-aligned /// chart concept). OSB `Account`, Odoo `account.account` + @@ -5682,9 +5642,3 @@ mod tests { } } } - -impl Default for Language { - fn default() -> Self { - Self::Ruby - } -}