diff --git a/README.md b/README.md index d8a74ba..df2ce9c 100644 --- a/README.md +++ b/README.md @@ -443,6 +443,65 @@ schema.to_json_schema # } ``` +### Dependencies + +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 + string :name + number :credit_card, required: false, requires: %i[billing_address cvv] + string :billing_address, required: false + string :cvv, required: false +end +``` + +Use a `dependent` block when you also need validations — this upgrades the output to `dependentSchemas`: + +```ruby +dependent :credit_card do + requires :billing_address + validates :billing_address, type: :string, min_length: 1 +end +``` + +### Conditionals + +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 + 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 + validates :cancellation_reason, type: :string, min_length: 1 + 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 +given domestic: true do + requires :state + + otherwise do + requires :country + end +end +``` + +Conditions propagate through nested schemas via `of:`. + ## 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/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 new file mode 100644 index 0000000..ba310bc --- /dev/null +++ b/lib/ruby_llm/schema/dsl/conditionals.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +module RubyLLM + class Schema + module DSL + module Conditionals + def conditions + @conditions ||= [] + end + + def dependencies + @dependencies ||= {} + 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 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 + 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 + + 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 = {} + + 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 + + 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 a8c137d..287865d 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 @@ -180,7 +182,10 @@ 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) end end end diff --git a/lib/ruby_llm/schema/dsl/utilities.rb b/lib/ruby_llm/schema/dsl/utilities.rb index b80f536..35d2565 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) @@ -27,7 +31,7 @@ def reference(schema_name) private - def add_property(name, definition, required:) + def add_property(name, definition, required:, requires: nil) property_name = name.to_sym properties[property_name] = definition @@ -37,6 +41,12 @@ def add_property(name, definition, required:) required_properties.delete(property_name) end + if requires + builder = ConditionalBuilder.new + builder.requires(*Array(requires)) + dependencies[name.to_s] = builder + end + nil end diff --git a/lib/ruby_llm/schema/json_output.rb b/lib/ruby_llm/schema/json_output.rb index 5471e3e..2878f9e 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.send(: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..e654400 --- /dev/null +++ b/spec/ruby_llm/schema/properties/conditionals_spec.rb @@ -0,0 +1,536 @@ +# frozen_string_literal: true + +require "spec_helper" + +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 + schema_class.string :permissions, required: false + + schema_class.given role: "admin" do + requires :permissions + end + + expect(schema_output[: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: %w[suspended banned] do + requires :reason + end + + expect(schema_output[:if][:properties]["status"]).to eq({enum: %w[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 + + expect(schema_output[: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 + + expect(schema_output[: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 + + expect(schema_output[: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 + + expect(schema_output[: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 + + schema = schema_output + + expect(schema[:if][:properties]).to eq({ + "country" => {const: "US"}, + "role" => {const: "employee"} + }) + expect(schema[: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 + + expect(schema_output[:then][:required]).to eq(%w[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 + + expect(schema_output[:then][:properties]["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 + + expect(schema_output[:then][:properties]["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 + + expect(schema_output[:then][:properties]["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: %w[CA NY TX] + end + + expect(schema_output[:then][:properties]["state"]).to eq({enum: %w[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 + + schema = schema_output + + expect(schema[:then][:required]).to eq(["notes"]) + 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 + + schema_class.given country: "US" do + validates :zip_code, pattern: /^\d{5}(-\d{4})?$/ + end + + expect(schema_output[:then][:properties]["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 + + 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 + schema_class.string :role + schema_class.string :permissions, required: false + + schema_class.given role: "admin" do + requires :permissions + end + + expect(schema_output).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 + + 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 + + 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_output + + 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_output + + 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_output + + 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 + + parent_schema = Class.new(described_class) do + string :name + array :addresses, of: address_schema, required: false + end + + 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"]) + 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_output + + 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_output + + 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 + + expect(schema_output[:dependentRequired]).to eq({"credit_card" => %w[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 + + expect(schema_output[: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 + + expect(schema_output[:dependentRequired]).to eq({"credit_card" => %w[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 + + expect(schema_output[:dependentRequired]).to eq({ + "email" => ["name"], + "active" => ["activated_at"] + }) + 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 + + schema_class.dependent :credit_card do + requires :billing_address + validates :billing_address, type: :string, min_length: 1 + end + + schema = schema_output + + 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 + + expect(schema_output[: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_output + + 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_output + + 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 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