Skip to content

Commit 21d8ef0

Browse files
committed
fix(odoo-spo): read selection_add + merge across classes (codex #530)
Odoo addons extend an existing Selection field's domain via `_inherit` + `fields.Selection(selection_add=[('reviewed','Reviewed')])` WITHOUT redeclaring the base list. `_extract_selection_values` only read the base `selection=` arg, so the emitted `selection_value` set was incomplete and a downstream `ASSERT $value IN [...]` would reject records in legal extension-added states. Two fixes: 1. `_extract_selection_values` now reads BOTH the base list (positional arg 0 or `selection=`) AND `selection_add=`, unioning base-first with order-preserving dedup (factored into `_selection_tuple_keys`). 2. `_scan_file` binding MERGES selection keys per (model, field) instead of last-write-wins — the base declaration and the `selection_add` extension live in DIFFERENT classes, so the field's value set must accumulate across scans, not overwrite. Tests (53 -> 57): - selection_add kwarg alone -> added keys - base + selection_add -> union, base first - selection_add dedups against base - cross-class merge: base in _name class + selection_add in _inherit class -> accumulated [draft, posted, reviewed] python3 -m unittest tests.test_spo_enrich : 57/57 OK
1 parent 502a255 commit 21d8ef0

2 files changed

Lines changed: 91 additions & 30 deletions

File tree

tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,13 @@
7272
7373
Enables the consumer to lower a Selection field to
7474
`DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']`.
75-
Dynamic selections (`selection='_compute_states'`, a bare Name constant,
76-
or `related=`) are skipped — their values aren't statically knowable.
77-
Source order is preserved. Selection fields bind to the same
75+
Both the base list (positional arg 0 or `selection=`) AND a
76+
`_inherit` addon's `selection_add=[…]` extension are read and unioned —
77+
an extension's keys MERGE onto the field's accumulated set across
78+
classes, so the `ASSERT` set includes legal extension-added states (codex
79+
#530). Dynamic selections (`selection='_compute_states'`, a bare Name
80+
constant, or `related=`) are skipped — their values aren't statically
81+
knowable. Source order is preserved. Selection fields bind to the same
7882
`model_names` as relational fields (`_name`, else `_inherit[0]`).
7983
8084
This is *in addition* to the existing shallow relation read
@@ -162,37 +166,49 @@
162166
_VALIDATION_KINDS = ("presence", "uniqueness", "range", "format", "lookup")
163167

164168

169+
def _selection_tuple_keys(node: Optional[ast.expr], out: List[str], seen: Set[str]) -> None:
170+
"""Append the first-element string key of each 2-tuple in `node` (a list
171+
or tuple of `('key','Label')` pairs) to `out`, de-duplicating against
172+
`seen`, preserving order. Non-list/tuple `node` (a str method-ref, a bare
173+
Name constant, `related=`) contributes nothing — those aren't static."""
174+
if not isinstance(node, (ast.List, ast.Tuple)):
175+
return
176+
for elt in node.elts:
177+
if not isinstance(elt, (ast.Tuple, ast.List)) or len(elt.elts) < 1:
178+
continue
179+
key = _const_str(elt.elts[0])
180+
if key is not None and key not in seen:
181+
seen.add(key)
182+
out.append(key)
183+
184+
165185
def _extract_selection_values(call: ast.Call) -> List[str]:
166-
"""Pull the value KEYS from a `fields.Selection([('k','Label'), …])`
167-
declaration. The selection list is the first positional arg OR the
168-
`selection=` kwarg; each entry is a 2-tuple whose first element is the
169-
stored key.
170-
171-
Returns the keys in source order, de-duplicated (first occurrence wins).
172-
Returns `[]` for dynamic selections — a bare Name (constant reference),
173-
a string (compute-method name), `related=`, or any entry whose first
174-
element isn't a string constant. Those values aren't statically known.
186+
"""Pull the value KEYS from a `fields.Selection(...)` declaration.
187+
188+
Reads BOTH the base list — first positional arg OR `selection=` kwarg —
189+
AND the `selection_add=` kwarg (the Odoo idiom for an `_inherit` addon
190+
that *extends* an existing Selection field's domain without redeclaring
191+
it). Keys from both are unioned, base first, in source order, de-duped.
192+
193+
Returns `[]` when neither source is a static list of 2-tuples — a dynamic
194+
`selection='_compute_x'` (str), a bare Name constant, or `related=`.
195+
A pure `selection_add=` extension (no base list) yields just the added
196+
keys, which the caller MERGES onto the field's accumulated set.
175197
"""
176-
sel: Optional[ast.expr] = None
198+
base: Optional[ast.expr] = None
199+
add: Optional[ast.expr] = None
177200
for kw in call.keywords:
178201
if kw.arg == "selection":
179-
sel = kw.value
180-
break
181-
if sel is None and call.args:
182-
sel = call.args[0]
183-
if not isinstance(sel, (ast.List, ast.Tuple)):
184-
# `selection='_compute_x'` (str), a Name constant, or related= → skip.
185-
return []
202+
base = kw.value
203+
elif kw.arg == "selection_add":
204+
add = kw.value
205+
if base is None and call.args:
206+
base = call.args[0]
186207

187208
out: List[str] = []
188209
seen: Set[str] = set()
189-
for elt in sel.elts:
190-
if not isinstance(elt, (ast.Tuple, ast.List)) or len(elt.elts) < 1:
191-
continue
192-
key = _const_str(elt.elts[0])
193-
if key is not None and key not in seen:
194-
seen.add(key)
195-
out.append(key)
210+
_selection_tuple_keys(base, out, seen)
211+
_selection_tuple_keys(add, out, seen)
196212
return out
197213

198214

@@ -449,9 +465,15 @@ def _scan_file(
449465
relmap[(mu, field_name)] = (comodel, inverse)
450466
if selections is not None:
451467
for field_name, vals in local_selections.items():
452-
# Last write wins (a later reopening that redeclares the
453-
# Selection with a fuller value list supersedes).
454-
selections[(mu, field_name)] = vals
468+
# MERGE, not overwrite: the base `selection=` declaration
469+
# and any `_inherit` addon's `selection_add=` extensions
470+
# live in DIFFERENT classes, so a given (model, field)
471+
# accumulates across scans (codex #530). Order-preserving,
472+
# de-duplicated union.
473+
acc = selections.setdefault((mu, field_name), [])
474+
for v in vals:
475+
if v not in acc:
476+
acc.append(v)
455477

456478
# ── P1b: inherits_from edges ─────────────────────────────────────
457479
# Only when the class declares a NEW model (_name is set). An

tools/odoo-blueprint-extractor/tests/test_spo_enrich.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,27 @@ def test_non_tuple_entries_skipped(self):
655655
vals = _classify_sel("fields.Selection([('a','A'), 'bogus', ('b','B')])")
656656
self.assertEqual(vals, ["a", "b"])
657657

658+
# codex #530 — selection_add (the _inherit extension idiom)
659+
def test_selection_add_kwarg_alone(self):
660+
# A pure extension: `selection_add=` with no base list → the added keys.
661+
vals = _classify_sel(
662+
"fields.Selection(selection_add=[('reviewed','Reviewed'),('void','Void')])"
663+
)
664+
self.assertEqual(vals, ["reviewed", "void"])
665+
666+
def test_selection_base_plus_add_union_base_first(self):
667+
vals = _classify_sel(
668+
"fields.Selection(selection=[('draft','Draft'),('posted','Posted')], "
669+
"selection_add=[('reviewed','Reviewed')])"
670+
)
671+
self.assertEqual(vals, ["draft", "posted", "reviewed"])
672+
673+
def test_selection_add_dedups_against_base(self):
674+
vals = _classify_sel(
675+
"fields.Selection(selection=[('a','A')], selection_add=[('a','A2'),('b','B')])"
676+
)
677+
self.assertEqual(vals, ["a", "b"])
678+
658679

659680
class TestSelectionScan(unittest.TestCase):
660681
"""`_scan_file` binds Selection values to model_names (per #525 rule)."""
@@ -683,6 +704,24 @@ def test_dynamic_selection_field_records_nothing(self):
683704
)
684705
self.assertEqual(sel, {})
685706

707+
def test_selection_add_merges_across_classes(self):
708+
# codex #530: base declaration in the _name class + a selection_add
709+
# extension in a separate _inherit class must ACCUMULATE, not
710+
# overwrite — else the ASSERT $value IN [...] rejects legal added
711+
# states.
712+
sel = _scan_sel(
713+
"class Base:\n"
714+
" _name = 'account.move'\n"
715+
" state = fields.Selection([('draft','Draft'),('posted','Posted')])\n"
716+
"\n"
717+
"class Ext:\n"
718+
" _inherit = 'account.move'\n"
719+
" state = fields.Selection(selection_add=[('reviewed','Reviewed')])\n"
720+
)
721+
self.assertEqual(
722+
sel, {("account_move", "state"): ["draft", "posted", "reviewed"]}
723+
)
724+
686725

687726
class TestP3SelectionValueEmission(unittest.TestCase):
688727
def _triples(self, field_iri="odoo:account_move.state"):

0 commit comments

Comments
 (0)