From 47e5e74ab296363a7ae8060d2b53a642dc48ccfb Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 10:48:49 +0100 Subject: [PATCH 1/9] fix: emit string type for custom scalar variables in JSON schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom scalars previously mapped to an untyped {} schema, which MCP/LLM tool consumers reject (properties must declare a type). Default to "string" — the wire format of virtually all opaque scalars. --- v2/pkg/engine/jsonschema/variables_schema.go | 7 +- .../jsonschema/variables_schema_test.go | 108 +++++++++++++++++- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index 2944e4ec0e..f283dc76f8 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -267,8 +267,11 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema return v.processInputObjectType(node) case ast.NodeKindScalarTypeDefinition: - schema := NewAnySchema() - // Add description if available + // Custom scalars are opaque to JSON Schema. Emit a best-effort "string" + // type: MCP/LLM tool consumers reject or degrade on untyped properties, + // and opaque scalars are overwhelmingly strings on the wire. Callers can + // override per scalar via WithScalarSchemas. + schema := NewStringSchema() if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) } diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 57ef88a6fa..95474f7769 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1609,10 +1609,12 @@ func TestBuildJsonSchema(t *testing.T) { "additionalProperties": false, "properties": { "filter": { - "description": "JSON object represented as string" + "description": "JSON object represented as string", + "type": ["string", "null"] }, "from": { - "description": "ISO-8601 date time format" + "description": "ISO-8601 date time format", + "type": ["string", "null"] } }, "type": "object" @@ -1681,4 +1683,106 @@ func TestBuildJsonSchema(t *testing.T) { assert.JSONEq(t, expectedJSON, string(data)) }) + + t.Run("query with custom scalar variables defaults to string type", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` +schema { + query: Query +} + +"""An opaque pagination cursor""" +scalar Cursor + +type Query { + items(after: Cursor, first: Int!): String +} +` + operation := ` +query Items($after: Cursor, $first: Int!) { + items(after: $after, first: $first) +} +` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "type": "object", + "properties": { + "after": { + "type": ["string", "null"], + "description": "An opaque pagination cursor" + }, + "first": { + "type": "integer" + } + }, + "required": ["first"], + "additionalProperties": false +}` + + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("input object field with custom scalar defaults to string type", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` +schema { + query: Query +} + +scalar Cursor + +input Pagination { + after: Cursor + first: Int! +} + +type Query { + items(page: Pagination!): String +} +` + operation := ` +query Items($page: Pagination!) { + items(page: $page) +} +` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + expectedJSON := `{ + "type": "object", + "properties": { + "page": { + "type": "object", + "properties": { + "after": { "type": ["string", "null"] }, + "first": { "type": "integer" } + }, + "required": ["first"], + "additionalProperties": false + } + }, + "required": ["page"], + "additionalProperties": false +}` + + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) } From 9982d8525f55647768e5a371cd498d655375ada5 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 10:52:43 +0100 Subject: [PATCH 2/9] test: rename custom scalar subtest to match string default behavior The test 'custom scalar types are represented as objects' was renamed to 'custom scalar variables with descriptions default to string type' to accurately reflect the test assertion that custom scalars emit "type": ["string", "null"] rather than an empty object. --- v2/pkg/engine/jsonschema/variables_schema_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 95474f7769..4fa8273ecd 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1554,7 +1554,7 @@ func TestBuildJsonSchema(t *testing.T) { assert.JSONEq(t, expectedJSON, string(data), "JSON schema does not match expected structure") }) - t.Run("custom scalar types are represented as objects", func(t *testing.T) { + t.Run("custom scalar variables with descriptions default to string type", func(t *testing.T) { // Define schema with custom scalar types schemaSDL := scalarDefinitions + ` schema { From 2e4f30b4686843a0fa658471f9904681007d29ba Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 10:55:20 +0100 Subject: [PATCH 3/9] feat: add deep Clone method to JsonSchema --- v2/pkg/engine/jsonschema/schema.go | 42 ++++++++++++++++++++++++ v2/pkg/engine/jsonschema/schema_test.go | 43 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 6481edc142..268ba60471 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -255,3 +255,45 @@ func (s *JsonSchema) WithNullable(nullable bool) *JsonSchema { s.Nullable = nullable return s } + +// Clone returns a deep copy of the schema. Callers that hand out schemas from +// a shared map (e.g. scalar overrides) must clone per use: the builder mutates +// Nullable on returned schemas depending on the variable's non-null context. +func (s *JsonSchema) Clone() *JsonSchema { + if s == nil { + return nil + } + clone := *s + if s.Properties != nil { + clone.Properties = make(map[string]*JsonSchema, len(s.Properties)) + for k, v := range s.Properties { + clone.Properties[k] = v.Clone() + } + } + if s.Required != nil { + clone.Required = append([]string(nil), s.Required...) + } + if s.AdditionalProperties != nil { + val := *s.AdditionalProperties + clone.AdditionalProperties = &val + } + if s.Defs != nil { + clone.Defs = make(map[string]*JsonSchema, len(s.Defs)) + for k, v := range s.Defs { + clone.Defs[k] = v.Clone() + } + } + clone.Items = s.Items.Clone() + if s.Enum != nil { + clone.Enum = append([]string(nil), s.Enum...) + } + if s.Minimum != nil { + val := *s.Minimum + clone.Minimum = &val + } + if s.Maximum != nil { + val := *s.Maximum + clone.Maximum = &val + } + return &clone +} diff --git a/v2/pkg/engine/jsonschema/schema_test.go b/v2/pkg/engine/jsonschema/schema_test.go index af48a4e205..0cbc265ea4 100644 --- a/v2/pkg/engine/jsonschema/schema_test.go +++ b/v2/pkg/engine/jsonschema/schema_test.go @@ -616,3 +616,46 @@ func TestSchemaFeatures(t *testing.T) { assert.False(t, schema.Nullable) }) } + +func TestJsonSchemaClone(t *testing.T) { + t.Run("mutating the clone does not affect the original", func(t *testing.T) { + additionalProps := false + minimum := 1.0 + original := &JsonSchema{ + Type: TypeObject, + Properties: map[string]*JsonSchema{"name": NewStringSchema()}, + Required: []string{"name"}, + AdditionalProperties: &additionalProps, + Description: "original", + Nullable: true, + Items: NewStringSchema(), + Enum: []string{"a", "b"}, + Minimum: &minimum, + } + + clone := original.Clone() + + clone.Nullable = false + clone.Description = "mutated" + clone.Properties["name"].Type = TypeInteger + clone.Required[0] = "changed" + *clone.AdditionalProperties = true + clone.Items.Type = TypeBoolean + clone.Enum[0] = "z" + *clone.Minimum = 99 + + assert.True(t, original.Nullable) + assert.Equal(t, "original", original.Description) + assert.Equal(t, TypeString, original.Properties["name"].Type) + assert.Equal(t, "name", original.Required[0]) + assert.False(t, *original.AdditionalProperties) + assert.Equal(t, TypeString, original.Items.Type) + assert.Equal(t, "a", original.Enum[0]) + assert.Equal(t, 1.0, *original.Minimum) + }) + + t.Run("nil receiver returns nil", func(t *testing.T) { + var s *JsonSchema + assert.Nil(t, s.Clone()) + }) +} From f0bcb682d6a97bd84bca22c1eabb3cb3d6b45182 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 10:58:27 +0100 Subject: [PATCH 4/9] docs: state nil-receiver behavior in Clone doc comment --- v2/pkg/engine/jsonschema/schema.go | 1 + 1 file changed, 1 insertion(+) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 268ba60471..d9b470ac87 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -259,6 +259,7 @@ func (s *JsonSchema) WithNullable(nullable bool) *JsonSchema { // Clone returns a deep copy of the schema. Callers that hand out schemas from // a shared map (e.g. scalar overrides) must clone per use: the builder mutates // Nullable on returned schemas depending on the variable's non-null context. +// Clone returns nil if s is nil. func (s *JsonSchema) Clone() *JsonSchema { if s == nil { return nil From e5f9986a15226c05972fb84a87f375efe0d96966 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 11:03:57 +0100 Subject: [PATCH 5/9] feat: add WithScalarSchemas option and DefaultedScalars accessor Callers can override the JSON schema per custom scalar name; unmapped custom scalars default to string and are reported via DefaultedScalars so integrators (e.g. Cosmo router) can log a startup warning. --- v2/pkg/engine/jsonschema/variables_schema.go | 55 +++++++++++- .../jsonschema/variables_schema_test.go | 84 +++++++++++++++++++ 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema.go b/v2/pkg/engine/jsonschema/variables_schema.go index f283dc76f8..30d63bad09 100644 --- a/v2/pkg/engine/jsonschema/variables_schema.go +++ b/v2/pkg/engine/jsonschema/variables_schema.go @@ -2,6 +2,7 @@ package jsonschema import ( "fmt" + "sort" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" @@ -21,6 +22,23 @@ type VariablesSchemaBuilder struct { // defs accumulates schemas for recursive input types; attached to the root // schema as "$defs". defs map[string]*JsonSchema + // scalarSchemas overrides the schema emitted per custom scalar type name. + scalarSchemas map[string]*JsonSchema + // defaultedScalars records custom scalars that fell back to the string + // default, so callers can surface missing mappings. + defaultedScalars map[string]bool +} + +// VariablesSchemaOption configures a VariablesSchemaBuilder. +type VariablesSchemaOption func(*VariablesSchemaBuilder) + +// WithScalarSchemas overrides the JSON schema emitted for custom scalar types, +// keyed by scalar type name. Unmapped custom scalars default to "string". +// Built-in scalars (String, ID, Int, Float, Boolean) cannot be overridden. +func WithScalarSchemas(schemas map[string]*JsonSchema) VariablesSchemaOption { + return func(v *VariablesSchemaBuilder) { + v.scalarSchemas = schemas + } } // Ensure VariablesSchemaBuilder implements the necessary astvisitor interfaces @@ -30,15 +48,22 @@ var ( ) // NewVariablesSchemaBuilder creates a new VariablesSchemaBuilder. -func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Document) *VariablesSchemaBuilder { - return &VariablesSchemaBuilder{ +func NewVariablesSchemaBuilder(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) *VariablesSchemaBuilder { + v := &VariablesSchemaBuilder{ operationDocument: operationDocument, definitionDocument: definitionDocument, schema: NewObjectSchema(), report: &operationreport.Report{}, recursiveTypes: make(map[string]bool), defs: make(map[string]*JsonSchema), + defaultedScalars: make(map[string]bool), + } + + for _, opt := range opts { + opt(v) } + + return v } // EnterDocument implements the astvisitor.EnterDocumentVisitor interface @@ -49,6 +74,7 @@ func (v *VariablesSchemaBuilder) EnterDocument(operation, definition *ast.Docume v.schema = NewObjectSchema() v.defs = make(map[string]*JsonSchema) // Reset defs for each build + v.defaultedScalars = make(map[string]bool) // Reset defaulted scalars for each build v.recursiveTypes = v.computeRecursiveInputTypes() // Identify recursive input types // Extract descriptions from root fields @@ -174,6 +200,17 @@ func (v *VariablesSchemaBuilder) GetReport() *operationreport.Report { return v.report } +// DefaultedScalars returns the sorted names of custom scalars that fell back +// to the default "string" schema during the last build. +func (v *VariablesSchemaBuilder) DefaultedScalars() []string { + names := make([]string, 0, len(v.defaultedScalars)) + for name := range v.defaultedScalars { + names = append(names, name) + } + sort.Strings(names) + return names +} + // Build traverses the operation and builds a unified JSON schema for its variables func (v *VariablesSchemaBuilder) Build() (*JsonSchema, error) { // Create a new walker for AST traversal @@ -267,10 +304,20 @@ func (v *VariablesSchemaBuilder) processTypeByName(typeName string) *JsonSchema return v.processInputObjectType(node) case ast.NodeKindScalarTypeDefinition: + if override, ok := v.scalarSchemas[typeName]; ok { + // Clone per use: the builder mutates Nullable on returned schemas + // depending on each variable's non-null context. + schema := override.Clone() + if schema.Description == "" && v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { + schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) + } + return schema + } // Custom scalars are opaque to JSON Schema. Emit a best-effort "string" // type: MCP/LLM tool consumers reject or degrade on untyped properties, // and opaque scalars are overwhelmingly strings on the wire. Callers can // override per scalar via WithScalarSchemas. + v.defaultedScalars[typeName] = true schema := NewStringSchema() if v.definitionDocument.ScalarTypeDefinitions[node.Ref].Description.IsDefined { schema.Description = v.definitionDocument.ScalarTypeDefinitionDescriptionString(node.Ref) @@ -537,10 +584,10 @@ func (v *VariablesSchemaBuilder) convertDefinitionValueToNative(value ast.Value) // BuildJsonSchema builds a JSON schema for the variables of the given operation. // Recursive input types are represented via "$ref"/"$defs" and support arbitrary // nesting depth. -func BuildJsonSchema(operationDocument, definitionDocument *ast.Document) (*JsonSchema, error) { +func BuildJsonSchema(operationDocument, definitionDocument *ast.Document, opts ...VariablesSchemaOption) (*JsonSchema, error) { if len(operationDocument.OperationDefinitions) == 0 { return nil, fmt.Errorf("no operations found in document") } - return NewVariablesSchemaBuilder(operationDocument, definitionDocument).Build() + return NewVariablesSchemaBuilder(operationDocument, definitionDocument, opts...).Build() } diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 4fa8273ecd..cc538a990b 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1785,4 +1785,88 @@ query Items($page: Pagination!) { assert.JSONEq(t, expectedJSON, string(actualJSON)) }) + + t.Run("scalar schema overrides", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` +schema { + query: Query +} + +scalar JSON +scalar Cursor + +type Query { + search(filter: JSON!, after: Cursor, cursor: Cursor!) : String +} +` + operation := ` +query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { + search(filter: $filter, after: $after, cursor: $cursor) +} +` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*JsonSchema{ + "JSON": {Type: TypeObject, Description: "Arbitrary JSON object"}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + // - JSON! is mapped to object and non-null context strips the null union + // - after/cursor prove per-use cloning: same scalar, different nullability + expectedJSON := `{ + "type": "object", + "properties": { + "filter": { "type": "object", "description": "Arbitrary JSON object" }, + "after": { "type": ["string", "null"] }, + "cursor": { "type": "string" } + }, + "required": ["filter", "cursor"], + "additionalProperties": false + }` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + + t.Run("DefaultedScalars reports unmapped custom scalars once, sorted", func(t *testing.T) { + schemaSDL := scalarDefinitions + ` +schema { + query: Query +} + +scalar JSON +scalar Cursor +scalar BigInt + +type Query { + search(filter: JSON!, after: Cursor, before: Cursor, size: BigInt) : String +} +` + operation := ` +query Search($filter: JSON!, $after: Cursor, $before: Cursor, $size: BigInt) { + search(filter: $filter, after: $after, before: $before, size: $size) +} +` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + builder := NewVariablesSchemaBuilder(&operationDoc, &definitionDoc, WithScalarSchemas(map[string]*JsonSchema{ + "JSON": {Type: TypeObject}, + })) + _, err := builder.Build() + require.NoError(t, err) + + // JSON is mapped -> not reported; Cursor used twice -> reported once + assert.Equal(t, []string{"BigInt", "Cursor"}, builder.DefaultedScalars()) + }) } From 44e60768dd25acf84935f15b15c08fa87db8d88b Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 11:12:00 +0100 Subject: [PATCH 6/9] test: prove override schemas are cloned per use across nullabilities Add a subtest using the same overridden scalar at two different non-null contexts in one operation. Without Clone() the second variable's Nullable mutation leaks into the first via the shared override pointer; sanity-checked by temporarily removing the Clone() call locally and confirming the new subtest fails. --- .../jsonschema/variables_schema_test.go | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index cc538a990b..339ac4dc1b 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1835,6 +1835,58 @@ query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { assert.JSONEq(t, expectedJSON, string(actualJSON)) }) + t.Run("same overridden scalar at two nullabilities yields independent schemas", func(t *testing.T) { + // The override type is deliberately non-object: object-typed top-level + // variables are unconditionally forced non-nullable elsewhere (see + // EnterVariableDefinition), which would mask the nullability leak this + // test exists to catch. + schemaSDL := scalarDefinitions + ` +schema { + query: Query +} + +scalar BigInt + +type Query { + search(filter: BigInt!, meta: BigInt) : String +} +` + operation := ` +query Search($filter: BigInt!, $meta: BigInt) { + search(filter: $filter, meta: $meta) +} +` + definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) + require.False(t, report.HasErrors(), "schema parsing failed") + + operationDoc, report := astparser.ParseGraphqlDocumentString(operation) + require.False(t, report.HasErrors(), "operation parsing failed") + + overrides := map[string]*JsonSchema{ + "BigInt": {Type: TypeInteger}, + } + + schema, err := BuildJsonSchema(&operationDoc, &definitionDoc, WithScalarSchemas(overrides)) + require.NoError(t, err) + + actualJSON, err := json.Marshal(schema) + require.NoError(t, err) + + // Both variables resolve the same override map entry. Without cloning + // per use, the second-processed variable's Nullable mutation would leak + // into the first via the shared *JsonSchema pointer. + expectedJSON := `{ + "type": "object", + "properties": { + "filter": { "type": "integer" }, + "meta": { "type": ["integer", "null"] } + }, + "required": ["filter"], + "additionalProperties": false + }` + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + t.Run("DefaultedScalars reports unmapped custom scalars once, sorted", func(t *testing.T) { schemaSDL := scalarDefinitions + ` schema { From 88073b801ad28988b2e518f020da855a1580a411 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 7 Aug 2026 11:21:41 +0100 Subject: [PATCH 7/9] test: rename override subtest and add Clone tripwire comment --- v2/pkg/engine/jsonschema/schema.go | 3 ++- v2/pkg/engine/jsonschema/variables_schema_test.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index d9b470ac87..40b1c06938 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -17,7 +17,8 @@ const ( TypeNull SchemaType = "null" ) -// JsonSchema represents a JSON Schema definition +// JsonSchema represents a JSON Schema definition. +// When adding a reference-typed field (map, slice, pointer), update Clone or it will alias. type JsonSchema struct { // Core schema fields Type SchemaType `json:"type,omitempty"` diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 339ac4dc1b..7d944ed556 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1786,7 +1786,7 @@ query Items($page: Pagination!) { assert.JSONEq(t, expectedJSON, string(actualJSON)) }) - t.Run("scalar schema overrides", func(t *testing.T) { + t.Run("overridden scalar emits the mapped schema and unmapped scalars keep the string default", func(t *testing.T) { schemaSDL := scalarDefinitions + ` schema { query: Query From eae502104dd03869708d21672437b5c0c11ec7b1 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 00:27:56 +0100 Subject: [PATCH 8/9] docs: state why Clone is hand-written and what the regression test guards --- v2/pkg/engine/jsonschema/schema.go | 11 +++++++++-- v2/pkg/engine/jsonschema/variables_schema_test.go | 3 +++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/v2/pkg/engine/jsonschema/schema.go b/v2/pkg/engine/jsonschema/schema.go index 40b1c06938..9303dc45f1 100644 --- a/v2/pkg/engine/jsonschema/schema.go +++ b/v2/pkg/engine/jsonschema/schema.go @@ -258,8 +258,15 @@ func (s *JsonSchema) WithNullable(nullable bool) *JsonSchema { } // Clone returns a deep copy of the schema. Callers that hand out schemas from -// a shared map (e.g. scalar overrides) must clone per use: the builder mutates -// Nullable on returned schemas depending on the variable's non-null context. +// a shared map (e.g. scalar overrides) must clone per use: nullability belongs +// to each usage site, and the builder records it by mutating Nullable on the +// schema it returns. Without a per-use copy, two variables of the same mapped +// scalar alias one object and the last-processed variable's nullability +// overwrites the first (guarded by the "same overridden scalar at two +// nullabilities" regression test). +// This is a hand-written copy on purpose: a marshal/unmarshal round-trip would +// silently drop Nullable (tagged json:"-"), and a shallow struct copy would +// still share Properties/Items/Defs. // Clone returns nil if s is nil. func (s *JsonSchema) Clone() *JsonSchema { if s == nil { diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 7d944ed556..160920a868 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1835,6 +1835,9 @@ query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { assert.JSONEq(t, expectedJSON, string(actualJSON)) }) + // Guards the Clone-per-use invariant: if the override branch stops cloning, + // both variables below alias one *JsonSchema and the last-processed + // variable's nullability overwrites the first, failing this test. t.Run("same overridden scalar at two nullabilities yields independent schemas", func(t *testing.T) { // The override type is deliberately non-object: object-typed top-level // variables are unconditionally forced non-nullable elsewhere (see From 979913e3baa6c690677b997bcfc0c054f95b177f Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Sat, 8 Aug 2026 00:36:44 +0100 Subject: [PATCH 9/9] test: align raw string indentation with file conventions --- .../jsonschema/variables_schema_test.go | 282 ++++++++++-------- 1 file changed, 160 insertions(+), 122 deletions(-) diff --git a/v2/pkg/engine/jsonschema/variables_schema_test.go b/v2/pkg/engine/jsonschema/variables_schema_test.go index 160920a868..c465a19763 100644 --- a/v2/pkg/engine/jsonschema/variables_schema_test.go +++ b/v2/pkg/engine/jsonschema/variables_schema_test.go @@ -1686,22 +1686,22 @@ func TestBuildJsonSchema(t *testing.T) { t.Run("query with custom scalar variables defaults to string type", func(t *testing.T) { schemaSDL := scalarDefinitions + ` -schema { - query: Query -} - -"""An opaque pagination cursor""" -scalar Cursor - -type Query { - items(after: Cursor, first: Int!): String -} -` + schema { + query: Query + } + + """An opaque pagination cursor""" + scalar Cursor + + type Query { + items(after: Cursor, first: Int!): String + } + ` operation := ` -query Items($after: Cursor, $first: Int!) { - items(after: $after, first: $first) -} -` + query Items($after: Cursor, $first: Int!) { + items(after: $after, first: $first) + } + ` definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) require.False(t, report.HasErrors(), "schema parsing failed") @@ -1715,18 +1715,23 @@ query Items($after: Cursor, $first: Int!) { require.NoError(t, err) expectedJSON := `{ - "type": "object", - "properties": { - "after": { - "type": ["string", "null"], - "description": "An opaque pagination cursor" - }, - "first": { - "type": "integer" - } - }, - "required": ["first"], - "additionalProperties": false + "additionalProperties": false, + "properties": { + "after": { + "description": "An opaque pagination cursor", + "type": [ + "string", + "null" + ] + }, + "first": { + "type": "integer" + } + }, + "required": [ + "first" + ], + "type": "object" }` assert.JSONEq(t, expectedJSON, string(actualJSON)) @@ -1734,26 +1739,26 @@ query Items($after: Cursor, $first: Int!) { t.Run("input object field with custom scalar defaults to string type", func(t *testing.T) { schemaSDL := scalarDefinitions + ` -schema { - query: Query -} - -scalar Cursor - -input Pagination { - after: Cursor - first: Int! -} - -type Query { - items(page: Pagination!): String -} -` + schema { + query: Query + } + + scalar Cursor + + input Pagination { + after: Cursor + first: Int! + } + + type Query { + items(page: Pagination!): String + } + ` operation := ` -query Items($page: Pagination!) { - items(page: $page) -} -` + query Items($page: Pagination!) { + items(page: $page) + } + ` definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) require.False(t, report.HasErrors(), "schema parsing failed") @@ -1767,20 +1772,31 @@ query Items($page: Pagination!) { require.NoError(t, err) expectedJSON := `{ - "type": "object", - "properties": { - "page": { - "type": "object", - "properties": { - "after": { "type": ["string", "null"] }, - "first": { "type": "integer" } - }, - "required": ["first"], - "additionalProperties": false - } - }, - "required": ["page"], - "additionalProperties": false + "additionalProperties": false, + "properties": { + "page": { + "additionalProperties": false, + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "first": { + "type": "integer" + } + }, + "required": [ + "first" + ], + "type": "object" + } + }, + "required": [ + "page" + ], + "type": "object" }` assert.JSONEq(t, expectedJSON, string(actualJSON)) @@ -1788,22 +1804,22 @@ query Items($page: Pagination!) { t.Run("overridden scalar emits the mapped schema and unmapped scalars keep the string default", func(t *testing.T) { schemaSDL := scalarDefinitions + ` -schema { - query: Query -} - -scalar JSON -scalar Cursor - -type Query { - search(filter: JSON!, after: Cursor, cursor: Cursor!) : String -} -` + schema { + query: Query + } + + scalar JSON + scalar Cursor + + type Query { + search(filter: JSON!, after: Cursor, cursor: Cursor!) : String + } + ` operation := ` -query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { - search(filter: $filter, after: $after, cursor: $cursor) -} -` + query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { + search(filter: $filter, after: $after, cursor: $cursor) + } + ` definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) require.False(t, report.HasErrors(), "schema parsing failed") @@ -1823,15 +1839,28 @@ query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { // - JSON! is mapped to object and non-null context strips the null union // - after/cursor prove per-use cloning: same scalar, different nullability expectedJSON := `{ - "type": "object", - "properties": { - "filter": { "type": "object", "description": "Arbitrary JSON object" }, - "after": { "type": ["string", "null"] }, - "cursor": { "type": "string" } - }, - "required": ["filter", "cursor"], - "additionalProperties": false - }` + "additionalProperties": false, + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "cursor": { + "type": "string" + }, + "filter": { + "description": "Arbitrary JSON object", + "type": "object" + } + }, + "required": [ + "filter", + "cursor" + ], + "type": "object" +}` assert.JSONEq(t, expectedJSON, string(actualJSON)) }) @@ -1844,21 +1873,21 @@ query Search($filter: JSON!, $after: Cursor, $cursor: Cursor!) { // EnterVariableDefinition), which would mask the nullability leak this // test exists to catch. schemaSDL := scalarDefinitions + ` -schema { - query: Query -} - -scalar BigInt - -type Query { - search(filter: BigInt!, meta: BigInt) : String -} -` + schema { + query: Query + } + + scalar BigInt + + type Query { + search(filter: BigInt!, meta: BigInt) : String + } + ` operation := ` -query Search($filter: BigInt!, $meta: BigInt) { - search(filter: $filter, meta: $meta) -} -` + query Search($filter: BigInt!, $meta: BigInt) { + search(filter: $filter, meta: $meta) + } + ` definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) require.False(t, report.HasErrors(), "schema parsing failed") @@ -1879,36 +1908,45 @@ query Search($filter: BigInt!, $meta: BigInt) { // per use, the second-processed variable's Nullable mutation would leak // into the first via the shared *JsonSchema pointer. expectedJSON := `{ - "type": "object", - "properties": { - "filter": { "type": "integer" }, - "meta": { "type": ["integer", "null"] } - }, - "required": ["filter"], - "additionalProperties": false - }` + "additionalProperties": false, + "properties": { + "filter": { + "type": "integer" + }, + "meta": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "filter" + ], + "type": "object" +}` assert.JSONEq(t, expectedJSON, string(actualJSON)) }) t.Run("DefaultedScalars reports unmapped custom scalars once, sorted", func(t *testing.T) { schemaSDL := scalarDefinitions + ` -schema { - query: Query -} - -scalar JSON -scalar Cursor -scalar BigInt - -type Query { - search(filter: JSON!, after: Cursor, before: Cursor, size: BigInt) : String -} -` + schema { + query: Query + } + + scalar JSON + scalar Cursor + scalar BigInt + + type Query { + search(filter: JSON!, after: Cursor, before: Cursor, size: BigInt) : String + } + ` operation := ` -query Search($filter: JSON!, $after: Cursor, $before: Cursor, $size: BigInt) { - search(filter: $filter, after: $after, before: $before, size: $size) -} -` + query Search($filter: JSON!, $after: Cursor, $before: Cursor, $size: BigInt) { + search(filter: $filter, after: $after, before: $before, size: $size) + } + ` definitionDoc, report := astparser.ParseGraphqlDocumentString(schemaSDL) require.False(t, report.HasErrors(), "schema parsing failed")