Skip to content

Add support for Dependencies and Conditionals#31

Merged
crmne merged 4 commits into
crmne:mainfrom
marcoroth:conditionals
May 20, 2026
Merged

Add support for Dependencies and Conditionals#31
crmne merged 4 commits into
crmne:mainfrom
marcoroth:conditionals

Conversation

@marcoroth

@marcoroth marcoroth commented May 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Add given, dependent, and inline requires: DSL methods for expressing JSON Schema conditionals and dependencies.

These keywords are not typically supported by LLM providers for structured output, but ruby_llm-schema's DSL is also used beyond LLM contexts, for example, RubyEvents uses it to define YAML validation schemas that are exported to JSON Schema and validated with yerba.

requires: inline property dependencies

Maps to dependentRequired. The simplest form for "if this property is present, require those":

class PaymentSchema < RubyLLM::Schema
  number :credit_card, required: false, requires: %i[billing_address cvv]
  string :billing_address, required: false
  string :cvv, required: false
end

dependent property dependencies with validations

Use a block when you need validates, upgrades output to dependentSchemas:

dependent :credit_card do
  requires :billing_address
  validates :billing_address, type: :string, min_length: 1
end

given if/then/else

Condition values are auto-coerced: strings → const, arrays → enum, regexps → pattern, hashes → raw schema.

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

All three propagate through nested schemas via of: and define/$defs.

Closes #44

marcoroth added a commit to rubyevents/rubyevents that referenced this pull request May 14, 2026
@crmne

crmne commented May 14, 2026

Copy link
Copy Markdown
Owner

Hi @marcoroth and thanks for the PR!

I have a few thoughts about the DSL:

I'd prefer dependentRequired to be specified inline with the property that requires it:

class PaymentSchema < RubyLLM::Schema
  number :credit_card, required: false, requires: :billing_address
  string :billing_address, required: false
end

Maybe also

number :credit_card, required: false, requires: %i[billing_address cvv]

For conditionals, I'd prefer:

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

@marcoroth

marcoroth commented May 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @crmne, I updated the PR to use given and added the requires kwarg 🙌🏼

@crmne crmne left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. The direction makes sense and the surface API fits the gem, but I think this needs one correctness fix before merging.

Required change

defined schemas do not carry the new conditional/dependency metadata into $defs.

The new merge path is wired into top-level output and inline/nested schema generation, but define still builds the $defs entry manually in lib/ruby_llm/schema/dsl/utilities.rb without calling the same merge helper. That means this sort of schema silently drops the rule:

class OrderSchema < RubyLLM::Schema
  define :address do
    string :country
    string :state, required: false

    given country: "US" do
      requires :state
    end
  end

  object :address, of: :address
end

The generated $defs[:address] contains the properties and required fields, but not the if/then rule. The same applies to requires: / dependent inside a define block. Since define is one of the existing reusable-schema paths, this creates inconsistent behavior between referenced schemas and inline schemas.

Please update define to include conditions/dependencies and add tests in the definitions/reference specs covering both given and dependency output inside a defined schema.

Style notes

The implementation mostly matches the existing codebase: the feature is isolated in a DSL module, uses the same keyword-option style, and keeps the schema hashes compact.

A few things I would adjust for consistency:

  • Prefer testing more through to_json_schema rather than directly asserting schema_class.conditions. Existing property specs mostly exercise the public output shape, which makes the tests less coupled to internal storage.
  • merge_conditions looks like an internal schema-output helper, but it is currently a public DSL method. Consider making it private or renaming/placing it so it is clearly not user-facing.
  • The README examples are useful, but the new section is much more exhaustive than nearby feature sections. I would tighten it a bit and keep the deeper behavior covered by specs.

I ran the suite locally and the current PR is green (bundle exec rspec, bundle exec rubocop --force-exclusion), so the blocker is about the missing $defs behavior rather than a failing existing test.

@marcoroth

Copy link
Copy Markdown
Contributor Author

@crmne thanks for the review, just updated it 🙌🏼

@crmne crmne added this to the 1.0.0 milestone May 19, 2026
@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@crmne crmne left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates. PR #31 is now linked to #44 and the overall direction matches the issue: given, dependent, and inline requires: are the right DSL shape for these JSON Schema keywords. The $defs propagation issue from the previous review is also fixed.

I still need a few changes before this is ready:

  1. validates drops falsey constraint values.

    In lib/ruby_llm/schema/dsl/conditionals.rb, the constraint builder uses truthiness checks:

    constraints[:const] = const if const
    constraints[:not] = {const: not_value} if not_value

    This silently loses valid JSON Schema constraints such as const: false and not_value: false, producing an empty property schema instead of the intended validation. Please preserve explicitly provided falsey values. Also consider how const: nil should be represented, since nil is a valid JSON value but cannot be distinguished from the default with the current keyword signature.

  2. Inline requires: only works on primitive properties.

    The issue describes inline property dependencies generally, but requires: is only threaded through PrimitiveTypes. Complex property methods still pass all options into their schema builders, so this raises unknown keyword: :requires:

    object :payment, required: false, requires: :billing_address do
      string :method
    end

    Please make inline requires: consistent across property DSL methods (object, array, any_of, one_of as well as primitives), or narrow the public API and docs/tests so the limitation is explicit.

  3. Please avoid changing the global RuboCop parameter-list limit for this feature.

    .rubocop.yml increases Metrics/ParameterLists from 6 to 10 to accommodate ConditionalBuilder#validates. That weakens the project-wide style guard for one method. Refactor validates instead, for example by accepting **options and normalizing allowed keys, or by extracting a small constraint-building helper.

  4. Keep internal output helpers out of the public DSL surface.

    merge_conditions is an output-generation helper, and coerce_condition is also implementation detail. They are currently public class methods because they live alongside user DSL methods in Conditionals. Please make these private or move the output merge behavior somewhere that is clearly internal.

  5. Reduce duplicated schema-building logic in ConditionalBuilder#validates.

    The method manually maps DSL options to JSON Schema keys, separate from the existing schema builder path. That makes it easier for conditionals to drift from normal property behavior, and it is already where the falsey-value bug appears. Please align this with the existing builder style so condition branch validations behave predictably and stay easier to maintain.

  6. Bring the README section closer to the surrounding documentation style.

    The new examples are useful, but the section is more expansive than nearby property sections and uses [!NOTE] callouts where the README mostly uses plain prose. Please tighten it to the public API and keep deeper edge behavior in specs.

I reran the current PR checks locally earlier and the suite/lint were green, so these are API correctness and maintainability changes rather than CI failures.

@crmne

crmne commented May 20, 2026

Copy link
Copy Markdown
Owner

One clarification on the review: the falsey-value bug is a symptom of the same design issue as the duplicated validation builder.

ConditionalBuilder#validates is manually rebuilding JSON Schema constraint hashes instead of reusing or mirroring the existing schema-building path. That is why const: false / not_value: false get dropped, and it also creates future drift risk between normal property schemas and conditional branch schemas.

Please treat that as the main fix: refactor the conditional validation path so it preserves explicitly supplied values and stays aligned with the existing schema builder behavior, rather than patching only the individual falsey checks.

@marcoroth

Copy link
Copy Markdown
Contributor Author

@crmne updated

@crmne crmne merged commit 2c5629e into crmne:main May 20, 2026
10 checks passed
@marcoroth marcoroth deleted the conditionals branch May 20, 2026 12:41
@crmne

crmne commented May 20, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR, it's now merged!

The send(:merge_conditions, ...) is a little awkward but I accepted it since it keeps the helper private.

@marcoroth

marcoroth commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

The send(:merge_conditions, ...) is a little awkward but I accepted it since it keeps the helper private.

@crmne let me if you have a better idea on how to solve it

@crmne

crmne commented May 20, 2026

Copy link
Copy Markdown
Owner

A separate internal module/object would be cleaner, like Conditionals.merge_into(schema_hash, schema_class), but I think we're splitting hairs here. I'm fine with this as-is!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add conditional and dependency keywords

2 participants