From 50f5691074c21a8f4f9dd41ab1989e021f5ddccc Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Fri, 26 Sep 2025 21:18:23 -0700 Subject: [PATCH 1/2] Fix JSON schema generation for array size predicates - Use minItems/maxItems for array size constraints instead of minLength/maxLength - Add context awareness to distinguish array-level vs item-level predicates - Preserve existing behavior for string size constraints - Add comprehensive test coverage Fixes #481 --- CHANGELOG.md | 3 + .../extensions/json_schema/schema_compiler.rb | 23 +++++- .../json_schema/array_size_predicates_spec.rb | 79 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 spec/integration/extensions/json_schema/array_size_predicates_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 751830dc..2551ad87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ## [Unreleased] +### Fixed + +- JSON schema generation now correctly uses `minItems`/`maxItems` for array size predicates instead of `minLength`/`maxLength` (fixes #481) (@baweaver) ## [1.14.1] - 2025-03-03 diff --git a/lib/dry/schema/extensions/json_schema/schema_compiler.rb b/lib/dry/schema/extensions/json_schema/schema_compiler.rb index f4383693..43ace485 100644 --- a/lib/dry/schema/extensions/json_schema/schema_compiler.rb +++ b/lib/dry/schema/extensions/json_schema/schema_compiler.rb @@ -169,7 +169,10 @@ def visit_predicate(node, opts = EMPTY_HASH) target = keys[opts[:key]] type_opts = fetch_type_opts_for_predicate(name, rest, target) - if target[:type]&.include?("array") + if target[:type]&.include?("array") && array_size_predicate?(name) && !opts[:member] + array_type_opts = convert_array_size_predicate(name, rest) + merge_opts!(target, array_type_opts) + elsif target[:type]&.include?("array") target[:items] ||= {} merge_opts!(target[:items], type_opts) else @@ -178,6 +181,24 @@ def visit_predicate(node, opts = EMPTY_HASH) end end + # @api private + def array_size_predicate?(name) + name == :min_size? || name == :max_size? + end + + # @api private + def convert_array_size_predicate(name, rest) + value = rest[0][1].to_i + case name + when :min_size? + { minItems: value } + when :max_size? + { maxItems: value } + else + {} + end + end + # @api private def fetch_type_opts_for_predicate(name, rest, target) type_opts = PREDICATE_TO_TYPE.fetch(name) do diff --git a/spec/integration/extensions/json_schema/array_size_predicates_spec.rb b/spec/integration/extensions/json_schema/array_size_predicates_spec.rb new file mode 100644 index 00000000..3b40d920 --- /dev/null +++ b/spec/integration/extensions/json_schema/array_size_predicates_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.describe "JSON Schema with array size predicates" do + before do + Dry::Schema.load_extensions(:json_schema) + end + + context "with min_size? and max_size? predicates" do + let(:schema) do + Dry::Schema.JSON do + required(:users).value(:array?, min_size?: 5, max_size?: 10).each(:str?) + end + end + + it "generates minItems and maxItems on array" do + json_schema = schema.json_schema + + expect(json_schema[:properties][:users]).to include( + type: "array", + minItems: 5, + maxItems: 10, + items: { type: "string" } + ) + + expect(json_schema[:properties][:users][:items]).not_to have_key(:minLength) + expect(json_schema[:properties][:users][:items]).not_to have_key(:maxLength) + end + end + + context "with string items having size predicates" do + let(:schema) do + Dry::Schema.JSON do + required(:names).value(:array?, min_size?: 2).each(:str?, min_size?: 3, max_size?: 50) + end + end + + it "applies array size to array and string size to items" do + json_schema = schema.json_schema + + expect(json_schema[:properties][:names]).to include( + type: "array", + minItems: 2, + items: { + type: "string", + minLength: 3, + maxLength: 50 + } + ) + end + end + + context "with equal min and max size constraints" do + let(:schema) do + Dry::Schema.JSON do + required(:users).value(:array?, min_size?: 5, max_size?: 5).each(:str?) + end + end + + it "generates correct minItems and maxItems" do + expected = { + "$schema": "http://json-schema.org/draft-06/schema#", + type: "object", + properties: { + users: { + type: "array", + minItems: 5, + maxItems: 5, + items: { + type: "string" + } + } + }, + required: ["users"] + } + + expect(schema.json_schema).to eq(expected) + end + end +end From 1ecd38d43ed88714652d631d7c19ca475fa43505 Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Sat, 11 Oct 2025 14:11:42 -0700 Subject: [PATCH 2/2] Fix Rubocop issues: refactor visit_predicate to reduce complexity --- dry-schema.gemspec | 1 - .../extensions/json_schema/schema_compiler.rb | 57 +++++++++++++------ .../json_schema/array_size_predicates_spec.rb | 2 +- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/dry-schema.gemspec b/dry-schema.gemspec index b2c53f2c..2d7aae36 100644 --- a/dry-schema.gemspec +++ b/dry-schema.gemspec @@ -47,4 +47,3 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec" spec.add_development_dependency "yard" end - diff --git a/lib/dry/schema/extensions/json_schema/schema_compiler.rb b/lib/dry/schema/extensions/json_schema/schema_compiler.rb index 43ace485..9400912b 100644 --- a/lib/dry/schema/extensions/json_schema/schema_compiler.rb +++ b/lib/dry/schema/extensions/json_schema/schema_compiler.rb @@ -163,24 +163,49 @@ def visit_predicate(node, opts = EMPTY_HASH) name, rest = node if name.equal?(:key?) - prop_name = rest[0][1] - keys[prop_name] = {} + handle_key_predicate(rest) else - target = keys[opts[:key]] - type_opts = fetch_type_opts_for_predicate(name, rest, target) - - if target[:type]&.include?("array") && array_size_predicate?(name) && !opts[:member] - array_type_opts = convert_array_size_predicate(name, rest) - merge_opts!(target, array_type_opts) - elsif target[:type]&.include?("array") - target[:items] ||= {} - merge_opts!(target[:items], type_opts) - else - merge_opts!(target, type_opts) - end + handle_value_predicate(name, rest, opts) end end + # @api private + def handle_key_predicate(rest) + prop_name = rest[0][1] + keys[prop_name] = {} + end + + # @api private + def handle_value_predicate(name, rest, opts) + target = keys[opts[:key]] + type_opts = fetch_type_opts_for_predicate(name, rest, target) + + if array_with_size_predicate?(target, name, opts) + apply_array_size_constraint(target, name, rest) + elsif target[:type]&.include?("array") + apply_array_item_constraint(target, type_opts) + else + merge_opts!(target, type_opts) + end + end + + # @api private + def array_with_size_predicate?(target, name, opts) + target[:type]&.include?("array") && array_size_predicate?(name) && !opts[:member] + end + + # @api private + def apply_array_size_constraint(target, name, rest) + array_type_opts = convert_array_size_predicate(name, rest) + merge_opts!(target, array_type_opts) + end + + # @api private + def apply_array_item_constraint(target, type_opts) + target[:items] ||= {} + merge_opts!(target[:items], type_opts) + end + # @api private def array_size_predicate?(name) name == :min_size? || name == :max_size? @@ -191,9 +216,9 @@ def convert_array_size_predicate(name, rest) value = rest[0][1].to_i case name when :min_size? - { minItems: value } + {minItems: value} when :max_size? - { maxItems: value } + {maxItems: value} else {} end diff --git a/spec/integration/extensions/json_schema/array_size_predicates_spec.rb b/spec/integration/extensions/json_schema/array_size_predicates_spec.rb index 3b40d920..4043c12f 100644 --- a/spec/integration/extensions/json_schema/array_size_predicates_spec.rb +++ b/spec/integration/extensions/json_schema/array_size_predicates_spec.rb @@ -19,7 +19,7 @@ type: "array", minItems: 5, maxItems: 10, - items: { type: "string" } + items: {type: "string"} ) expect(json_schema[:properties][:users][:items]).not_to have_key(:minLength)