Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@
* **Breaking Change** Removed `SubscriptionsDict`, `SubscriptionUnsubscriber` and `OnUnsubscribeAction` from `FSharp.Data.GraphQL.Shared.WebSockets`: the `graphql-transport-ws` middleware keeps its subscriptions in a per-connection registry owned by a single loop
* **Breaking Change** `@defer` and `@stream` now declare the arguments the incremental delivery specification requires: `if: Boolean = true` and `label: String` on both, `initialCount: Int = 0` on `@stream`. `@stream` is allowed on `FIELD` only and `@defer` on `FIELD`, `FRAGMENT_SPREAD` and `INLINE_FRAGMENT`, no longer on `FRAGMENT_DEFINITION`. `if: false`, literal or through a variable, executes the field inline; `initialCount` delivers the first items with the initial payload and streams the rest; `label` is carried by the `pending` entry announcing the field
* **Breaking Change** `GQLDeferredResponseContent.DeferredPending` gained `InitialCount`, the number of items of a streamed field delivered with the initial payload, so that the `graphql-transport-ws` translator expects the streamed items from that index
* **Breaking Change** Added `@defer` on fragment spreads and inline fragments, as the incremental delivery specification defines it: the fragment's fields are resolved together and delivered as one payload of the object containing them, announced at that object's path with the fragment's `label`; a field also selected directly on the object is executed with it, together with whatever the fragment selects under it, and left out of the fragment; a fragment spread directly anywhere in the selection is resolved with the object, whichever spread of it comes first; a fragment spread twice deferred at the same place is delivered once; a fragment whose `if` is `false` through a variable is resolved with the object; a fragment on an abstract type delivers the fields of the concrete type; an error propagating up to the fragment completes it with the errors and no data. The engine reports fragments through the new `DeferredFragmentPending`, `DeferredFragmentResult` and `DeferredFragmentCompleted` events, and plans them as the new `ResolveDeferredFragment` kind
* **Breaking Change** `BufferedStreamOptions.Interval` and `BufferedStreamOptions.PreferredBatchSize` are now `int voption`
* **Breaking Change** `ServerMessage.Error` and `ServerRawPayload.ErrorMessages` now carry `GQLProblemDetails list` instead of `NameValueLookup list`, so an `error` message's `payload` is a standard GraphQL error array as the `graphql-transport-ws` protocol requires
* **Breaking Change** A query or mutation whose non-null root field fails during execution now produces a `Direct` (execution) result with `null` data instead of a `RequestError`, which is now only ever produced for a request rejected before execution (validation, planning, variable or inline argument coercion, a middleware, or the executor itself failing); HTTP and `graphql-transport-ws` responses for such a failure now carry `data: null` as the spec requires, instead of omitting `data` entirely. This also changes the public `GQLResponse.Data`, `GQLResponseContent.Direct.Data`, `DeferredErrors.Data`, and `SubscriptionErrors.Data` signatures to use `voption`
Expand Down Expand Up @@ -322,4 +323,5 @@
* Fixed `graphql-transport-ws` never sending `complete` after the `next` of a query or mutation result, as the protocol requires
* Fixed `graphql-transport-ws` sending a request error (rejected before execution: validation, planning, variable coercion, a middleware, or the executor itself failing) as a `next` result followed by `complete`, instead of the terminal `error` message the protocol requires for it; a query or mutation whose non-null root field fails during execution still gets `next` + `complete`, since it is a result, not a request error
* Fixed `graphql-transport-ws` throwing while serializing an `error` message or a `pong` carrying a payload, since neither was written under the `payload` property name `Utf8JsonWriter` requires
* Fixed validation of an inline fragment without a type condition (`... { … }`), which used to fail with an exception instead of applying to the parent type
* Removed the internal `Observable.withCompletionMarker`
95 changes: 95 additions & 0 deletions docs/bug-spec-interface-possible-types-keynotfound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Bug Spec: KeyNotFoundException in Interface Possible Types Resolution

## Summary
Schema introspection may crash with `System.Collections.Generic.KeyNotFoundException` when resolving possible types for an interface with no registered object implementations in the computed implementations map.

## Observed Runtime Evidence
- Exception type: `System.Collections.Generic.KeyNotFoundException`
- Message: `The given key was not present in the dictionary.`
- Top failing library frame: `FSharp.Data.GraphQL.Schema<'Root>.getPossibleTypes`
- Failing code path (`src/FSharp.Data.GraphQL.Server/Schema.fs`):
- `| Interface i -> Map.find i.Name (implementations.Force()) |> Array.ofList`

## Exact Failure Location
File: `src/FSharp.Data.GraphQL.Server/Schema.fs`
- `getImplementations`: builds `Map<string, ObjectDef list>` from `objdef.Implements`
- `getPossibleTypes`: uses `Map.find` for interfaces
- `introspectType` for `Interface` calls `getPossibleTypes` and crashes before graceful validation/error reporting

## Root Cause
`Map.find` assumes every interface name exists as a key in `implementations` map. This assumption is false when at least one schema interface has zero object implementations in the discovered type map.
In that case, lookup throws immediately, producing infrastructure exception instead of structured GraphQL/type validation feedback.

## Why This Is Problematic
1. Hard crash during schema startup/introspection.
2. No actionable validation message identifying which interface is orphaned.
3. Behavior differs from expected robust validation (should return deterministic `ValidationError` or safe empty set depending on policy).

## Reproduction (Generic, Domain-Agnostic)
1. Define interface `IParentInfo` with at least one field.
2. Register the interface in schema type map.
3. Ensure no object type in type map includes this interface in `interfaces = [ ... ]`.
4. Trigger schema introspection or schema initialization path that builds introspection metadata.
5. Observe `KeyNotFoundException` at `Map.find i.Name (implementations.Force())`.

## Expected Behavior
One of the following (explicitly chosen policy):
- **Preferred**: do not throw; treat no implementations as empty set for possible types, and surface a validation error later if this is invalid by policy.
- **Alternative**: immediately return structured validation error: `Interface <name> has no implementing object types.`

No raw `KeyNotFoundException` should escape from schema construction/introspection.

## Proposed Fix
### Safe Lookup Change
Replace unsafe lookup in `getPossibleTypes` with safe lookup:
- from: `Map.find i.Name (implementations.Force()) |> Array.ofList`
- to: `implementations.Force() |> Map.tryFind i.Name |> Option.defaultValue [] |> Array.ofList`

### Validation Enhancement
Add explicit validation for orphaned interfaces in type-map validation layer:
- detect interfaces with zero implementing object types
- return deterministic `ValidationError` with interface name

This keeps runtime stable and preserves strict schema diagnostics.

## Test Specification
Create dedicated tests in `tests/FSharp.Data.GraphQL.Tests` (new file recommended: `InterfacePossibleTypesValidationTests.fs`).

### Test 1: Regression Repro (pre-fix behavior)
- Build schema with one interface and no implementors.
- Assert old code throws `KeyNotFoundException` (documented regression test, can be skipped/removed after fix depending policy).

### Test 2: Safe Introspection (post-fix)
- Same schema as Test 1.
- Assert no `KeyNotFoundException` is thrown during introspection/schema init.

### Test 3: Validation Error for Orphan Interface
- Same schema as Test 1.
- Run type-map validation entry point.
- Assert deterministic error contains interface name and orphaned-implementation message.

### Test 4: Normal Interface Implementations
- Interface with one object implementation.
- Assert introspection returns that object in possible types.

### Test 5: Multiple Implementations
- Interface with two object implementations.
- Assert introspection returns both possible types.

### Test 6: Mixed Schema Stability
- Include additional unrelated interfaces/unions/objects.
- Assert no crashes and correct possible type resolution across all abstract types.

## Acceptance Criteria
1. No `KeyNotFoundException` from `getPossibleTypes` for missing interface key.
2. Orphan interface case yields controlled behavior (empty set + validation error, or direct structured validation error per chosen policy).
3. Existing interface/union introspection behavior remains unchanged for valid schemas.
4. Tests cover single/multiple/no implementations and pass consistently.

## Backward Compatibility Notes
- Safe lookup is non-breaking for valid schemas.
- Invalid schemas move from low-level exception to explicit, actionable diagnostics.

## Implementation Notes
- Keep error text stable for test assertions.
- Prefer adding tests before/with fix to prevent future regressions.
127 changes: 127 additions & 0 deletions docs/covariance-validation-test-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Covariance Validation Test Specification

## Purpose
Define exhaustive tests for GraphQL interface implementation covariance and nullability compatibility in FSharp.Data.GraphQL type validation.

## Problem Statement (Current Bug)
During schema initialization, executor calls `Validation.Types.validateTypeMap schema.TypeMap` and throws `GQLMessageException` when validation returns errors.

Observed runtime error pattern:
- `'<Object>.<field>' field signature does not match it's definition in interface <Interface>`

Exact failure location in library:
- `src/FSharp.Data.GraphQL.Server/Executor.fs` (schema startup validation)
- `src/FSharp.Data.GraphQL.Shared/Validation.fs`, function `validateImplements`

Current implementation in `validateImplements` uses strict equality:
- `Some objf when objf = f -> acc`
- otherwise reports signature mismatch

This equality-based check is stricter than GraphQL spec subtyping rules for interface field return types and nullability covariance.

## GraphQL Compatibility Rules to Validate
For object type `O implements I`, each interface field `f` must satisfy:
1. Field exists on object with same name.
2. Arguments are compatible (same required args; extra args on object must be optional).
3. Return type on object is equal to or a valid subtype of interface return type.
4. Non-null covariance: `T!` is subtype of `T` (allowed).
5. List/null wrappers must be compared structurally by spec subtyping rules.
6. If interface return type is interface/union, object return type may be a concrete implementing/member type (covariance).

## Nullable / StructNullable Coverage
In this codebase:
- `Nullable X` and `StructNullable X` both produce nullable GraphQL wrappers.
- Non-wrapper `X` is non-null GraphQL type.

Tests must cover both wrappers equivalently for compatibility decisions:
- `Nullable InterfaceType` vs concrete non-null implementor type.
- `StructNullable InterfaceType` vs concrete non-null implementor type.
- `Nullable T` vs `Nullable T` exact match.
- `StructNullable T` vs `StructNullable T` exact match.
- Negative cases where nested wrappers are incompatible (e.g., list item nullability mismatch).

## Generic Test Model (No domain-specific names)
Use neutral names only:

Interfaces:
- `IParentView`
- `IChildView`

GraphQL interfaces:
- `IChildInfo`
- `IParentInfo` with field `child: IChildInfo`

Concrete object types:
- `ChildAInfo implements IChildInfo`
- `ChildBInfo implements IChildInfo`
- `ParentAInfo implements IParentInfo` with `child: ChildAInfo`
- `ParentBInfo implements IParentInfo` with `child: ChildBInfo`

This model must be reused for all covariance and nullability test cases.

## Test Matrix (Must Cover All Cases)
### A. Positive covariance cases (must pass)
1. Interface field type `IChildInfo`, object field type `ChildAInfo` (implements `IChildInfo`).
2. Same as A1 for second implementation (`ChildBInfo`).
3. Interface field `Nullable IChildInfo`, object field non-null `ChildAInfo`.
4. Interface field `StructNullable IChildInfo`, object field non-null `ChildAInfo`.
5. Interface field non-null `IChildInfo`, object field same non-null `IChildInfo` (exact).
6. Interface field list `List<IChildInfo>`, object field list `List<ChildAInfo>` where library supports list covariance by member subtype.
7. Deep wrappers: interface `Nullable(List(Nullable(IChildInfo)))`, object `List(ChildAInfo)` where valid by non-null covariance.

### B. Negative covariance cases (must fail)
1. Interface field `IChildInfo`, object field unrelated object type `OtherInfo` (not implementing).
2. Interface field non-null `IChildInfo`, object field nullable `Nullable IChildInfo` (wider, invalid).
3. Interface field list `List<IChildInfo>`, object field scalar `ChildAInfo`.
4. Interface field `List<NonNull IChildInfo>`, object field `List<Nullable ChildAInfo>` (invalid nullability widening).
5. Interface field arguments mismatch (missing required arg, type mismatch, extra required arg).

### C. Nullable vs StructNullable parity (must pass/fail identically)
For each scenario A3, A4, B2, B4 create paired tests:
- one with `Nullable`
- one with `StructNullable`
Expected result must be identical for semantic-equivalent wrappers.

### D. Existing strict-equality regression (must reproduce old bug)
Create a test where only difference is:
- interface field type = interface def
- object field type = implementing concrete object def

Expected by spec: Success.
Current behavior before fix: ValidationError with signature mismatch message.
This test documents the bug and prevents reintroduction.

## Test File Placement
- Extend `tests/FSharp.Data.GraphQL.Tests/TypeValidationTests.fs` for focused unit cases, or
- create `tests/FSharp.Data.GraphQL.Tests/TypeValidationCovarianceTests.fs` if separation is preferred.

## Assertion Style
- Use `validateImplements` for unit-level behavior.
- Use `validateTypeMap` for end-to-end schema-level validation with multiple types registered.
- Verify exact error strings for negative tests where stable, otherwise verify error contains object+field+interface identifiers.

## Proposed Fix in Validation Engine
Replace strict `objf = f` signature equality with structural GraphQL compatibility check:
1. Compare field names and argument compatibility by spec rules.
2. Compare return types via `isOutputSubtype(objectType, interfaceType)`.
3. Implement recursive wrapper-aware subtype check:
- `NonNull(A)` subtype of `A`
- `List(A)` subtype of `List(B)` iff `A` subtype of `B`
- object subtype of interface if object implements interface
- object subtype of union if object is a union member
- named scalars/enums require exact type identity

Pseudo-contract:
- `isFieldImplementationCompatible(objectField, interfaceField) -> bool`
- used by `validateImplements` instead of direct equality.

## Acceptance Criteria
1. All positive covariance tests pass.
2. All negative compatibility tests fail with deterministic errors.
3. Nullable/StructNullable parity tests pass.
4. No regressions in existing `TypeValidationTests.fs`.
5. Schema initialization no longer throws for valid covariance implementations.

## Notes for Reviewers
- This is a spec-driven validation correction, not a domain-model workaround.
- Goal is GraphQL spec compliance at type-system validation layer.
Loading
Loading