Skip to content

Commit bc602ed

Browse files
committed
feat(ogar-adapter-surrealql): emit array<record> for to-many associations (#2 Stage A)
W3.3's "One2many/Many2many array<record>" emitter gap: the shared SurrealQL emitter rendered only the owning side (BelongsTo → record<X>) and dropped to-many associations to a comment marker. Now that the lift carries the association (OGAR #132 `relation_kind` → HasMany/HasAndBelongsToMany + comodel), `emit_field_assoc` renders both as `array<record<comodel>>`: - BelongsTo (Many2one) -> record<X> / option<record<X>> (unchanged) - HasMany (One2many) -> array<record<X>> (NEW) - HasAndBelongsToMany (Many2many) -> array<record<X>> (NEW) - HasOne (Rails-only, non-owning) -> comment marker (unchanged) Target handling mirrors the owning-side record<X> exactly (dotted Odoo comodels are quoted), so no new normalization. The One2many *computed* `VALUE <-comodel.<inverse> READONLY` reverse-link is the separate W3.3 "computed VALUE" gap and is deferred; this lands the structural array TYPE. Parse-back of `array<record<…>>` is the companion `surrealdb-parser` follow-up — today's `walk` recovers the owning-side record<X> only (same emit-richer-than-parse asymmetry the prior comment-marker had; the gated round-trip tests cover BelongsTo + primitives, unaffected). This is #2 Stage A from odoo-rs #19 — it unblocks deleting od-ontology's native emit fork (W3.3) once the shared emitter covers the schema. Verified via probe (default features, no surrealdb): 22 tests pass (3 new: has_many/has_and_belongs_to_many → array<record>, has_one → comment), clippy -D warnings clean. The emit side is pure string-gen (no surrealdb); the OGAR workspace's surrealdb-parser leg builds on CI. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d1926db commit bc602ed

1 file changed

Lines changed: 71 additions & 27 deletions

File tree

  • crates/ogar-adapter-surrealql/src

crates/ogar-adapter-surrealql/src/lib.rs

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ pub fn emit_surrealql_ddl(classes: &[Class]) -> String {
124124
/// |---|---|
125125
/// | `DefineTable { name, .. }` | `Class { identity: name, .. }` |
126126
/// | `DefineField TYPE record<x>` | `Association { kind: BelongsTo, class_name: Some(x) }` |
127+
/// | `DefineField TYPE array<record<x>>` (emit) | `Association { kind: HasMany \| HasAndBelongsToMany, class_name: Some(x) }` |
127128
/// | `DefineField TYPE string + ASSERT $value IN [...]` | `EnumDecl { variants: [...] }` |
128129
/// | `DefineField TYPE option<X>` | `Attribute { type_name: Some(X), optional: true }` |
129130
/// | `DefineField TYPE <scalar>` | `Attribute { type_name: Some(scalar) }` |
@@ -549,19 +550,14 @@ fn emit_field_attr(table: &str, attr: &Attribute, out: &mut String) {
549550
}
550551

551552
fn emit_field_assoc(table: &str, assoc: &Association, out: &mut String) {
552-
// Only owning side gets a field on this table (BelongsTo).
553-
// HasMany/HasOne are the non-owning side — FK lives on the other table;
554-
// we emit a comment marker so a roundtrip via unmap can reconstruct
555-
// the inverse, but no DEFINE FIELD here.
553+
// The record/array target is a SurrealQL identifier — quote if non-bare
554+
// (Odoo `res.partner` → `` `res.partner` ``). Same handling as the
555+
// owning-side record<X> below, used by every relational arm.
556+
let target = assoc.class_name.as_deref().unwrap_or(&assoc.name);
557+
let target_ident = surrealql_ident(target);
556558
match assoc.kind {
557559
AssociationKind::BelongsTo => {
558-
let target = assoc
559-
.class_name
560-
.as_deref()
561-
.unwrap_or(&assoc.name); // fallback: relation name as target
562-
// The record target is a SurrealQL identifier — quote if
563-
// non-bare (Odoo `res.partner` → `` `res.partner` ``).
564-
let target_ident = surrealql_ident(target);
560+
// Owning side (Odoo Many2one): the FK record lives on this table.
565561
let ty = if assoc.optional.unwrap_or(false) {
566562
format!("option<record<{target_ident}>>")
567563
} else {
@@ -574,21 +570,39 @@ fn emit_field_assoc(table: &str, assoc: &Association, out: &mut String) {
574570
ty
575571
));
576572
}
577-
AssociationKind::HasOne | AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => {
578-
// Non-owning / join-table sides: no field on this table.
579-
// Roundtrip note for unmap: the inverse side reconstructs from
580-
// the owning side's `record<X>` field on the target table.
581-
// The comment body isn't parsed; leave names un-quoted for
582-
// readability.
573+
AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => {
574+
// To-many relations land as a SurrealQL `array<record<comodel>>`
575+
// — Odoo `One2many` (the reverse set; the FK/inverse lives on the
576+
// comodel) and `Many2many` (the stored join set) both. This closes
577+
// the W3.3 "One2many/Many2many array<record>" emitter gap now that
578+
// the lift carries the association (#132 `relation_kind`).
579+
//
580+
// The One2many *computed* `VALUE <-comodel.<inverse> READONLY`
581+
// reverse-link (the reactive recompute) is the separate W3.3
582+
// "computed VALUE" gap and is deferred; the array TYPE is the
583+
// structural relation. Parse-back of `array<record<…>>` is the
584+
// companion `surrealdb-parser` follow-up (today's `walk` recovers
585+
// the owning-side `record<X>` only — same emit-richer-than-parse
586+
// asymmetry the prior comment-marker had).
583587
out.push_str(&format!(
584-
"-- {} {:?} {} (no DEFINE FIELD — non-owning / join side)\n",
588+
"DEFINE FIELD {} ON {} TYPE array<record<{target_ident}>>;\n",
589+
surrealql_ident(&assoc.name),
590+
table
591+
));
592+
}
593+
AssociationKind::HasOne => {
594+
// Non-owning single (Rails `has_one`; Odoo has no analogue —
595+
// it models to-one as Many2one). FK lives on the other table:
596+
// a comment marker, no DEFINE FIELD here.
597+
out.push_str(&format!(
598+
"-- {} {:?} {} (no DEFINE FIELD — non-owning side)\n",
585599
table, assoc.kind, assoc.name
586600
));
587601
}
588-
// `AssociationKind` is `#[non_exhaustive]` in `ogar-vocab`; the four
589-
// arms above cover every variant defined today. The wildcard exists
590-
// only so adding a variant in `ogar-vocab` produces a clean
591-
// panic-on-first-emit instead of a silent miscompile elsewhere.
602+
// `AssociationKind` is `#[non_exhaustive]` in `ogar-vocab`; the arms
603+
// above cover every variant defined today. The wildcard exists only so
604+
// adding a variant in `ogar-vocab` produces a clean panic-on-first-emit
605+
// instead of a silent miscompile elsewhere.
592606
_ => unreachable!("vocab variant added without adapter update: AssociationKind"),
593607
}
594608
}
@@ -755,14 +769,44 @@ mod tests {
755769
}
756770

757771
#[test]
758-
fn emit_class_with_has_many_does_not_define_field_on_this_table() {
772+
fn emit_class_with_has_many_renders_array_of_record() {
773+
// One2many → array<record<comodel>> (the W3.3 array<record> gap, closed).
759774
let mut c = Class::new("project");
760-
let assoc = Association::new(AssociationKind::HasMany, "work_packages");
775+
let mut assoc = Association::new(AssociationKind::HasMany, "work_packages");
776+
assoc.class_name = Some("work_package".to_string());
761777
c.associations.push(assoc);
762778
let ddl = emit_surrealql_ddl(&[c]);
763-
// No DEFINE FIELD; only a comment marker (FK is on the other table)
764-
assert!(!ddl.contains("DEFINE FIELD work_packages"), "got: {ddl}");
765-
assert!(ddl.contains("(no DEFINE FIELD"), "expected non-owning-side comment, got: {ddl}");
779+
assert!(
780+
ddl.contains("DEFINE FIELD work_packages ON project TYPE array<record<work_package>>;"),
781+
"got: {ddl}"
782+
);
783+
}
784+
785+
#[test]
786+
fn emit_class_with_has_and_belongs_to_many_renders_array_of_record() {
787+
// Many2many → array<record<comodel>> (stored join set). Dotted Odoo
788+
// comodels are quoted, like the owning-side record<X>.
789+
let mut c = Class::new("account_move");
790+
let mut assoc = Association::new(AssociationKind::HasAndBelongsToMany, "tag_ids");
791+
assoc.class_name = Some("account.analytic.tag".to_string());
792+
c.associations.push(assoc);
793+
let ddl = emit_surrealql_ddl(&[c]);
794+
assert!(
795+
ddl.contains(
796+
"DEFINE FIELD tag_ids ON account_move TYPE array<record<`account.analytic.tag`>>;"
797+
),
798+
"got: {ddl}"
799+
);
800+
}
801+
802+
#[test]
803+
fn emit_class_with_has_one_keeps_non_owning_comment() {
804+
let mut c = Class::new("project");
805+
c.associations
806+
.push(Association::new(AssociationKind::HasOne, "lead"));
807+
let ddl = emit_surrealql_ddl(&[c]);
808+
assert!(!ddl.contains("DEFINE FIELD lead"), "got: {ddl}");
809+
assert!(ddl.contains("(no DEFINE FIELD"), "expected non-owning comment, got: {ddl}");
766810
}
767811

768812
#[test]

0 commit comments

Comments
 (0)