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
62 changes: 61 additions & 1 deletion crates/ogar-adapter-surrealql/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.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.
Expand Down Expand Up @@ -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")];
Expand Down
93 changes: 92 additions & 1 deletion crates/ogar-emitter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1174,6 +1203,68 @@ mod tests {
}));
}

fn enum_triples(source: ogar_vocab::EnumSource) -> Vec<Triple> {
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
Expand Down
140 changes: 78 additions & 62 deletions crates/ogar-from-ruff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,48 +780,38 @@ fn lift_attribute(a: &AttrDecl) -> Option<Attribute> {
}

/// 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<EnumDecl> {
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<String> = 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)
}

Expand Down Expand Up @@ -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);
Expand All @@ -1628,13 +1618,13 @@ mod tests {

fn lifted_keys(a: &AttrDecl) -> Vec<String> {
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")]);
Expand All @@ -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.
Expand Down
Loading
Loading