Odoo Schema

Schema (ip.schema)

The Odoo Schema (ip.schema.odoo_schema field on ip.schema) is a JSON object that maps Odoo field names to JSON paths and defines the full import/export behaviour — relational resolution, upsert logic, routing, and binary blob handling. It is evaluated by the mapping engine on every event via ip.schema._to_payload() (export) and ip.schema._to_records() (import), and translated into ORM calls.

See also

Data Schema — JSON Schema validation layer. Events — mapping states (pending, waiting, mapped, failed) driven by this engine. sync_base.ip.schema — technical reference.


Overview

{
  "__root__":  { "<...>" },
  "__nodes__": "dot.path.to.ip_node",
  "odoo_field_name": "json.path",
  "odoo_field_name": { "__json__": "json.path", "<operator>": "<value>" }
}

Top-level keys must be exact Odoo field names (no dots). Keys starting with __ are reserved metadata directives.


Scalar Mapping

Short form — JSON path string:

{ "name": "partner.name" }

Long form — descriptor object (required when adding operators or traits):

{ "name": { "__json__": "partner.name" } }

The JSON path supports:

  • Dot notation for nested objects: "content.body"

  • Array wildcard [*] for array items: "tags[*].label"


Metadata Directives

__root__

The upsert key — without it every inbound event creates a new record.

"__root__": { "match": { "odoo_ref": "json_ref" }, "on_miss": "create" }

Key

Description

match

{"odoo.field": "json.path"} — one or more lookup keys used to find the existing record. Supports dot-notation on the Odoo side. Values can also be descriptor objects with __json__ and traits.

on_miss

"create" (default) | "fail" | "ignore" — what to do when no match is found.

__nodes__

Dot-path to ip.node records. Creates one outbound event per resolved node, enabling direct peer-to-peer routing.

"__nodes__": "channel_id.member_ids.partner_id.peer_id.host_node_id"

Note

Dynamic routing override that supersedes the static topic configuration. See Delivery for the static routing_mode setting on the topic.


Relational Field Operators

Relational fields (Many2one, One2many, Many2many) use a descriptor object. match is always required; all other operators are optional.

Operator

Type

Description

match

dict

{"odoo.path": "json.path"} — lookup key(s) to resolve the relation. Supports dot-notation for traversal on the Odoo side. Required for all relational fields.

on_miss

string

"fail" (default) | "ignore" | "create" | "clear" | "default" — what to do when no match is found.

command

string

x2many write strategy: "link" (Command 4, append, default) | "set" (Command 6, replace all).

default

dict | int

Field values written only on create (when on_miss = "create"), or a fallback record ID when on_miss = "default". Values in a default dict can be strings (JSON paths), {"__literal__": v}, or a nested {"match": {...}, "on_miss": "ignore"} for relational resolution.

fields

dict

Field values written on every import (create and update). Same value syntax as default.

for

dict

Scopes command: set to a sub-domain. "__from__" resolves to the event’s source node at runtime.


Intra-Batch Deferral

When resolving relational fields (like Many2one), the engine must typically look up an existing record’s Integer ID. However, when processing flat arrays of cross-referenced JSON objects, a child record might reference a parent record that is being created in the exact same batch.

To solve this “ID Vacuum”, the DataLoader implements an Intra-Batch Deferral Mechanism:

  1. Deferral (Pass 3): When a Many2one (or non-create x2many) lookup misses in the initial cache pass, the engine packages the unresolved lookup keys and on_miss policy into a deferred queue rather than failing or creating stubs prematurely.

  2. Root Execution: The engine bulk-creates all new root records in the batch and immediately injects their newly minted Integer IDs into the resolution cache.

  3. Retroactive Resolution (Pass 4): The engine re-evaluates all deferred lookups against the updated cache: - If the target record was created in the same batch, it links the newly minted ID via the batch write() queue. - If the target is still missing after root creation, the configured on_miss policy executes: "ignore" skips it, "create" mints a single deduplicated stub record (and caches it for subsequent items in the batch), "clear" sets False, "default" assigns the configured fallback ID, and "fail" raises a ValidationError.

This natively supports cyclic references and prevents performance degradation caused by topologically sorting payloads.

Warning

Architectural Discrepancies (Array vs Split Events)

Relying on the Intra-Batch Deferral mechanism (sending an array [A, B] in a single event) executes differently at the ORM level compared to sending two isolated events ([A] then [B]).

  1. Circular Dependencies: Array batches natively resolve circular dependencies. If A references B and B references A, both are created and cross-linked successfully via deferral. Split events with on_miss: ignore cannot do this; the first event ([A]) will fail to find B and skip the link, resulting in a broken half-link.

  2. ``x2many`` with ``on_miss: create``: Unlike Many2one (which defers on_miss: create until after root creation), One2many and Many2many fields with on_miss: create execute synchronously via Command.create during root creation so Odoo can automatically inject parent_id and satisfy NOT NULL constraints. Therefore, for x2many relations referencing other root records in the same batch, use on_miss: ignore so they are linked via Command.link in Pass 4 rather than duplicated inline.

  3. Required Constraints (Limitation): The engine lacks topological assembly. Batched records are created completely detached from their intra-batch parents. If a deferred relational field (like parent_id) is strictly required=True at the PostgreSQL level, the batch create() will crash with an IntegrityError before the trailing write() can stitch them together. (Note: SM_DELAY is an upcoming task meant to allow events to recognize a miss implies a topological yield, forcing the engine to delay mapping until the dependency is resolved).


Special Values

Available inside default, fields, and for values:

Value

Description

{"__literal__": value}

Hardcoded constant. Example: {"type": {"__literal__": "url"}}

{"match": {...}, "comodel": "...", "on_miss": "ignore"|"create"}

Relational lookup in the field’s comodel. comodel is optional and overrides the target model lookup, which is required for polymorphic integer fields like res_id. ignore: field omitted on miss. create: stub record created with the match fields, as well as any nested default or fields blocks. Stubs are healed in-place when the real payload arrives via __root__.

"__from__"

Resolves to the event’s source node at runtime.


Outbound Payload Generation

The schema engine is completely bidirectional. When exporting an Odoo record into a JSON payload (via ip.schema._to_payload()), the engine scans the schema to determine which keys to populate in the JSON dictionary:

  1. Explicit JSON mapping (``__json__``) Any field definition that provides an explicit __json__ key (or uses the flat string syntactic sugar) is automatically evaluated and injected into the outbound payload. This applies to root fields, as well as nested fields inside fields or default blocks.

  2. Automatic Match Inference If a relational field defines a match dictionary inside a default or fields block, but does not explicitly provide a __json__ key, the engine automatically infers the export mapping. It traverses the relation using the match key, and exports the value into the payload under the match value.

    For example, the following block automatically exports company_uuid by traversing company_id.sync_uuid on the source record:

    "default": {
        "company_id": {
            "match": {"sync_uuid": "company_uuid"},
            "on_miss": "ignore"
        }
    }
    

__odoo__ Override

When a match key needs a trait but the trait’s return type differs from the ORM lookup field, use __odoo__ to override the lookup path. The trait still runs for its side-effects.

"attachment_cid_id": {
  "__json__":  "file.cid_str",
  "__odoo__":  "attachment_cid_id.cid",
  "is_cid":    { "action": "attach" }
}

__odoo__ overrides only the ORM lookup path — the trait still fires during mapping.


Traits

Traits are keys in a field descriptor that are not __json__, __odoo__, or structural operators. They hook into the mapping engine at three points:

Hook

Description

pre(val, config, ctx)

Runs before the field is mapped; can modify context. For inbound events, ctx["payload_list"] contains the full array of payloads being processed in the batch. config is a dictionary {"ast_nodes": [...]} containing the full AST configurations of all fields utilizing this trait.

mid(val, config, ctx)

Runs during mapping; transforms the value. config contains the exact AST field configuration dictionary for the specific field being processed.

post(val, config, ctx)

Runs after the record is created or updated. config is identical to pre, containing the global ast_nodes dictionary.

Built-in Trait: is_cid

The only built-in trait. Makes any field transparently handle binary blobs as content addressing CID references.

``action: “attach”`` — resolves the CID to an ip.cid Many2one FK:

Direction

Behaviour

Inbound

Checks that the CID is pinned locally. If not, raises MissingCidError → the event transitions to waiting state until the CID arrives. Returns the ip.cid ID for the FK field.

Outbound

Registers the CID for transport (includes it in cid_ids).

"attachment_cid_id": { "__json__": "file.cid_str", "is_cid": { "action": "attach" } }

``action: “store”`` — writes raw bytes directly into a fields.Binary field:

Direction

Behaviour

Inbound

Fetches the CID content and base64-encodes it into the field.

Outbound

Reads the binary, uploads via ip.node.add(), and substitutes the resulting CID string into the payload. For ir.attachment fields, ir.attachment._get_sync_cid() is used to lazily compute and cache the CID.

"image_1920": { "__json__": "avatar.cid_str", "is_cid": { "action": "store" } }

Static Analysis

When you save an ip.schema record, ip.schema._check_schemas() runs immediately. It validates:

  • the ip.schema.data_schema against the JSON Schema meta-schema,

  • every field path in ip.schema.odoo_schema against the ORM’s field registry,

  • operator consistency (e.g. command only on relational fields, match present when required).

A ValidationError is raised and the record is not saved if any check fails.


Internals

Three-Pass DataLoader

The import engine uses a batched three-pass algorithm to avoid N+1 ORM queries when processing array payloads.

Pass

Description

1 — Discovery

Scan all payloads in the batch; collect every unique relational lookup value from match and __root__.

2 — Resolution Cache

Execute one bulk model.search() per target model. Cache {lookup_value: record_id} in memory.

3 — Assembly

Iterate payloads, swap JSON values for cached IDs, apply trait mutations, wrap x2many entries into ORM Commands, then execute bulk create() or write().

Result: O(1) ORM calls per model per batch, regardless of batch size.

MissingCidError Flow

When is_cid: {action: "attach"} cannot find a pinned ip.cid:

MissingCidError raised inside savepoint
        │
        ▼
Savepoint rolls back partial record creation
        │
        ▼
Event transitions to mapping_state = "waiting"
        │
        ▼
CID cid_state transitions to "pinned"
        │
        ▼
Event automatically requeued
  1. MissingCidError is raised inside the savepoint.

  2. The savepoint rolls back the partial record creation.

  3. The ip.event transitions to mapping_state = "waiting".

  4. When the ip.cid’s cid_state transitions to pinned, the event is automatically requeued.


Cookbook

Upsert a Record

Use this in order to update an existing record if it exists, or create a new one if it doesn’t.

{
  "__root__": { "match": { "ref": "ext_ref" }, "on_miss": "create" },
  "ref": "ext_ref"
}

Plain English

Look up the record by matching its ref field to ext_ref in the payload. If found, update it. Otherwise, create it.

You can also use a descriptor object in match to apply traits during the lookup:

{
  "__root__": {
    "match": {
      "ref": { "__json__": "ext_ref", "my_trait": true }
    },
    "on_miss": "create"
  }
}

Set Constant Values

Use this in order to hardcode an Odoo field value regardless of the payload.

{
  "partner_id": {
    "match": {"ref": "company_ref"},
    "on_miss": "create",
    "default": {
      "is_company": {"__literal__": true}
    }
  }
}

Plain English

The field is permanently set to the constant value, completely bypassing the JSON payload. In this example, every dynamically created partner is forcefully flagged as a company.

Update Many2one Fields

Use this in order to overwrite related record fields on every single import.

{
  "partner_id": {
    "match":  { "ref": "partner_ref" },
    "fields": { "name": "partner_name" }
  }
}

Plain English

Unlike default, values in the fields block are aggressively written on both creation and updates.

Match a res_id

Use this in order to resolve polymorphic relations like res_id.

{
  "res_id": {
    "match": {"uuid": "target_uuid"},
    "comodel": "discuss.channel"
  }
}

Plain English

Because res_id is an integer, Odoo cannot introspect the target table. Explicitly passing comodel tells the engine where to search.

Replace an x2many Relation

Use this in order to completely replace a relational field with the items in the payload.

{
  "tag_ids": {
    "match":   { "name": "tags[*].label" },
    "command": "set"
  }
}

Plain English

The command: "set" directive acts as an ORM Command 6, replacing the current relations and removing any previously linked records that are missing from the payload.

Scoped x2many Replacement

Use this in order to replace only the related records that belong to the sender, leaving other records untouched.

{
  "tag_ids": {
    "match":   { "name": "tags[*].label" },
    "command": "set",
    "for":     { "source_node_id": "__from__" }
  }
}

Plain English

The for directive scopes the replacement. Only tags originally written by the sending peer (__from__) are removed if missing.

Store Raw Binaries

Use this in order to inject file bytes directly into an Odoo column.

{
  "image_1920": {
    "__json__": "avatar.cid_str",
    "is_cid": { "action": "store" }
  }
}

Plain English

The engine fetches the file from the network and writes its raw base64 bytes into the fields.Binary column.

Override Trait Lookups

Use this in order to fix Odoo lookup paths when a trait alters the value type.

{
  "attachment_cid_id": {
    "__json__": "file.cid_str",
    "__odoo__": "attachment_cid_id.cid",
    "is_cid":   { "action": "attach" }
  }
}

Plain English

The trait converts the string payload to an integer ID, but the ORM search must still compare the string CID against the string column. __odoo__ overrides the search path.

Send Direct Events

Use this in order to send an event directly to specific peers instead of broadcasting it.

{
  "__nodes__": "channel_id.member_ids.partner_id.peer_id.host_node_id"
}

Plain English

Instead of broadcasting to the swarm, the event is routed point-to-point to each resolved ip.node.