feat: EDM-driven OData open types on complex types, opt-in (#389) - #395
Open
engenb wants to merge 5 commits into
Open
feat: EDM-driven OData open types on complex types, opt-in (#389)#395engenb wants to merge 5 commits into
engenb wants to merge 5 commits into
Conversation
A complex type carrying an `IDictionary<string, object?>` member now serializes and binds FLAT — dynamic keys as siblings of the declared properties, never nested under the container property's own name. Support is driven entirely from the EDM. `ODataConventionModelBuilder` already infers the container, marks the type `OpenType="true"`, omits it from the CSDL, and records the backing `PropertyInfo` as a `DynamicPropertyDictionaryAnnotation`. `OpenTypeJsonOptions` reads that annotation back at `MapOhData()` and layers one `TypeInfoResolver` modifier onto the registration's serializer options that sets `JsonPropertyInfo.IsExtensionData` on exactly that member. The consumer's CLR model therefore needs no `[JsonExtensionData]` — or any other — attribute, which was the hard constraint: a shared contract package must not pay framework cost. Covered: collection GET, GET by key, navigation and property routes, `$expand` targets, POST/PUT/PATCH (undeclared keys reach the handler), property-route writes, `$select=<container>`, complex-type inheritance, and `OpenType="true"` in `$metadata`. Integration notes: - The modifier is added AFTER the ignored-property modifier (so `Ignore()` on a container still wins) and BEFORE the per-request nav-suppression modifier, which derives from these options. All three chain via `WithAddedModifier`; `OpenTypeCompositionTests` proves an expanded navigation, an ignored property and a flattened bag all hold simultaneously, including on a cyclic model. - Clause-bounded serialization (#325/#326) is not widened. The bag's values already reached System.Text.Json; only the emitted key placement changes. No entity type and no navigation property is touched. - The container is matched by `MemberInfo.HasSameMetadataDefinitionAs`, not `==`: the builder's annotation and STJ's `AttributeProvider` come from independent reflection walks that disagree on `ReflectedType`. - Zero delta when unused: a model with no open complex type gets no derived options at all (asserted reference-equal). Out of scope, documented in docs/open-types.md and locked down as behavior in OpenTypeLimitationTests: entity-ROOT dynamic containers, and `$filter`/`$orderby` over an individual dynamic key (Microsoft's query binder faults building the property-bag indexer access, so nothing reaches the database).
…ims (#389) Adversarial-review fixes on top of the #389 feature commit. CRITICAL - open types are now OPT-IN via AddOhData(o => o.WithOpenTypes()), default OFF. Flattening RE-BINDS a body an existing adopter already sends: once the container is extension data it is no longer a declared property, so {"Meta":{"Bag":{"a":1}}} stops meaning "the Bag property" and starts meaning "a dynamic key named Bag" - and the echo of the mis-bound value is byte-identical to the correct one, so the corruption is invisible from the wire. When off, the container map is not built, Build returns the base options reference-equal, and no write-path validation runs. HIGH - reserved/non-conformant dynamic keys are rejected on POST/PUT/PATCH and property-route writes with 400 + the OData error envelope naming the key. A bag key is persisted verbatim and echoed on every later read, so an unpoliced '@odata.type' inside a complex value is a STORED deserialization-fault vector against conforming readers. Implemented as a JsonElement walk against JsonTypeInfo (OpenTypeJsonOptions.FindInvalidDynamicKey) rather than a CLR graph walk after binding: JsonTypeInfo already is the plan, needs no cycle guard, and adds one call per write route to OhDataEndpointFactory. HIGH - a bag key equal to a declared property's JSON name used to emit that name TWICE in one object (measured: {"Region":"declared","Region":"fromBag"}), which every .NET reader tested resolves in the bag's favour. The declared property now wins and the bag entry is dropped, with a warning naming the type and key. Done by wrapping JsonPropertyInfo.Get; the same reference is handed back when there is no collision, so the deserialize path (which also calls the getter) is untouched. The filtered clone preserves the container's RUNTIME type - substituting a plain Dictionary for a `MyBag : Dictionary<...>` container throws InvalidCastException mid-serialization (measured). HIGH - PATCH of a complex member is whole-value REPLACE, not merge. Semantics unchanged (pre-existing for any complex member), but the docs claimed otherwise and Patch_UndeclaredKeysSurviveAlongsideADeclaredProperty OBSERVED the wipe while reading as a pass. Test split and renamed to assert the loss explicitly, plus its true complement (omitting the member preserves the bag); docs rewritten with a read-modify-write recipe. MEDIUM - ValidateOrThrow now covers what it promised. Its own primary case - a competing [JsonExtensionData] member - is NOT caught by GetTypeInfo, which accepts a two-extension-member contract and only fails from Serialize (measured); an explicit extension-member count catches it. Also probes derived open complex types (they have their own contracts but collapse onto the base's map entry), wraps any exception, and the docstring is now honest that an instance-dependent fault - a writable container holding a read-only dictionary - cannot be caught at startup. MEDIUM - a getter-only container (the idiomatic `{ get; } = new();`) now throws at MapOhData naming the type, member and fix. Silently skipping left the CSDL saying OpenType="true" while the wire nested the bag anyway, and marking it would silently DROP every incoming dynamic key (both measured). Corrected false claims, all re-verified by running code: - The "added after the ignored-property modifier so Ignore() still wins" rationale in CLAUDE.md, OhDataEndpointFactory and OpenTypeJsonOptions was UNREACHABLE: the ignored-property map is keyed by profile.ModelType (an entity type) and Ignore() takes a root member of that entity, while a container lives on a complex type. The two modifiers can never see the same JsonTypeInfo. OpenTypeCompositionTests' comment relabelled honestly. - A derived type shadowing the container with `new` is FLATTENED, not "left serializing as it does today" - the builder records the derived member for the derived EDM type. Docs, comment and a pinning test corrected. - HasSameMetadataDefinitionAs is not whole-member identity: it matches across generic instantiations (measured). The real invariant is the base-chain walk against a DeclaringType-keyed map; the comment now says so, and warns that re-keying the map would convert a declared property into a bag. - The type-assignability guard is genuinely unreachable (the builder only infers IDictionary<string, object>-assignable members, and the annotation cannot be written by hand - the setter is not public and the annotation type is internal), so it stays a defensive guard and the comment says why. Docs also record the null/empty asymmetry (a POST with no undeclared keys leaves the container null, not empty) and the detection recipe for whether the opt-in affects a given model. Tests: new OpenTypeJsonOptionsTests.cs (hostless, mirroring IgnoredPropertyJsonOptionsTests) holding the container-map, zero-delta, modifier, ValidateOrThrow and dynamic-key-grammar coverage - including the two unit-level tests that previously spun up a full TestHostBuilder just to obtain an IEdmModel. 1582 -> 1635 passing.
Comment on lines
+352
to
+359
| var req = new HttpRequestMessage(HttpMethod.Patch, | ||
| $"/odata/ExternalReferences({ExternalReferenceStore.Seed})") | ||
| { | ||
| Content = Json( | ||
| """ | ||
| { "Source": "Patched", "Metadata": { "patchedKey": "patchedValue" } } | ||
| """), | ||
| }; |
Comment on lines
+389
to
+393
| var req = new HttpRequestMessage(HttpMethod.Patch, | ||
| $"/odata/ExternalReferences({ExternalReferenceStore.Seed})") | ||
| { | ||
| Content = Json("""{ "Source": "Patched" }"""), | ||
| }; |
Comment on lines
+462
to
+466
| var req = new HttpRequestMessage(HttpMethod.Patch, | ||
| $"/odata/ExternalReferences({ExternalReferenceStore.Seed})") | ||
| { | ||
| Content = Json("""{ "Metadata": { "@odata.id": "http://evil/x" } }"""), | ||
| }; |
Comment on lines
+479
to
+483
| var req = new HttpRequestMessage(HttpMethod.Put, | ||
| $"/odata/ExternalReferences({ExternalReferenceStore.Seed})/Metadata") | ||
| { | ||
| Content = Json("""{ "value": { "@odata.type": "#Evil.Type" } }"""), | ||
| }; |
Comment on lines
+540
to
+544
| var req = new HttpRequestMessage(HttpMethod.Put, | ||
| $"/odata/ExternalReferences({ExternalReferenceStore.Seed})/Metadata") | ||
| { | ||
| Content = Json("""{ "value": { "onlyKey": 42 } }"""), | ||
| }; |
Comment on lines
+763
to
+765
| new StringContent( | ||
| """{ "Source": "S", "Xref": "X", "Metadata": { "KeyValuePairs": { "a": 1 } } }""", | ||
| Encoding.UTF8, "application/json")); |
Comment on lines
+780
to
+782
| new StringContent( | ||
| """{ "Source": "S", "Xref": "X", "Metadata": { "KeyValuePairs": { "@odata.type": "#Evil" } } }""", | ||
| Encoding.UTF8, "application/json")); |
Comment on lines
+300
to
+303
| catch (Exception) | ||
| { | ||
| return null; | ||
| } |
Comment on lines
+357
to
+361
| catch (Exception ex) | ||
| { | ||
| throw ContractRejected(openClrType, containers, | ||
| $"resolving its JSON contract threw {ex.GetType().Name}: {ex.Message}", ex); | ||
| } |
Comment on lines
+121
to
+130
| foreach (IEdmComplexType complexType in model.SchemaElements.OfType<IEdmComplexType>()) | ||
| { | ||
| if (!complexType.IsOpen) continue; | ||
| PropertyInfo? container = model.GetDynamicPropertyDictionary(complexType); | ||
| if (container?.DeclaringType is null) continue; | ||
| ThrowIfUnusableAsExtensionData(container, complexType); | ||
| byDeclaringType[container.DeclaringType] = container; | ||
| Type openClrType = container.ReflectedType ?? container.DeclaringType; | ||
| if (!openClrTypes.Contains(openClrType)) openClrTypes.Add(openClrType); | ||
| } |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
engenb
marked this pull request as draft
August 15, 2026 03:01
…fault reads (#389) Round-2 adversarial review findings on the open-types feature. H1 - FindInvalidDynamicKey never descended into a dynamic property's VALUE, so only the first level of bag keys was policed and the stored-@odata.type vector the check exists to close stayed open one level down. Adds a type-free JSON walk (a dynamic value has no declared type by construction) over every object key at every depth, arrays included. Linear in body size; relies on JsonDocument's existing 64-level parse cap rather than adding a second bound. The test named FindInvalidDynamicKey_FindsAReservedKeyNestedInAComplexValue tested a top-level bag key, not a nested one - renamed to what it asserts, with real nested and in-array coverage added. H2 - the check was wired into POST/PUT/PATCH and the property-write route but not into the navigation-POST create route or the action routes. The nav route is a documented create route and is now wired in with the same buffer-then-bind shape PUT uses. The action routes are wired in too, per PARAMETER against that parameter's declared type: an action parameter binds into the same CLR types and reaches the same handlers, so a persisted parameter stores a reserved key exactly as an entity body would, and checking per parameter means the {"paramName":value} envelope is never itself treated as a bag. Docs and code now agree. M1 - DropShadowedKeys assumed Activator.CreateInstance returned an empty dictionary and then used IDictionary.Add, so a container type whose parameterless constructor seeds an entry faulted a plain GET with 500 (ArgumentException on the duplicate key), refuting both the "caller degrades rather than throwing" comment and the docs' "a read is never faulted over a data condition". Clears the clone, rejects a read-only one, and assigns through the indexer so a comparer mismatch degrades to last-write-wins instead of faulting. M2 - documents the pre-seeded-container corner in docs/open-types.md. It is stronger than the code comment claimed: every dynamic key in the request is dropped and the write still reports 201. Left uncovered deliberately - the condition depends on the runtime instance, so startup cannot see it, and the getter cannot tell the serialize path from the deserialize path. M3 - IsValidDynamicPropertyName used char.IsLetter/IsLetterOrDigit, which is narrower than the odataIdentifier ABNF and rejected legitimate keys: combining marks (Mn/Mc), Nl, and NFD-decomposed Latin. macOS normalises to NFD and Windows to NFC, so the same key got two different status codes depending on the client OS. Implements the actual category set (L/Nl leading; L/Nl/Nd/Mn/Mc/Pc/Cf following) over rune enumeration, and counts the 128 cap in code points rather than UTF-16 code units so an astral-plane identifier is not charged double. L1 - WithOpenTypes() was documented as a byte-identical no-op on a model with no open complex type and was not one: the PUT path and the write-body walk gated on OpenTypesEnabled (did the consumer opt in?) rather than on whether the EDM actually produced an open type, so such a registration still buffered every PUT body - which changed the malformed-body error message. Adds OhDataRegistration.OpenTypesActive, set from MapAll only when the container map is non-empty, and gates every per-request path on it. L3 - the justification comment on ThrowIfUnusableAsExtensionData's type branch was false in both halves: DynamicPropertyDictionaryAnnotation is an exported public type with a public ctor(PropertyInfo), and AddDynamicPropertyDictionary, StructuralTypeConfiguration.ModelBuilder and ODataModelBuilder.AddComplexType are all public. Measured by reflection. The raw builder is also reachable from what AdvancedConfigure is handed - EntitySetConfiguration<T>.EntityType.BaseType is the non-generic EntityTypeConfiguration, which IS a StructuralTypeConfiguration, and its ModelBuilder is the very instance OhData is building with. The branch is unreachable for a different, verified reason: AddDynamicPropertyDictionary itself throws ArgumentException for any member not assignable to IDictionary<string, object>, so a wrong-typed container cannot enter the model by hand either. L4 - collapses the shadowed-key warning from one record per key to one per container instance. Amplification across a page is left bounded by page size; the comment records why going further is worse. 1663 tests pass (1635 before).
…#389) Round-3 review cleanup. LOW/INFO only -- no semantic change to PATCH, entity-root scope, delegate safety, or the zero-delta-when-off guarantee. LOW-1 FindInvalidDynamicKey bailed on any JsonTypeInfoKind that was not Object, so a declared IDictionary<string, TOpenComplex> member (Kind == Dictionary) stopped the walk one member short of the bag System.Text.Json binds straight into. Measured: {"MetaMap":{"one":{"@odata.type":"#Evil"}}} was accepted with a 201 and echoed on every later read, while the byte-identical keys one member over through a plain complex member were correctly rejected with a 400. Adds a Dictionary branch that recurses into the VALUES; the dictionary's own map keys are keys of a declared property, not dynamic property names, and are deliberately not held to the identifier grammar. The value type comes from CLR reflection, not JsonTypeInfo.ElementType, because this assembly also targets net8.0. This is what makes the "applies at every depth" prose true. LOW-2 The 400 message still described the pre-M3 ASCII grammar ("a letter or '_' followed by letters, digits or '_'"), which would wrongly imply non-Latin and NFD-spelled names are invalid, and never mentioned the 128 cap a name can also be rejected for. Reworded in plain terms; docs carry the formal grammar. INFO-2 "NFD and NFC spellings can never get different status codes" is false at the cap boundary: 128 x U+00EF is accepted, its 256-code-point NFD form is not. The implementation is right (the grammar defines the limit in characters); the absolute wording was not. Qualified in CHANGELOG.md, CLAUDE.md and the IsValidDynamicPropertyName remarks, and pinned by a test. INFO-3 BoundAction_StillAcceptsAConformantParameterAndBindsDynamicKeys did not discriminate -- its envelope key "meta" is itself a valid identifier, so the test passed identically under the buggy logic of checking the whole envelope against the parameter's declared type (verified by measurement). The envelope now carries meta@odata.type, which fails the moment the envelope is policed. INFO-1 Documents that Cf (format) characters, including bidi controls, are permitted by the normative grammar in following position and are echoed verbatim, so a consumer rendering dynamic keys should treat them as untrusted display text. No behavior change -- the grammar is not deviated from.
engenb
marked this pull request as ready for review
August 15, 2026 04:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements OData open types (dynamic property bags) on complex types, driven entirely by the EDM — the consumer's model needs no
[JsonExtensionData]or any other attribute.Closes #389.
What it does
ODataConventionModelBuilder— the builder OhData already uses — infers a dynamic-property container from anIDictionary<string, object>-assignable member, marks the containing typeOpenType="true"in the CSDL, omits the member from the declared properties, and records the backingPropertyInfoas aDynamicPropertyDictionaryAnnotation. This PR reads that annotation back atMapOhData()and marks exactly that member asSystem.Text.Jsonextension data, so dynamic keys serialize and bind flat — siblings of the declared properties rather than nested under the container's name.The same registration that produces the CSDL produces the wire shape, so the two cannot drift. Nothing is matched by property name or by convention.
KeyValuePairsappears nowhere on the wire and nowhere in$metadata.Opt-in — and why
The first cut of this applied unconditionally, on the reasoning that the CSDL already said
OpenType="true"so we were only making the JSON agree with its own metadata. Adversarial review showed why that is wrong: the CSDL was the half nobody was reading, and the wire was the contract in practice.Once the container is extension data it is no longer a declared property, so an existing adopter's body
{ "Meta": { "Region": "r", "Bag": { "a": 1 } } }binds
"Bag"as a dynamic key holding the old dictionary — the handler persistsBag = { "Bag": {"a":1} }— and the response echo is byte-identical to the previous one. Silent write corruption with no runtime signal.Off by default ⇒ the container map is never built,
Buildreturns the baseJsonSerializerOptionsreference-equal, no per-request validation runs, and behaviour is byte-identical todevelop.OpenTypeOptInTestspins both halves.Review findings addressed
Two-pass local review on the first commit returned 1 critical + 3 high, all invisible in the response body — which is why the original 21 tests passed. Each is fixed and pinned:
@odata.*and non-identifier keys persist into the bag and echo on every read — a stored deserialization-fault vector against other OData consumersFindInvalidDynamicKeywalks the rawJsonElementagainstJsonTypeInfo; 400 + OData envelope withtarget= the offending key, on POST/PUT/PATCH and property-route writesPatch_ReplacesTheWholeComplexValue_SeededDynamicKeysAreLost— it previously observed the wipe and read as a passValidateOrThrowdid not cover what its docstring promisedMapOhData()naming type, member and fixCLAUDE.mdand the factory was false —Ignore()cannot reach a complex type, so the two modifiers can never see the sameJsonTypeInfonew-shadowed containersHasSameMetadataDefinitionAscomment claimed an identity match it does not provide (GBag<int>/GBag<string>match)DeclaringType-keyed map — and to warn that re-keying would convert a declared property into a bagThree review findings were pushed back on with evidence rather than accepted:
GetTypeInfodoes not catch the competing-[JsonExtensionData]case at all (it resolves fine; onlySerializethrows), so the method's stated primary purpose was entirely unmet rather than under-covered.IDictionary<string,object>-assignable members, and a consumer cannot write the annotation by hand —EdmAnnotationExtensionsexposes only a getter andDynamicPropertyDictionaryAnnotationis internal. It stays as a defensive guard with that reason recorded.A bug in the fix itself was found and fixed during implementation: returning a plain
Dictionaryfrom the collision-filtering getter throwsInvalidCastExceptionwhen the container is declared as a custom subclass, because STJ casts back to the declared type. The clone now preserves the runtime type and degrades with a logged error rather than faulting on a read path.Scope
Complex types only. Entity-root dynamic containers are deliberately not handled: the PATCH delta loop resolves body members through
FindClrPropertyByEdmNameand skips what it cannot resolve, so a root-level undeclared key would be silently dropped on write. Half-working is worse than absent. Documented as a known limitation indocs/open-types.md.#325/#326clause-bounded serialization is not widened. Review confirmed against adevelopbaseline that bag values already reachedSystem.Text.Jsonbefore this change — they were simply written one level deeper — and that no code path added here can reachResolveNavTreatment,TryBuildEngagedExpand, or the pushdown gate. Complex types carry no EDM navigation properties, so delegate safety is untouched.Review rounds
Three adversarial rounds ran on this branch. Each is recorded because the pattern matters more than any single finding: the code was broadly right every time; the prose describing it was not.
b65154c(feature)a2f0536)a2f0536(round-1 fix)@odata.typemitigation was one level deep and never recursed into a dynamic key's value; nav-POST and action routes were unwired; a GET could 500; the identifier check rejected valid non-ASCII identifiers (9cb2524)9cb2524(round-2 fix)f31ab8f)Round 2 also found that round 1's justification comment for an untested branch was false, and round 3 found that round 2's replacement justification was false as well — the real reason that branch is unreachable is that
AddDynamicPropertyDictionaryself-validates the container type, measured by reflection rather than reasoned about.Round 3 verified, empirically rather than by reading: every one of the eight body-reading write routes rejects first-level and nested reserved keys (including the two the tests did not cover — entity-level bound action and unbound action);
$refwrites are correctly excluded;JsonDocument's 64-depth cap terminates before the walk is entered; cost is linear (50,000 sibling keys → 106 ms); the ABNF leading/following split, surrogate handling and 128-code-point cap are exact; and named registrations do not leak opt-in state —v1with the flag rejects whilev2over the same CLR model returns 201 unflattened.Verification
net8.0andnet10.0.develop(0552f34, including the five dependabot bumps); merge touched only.csprojand workflow files.develop@0552f34for malformed, empty, array,null, trailing-garbage and 70-level-deep bodies, wrongContent-Type, well-formed writes on every route, plus$metadataand the service document.DropShadowedKeysfix and theOpenTypesActivegate each fails specific tests. A test that still passes with its fix removed is not a regression test.ObservabilityTests.Metadata_Operation_IsTaggedfailing. It passes in isolation on both this branch and cleandevelop, and the full project passes on consecutive runs — a load-dependent flake, filed as Flaky test: ObservabilityTests.Metadata_Operation_IsTagged captures no activity under solution-wide parallel load #394, not a regression from this change.Performance
No benchmarks were run for this PR, and here is the honest reason rather than a silent omission: with the feature off — the default, and what every existing deployment gets —
Buildreturns the baseJsonSerializerOptionsreference-equal and no code added here executes, so there is nothing to measure.OpenTypesActivenarrows this further: a registration that opts in but whose model has no open complex types also does no per-request work.With the feature on, two costs are added:
JsonElementwalk per write request against the already-resolvedJsonTypeInfo— measured linear, 50,000 sibling keys under one dynamic key in 106 ms, andJsonDocument's 64-level cap rejects deep bodies before the walk is entered;O(bag size),HashSetlookups, no allocation unless a collision is found) — unmeasured.Neither is on the collection read path for a model that does not opt in. If the opt-in path becomes hot for an adopter, the serialization-side pass should be benchmarked then.
Follow-ups filed
$filter/$orderbyover a dynamic property returns 500 instead of 400. Pre-existing behaviour surfaced by this work, not a regression from it.