rule: _sql_constraints is ignored from Odoo 19
Browse filesodoo.sql-constraints-are-ignored-from-19, from OpenSPP 208d9758 (#394):
a _sql_constraints block on a 19.0 module, which Odoo 19 logs once and
never creates, so duplicates were saved silently. An AST pass over the
file plus the manifest's series, legacy_sql_constraints_on_19. Reported
on 19+; silent on 16 to 18, where it is the only spelling; silent when a
walked tree pins no series (odoo core, 174 files); advisory per finding
when nothing was walked at all.
Fires nowhere at HEAD on the three trees, as expected: OpenSPP had
migrated, OCA and core are 18.0. OpenSPP's history holds the evidence:
it catches all four fixed instances (three in ca44b5e3, one in 208d9758)
on the files before their fix and none after, and nexa check reports the
#394 file at the line the fix removed. Graded defect: on 19 the attribute
does nothing, so every finding is a constraint that does not exist.
Also corrects "models.Constraint arrived in Odoo 17": 18.0 core has none
and the 18.0 ORM reference has no Constraints section; the 19.0 one does.
The rule cites it. First rule declaring odoo_versions >=19.0, which
exercises the context layer's per-rule series check; tests pin it.
Retrieval, index rebuilt: 28/28 at TOP_K 6, 26/28 in the top 3.
Corpus 28 rules. 277 of 277 true positives still reported.
ARCHITECTURE 3.62.
- ARCHITECTURE.md +59 -8
- README.md +12 -11
- bin/layers.py +4 -3
- bin/semantics.py +79 -5
- corpus/build_corpus.py +53 -3
- corpus/corpus_meta.json +5 -5
- corpus/rules/odoo.json +30 -3
- fieldtest-oca-2026-09.json +2 -2
- fieldtest-odoo-core-2026-09.json +2 -2
- fieldtest-openspp-2026-09.json +2 -2
- modelfiles/gen-odoo.Modelfile +35 -7
- tests/test_layers.py +16 -2
- tests/test_semantics.py +45 -0
|
@@ -3065,6 +3065,56 @@ with it: it was a comparison, the one shape that did not survive, and a checker
|
|
| 3065 |
its own wrong sample. The composed-in Python rules gain nothing and lose nothing in their
|
| 3066 |
classification: the verdicts are unchanged, only which of them are still reported.
|
| 3067 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3068 |
## 4. Performance traps, all found by measurement
|
| 3069 |
|
| 3070 |
Within four instrumented runs the report showed the 7B at ~10 tok/s against a
|
|
@@ -3136,12 +3186,12 @@ in a corpus; it does not close the reasoning gap.
|
|
| 3136 |
|
| 3137 |
### Checking is partial, and the layers say where
|
| 3138 |
|
| 3139 |
-
- 1 of
|
| 3140 |
false pass, and `nexa coverage` prints the reason.
|
| 3141 |
- <!--fig:corpus.by_pattern-->9<!--/fig--> of the
|
| 3142 |
-
<!--fig:corpus.checkable-->
|
| 3143 |
generated code**. That catches textual signatures, not meaning. Code wrong in a way
|
| 3144 |
-
no pattern describes passes silently. The other <!--fig:corpus.by_ast-->
|
| 3145 |
are decided by an AST pass (§3.12, §3.47, §3.54). The regexes
|
| 3146 |
no longer see docstrings, which are emptied before matching; they still see every
|
| 3147 |
other string literal, so a violation quoted inside a triple-quoted SQL string or a
|
|
@@ -3299,13 +3349,14 @@ unawares.
|
|
| 3299 |
|
| 3300 |
### Corpus provenance
|
| 3301 |
|
| 3302 |
-
|
| 3303 |
deserve more scepticism. Most of the rules were written by an assistant rather than
|
| 3304 |
harvested from production incidents, and rules drawn from real incidents are worth
|
| 3305 |
-
more.
|
| 3306 |
from an external ruleset disagreeing with this one (3.34), and
|
| 3307 |
-
`depends-must-not-name-unread-fields` from a fix commit in OpenSPP's history (3.35)
|
| 3308 |
-
|
|
|
|
| 3309 |
|
| 3310 |
This is the deepest unresolved problem in the design and no layer addresses it: the
|
| 3311 |
validator and the thing being validated are the same class of system. Provenance
|
|
@@ -3317,7 +3368,7 @@ tracks sources, versions and checker identities. It cannot establish that a rule
|
|
| 3317 |
Odoo, and nothing else (§3.46). Odoo rules were checked against 18.0 documentation and
|
| 3318 |
its source; behaviour differs across major versions.
|
| 3319 |
|
| 3320 |
-
One stack,
|
| 3321 |
because an Odoo module is a Python module. Both halves carry field evidence from two
|
| 3322 |
ecosystems and a hand classification of every finding, and they are **not the same
|
| 3323 |
quality** — <!--fig:cls.openspp.precision-->85<!--/fig-->% on the two flagship ORM
|
|
|
|
| 3065 |
its own wrong sample. The composed-in Python rules gain nothing and lose nothing in their
|
| 3066 |
classification: the verdicts are unchanged, only which of them are still reported.
|
| 3067 |
|
| 3068 |
+
### 3.62 `_sql_constraints` is ignored from Odoo 19
|
| 3069 |
+
|
| 3070 |
+
**Measured 2026-09-26.** The rule an incident asked for, and the first new rule since the
|
| 3071 |
+
corpus reached 27. OpenSPP's 208d9758 (#394) found `spp.cr.type.reason.document` declaring
|
| 3072 |
+
its `(cr_type_id, reason)` uniqueness in `_sql_constraints` on a 19.0 module. Odoo 19 logs
|
| 3073 |
+
one warning at load and creates nothing, so duplicate rules were saved from 19.0.3.0.0
|
| 3074 |
+
until the fix, and the fix added a lint of its own because nothing in CI had caught it.
|
| 3075 |
+
`odoo.sql-constraints-are-ignored-from-19` is an AST pass over the file plus the manifest's
|
| 3076 |
+
series, `legacy_sql_constraints_on_19`.
|
| 3077 |
+
|
| 3078 |
+
**A version claim was wrong on the way in.** §3.45 and the `prefer-sql-constraint` rule said
|
| 3079 |
+
`models.Constraint` arrived in Odoo 17. Odoo 18.0 core at 3c3e3b3 has no `models.Constraint`
|
| 3080 |
+
anywhere and declares `_sql_constraints` in 174 files; the 18.0 ORM reference has no
|
| 3081 |
+
"Constraints and indexes" section and the 19.0 one does, declaring `Constraint`, `Index` and
|
| 3082 |
+
`UniqueIndex` and never naming `_sql_constraints`. So up to 18 the attribute is the only
|
| 3083 |
+
spelling, and from 19 it does nothing. The rule text, both correct samples that said "Odoo 16
|
| 3084 |
+
only", and the checker docstring that repeated it now say 16 to 18. §3.45 is left as it was
|
| 3085 |
+
written; this is its correction.
|
| 3086 |
+
|
| 3087 |
+
**Which series decides everything, and there are three answers.** 19 or later: reported, at
|
| 3088 |
+
the assignment. 16 to 18: silent, because a finding would be advice that cannot be taken.
|
| 3089 |
+
Unknown: silent when a tree was walked and simply does not pin a series, which is odoo core,
|
| 3090 |
+
whose manifests carry versions like "1.0" and would otherwise yield 174 findings, every one
|
| 3091 |
+
wrong. Reported as advisory, `unproven` on the finding itself, when nothing was walked at
|
| 3092 |
+
all, because "nobody looked" and "looked and it does not say" are different answers, and
|
| 3093 |
+
silence on the first would hide the rule from a bare snippet and from `validate`'s own samples.
|
| 3094 |
+
It is the first rule to declare `odoo_versions` other than `>=16.0`, and that exercised the
|
| 3095 |
+
context layer's per-rule check for the first time: grounding a 17.0 module with it is now a
|
| 3096 |
+
finding, which is what that check was written to say. A test pins both sides.
|
| 3097 |
+
|
| 3098 |
+
On the trees it fires nowhere, and that is the expected answer rather than a missing one.
|
| 3099 |
+
OpenSPP at the pinned commit already contains the fix and has 72 files of `models.Constraint`;
|
| 3100 |
+
OCA's 20 `_sql_constraints` are all in 18.0 modules; core is 18.0. So the evidence is
|
| 3101 |
+
OpenSPP's history. Five blocks were written on 19. Three were fixed together by `ca44b5e3`,
|
| 3102 |
+
*"migrate _sql_constraints to models.Constraint (Odoo 19)"*, one is #394, and one went with a
|
| 3103 |
+
reverted feature rather than a fix. The checker fires on all four fixed ones as the files stood
|
| 3104 |
+
before their fix and on none after, and `nexa check --no-model` on the #394 file inside its
|
| 3105 |
+
19.0 module reports it as high impact, likelihood defect, at the line the fix removed.
|
| 3106 |
+
|
| 3107 |
+
It is graded **defect** on that, which is more than `noupdate` got for one incident in §3.55,
|
| 3108 |
+
and the difference is the construct rather than the count. A heuristic can be wrong about
|
| 3109 |
+
intent. This cannot: on 19 the attribute does nothing, so every finding is a constraint that
|
| 3110 |
+
does not exist. The one way to be wrong is a series misread, and that is why the unknown
|
| 3111 |
+
series is handled the way it is.
|
| 3112 |
+
|
| 3113 |
+
Its probe, *"after upgrading to Odoo 19 my unique constraint is not enforced and duplicates
|
| 3114 |
+
are saved"*, brings it back inside the top 3. Re-measured 2026-09-27 with the index rebuilt:
|
| 3115 |
+
all 28 rules retrievable at `TOP_K` 6, and 26 of 28 in the top 3, where it had been 25 of 27.
|
| 3116 |
+
The two that miss at 3 are `commit-inside-a-loop` and `eval-and-pickle`, not this one.
|
| 3117 |
+
|
| 3118 |
## 4. Performance traps, all found by measurement
|
| 3119 |
|
| 3120 |
Within four instrumented runs the report showed the 7B at ~10 tok/s against a
|
|
|
|
| 3186 |
|
| 3187 |
### Checking is partial, and the layers say where
|
| 3188 |
|
| 3189 |
+
- 1 of <!--fig:corpus.rules-->28<!--/fig--> rules has no automated check at all. It returns **no verdict**, never a
|
| 3190 |
false pass, and `nexa coverage` prints the reason.
|
| 3191 |
- <!--fig:corpus.by_pattern-->9<!--/fig--> of the
|
| 3192 |
+
<!--fig:corpus.checkable-->27<!--/fig--> checked rules are decided by a **regex over
|
| 3193 |
generated code**. That catches textual signatures, not meaning. Code wrong in a way
|
| 3194 |
+
no pattern describes passes silently. The other <!--fig:corpus.by_ast-->18<!--/fig-->
|
| 3195 |
are decided by an AST pass (§3.12, §3.47, §3.54). The regexes
|
| 3196 |
no longer see docstrings, which are emptied before matching; they still see every
|
| 3197 |
other string literal, so a violation quoted inside a triple-quoted SQL string or a
|
|
|
|
| 3349 |
|
| 3350 |
### Corpus provenance
|
| 3351 |
|
| 3352 |
+
<!--fig:corpus.sourced-->26<!--/fig--> of <!--fig:corpus.rules-->28<!--/fig--> rules cite upstream documentation. The rest rest on general knowledge and
|
| 3353 |
deserve more scepticism. Most of the rules were written by an assistant rather than
|
| 3354 |
harvested from production incidents, and rules drawn from real incidents are worth
|
| 3355 |
+
more. Four now are: `commit-inside-a-loop` and `sql-needs-query-parameters` came
|
| 3356 |
from an external ruleset disagreeing with this one (3.34), and
|
| 3357 |
+
`depends-must-not-name-unread-fields` from a fix commit in OpenSPP's history (3.35), and
|
| 3358 |
+
`sql-constraints-are-ignored-from-19` from an OpenSPP incident (3.62). Four of
|
| 3359 |
+
twenty-eight is not a change of method yet.
|
| 3360 |
|
| 3361 |
This is the deepest unresolved problem in the design and no layer addresses it: the
|
| 3362 |
validator and the thing being validated are the same class of system. Provenance
|
|
|
|
| 3368 |
Odoo, and nothing else (§3.46). Odoo rules were checked against 18.0 documentation and
|
| 3369 |
its source; behaviour differs across major versions.
|
| 3370 |
|
| 3371 |
+
One stack, <!--fig:corpus.rules-->28<!--/fig--> rules: 20 about the ORM and 8 about the Python language, which apply
|
| 3372 |
because an Odoo module is a Python module. Both halves carry field evidence from two
|
| 3373 |
ecosystems and a hand classification of every finding, and they are **not the same
|
| 3374 |
quality** — <!--fig:cls.openspp.precision-->85<!--/fig-->% on the two flagship ORM
|
|
@@ -135,7 +135,7 @@ publishing a classification.
|
|
| 135 |
|
| 136 |
## What it is not
|
| 137 |
|
| 138 |
-
**Not a general linter.**
|
| 139 |
wrapper above it does not have to.
|
| 140 |
|
| 141 |
**Not a bug finder.** Against real Odoo bugfixes the corpus catches
|
|
@@ -427,7 +427,7 @@ the same command.
|
|
| 427 |
It prints **no verdict**, because nothing generated an answer to have a verdict
|
| 428 |
about. A clean run says *no pattern fired* in those words, and exits 1 if one did.
|
| 429 |
|
| 430 |
-
**A `.py` or `.xml` file is reviewed against all
|
| 431 |
by name.**
|
| 432 |
|
| 433 |
```
|
|
@@ -583,10 +583,10 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
|
|
| 583 |
|
| 584 |
| | |
|
| 585 |
|---|---|
|
| 586 |
-
| Rules | <!--fig:corpus.rules-->
|
| 587 |
| Critical | <!--fig:corpus.critical-->12<!--/fig--> |
|
| 588 |
-
| Machine-checkable | <!--fig:corpus.checkable-->
|
| 589 |
-
| Traceable to upstream docs | <!--fig:corpus.sourced-->
|
| 590 |
| Pattern false positives | 0 cross-hits across the corpus |
|
| 591 |
| Recall under meaning-preserving rewrite | 79/79 |
|
| 592 |
| Satisfied patterns bypassable by a comment | 0 of <!--fig:corpus.satisfied_patterns-->1<!--/fig--> |
|
|
@@ -609,7 +609,7 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
|
|
| 609 |
| `nexa check` verdict stability | 4 of 5 files on one run, 3 of 5 on the next |
|
| 610 |
| `nexa check --no-model` | 0.26s, identical output every run |
|
| 611 |
|
| 612 |
-
|
| 613 |
`schema-change-needs-migration`, whose violation lives in the diff between two module
|
| 614 |
versions — returns **no verdict** rather than a false pass, and `nexa coverage` names
|
| 615 |
it and says why.
|
|
@@ -1253,11 +1253,12 @@ nexa eval --retrieval
|
|
| 1253 |
asks each rule its own probe and reports whether it comes back. No generation, so it
|
| 1254 |
needs only the embedding model and finishes in seconds.
|
| 1255 |
|
| 1256 |
-
All
|
| 1257 |
-
3
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
|
|
|
| 1261 |
Reworded to the question only it answers — *"should this check be a database
|
| 1262 |
constraint or an api.constrains method"* — and measured again. It is the third rule to
|
| 1263 |
fail this way, and all three failed identically: correctly worded, in the vocabulary of
|
|
|
|
| 135 |
|
| 136 |
## What it is not
|
| 137 |
|
| 138 |
+
**Not a general linter.** <!--fig:corpus.rules-->28<!--/fig--> rules. It will not replace `pylint-odoo`, and after the
|
| 139 |
wrapper above it does not have to.
|
| 140 |
|
| 141 |
**Not a bug finder.** Against real Odoo bugfixes the corpus catches
|
|
|
|
| 427 |
It prints **no verdict**, because nothing generated an answer to have a verdict
|
| 428 |
about. A clean run says *no pattern fired* in those words, and exits 1 if one did.
|
| 429 |
|
| 430 |
+
**A `.py` or `.xml` file is reviewed against all <!--fig:corpus.rules-->28<!--/fig--> rules. Anything else is refused
|
| 431 |
by name.**
|
| 432 |
|
| 433 |
```
|
|
|
|
| 583 |
|
| 584 |
| | |
|
| 585 |
|---|---|
|
| 586 |
+
| Rules | <!--fig:corpus.rules-->28<!--/fig--> — 19 Odoo ORM, 8 plain-Python, one stack |
|
| 587 |
| Critical | <!--fig:corpus.critical-->12<!--/fig--> |
|
| 588 |
+
| Machine-checkable | <!--fig:corpus.checkable-->27<!--/fig--> (<!--fig:corpus.checkable.pct-->96<!--/fig-->%): <!--fig:corpus.by_pattern-->9<!--/fig--> by pattern, <!--fig:corpus.by_ast-->18<!--/fig--> by AST |
|
| 589 |
+
| Traceable to upstream docs | <!--fig:corpus.sourced-->26<!--/fig--> (<!--fig:corpus.sourced.pct-->93<!--/fig-->%), each audited for topic fit |
|
| 590 |
| Pattern false positives | 0 cross-hits across the corpus |
|
| 591 |
| Recall under meaning-preserving rewrite | 79/79 |
|
| 592 |
| Satisfied patterns bypassable by a comment | 0 of <!--fig:corpus.satisfied_patterns-->1<!--/fig--> |
|
|
|
|
| 609 |
| `nexa check` verdict stability | 4 of 5 files on one run, 3 of 5 on the next |
|
| 610 |
| `nexa check --no-model` | 0.26s, identical output every run |
|
| 611 |
|
| 612 |
+
<!--fig:corpus.checkable-->27<!--/fig--> of <!--fig:corpus.rules-->28<!--/fig--> rules carry an automated check. The one that does not —
|
| 613 |
`schema-change-needs-migration`, whose violation lives in the diff between two module
|
| 614 |
versions — returns **no verdict** rather than a false pass, and `nexa coverage` names
|
| 615 |
it and says why.
|
|
|
|
| 1253 |
asks each rule its own probe and reports whether it comes back. No generation, so it
|
| 1254 |
needs only the embedding model and finishes in seconds.
|
| 1255 |
|
| 1256 |
+
All 28 are retrievable at the operating `TOP_K` of 6, and 26 of them rank in the top
|
| 1257 |
+
3, re-measured on 2026-09-27 with the `_sql_constraints` rule added. That number got
|
| 1258 |
+
harder rather than easier when the corpus became one stack: with 19 rules competing,
|
| 1259 |
+
`prefer-sql-constraint` came back first for *"stop anyone entering a negative quantity
|
| 1260 |
+
on a sale order line"*; with 27 it stopped coming back at all, because that is the
|
| 1261 |
+
generic validation question every constraint rule here answers.
|
| 1262 |
Reworded to the question only it answers — *"should this check be a database
|
| 1263 |
constraint or an api.constrains method"* — and measured again. It is the third rule to
|
| 1264 |
fail this way, and all three failed identically: correctly worded, in the vocabulary of
|
|
@@ -1083,9 +1083,10 @@ def _version_applies(declared, series):
|
|
| 1083 |
comparators combine with AND, the rest with OR, and a series passes if it
|
| 1084 |
satisfies every bound or matches any alternative.
|
| 1085 |
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
|
|
|
| 1089 |
"""
|
| 1090 |
try:
|
| 1091 |
target = float(series)
|
|
|
|
| 1083 |
comparators combine with AND, the rest with OR, and a series passes if it
|
| 1084 |
satisfies every bound or matches any alternative.
|
| 1085 |
|
| 1086 |
+
Every versioned rule declared the single clause '>=16.0' until
|
| 1087 |
+
odoo.sql-constraints-are-ignored-from-19, which declares '>=19.0' because
|
| 1088 |
+
18.0 has no models.Constraint. It is settled here so the first bounded
|
| 1089 |
+
range somebody writes means what they meant by it.
|
| 1090 |
"""
|
| 1091 |
try:
|
| 1092 |
target = float(series)
|
|
@@ -648,11 +648,12 @@ def _owner_of(tree, fn):
|
|
| 648 |
def _columns_already_constrained(tree, series=None):
|
| 649 |
"""{class node id: column names a SQL constraint on that class already covers}
|
| 650 |
|
| 651 |
-
Two spellings, and
|
| 652 |
-
`_code_unique = models.Constraint("UNIQUE(code)", msg)`
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
|
|
|
| 656 |
|
| 657 |
So on a module that pins 19 or later, a _sql_constraints block is not a
|
| 658 |
column being spoken for -- it is a column that nobody is watching and
|
|
@@ -694,6 +695,75 @@ def _assigns_name(statement, name):
|
|
| 694 |
return any(isinstance(t, ast.Name) and t.id == name for t in statement.targets)
|
| 695 |
|
| 696 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
def _series_at_least(series, floor):
|
| 698 |
"""Is this module pinned to `floor` or later? False when nothing said."""
|
| 699 |
try:
|
|
@@ -3392,6 +3462,7 @@ CHECKS = {
|
|
| 3392 |
"parent_can_form_a_cycle": parent_can_form_a_cycle,
|
| 3393 |
"editable_record_without_noupdate": editable_record_without_noupdate,
|
| 3394 |
"naive_time_leaves_or_dates_a_day": naive_time_leaves_or_dates_a_day,
|
|
|
|
| 3395 |
}
|
| 3396 |
|
| 3397 |
|
|
@@ -3408,6 +3479,9 @@ CONTEXT_CHECKS = frozenset([
|
|
| 3408 |
# Reads odoo_series: whether a _sql_constraints block is a constraint at
|
| 3409 |
# all depends on which Odoo is going to load it.
|
| 3410 |
"prefer_sql_constraint_applies",
|
|
|
|
|
|
|
|
|
|
| 3411 |
])
|
| 3412 |
|
| 3413 |
# Checkers that can meet input they cannot decide about -- a decorator whose
|
|
|
|
| 648 |
def _columns_already_constrained(tree, series=None):
|
| 649 |
"""{class node id: column names a SQL constraint on that class already covers}
|
| 650 |
|
| 651 |
+
Two spellings, and neither works everywhere.
|
| 652 |
+
`_code_unique = models.Constraint("UNIQUE(code)", msg)` is the 19 one: odoo
|
| 653 |
+
18.0 core has no models.Constraint at all and declares _sql_constraints in
|
| 654 |
+
174 files. `_sql_constraints = [(name, "UNIQUE(code)", msg)]` is the classic
|
| 655 |
+
one and is IGNORED from 19: the registry logs a warning at load and the
|
| 656 |
+
constraint is never created.
|
| 657 |
|
| 658 |
So on a module that pins 19 or later, a _sql_constraints block is not a
|
| 659 |
column being spoken for -- it is a column that nobody is watching and
|
|
|
|
| 695 |
return any(isinstance(t, ast.Name) and t.id == name for t in statement.targets)
|
| 696 |
|
| 697 |
|
| 698 |
+
def legacy_sql_constraints_on_19(code, facts=None):
|
| 699 |
+
"""odoo.sql-constraints-are-ignored-from-19
|
| 700 |
+
|
| 701 |
+
A class-body `_sql_constraints` assignment that Odoo 19 will not read. From
|
| 702 |
+
19 the registry logs one warning at load and creates nothing, so every
|
| 703 |
+
UNIQUE and CHECK in the block is a constraint nobody is enforcing and
|
| 704 |
+
everybody thinks somebody is.
|
| 705 |
+
|
| 706 |
+
The incident is OpenSPP's 208d9758 (#394): spp.cr.type.reason.document
|
| 707 |
+
declared (cr_type_id, reason) this way, duplicates were saved from
|
| 708 |
+
19.0.3.0.0, and the fix re-declared it as models.Constraint and added a
|
| 709 |
+
lint of its own because nothing in CI had caught it. The rest of that tree
|
| 710 |
+
had already moved, 72 files of models.Constraint to this one holdout.
|
| 711 |
+
|
| 712 |
+
Which series, three answers:
|
| 713 |
+
|
| 714 |
+
19 or later reported.
|
| 715 |
+
16 to 18 silent. _sql_constraints is the ONLY spelling there --
|
| 716 |
+
odoo 18.0 core declares it in 174 files and has no
|
| 717 |
+
models.Constraint -- so a finding would be advice that
|
| 718 |
+
cannot be taken. OCA's twenty are all 18.0.
|
| 719 |
+
unknown silent when a tree was walked and does not pin a series,
|
| 720 |
+
which is odoo core itself: its manifests carry versions
|
| 721 |
+
like "1.0", and 174 findings there would all be wrong.
|
| 722 |
+
Reported, advisory, when NOTHING was walked -- a bare
|
| 723 |
+
snippet, validate's own samples -- because "nobody looked"
|
| 724 |
+
and "looked and it does not say" are different answers,
|
| 725 |
+
and silence on the first would hide the rule entirely.
|
| 726 |
+
|
| 727 |
+
An empty list declares nothing and is not reported.
|
| 728 |
+
"""
|
| 729 |
+
facts = facts or {}
|
| 730 |
+
series = facts.get("odoo_series")
|
| 731 |
+
if _series_at_least(series, 19.0):
|
| 732 |
+
grade = None
|
| 733 |
+
elif series is None and not facts.get("walked"):
|
| 734 |
+
grade = UNPROVEN
|
| 735 |
+
else:
|
| 736 |
+
return []
|
| 737 |
+
tree, _ = parse_python(code)
|
| 738 |
+
if tree is None:
|
| 739 |
+
return []
|
| 740 |
+
findings = []
|
| 741 |
+
for cls in ast.walk(tree):
|
| 742 |
+
if not isinstance(cls, ast.ClassDef):
|
| 743 |
+
continue
|
| 744 |
+
for statement in cls.body:
|
| 745 |
+
if isinstance(statement, ast.Assign):
|
| 746 |
+
targets, value = statement.targets, statement.value
|
| 747 |
+
elif isinstance(statement, ast.AnnAssign) and statement.value is not None:
|
| 748 |
+
targets, value = [statement.target], statement.value
|
| 749 |
+
else:
|
| 750 |
+
continue
|
| 751 |
+
if not any(isinstance(t, ast.Name) and t.id == "_sql_constraints"
|
| 752 |
+
for t in targets):
|
| 753 |
+
continue
|
| 754 |
+
if isinstance(value, (ast.List, ast.Tuple)) and not value.elts:
|
| 755 |
+
continue
|
| 756 |
+
findings.append((
|
| 757 |
+
statement.lineno,
|
| 758 |
+
"{0} declares _sql_constraints, which Odoo 19 ignores{1}: the "
|
| 759 |
+
"registry logs a warning and creates no constraint, so nothing "
|
| 760 |
+
"enforces it -- declare each one as models.Constraint".format(
|
| 761 |
+
cls.name, "" if grade is None else
|
| 762 |
+
" (the series is unknown here; this holds from 19)"),
|
| 763 |
+
grade))
|
| 764 |
+
return findings
|
| 765 |
+
|
| 766 |
+
|
| 767 |
def _series_at_least(series, floor):
|
| 768 |
"""Is this module pinned to `floor` or later? False when nothing said."""
|
| 769 |
try:
|
|
|
|
| 3462 |
"parent_can_form_a_cycle": parent_can_form_a_cycle,
|
| 3463 |
"editable_record_without_noupdate": editable_record_without_noupdate,
|
| 3464 |
"naive_time_leaves_or_dates_a_day": naive_time_leaves_or_dates_a_day,
|
| 3465 |
+
"legacy_sql_constraints_on_19": legacy_sql_constraints_on_19,
|
| 3466 |
}
|
| 3467 |
|
| 3468 |
|
|
|
|
| 3479 |
# Reads odoo_series: whether a _sql_constraints block is a constraint at
|
| 3480 |
# all depends on which Odoo is going to load it.
|
| 3481 |
"prefer_sql_constraint_applies",
|
| 3482 |
+
# Reads odoo_series, and whether a tree was walked at all: whether a
|
| 3483 |
+
# _sql_constraints block exists in the database depends on the series.
|
| 3484 |
+
"legacy_sql_constraints_on_19",
|
| 3485 |
])
|
| 3486 |
|
| 3487 |
# Checkers that can meet input they cannot decide about -- a decorator whose
|
|
@@ -56,7 +56,7 @@ def _onchange_qty(self):
|
|
| 56 |
raise ValidationError('Quantity must be positive')""",
|
| 57 |
"correct": """_qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
|
| 58 |
|
| 59 |
-
# Odoo 16 only. From 19 this attribute is ignored and creates nothing:
|
| 60 |
# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]""",
|
| 61 |
"tags": ["onchange", "constraints", "bypass"],
|
| 62 |
# onchange is allowed as UX garnish, but only alongside a real enforcement
|
|
@@ -167,7 +167,7 @@ def _check_deadline(self):
|
|
| 167 |
"odoo_versions": ">=16.0",
|
| 168 |
"severity": "high",
|
| 169 |
"title": "Prefer a database constraint when the rule fits in SQL",
|
| 170 |
-
"rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint
|
| 171 |
"why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
|
| 172 |
"enforcement_layer": "database",
|
| 173 |
"bypassed_by": [],
|
|
@@ -178,12 +178,35 @@ def _check_unique_code(self):
|
|
| 178 |
raise ValidationError('Code must be unique')""",
|
| 179 |
"correct": """_code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
|
| 180 |
|
| 181 |
-
# Odoo 16 only. From 19 this attribute is ignored and creates nothing:
|
| 182 |
# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]""",
|
| 183 |
"tags": ["sql-constraints", "uniqueness", "race-condition", "check-constraint",
|
| 184 |
"bounds", "range", "negative-value", "positive-value", "quantity",
|
| 185 |
"minimum", "maximum"],
|
| 186 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
{
|
| 188 |
"id": "odoo.check-constraint-cannot-cross-tables",
|
| 189 |
"odoo_versions": ">=16.0",
|
|
@@ -613,6 +636,11 @@ def revoke(consent_id: str):
|
|
| 613 |
# ignore is worse than no checker.
|
| 614 |
|
| 615 |
ODOO_ORM = "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
ODOO_DATA = "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html"
|
| 617 |
ODOO_SECURITY = "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html"
|
| 618 |
ODOO_EXTERNAL_API = "https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"
|
|
@@ -710,6 +738,15 @@ ENRICH = {
|
|
| 710 |
"semantic_check": "prefer_sql_constraint_applies",
|
| 711 |
"source": ODOO_CONSTRAINTS,
|
| 712 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
"odoo.check-constraint-cannot-cross-tables": {
|
| 714 |
"violation_pattern": r"CHECK\s*\([^)]*(?:SELECT|[a-z_]+_id\.[a-z_]+)",
|
| 715 |
"source": PG_CREATE_TABLE,
|
|
@@ -1024,6 +1061,17 @@ LIKELIHOOD = {
|
|
| 1024 |
# was 3 of 33 and 0 of 15.
|
| 1025 |
"python.naive-and-aware-datetimes-do-not-compare": "defect",
|
| 1026 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1027 |
# 1 on-target of 14, 3 in the right method describing something else, and
|
| 1028 |
# one commit caught. The same shape as ondelete's grade and a touch better
|
| 1029 |
# aimed: the rule recognises a company-crossing relation and is about the
|
|
@@ -1109,6 +1157,8 @@ PROBES = {
|
|
| 1109 |
# here is about a race.
|
| 1110 |
"odoo.prefer-sql-constraint":
|
| 1111 |
"should this check be a database constraint or an api.constrains method",
|
|
|
|
|
|
|
| 1112 |
"odoo.check-constraint-cannot-cross-tables":
|
| 1113 |
"my CHECK constraint will not install because it reads the product stock",
|
| 1114 |
"odoo.check-company-on-relations":
|
|
|
|
| 56 |
raise ValidationError('Quantity must be positive')""",
|
| 57 |
"correct": """_qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
|
| 58 |
|
| 59 |
+
# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
|
| 60 |
# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]""",
|
| 61 |
"tags": ["onchange", "constraints", "bypass"],
|
| 62 |
# onchange is allowed as UX garnish, but only alongside a real enforcement
|
|
|
|
| 167 |
"odoo_versions": ">=16.0",
|
| 168 |
"severity": "high",
|
| 169 |
"title": "Prefer a database constraint when the rule fits in SQL",
|
| 170 |
+
"rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint is the only spelling that works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16 to 18, _sql_constraints is the only spelling: 18.0 has no models.Constraint. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.",
|
| 171 |
"why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
|
| 172 |
"enforcement_layer": "database",
|
| 173 |
"bypassed_by": [],
|
|
|
|
| 178 |
raise ValidationError('Code must be unique')""",
|
| 179 |
"correct": """_code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
|
| 180 |
|
| 181 |
+
# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
|
| 182 |
# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]""",
|
| 183 |
"tags": ["sql-constraints", "uniqueness", "race-condition", "check-constraint",
|
| 184 |
"bounds", "range", "negative-value", "positive-value", "quantity",
|
| 185 |
"minimum", "maximum"],
|
| 186 |
},
|
| 187 |
+
{
|
| 188 |
+
"id": "odoo.sql-constraints-are-ignored-from-19",
|
| 189 |
+
"odoo_versions": ">=19.0",
|
| 190 |
+
"odoo_versions_basis": "Measured on the trees, not read off a page. Odoo 18.0 core (3c3e3b3) declares _sql_constraints in 174 files and has no models.Constraint anywhere, so the attribute is the only spelling up to 18. OpenSPP on 19.0 declares models.Constraint in 72 files, and its 208d9758 (#394) records that 19 logs a warning and creates nothing for _sql_constraints. The ORM reference agrees: the 19.0 page declares Constraint, Index and UniqueIndex under 'Constraints and indexes' and never names _sql_constraints, and the 18.0 page has no such section. The corpus is checked against 18.0; this is the one rule whose subject starts after it.",
|
| 191 |
+
"severity": "high",
|
| 192 |
+
"title": "_sql_constraints is ignored from Odoo 19",
|
| 193 |
+
"rule": "On a module for Odoo 19 or later, declare every UNIQUE and CHECK as a models.Constraint class attribute. A _sql_constraints list is not read at 19: the registry logs one warning at load and creates nothing. When moving one over, remove the duplicates already stored first, because a constraint that fails to apply during an upgrade is downgraded to a schema warning and stays missing.",
|
| 194 |
+
"why": "The constraint everybody reads in the class does not exist in the database, so the duplicates or out-of-range values it was written to refuse are saved without a word. The only trace is a warning in a log at every registry load. OpenSPP shipped exactly this on spp.cr.type.reason.document from 19.0.3.0.0, duplicate rules accumulated, and nothing in CI caught it.",
|
| 195 |
+
"enforcement_layer": "database",
|
| 196 |
+
"bypassed_by": [],
|
| 197 |
+
"wrong": """class ReasonDocument(models.Model):
|
| 198 |
+
_name = 'spp.cr.type.reason.document'
|
| 199 |
+
|
| 200 |
+
_sql_constraints = [
|
| 201 |
+
('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),
|
| 202 |
+
]""",
|
| 203 |
+
"correct": """class ReasonDocument(models.Model):
|
| 204 |
+
_name = 'spp.cr.type.reason.document'
|
| 205 |
+
|
| 206 |
+
_reason_uniq = models.Constraint('UNIQUE(cr_type_id, reason)', 'One rule per reason.')""",
|
| 207 |
+
"tags": ["sql-constraints", "migration", "odoo-19", "uniqueness", "check-constraint",
|
| 208 |
+
"silent-failure"],
|
| 209 |
+
},
|
| 210 |
{
|
| 211 |
"id": "odoo.check-constraint-cannot-cross-tables",
|
| 212 |
"odoo_versions": ">=16.0",
|
|
|
|
| 636 |
# ignore is worse than no checker.
|
| 637 |
|
| 638 |
ODOO_ORM = "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html"
|
| 639 |
+
# The one rule whose subject starts after the series the corpus is checked
|
| 640 |
+
# against. The 19.0 page has a "Constraints and indexes" section declaring
|
| 641 |
+
# Constraint, Index and UniqueIndex and never names _sql_constraints; the 18.0
|
| 642 |
+
# page has no such section.
|
| 643 |
+
ODOO_ORM_19 = "https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html"
|
| 644 |
ODOO_DATA = "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html"
|
| 645 |
ODOO_SECURITY = "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html"
|
| 646 |
ODOO_EXTERNAL_API = "https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"
|
|
|
|
| 738 |
"semantic_check": "prefer_sql_constraint_applies",
|
| 739 |
"source": ODOO_CONSTRAINTS,
|
| 740 |
},
|
| 741 |
+
"odoo.sql-constraints-are-ignored-from-19": {
|
| 742 |
+
# From an incident, not from the harvest: OpenSPP 208d9758 (#394)
|
| 743 |
+
# shipped a _sql_constraints block on 19, duplicates accumulated, and
|
| 744 |
+
# the fix added a lint of its own because nothing had caught it. The
|
| 745 |
+
# question is one file plus the manifest's series, so it is an AST
|
| 746 |
+
# pass reading odoo_series; see semantics.legacy_sql_constraints_on_19.
|
| 747 |
+
"semantic_check": "legacy_sql_constraints_on_19",
|
| 748 |
+
"source": ODOO_ORM_19,
|
| 749 |
+
},
|
| 750 |
"odoo.check-constraint-cannot-cross-tables": {
|
| 751 |
"violation_pattern": r"CHECK\s*\([^)]*(?:SELECT|[a-z_]+_id\.[a-z_]+)",
|
| 752 |
"source": PG_CREATE_TABLE,
|
|
|
|
| 1061 |
# was 3 of 33 and 0 of 15.
|
| 1062 |
"python.naive-and-aware-datetimes-do-not-compare": "defect",
|
| 1063 |
|
| 1064 |
+
# Nothing live on the three trees -- OpenSPP had migrated, OCA and core
|
| 1065 |
+
# are 18.0 -- but OpenSPP's history holds every instance there was: five
|
| 1066 |
+
# blocks written on 19, three fixed together by ca44b5e3 ("migrate
|
| 1067 |
+
# _sql_constraints to models.Constraint"), one by 208d9758 (#394), one
|
| 1068 |
+
# gone with a reverted feature. The checker fires on all four fixed ones
|
| 1069 |
+
# as they were before the fix and on none after. And it cannot be wrong
|
| 1070 |
+
# the way a heuristic can: on 19 the attribute does nothing, so every
|
| 1071 |
+
# finding is a constraint that does not exist. A finding made without a
|
| 1072 |
+
# known series carries `unproven` on its own and gates nothing (3.62).
|
| 1073 |
+
"odoo.sql-constraints-are-ignored-from-19": "defect",
|
| 1074 |
+
|
| 1075 |
# 1 on-target of 14, 3 in the right method describing something else, and
|
| 1076 |
# one commit caught. The same shape as ondelete's grade and a touch better
|
| 1077 |
# aimed: the rule recognises a company-crossing relation and is about the
|
|
|
|
| 1157 |
# here is about a race.
|
| 1158 |
"odoo.prefer-sql-constraint":
|
| 1159 |
"should this check be a database constraint or an api.constrains method",
|
| 1160 |
+
"odoo.sql-constraints-are-ignored-from-19":
|
| 1161 |
+
"after upgrading to Odoo 19 my unique constraint is not enforced and duplicates are saved",
|
| 1162 |
"odoo.check-constraint-cannot-cross-tables":
|
| 1163 |
"my CHECK constraint will not install because it reads the product stock",
|
| 1164 |
"odoo.check-company-on-relations":
|
|
@@ -1,12 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"built_utc": "2026-09-
|
| 3 |
-
"rules":
|
| 4 |
-
"machine_checkable":
|
| 5 |
"by_pattern": 9,
|
| 6 |
-
"by_semantic_check":
|
| 7 |
"checked_against": {
|
| 8 |
"odoo": "18.0",
|
| 9 |
"python": "3.12"
|
| 10 |
},
|
| 11 |
-
"corpus_sha256": "
|
| 12 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"built_utc": "2026-09-27T11:31:01.417630+00:00",
|
| 3 |
+
"rules": 28,
|
| 4 |
+
"machine_checkable": 27,
|
| 5 |
"by_pattern": 9,
|
| 6 |
+
"by_semantic_check": 18,
|
| 7 |
"checked_against": {
|
| 8 |
"odoo": "18.0",
|
| 9 |
"python": "3.12"
|
| 10 |
},
|
| 11 |
+
"corpus_sha256": "3ccc62bb28e3c26eaa15e272668fae5b24d6c0465d2a7e19733109815a32edb3"
|
| 12 |
}
|
|
@@ -17,7 +17,7 @@
|
|
| 17 |
"raw SQL"
|
| 18 |
],
|
| 19 |
"wrong": "@api.onchange('qty')\ndef _onchange_qty(self):\n if self.qty <= 0:\n raise ValidationError('Quantity must be positive')",
|
| 20 |
-
"correct": "_qty_positive = models.Constraint(\"CHECK(qty > 0)\", \"Quantity must be positive.\")\n\n# Odoo 16 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]",
|
| 21 |
"tags": [
|
| 22 |
"onchange",
|
| 23 |
"constraints",
|
|
@@ -133,12 +133,12 @@
|
|
| 133 |
"odoo_versions": ">=16.0",
|
| 134 |
"severity": "high",
|
| 135 |
"title": "Prefer a database constraint when the rule fits in SQL",
|
| 136 |
-
"rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint
|
| 137 |
"why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
|
| 138 |
"enforcement_layer": "database",
|
| 139 |
"bypassed_by": [],
|
| 140 |
"wrong": "@api.constrains('code')\ndef _check_unique_code(self):\n for rec in self:\n if self.search_count([('code', '=', rec.code), ('id', '!=', rec.id)]):\n raise ValidationError('Code must be unique')",
|
| 141 |
-
"correct": "_code_uniq = models.Constraint(\"UNIQUE(code)\", \"Code must be unique.\")\n\n# Odoo 16 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]",
|
| 142 |
"tags": [
|
| 143 |
"sql-constraints",
|
| 144 |
"uniqueness",
|
|
@@ -159,6 +159,33 @@
|
|
| 159 |
"advisory": true,
|
| 160 |
"probe": "should this check be a database constraint or an api.constrains method"
|
| 161 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
{
|
| 163 |
"id": "odoo.check-constraint-cannot-cross-tables",
|
| 164 |
"odoo_versions": ">=16.0",
|
|
|
|
| 17 |
"raw SQL"
|
| 18 |
],
|
| 19 |
"wrong": "@api.onchange('qty')\ndef _onchange_qty(self):\n if self.qty <= 0:\n raise ValidationError('Quantity must be positive')",
|
| 20 |
+
"correct": "_qty_positive = models.Constraint(\"CHECK(qty > 0)\", \"Quantity must be positive.\")\n\n# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]",
|
| 21 |
"tags": [
|
| 22 |
"onchange",
|
| 23 |
"constraints",
|
|
|
|
| 133 |
"odoo_versions": ">=16.0",
|
| 134 |
"severity": "high",
|
| 135 |
"title": "Prefer a database constraint when the rule fits in SQL",
|
| 136 |
+
"rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint is the only spelling that works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16 to 18, _sql_constraints is the only spelling: 18.0 has no models.Constraint. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.",
|
| 137 |
"why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
|
| 138 |
"enforcement_layer": "database",
|
| 139 |
"bypassed_by": [],
|
| 140 |
"wrong": "@api.constrains('code')\ndef _check_unique_code(self):\n for rec in self:\n if self.search_count([('code', '=', rec.code), ('id', '!=', rec.id)]):\n raise ValidationError('Code must be unique')",
|
| 141 |
+
"correct": "_code_uniq = models.Constraint(\"UNIQUE(code)\", \"Code must be unique.\")\n\n# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]",
|
| 142 |
"tags": [
|
| 143 |
"sql-constraints",
|
| 144 |
"uniqueness",
|
|
|
|
| 159 |
"advisory": true,
|
| 160 |
"probe": "should this check be a database constraint or an api.constrains method"
|
| 161 |
},
|
| 162 |
+
{
|
| 163 |
+
"id": "odoo.sql-constraints-are-ignored-from-19",
|
| 164 |
+
"odoo_versions": ">=19.0",
|
| 165 |
+
"odoo_versions_basis": "Measured on the trees, not read off a page. Odoo 18.0 core (3c3e3b3) declares _sql_constraints in 174 files and has no models.Constraint anywhere, so the attribute is the only spelling up to 18. OpenSPP on 19.0 declares models.Constraint in 72 files, and its 208d9758 (#394) records that 19 logs a warning and creates nothing for _sql_constraints. The ORM reference agrees: the 19.0 page declares Constraint, Index and UniqueIndex under 'Constraints and indexes' and never names _sql_constraints, and the 18.0 page has no such section. The corpus is checked against 18.0; this is the one rule whose subject starts after it.",
|
| 166 |
+
"severity": "high",
|
| 167 |
+
"title": "_sql_constraints is ignored from Odoo 19",
|
| 168 |
+
"rule": "On a module for Odoo 19 or later, declare every UNIQUE and CHECK as a models.Constraint class attribute. A _sql_constraints list is not read at 19: the registry logs one warning at load and creates nothing. When moving one over, remove the duplicates already stored first, because a constraint that fails to apply during an upgrade is downgraded to a schema warning and stays missing.",
|
| 169 |
+
"why": "The constraint everybody reads in the class does not exist in the database, so the duplicates or out-of-range values it was written to refuse are saved without a word. The only trace is a warning in a log at every registry load. OpenSPP shipped exactly this on spp.cr.type.reason.document from 19.0.3.0.0, duplicate rules accumulated, and nothing in CI caught it.",
|
| 170 |
+
"enforcement_layer": "database",
|
| 171 |
+
"bypassed_by": [],
|
| 172 |
+
"wrong": "class ReasonDocument(models.Model):\n _name = 'spp.cr.type.reason.document'\n\n _sql_constraints = [\n ('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),\n ]",
|
| 173 |
+
"correct": "class ReasonDocument(models.Model):\n _name = 'spp.cr.type.reason.document'\n\n _reason_uniq = models.Constraint('UNIQUE(cr_type_id, reason)', 'One rule per reason.')",
|
| 174 |
+
"tags": [
|
| 175 |
+
"sql-constraints",
|
| 176 |
+
"migration",
|
| 177 |
+
"odoo-19",
|
| 178 |
+
"uniqueness",
|
| 179 |
+
"check-constraint",
|
| 180 |
+
"silent-failure"
|
| 181 |
+
],
|
| 182 |
+
"stack": "odoo",
|
| 183 |
+
"semantic_check": "legacy_sql_constraints_on_19",
|
| 184 |
+
"source": "https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html",
|
| 185 |
+
"likelihood": "defect",
|
| 186 |
+
"advisory": false,
|
| 187 |
+
"probe": "after upgrading to Odoo 19 my unique constraint is not enforced and duplicates are saved"
|
| 188 |
+
},
|
| 189 |
{
|
| 190 |
"id": "odoo.check-constraint-cannot-cross-tables",
|
| 191 |
"odoo_versions": ">=16.0",
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-26T21:
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"server-tools",
|
|
@@ -13,7 +13,7 @@
|
|
| 13 |
"lines": 33960,
|
| 14 |
"xml_files": 223,
|
| 15 |
"xml_lines": 12853,
|
| 16 |
-
"checkers":
|
| 17 |
"triggers": {
|
| 18 |
"files": 879,
|
| 19 |
"models_declared": 139,
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_utc": "2026-09-26T21:57:36+00:00",
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"server-tools",
|
|
|
|
| 13 |
"lines": 33960,
|
| 14 |
"xml_files": 223,
|
| 15 |
"xml_lines": 12853,
|
| 16 |
+
"checkers": 27,
|
| 17 |
"triggers": {
|
| 18 |
"files": 879,
|
| 19 |
"models_declared": 139,
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"odoo"
|
|
@@ -11,7 +11,7 @@
|
|
| 11 |
"lines": 559663,
|
| 12 |
"xml_files": 3119,
|
| 13 |
"xml_lines": 322608,
|
| 14 |
-
"checkers":
|
| 15 |
"triggers": {
|
| 16 |
"files": 10252,
|
| 17 |
"models_declared": 1269,
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_utc": "2026-09-26T22:02:53+00:00",
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"odoo"
|
|
|
|
| 11 |
"lines": 559663,
|
| 12 |
"xml_files": 3119,
|
| 13 |
"xml_lines": 322608,
|
| 14 |
+
"checkers": 27,
|
| 15 |
"triggers": {
|
| 16 |
"files": 10252,
|
| 17 |
"models_declared": 1269,
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-26T21:
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"OpenSPP2-19.0"
|
|
@@ -11,7 +11,7 @@
|
|
| 11 |
"lines": 226937,
|
| 12 |
"xml_files": 793,
|
| 13 |
"xml_lines": 118020,
|
| 14 |
-
"checkers":
|
| 15 |
"triggers": {
|
| 16 |
"files": 2265,
|
| 17 |
"models_declared": 492,
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_utc": "2026-09-26T21:59:50+00:00",
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"OpenSPP2-19.0"
|
|
|
|
| 11 |
"lines": 226937,
|
| 12 |
"xml_files": 793,
|
| 13 |
"xml_lines": 118020,
|
| 14 |
+
"checkers": 27,
|
| 15 |
"triggers": {
|
| 16 |
"files": 2265,
|
| 17 |
"models_declared": 492,
|
|
@@ -103,7 +103,7 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
|
|
| 103 |
Correct:
|
| 104 |
_qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
|
| 105 |
|
| 106 |
-
# Odoo 16 only. From 19 this attribute is ignored and creates nothing:
|
| 107 |
# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]
|
| 108 |
|
| 109 |
[odoo.schema-change-needs-migration] (critical) Schema changes need a migration script
|
|
@@ -290,7 +290,7 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
|
|
| 290 |
rec.label = rec.order_id.name
|
| 291 |
|
| 292 |
[odoo.prefer-sql-constraint] (high) Prefer a database constraint when the rule fits in SQL
|
| 293 |
-
Rule: If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint
|
| 294 |
Why: A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.
|
| 295 |
Wrong:
|
| 296 |
@api.constrains('code')
|
|
@@ -301,7 +301,7 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
|
|
| 301 |
Correct:
|
| 302 |
_code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
|
| 303 |
|
| 304 |
-
# Odoo 16 only. From 19 this attribute is ignored and creates nothing:
|
| 305 |
# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]
|
| 306 |
|
| 307 |
[odoo.recursion-check-on-hierarchy] (high) parent_id hierarchies need a recursion check
|
|
@@ -317,6 +317,22 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
|
|
| 317 |
if self._has_cycle():
|
| 318 |
raise ValidationError('You cannot create recursive hierarchies.')
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
[python.bare-except-catches-the-exit] (high) A bare except swallows Ctrl-C and sys.exit too
|
| 321 |
Rule: Catch the exceptions you can actually handle. Never write a bare except, and use except Exception only at a boundary that logs and re-raises.
|
| 322 |
Why: A bare except catches KeyboardInterrupt and SystemExit as well, so Ctrl-C stops working and sys.exit() is ignored. It also hides a typo exactly as readily as the error it was written for, and the failure then surfaces much later as wrong data rather than as a traceback pointing at its cause.
|
|
@@ -410,13 +426,25 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
|
|
| 410 |
Rule: Inside the ORM, datetimes are naive UTC: Odoo pins the server process to UTC, so datetime.now() and fields.Datetime.now() are the same value, and an aware datetime written to a Datetime field raises ValueError. The defects are at the edges. A timestamp leaving the system -- an API response, a token, a file for another system -- carries its offset: datetime.now(timezone.utc). A value parsed with an offset is converted to naive UTC before it meets the ORM or a naive value. And a date meant as a user's or company's calendar day comes from fields.Date.context_today, not the server's UTC date.
|
| 411 |
Why: Comparing a naive datetime with an aware one raises TypeError, so the two meet for the first time in whichever branch is exercised least -- usually expiry, usually in production. A naive timestamp handed to another system cannot say which zone it is in. And the server's date is UTC's date: for a company ahead of UTC, anything dated 'today' in its early hours is dated yesterday, which at month end is the previous period.
|
| 412 |
Wrong:
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
Correct:
|
| 416 |
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
-
|
| 419 |
-
|
|
|
|
| 420 |
|
| 421 |
|
| 422 |
=== HOW TO ANSWER ===
|
|
|
|
| 103 |
Correct:
|
| 104 |
_qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
|
| 105 |
|
| 106 |
+
# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
|
| 107 |
# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]
|
| 108 |
|
| 109 |
[odoo.schema-change-needs-migration] (critical) Schema changes need a migration script
|
|
|
|
| 290 |
rec.label = rec.order_id.name
|
| 291 |
|
| 292 |
[odoo.prefer-sql-constraint] (high) Prefer a database constraint when the rule fits in SQL
|
| 293 |
+
Rule: If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint is the only spelling that works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16 to 18, _sql_constraints is the only spelling: 18.0 has no models.Constraint. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.
|
| 294 |
Why: A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.
|
| 295 |
Wrong:
|
| 296 |
@api.constrains('code')
|
|
|
|
| 301 |
Correct:
|
| 302 |
_code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
|
| 303 |
|
| 304 |
+
# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
|
| 305 |
# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]
|
| 306 |
|
| 307 |
[odoo.recursion-check-on-hierarchy] (high) parent_id hierarchies need a recursion check
|
|
|
|
| 317 |
if self._has_cycle():
|
| 318 |
raise ValidationError('You cannot create recursive hierarchies.')
|
| 319 |
|
| 320 |
+
[odoo.sql-constraints-are-ignored-from-19] (high) _sql_constraints is ignored from Odoo 19
|
| 321 |
+
Rule: On a module for Odoo 19 or later, declare every UNIQUE and CHECK as a models.Constraint class attribute. A _sql_constraints list is not read at 19: the registry logs one warning at load and creates nothing. When moving one over, remove the duplicates already stored first, because a constraint that fails to apply during an upgrade is downgraded to a schema warning and stays missing.
|
| 322 |
+
Why: The constraint everybody reads in the class does not exist in the database, so the duplicates or out-of-range values it was written to refuse are saved without a word. The only trace is a warning in a log at every registry load. OpenSPP shipped exactly this on spp.cr.type.reason.document from 19.0.3.0.0, duplicate rules accumulated, and nothing in CI caught it.
|
| 323 |
+
Wrong:
|
| 324 |
+
class ReasonDocument(models.Model):
|
| 325 |
+
_name = 'spp.cr.type.reason.document'
|
| 326 |
+
|
| 327 |
+
_sql_constraints = [
|
| 328 |
+
('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),
|
| 329 |
+
]
|
| 330 |
+
Correct:
|
| 331 |
+
class ReasonDocument(models.Model):
|
| 332 |
+
_name = 'spp.cr.type.reason.document'
|
| 333 |
+
|
| 334 |
+
_reason_uniq = models.Constraint('UNIQUE(cr_type_id, reason)', 'One rule per reason.')
|
| 335 |
+
|
| 336 |
[python.bare-except-catches-the-exit] (high) A bare except swallows Ctrl-C and sys.exit too
|
| 337 |
Rule: Catch the exceptions you can actually handle. Never write a bare except, and use except Exception only at a boundary that logs and re-raises.
|
| 338 |
Why: A bare except catches KeyboardInterrupt and SystemExit as well, so Ctrl-C stops working and sys.exit() is ignored. It also hides a typo exactly as readily as the error it was written for, and the failure then surfaces much later as wrong data rather than as a traceback pointing at its cause.
|
|
|
|
| 426 |
Rule: Inside the ORM, datetimes are naive UTC: Odoo pins the server process to UTC, so datetime.now() and fields.Datetime.now() are the same value, and an aware datetime written to a Datetime field raises ValueError. The defects are at the edges. A timestamp leaving the system -- an API response, a token, a file for another system -- carries its offset: datetime.now(timezone.utc). A value parsed with an offset is converted to naive UTC before it meets the ORM or a naive value. And a date meant as a user's or company's calendar day comes from fields.Date.context_today, not the server's UTC date.
|
| 427 |
Why: Comparing a naive datetime with an aware one raises TypeError, so the two meet for the first time in whichever branch is exercised least -- usually expiry, usually in production. A naive timestamp handed to another system cannot say which zone it is in. And the server's date is UTC's date: for a company ahead of UTC, anything dated 'today' in its early hours is dated yesterday, which at month end is the previous period.
|
| 428 |
Wrong:
|
| 429 |
+
from datetime import datetime
|
| 430 |
+
from fastapi import APIRouter
|
| 431 |
+
|
| 432 |
+
router = APIRouter()
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@router.delete("/consent/{consent_id}")
|
| 436 |
+
def revoke(consent_id: str):
|
| 437 |
+
return {"consent_id": consent_id, "revoked_at": datetime.utcnow().isoformat()}
|
| 438 |
Correct:
|
| 439 |
from datetime import datetime, timezone
|
| 440 |
+
from fastapi import APIRouter
|
| 441 |
+
|
| 442 |
+
router = APIRouter()
|
| 443 |
+
|
| 444 |
|
| 445 |
+
@router.delete("/consent/{consent_id}")
|
| 446 |
+
def revoke(consent_id: str):
|
| 447 |
+
return {"consent_id": consent_id, "revoked_at": datetime.now(timezone.utc).isoformat()}
|
| 448 |
|
| 449 |
|
| 450 |
=== HOW TO ANSWER ===
|
|
@@ -131,13 +131,27 @@ def test_context_mismatched_series_is_a_gap_and_not_a_finding(odoo_rules):
|
|
| 131 |
rules carries an odoo_versions field, so the blanket warning was standing in
|
| 132 |
for per-rule data that does not exist yet.
|
| 133 |
"""
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
| 136 |
assert result.findings == []
|
| 137 |
assert any("17.0" in why and "18.0" in why for _, why in result.not_checked)
|
| 138 |
# And it is not UNKNOWN either, because every rule now declares the series it
|
| 139 |
# holds for, so there is a question here this layer can actually answer.
|
| 140 |
assert result.verdict == layers.PASS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
assert any("odoo_versions" in c for c in result.checked)
|
| 142 |
|
| 143 |
|
|
|
|
| 131 |
rules carries an odoo_versions field, so the blanket warning was standing in
|
| 132 |
for per-rule data that does not exist yet.
|
| 133 |
"""
|
| 134 |
+
applying = [r for r in odoo_rules
|
| 135 |
+
if layers._version_applies(r.get("odoo_versions") or ">=16.0", "17.0")]
|
| 136 |
+
result = layers.layer_context(applying, _ctx(odoo_series="17.0",
|
| 137 |
+
manifest="__manifest__.py"))
|
| 138 |
assert result.findings == []
|
| 139 |
assert any("17.0" in why and "18.0" in why for _, why in result.not_checked)
|
| 140 |
# And it is not UNKNOWN either, because every rule now declares the series it
|
| 141 |
# holds for, so there is a question here this layer can actually answer.
|
| 142 |
assert result.verdict == layers.PASS
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_a_retrieved_rule_for_a_later_series_is_a_finding(odoo_rules):
|
| 146 |
+
"""The per-rule check, exercised by a real rule for the first time: the
|
| 147 |
+
_sql_constraints rule holds from 19, and grounding a 17.0 module with it
|
| 148 |
+
would tell the model to use a spelling 17.0 does not have."""
|
| 149 |
+
later = [r for r in odoo_rules if r["id"] == "odoo.sql-constraints-are-ignored-from-19"]
|
| 150 |
+
result = layers.layer_context(later, _ctx(odoo_series="17.0",
|
| 151 |
+
manifest="__manifest__.py"))
|
| 152 |
+
assert [f.rule for f in result.findings] == ["odoo.sql-constraints-are-ignored-from-19"]
|
| 153 |
+
assert layers.layer_context(later, _ctx(odoo_series="19.0",
|
| 154 |
+
manifest="__manifest__.py")).findings == []
|
| 155 |
assert any("odoo_versions" in c for c in result.checked)
|
| 156 |
|
| 157 |
|
|
@@ -1659,3 +1659,48 @@ def test_datetime_reports_the_two_shapes_that_survived(why, body):
|
|
| 1659 |
])
|
| 1660 |
def test_datetime_leaves_the_shapes_that_did_not(why, body):
|
| 1661 |
assert semantics.run_check("naive_time_leaves_or_dates_a_day", body) == [], why
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1659 |
])
|
| 1660 |
def test_datetime_leaves_the_shapes_that_did_not(why, body):
|
| 1661 |
assert semantics.run_check("naive_time_leaves_or_dates_a_day", body) == [], why
|
| 1662 |
+
|
| 1663 |
+
|
| 1664 |
+
_LEGACY = ("from odoo import models\n\n\n"
|
| 1665 |
+
"class ReasonDocument(models.Model):\n"
|
| 1666 |
+
" _name = 'spp.cr.type.reason.document'\n\n"
|
| 1667 |
+
" _sql_constraints = [\n"
|
| 1668 |
+
" ('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),\n"
|
| 1669 |
+
" ]\n")
|
| 1670 |
+
|
| 1671 |
+
|
| 1672 |
+
def test_sql_constraints_on_19_are_a_constraint_that_does_not_exist():
|
| 1673 |
+
"""OpenSPP's 208d9758: the block sat on a 19.0 module and created nothing."""
|
| 1674 |
+
found = semantics.run_check("legacy_sql_constraints_on_19", _LEGACY,
|
| 1675 |
+
facts={"walked": True, "odoo_series": "19.0"})
|
| 1676 |
+
assert [(line, grade) for line, _, grade in found] == [(7, None)]
|
| 1677 |
+
|
| 1678 |
+
|
| 1679 |
+
def test_sql_constraints_before_19_are_the_only_spelling():
|
| 1680 |
+
"""18.0 core declares the attribute in 174 files and has no
|
| 1681 |
+
models.Constraint, so a finding there is advice that cannot be taken."""
|
| 1682 |
+
for series in ("16.0", "17.0", "18.0"):
|
| 1683 |
+
assert semantics.run_check("legacy_sql_constraints_on_19", _LEGACY,
|
| 1684 |
+
facts={"walked": True, "odoo_series": series}) == []
|
| 1685 |
+
|
| 1686 |
+
|
| 1687 |
+
def test_sql_constraints_series_unknown_after_a_walk_is_silent():
|
| 1688 |
+
"""odoo core's own manifests pin no series; 174 findings there would all
|
| 1689 |
+
be wrong."""
|
| 1690 |
+
assert semantics.run_check("legacy_sql_constraints_on_19", _LEGACY,
|
| 1691 |
+
facts={"walked": True, "odoo_series": None}) == []
|
| 1692 |
+
|
| 1693 |
+
|
| 1694 |
+
def test_sql_constraints_with_nothing_walked_is_advisory():
|
| 1695 |
+
"""Nobody looked is not the same answer as looked and it does not say."""
|
| 1696 |
+
found = semantics.run_check("legacy_sql_constraints_on_19", _LEGACY)
|
| 1697 |
+
assert [grade for _, _, grade in found] == [semantics.UNPROVEN]
|
| 1698 |
+
|
| 1699 |
+
|
| 1700 |
+
def test_an_empty_sql_constraints_list_declares_nothing():
|
| 1701 |
+
body = ("from odoo import models\n\n\n"
|
| 1702 |
+
"class Thing(models.Model):\n"
|
| 1703 |
+
" _name = 'my.thing'\n"
|
| 1704 |
+
" _sql_constraints = []\n")
|
| 1705 |
+
assert semantics.run_check("legacy_sql_constraints_on_19", body,
|
| 1706 |
+
facts={"walked": True, "odoo_series": "19.0"}) == []
|