Skip to content

[16.0][FIX] connector_sapb1: enforce SAP Street length in the partner adapter - #939

Open
eantones wants to merge 9 commits into
16.0from
16.0-fix-connector_sapb1-street_length_adapter
Open

[16.0][FIX] connector_sapb1: enforce SAP Street length in the partner adapter#939
eantones wants to merge 9 commits into
16.0from
16.0-fix-connector_sapb1-street_length_adapter

Conversation

@eantones

@eantones eantones commented Jul 1, 2026

Copy link
Copy Markdown
Member

Summary

SAP B1 rejects a whole document export with Value too long for column 'Street' when the concatenated street + street2 exceeds CRD1.Street (NVARCHAR(100)). #937 capped the value in the export mapper; this PR moves that concern to its proper layer, removes a dead field, and makes the partner address matching this uncovers actually work. Nine commits, each incremental:

  1. Revert of the mapper-level truncation ([16.0][FIX] connector_sapb1: truncate Street to SAP 100-char limit #937), in git's default revert format: column sizes are a backend schema constraint, not a mapping concern — the adapter is the facade that knows the backend database.
  2. [IMP] connector_extension: add _convert_format_domain_values(), the domain-side counterpart of _convert_format(): it applies a per-field converter mapping per clause (element-wise on in lists) and nothing else. Backend type formatting stays in _convert_format_domain() — one concern per function.
  3. [FIX] connector_sapb1: _format_partner_domain() passed its converter mapping as a second positional argument to _convert_format_domain(), which accepts none: any execution raised TypeError (latent, unreachable until now — see below). Compose the two base helpers instead: format the domain types, then apply the partner converters.
  4. [FIX] connector_sapb1: _get_partner() performed the request and parsed the response but fell off the end without returning it — every consumer needing the payload dereferences None. Latent for the same reason as the other two fixes; the revived matching reads the partner's addresses from it. Return the parsed partner.
  5. [REM] connector_sapb1: drop Block from the partner adapter converters and the binder external_alt_id. The export mapper never produces Block, so the alternate-key resolution always contained a null component and aborted: partner alternate-key matching has never executed — every unbound partner address went straight to create. Measured on a production dataset (19 mapped partner accounts, 7,515 address rows): distinct match keys with vs. without Block are identical (7,019 = 7,019), so Block adds zero discrimination; 6.6% of rows (496) are duplicates accumulated precisely because matching never ran.
  6. [FIX] connector_sapb1: enforce the 100-char cap in the partner adapter on all Street paths: create values, search domain (so lookups compare exactly what create writes), and write(), which previously bypassed value normalization entirely.
  7. [FIX] connector_sapb1: normalize the rows fetched by search_read with the same value converters used for writing. The lookup domain converts empty values to None while fetched rows kept empty strings, so the two sides of the comparison followed different conventions and an address with any empty component could never match — latent for the same reason as the TypeError above.
  8. [IMP] connector_extension: per-field null semantics for the alternate id (external_alt_id_nullable_fields) — a null on a declared-nullable component is a legitimate identity value (an address without zip stores NULL, so NULL is what an identical existing record contains); a null on a mandatory component raises InvalidDataError (a record with incomplete identity can neither be matched now nor ever be matched later — silently creating it would plant a permanently unmatchable row; the all-null key is the extreme case of the same rule); a component missing from the mapper output raises ValidationError (binder/mapper configuration error that silently disables matching for the whole model — exactly how the Block issue stayed invisible for years). At least one component must remain mandatory and nullable declarations must belong to the key, both enforced.
  9. [IMP] connector_sapb1: partner alternate id declares AddressName3, Street, ZipCode, City nullable; CardCode and AddressName2 stay mandatory.

Review notes

  1. Alternate-key matching for partner addresses becomes live for the first time: repeat addresses now link to the existing backend row instead of duplicating it, including addresses with empty nullable fields.

  2. Where identical duplicates already exist in the backend (the 496 legacy rows), the lookup raises "the alternate external id ... is not unique" and the job fails visibly — consistent with how every other backend anomaly surfaces (e.g. a locked posting period). A one-off backend dedup of those legacy rows ends these failures permanently; the exact row list can be extracted on request.

  3. Since this path has never run in production, a functional pass on a test database before deploying is recommended.

  4. If a deployment extends the partner export mapper to produce Block, dropping it from external_alt_id changes that deployment's match key; no in-tree module does.

  5. Deployment dependency: this PR must run together with [16.0][FIX] connector_sapb1: enable allow_commit on export_record job funct… #895 (already aggregated in production via refs/pull/895/head): the revived matching path persists bindings through bind_export(), whose eager commit needs [16.0][FIX] connector_sapb1: enable allow_commit on export_record job funct… #895's allow_commit job functions plus its cursor-rebind of job args. Without [16.0][FIX] connector_sapb1: enable allow_commit on export_record job funct… #895, every match ends in "Commit is forbidden in queue jobs".

Test plan

  • pre-commit passes locally on every commit
  • CI green
  • manual test on a dedicated test database: export a partner with a >100-char street (create); repeat an identical address (match instead of duplicate); an address with empty zip/email (match on NULL); a legacy-duplicated address (job fails with "not unique")

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 13.51351% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 47.21%. Comparing base (ef59229) to head (c6f7eee).

Files with missing lines Patch % Lines
connector_extension/components/binder.py 11.76% 15 Missing ⚠️
connector_extension/components/adapter.py 9.09% 10 Missing ⚠️
connector_sapb1/models/res_partner/adapter.py 16.66% 5 Missing ⚠️
connector_sapb1/components/adapter.py 0.00% 1 Missing ⚠️
...onnector_sapb1/models/res_partner/export_mapper.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             16.0     #939      +/-   ##
==========================================
- Coverage   47.35%   47.21%   -0.15%     
==========================================
  Files         335      335              
  Lines        6799     6830      +31     
  Branches     1034     1042       +8     
==========================================
+ Hits         3220     3225       +5     
- Misses       3544     3570      +26     
  Partials       35       35              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@eantones
eantones force-pushed the 16.0-fix-connector_sapb1-street_length_adapter branch 3 times, most recently from 9107a1c to 2f8da6d Compare July 2, 2026 07:36
eantones added 2 commits July 2, 2026 09:49
_convert_format() applies a per-field converter mapping to record
values, but there was no equivalent for search domains: a model
adapter that needs its domain values normalized the same way as its
written values had nowhere to do it without walking the domain itself.

Add _convert_format_domain_values(), the domain-side counterpart of
_convert_format(): it applies the converter mapping per clause by
field name, element-wise on list/tuple values, and nothing else.
Backend type formatting stays in _convert_format_domain(), one
concern per function.
_format_partner_domain() passed its converter mapping as a second
positional argument to _convert_format_domain(), which accepts none:
any execution raised "TypeError: _convert_format_domain() takes 2
positional arguments but 3 were given". The bug never surfaced
because its only consumer, the alternate-key matching of partner
addresses, aborted before reaching it (see the Block removal).

Compose the two base helpers instead: format the domain types first,
then apply the partner converters with
_convert_format_domain_values().
@eantones
eantones force-pushed the 16.0-fix-connector_sapb1-street_length_adapter branch 3 times, most recently from b7f310e to 83ad31c Compare July 2, 2026 08:49
eantones added 4 commits July 2, 2026 12:12
_get_partner() performs the request, parses the JSON and validates
the error cases, but falls off the end without returning the parsed
result, so every consumer that needs the payload dereferences None
and crashes with "'NoneType' object is not subscriptable".

The alternate-key matching of partner addresses reads the partner's
BPAddresses from this method: without the return, the first lookup
crashes as soon as the matching runs (see the Block removal). Return
the parsed partner, as the other _get_* methods do.
The Block address field was referenced in three places but never used:

* no export mapping produces it, so exported addresses never carry it;
* the /Block value converter (create) and the Block domain converter
  (search) in the partner adapter therefore never fire;
* the partner binder included Block in external_alt_id, the alternate
  match key.

Keeping Block in external_alt_id was not harmless. The alternate-key
lookup resolves the key from the export mapper output, and a component
missing from that output makes the resolved key null, which aborts the
lookup (is_id_null guard). As the mapper never produces Block, the
alternate-key matching of partner addresses never executed: every
unbound partner went straight to create. This is observable in a
production database as duplicated identical address rows: 496 of 7,515
address rows (6.6%) across the 19 mapped partner accounts.

Measured on that same dataset, the number of distinct match keys is
identical with and without Block (7,019 = 7,019): Block adds zero
discrimination, so dropping it cannot alter any match outcome. The
only rows with Block populated (203, all entered manually in SAP) are
already distinct by the remaining key fields.

Besides removing dead entries, this re-enables the intended
alternate-key matching for partners whose key is complete: repeat
addresses now link to the existing backend row instead of duplicating
it. Where identical duplicates already exist in the backend, the
lookup now raises "the alternate external id is not unique" and the
job fails visibly instead of silently adding another duplicate.

Note: if a deployment extends the partner export mapper to produce
Block, dropping it from external_alt_id changes that deployment's
match key; no in-tree module does.
SAP B1 stores business partner address streets in CRD1.Street, an
NVARCHAR(100) column; exporting a longer value makes the backend
reject the whole document with "Value too long for column 'Street'".
Odoo has no such limit, and the mapper deliberately concatenates
street and street2 into Street (the Block field does not reach printed
documents), so overflow is possible on any long address.

Apply the 100-character cap in the partner adapter on every path that
carries a Street value to or against the backend:

* _format_partner_values (create), so written values always fit;
* _format_partner_domain (search), so alternate-key lookups compare
  exactly what create writes; otherwise an over-long street would
  never match its stored, truncated row and every repeat order would
  create a duplicate address;
* write(), which previously bypassed _format_partner_values entirely,
  now normalizes values like create() does, so updates get the same
  falsy-to-None normalization and length cap.

Truncation is lossy for streets over 100 characters by design: a
slightly clipped address that reaches the backend beats a document
that cannot be exported at all. All 51,806 existing CRD1 address rows
in the reference production schema are already <= 100 characters, so
the cap changes nothing for previously synced data.
…lues

The partner address lookup compared its two sides with different
conventions: the lookup domain converts empty values to None
(_format_partner_domain), while the rows fetched from the backend kept
empty strings in empty components. The comparison is strict, so an
address with any empty component could never match its stored
counterpart.

The defect never surfaced because the only consumer of this comparison,
the alternate-key matching of partner addresses, never ran (see the
Block removal). Apply the same value converters used for writing
(_format_partner_values) to each fetched row, so both sides of the
comparison follow one convention.
@eantones
eantones force-pushed the 16.0-fix-connector_sapb1-street_length_adapter branch from 83ad31c to fcda448 Compare July 2, 2026 10:12
eantones added 2 commits July 2, 2026 13:21
…ookup

An ID cannot contain nulls by definition: a null component means the
record has no identity, and that is exactly what is_id_null() checks.
An alternate key is a different kind of identifier: a natural key
built from business data, where a null component can be a legitimate
part of the identity (an address without zip stores NULL, and an
identical existing address is found by matching on that NULL). The
lookup applied the ID contract to the alternate key, aborting on any
null component, so records with legitimately empty fields were never
matched and were re-created instead.

Give the alternate key its own contract:
external_alt_id_nullable_fields declares, per component, where null
is a value (nullable) and where it is absence of identity (mandatory,
the default). _check_external_alt_id() replaces the is_id_null() call
and enforces it:

* a null on a nullable component participates in the lookup as a
  value to match on;
* a null on a mandatory component raises InvalidDataError: the record
  can neither be matched now nor ever be matched later, so creating
  it would plant a permanently unmatchable row (a future duplicate);
* a component missing from the mapper output raises ValidationError:
  a binder/mapper configuration error that silently disables matching
  for the whole model, exactly how an unmapped key field went
  unnoticed for years;
* declared nullable fields must belong to the key and at least one
  component must stay mandatory, so a single-component alternate id
  is always mandatory.

In-tree export mappers already validate the mandatory fields
themselves, so these checks act as a safety net with precise messages
rather than a behavior change for healthy data.

The old TODO asking to move this block into a hook is fulfilled by the
dedicated overridable method and removed.

A nullable null travels through the lookup domain as a None value, so
_convert_format_domain() now passes None through, exactly as
_convert_format() already does for record values; it had never
received one because the old lookup aborted before building a domain.
CardCode and AddressName2 identify the account and the addressee and
remain mandatory: a lookup without them would be meaningless.
AddressName3, Street, ZipCode and City describe the address and are
declared nullable: an address without email, street, zip or city
stores NULL in those columns, and an identical existing address must
match on those NULLs instead of being re-created.
@eantones
eantones force-pushed the 16.0-fix-connector_sapb1-street_length_adapter branch from fcda448 to c6f7eee Compare July 2, 2026 11:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant