From a75e3c4dee8a7fdc3e182465a5e3475c4279c177 Mon Sep 17 00:00:00 2001 From: Carmine Paolino Date: Sat, 6 Jun 2026 12:39:22 +0200 Subject: [PATCH] Add object validation keywords --- lib/ruby_llm/schema/dsl/schema_builders.rb | 6 +- lib/ruby_llm/schema/dsl/utilities.rb | 24 ++++++ .../object_validation_keywords_spec.rb | 79 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 spec/ruby_llm/schema/properties/object_validation_keywords_spec.rb diff --git a/lib/ruby_llm/schema/dsl/schema_builders.rb b/lib/ruby_llm/schema/dsl/schema_builders.rb index 900736a..e593a1b 100644 --- a/lib/ruby_llm/schema/dsl/schema_builders.rb +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -63,7 +63,7 @@ def null_schema(description: nil, const: NOT_GIVEN) add_const({type: "null", description: description}.compact, const) end - def object_schema(description: nil, of: nil, reference: nil, unevaluated_properties: nil, &block) + def object_schema(description: nil, of: nil, reference: nil, min_properties: nil, max_properties: nil, unevaluated_properties: nil, &block) if reference warn "[DEPRECATION] The `reference` option will be deprecated. Please use `of` instead." of = reference @@ -72,6 +72,8 @@ def object_schema(description: nil, of: nil, reference: nil, unevaluated_propert schema = of ? determine_object_reference(of, description) : build_object_schema(description, &block) schema[:unevaluatedProperties] = unevaluated_properties unless unevaluated_properties.nil? + schema[:minProperties] = min_properties unless min_properties.nil? + schema[:maxProperties] = max_properties unless max_properties.nil? schema end @@ -163,6 +165,7 @@ def build_object_schema(description, &block) }.compact merge_conditions(schema, sub_schema) + merge_object_keywords(schema, sub_schema) end def determine_object_reference(of, description = nil) @@ -245,6 +248,7 @@ def schema_class_to_inline_schema(schema_class_or_instance) schema[:description] = description if description merge_conditions(schema, schema_class) + merge_object_keywords(schema, schema_class) end end end diff --git a/lib/ruby_llm/schema/dsl/utilities.rb b/lib/ruby_llm/schema/dsl/utilities.rb index 35d2565..835ff8e 100644 --- a/lib/ruby_llm/schema/dsl/utilities.rb +++ b/lib/ruby_llm/schema/dsl/utilities.rb @@ -16,11 +16,25 @@ def define(name, &) additionalProperties: sub_schema.additional_properties } + merge_object_keywords(schema, sub_schema) merge_conditions(schema, sub_schema) definitions[name] = schema end + def object_keywords + @object_keywords ||= {} + end + + def keys_matching(pattern, &block) + object_keywords[:patternProperties] ||= {} + object_keywords[:patternProperties][pattern_source(pattern)] = collect_schemas_from_block(&block).first + end + + def keys(&block) + object_keywords[:propertyNames] = collect_schemas_from_block(&block).first + end + def reference(schema_name) if schema_name == :root {"$ref" => "#"} @@ -57,6 +71,16 @@ def primitive_type?(type) def schema_class?(type) (type.is_a?(Class) && type < Schema) || type.is_a?(Schema) end + + def merge_object_keywords(schema, schema_class) + return schema unless schema_class.respond_to?(:object_keywords) + + schema.merge!(schema_class.object_keywords) + end + + def pattern_source(pattern) + pattern.is_a?(Regexp) ? pattern.source : pattern.to_s + end end end end diff --git a/spec/ruby_llm/schema/properties/object_validation_keywords_spec.rb b/spec/ruby_llm/schema/properties/object_validation_keywords_spec.rb new file mode 100644 index 0000000..f8251dd --- /dev/null +++ b/spec/ruby_llm/schema/properties/object_validation_keywords_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "object validation keywords" do + let(:schema_class) { Class.new(described_class) } + + it "supports object property count constraints" do + schema_class.object :metadata, min_properties: 1, max_properties: 5 do + string :name + end + + metadata_schema = schema_class.properties[:metadata] + + expect(metadata_schema[:minProperties]).to eq(1) + expect(metadata_schema[:maxProperties]).to eq(5) + end + + it "supports patternProperties with keys_matching" do + schema_class.object :metadata do + keys_matching(/^x-/) do + string + end + + keys_matching(/^count_/) do + integer minimum: 0 + end + end + + expect(schema_class.properties[:metadata][:patternProperties]).to eq({ + "^x-" => {type: "string"}, + "^count_" => {type: "integer", minimum: 0} + }) + end + + it "supports propertyNames with keys" do + schema_class.object :metadata do + keys do + string pattern: "^[a-z_]+$" + end + end + + expect(schema_class.properties[:metadata][:propertyNames]).to eq({ + type: "string", + pattern: "^[a-z_]+$" + }) + end + + it "includes object validation keywords in definitions" do + schema_class.define :metadata do + keys_matching(/^x-/) do + string + end + + keys do + string pattern: "^[a-z_]+$" + end + end + + definition = schema_class.definitions[:metadata] + + expect(definition[:patternProperties]).to eq({"^x-" => {type: "string"}}) + expect(definition[:propertyNames]).to eq({type: "string", pattern: "^[a-z_]+$"}) + end + + it "includes object validation keywords when embedding schema classes" do + metadata_schema = Class.new(described_class) do + keys_matching(/^x-/) do + string + end + end + + schema_class.object :metadata, of: metadata_schema + + expect(schema_class.properties[:metadata][:patternProperties]).to eq({ + "^x-" => {type: "string"} + }) + end +end