diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..c97fe61 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +github-cli 2.42.1 diff --git a/CLAUDE.md b/CLAUDE.md index e0468e1..ada85d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ tests/ F# 8.0+ / .NET SDK 8.0+: Follow standard conventions ## Recent Changes +- 003-recursive-types: Added F# 8.0+ / .NET SDK 8.0+ + FSharp.Core, FSharp.SystemTextJson (Core); NJsonSchema (main package); Microsoft.OpenApi (OpenApi package) - 002-extended-types: Added F# 8.0+ / .NET SDK 8.0+ + FSharp.Core, FSharp.SystemTextJson (Core); NJsonSchema (main package); Microsoft.OpenApi (OpenApi package) - 001-core-extraction: Added F# 8.0+ / .NET SDK 8.0+ diff --git a/specs/003-recursive-types/checklists/requirements.md b/specs/003-recursive-types/checklists/requirements.md new file mode 100644 index 0000000..512a9e1 --- /dev/null +++ b/specs/003-recursive-types/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Recursive Type Schema Generation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-06 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. The spec is ready for `/speckit.clarify` or `/speckit.plan`. +- The Assumptions section acknowledges that existing recursion detection infrastructure likely already handles this; the feature is primarily about validation, test coverage, and documenting the behavior. diff --git a/specs/003-recursive-types/contracts/expected-schemas.md b/specs/003-recursive-types/contracts/expected-schemas.md new file mode 100644 index 0000000..c9ef14c --- /dev/null +++ b/specs/003-recursive-types/contracts/expected-schemas.md @@ -0,0 +1,215 @@ +# Expected Schema Contracts: Recursive Types + +**Feature**: 003-recursive-types +**Date**: 2026-02-06 + +## Contract 1: Self-Recursive DU (TreeNode) + +**Input Type**: +```fsharp +type TreeNode = + | Leaf of int + | Branch of TreeNode * TreeNode +``` + +**Expected JSON Schema** (NJsonSchema output, InternalTag encoding): +```json +{ + "title": "TreeNode", + "definitions": { + "Leaf": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "Item"], + "properties": { + "kind": { + "type": "string", + "default": "Leaf", + "enum": ["Leaf"], + "x-enumNames": ["Leaf"] + }, + "Item": { + "type": "integer", + "format": "int32" + } + } + }, + "Branch": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "Item1", "Item2"], + "properties": { + "kind": { + "type": "string", + "default": "Branch", + "enum": ["Branch"], + "x-enumNames": ["Branch"] + }, + "Item1": { + "$ref": "#" + }, + "Item2": { + "$ref": "#" + } + } + } + }, + "anyOf": [ + { "$ref": "#/definitions/Leaf" }, + { "$ref": "#/definitions/Branch" } + ] +} +``` + +**Key assertions**: +- `Item1` and `Item2` in Branch case both use `$ref: "#"` (root self-reference) +- No infinite nesting or expansion + +## Contract 2: Self-Recursive Record (LinkedNode) + +**Input Type**: +```fsharp +type LinkedNode = { Value: int; Next: LinkedNode option } +``` + +**Expected JSON Schema**: +```json +{ + "title": "LinkedNode", + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "integer", + "format": "int32" + }, + "next": { + "anyOf": [ + { "$ref": "#" }, + { "type": "null" } + ] + } + } +} +``` + +**Key assertions**: +- `next` field uses `$ref: "#"` wrapped in anyOf with null (nullable self-reference) +- No definitions needed (record is the root, self-reference uses "#") + +## Contract 3: Recursion Through Collection (TreeRecord) + +**Input Type**: +```fsharp +type TreeRecord = { Value: string; Children: TreeRecord list } +``` + +**Expected JSON Schema**: +```json +{ + "title": "TreeRecord", + "type": "object", + "additionalProperties": false, + "required": ["value", "children"], + "properties": { + "value": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#" + } + } + } +} +``` + +**Key assertions**: +- `children` array items use `$ref: "#"` (root self-reference) +- No definitions needed + +## Contract 4: Multi-Case Self-Recursive DU (Expression) + +**Input Type**: +```fsharp +type Expression = + | Literal of int + | Add of Expression * Expression + | Negate of Expression +``` + +**Expected JSON Schema**: +```json +{ + "title": "Expression", + "definitions": { + "Literal": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "Item"], + "properties": { + "kind": { + "type": "string", + "default": "Literal", + "enum": ["Literal"], + "x-enumNames": ["Literal"] + }, + "Item": { + "type": "integer", + "format": "int32" + } + } + }, + "Add": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "Item1", "Item2"], + "properties": { + "kind": { + "type": "string", + "default": "Add", + "enum": ["Add"], + "x-enumNames": ["Add"] + }, + "Item1": { + "$ref": "#" + }, + "Item2": { + "$ref": "#" + } + } + }, + "Negate": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "Item"], + "properties": { + "kind": { + "type": "string", + "default": "Negate", + "enum": ["Negate"], + "x-enumNames": ["Negate"] + }, + "Item": { + "$ref": "#" + } + } + } + }, + "anyOf": [ + { "$ref": "#/definitions/Literal" }, + { "$ref": "#/definitions/Add" }, + { "$ref": "#/definitions/Negate" } + ] +} +``` + +**Key assertions**: +- All recursive case fields (Add.Item1, Add.Item2, Negate.Item) use `$ref: "#"` +- Non-recursive case (Literal) has normal integer field + +--- + +**Note**: These schemas are approximate. The actual output will be determined by running the tests and capturing verified snapshots. Minor formatting differences (property ordering, whitespace) may vary. diff --git a/specs/003-recursive-types/data-model.md b/specs/003-recursive-types/data-model.md new file mode 100644 index 0000000..6e5202b --- /dev/null +++ b/specs/003-recursive-types/data-model.md @@ -0,0 +1,119 @@ +# Data Model: Recursive Type Schema Generation + +**Feature**: 003-recursive-types +**Date**: 2026-02-06 + +## Entities + +### SchemaNode (Existing - No Changes) + +The core intermediate representation for JSON Schema nodes. Already includes the `Ref` variant that handles recursive references. + +| Variant | Description | Recursive Relevance | +|---------|-------------|-------------------| +| `Ref of typeId: string` | Reference to another schema definition | `"#"` = self-reference to root; other strings = definition reference | +| `AnyOf of SchemaNode list` | Union of schemas (DU representation) | Root DU produces AnyOf of Refs to case definitions | +| `Object` | Object with properties | DU cases and records produce Object nodes | +| `Array of SchemaNode` | Array with item schema | Items can be `Ref` for recursive collections | +| `Nullable of SchemaNode` | Nullable wrapper | Inner schema can be `Ref` for recursive option fields | +| Other variants | Primitive, Enum, Map, Const, OneOf, Any | Not directly involved in recursion | + +### SchemaDocument (Existing - No Changes) + +``` +SchemaDocument = { + Root: SchemaNode -- Top-level schema (AnyOf for DUs, Object for records) + Definitions: (string * SchemaNode) list -- Named definitions in insertion order +} +``` + +### Recursion Detection State (Internal - No Changes) + +| State | Type | Purpose | +|-------|------|---------| +| `visiting` | `HashSet` | Tracks types currently being analyzed (cycle detection) | +| `analyzed` | `Dictionary` | Caches completed type-to-typeId mappings | +| `definitions` | `Dictionary` | Accumulates definitions in insertion order | + +## Recursive Type Patterns + +### Pattern 1: Self-Recursive DU (Issue #15) + +**F# Type**: +```fsharp +type TreeNode = + | Leaf of int + | Branch of TreeNode * TreeNode +``` + +**Expected SchemaDocument**: +``` +Root = AnyOf [Ref "Leaf"; Ref "Branch"] +Definitions = [ + ("Leaf", Object { Properties = [kind="Leaf" (Const); Item (Primitive Int)] }) + ("Branch", Object { Properties = [kind="Branch" (Const); Item1 (Ref "#"); Item2 (Ref "#")] }) +] +``` + +### Pattern 2: Self-Recursive Record + +**F# Type**: +```fsharp +type LinkedNode = { Value: int; Next: LinkedNode option } +``` + +**Expected SchemaDocument**: +``` +Root = Object { Properties = [value (Primitive Int); next (Nullable (Ref "#"))] } +Definitions = [] +``` + +### Pattern 3: Recursion Through Collection + +**F# Type**: +```fsharp +type TreeRecord = { Value: string; Children: TreeRecord list } +``` + +**Expected SchemaDocument**: +``` +Root = Object { Properties = [value (Primitive String); children (Array (Ref "#"))] } +Definitions = [] +``` + +### Pattern 4: Multi-Case Self-Recursive DU + +**F# Type**: +```fsharp +type Expression = + | Literal of int + | Add of Expression * Expression + | Negate of Expression +``` + +**Expected SchemaDocument**: +``` +Root = AnyOf [Ref "Literal"; Ref "Add"; Ref "Negate"] +Definitions = [ + ("Literal", Object { Properties = [kind="Literal" (Const); Item (Primitive Int)] }) + ("Add", Object { Properties = [kind="Add" (Const); Item1 (Ref "#"); Item2 (Ref "#")] }) + ("Negate", Object { Properties = [kind="Negate" (Const); Item (Ref "#")] }) +] +``` + +## Relationships + +``` +SchemaAnalyzer.analyze + ├── analyzeType → analyzeMultiCaseDU (for DU types) + │ ├── buildCaseSchema → analyzeDuCaseFieldSchema + │ │ └── getOrAnalyzeRef → detects visiting set → Ref "#" or Ref typeId + │ └── definitions accumulate case schemas + ├── analyzeType → analyzeRecord (for record types) + │ └── analyzeFieldSchema → getOrAnalyzeRef → Ref "#" or Ref typeId + └── Returns SchemaDocument { Root; Definitions } +``` + +## State Transitions + +No state transitions apply - schema generation is a pure analysis pass that reads F# type metadata and produces an immutable SchemaDocument. diff --git a/specs/003-recursive-types/plan.md b/specs/003-recursive-types/plan.md new file mode 100644 index 0000000..e8f0953 --- /dev/null +++ b/specs/003-recursive-types/plan.md @@ -0,0 +1,84 @@ +# Implementation Plan: Recursive Type Schema Generation + +**Branch**: `003-recursive-types` | **Date**: 2026-02-06 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/003-recursive-types/spec.md` + +## Summary + +Resolve GitHub issue #15 by adding comprehensive test coverage for self-recursive F# types in JSON Schema generation. Research confirms the existing `SchemaAnalyzer` recursion detection mechanism (`visiting` HashSet + `analyzed` cache) already handles self-recursive types correctly. This feature is primarily test-first validation: define recursive test types, write tests across Core IR, NJsonSchema, and OpenApi, and fix any bugs discovered. No new public API surface is expected. + +## Technical Context + +**Language/Version**: F# 8.0+ / .NET SDK 8.0+ +**Primary Dependencies**: FSharp.Core, FSharp.SystemTextJson (Core); NJsonSchema (main package); Microsoft.OpenApi (OpenApi package) +**Storage**: N/A (library, no persistence) +**Testing**: Expecto + Verify (snapshot testing), multi-target (net8.0, net9.0, net10.0) +**Target Platform**: netstandard2.0 through net10.0 +**Project Type**: Multi-package NuGet library +**Performance Goals**: Schema generation must complete without infinite loops for all recursive type patterns +**Constraints**: No new runtime dependencies; all existing 141 tests must continue passing +**Scale/Scope**: ~4 new test types, ~15-20 new tests across 3 test projects + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. F#-Idiomatic API Design | PASS | Self-recursive DUs and records are core F# patterns; schema must handle them correctly | +| II. Minimal Dependency Surface | PASS | No new dependencies; changes are test-only | +| III. Broad Framework Compatibility | PASS | Tests run on net8.0, net9.0, net10.0; no target changes | +| IV. Schema Stability via Snapshot Testing | PASS | New snapshot tests will be added for all recursive type patterns | +| V. Simplicity and Focus | PASS | Test coverage for existing functionality; no scope creep | +| VI. Semantic Versioning Discipline | PASS | PATCH bump if bug fixes needed; otherwise test-only (no version change needed) | + +**Post-Design Re-check**: All principles remain satisfied. No new abstractions, dependencies, or API surface introduced. + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-recursive-types/ +├── plan.md # This file +├── spec.md # Feature specification +├── research.md # Phase 0: Research findings +├── data-model.md # Phase 1: Expected schema structures +├── quickstart.md # Phase 1: Implementation guide +├── contracts/ # Phase 1: Expected schema contracts +│ └── expected-schemas.md +├── checklists/ +│ └── requirements.md # Spec quality checklist +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +src/ +├── FSharp.Data.JsonSchema.Core/ +│ └── SchemaAnalyzer.fs # May need fixes if tests reveal bugs +├── FSharp.Data.JsonSchema/ +│ └── NJsonSchemaTranslator.fs # May need fixes if tests reveal bugs +└── FSharp.Data.JsonSchema.OpenApi/ + └── OpenApiSchemaTranslator.fs # May need fixes if tests reveal bugs + +test/ +├── FSharp.Data.JsonSchema.Core.Tests/ +│ ├── TestTypes.fs # Add recursive test types +│ └── AnalyzerTests.fs # Add Core IR recursion tests +├── FSharp.Data.JsonSchema.Tests/ +│ ├── TestTypes.fs # Add recursive test types +│ ├── GeneratorTests.fs # Add NJsonSchema snapshot tests +│ ├── ValidationTests.fs # Add recursive validation tests +│ └── generator-verified/ # New snapshot files (auto-generated) +└── FSharp.Data.JsonSchema.OpenApi.Tests/ + ├── TransformerIntegrationTests.fs # Add OpenApi recursion tests + └── (TestTypes.fs if needed) +``` + +**Structure Decision**: Existing multi-package library structure is used unchanged. All changes are in test projects, with potential minor fixes in source projects if bugs are discovered. + +## Complexity Tracking + +No constitution violations. No complexity tracking needed. diff --git a/specs/003-recursive-types/quickstart.md b/specs/003-recursive-types/quickstart.md new file mode 100644 index 0000000..d2b4d1a --- /dev/null +++ b/specs/003-recursive-types/quickstart.md @@ -0,0 +1,84 @@ +# Quickstart: Recursive Type Schema Generation + +**Feature**: 003-recursive-types +**Date**: 2026-02-06 + +## Overview + +This feature adds comprehensive test coverage for recursive type patterns in JSON Schema generation. The underlying recursion detection mechanism already exists in the SchemaAnalyzer; this work validates and documents that behavior with snapshot-verified tests across all three output targets. + +## What's Changing + +### Test Types to Add + +Add these types to the test type files (Core, NJsonSchema, OpenApi): + +```fsharp +// Self-recursive DU (Issue #15 pattern) +type TreeNode = + | Leaf of int + | Branch of TreeNode * TreeNode + +// Self-recursive record +type LinkedNode = { Value: int; Next: LinkedNode option } + +// Recursion through collection +type TreeRecord = { Value: string; Children: TreeRecord list } + +// Multi-case self-recursive DU +type Expression = + | Literal of int + | Add of Expression * Expression + | Negate of Expression +``` + +### Tests to Add + +**Core Tests (AnalyzerTests.fs)**: +- Self-recursive DU produces definitions with `Ref "#"` +- Self-recursive record produces `Ref "#"` for recursive field +- Recursion through list produces `Array(Ref "#")` +- Multi-case self-recursive DU produces correct definitions + +**NJsonSchema Tests (GeneratorTests.fs)**: +- Snapshot tests for each recursive type pattern +- Validation tests for recursive type instances + +**OpenApi Tests (TranslatorTests.fs)**: +- Recursive type translation produces correct component references +- Self-reference resolves to root schema title + +## Implementation Approach + +1. **Test-first**: Write all test types and tests before any code changes +2. **Run tests**: If all pass, the feature is complete (existing mechanism works) +3. **Fix if needed**: If any tests fail, fix the specific bug in SchemaAnalyzer +4. **Snapshot approval**: Review and approve all new snapshot files +5. **Close issue #15**: Reference the test evidence in the issue closure + +## Key Files + +| File | Action | +|------|--------| +| `test/FSharp.Data.JsonSchema.Core.Tests/TestTypes.fs` | Add recursive test types | +| `test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs` | Add Core IR tests | +| `test/FSharp.Data.JsonSchema.Tests/TestTypes.fs` | Add recursive test types | +| `test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs` | Add snapshot tests | +| `test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs` | Add validation tests | +| `test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs` | Add OpenApi tests | + +## Verification + +```bash +# Run all tests +dotnet test + +# Run only Core tests +dotnet test test/FSharp.Data.JsonSchema.Core.Tests/ + +# Run only NJsonSchema tests +dotnet test test/FSharp.Data.JsonSchema.Tests/ + +# Run only OpenApi tests +dotnet test test/FSharp.Data.JsonSchema.OpenApi.Tests/ +``` diff --git a/specs/003-recursive-types/research.md b/specs/003-recursive-types/research.md new file mode 100644 index 0000000..8f123c6 --- /dev/null +++ b/specs/003-recursive-types/research.md @@ -0,0 +1,73 @@ +# Research: Recursive Type Schema Generation + +**Feature**: 003-recursive-types +**Date**: 2026-02-06 + +## Decision 1: Existing Recursion Detection Is Already Functional + +**Decision**: The existing `visiting` HashSet / `analyzed` Dictionary mechanism in SchemaAnalyzer correctly handles self-recursive types. This feature is primarily about adding test coverage and documenting existing behavior, not implementing new recursion detection. + +**Rationale**: Code analysis of `SchemaAnalyzer.fs` shows: +- `getOrAnalyzeRef` (lines 142-155) checks `visiting.Contains ty` before recursing +- When a type is currently being visited, it returns `"#"` for root self-reference or `typeId` for non-root back-reference +- The `analyzeMultiCaseDU` function (lines 695-714) correctly handles root DU case expansion with definitions +- `analyzeDuCaseFieldSchema` (lines 312-337) routes non-primitive field types through `getOrAnalyzeRef` + +**Trace for `type Node = | Leaf of int | Node of Node * Node`**: +1. `visiting.Add(Node)` at entry +2. `analyzeMultiCaseDU` detects `isRoot = true` +3. For `Leaf of int` case: `int` is primitive, inlined directly +4. For `Node of Node * Node` case: each `Node` field hits `analyzeDuCaseFieldSchema` → `getOrAnalyzeRef(Node)` → `visiting.Contains(Node)=true` → returns `"#"` → produces `Ref "#"` +5. Result: Root is `AnyOf [Ref "Leaf"; Ref "Node"]` with definitions for both cases + +**Alternatives considered**: +- Reimplementing recursion detection: Unnecessary, existing mechanism is correct +- Adding depth limits: Not needed for schema generation (only produces references, not infinite expansion) + +## Decision 2: Test Coverage Strategy + +**Decision**: Add comprehensive test coverage across all three test projects for self-recursive types, recursion through collections, and recursion through options. + +**Rationale**: Current test coverage only includes: +- Mutually recursive DUs (Chicken/Egg) in Core and NJsonSchema tests +- Mutually recursive DUs with options (Even/Odd) in Core and NJsonSchema tests +- No self-recursive DU tests (the exact pattern from issue #15) +- No recursive record tests +- No recursion-through-collection tests +- No OpenApi tests for any recursive patterns +- No validation tests for recursive types + +**Test types to add**: +1. `type TreeNode = | Leaf of int | Branch of TreeNode * TreeNode` (self-recursive DU, issue #15 pattern) +2. `type LinkedNode = { Value: int; Next: LinkedNode option }` (self-recursive record) +3. `type TreeRecord = { Value: string; Children: TreeRecord list }` (recursion through collection) +4. `type Expression = | Literal of int | Add of Expression * Expression | Negate of Expression` (multi-case self-recursive DU) + +## Decision 3: Reference Format Across Translators + +**Decision**: Self-references use `Ref "#"` in the Core IR, which translates to: +- NJsonSchema: `Reference = rootSchema` (object identity) +- OpenApi NET9: `schema.Reference = OpenApiReference(Id = rootTitle)` +- OpenApi NET10: `s.AnyOf.Add(OpenApiSchemaReference(rootTitle, null))` + +**Rationale**: All three translators already handle `Ref "#"` correctly. The NJsonSchema translator produces `$ref: "#"` in JSON output. OpenApi translators resolve "#" to the root schema's title for component reference naming. + +## Decision 4: Versioning Impact + +**Decision**: This is a PATCH bump (bug fix / test coverage addition), not a MINOR or MAJOR bump. + +**Rationale**: Per constitution principle VI (Semantic Versioning Discipline): +- No changes to generated schema output for existing types (no MAJOR bump needed) +- No new public API surface (no MINOR bump needed) +- If any bugs are found and fixed during testing, those are PATCH-level fixes +- Adding test coverage and documentation is a PATCH activity + +## Decision 5: No Code Changes Expected (Validation-First Approach) + +**Decision**: Write tests first. If all tests pass without code changes, the feature is "verify existing behavior works for issue #15 patterns." If any tests fail, fix the bugs as part of this feature. + +**Rationale**: The code analysis strongly suggests the recursion mechanism already works for self-recursive types. The test-first approach: +1. Proves the behavior works (or identifies specific failures) +2. Prevents regression in future changes +3. Satisfies constitution principle IV (Schema Stability via Snapshot Testing) +4. Closes GitHub issue #15 with concrete evidence diff --git a/specs/003-recursive-types/spec.md b/specs/003-recursive-types/spec.md new file mode 100644 index 0000000..0aa352a --- /dev/null +++ b/specs/003-recursive-types/spec.md @@ -0,0 +1,94 @@ +# Feature Specification: Recursive Type Schema Generation + +**Feature Branch**: `003-recursive-types` +**Created**: 2026-02-06 +**Status**: Draft +**Input**: User description: "Resolve GitHub issue #15: how to add a schema generator for a recursive schema like `type Node = | Leaf of int | Node of Node * Node`" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Self-Recursive Discriminated Union (Priority: P1) + +A library consumer defines a self-recursive discriminated union type (e.g., a tree node type where one case contains the same type) and generates a JSON Schema from it. The schema generation completes without infinite loops and produces a valid JSON Schema that uses `$ref` to represent the recursive reference. + +**Why this priority**: This is the exact scenario described in GitHub issue #15. Self-recursive DU types are common in F# domain modeling (expression trees, ASTs, nested data structures). Generating schemas for these types without hanging is the core ask. + +**Independent Test**: Can be fully tested by defining a self-recursive DU type, invoking the schema generator, and verifying that the output is a valid JSON Schema with correct `$ref` references and no infinite recursion. + +**Acceptance Scenarios**: + +1. **Given** a self-recursive DU type `type Node = | Leaf of int | Node of Node * Node`, **When** the schema generator analyzes this type, **Then** the generation completes without hanging and produces a valid JSON Schema document. +2. **Given** a self-recursive DU type, **When** the generated schema is examined, **Then** the recursive case uses `$ref` to reference the root type rather than embedding an infinite expansion. +3. **Given** a self-recursive DU type, **When** the generated schema is used to validate a valid JSON instance (e.g., `{"Node": {"Item1": {"Leaf": {"Item": 1}}, "Item2": {"Leaf": {"Item": 2}}}}`), **Then** the instance validates successfully. + +--- + +### User Story 2 - Self-Recursive Record Type (Priority: P2) + +A library consumer defines a record type that references itself (e.g., a linked list node with a field of its own type wrapped in option) and generates a JSON Schema. The schema generation produces correct `$ref` references for the recursive field. + +**Why this priority**: Self-recursive records are another common F# pattern alongside DUs. Supporting this ensures recursive type handling works across F# type constructs, not just DUs. + +**Independent Test**: Can be fully tested by defining a self-recursive record type, generating a schema, and verifying `$ref` references appear for the recursive fields. + +**Acceptance Scenarios**: + +1. **Given** a self-recursive record type `type LinkedNode = { Value: int; Next: LinkedNode option }`, **When** the schema generator analyzes this type, **Then** the generation completes and produces a valid JSON Schema with `$ref` for the `Next` field. +2. **Given** the generated schema, **When** validating a nested JSON instance, **Then** the schema correctly validates instances at arbitrary depth. + +--- + +### User Story 3 - Deeply Nested Recursive Structures (Priority: P3) + +A library consumer defines types with deep or multi-level recursion (e.g., mutually recursive types, or a type that references itself through an intermediate type) and generates schemas. The schema generation handles all recursion patterns consistently, producing `$ref` references wherever recursion occurs. + +**Why this priority**: While basic self-recursion is the most common case, real-world domain models sometimes involve indirect recursion or recursion through intermediate types (e.g., `Tree` -> `Branch` -> `Tree`). Ensuring all recursion patterns are handled increases library robustness. + +**Independent Test**: Can be fully tested by defining mutually recursive types and types with indirect recursion, generating schemas, and verifying all recursive references resolve correctly. + +**Acceptance Scenarios**: + +1. **Given** mutually recursive types (e.g., `type A = | HasB of B and type B = | HasA of A`), **When** the schema generator analyzes either type, **Then** the schema contains definitions for both types with `$ref` cross-references. +2. **Given** a type with recursion through a collection (e.g., `type Tree = { Children: Tree list }`), **When** the schema generator analyzes this type, **Then** the array items schema uses `$ref` to reference the parent type. + +--- + +### Edge Cases + +- What happens when a recursive type references itself through multiple case fields (e.g., `Node of Node * Node` with two self-references in the same case)? +- How does the system handle recursion through option types (e.g., `type T = { Child: T option }`)? +- How does the system handle recursion through collections (e.g., `type T = { Children: T list }`)? +- What happens with deeply nested instantiation during validation (e.g., 100 levels deep)? +- How does the system handle a DU case where the recursive reference is one of several fields of different types? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The schema generator MUST produce a valid JSON Schema document for any self-recursive discriminated union type without entering an infinite loop. +- **FR-002**: The schema generator MUST represent recursive type references using JSON Schema `$ref` notation pointing to the appropriate definition. +- **FR-003**: The schema generator MUST produce a valid JSON Schema document for self-recursive record types without entering an infinite loop. +- **FR-004**: The schema generator MUST handle recursion that occurs through collection types (e.g., a type containing a list of itself). +- **FR-005**: The schema generator MUST handle recursion that occurs through option-wrapped fields. +- **FR-006**: The generated schema MUST validate correct JSON instances of recursive types at arbitrary nesting depth. +- **FR-007**: The schema generation MUST work consistently across all output targets (Core IR, NJsonSchema, OpenApi). + +### Key Entities + +- **Self-Recursive Type**: A type whose definition references itself directly in one or more of its fields or cases. Examples include tree structures, expression ASTs, and linked lists. +- **Recursive Reference**: A `$ref` entry in the generated JSON Schema that points back to the type's own definition or to a mutually referenced definition, preventing infinite schema expansion. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Schema generation for all recursive type patterns (self-recursive DU, self-recursive record, recursion through collections, recursion through options) completes without hanging. +- **SC-002**: 100% of generated schemas for recursive types pass JSON Schema validation (the schema itself is a valid JSON Schema document). +- **SC-003**: Valid JSON instances of recursive types at various nesting depths (1, 3, 10 levels) validate successfully against the generated schema. +- **SC-004**: All recursive type test cases produce deterministic, snapshot-verified output across Core IR, NJsonSchema, and OpenApi translators. + +## Assumptions + +- The F# `System.Text.Json` serialization conventions (via `FSharp.SystemTextJson`) determine the JSON structure of recursive types, and the schema should match those conventions. +- The existing `visiting` set / `analyzed` cache mechanism in the SchemaAnalyzer is the correct foundation for recursion detection; this feature validates and extends test coverage for that mechanism rather than replacing it. +- Mutual recursion between two distinct types (e.g., Chicken/Egg) is already tested and working; this feature focuses primarily on self-referencing types as reported in issue #15. diff --git a/specs/003-recursive-types/tasks.md b/specs/003-recursive-types/tasks.md new file mode 100644 index 0000000..dc103b2 --- /dev/null +++ b/specs/003-recursive-types/tasks.md @@ -0,0 +1,194 @@ +# Tasks: Recursive Type Schema Generation + +**Input**: Design documents from `/specs/003-recursive-types/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ + +**Tests**: This feature is test-first by design (research Decision 5). All tasks are tests or test support, with potential bug fixes if tests reveal issues. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Verify existing tests pass and establish baseline + +- [ ] T001 Run full test suite to confirm all 141 existing tests pass as baseline via `dotnet test` + +--- + +## Phase 2: Foundational (Test Types) + +**Purpose**: Define all recursive test types needed across user stories. Types must be added to TestTypes.fs files before any tests can be written. + +- [ ] T002 [P] Add `TreeNode`, `LinkedNode`, `TreeRecord`, and `Expression` recursive type definitions to `test/FSharp.Data.JsonSchema.Core.Tests/TestTypes.fs` (see data-model.md for exact type definitions) +- [ ] T003 [P] Add `TreeNode`, `LinkedNode`, `TreeRecord`, and `Expression` recursive type definitions to `test/FSharp.Data.JsonSchema.Tests/TestTypes.fs` (same types as Core) + +**Checkpoint**: Test types defined, all existing tests still pass + +--- + +## Phase 3: User Story 1 - Self-Recursive Discriminated Union (Priority: P1) 🎯 MVP + +**Goal**: Prove that self-recursive DU types like `type TreeNode = | Leaf of int | Branch of TreeNode * TreeNode` (the exact issue #15 pattern) generate correct JSON Schemas with `$ref` self-references. + +**Independent Test**: Run Core analyzer tests for TreeNode and verify `Ref "#"` appears in Branch case fields. Run NJsonSchema snapshot test and verify `$ref: "#"` in output. Run validation test with a nested tree instance. + +### Tests for User Story 1 + +- [ ] T004 [P] [US1] Add Core IR tests for `TreeNode` in `test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs`: (1) self-recursive DU produces definitions for Leaf and Branch cases, (2) Branch case fields produce `Ref "#"` for both Item1 and Item2, (3) root is `AnyOf` with refs to case definitions +- [ ] T005 [P] [US1] Add NJsonSchema snapshot test for `TreeNode` in `test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs` using `verify "Self-recursive DU generates proper schema" { return generator typeof |> json }` pattern. Run once to generate snapshot, review and approve the `.verified.txt` file in `test/FSharp.Data.JsonSchema.Tests/generator-verified/` +- [ ] T006 [P] [US1] Add NJsonSchema validation test for `TreeNode` in `test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs`: serialize a nested TreeNode instance (e.g., `Branch(Leaf 1, Branch(Leaf 2, Leaf 3))`) using `Json.Serialize` and validate against generated schema +- [ ] T007 [US1] Add OpenApi integration test for `TreeNode` in `test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs`: analyze `TreeNode` with `SchemaAnalyzer.analyze`, translate with `OpenApiSchemaTranslator.translate`, verify root schema has `AnyOf` entries and component schemas are produced. Use conditional compilation for NET9/NET10 differences. + +### Bug Fix (if needed) + +- [ ] T008 [US1] If any US1 tests fail: diagnose and fix the specific issue in `src/FSharp.Data.JsonSchema.Core/SchemaAnalyzer.fs`, `src/FSharp.Data.JsonSchema/NJsonSchemaTranslator.fs`, or `src/FSharp.Data.JsonSchema.OpenApi/OpenApiSchemaTranslator.fs`. Re-run tests until all pass. + +**Checkpoint**: Self-recursive DU (issue #15 pattern) validated across Core IR, NJsonSchema, and OpenApi. All tests green. + +--- + +## Phase 4: User Story 2 - Self-Recursive Record Type (Priority: P2) + +**Goal**: Prove that self-recursive record types like `type LinkedNode = { Value: int; Next: LinkedNode option }` generate correct JSON Schemas with `$ref` self-references through option-wrapped fields. + +**Independent Test**: Run Core analyzer tests for LinkedNode and verify `Nullable(Ref "#")` for the Next field. Run NJsonSchema snapshot test and verify `$ref: "#"` in nullable context. + +### Tests for User Story 2 + +- [ ] T009 [P] [US2] Add Core IR tests for `LinkedNode` in `test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs`: (1) self-recursive record root is `Object`, (2) `next` property schema is `Nullable(Ref "#")`, (3) `value` property is `Primitive Int`, (4) no definitions needed (self-ref to root) +- [ ] T010 [P] [US2] Add NJsonSchema snapshot test for `LinkedNode` in `test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs` using the `verify` pattern. Run to generate snapshot, review and approve. +- [ ] T011 [P] [US2] Add NJsonSchema validation test for `LinkedNode` in `test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs`: serialize a linked list instance (e.g., `{ Value = 1; Next = Some { Value = 2; Next = None } }`) and validate against generated schema +- [ ] T012 [US2] Add OpenApi integration test for `LinkedNode` in `test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs`: verify root schema is object type with properties including nullable self-reference + +### Bug Fix (if needed) + +- [ ] T013 [US2] If any US2 tests fail: diagnose and fix the specific issue in source files. Re-run tests until all pass. + +**Checkpoint**: Self-recursive records validated across all targets. All tests green. + +--- + +## Phase 5: User Story 3 - Deeply Nested Recursive Structures (Priority: P3) + +**Goal**: Prove that recursion through collections and multi-case self-recursive DUs work correctly. This covers `TreeRecord` (recursion through list) and `Expression` (multi-case self-recursive DU with varying arity). + +**Independent Test**: Run Core analyzer tests for TreeRecord and Expression. Verify `Array(Ref "#")` for collection recursion and multiple `Ref "#"` patterns in Expression cases. + +### Tests for User Story 3 + +- [ ] T014 [P] [US3] Add Core IR tests for `TreeRecord` in `test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs`: (1) root is `Object`, (2) `children` property schema is `Array(Ref "#")` (recursion through list), (3) `value` property is `Primitive String` +- [ ] T015 [P] [US3] Add Core IR tests for `Expression` in `test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs`: (1) produces definitions for Literal, Add, and Negate cases, (2) Add case has two `Ref "#"` fields, (3) Negate case has one `Ref "#"` field, (4) Literal case has `Primitive Int` field +- [ ] T016 [P] [US3] Add NJsonSchema snapshot test for `TreeRecord` in `test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs` using the `verify` pattern. Run to generate snapshot, review and approve. +- [ ] T017 [P] [US3] Add NJsonSchema snapshot test for `Expression` in `test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs` using the `verify` pattern. Run to generate snapshot, review and approve. +- [ ] T018 [P] [US3] Add NJsonSchema validation tests in `test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs`: (1) validate a TreeRecord instance with nested children, (2) validate an Expression instance like `Add(Literal 1, Negate(Literal 2))` +- [ ] T019 [US3] Add OpenApi integration tests for `TreeRecord` and `Expression` in `test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs`: verify correct schema structure and component references for both types + +### Bug Fix (if needed) + +- [ ] T020 [US3] If any US3 tests fail: diagnose and fix the specific issue in source files. Re-run tests until all pass. + +**Checkpoint**: All recursive type patterns validated. All tests green. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation, cleanup, and issue closure + +- [ ] T021 Run full test suite via `dotnet test` to confirm all existing tests still pass alongside new tests +- [ ] T022 If any bug fixes were made (T008, T013, T020): update RELEASE_NOTES.md and version in `Directory.Build.props` per constitution principle VI (PATCH bump) +- [ ] T023 Close GitHub issue #15 with comment referencing the new test types and test results as evidence that recursive types are supported + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - baseline validation +- **Foundational (Phase 2)**: Depends on Phase 1 - adds test types needed by all stories +- **User Stories (Phase 3-5)**: All depend on Phase 2 (test types must exist) + - US1, US2, US3 can proceed in parallel after Phase 2 + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Phase 6)**: Depends on all user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Phase 2 - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Phase 2 - No dependencies on other stories +- **User Story 3 (P3)**: Can start after Phase 2 - No dependencies on other stories + +### Within Each User Story + +- Core IR tests, NJsonSchema snapshot tests, NJsonSchema validation tests, and OpenApi tests marked [P] can run in parallel +- OpenApi integration test depends on Core analysis working (sequential within story if bugs found) +- Bug fix task depends on identifying which tests fail + +### Parallel Opportunities + +- T002 and T003 (test type definitions in different files) can run in parallel +- Within each user story: T004/T005/T006, T009/T010/T011, T014/T015/T016/T017/T018 can all run in parallel +- User stories 1, 2, and 3 can run in parallel after Phase 2 + +--- + +## Parallel Example: User Story 1 + +```text +# After Phase 2 (test types defined), launch all US1 tests in parallel: +T004: "Core IR tests for TreeNode in AnalyzerTests.fs" +T005: "NJsonSchema snapshot test for TreeNode in GeneratorTests.fs" +T006: "NJsonSchema validation test for TreeNode in ValidationTests.fs" + +# Then sequentially: +T007: "OpenApi integration test for TreeNode in TransformerIntegrationTests.fs" +T008: "Bug fix if any tests fail" (only if needed) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Baseline validation +2. Complete Phase 2: Add test types to TestTypes.fs files +3. Complete Phase 3: US1 - Self-recursive DU (TreeNode) tests +4. **STOP and VALIDATE**: If all US1 tests pass, issue #15 is resolved for the core case +5. This alone is sufficient to close GitHub issue #15 + +### Incremental Delivery + +1. Setup + Foundational → Test types defined +2. Add US1 (self-recursive DU) → Resolves issue #15 core case (MVP!) +3. Add US2 (self-recursive record) → Extends coverage to records +4. Add US3 (collections + multi-case DU) → Full recursive type coverage +5. Each story adds confidence without breaking previous stories + +### Parallel Strategy + +With multiple agents/developers: + +1. Complete Setup + Foundational together +2. Once Phase 2 is done: + - Agent A: User Story 1 (self-recursive DU) + - Agent B: User Story 2 (self-recursive record) + - Agent C: User Story 3 (collections + multi-case DU) +3. All stories complete independently; merge and run full suite + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- This feature is validation-first: existing code likely already works; tests prove it +- If no bugs are found, no source code changes are needed (test-only feature) +- Snapshot files (`.verified.txt`) must be reviewed and approved after first test run +- Commit after each user story phase completes with all tests green diff --git a/src/FSharp.Data.JsonSchema.NJsonSchema/NJsonSchemaTranslator.fs b/src/FSharp.Data.JsonSchema.NJsonSchema/NJsonSchemaTranslator.fs index 930f644..ed2009e 100644 --- a/src/FSharp.Data.JsonSchema.NJsonSchema/NJsonSchemaTranslator.fs +++ b/src/FSharp.Data.JsonSchema.NJsonSchema/NJsonSchemaTranslator.fs @@ -104,6 +104,12 @@ module internal NJsonSchemaTranslator = let p = mkProp (JsonObjectType.Array ||| JsonObjectType.Null) p.Item <- translateNode rootSchema parentSchema defs items p + | SchemaNode.Ref "#" -> + let p = JsonSchemaProperty() + let nullSchema = mkSchema JsonObjectType.Null + p.OneOf.Add(nullSchema) + p.OneOf.Add(mkRefSchema rootSchema) + p | SchemaNode.Ref typeId -> let p = JsonSchemaProperty() let nullSchema = mkSchema JsonObjectType.Null @@ -174,6 +180,11 @@ module internal NJsonSchemaTranslator = let s = mkSchema (JsonObjectType.Array ||| JsonObjectType.Null) s.Item <- translateNode rootSchema parentSchema defs items s + | SchemaNode.Ref "#" -> + let s = JsonSchema() + s.OneOf.Add(mkSchema JsonObjectType.Null) + s.OneOf.Add(mkRefSchema rootSchema) + s | SchemaNode.Ref typeId -> let s = JsonSchema() s.OneOf.Add(mkSchema JsonObjectType.Null) diff --git a/test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs b/test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs index 9b0230a..fc70965 100644 --- a/test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs +++ b/test/FSharp.Data.JsonSchema.Core.Tests/AnalyzerTests.fs @@ -245,6 +245,47 @@ let recursiveTests = | other -> failtestf "Expected Ref #, got %A" other | other -> failtestf "Expected AnyOf, got %A" other } + + test "self-recursive DU (TreeNode) produces definitions for cases" { + let doc = analyze + Expect.isTrue (hasDef "Leaf" doc) "Leaf case definition exists" + Expect.isTrue (hasDef "Branch" doc) "Branch case definition exists" + } + + test "self-recursive DU (TreeNode) Branch fields produce Ref #" { + let doc = analyze + let branchDef = getDef "Branch" doc + // Check that the Branch definition contains at least one Ref "#" + let rec findRefs (node: SchemaNode) : SchemaNode list = + match node with + | SchemaNode.Ref "#" as r -> [r] + | SchemaNode.Object obj -> + obj.Properties |> List.collect (fun p -> findRefs p.Schema) + | SchemaNode.AnyOf nodes -> + nodes |> List.collect findRefs + | SchemaNode.OneOf (nodes, _) -> + nodes |> List.collect findRefs + | SchemaNode.Array itemSchema -> + findRefs itemSchema + | SchemaNode.Nullable schema -> + findRefs schema + | _ -> [] + let selfRefs = findRefs branchDef + Expect.isGreaterThanOrEqual (List.length selfRefs) 1 "Branch case should contain at least one self-reference (Ref #)" + } + + test "self-recursive DU (TreeNode) root is AnyOf with refs to cases" { + let doc = analyze + match doc.Root with + | SchemaNode.AnyOf cases -> + Expect.equal (List.length cases) 2 "Two cases (Leaf, Branch)" + let caseRefs = cases |> List.map (fun c -> + match c with + | SchemaNode.Ref id -> id + | other -> failtestf "Expected Ref in AnyOf, got %A" other) + Expect.equal (Set.ofList caseRefs) (Set.ofList ["Leaf"; "Branch"]) "Case references" + | other -> failtestf "Expected AnyOf at root, got %A" other + } ] [] diff --git a/test/FSharp.Data.JsonSchema.Core.Tests/TestTypes.fs b/test/FSharp.Data.JsonSchema.Core.Tests/TestTypes.fs index 376d84f..93d737d 100644 --- a/test/FSharp.Data.JsonSchema.Core.Tests/TestTypes.fs +++ b/test/FSharp.Data.JsonSchema.Core.Tests/TestTypes.fs @@ -102,3 +102,21 @@ type TestDUForEncoding = type TestDUWithAttributeOverride = | Case1 | Case2 of value: string + +// Recursive type patterns for issue #15 +// Self-recursive DU (exact pattern from GitHub issue #15) +type TreeNode = + | Leaf of int + | Branch of TreeNode * TreeNode + +// Self-recursive record +type LinkedNode = { Value: int; Next: LinkedNode option } + +// Recursion through collection +type TreeRecord = { Value: string; Children: TreeRecord list } + +// Multi-case self-recursive DU +type Expression = + | Literal of int + | Add of Expression * Expression + | Negate of Expression diff --git a/test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs b/test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs index c31ad60..64f9cee 100644 --- a/test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs +++ b/test/FSharp.Data.JsonSchema.OpenApi.Tests/TransformerIntegrationTests.fs @@ -18,6 +18,11 @@ type TestDU = | Case | WithField of value: int +// Recursive type patterns for issue #15 +type TreeNode = + | Leaf of int + | Branch of TreeNode * TreeNode + /// Integration tests verifying end-to-end SchemaAnalyzer → OpenApiSchemaTranslator pipeline. [] let integrationTests = @@ -55,4 +60,13 @@ let integrationTests = let transformer = FSharpSchemaTransformer(config) Expect.isNotNull (transformer :> obj) "transformer created with custom config" } + + test "Self-recursive DU (TreeNode) produces AnyOf and component schemas" { + let doc = SchemaAnalyzer.analyze SchemaGeneratorConfig.defaults typeof + let (schema: OASchema, components) = OpenApiSchemaTranslator.translate doc + // Root should have AnyOf entries + Expect.isGreaterThan schema.AnyOf.Count 0 "has anyOf entries for Leaf and Branch" + // Should have component schemas for the cases + Expect.isGreaterThan components.Count 0 "has component schemas for cases" + } ] diff --git a/test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs b/test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs index 908065e..aa09f1b 100644 --- a/test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs +++ b/test/FSharp.Data.JsonSchema.Tests/GeneratorTests.fs @@ -353,4 +353,24 @@ let duEncodingTests = let gen = Generator.Create(unionEncoding = FSharp.Data.JsonSchema.Core.UnionEncodingStyle.Untagged) return gen typeof |> json } + + verify "Self-recursive DU generates proper schema" { + let gen = Generator.CreateMemoized("tag") + return gen typeof |> json + } + + verify "Self-recursive record generates proper schema" { + let gen = Generator.CreateMemoized("tag") + return gen typeof |> json + } + + verify "Recursion through collection generates proper schema" { + let gen = Generator.CreateMemoized("tag") + return gen typeof |> json + } + + verify "Multi-case self-recursive DU generates proper schema" { + let gen = Generator.CreateMemoized("tag") + return gen typeof |> json + } ] diff --git a/test/FSharp.Data.JsonSchema.Tests/TestTypes.fs b/test/FSharp.Data.JsonSchema.Tests/TestTypes.fs index a72e7b7..850304f 100644 --- a/test/FSharp.Data.JsonSchema.Tests/TestTypes.fs +++ b/test/FSharp.Data.JsonSchema.Tests/TestTypes.fs @@ -156,6 +156,24 @@ type TestDUWithAttributeOverride = | Case1 | Case2 of value: string +// Recursive type patterns for issue #15 +// Self-recursive DU (exact pattern from GitHub issue #15) +type TreeNode = + | Leaf of int + | Branch of TreeNode * TreeNode + +// Self-recursive record +type LinkedNode = { Value: int; Next: LinkedNode option } + +// Recursion through collection +type TreeRecord = { Value: string; Children: TreeRecord list } + +// Multi-case self-recursive DU +type Expression = + | Literal of int + | Add of Expression * Expression + | Negate of Expression + module Util = let stripWhitespace text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", "") diff --git a/test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs b/test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs index 306d4c8..127246e 100644 --- a/test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs +++ b/test/FSharp.Data.JsonSchema.Tests/ValidationTests.fs @@ -218,4 +218,74 @@ let tests = Expect.equal actual expected "Did not roundtrip" } + test "TreeNode (self-recursive DU) with nested structure validates at depth 1" { + let schema = generator(typeof) + let instance = TreeNode.Leaf 42 + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + + test "TreeNode (self-recursive DU) with nested structure validates at depth 3" { + let schema = generator(typeof) + let instance = TreeNode.Branch(TreeNode.Leaf 1, TreeNode.Branch(TreeNode.Leaf 2, TreeNode.Leaf 3)) + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + + test "LinkedNode (self-recursive record) validates at depth 1 with None" { + let schema = generator(typeof) + let instance: LinkedNode = { Value = 42; Next = None } + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + + test "LinkedNode (self-recursive record) validates at depth 3" { + let schema = generator(typeof) + let instance: LinkedNode = { Value = 1; Next = Some { Value = 2; Next = Some { Value = 3; Next = None } } } + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + + test "TreeRecord (recursion through collection) validates at depth 1" { + let schema = generator(typeof) + let instance: TreeRecord = { Value = "root"; Children = [] } + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + + test "TreeRecord (recursion through collection) validates at depth 3" { + let schema = generator(typeof) + let instance: TreeRecord = { + Value = "root" + Children = [ + { Value = "child1"; Children = [] } + { Value = "child2"; Children = [{ Value = "grandchild"; Children = [] }] } + ] + } + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + + test "Expression (multi-case self-recursive DU) validates Literal" { + let schema = generator(typeof) + let instance: Expression = Expression.Literal 42 + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + + test "Expression (multi-case self-recursive DU) validates Add with nested Negate" { + let schema = generator(typeof) + let instance: Expression = Expression.Add(Expression.Literal 1, Expression.Negate(Expression.Literal 2)) + let json = Json.Serialize(instance, "tag") + let actual = Validation.validate schema json + "╰〳 ಠ 益 ಠೃ 〵╯" |> Expect.equal actual (Ok()) + } + ] diff --git a/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Multi-case self-recursive DU generates proper schema.verified.txt b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Multi-case self-recursive DU generates proper schema.verified.txt new file mode 100644 index 0000000..88563f8 --- /dev/null +++ b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Multi-case self-recursive DU generates proper schema.verified.txt @@ -0,0 +1,91 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Expression", + "definitions": { + "Literal": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag", + "item" + ], + "properties": { + "tag": { + "type": "string", + "default": "Literal", + "x-enumNames": [ + "Literal" + ], + "enum": [ + "Literal" + ] + }, + "item": { + "type": "integer", + "format": "int32" + } + } + }, + "Add": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag", + "item1", + "item2" + ], + "properties": { + "tag": { + "type": "string", + "default": "Add", + "x-enumNames": [ + "Add" + ], + "enum": [ + "Add" + ] + }, + "item1": { + "$ref": "#" + }, + "item2": { + "$ref": "#" + } + } + }, + "Negate": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag", + "item" + ], + "properties": { + "tag": { + "type": "string", + "default": "Negate", + "x-enumNames": [ + "Negate" + ], + "enum": [ + "Negate" + ] + }, + "item": { + "$ref": "#" + } + } + } + }, + "anyOf": [ + { + "$ref": "#/definitions/Literal" + }, + { + "$ref": "#/definitions/Add" + }, + { + "$ref": "#/definitions/Negate" + } + ] +} \ No newline at end of file diff --git a/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Recursion through collection generates proper schema.verified.txt b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Recursion through collection generates proper schema.verified.txt new file mode 100644 index 0000000..6964555 --- /dev/null +++ b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Recursion through collection generates proper schema.verified.txt @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "TreeRecord", + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "children" + ], + "properties": { + "value": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#" + } + } + } +} \ No newline at end of file diff --git a/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Self-recursive DU generates proper schema.verified.txt b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Self-recursive DU generates proper schema.verified.txt new file mode 100644 index 0000000..fbf11e6 --- /dev/null +++ b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Self-recursive DU generates proper schema.verified.txt @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "TreeNode", + "definitions": { + "Leaf": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag", + "item" + ], + "properties": { + "tag": { + "type": "string", + "default": "Leaf", + "x-enumNames": [ + "Leaf" + ], + "enum": [ + "Leaf" + ] + }, + "item": { + "type": "integer", + "format": "int32" + } + } + }, + "Branch": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag", + "item1", + "item2" + ], + "properties": { + "tag": { + "type": "string", + "default": "Branch", + "x-enumNames": [ + "Branch" + ], + "enum": [ + "Branch" + ] + }, + "item1": { + "$ref": "#" + }, + "item2": { + "$ref": "#" + } + } + } + }, + "anyOf": [ + { + "$ref": "#/definitions/Leaf" + }, + { + "$ref": "#/definitions/Branch" + } + ] +} \ No newline at end of file diff --git a/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Self-recursive record generates proper schema.verified.txt b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Self-recursive record generates proper schema.verified.txt new file mode 100644 index 0000000..d240a8e --- /dev/null +++ b/test/FSharp.Data.JsonSchema.Tests/generator-verified/GeneratorTests.Self-recursive record generates proper schema.verified.txt @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "LinkedNode", + "type": "object", + "additionalProperties": false, + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "format": "int32" + }, + "next": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#" + } + ] + } + } +} \ No newline at end of file