Graph-object binding and code generation: a design reflection¶
Status: draft for discussion, tracked in oold-python#107.
The recommended binding has been promoted out of the prototypes and now lives in the package proper:
| module | what it is |
|---|---|
src/oold/model/_descriptor.py |
the binding. Descriptors installed from annotations, the Link[T] / LinkList[T] notation, the query DSL |
src/oold/model/v1/_descriptor.py |
the same for pydantic v1 |
src/oold/model/_compat.py |
the downstream API surface (__iris__, get_iri_ref, to_json ...) |
src/oold/model/_notation.py |
the reviewed notations on top of it: OoldField(), union arms |
src/oold/experimental/codegen_spike.py |
IR-based code generation without text post-processing (still a spike) |
It is oold.model.LinkedBaseModel; the legacy per-attribute-interception
binding is reachable with OOLD_DESCRIPTOR_BINDING=0, and
oold.model.LINK_NOTATIONS_ACTIVE reports which is in force.
It took two attempts. The first flip rested on three downstream suites passing,
which was necessary and not sufficient: a review then found seven behaviours the
binding did not reproduce - BaseController.to_json() stripping every data
field, FieldProxy losing truthiness and default forwarding,
x-oold-required-iri enforced nowhere, __iris__ assignment merging instead of
replacing, get_raw answering [None], an inline linked object without an IRI
being dropped, and unset links vanishing from model_dump(). None of those
paths were reached by any suite. The default was withdrawn, each was fixed with
a test in tests/test_compat_parity.py that fails without its fix, and the
binding was made the default again on that basis rather than on suite results
alone.
Verification scripts under examples/: check_binding_features.py (requirement
matrix), bench_binding_variants.py (per-operation benchmarks),
bench_attribute_access.py (where the interception cost comes from).
This document asks the questions the OO-LD v0.8 migration hinges on:
- Is the current object-graph binding the best approach we can build in Python?
- Would the problem be easier in another language, and what does that tell us?
- What would a linked-data-native language look like?
- Given how much of the toolchain is patch code around
datamodel-code-generator, should we write our own generator?
1. What the binding must do¶
A property whose value is another entity can be written two ways in the same
field: inline as a nested object, or by reference as an IRI string (annotated
x-oold-range, legacy range). The binding layer has to construct from
either form, resolve an IRI lazily through a pluggable backend,
serialise references back to IRIs in JSON and JSON-LD, and keep static
typing so the declared type is the target model.
Beyond that minimum, the shipped library also provides polymorphic resolution
(dispatch on the instance type IRI), batched list resolution, rich list
operations, and a class-level query DSL. All of these are requirements, not
extras: they are verified per variant in check_binding_features.py.
2. Critique of the current approach¶
src/oold/model/__init__.py intercepts attribute access unconditionally:
- Global monkeypatch.
pydantic.fields.FieldInfois replaced process-wide at import (model/__init__.py:57). Any code importingoold.modelinherits a patched pydantic. - Metaclass attribute interception.
LinkedBaseModelMetaClassoverrides__getattribute__(:157) for the class-level query DSL, needing a_constructingguard (:119-132) to avoid corrupting pydantic's own metaclass bookkeeping. - Instance interception plus a parallel state dict. Each instance overrides
__getattribute__/__setattr__(:625-673); every read consults the__iris__side-dict (:399) and may perform synchronous backend I/O inside the getter.__iris__duplicates field state, forcing heavy__init__special-casing (:474-582) and a bespoke list type (:223). - Import-order-dependent registries
_types/_controller_types(:109).
Cost, measured¶
examples/bench_binding_variants.py, 100k iterations per operation, best of 5,
each variant in its own process. (x) is relative to plain pydantic v2 reads.
| variant | plain read | plain write | link read | link write | query build |
|---|---|---|---|---|---|
| plain pydantic v1 | 3.5 (0.7x) | 47.9 (9.8x) | na | na | na |
| plain pydantic v2 | 4.9 (1.0x) | 23.9 (4.9x) | na | na | na |
gated __getattribute__ |
19.8 (4.0x) | 69.0 (14.1x) | na | na | na |
| shipped v1 | 58.1 (11.9x) | 959.9 (195.8x) | 426.2 (86.9x) | 1590.4 (324.4x) | 286.9 (58.5x) |
| shipped v2 | 93.3 (19.0x) | 572.5 (116.8x) | 838.1 (171.0x) | 1140.8 (232.7x) | 490.1 (100.0x) |
| auto-descriptor | 5.1 (1.0x) | 50.1 (10.2x) | 6.3 (1.3x) | 270.8 (55.2x) | 194.6 (39.7x) |
explicit Ref[T] |
4.9 (1.0x) | 24.4 (5.0x) | 5.8 (1.2x) | 27.6 (5.6x) | na |
Every attribute of every LinkedBaseModel - including fields that are not
references - pays roughly 12x (v1) to 19x (v2).
Could the interception just be gated on range annotations?¶
Partly. Early-exiting for non-link fields removes most of the work (19x down
to ~3.1x for an idealised closure-frozenset gate, ~4.5x for a realistic
per-class one), but not the call: defining __getattribute__ at all forces a
Python-level function call on every access instead of the C-level slot. The gate
shrinks the body, not the call. A descriptor is that same gate implemented in C.
3. Python alternatives¶
(a) Per-field descriptors (recommended). Only x-oold-range fields become
descriptors, so plain fields keep native access. Plain attribute access returns
the real resolved object, so isinstance holds and the value passes
anywhere the target type is expected.
(b) Explicit Ref[T] (opt-in). A reference is a first-class value with
resolve() / await aresolve(). Resolution becomes visible, batchable and
awaitable - none of which the shipped design can express. Cost: p.knows[0] is
a Ref, not a Person, so isinstance fails.
© The trap: do not dress (b) up as (a). Declaring a Ref field as
Annotated[Person, ...] makes a checker read it as Person while the runtime
value is a Ref. isinstance is False; the static type is not backed by the
runtime value. Rejected.
3.1 The key optimisation: non-data descriptor plus instance-dict cache¶
The descriptor is deliberately non-data (it defines __get__ but not
__set__) and stores the resolved value in the instance __dict__. Because an
instance dict entry shadows a non-data descriptor, every subsequent read is a
plain C-level dict lookup that never re-enters Python - the
functools.cached_property pattern. Writes remain intercepted by a targeted
__setattr__, which pops the entry to invalidate it.
Caching in a pydantic PrivateAttr instead costs a Python-level __getattr__
per read, which is what made link reads slow:
| link read (warm) | time | vs plain field |
|---|---|---|
data descriptor + PrivateAttr cache |
336.0ms | 31.8x |
non-data descriptor + __dict__ cache |
10.5ms | 1.00x |
| plain pydantic field (baseline) | 10.6ms | 1.00x |
A 32x improvement on the hot path; link reads drop from 33.6x to 1.3x in the full matrix. Both descriptor prototypes use it.
3.2 Static typing¶
A link has two types and one annotation can only state one of them. What you
read is a resolved object; what you may write is that object or a reference to
it - an IRI string, or a JSON object still to be constructed. Since pydantic's
dataclass_transform takes the annotation as the __init__ parameter type,
knows: list[Person] necessarily rejects knows=["ex:bob"].
The only mechanism in the typing spec that carries both is the descriptor
protocol (PEP 681): when the annotation is a descriptor type, a checker takes
the __init__ parameter and the assignment type from __set__ and the attribute
type from __get__. Link[T] and LinkList[T] are therefore usable as the
whole annotation:
class Person(LinkedBaseModel):
knows: LinkList["Person | None"] = OoldField()
employer: Link[Organization] = OoldField()
__set__ is declared under TYPE_CHECKING only, so at runtime the descriptor
stays non-data and the instance-__dict__ cache from 3.1 is untouched.
__get_pydantic_core_schema__ builds the schema of the target, so the emitted
JSON Schema is byte-identical to the plain annotation - $ref, arrays, unions
and forward references included.
Optionality is declared, not assumed¶
Link[T] reads as T, not T | None. The declaration is a promise the binding
keeps, so a chain of mandatory links needs no guard per hop:
class Person(LinkedBaseModel):
father: Link["Person"] = OoldField() # mandatory
mother: Link["Person | None"] = OoldField() # optional
person.father.father.father.name # no guards - the type says it resolves
mother = person.mother # guard required, and warranted
Without this, every hop needs an is not None check and chaining collapses -
the type would be truthful and useless at once. Handing back a None the
declaration denies is the alternative, and that is the same polite fiction the
Annotated-over-Ref form was rejected for in 3©.
What the promise costs, by case:
Link[T] |
Link[T \| None] |
|
|---|---|---|
| not set, no IRI | raises LinkNotResolved |
None |
| backend error | propagates | propagates |
| answered, no such entity | raises LinkNotResolved |
None |
All three fire on access, not at construction. An earlier version rejected an
absent mandatory link when the object was built - knowable without resolving
anything, and tempting for that reason - but it over-enforces: the annotation
says what reading the link yields, not that every instance carries one. Graph
data is routinely partial (most Wikidata people have no recorded father), and
rejecting those objects makes them unloadable. Declaring a link mandatory states
an intent to traverse it, so one try/except around a whole walk replaces a
guard at every hop.
The middle row is unchanged and matters: a transport failure is not "has no father", and conflating the two would be the real bug.
Coverage¶
| spelling | read | write by IRI | runtime |
|---|---|---|---|
Link[Organization] |
Organization |
typed | mandatory |
Link[Organization \| None] |
Organization \| None |
typed | optional |
LinkList["Person"] |
LinkResultList[Person] |
typed | mandatory elements |
LinkList["Person \| None"] |
LinkResultList[Person \| None] |
typed | optional elements |
list["Person"] |
list[Person] |
not typed | optional |
Optional[Organization] |
Organization \| None |
not typed | optional |
knows = LinkList("Person") |
LinkResultList[Person] |
n/a - no field | optional |
Every spelling except the two Link[...] forms stays optional, so existing
declarations - including everything the generator emits - behave exactly as
before. Consumers that want reference assignment enforced on generated models can
scope the rule per file rather than repo-wide: ty supports
[[tool.ty.overrides]] with an include glob.
A to-many link keeps the slot of an unresolvable reference, so the list stays
aligned with the stored references - as None when the element type admits it,
otherwise as a raise. A query result is different: it answers with what it found,
so an IRI it could not place is dropped rather than kept.
Both pyright and ty resolve all of it, including Model[...] through the
metaclass __getitem__ overloads. tests/typing/links.py and
tests/typing/query_dsl.py are checked by both.
One environment trap is worth knowing, because it fails silently rather than
loudly: if the configured environment cannot resolve pydantic, ty reports a
spurious conflicting-metaclass on every model and then infers Unknown for
class subscription - so every assert_type passes vacuously. tests/test_typing.py
therefore points ty at the interpreter running the tests, not at ./.venv.
3.3 Declaration notations¶
Recommended: Link[T] / LinkList[T] as the whole annotation, with a bare
OoldField().
class Person(LinkedBaseModel):
id: str
employer: Link["Organization | None"] = OoldField()
knows: LinkList["Person"] = OoldField()
The annotation is the single source of truth: it names the target, says the
field is a link, and declares optionality. So range= is not passed - it would
state the target twice and let the two disagree - and link=True is redundant.
x-oold-range is derived from the annotation when the schema is generated,
which is what keeps the emitted schema a link schema.
Deriving rather than repeating had to wait for the right moment to do it. A
forward reference is not resolvable when the descriptor is installed, and a
Field() object is shared between models, so its json_schema_extra must not
be mutated in place. __get_pydantic_json_schema__ has neither problem.
Every other notation is supported and keeps working - including the legacy
Field(None, json_schema_extra={"range": ...}) that code generation still
emits - but each gives something up, and the table after the example says what.
Link[T] / LinkList[T] are pydantic v2 only; oold.model.v1 keeps the
range= form.
Four notations are supported; all share one descriptor implementation, and they can be mixed in a single class.
class Person(OoldModel):
id: str
name: Optional[str] = None
# 1. Link[T] / LinkList[T] as the whole annotation - typed both ways (3.2)
knows: LinkList["Person"] = OoldField()
employer: Link[Organization] = OoldField()
# 2. implicit, zero-config - target inferred from the annotation
friends: Optional[List["Person"]] = OoldField()
# 3. union arms: literal text | inline object | reference
location: Union[str, Location, None] = OoldField(link=True)
# 4. unannotated descriptor - no annotation, so no static type at all
# addresses = LinkList(Address)
All four are the same field at runtime and produce the same JSON Schema; they
differ only in what a type checker can see, per the coverage table in 3.2. The
union arms discriminate at
construction: a bare string stays a literal when a str arm is declared, a
{"@id": ...} object is a reference, and any other object is inline. An inline
object with no @id cannot be emitted as a reference, so it serialises nested -
a blank node.
Which notation supports what¶
Every row is the same field at runtime - they resolve, batch, serialise and
query identically. They differ in what a type checker sees and what reaches the
JSON Schema. Measured, not asserted: read types from ty, schema keys from
model_json_schema().
| notation | codegen emits it | target inferred | range keyword in schema | read type | IRI write typed | optionality declarable |
|---|---|---|---|---|---|---|
Optional[List[T]] = Field(None, json_schema_extra={"range": ...}) |
yes | no | range (legacy) |
list[T] \| None |
no | no |
= OoldField(range="...") |
no | no | x-oold-range as given |
as annotated | no | no |
= OoldField() |
no | yes | x-oold-range, derived |
as annotated | no | no |
Link[T] / LinkList[T] with OoldField() |
not yet | yes | x-oold-range, derived |
exact (T, LinkResultList[T]) |
yes | yes |
= Link(T) / = LinkList(T) |
no | yes (from the argument) | field absent from schema | exact | n/a - not a field | no |
str \| Location \| None = OoldField(link=True) |
no | yes | derived | union as declared | no | via the None arm |
The derived range is the target's get_cls_iri(), taken at schema-generation
time; an explicit range= is never overwritten, and a target that cannot name
itself contributes nothing, leaving the x-oold-link marker in place. The
unannotated descriptor form is not a pydantic field at all, so it neither
appears in the schema nor gets an __init__ parameter, though its read type is
exact.
Requiredness is a field argument, not the annotation¶
Two different questions - "must the caller supply it?" and "what do I get when I read it?" - so two carriers:
| declaration | stored as | enforced | on violation |
|---|---|---|---|
Link[T] - no None arm |
_AutoLink.optional = False |
on read | LinkNotResolved |
OoldField(required=True) |
_AutoLink.required_iri, precomputed into cls.__required_links__; emitted as x-oold-required-iri and into the standard required array |
in __init__ |
ValueError: ... is required but not set |
A link is never required at the pydantic level, because its value is routed
out of the payload before validation - which is why the legacy binding declared
every generated link field Optional[...] and carried requiredness in the
keyword. A bare Link[T] annotation with no default is read as required, which
is what Python means by "no default" everywhere else.
Why not put requiredness in the annotation. required -> Link[T],
absence -> Link[T | None] reads well and was the first proposal. It fails on a
self-referential link. father: Link["Person"] required means every person in
the dataset carries a father IRI, which is only true of a graph with no root -
and resolution constructs target objects, so the failure surfaces one hop from
its cause: reading alice.father raises ValueError: father is required but not
set about Bob's document, which you never asked for. So father would have
to be Link["Person | None"], which needs a guard per hop - the thing the
annotation exists to avoid.
The alternative was to strip the | None under TYPE_CHECKING, which both
pyright and ty do resolve (a self-type overload on Link[X | None] yields X).
It was rejected because the runtime would then have to raise instead of
returning None, which silently breaks entity.link is None, if entity.link:
and getattr(entity, f, None) - and the last does not save the caller, since
LinkNotResolved is a LookupError, not an AttributeError. Pattern A in
downstream-migration.md is exactly that shape.
Suppressing the diagnostic instead is only half-available: pyright separates
reportOptionalMemberAccess from reportAttributeAccessIssue, so it can be
turned off without losing typo detection, but ty reports both under
unresolved-attribute (checked on 0.0.49 and 0.0.80). Either way it is a
setting every downstream consumer would have to make.
Open: nothing emits the read-side promise, so a model -> schema -> model
round trip loses the distinction between Link[T] and Link[T | None]. Code
generation can default to Link[T] for required properties and
Link[T | None] otherwise; expressing it exactly needs a keyword of its own.
Notations considered and dropped¶
| notation | why it is not used |
|---|---|
list[Link[T]] - Link nested inside a container |
Silently degrades. A descriptor nested in a list is not treated as one, so the read type comes back as list[Link[T]] - not merely untyped but wrong. Superseded by LinkList[T], which carries the to-many-ness itself. Still works at runtime, which is what makes it dangerous. |
Optional[Link[T]] - Link nested inside a union |
Half-degrades: the read type narrows to T \| None correctly, but the descriptor's __set__ is not seen through the wrapper, so assigning an IRI is rejected (Expected Link[T] \| None). Link[T \| None] states the same thing and types both directions. |
Annotated[Person, OoldRange(...)] wrapping a Ref value |
The static type is not backed by the runtime value: a checker reads Person, isinstance says Ref. Rejected in 3©. |
Ref[T] as the field type |
Honest, but p.knows[0] is a Ref, not a Person - isinstance fails and the list operations, polymorphic dispatch and query DSL go with it (3.6). Kept only as an opt-in handle for visible or async resolution. |
~Person.name == "x" for match filters |
~ binds tighter than ==, so this parses and would work - but pandas established ~ as NOT, and colliding with that is worse than a method. |
Two semantics were dropped along the way, for the record: elements of a
to-many link were briefly T | None unconditionally (replaced by declaring it),
and a mandatory link was briefly rejected at construction rather than on access
(see the table above).
3.4 Typed json_schema_extra¶
The raw dict can be replaced by a validated class, but it must subclass
dict: pydantic merges extras via isinstance(json_schema_extra, dict), so a
plain BaseModel is accepted at declaration and then silently dropped from the
schema. OoldExtra delegates validation to a pydantic model and exposes typed
properties, so runtime code stops doing extra["x-oold-range"].
OoldExtra(range="") # ValidationError: String should have at least 1 character
extra.range # typed read (str)
Pass the payload to model_validate as a dict rather than as aliased kwargs,
otherwise type checkers reject range= as "No parameter named".
3.5 Query DSL¶
Preserved, and cheaper. It moves from __getattribute__ (every access) to
__getattr__ (a fallback, only when lookup fails). Pydantic v2 removes field
names from the class namespace, so Person.name fails naturally and lands
there at no cost to anything else. For link fields no metaclass is involved at
all: the descriptor's __get__(None, owner) returns the descriptor on class
access, so comparison operators live directly on it.
__getattr__ on the metaclass must never call getattr(cls, ...):
cls.model_fields is a property that itself calls getattr, which recurses
until the stack overflows. Read klass.__dict__["__pydantic_fields__"] along
the MRO and reject _-prefixed names.
Static types. The condition expression itself cannot be typed: Entity.name
is a declared field, so pydantic's dataclass_transform makes the annotation
authoritative for class-level access too and the FieldProxy really returned is
invisible, leaving Entity.name == "x" as bool. The shipped binding handles
this by accepting bool in the __getitem__ overloads, and the descriptor
binding does the same: the argument type stays wrong, the result type comes out
right. What the subscript yields is fully typed:
| expression | static type |
|---|---|
Entity["ex:e1"] |
Entity \| None |
Entity[Entity.name == "x"] |
LinkResultList[Entity] \| None |
...[0] |
Entity |
...[cond] |
LinkResultList[Entity] |
LinkResultList is generic in the item type, which is what keeps the item type
through indexing and filtering. Accepting bool there widens
list.__getitem__, which answers T for a bool index - a deliberate
divergence, since indexing a list by True is not something anyone writes.
tests/typing/query_dsl.py pins these with assert_type;
tests/test_typing.py runs pyright over it.
The | None is the one difference from the shipped overloads, which promise a
bare M: the query really does return None when nothing matches, so the
stricter type is the truthful one.
Instance-level filtering (entity.links[cond]) is typed only when the field is
annotated LinkResultList[T]. The list[T] | None form the current codegen
emits stays unfiltered at the type level, so the generator should emit
LinkResultList[T] for to-many links.
The DSL against a real query language¶
A Condition is consumed by two things, and the check that matters is that they
agree: apply_operator, which filters objects already in memory, and the SPARQL
resolvers, which have to ask a triple store the same question. query() on
SparqlResolver, LocalSparqlResolver and WikiDataSparqlResolver translates
eq, ne, lt, le, gt, ge and &; anything else raises rather than quietly
returning the wrong rows.
Two things fell out of doing it, which is why it was worth doing before adding more operators:
- The predicate and the literal both come from the model's own context. A
probe document is expanded through it, so a term scoped
@language: enyields"x"@enand one with an@typeyields"x"^^<xsd:integer>. Deriving either by hand would be a second, divergent reading of the same context. - A match needs a type constraint.
rdfs:label "Tim Berners-Lee"@enalso matches a book edition, and resolving that fails on an unknown type IRI. The Wikidata resolver constrains on P31 - the predicate it already rewrites into@typeon the way in.
tests/test_sparql_query.py asserts the SPARQL answer against apply_operator
over the same data rather than against hand-written expectations, so it tests
the agreement rather than one implementation twice. It runs offline against an
rdflib graph.
Still missing, and visible from here: there is no | (the model defines
__and__ but not __or__), no ~, and link descriptors carry only == / !=,
not the ordering operators.
3.6 Requirement matrix¶
From examples/check_binding_features.py, which exercises each requirement
rather than asserting it.
| requirement | shipped v1 | shipped v2 | auto (implicit) | auto (explicit) | Ref[T] |
|---|---|---|---|---|---|
| syntax_unchanged | ok | ok | ok | FAIL | FAIL |
| build_by_iri / build_by_object | ok | ok | ok | ok | ok |
| lazy | ok | ok | ok | ok | ok |
real_object (isinstance) |
ok | ok | ok | ok | FAIL |
| polymorphic | ok | ok | ok | ok | FAIL |
| batched | ok | ok | ok | ok | FAIL |
| cached | ok | ok | ok | ok | ok |
| mutation | ok | ok | ok | ok | FAIL |
| link_validated | ok | ok | ok | ok | ok |
| list_lookup / list_filter | ok | ok | ok | ok | FAIL |
| list_projection | FAIL | FAIL | ok | ok | FAIL |
| serialize_iri | ok | ok | ok | ok | ok |
| query_dsl | ok | ok | ok | ok | FAIL |
| typed_extras | FAIL | FAIL | ok | ok | FAIL |
| no_monkeypatch | FAIL | FAIL | ok | ok | ok |
The descriptor binding is a strict superset of the shipped one. Note the shipped implementation does batch list resolution - an earlier claim to the contrary was wrong.
On validation: the parent's field validation is bypassed (the descriptor shadows the field), but the linked object is still validated at construction of the linked class, which is where its constraints live.
4. Would another language do better?¶
The shipped design makes every attribute transparently resolve. Python has no
cheap whole-object proxy, so that choice forces __getattribute__ plus a
metaclass. Per-field descriptors avoid it entirely.
- JavaScript / TypeScript.
Proxyis a language primitive, so transparent lazy references are idiomatic and cheap (LDO, rdf-ts). - Rust / TreeLDR. Compiles a linked-data schema to typed Rust plus a JSON-LD
context; references are an id newtype (
IdRef<T>) and resolution is explicit I/O - essentially theRef[T]design enforced by the type system. - Java / twa, TheWorldAvatar OGM. Annotation-driven mapping resolving through a session object; explicit, not getter-side-effect.
- Clojure / Datomic. No object graph at all: entity-attribute-value tuples,
a reference is an entity id, and resolution is an explicit
pullwith a declared shape.
Every ecosystem that handles this well makes resolution explicit or has a
language-level proxy. Python has neither at whole-object level, but the
descriptor protocol is exactly the per-field equivalent, and Ref[T] covers the
explicit camp. Supporting both matches the two durable designs rather than
picking one.
5. What would a linked-data-native language look like?¶
- IRIs and language-tagged strings as primitive types, not
str. - Identity and type first-class on every value; open-world structural typing aligned to SHACL shapes rather than closed classes.
- Lexical namespaces / contexts, so
nameresolving toschema:nameis a compile-time fact. - References transparent, resolution an effect. Reading a linked value is
ordinary syntax, but resolution is tracked by an effect / capability (like
async) served by a pluggable resolver: transparency without hidden, untyped I/O. - Graph literals and query comprehensions as language constructs.
- Built-in JSON-LD / RDF serialisation, because the object model is the RDF model.
Prior art: N3, Shen, LinkML, TreeLDR, RDF-star, Datomic/Datalog, GraphQL-LD.
The recommended binding already approximates the ideal: reads look ordinary and
return real objects, while .refs() / aresolve() expose resolution as an
explicit, batchable, awaitable effect. What Python cannot have - IRIs as
primitives, structural open-world typing - is exactly what argues for keeping
the source of truth in the schema and generating the binding from it.
6. Own generator vs datamodel-code-generator¶
Code generation currently fights the tool from both ends: src/oold/generator.py
monkeypatches the parser and regex-fixes its output; src/oold/utils/codegen.py
subclasses it to inject @context and repair allOf; and the external
osw-python-package-generator post-processes the generated text with roughly
1100 lines of regex (_fix_missing_allof_bases,
replace_duplicated_classes_with_imports, UUID/OSW-ID dedup,
replace_unit_enums).
Those structural problems - multiple allOf inheritance, class identity by
x-oold-uuid, cross-package imports, typed x-oold-range references - are
graph facts the tool does not model, so they are fought after the fact on
strings.
Options: (1) keep and patch, (2) hybrid - keep the tool for plain JSON Schema fragments but operate on its model objects instead of text, (3) own IR-based generator.
codegen_spike.py implements a minimal option 3 and shows the three
regex-fought problems fall out for free from an IR: allOf becomes real
multiple inheritance, two schemas sharing x-oold-uuid collapse to one class,
and x-oold-range becomes a typed reference field - with no post-processing.
Recommendation: option 3, staged through option 2. The real cost is re-implementing the JSON Schema breadth the tool gives for free (unions, enums, constraints, formats, naming). Mitigations: stage through the hybrid; gate the switch on a golden-file diff of a regenerated real package; keep the IR language-agnostic so it can later emit TypeScript or Rust.
7. CPython and Rust optimisation potential¶
Applied: the non-data descriptor plus instance-dict cache (section 3.1), a 32x win that puts warm link reads at native speed.
Remaining CPython headroom:
- Query construction, ~6.6x available.
Conditionis a pydanticBaseModel, so everyCls.field == valuepays full validation: 200.1ms vs 30.4ms for a__slots__class over 200k iterations. It touches the publicoold.backend.interfaceAPI, so it is a deliberate change. - Link writes (55.2x), dominated by
Refconstruction and private-attr access on the write path. - Plain writes (10.2x vs 4.9x), entirely the extra
__setattr__frame; classes with no link fields need no override at all.
Rust: after the fix above the binding hot path is a C-level dict lookup and
pydantic's validation core is already Rust, so there is little left to win
there. The real opportunities are elsewhere: pyld is pure Python and
dominates RDF export (to_jsonld() 120.7 us/op vs to_json() 19.7 us/op, a
6x gap, essentially all context expansion), and rdflib is likewise pure Python
where pyoxigraph (Rust) is an alternative for graph storage and SPARQL.
Priority: the JSON-LD/RDF layer, not the object binding.
8. Recommendations and impact on the migration¶
- Binding: adopt the per-field descriptor design, declared by annotation
(unchanged syntax, so generated packages are untouched), with the explicit
descriptor and
Link[T]notations available andRef[T]as an opt-in handle for visible or async resolution. Avoid theAnnotated-over-Refform. - Query DSL: carried over whole, including the typed subscription overloads
(3.5). The
_constructingguard the metaclass carries is not a cost of the DSL - the descriptor needs it independently, since a descriptor is an ordinary class attribute that pydantic's base-attribute probe trips over without__getattr__ever being consulted. - Code generation: move off text post-processing toward an IR-based
generator, staging through a hybrid that first deletes the regex. Fold
osw-python'sfetch_schemaorchestration and the package-generator passes into it. EmitLinkResultList[T]for to-many links so instance-level filtering type-checks. - Sequencing into v0.8: the keyword migration should read
x-oold-range/x-oold-iri/x-oold-uuid(dual-read with legacy) through the same IR and binding, so the generator, the runtime binding and the RDF layer share one keyword-normalisation path rather than three.
Open items¶
- pydantic v1: the prototypes are v2-only
(
__pydantic_init_subclass__, core schemas);model/v1/__init__.pyis a full parallel implementation and the package generator emits both. - Public-API equivalence with the shipped
LinkedBaseModel(to_json,to_jsonld,from_json,from_jsonld,cast, controllers,Model["iri"]) must be demonstrated before adoption soosw-pythonis unaffected. - The class registry is still a process-wide global keyed by type IRI, so two classes claiming the same IRI shadow each other and resolution depends on import order. The prototype reproduces the very flaw criticised in section 2; it surfaced as a cross-module test collision and is currently worked around by using distinct IRIs per test module. A scoped registry (per root model or per explicit registry object, with the global as a default) is needed before adoption.