diff --git a/crates/ogar-adapter-surrealql/src/lib.rs b/crates/ogar-adapter-surrealql/src/lib.rs index 39df23bf..e917f1fc 100644 --- a/crates/ogar-adapter-surrealql/src/lib.rs +++ b/crates/ogar-adapter-surrealql/src/lib.rs @@ -712,7 +712,31 @@ fn emit_field_enum(table: &str, enum_decl: &EnumDecl, out: &mut String) { "DEFINE FIELD {column_ident} ON {table} TYPE string /* computed: {escaped} */;\n" )); } - // `EnumSource` is `#[non_exhaustive]` in `ogar-vocab`; the three arms + ogar_vocab::EnumSource::Unlabeled(keys) => { + // Keys are canonical; the source declares no label. + let variants = keys + .iter() + .map(|key| surreal_string_literal(key)) + .collect::>() + .join(", "); + out.push_str(&format!( + "DEFINE FIELD {column_ident} ON {table} TYPE string ASSERT $value IN [{variants}];\n" + )); + } + ogar_vocab::EnumSource::Unresolved(why) => { + // Enum declared, variants not recovered: never assert a value set. + let escaped = why.replace("*/", "* /"); + out.push_str(&format!( + "DEFINE FIELD {column_ident} ON {table} TYPE string /* unresolved enum: {escaped} */;\n" + )); + } + ogar_vocab::EnumSource::Invalid(why) => { + let escaped = why.replace("*/", "* /"); + out.push_str(&format!( + "DEFINE FIELD {column_ident} ON {table} TYPE string /* invalid enum: {escaped} */;\n" + )); + } + // `EnumSource` is `#[non_exhaustive]` in `ogar-vocab`; the arms // above cover every variant defined today. The wildcard exists only // so adding a variant in `ogar-vocab` produces a clean panic-on-first // -emit instead of a silent miscompile elsewhere. @@ -941,6 +965,42 @@ mod tests { ); } + #[test] + fn emit_class_with_unlabeled_enum_asserts_the_keys() { + let mut c = Class::new("order"); + let mut e = EnumDecl::new("status"); + e.source = ogar_vocab::EnumSource::Unlabeled(vec!["shipped".into(), "cancelled".into()]); + c.enums.push(e); + let ddl = emit_surrealql_ddl(&[c]); + assert!( + ddl.contains("ASSERT $value IN ['shipped', 'cancelled']"), + "got: {ddl}" + ); + } + + #[test] + fn emit_class_with_unresolved_or_invalid_enum_never_asserts_values() { + let mut c = Class::new("order"); + let mut u = EnumDecl::new("status"); + u.source = ogar_vocab::EnumSource::Unresolved("not recovered */ x".into()); + let mut i = EnumDecl::new("kind"); + i.source = ogar_vocab::EnumSource::Invalid("duplicate enum key `a`".into()); + c.enums.push(u); + c.enums.push(i); + let ddl = emit_surrealql_ddl(&[c]); + assert!( + ddl.contains("DEFINE FIELD status ON order TYPE string /* unresolved enum: not recovered * / x */;"), + "got: {ddl}" + ); + assert!( + ddl.contains( + "DEFINE FIELD kind ON order TYPE string /* invalid enum: duplicate enum key `a` */;" + ), + "got: {ddl}" + ); + assert!(!ddl.contains("ASSERT $value IN ["), "got: {ddl}"); + } + #[test] fn emit_multiple_classes_in_order() { let cs = vec![Class::new("a"), Class::new("b"), Class::new("c")]; diff --git a/crates/ogar-emitter/src/lib.rs b/crates/ogar-emitter/src/lib.rs index 8c44c99a..f69c11e9 100644 --- a/crates/ogar-emitter/src/lib.rs +++ b/crates/ogar-emitter/src/lib.rs @@ -520,7 +520,7 @@ impl TripleEmitter { Triple::new(&enum_id, "rdf:type", "ogar:EnumDecl"), Triple::new(&enum_id, "ogar:column", enum_decl.column.clone()), ]; - // EnumSource has three variants — emit different triples for each + // EnumSource has several variants — emit different triples for each // to preserve Odoo's `selection=lambda` and `selection_add=` cases // beyond the simple static list. match &enum_decl.source { @@ -560,6 +560,35 @@ impl TripleEmitter { triples.push(Triple::new(&variant_id, "ogar:variantValue", value.clone())); } } + ogar_vocab::EnumSource::Unlabeled(keys) => { + triples.push(Triple::new( + &enum_id, + "ogar:enumSourceKind", + "ogar:Unlabeled", + )); + for (i, name) in keys.iter().enumerate() { + let variant_id = format!("{enum_id}#{i}"); + triples.push(Triple::new(&enum_id, "ogar:hasVariant", variant_id.clone())); + triples.push(Triple::new(&variant_id, "rdf:type", "ogar:EnumVariant")); + triples.push(Triple::new(&variant_id, "ogar:variantName", name.clone())); + } + } + ogar_vocab::EnumSource::Unresolved(why) => { + triples.push(Triple::new( + &enum_id, + "ogar:enumSourceKind", + "ogar:Unresolved", + )); + triples.push(Triple::new( + &enum_id, + "ogar:enumUnresolvedReason", + why.clone(), + )); + } + ogar_vocab::EnumSource::Invalid(why) => { + triples.push(Triple::new(&enum_id, "ogar:enumSourceKind", "ogar:Invalid")); + triples.push(Triple::new(&enum_id, "ogar:enumInvalidReason", why.clone())); + } _ => { // Future non_exhaustive EnumSource variants — emit a // marker triple but don't panic. Real producers must @@ -1174,6 +1203,68 @@ mod tests { })); } + fn enum_triples(source: ogar_vocab::EnumSource) -> Vec { + let mut class = sample_work_package(); + let mut e = EnumDecl::new("status"); + e.source = source; + class.enums.push(e); + TripleEmitter::emit_class(&class, "ogit-op") + } + + #[test] + fn unlabeled_enum_emits_names_and_no_variant_value() { + let triples = enum_triples(ogar_vocab::EnumSource::Unlabeled(vec![ + "open".into(), + "closed".into(), + ])); + assert!( + triples + .iter() + .any(|t| t.predicate == "ogar:enumSourceKind" && t.object == "ogar:Unlabeled") + ); + for name in ["open", "closed"] { + assert!( + triples + .iter() + .any(|t| t.predicate == "ogar:variantName" && t.object == name) + ); + } + assert_eq!( + triples + .iter() + .filter(|t| t.predicate == "ogar:variantValue") + .count(), + 0 + ); + } + + #[test] + fn unresolved_and_invalid_enums_emit_reason_and_no_variants() { + let unresolved = enum_triples(ogar_vocab::EnumSource::Unresolved("not recovered".into())); + assert!( + unresolved + .iter() + .any(|t| { t.predicate == "ogar:enumSourceKind" && t.object == "ogar:Unresolved" }) + ); + assert!(unresolved.iter().any(|t| { + t.predicate == "ogar:enumUnresolvedReason" && t.object == "not recovered" + })); + assert!(!unresolved.iter().any(|t| t.predicate == "ogar:hasVariant")); + + let invalid = enum_triples(ogar_vocab::EnumSource::Invalid( + "duplicate enum key `a`".into(), + )); + assert!( + invalid + .iter() + .any(|t| t.predicate == "ogar:enumSourceKind" && t.object == "ogar:Invalid") + ); + assert!(invalid.iter().any(|t| { + t.predicate == "ogar:enumInvalidReason" && t.object == "duplicate enum key `a`" + })); + assert!(!invalid.iter().any(|t| t.predicate == "ogar:hasVariant")); + } + #[test] fn duplicate_callbacks_get_indexed_subjects() { // Rails allows two `after_create do ... end` blocks on the same diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index ef25ae23..26c5c97e 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -780,48 +780,38 @@ fn lift_attribute(a: &AttrDecl) -> Option { } /// Lift a Rails `enum :status, { open: 0, closed: 1 }` declaration -/// to an OGAR [`EnumDecl`]. Returns `None` for non-Enum [`AttrDecl`] -/// kinds, and for an enum that declares the same key twice (refused, -/// like every other `lift_*` that cannot represent its input -/// faithfully; the column is not claimed to be enum-backed with a -/// guessed variant list). +/// to an OGAR [`EnumDecl`]. Returns `None` only for non-Enum +/// [`AttrDecl`] kinds; an enum declaration is never dropped. /// -/// The ruby walker keeps the variant hash of the `enum :col, { k: n }` -/// form in `AttrDecl::options`, in source order. This lift carries the -/// **keys**, in that order, into [`EnumSource::Static`]. +/// The ruby walker keeps the variant hash in `AttrDecl::options`, in +/// source order. The mapping is: /// -/// - **Label slot.** [`EnumSource::Static`] holds `(key, label)` pairs -/// and Rails source declares no label (labels live in i18n files -/// outside the model). No "absent label" representation exists in -/// the vocab, so the label is the key, the same self-label -/// `ogar-from-schema` uses. The Rails integer is deliberately **not** -/// put there: the emitter publishes the label slot as `variantValue`, -/// and the integer is a source-local physical id, not a canonical -/// value. -/// - **Empty options.** The walker also yields empty options for forms -/// whose variants it cannot see: the array form -/// (`enum :status, [:a, :b]`), a constant or other dynamic argument, -/// and a declaration with no variant argument at all. These are -/// indistinguishable from each other here, and no [`EnumSource`] -/// variant can say "variants not recoverable" (`Computed` needs a -/// verbatim body the walker does not keep). The lift therefore -/// returns `Static` with no variants, which means "the variant list -/// was not recovered", not "the enum has no values". The kwarg form -/// `enum status: { .. }` produces no `AttrDecl` at all and never -/// reaches this function. +/// - non-empty options, unique keys: [`EnumSource::Unlabeled`] with the +/// keys in order. Rails declares no label, so none is synthesized, and +/// the source integer (a physical id) is not carried. +/// - a duplicate key: [`EnumSource::Invalid`] naming the first +/// duplicated key, so the declaration stays visible and refusable. +/// - empty options: [`EnumSource::Unresolved`]. The walker also yields +/// empty options for the array form, a constant argument, and a +/// missing argument, and cannot tell them apart; no values are claimed. fn lift_enum(a: &AttrDecl) -> Option { if !matches!(a.kind, AttrKind::Enum) { return None; } - let mut variants: Vec<(String, String)> = Vec::with_capacity(a.options.len()); + let mut out = EnumDecl::new(&a.name); + if a.options.is_empty() { + out.source = EnumSource::Unresolved("variant list not recovered by the Ruby walker".into()); + return Some(out); + } + let mut keys: Vec = Vec::with_capacity(a.options.len()); for (key, _source_integer) in &a.options { - if variants.iter().any(|(seen, _)| seen == key) { - return None; + if keys.iter().any(|seen| seen == key) { + out.source = EnumSource::Invalid(format!("duplicate enum key `{key}`")); + return Some(out); } - variants.push((key.clone(), key.clone())); + keys.push(key.clone()); } - let mut out = EnumDecl::new(&a.name); - out.source = EnumSource::Static(variants); + out.source = EnumSource::Unlabeled(keys); Some(out) } @@ -1605,9 +1595,9 @@ mod tests { assert_eq!(class.enums.len(), 1); assert_eq!(class.enums[0].column, "status"); // The fixture has no options (the walker's output for an enum whose - // variants it could not see), so the variant list stays empty. The + // variants it could not see), so the variant list is Unresolved. The // variant-bearing case is pinned by the `lift_enum_*` tests below. - assert!(matches!(class.enums[0].source, EnumSource::Static(ref v) if v.is_empty())); + assert!(matches!(class.enums[0].source, EnumSource::Unresolved(_))); // Attribute lifts to attributes slot only — enum does NOT // double-emit. assert_eq!(class.attributes.len(), 1); @@ -1628,13 +1618,13 @@ mod tests { fn lifted_keys(a: &AttrDecl) -> Vec { match lift_enum(a).expect("enum lifts").source { - EnumSource::Static(v) => v.into_iter().map(|(k, _)| k).collect(), - other => panic!("expected Static, got {other:?}"), + EnumSource::Unlabeled(v) => v, + other => panic!("expected Unlabeled, got {other:?}"), } } /// `enum :status, { shipped: 3, cancelled: 7 }` keeps both keys in - /// declared order (was `Static(empty)`: the lift never read options). + /// declared order . #[test] fn lift_enum_keeps_variant_keys_in_declared_order() { let a = enum_decl("status", &[("shipped", "3"), ("cancelled", "7")]); @@ -1654,43 +1644,69 @@ mod tests { assert_eq!(ka, kb); } - /// Label convention: no label is declared in Rails source, so the label - /// is the key (as in `ogar-from-schema`). The Rails integer must never - /// appear in the label slot: the emitter publishes it as `variantValue`. + /// Unlabeled carries only the keys; the Rails integers appear nowhere + /// in the lifted declaration. #[test] - fn lift_enum_label_is_the_key_never_the_rails_integer() { + fn lift_enum_unlabeled_carries_keys_and_no_rails_integer() { let a = enum_decl("status", &[("shipped", "3"), ("cancelled", "7")]); - match lift_enum(&a).unwrap().source { - EnumSource::Static(v) => assert_eq!( - v, - [ - ("shipped".to_string(), "shipped".to_string()), - ("cancelled".to_string(), "cancelled".to_string()), - ] - ), - other => panic!("expected Static, got {other:?}"), - } + let decl = lift_enum(&a).unwrap(); + assert_eq!( + decl.source, + EnumSource::Unlabeled(vec!["shipped".to_string(), "cancelled".to_string()]) + ); + let dbg = format!("{decl:?}"); + assert!(!dbg.contains('3') && !dbg.contains('7'), "{dbg}"); } - /// A duplicate key is refused (`None`, like the other `lift_*` that cannot - /// represent their input), never collapsed or lifted with a guessed list. + /// A duplicate key is kept as an observable `Invalid` declaration on the + /// class, never silently dropped and never collapsed. #[test] - fn lift_enum_refuses_duplicate_key() { + fn lift_enum_duplicate_key_is_kept_as_invalid() { let a = enum_decl("status", &[("shipped", "3"), ("shipped", "7")]); - assert!(lift_enum(&a).is_none()); + let decl = lift_enum(&a).expect("kept, not dropped"); + match decl.source { + EnumSource::Invalid(ref why) => assert!(why.contains("shipped"), "{why}"), + other => panic!("expected Invalid, got {other:?}"), + } let mut m = Model::new("Order"); m.attributes.push(a); - assert!(lift_model(&m).enums.is_empty()); + let class = lift_model(&m); + assert_eq!(class.enums.len(), 1); + assert!(matches!(class.enums[0].source, EnumSource::Invalid(_))); } /// Empty options (array form, constant argument, no variant argument) - /// stay a key-less `Static`: no variant is invented, and no value is - /// claimed. Pins the documented limitation. + /// are `Unresolved`: no values are claimed, and it is not a trusted + /// empty `Static` set. #[test] - fn lift_enum_without_visible_variants_claims_no_values() { + fn lift_enum_without_visible_variants_is_unresolved_not_static() { let a = enum_decl("status", &[]); - assert!(lifted_keys(&a).is_empty()); - assert_eq!(lift_enum(&a).unwrap().column, "status"); + let decl = lift_enum(&a).unwrap(); + assert_eq!(decl.column, "status"); + assert!(matches!(decl.source, EnumSource::Unresolved(_))); + assert!(!matches!(decl.source, EnumSource::Static(_))); + } + + /// Unlabeled, Unresolved and Static(empty) are three distinct states. + #[test] + fn lift_enum_states_are_distinguishable() { + let unlabeled = lift_enum(&enum_decl("s", &[("a", "0")])).unwrap().source; + let unresolved = lift_enum(&enum_decl("s", &[])).unwrap().source; + let static_empty = EnumSource::Static(Vec::new()); + assert_ne!(unlabeled, unresolved); + assert_ne!(unlabeled, static_empty); + assert_ne!(unresolved, static_empty); + } + + /// A non-Enum attribute still yields no enum declaration. + #[test] + fn lift_enum_non_enum_kind_returns_none() { + let a = AttrDecl { + kind: AttrKind::Attribute, + name: "age".to_string(), + options: vec![], + }; + assert!(lift_enum(&a).is_none()); } /// Ordinary attributes are unchanged and an enum-less class has no enums. diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index fb873a0b..5a8203ac 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -852,7 +852,7 @@ pub struct Association { /// An enum-backed column declaration. /// /// The `source` field captures three Odoo cases (static / computed / -/// additive); for Rails / Django / Ecto only `Static` applies. +/// additive); Rails uses `Unlabeled` / `Unresolved` / `Invalid`. /// See `docs/ODOO-TRANSCODING.md` §6. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -879,7 +879,8 @@ impl Default for EnumDecl { /// Source of an enum's variant list. Three cases capture Odoo's /// `selection=`, `selection=lambda`, and `selection_add=` -/// surface; Rails / Django / Ecto producers always emit `Static`. +/// surface; `Unlabeled`, `Unresolved` and `Invalid` keep unlabeled static +/// variants and unrecovered or unrepresentable declarations explicit. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] @@ -900,6 +901,21 @@ pub enum EnumSource { /// (e.g. `"account.move.line"`). parent_selection: String, }, + /// Fixed variant keys in source order, where the source declares **no + /// label** (Rails: labels live in i18n files outside the model). + /// A label is never synthesized from the key, and a source-local + /// physical value (such as a Rails integer) is never stored here. + Unlabeled(Vec), + /// The source declares an enum but its variant list was not + /// recovered. The string records why, or what was seen, verbatim + /// (e.g. `"not recovered"` or a constant name). Asserts no values: + /// distinct from `Static(vec![])` (a trusted empty set) and from a + /// field that is not an enum. + Unresolved(String), + /// The declaration cannot be represented faithfully (e.g. a duplicate + /// key); the string is the reason. Asserts no values. The declaration + /// stays visible so a consumer can refuse it. + Invalid(String), } /// A `store_accessor :col, %i[a b c], prefix: true` declaration — N