From 1638e85512f7051058dd8d29d6c1b9232f288485 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Wed, 13 May 2026 20:13:34 +0200 Subject: [PATCH 1/3] Add support for Dependencies and Conditionals --- Gemfile.lock | 2 +- README.md | 215 +++++++- lib/ruby_llm/schema/dsl.rb | 2 + lib/ruby_llm/schema/dsl/conditionals.rb | 155 ++++++ lib/ruby_llm/schema/dsl/primitive_types.rb | 20 +- lib/ruby_llm/schema/dsl/schema_builders.rb | 6 +- lib/ruby_llm/schema/dsl/utilities.rb | 8 +- lib/ruby_llm/schema/json_output.rb | 2 + .../schema/properties/conditionals_spec.rb | 500 ++++++++++++++++++ 9 files changed, 884 insertions(+), 26 deletions(-) create mode 100644 lib/ruby_llm/schema/dsl/conditionals.rb create mode 100644 spec/ruby_llm/schema/properties/conditionals_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 5c28bb3..9c384a3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -66,7 +66,7 @@ GEM rubocop-performance (~> 1.25.0) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-emoji (4.2.0) PLATFORMS arm64-darwin-24 diff --git a/README.md b/README.md index ad1a1bc..e9b9678 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Structured output is a powerful tool for LLMs to generate consistent and predict Some ideal use cases: - Extracting *metadata, topics, and summary* from articles or blog posts -- Organizing unstructured feedback or reviews with *sentiment and summary* +- Organizing unstructured feedback or reviews with *sentiment and summary* - Defining structured *actions* from user messages or emails - Extracting *entities and relationships* from documents @@ -24,22 +24,22 @@ class PersonSchema < RubyLLM::Schema string :name, description: "Person's full name" number :age, description: "Age in years", minimum: 0, maximum: 120 boolean :active, required: false - + object :address do string :street string :city string :country, required: false end - + array :tags, of: :string, description: "User tags" - + array :contacts do object do string :email, format: "email" string :phone, required: false end end - + any_of :status do string enum: ["active", "pending", "inactive"] null @@ -101,12 +101,12 @@ class PersonSchema < RubyLLM::Schema string :name, description: "Person's full name" number :age boolean :active, required: false - + object :address do string :street string :city end - + array :tags, of: :string end @@ -121,12 +121,12 @@ PersonSchema = RubyLLM::Schema.create do string :name, description: "Person's full name" number :age boolean :active, required: false - + object :address do string :street string :city end - + array :tags, of: :string end @@ -144,12 +144,12 @@ person_schema = schema "PersonData", description: "A person object" do string :name, description: "Person's full name" number :age boolean :active, required: false - + object :address do string :street string :city end - + array :tags, of: :string end @@ -289,7 +289,7 @@ class MySchema < RubyLLM::Schema string :latitude string :longitude end - + # Using a reference in an array array :coordinates, of: :location @@ -324,7 +324,7 @@ class CompanySchema < RubyLLM::Schema # Using 'of' parameter object :ceo, of: PersonSchema array :employees, of: PersonSchema - + # Using Schema.new in block object :founder do PersonSchema.new @@ -403,6 +403,195 @@ schema.to_json_schema # } ``` +### Dependencies + +> [!NOTE] +> `dependentRequired` and `dependentSchemas` were introduced in JSON Schema Draft 2019-09. Not all LLM providers or validators support these keywords, check your provider's documentation for compatibility. + +Use `requires:` inline or `dependent` block to express that the presence of one property requires other properties. This maps to JSON Schema's [`dependentRequired`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentRequired) and [`dependentSchemas`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentSchemas). + +The simplest form uses inline `requires:` on the property declaration: + +```ruby +class PaymentSchema < RubyLLM::Schema + string :name + number :credit_card, required: false, requires: %i[billing_address cvv] + string :billing_address, required: false + string :cvv, required: false +end + +# Generates: +# { +# "dependentRequired": { +# "credit_card": ["billing_address", "cvv"] +# } +# } +``` + +For a single dependency, use a symbol: + +```ruby +number :credit_card, required: false, requires: :billing_address +``` + +Use `dependent` block when you need validations. When only `requires` is used, the output uses the simpler `dependentRequired`. When `validates` is also used, it upgrades to `dependentSchemas`: + +```ruby +class PaymentSchema < RubyLLM::Schema + string :name + number :credit_card, required: false + string :billing_address, required: false + + dependent :credit_card do + requires :billing_address + validates :billing_address, type: :string, min_length: 1 + end +end + +# Generates: +# { +# "dependentSchemas": { +# "credit_card": { +# "required": ["billing_address"], +# "properties": { +# "billing_address": { "type": "string", "minLength": 1 } +# } +# } +# } +# } +``` + +### Conditionals + +> [!NOTE] +> `if`/`then`/`else` was introduced in JSON Schema Draft 7. Not all LLM providers or validators support these keywords, check your provider's documentation for compatibility. + +Use `given` to add [JSON Schema `if`/`then`/`else`](https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse) rules. The condition values are automatically coerced: + +| Ruby value | JSON Schema | +|--------------------------|----------------------------------| +| `"string"` | `{ "const": "string" }` | +| `123` / `true` / `false` | `{ "const": 123 }` | +| `["a", "b"]` | `{ "enum": ["a", "b"] }` | +| `/pattern/` | `{ "pattern": "pattern" }` | +| `{ minimum: 18 }` | `{ "minimum": 18 }` (raw schema) | + + +Require a field when a property has a specific value: + +```ruby +class OrderSchema < RubyLLM::Schema + string :status, enum: ["pending", "shipped", "cancelled"] + string :tracking_number, required: false + string :cancellation_reason, required: false + + given status: "shipped" do + requires :tracking_number + end + + given status: "cancelled" do + requires :cancellation_reason + end +end +``` + +Multiple property conditions: + +```ruby +class EmployeeSchema < RubyLLM::Schema + string :country + string :role + string :tax_id, required: false + + given country: "US", role: "employee" do + requires :tax_id + end +end +``` + +Array conditions (enum), regexp conditions (pattern), and hash conditions (raw schema): + +* Array → enum +* Regexp → pattern +* Hash → raw JSON Schema + +```ruby +class AccountSchema < RubyLLM::Schema + string :status + string :reason, required: false + string :email + string :employee_id, required: false + integer :age, required: false + boolean :parental_consent, required: false + + given status: ["suspended", "banned"] do + requires :reason + end + + given email: /@acme\.com$/ do + requires :employee_id + end + + given age: { maximum: 17 } do + requires :parental_consent + end +end +``` + +Validate property values in the `then` branch: + +```ruby +class EventSchema < RubyLLM::Schema + string :format + string :date, required: false + + given format: "iso8601" do + requires :date + validates :date, type: :string, min_length: 10, pattern: "^\\d{4}-\\d{2}-\\d{2}" + end +end +``` + +`validates` supports: `type:`, `not_value:`, `min_length:`, `max_length:`, `pattern:` (`String` or `Regexp`), `enum:`, `const:`, `minimum:`, `maximum:`. + +Use `otherwise` for an `else` branch: + +```ruby +class ShippingSchema < RubyLLM::Schema + boolean :domestic + string :state, required: false + string :country, required: false + + given domestic: true do + requires :state + + otherwise do + requires :country + end + end +end +``` + +Conditions propagate through nested schemas via `of:`: + +```ruby +class AddressSchema < RubyLLM::Schema + string :country + string :state, required: false + + given country: "US" do + requires :state + end +end + +class PersonSchema < RubyLLM::Schema + string :name + array :addresses, of: AddressSchema, required: false +end +``` + +The generated JSON Schema for addresses items will include the if/then rule. + ## JSON Output ```ruby diff --git a/lib/ruby_llm/schema/dsl.rb b/lib/ruby_llm/schema/dsl.rb index 2a65697..4868987 100644 --- a/lib/ruby_llm/schema/dsl.rb +++ b/lib/ruby_llm/schema/dsl.rb @@ -3,6 +3,7 @@ require_relative "dsl/schema_builders" require_relative "dsl/primitive_types" require_relative "dsl/complex_types" +require_relative "dsl/conditionals" require_relative "dsl/utilities" module RubyLLM @@ -11,6 +12,7 @@ module DSL include SchemaBuilders include PrimitiveTypes include ComplexTypes + include Conditionals include Utilities end end diff --git a/lib/ruby_llm/schema/dsl/conditionals.rb b/lib/ruby_llm/schema/dsl/conditionals.rb new file mode 100644 index 0000000..b0e1ca9 --- /dev/null +++ b/lib/ruby_llm/schema/dsl/conditionals.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module RubyLLM + class Schema + module DSL + module Conditionals + def conditions + @conditions ||= [] + end + + def dependencies + @dependencies ||= {} + end + + def merge_conditions(schema, schema_class) + if schema_class.respond_to?(:conditions) && schema_class.conditions.any? + if schema_class.conditions.length == 1 + schema.merge!(schema_class.conditions.first) + else + schema[:allOf] = schema_class.conditions + end + end + + if schema_class.respond_to?(:dependencies) && schema_class.dependencies.any? + dependent_required = {} + dependent_schemas = {} + + schema_class.dependencies.each do |property, builder| + if builder.validations_empty? + dependent_required[property] = builder.required_fields + else + dependent_schemas[property] = builder.to_schema + end + end + + schema[:dependentRequired] = dependent_required if dependent_required.any? + schema[:dependentSchemas] = dependent_schemas if dependent_schemas.any? + end + + schema + end + + def dependent(property, &block) + builder = ConditionalBuilder.new + builder.instance_eval(&block) + + dependencies[property.to_s] = builder + end + + def given(**properties, &block) + raise ArgumentError, "given requires at least one property condition" if properties.empty? + + if_schema = { + properties: properties.transform_keys(&:to_s).transform_values { |v| coerce_condition(v) }, + required: properties.keys.map(&:to_s) + } + + then_builder = ConditionalBuilder.new + else_builder = ConditionalBuilder.new + + context = ConditionalContext.new(then_builder, else_builder) + context.instance_eval(&block) + + condition = {if: if_schema, then: then_builder.to_schema} + condition[:else] = else_builder.to_schema unless else_builder.empty? + + conditions << condition + end + + private + + def coerce_condition(value) + case value + when Array then {enum: value} + when Regexp then {pattern: value.source} + when Hash then value + else {const: value} + end + end + end + + class ConditionalContext + def initialize(then_builder, else_builder) + @then_builder = then_builder + @else_builder = else_builder + end + + def requires(*fields) + @then_builder.requires(*fields) + end + + def validates(field, **options) + @then_builder.validates(field, **options) + end + + def otherwise(&block) + @else_builder.instance_eval(&block) + end + end + + class ConditionalBuilder + def requires(*fields) + required.concat(fields.map(&:to_s)) + end + + def validates(field, type: nil, not_value: nil, min_length: nil, max_length: nil, pattern: nil, enum: nil, const: nil, minimum: nil, maximum: nil) + constraints = {} + + constraints[:type] = type.to_s if type + constraints[:const] = const if const + constraints[:enum] = enum if enum + constraints[:not] = {const: not_value} if not_value + constraints[:minLength] = min_length if min_length + constraints[:maxLength] = max_length if max_length + constraints[:pattern] = pattern.is_a?(Regexp) ? pattern.source : pattern if pattern + constraints[:minimum] = minimum if minimum + constraints[:maximum] = maximum if maximum + + validations[field.to_s] = constraints + end + + def to_schema + schema = {} + + schema[:required] = required if required.any? + schema[:properties] = validations if validations.any? + + schema + end + + def empty? + required.empty? && validations.empty? + end + + def required_fields + required.dup + end + + def validations_empty? + validations.empty? + end + + private + + def required + @required ||= [] + end + + def validations + @validations ||= {} + end + end + end + end +end diff --git a/lib/ruby_llm/schema/dsl/primitive_types.rb b/lib/ruby_llm/schema/dsl/primitive_types.rb index a8fd08f..f7571f3 100644 --- a/lib/ruby_llm/schema/dsl/primitive_types.rb +++ b/lib/ruby_llm/schema/dsl/primitive_types.rb @@ -4,24 +4,24 @@ module RubyLLM class Schema module DSL module PrimitiveTypes - def string(name, description: nil, required: true, **options) - add_property(name, string_schema(description: description, **options), required: required) + def string(name, description: nil, required: true, requires: nil, **options) + add_property(name, string_schema(description: description, **options), required: required, requires: requires) end - def number(name, description: nil, required: true, **options) - add_property(name, number_schema(description: description, **options), required: required) + def number(name, description: nil, required: true, requires: nil, **options) + add_property(name, number_schema(description: description, **options), required: required, requires: requires) end - def integer(name, description: nil, required: true, **options) - add_property(name, integer_schema(description: description, **options), required: required) + def integer(name, description: nil, required: true, requires: nil, **options) + add_property(name, integer_schema(description: description, **options), required: required, requires: requires) end - def boolean(name, description: nil, required: true, **options) - add_property(name, boolean_schema(description: description, **options), required: required) + def boolean(name, description: nil, required: true, requires: nil, **options) + add_property(name, boolean_schema(description: description, **options), required: required, requires: requires) end - def null(name, description: nil, required: true, **options) - add_property(name, null_schema(description: description, **options), required: required) + def null(name, description: nil, required: true, requires: nil, **options) + add_property(name, null_schema(description: description, **options), required: required, requires: requires) end end end diff --git a/lib/ruby_llm/schema/dsl/schema_builders.rb b/lib/ruby_llm/schema/dsl/schema_builders.rb index 91ca52b..5039b88 100644 --- a/lib/ruby_llm/schema/dsl/schema_builders.rb +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -64,13 +64,15 @@ def object_schema(description: nil, of: nil, reference: nil, &block) schema_class_to_inline_schema(result).merge(description ? {description: description} : {}) # Block didn't return reference or schema, so we build an inline object schema else - { + schema = { type: "object", properties: sub_schema.properties, required: sub_schema.required_properties, additionalProperties: sub_schema.additional_properties, description: description }.compact + + merge_conditions(schema, sub_schema) end end end @@ -183,6 +185,8 @@ def schema_class_to_inline_schema(schema_class_or_instance) schema_class_or_instance.instance_variable_get(:@description) || schema_class.description end schema[:description] = description if description + + merge_conditions(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 6118d76..22ae3a6 100644 --- a/lib/ruby_llm/schema/dsl/utilities.rb +++ b/lib/ruby_llm/schema/dsl/utilities.rb @@ -27,9 +27,15 @@ def reference(schema_name) private - def add_property(name, definition, required:) + def add_property(name, definition, required:, requires: nil) properties[name.to_sym] = definition required_properties << name.to_sym if required + + if requires + builder = ConditionalBuilder.new + builder.requires(*Array(requires)) + dependencies[name.to_s] = builder + end end def primitive_type?(type) diff --git a/lib/ruby_llm/schema/json_output.rb b/lib/ruby_llm/schema/json_output.rb index 0fb57a7..fdca067 100644 --- a/lib/ruby_llm/schema/json_output.rb +++ b/lib/ruby_llm/schema/json_output.rb @@ -18,6 +18,8 @@ def to_json_schema # Only include $defs if there are definitions schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty? + self.class.merge_conditions(schema_hash, self.class) + { name: @name, description: @description || self.class.description, diff --git a/spec/ruby_llm/schema/properties/conditionals_spec.rb b/spec/ruby_llm/schema/properties/conditionals_spec.rb new file mode 100644 index 0000000..90a528c --- /dev/null +++ b/spec/ruby_llm/schema/properties/conditionals_spec.rb @@ -0,0 +1,500 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe RubyLLM::Schema, "conditional properties" do + let(:schema_class) { Class.new(described_class) } + + describe "condition coercion" do + it "coerces string to const" do + schema_class.string :role + schema_class.string :permissions, required: false + + schema_class.given role: "admin" do + requires :permissions + end + + condition = schema_class.conditions.first + expect(condition[:if][:properties]["role"]).to eq({const: "admin"}) + end + + it "coerces array to enum" do + schema_class.string :status + schema_class.string :reason, required: false + + schema_class.given status: ["suspended", "banned"] do + requires :reason + end + + condition = schema_class.conditions.first + expect(condition[:if][:properties]["status"]).to eq({enum: ["suspended", "banned"]}) + end + + it "coerces regexp to pattern" do + schema_class.string :email + schema_class.string :employee_id, required: false + + schema_class.given email: /@acme\.com$/ do + requires :employee_id + end + + condition = schema_class.conditions.first + expect(condition[:if][:properties]["email"]).to eq({pattern: "@acme\\.com$"}) + end + + it "passes hash through as raw schema" do + schema_class.integer :age, required: false + schema_class.boolean :parental_consent, required: false + + schema_class.given age: {maximum: 17} do + requires :parental_consent + end + + condition = schema_class.conditions.first + expect(condition[:if][:properties]["age"]).to eq({maximum: 17}) + end + + it "coerces integer to const" do + schema_class.integer :level + schema_class.string :badge, required: false + + schema_class.given level: 10 do + requires :badge + end + + condition = schema_class.conditions.first + expect(condition[:if][:properties]["level"]).to eq({const: 10}) + end + + it "coerces boolean to const" do + schema_class.boolean :active + schema_class.string :deactivation_reason, required: false + + schema_class.given active: false do + requires :deactivation_reason + end + + condition = schema_class.conditions.first + expect(condition[:if][:properties]["active"]).to eq({const: false}) + end + end + + describe "multiple properties" do + it "supports conditions on multiple properties" do + schema_class.string :country + schema_class.string :role + schema_class.string :tax_id, required: false + + schema_class.given country: "US", role: "employee" do + requires :tax_id + end + + condition = schema_class.conditions.first + expect(condition[:if][:properties]).to eq({ + "country" => {const: "US"}, + "role" => {const: "employee"} + }) + expect(condition[:if][:required]).to contain_exactly("country", "role") + end + end + + describe "then schema" do + it "supports requires with multiple fields" do + schema_class.string :role + schema_class.string :permissions, required: false + schema_class.string :department, required: false + + schema_class.given role: "manager" do + requires :permissions, :department + end + + then_schema = schema_class.conditions.first[:then] + expect(then_schema[:required]).to eq(["permissions", "department"]) + end + + it "supports validates with type and string constraints" do + schema_class.string :format + schema_class.string :date, required: false + + schema_class.given format: "iso8601" do + validates :date, type: :string, min_length: 10, pattern: "^\\d{4}-\\d{2}-\\d{2}" + end + + props = schema_class.conditions.first[:then][:properties] + expect(props["date"]).to eq({ + type: "string", + minLength: 10, + pattern: "^\\d{4}-\\d{2}-\\d{2}" + }) + end + + it "supports validates without explicit type" do + schema_class.string :status + schema_class.string :code, required: false + + schema_class.given status: "error" do + validates :code, min_length: 3, max_length: 10 + end + + props = schema_class.conditions.first[:then][:properties] + expect(props["code"]).to eq({minLength: 3, maxLength: 10}) + end + + it "supports validates with numeric constraints" do + schema_class.string :membership + schema_class.number :discount, required: false + + schema_class.given membership: "premium" do + validates :discount, type: :number, minimum: 10, maximum: 50 + end + + props = schema_class.conditions.first[:then][:properties] + expect(props["discount"]).to eq({type: "number", minimum: 10, maximum: 50}) + end + + it "supports validates with enum" do + schema_class.string :country + schema_class.string :state, required: false + + schema_class.given country: "US" do + validates :state, enum: ["CA", "NY", "TX"] + end + + props = schema_class.conditions.first[:then][:properties] + expect(props["state"]).to eq({enum: ["CA", "NY", "TX"]}) + end + + it "supports validates with not_value" do + schema_class.string :status + schema_class.string :notes, required: false + + schema_class.given status: "rejected" do + requires :notes + validates :notes, not_value: "N/A" + end + + props = schema_class.conditions.first[:then][:properties] + expect(props["notes"]).to eq({not: {const: "N/A"}}) + end + + it "supports validates with regexp pattern" do + schema_class.string :country + schema_class.string :zip_code, required: false + + schema_class.given country: "US" do + validates :zip_code, pattern: /^\d{5}(-\d{4})?$/ + end + + props = schema_class.conditions.first[:then][:properties] + expect(props["zip_code"]).to eq({pattern: "^\\d{5}(-\\d{4})?$"}) + end + end + + describe "otherwise (else)" do + it "includes else when otherwise is used" do + schema_class.boolean :domestic + schema_class.string :state, required: false + schema_class.string :country, required: false + + schema_class.given domestic: true do + requires :state + + otherwise do + requires :country + end + end + + condition = schema_class.conditions.first + expect(condition[:then][:required]).to eq(["state"]) + expect(condition[:else][:required]).to eq(["country"]) + end + + it "omits else when otherwise is not used" do + schema_class.string :role + schema_class.string :permissions, required: false + + schema_class.given role: "admin" do + requires :permissions + end + + expect(schema_class.conditions.first).not_to have_key(:else) + end + + it "supports validates in otherwise" do + schema_class.string :membership + schema_class.integer :max_items, required: false + + schema_class.given membership: "premium" do + validates :max_items, type: :integer, minimum: 100 + + otherwise do + validates :max_items, type: :integer, maximum: 10 + end + end + + condition = schema_class.conditions.first + expect(condition[:then][:properties]["max_items"]).to eq({type: "integer", minimum: 100}) + expect(condition[:else][:properties]["max_items"]).to eq({type: "integer", maximum: 10}) + end + end + + describe "JSON schema output" do + it "includes single condition as if/then at top level" do + schema_class.string :role + schema_class.string :permissions, required: false + + schema_class.given role: "admin" do + requires :permissions + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:if]).to eq({ + properties: {"role" => {const: "admin"}}, + required: ["role"] + }) + expect(schema[:then]).to eq({required: ["permissions"]}) + expect(schema).not_to have_key(:allOf) + end + + it "wraps multiple conditions in allOf" do + schema_class.string :role + schema_class.string :permissions, required: false + schema_class.string :api_key, required: false + + schema_class.given role: "admin" do + requires :permissions + end + + schema_class.given role: "developer" do + requires :api_key + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema).not_to have_key(:if) + expect(schema[:allOf].length).to eq(2) + expect(schema[:allOf][0][:if][:properties]["role"][:const]).to eq("admin") + expect(schema[:allOf][1][:if][:properties]["role"][:const]).to eq("developer") + end + + it "does not include conditions when none are defined" do + schema_class.string :name + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema).not_to have_key(:if) + expect(schema).not_to have_key(:then) + expect(schema).not_to have_key(:else) + expect(schema).not_to have_key(:allOf) + end + + it "propagates conditions through nested schema via of:" do + address_schema = Class.new(described_class) do + string :country, required: true + string :state, required: false + + given country: "US" do + requires :state + end + end + + person_schema = Class.new(described_class) do + string :name + array :addresses, of: address_schema, required: false + end + + items = person_schema.new.to_json_schema[:schema][:properties][:addresses][:items] + + expect(items[:if][:properties]["country"]).to eq({const: "US"}) + expect(items[:then][:required]).to eq(["state"]) + end + + it "includes else in JSON schema output" do + schema_class.boolean :domestic + schema_class.string :state, required: false + schema_class.string :country, required: false + + schema_class.given domestic: true do + requires :state + + otherwise do + requires :country + end + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:then][:required]).to eq(["state"]) + expect(schema[:else][:required]).to eq(["country"]) + end + end + + describe "dependent" do + it "outputs dependentRequired when only requires are used" do + schema_class.string :name + schema_class.number :credit_card, required: false + schema_class.string :billing_address, required: false + + schema_class.dependent :credit_card do + requires :billing_address + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) + expect(schema).not_to have_key(:dependentSchemas) + end + + it "outputs dependentRequired with multiple required fields" do + schema_class.number :credit_card, required: false + schema_class.string :billing_address, required: false + schema_class.string :cvv, required: false + + schema_class.dependent :credit_card do + requires :billing_address, :cvv + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address", "cvv"]}) + end + + it "supports inline requires: with a single field" do + schema_class.number :credit_card, required: false, requires: :billing_address + schema_class.string :billing_address, required: false + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) + end + + it "supports inline requires: with multiple fields" do + schema_class.number :credit_card, required: false, requires: %i[billing_address cvv] + schema_class.string :billing_address, required: false + schema_class.string :cvv, required: false + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address", "cvv"]}) + end + + it "supports inline requires: on different property types" do + schema_class.string :email, requires: :name + schema_class.string :name, required: false + schema_class.boolean :active, required: false, requires: :activated_at + schema_class.string :activated_at, required: false + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:dependentRequired]).to eq({ + "email" => ["name"], + "active" => ["activated_at"] + }) + end + + it "outputs dependentSchemas when validates are used" do + schema_class.number :credit_card, required: false + schema_class.string :billing_address, required: false + + schema_class.dependent :credit_card do + requires :billing_address + validates :billing_address, type: :string, min_length: 1 + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema).not_to have_key(:dependentRequired) + expect(schema[:dependentSchemas]).to eq({ + "credit_card" => { + required: ["billing_address"], + properties: {"billing_address" => {type: "string", minLength: 1}} + } + }) + end + + it "supports multiple dependencies" do + schema_class.number :credit_card, required: false + schema_class.string :billing_address, required: false + schema_class.string :name, required: false + schema_class.string :email, required: false + + schema_class.dependent :credit_card do + requires :billing_address + end + + schema_class.dependent :name do + requires :email + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:dependentRequired]).to eq({ + "credit_card" => ["billing_address"], + "name" => ["email"] + }) + end + + it "mixes dependentRequired and dependentSchemas" do + schema_class.number :credit_card, required: false + schema_class.string :billing_address, required: false + schema_class.string :name, required: false + schema_class.string :email, required: false + + schema_class.dependent :credit_card do + requires :billing_address + validates :billing_address, type: :string, min_length: 1 + end + + schema_class.dependent :name do + requires :email + end + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema[:dependentRequired]).to eq({"name" => ["email"]}) + expect(schema[:dependentSchemas]).to eq({ + "credit_card" => { + required: ["billing_address"], + properties: {"billing_address" => {type: "string", minLength: 1}} + } + }) + end + + it "propagates through nested schema via of:" do + payment_schema = Class.new(described_class) do + number :credit_card, required: false + string :billing_address, required: false + + dependent :credit_card do + requires :billing_address + end + end + + order_schema = Class.new(described_class) do + object :payment, of: payment_schema + end + + payment = order_schema.new.to_json_schema[:schema][:properties][:payment] + + expect(payment[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) + end + + it "does not include dependencies when none are defined" do + schema_class.string :name + + schema = schema_class.new.to_json_schema[:schema] + + expect(schema).not_to have_key(:dependentRequired) + expect(schema).not_to have_key(:dependentSchemas) + end + end + + describe "error handling" do + it "raises when no property conditions are provided" do + expect { + schema_class.given { requires :name } + }.to raise_error(ArgumentError, /requires at least one property condition/) + end + end +end From d2629b29ef7293c277ba65eed4b7863a5aa1adc1 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 19 May 2026 15:05:24 +0200 Subject: [PATCH 2/3] Address review feedback --- README.md | 152 ++---------------- lib/ruby_llm/schema/dsl/conditionals.rb | 57 ++++--- lib/ruby_llm/schema/dsl/utilities.rb | 6 +- .../schema/properties/conditionals_spec.rb | 109 ++++++------- .../properties/definitions_reference_spec.rb | 51 ++++++ 5 files changed, 146 insertions(+), 229 deletions(-) diff --git a/README.md b/README.md index e9b9678..39cde05 100644 --- a/README.md +++ b/README.md @@ -408,9 +408,7 @@ schema.to_json_schema > [!NOTE] > `dependentRequired` and `dependentSchemas` were introduced in JSON Schema Draft 2019-09. Not all LLM providers or validators support these keywords, check your provider's documentation for compatibility. -Use `requires:` inline or `dependent` block to express that the presence of one property requires other properties. This maps to JSON Schema's [`dependentRequired`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentRequired) and [`dependentSchemas`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentSchemas). - -The simplest form uses inline `requires:` on the property declaration: +Use `requires:` inline or `dependent` block to express that the presence of one property requires others. Maps to [`dependentRequired`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentRequired) and [`dependentSchemas`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentSchemas). ```ruby class PaymentSchema < RubyLLM::Schema @@ -419,65 +417,23 @@ class PaymentSchema < RubyLLM::Schema string :billing_address, required: false string :cvv, required: false end - -# Generates: -# { -# "dependentRequired": { -# "credit_card": ["billing_address", "cvv"] -# } -# } ``` -For a single dependency, use a symbol: +Use a `dependent` block when you also need validations — this upgrades the output to `dependentSchemas`: ```ruby -number :credit_card, required: false, requires: :billing_address -``` - -Use `dependent` block when you need validations. When only `requires` is used, the output uses the simpler `dependentRequired`. When `validates` is also used, it upgrades to `dependentSchemas`: - -```ruby -class PaymentSchema < RubyLLM::Schema - string :name - number :credit_card, required: false - string :billing_address, required: false - - dependent :credit_card do - requires :billing_address - validates :billing_address, type: :string, min_length: 1 - end +dependent :credit_card do + requires :billing_address + validates :billing_address, type: :string, min_length: 1 end - -# Generates: -# { -# "dependentSchemas": { -# "credit_card": { -# "required": ["billing_address"], -# "properties": { -# "billing_address": { "type": "string", "minLength": 1 } -# } -# } -# } -# } ``` ### Conditionals > [!NOTE] -> `if`/`then`/`else` was introduced in JSON Schema Draft 7. Not all LLM providers or validators support these keywords, check your provider's documentation for compatibility. - -Use `given` to add [JSON Schema `if`/`then`/`else`](https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse) rules. The condition values are automatically coerced: +> `if`/`then`/`else` was introduced in JSON Schema Draft 7. Not all LLM providers or validators support these keywords — check your provider's documentation for compatibility. -| Ruby value | JSON Schema | -|--------------------------|----------------------------------| -| `"string"` | `{ "const": "string" }` | -| `123` / `true` / `false` | `{ "const": 123 }` | -| `["a", "b"]` | `{ "enum": ["a", "b"] }` | -| `/pattern/` | `{ "pattern": "pattern" }` | -| `{ minimum: 18 }` | `{ "minimum": 18 }` (raw schema) | - - -Require a field when a property has a specific value: +Use `given` to add [JSON Schema `if`/`then`/`else`](https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse) rules. Condition values are automatically coerced: strings → `const`, arrays → `enum`, regexps → `pattern`, hashes → raw schema. ```ruby class OrderSchema < RubyLLM::Schema @@ -491,106 +447,26 @@ class OrderSchema < RubyLLM::Schema given status: "cancelled" do requires :cancellation_reason + validates :cancellation_reason, type: :string, min_length: 1 end end ``` -Multiple property conditions: - -```ruby -class EmployeeSchema < RubyLLM::Schema - string :country - string :role - string :tax_id, required: false - - given country: "US", role: "employee" do - requires :tax_id - end -end -``` - -Array conditions (enum), regexp conditions (pattern), and hash conditions (raw schema): - -* Array → enum -* Regexp → pattern -* Hash → raw JSON Schema - -```ruby -class AccountSchema < RubyLLM::Schema - string :status - string :reason, required: false - string :email - string :employee_id, required: false - integer :age, required: false - boolean :parental_consent, required: false - - given status: ["suspended", "banned"] do - requires :reason - end - - given email: /@acme\.com$/ do - requires :employee_id - end - - given age: { maximum: 17 } do - requires :parental_consent - end -end -``` - -Validate property values in the `then` branch: - -```ruby -class EventSchema < RubyLLM::Schema - string :format - string :date, required: false - - given format: "iso8601" do - requires :date - validates :date, type: :string, min_length: 10, pattern: "^\\d{4}-\\d{2}-\\d{2}" - end -end -``` - -`validates` supports: `type:`, `not_value:`, `min_length:`, `max_length:`, `pattern:` (`String` or `Regexp`), `enum:`, `const:`, `minimum:`, `maximum:`. +`validates` supports: `type:`, `not_value:`, `min_length:`, `max_length:`, `pattern:` (string or regexp), `enum:`, `const:`, `minimum:`, `maximum:`. Use `otherwise` for an `else` branch: ```ruby -class ShippingSchema < RubyLLM::Schema - boolean :domestic - string :state, required: false - string :country, required: false - - given domestic: true do - requires :state - - otherwise do - requires :country - end - end -end -``` - -Conditions propagate through nested schemas via `of:`: - -```ruby -class AddressSchema < RubyLLM::Schema - string :country - string :state, required: false +given domestic: true do + requires :state - given country: "US" do - requires :state + otherwise do + requires :country end end - -class PersonSchema < RubyLLM::Schema - string :name - array :addresses, of: AddressSchema, required: false -end ``` -The generated JSON Schema for addresses items will include the if/then rule. +Conditions propagate through nested schemas via `of:`. ## JSON Output diff --git a/lib/ruby_llm/schema/dsl/conditionals.rb b/lib/ruby_llm/schema/dsl/conditionals.rb index b0e1ca9..8a26791 100644 --- a/lib/ruby_llm/schema/dsl/conditionals.rb +++ b/lib/ruby_llm/schema/dsl/conditionals.rb @@ -12,34 +12,6 @@ def dependencies @dependencies ||= {} end - def merge_conditions(schema, schema_class) - if schema_class.respond_to?(:conditions) && schema_class.conditions.any? - if schema_class.conditions.length == 1 - schema.merge!(schema_class.conditions.first) - else - schema[:allOf] = schema_class.conditions - end - end - - if schema_class.respond_to?(:dependencies) && schema_class.dependencies.any? - dependent_required = {} - dependent_schemas = {} - - schema_class.dependencies.each do |property, builder| - if builder.validations_empty? - dependent_required[property] = builder.required_fields - else - dependent_schemas[property] = builder.to_schema - end - end - - schema[:dependentRequired] = dependent_required if dependent_required.any? - schema[:dependentSchemas] = dependent_schemas if dependent_schemas.any? - end - - schema - end - def dependent(property, &block) builder = ConditionalBuilder.new builder.instance_eval(&block) @@ -67,7 +39,34 @@ def given(**properties, &block) conditions << condition end - private + # @api private + def merge_conditions(schema, schema_class) + if schema_class.respond_to?(:conditions) && schema_class.conditions.any? + if schema_class.conditions.length == 1 + schema.merge!(schema_class.conditions.first) + else + schema[:allOf] = schema_class.conditions + end + end + + if schema_class.respond_to?(:dependencies) && schema_class.dependencies.any? + dependent_required = {} + dependent_schemas = {} + + schema_class.dependencies.each do |property, builder| + if builder.validations_empty? + dependent_required[property] = builder.required_fields + else + dependent_schemas[property] = builder.to_schema + end + end + + schema[:dependentRequired] = dependent_required if dependent_required.any? + schema[:dependentSchemas] = dependent_schemas if dependent_schemas.any? + end + + schema + end def coerce_condition(value) case value diff --git a/lib/ruby_llm/schema/dsl/utilities.rb b/lib/ruby_llm/schema/dsl/utilities.rb index 22ae3a6..b2daede 100644 --- a/lib/ruby_llm/schema/dsl/utilities.rb +++ b/lib/ruby_llm/schema/dsl/utilities.rb @@ -9,12 +9,16 @@ def define(name, &) sub_schema = Class.new(Schema) sub_schema.class_eval(&) - definitions[name] = { + schema = { type: "object", properties: sub_schema.properties, required: sub_schema.required_properties, additionalProperties: sub_schema.additional_properties } + + merge_conditions(schema, sub_schema) + + definitions[name] = schema end def reference(schema_name) diff --git a/spec/ruby_llm/schema/properties/conditionals_spec.rb b/spec/ruby_llm/schema/properties/conditionals_spec.rb index 90a528c..e70fe1b 100644 --- a/spec/ruby_llm/schema/properties/conditionals_spec.rb +++ b/spec/ruby_llm/schema/properties/conditionals_spec.rb @@ -5,6 +5,10 @@ RSpec.describe RubyLLM::Schema, "conditional properties" do let(:schema_class) { Class.new(described_class) } + def schema_output + schema_class.new.to_json_schema[:schema] + end + describe "condition coercion" do it "coerces string to const" do schema_class.string :role @@ -14,8 +18,7 @@ requires :permissions end - condition = schema_class.conditions.first - expect(condition[:if][:properties]["role"]).to eq({const: "admin"}) + expect(schema_output[:if][:properties]["role"]).to eq({const: "admin"}) end it "coerces array to enum" do @@ -26,8 +29,7 @@ requires :reason end - condition = schema_class.conditions.first - expect(condition[:if][:properties]["status"]).to eq({enum: ["suspended", "banned"]}) + expect(schema_output[:if][:properties]["status"]).to eq({enum: ["suspended", "banned"]}) end it "coerces regexp to pattern" do @@ -38,8 +40,7 @@ requires :employee_id end - condition = schema_class.conditions.first - expect(condition[:if][:properties]["email"]).to eq({pattern: "@acme\\.com$"}) + expect(schema_output[:if][:properties]["email"]).to eq({pattern: "@acme\\.com$"}) end it "passes hash through as raw schema" do @@ -50,8 +51,7 @@ requires :parental_consent end - condition = schema_class.conditions.first - expect(condition[:if][:properties]["age"]).to eq({maximum: 17}) + expect(schema_output[:if][:properties]["age"]).to eq({maximum: 17}) end it "coerces integer to const" do @@ -62,8 +62,7 @@ requires :badge end - condition = schema_class.conditions.first - expect(condition[:if][:properties]["level"]).to eq({const: 10}) + expect(schema_output[:if][:properties]["level"]).to eq({const: 10}) end it "coerces boolean to const" do @@ -74,8 +73,7 @@ requires :deactivation_reason end - condition = schema_class.conditions.first - expect(condition[:if][:properties]["active"]).to eq({const: false}) + expect(schema_output[:if][:properties]["active"]).to eq({const: false}) end end @@ -89,12 +87,13 @@ requires :tax_id end - condition = schema_class.conditions.first - expect(condition[:if][:properties]).to eq({ + schema = schema_output + + expect(schema[:if][:properties]).to eq({ "country" => {const: "US"}, "role" => {const: "employee"} }) - expect(condition[:if][:required]).to contain_exactly("country", "role") + expect(schema[:if][:required]).to contain_exactly("country", "role") end end @@ -108,8 +107,7 @@ requires :permissions, :department end - then_schema = schema_class.conditions.first[:then] - expect(then_schema[:required]).to eq(["permissions", "department"]) + expect(schema_output[:then][:required]).to eq(["permissions", "department"]) end it "supports validates with type and string constraints" do @@ -120,8 +118,7 @@ validates :date, type: :string, min_length: 10, pattern: "^\\d{4}-\\d{2}-\\d{2}" end - props = schema_class.conditions.first[:then][:properties] - expect(props["date"]).to eq({ + expect(schema_output[:then][:properties]["date"]).to eq({ type: "string", minLength: 10, pattern: "^\\d{4}-\\d{2}-\\d{2}" @@ -136,8 +133,7 @@ validates :code, min_length: 3, max_length: 10 end - props = schema_class.conditions.first[:then][:properties] - expect(props["code"]).to eq({minLength: 3, maxLength: 10}) + expect(schema_output[:then][:properties]["code"]).to eq({minLength: 3, maxLength: 10}) end it "supports validates with numeric constraints" do @@ -148,8 +144,7 @@ validates :discount, type: :number, minimum: 10, maximum: 50 end - props = schema_class.conditions.first[:then][:properties] - expect(props["discount"]).to eq({type: "number", minimum: 10, maximum: 50}) + expect(schema_output[:then][:properties]["discount"]).to eq({type: "number", minimum: 10, maximum: 50}) end it "supports validates with enum" do @@ -160,8 +155,7 @@ validates :state, enum: ["CA", "NY", "TX"] end - props = schema_class.conditions.first[:then][:properties] - expect(props["state"]).to eq({enum: ["CA", "NY", "TX"]}) + expect(schema_output[:then][:properties]["state"]).to eq({enum: ["CA", "NY", "TX"]}) end it "supports validates with not_value" do @@ -173,8 +167,10 @@ validates :notes, not_value: "N/A" end - props = schema_class.conditions.first[:then][:properties] - expect(props["notes"]).to eq({not: {const: "N/A"}}) + schema = schema_output + + expect(schema[:then][:required]).to eq(["notes"]) + expect(schema[:then][:properties]["notes"]).to eq({not: {const: "N/A"}}) end it "supports validates with regexp pattern" do @@ -185,8 +181,7 @@ validates :zip_code, pattern: /^\d{5}(-\d{4})?$/ end - props = schema_class.conditions.first[:then][:properties] - expect(props["zip_code"]).to eq({pattern: "^\\d{5}(-\\d{4})?$"}) + expect(schema_output[:then][:properties]["zip_code"]).to eq({pattern: "^\\d{5}(-\\d{4})?$"}) end end @@ -204,9 +199,10 @@ end end - condition = schema_class.conditions.first - expect(condition[:then][:required]).to eq(["state"]) - expect(condition[:else][:required]).to eq(["country"]) + schema = schema_output + + expect(schema[:then][:required]).to eq(["state"]) + expect(schema[:else][:required]).to eq(["country"]) end it "omits else when otherwise is not used" do @@ -217,7 +213,7 @@ requires :permissions end - expect(schema_class.conditions.first).not_to have_key(:else) + expect(schema_output).not_to have_key(:else) end it "supports validates in otherwise" do @@ -232,9 +228,10 @@ end end - condition = schema_class.conditions.first - expect(condition[:then][:properties]["max_items"]).to eq({type: "integer", minimum: 100}) - expect(condition[:else][:properties]["max_items"]).to eq({type: "integer", maximum: 10}) + schema = schema_output + + expect(schema[:then][:properties]["max_items"]).to eq({type: "integer", minimum: 100}) + expect(schema[:else][:properties]["max_items"]).to eq({type: "integer", maximum: 10}) end end @@ -247,7 +244,7 @@ requires :permissions end - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema[:if]).to eq({ properties: {"role" => {const: "admin"}}, @@ -270,7 +267,7 @@ requires :api_key end - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema).not_to have_key(:if) expect(schema[:allOf].length).to eq(2) @@ -281,7 +278,7 @@ it "does not include conditions when none are defined" do schema_class.string :name - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema).not_to have_key(:if) expect(schema).not_to have_key(:then) @@ -299,12 +296,12 @@ end end - person_schema = Class.new(described_class) do + parent_schema = Class.new(described_class) do string :name array :addresses, of: address_schema, required: false end - items = person_schema.new.to_json_schema[:schema][:properties][:addresses][:items] + items = parent_schema.new.to_json_schema[:schema][:properties][:addresses][:items] expect(items[:if][:properties]["country"]).to eq({const: "US"}) expect(items[:then][:required]).to eq(["state"]) @@ -323,7 +320,7 @@ end end - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema[:then][:required]).to eq(["state"]) expect(schema[:else][:required]).to eq(["country"]) @@ -340,7 +337,7 @@ requires :billing_address end - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) expect(schema).not_to have_key(:dependentSchemas) @@ -355,18 +352,14 @@ requires :billing_address, :cvv end - schema = schema_class.new.to_json_schema[:schema] - - expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address", "cvv"]}) + expect(schema_output[:dependentRequired]).to eq({"credit_card" => ["billing_address", "cvv"]}) end it "supports inline requires: with a single field" do schema_class.number :credit_card, required: false, requires: :billing_address schema_class.string :billing_address, required: false - schema = schema_class.new.to_json_schema[:schema] - - expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) + expect(schema_output[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) end it "supports inline requires: with multiple fields" do @@ -374,9 +367,7 @@ schema_class.string :billing_address, required: false schema_class.string :cvv, required: false - schema = schema_class.new.to_json_schema[:schema] - - expect(schema[:dependentRequired]).to eq({"credit_card" => ["billing_address", "cvv"]}) + expect(schema_output[:dependentRequired]).to eq({"credit_card" => ["billing_address", "cvv"]}) end it "supports inline requires: on different property types" do @@ -385,9 +376,7 @@ schema_class.boolean :active, required: false, requires: :activated_at schema_class.string :activated_at, required: false - schema = schema_class.new.to_json_schema[:schema] - - expect(schema[:dependentRequired]).to eq({ + expect(schema_output[:dependentRequired]).to eq({ "email" => ["name"], "active" => ["activated_at"] }) @@ -402,7 +391,7 @@ validates :billing_address, type: :string, min_length: 1 end - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema).not_to have_key(:dependentRequired) expect(schema[:dependentSchemas]).to eq({ @@ -427,9 +416,7 @@ requires :email end - schema = schema_class.new.to_json_schema[:schema] - - expect(schema[:dependentRequired]).to eq({ + expect(schema_output[:dependentRequired]).to eq({ "credit_card" => ["billing_address"], "name" => ["email"] }) @@ -450,7 +437,7 @@ requires :email end - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema[:dependentRequired]).to eq({"name" => ["email"]}) expect(schema[:dependentSchemas]).to eq({ @@ -483,7 +470,7 @@ it "does not include dependencies when none are defined" do schema_class.string :name - schema = schema_class.new.to_json_schema[:schema] + schema = schema_output expect(schema).not_to have_key(:dependentRequired) expect(schema).not_to have_key(:dependentSchemas) diff --git a/spec/ruby_llm/schema/properties/definitions_reference_spec.rb b/spec/ruby_llm/schema/properties/definitions_reference_spec.rb index f34aecc..791c14b 100644 --- a/spec/ruby_llm/schema/properties/definitions_reference_spec.rb +++ b/spec/ruby_llm/schema/properties/definitions_reference_spec.rb @@ -112,6 +112,57 @@ expect(properties[:headquarters]).to eq({"$ref" => "#/$defs/address"}) end + it "includes given conditions in $defs" do + schema_class.define :address do + string :country + string :state, required: false + + given country: "US" do + requires :state + end + end + + schema_class.object :address, of: :address + + defs = schema_class.new.to_json_schema[:schema]["$defs"][:address] + + expect(defs[:if]).to eq({ + properties: {"country" => {const: "US"}}, + required: ["country"] + }) + expect(defs[:then]).to eq({required: ["state"]}) + end + + it "includes dependent in $defs" do + schema_class.define :payment do + number :credit_card, required: false + string :billing_address, required: false + + dependent :credit_card do + requires :billing_address + end + end + + schema_class.object :payment, of: :payment + + defs = schema_class.new.to_json_schema[:schema]["$defs"][:payment] + + expect(defs[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) + end + + it "includes inline requires: in $defs" do + schema_class.define :payment do + number :credit_card, required: false, requires: :billing_address + string :billing_address, required: false + end + + schema_class.object :payment, of: :payment + + defs = schema_class.new.to_json_schema[:schema]["$defs"][:payment] + + expect(defs[:dependentRequired]).to eq({"credit_card" => ["billing_address"]}) + end + it "shows deprecation warning if using reference option" do schema_class.define :address do string :street From db83c76a9adce089783e0e3f46d53b322bcd0de2 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Wed, 20 May 2026 13:32:13 +0200 Subject: [PATCH 3/3] Adress PR feedback --- .rubocop.yml | 2 +- README.md | 10 +--- lib/ruby_llm/schema/dsl/complex_types.rb | 16 +++--- lib/ruby_llm/schema/dsl/conditionals.rb | 37 +++++++++----- lib/ruby_llm/schema/dsl/schema_builders.rb | 1 + lib/ruby_llm/schema/json_output.rb | 2 +- .../schema/properties/conditionals_spec.rb | 49 +++++++++++++++++++ 7 files changed, 88 insertions(+), 29 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 4c16da8..7831d0e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,7 +18,7 @@ Metrics/ClassLength: Metrics/CyclomaticComplexity: Max: 25 Metrics/ParameterLists: - Max: 10 + Max: 6 Metrics/PerceivedComplexity: Max: 12 Metrics/MethodLength: diff --git a/README.md b/README.md index 2d7fa2a..df2ce9c 100644 --- a/README.md +++ b/README.md @@ -445,10 +445,7 @@ schema.to_json_schema ### Dependencies -> [!NOTE] -> `dependentRequired` and `dependentSchemas` were introduced in JSON Schema Draft 2019-09. Not all LLM providers or validators support these keywords, check your provider's documentation for compatibility. - -Use `requires:` inline or `dependent` block to express that the presence of one property requires others. Maps to [`dependentRequired`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentRequired) and [`dependentSchemas`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentSchemas). +Use `requires:` inline or `dependent` block to express that the presence of one property requires others. Maps to [`dependentRequired`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentRequired) (Draft 2019-09) and [`dependentSchemas`](https://json-schema.org/understanding-json-schema/reference/conditionals#dependentSchemas) (Draft 2019-09). Check your provider's documentation for compatibility. ```ruby class PaymentSchema < RubyLLM::Schema @@ -470,10 +467,7 @@ end ### Conditionals -> [!NOTE] -> `if`/`then`/`else` was introduced in JSON Schema Draft 7. Not all LLM providers or validators support these keywords — check your provider's documentation for compatibility. - -Use `given` to add [JSON Schema `if`/`then`/`else`](https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse) rules. Condition values are automatically coerced: strings → `const`, arrays → `enum`, regexps → `pattern`, hashes → raw schema. +Use `given` to add [JSON Schema `if`/`then`/`else`](https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse) (Draft 7) rules. Condition values are automatically coerced: strings → `const`, arrays → `enum`, regexps → `pattern`, hashes → raw schema. ```ruby class OrderSchema < RubyLLM::Schema diff --git a/lib/ruby_llm/schema/dsl/complex_types.rb b/lib/ruby_llm/schema/dsl/complex_types.rb index fe5be49..e214f95 100644 --- a/lib/ruby_llm/schema/dsl/complex_types.rb +++ b/lib/ruby_llm/schema/dsl/complex_types.rb @@ -4,20 +4,20 @@ module RubyLLM class Schema module DSL module ComplexTypes - def object(name, description: nil, required: true, **options, &block) - add_property(name, object_schema(description: description, **options, &block), required: required) + def object(name, description: nil, required: true, requires: nil, **options, &block) + add_property(name, object_schema(description: description, **options, &block), required: required, requires: requires) end - def array(name, description: nil, required: true, **options, &block) - add_property(name, array_schema(description: description, **options, &block), required: required) + def array(name, description: nil, required: true, requires: nil, **options, &block) + add_property(name, array_schema(description: description, **options, &block), required: required, requires: requires) end - def any_of(name, description: nil, required: true, **options, &block) - add_property(name, any_of_schema(description: description, **options, &block), required: required) + def any_of(name, description: nil, required: true, requires: nil, **options, &block) + add_property(name, any_of_schema(description: description, **options, &block), required: required, requires: requires) end - def one_of(name, description: nil, required: true, **options, &block) - add_property(name, one_of_schema(description: description, **options, &block), required: required) + def one_of(name, description: nil, required: true, requires: nil, **options, &block) + add_property(name, one_of_schema(description: description, **options, &block), required: required, requires: requires) end def optional(name, description: nil, &block) diff --git a/lib/ruby_llm/schema/dsl/conditionals.rb b/lib/ruby_llm/schema/dsl/conditionals.rb index 8a26791..ba310bc 100644 --- a/lib/ruby_llm/schema/dsl/conditionals.rb +++ b/lib/ruby_llm/schema/dsl/conditionals.rb @@ -39,7 +39,8 @@ def given(**properties, &block) conditions << condition end - # @api private + private + def merge_conditions(schema, schema_class) if schema_class.respond_to?(:conditions) && schema_class.conditions.any? if schema_class.conditions.length == 1 @@ -102,18 +103,32 @@ def requires(*fields) required.concat(fields.map(&:to_s)) end - def validates(field, type: nil, not_value: nil, min_length: nil, max_length: nil, pattern: nil, enum: nil, const: nil, minimum: nil, maximum: nil) + VALIDATES_KEY_MAP = { + type: :type, + const: :const, + enum: :enum, + not_value: :not, + min_length: :minLength, + max_length: :maxLength, + pattern: :pattern, + minimum: :minimum, + maximum: :maximum + }.freeze + + def validates(field, **options) constraints = {} - constraints[:type] = type.to_s if type - constraints[:const] = const if const - constraints[:enum] = enum if enum - constraints[:not] = {const: not_value} if not_value - constraints[:minLength] = min_length if min_length - constraints[:maxLength] = max_length if max_length - constraints[:pattern] = pattern.is_a?(Regexp) ? pattern.source : pattern if pattern - constraints[:minimum] = minimum if minimum - constraints[:maximum] = maximum if maximum + options.each do |key, value| + schema_key = VALIDATES_KEY_MAP[key] + raise ArgumentError, "unknown validates option: #{key.inspect}" unless schema_key + + case key + when :type then constraints[:type] = value.to_s + when :not_value then constraints[:not] = {const: value} + when :pattern then constraints[:pattern] = value.is_a?(Regexp) ? value.source : value + else constraints[schema_key] = value + end + end validations[field.to_s] = constraints end diff --git a/lib/ruby_llm/schema/dsl/schema_builders.rb b/lib/ruby_llm/schema/dsl/schema_builders.rb index 887426a..287865d 100644 --- a/lib/ruby_llm/schema/dsl/schema_builders.rb +++ b/lib/ruby_llm/schema/dsl/schema_builders.rb @@ -182,6 +182,7 @@ def schema_class_to_inline_schema(schema_class_or_instance) else schema_class_or_instance.instance_variable_get(:@description) || schema_class.description end + schema[:description] = description if description merge_conditions(schema, schema_class) diff --git a/lib/ruby_llm/schema/json_output.rb b/lib/ruby_llm/schema/json_output.rb index c77a698..2878f9e 100644 --- a/lib/ruby_llm/schema/json_output.rb +++ b/lib/ruby_llm/schema/json_output.rb @@ -18,7 +18,7 @@ def to_json_schema # Only include $defs if there are definitions schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty? - self.class.merge_conditions(schema_hash, self.class) + self.class.send(:merge_conditions, schema_hash, self.class) { name: @name, diff --git a/spec/ruby_llm/schema/properties/conditionals_spec.rb b/spec/ruby_llm/schema/properties/conditionals_spec.rb index bd4c810..e654400 100644 --- a/spec/ruby_llm/schema/properties/conditionals_spec.rb +++ b/spec/ruby_llm/schema/properties/conditionals_spec.rb @@ -173,6 +173,39 @@ def schema_output expect(schema[:then][:properties]["notes"]).to eq({not: {const: "N/A"}}) end + it "preserves falsey constraint values" do + schema_class.boolean :enabled + schema_class.string :reason, required: false + + schema_class.given enabled: true do + validates :reason, const: false + end + + expect(schema_output[:then][:properties]["reason"]).to eq({const: false}) + end + + it "preserves not_value: false" do + schema_class.boolean :active + schema_class.boolean :verified, required: false + + schema_class.given active: true do + validates :verified, not_value: false + end + + expect(schema_output[:then][:properties]["verified"]).to eq({not: {const: false}}) + end + + it "raises on unknown validates option" do + schema_class.string :status + schema_class.string :code, required: false + + expect { + schema_class.given status: "error" do + validates :code, unknown_option: true + end + }.to raise_error(ArgumentError, /unknown validates option: :unknown_option/) + end + it "supports validates with regexp pattern" do schema_class.string :country schema_class.string :zip_code, required: false @@ -382,6 +415,22 @@ def schema_output }) end + it "supports inline requires: on object properties" do + schema_class.object :payment, required: false, requires: :billing_address do + string :method + end + schema_class.string :billing_address, required: false + + expect(schema_output[:dependentRequired]).to eq({"payment" => ["billing_address"]}) + end + + it "supports inline requires: on array properties" do + schema_class.array :items, of: :string, required: false, requires: :item_count + schema_class.integer :item_count, required: false + + expect(schema_output[:dependentRequired]).to eq({"items" => ["item_count"]}) + end + it "outputs dependentSchemas when validates are used" do schema_class.number :credit_card, required: false schema_class.string :billing_address, required: false