From bd4c32056f64fa8141272e5b4e3881bf1b17569e Mon Sep 17 00:00:00 2001 From: Hakan Ensari Date: Thu, 16 Oct 2025 21:07:42 +0200 Subject: [PATCH 1/2] feat: add null: false option for GraphQL-style non-null semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #15 by adding a `null` keyword parameter to both `attribute` and `attribute?` methods. When set to false, Structure will raise an ArgumentError if the coerced value is nil. - attribute(:id, String, null: false) enforces non-null on required keys - attribute?(:name, String, null: false) enforces non-null on optional keys when present (missing keys are allowed) This matches GraphQL's non-null type modifier semantics and makes Structure ideal for parsing GraphQL responses. Changes: - Add @non_nullable Set to Builder to track null: false attributes - Add null: true parameter to attribute and attribute? methods - Add Builder#non_nullable accessor method - Update Structure metadata to include non_nullable - Add validation in parse method after coercion - Add comprehensive test suite (18 tests) - Update RBS type signatures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 12 +++ lib/structure.rb | 22 ++-- lib/structure/builder.rb | 19 +++- sig/structure/builder.rbs | 10 +- test/test_non_nullable.rb | 220 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 test/test_non_nullable.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c017f53..ed343bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - Include custom instance and class methods from `Structure.new` blocks when emitting RBS signatures +- Add `null: false` option to `attribute` and `attribute?` for GraphQL-style non-null semantics + ```ruby + User = Structure.new do + attribute(:id, String, null: false) # required key, must be non-null + attribute?(:name, String, null: false) # optional key, but must be non-null when present + attribute?(:description, String) # optional key, can be null (default) + end + + User.parse(id: "123", name: "Alice") # ✓ valid + User.parse(id: "123", name: nil) # ✗ raises ArgumentError: cannot be null: :name + User.parse(id: "123") # ✓ valid (name is optional) + ``` ## [4.1.0] - 2025-10-09 diff --git a/lib/structure.rb b/lib/structure.rb index 7f0c4a3..f35ebc3 100644 --- a/lib/structure.rb +++ b/lib/structure.rb @@ -107,6 +107,7 @@ def new(&block) coercions: builder.coercions(klass), after_parse: builder.after_parse_callback, required: builder.required, + non_nullable: builder.non_nullable, custom_methods: custom_methods_metadata, }.freeze klass.instance_variable_set(:@__structure_meta__, meta) @@ -139,12 +140,13 @@ def new(&block) overrides&.each { |k, v| data[k.to_s] = v } - final = {} - mappings = __structure_meta__[:mappings] - defaults = __structure_meta__[:defaults] - coercions = __structure_meta__[:coercions] - after_parse = __structure_meta__[:after_parse] - required = __structure_meta__[:required] + final = {} + mappings = __structure_meta__[:mappings] + defaults = __structure_meta__[:defaults] + coercions = __structure_meta__[:coercions] + after_parse = __structure_meta__[:after_parse] + required = __structure_meta__[:required] + non_nullable = __structure_meta__[:non_nullable] # Check for missing required attributes required.each do |attr| @@ -155,6 +157,8 @@ def new(&block) end mappings.each do |attr, from| + key_present = data.key?(from) || data.key?(from.to_sym) + value = data.fetch(from) do data.fetch(from.to_sym) do defaults[attr] @@ -166,6 +170,12 @@ def new(&block) value = coercion.call(value) if coercion end + # Check non-null constraint after coercion + # Only check if key was present in data OR attribute has an explicit default + if value.nil? && non_nullable.include?(attr) && (key_present || defaults.key?(attr)) + raise ArgumentError, "cannot be null: :#{attr}" + end + final[attr] = value end diff --git a/lib/structure/builder.rb b/lib/structure/builder.rb index 8374476..59f7786 100644 --- a/lib/structure/builder.rb +++ b/lib/structure/builder.rb @@ -14,6 +14,7 @@ def initialize @defaults = {} @types = {} @optional = Set.new + @non_nullable = Set.new end # DSL method for defining attributes with optional type coercion @@ -22,6 +23,7 @@ def initialize # @param type [Class, Symbol, Array, nil] Type for coercion (e.g., String, :boolean, [String]) # @param from [String, nil] Source key in the data hash (defaults to name.to_s) # @param default [Object, nil] Default value if attribute is missing + # @param null [Boolean] Whether nil values are allowed (default: true) # @yield [value] Block for custom transformation # @raise [ArgumentError] If both type and block are provided # @@ -35,9 +37,13 @@ def initialize # attribute :price do |value| # Money.new(value["amount"], value["currency"]) # end - def attribute(name, type = nil, from: nil, default: nil, &block) + # + # @example Non-nullable attribute + # attribute :id, String, null: false + def attribute(name, type = nil, from: nil, default: nil, null: true, &block) mappings[name] = from || name.to_s defaults[name] = default unless default.nil? + @non_nullable.add(name) unless null if type && block raise ArgumentError, "Cannot specify both type and block for :#{name}" @@ -52,6 +58,7 @@ def attribute(name, type = nil, from: nil, default: nil, &block) # @param type [Class, Symbol, Array, nil] Type for coercion (e.g., String, :boolean, [String]) # @param from [String, nil] Source key in the data hash (defaults to name.to_s) # @param default [Object, nil] Default value if attribute is missing + # @param null [Boolean] Whether nil values are allowed (default: true) # @yield [value] Block for custom transformation # @raise [ArgumentError] If both type and block are provided # @@ -60,8 +67,11 @@ def attribute(name, type = nil, from: nil, default: nil, &block) # # @example Optional with default # attribute? :status, String, default: "pending" - def attribute?(name, type = nil, from: nil, default: nil, &block) - attribute(name, type, from: from, default: default, &block) + # + # @example Optional but non-nullable when present + # attribute? :name, String, null: false + def attribute?(name, type = nil, from: nil, default: nil, null: true, &block) + attribute(name, type, from: from, default: default, null: null, &block) @optional.add(name) end @@ -87,6 +97,9 @@ def optional = @optional.to_a # @api private def required = attributes - optional + # @api private + def non_nullable = @non_nullable.to_a + # @api private def coercions(context = nil) @types.transform_values { |type| Types.coerce(type, context) } diff --git a/sig/structure/builder.rbs b/sig/structure/builder.rbs index 646a618..37da130 100644 --- a/sig/structure/builder.rbs +++ b/sig/structure/builder.rbs @@ -5,12 +5,13 @@ module Structure @defaults: Hash[Symbol, untyped] @after_parse_callback: Proc? @optional: Set[Symbol] + @non_nullable: Set[Symbol] - def attribute: (Symbol name, untyped type, ?from: String?, ?default: untyped) ?{ (untyped) -> untyped } -> void - | (Symbol name, ?from: String, ?default: untyped) ?{ (untyped) -> untyped } -> void + def attribute: (Symbol name, untyped type, ?from: String?, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void + | (Symbol name, ?from: String, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void - def attribute?: (Symbol name, untyped type, ?from: String?, ?default: untyped) ?{ (untyped) -> untyped } -> void - | (Symbol name, ?from: String, ?default: untyped) ?{ (untyped) -> untyped } -> void + def attribute?: (Symbol name, untyped type, ?from: String?, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void + | (Symbol name, ?from: String, ?default: untyped, ?null: bool) ?{ (untyped) -> untyped } -> void def after_parse: () { (Data) -> void } -> void @@ -22,6 +23,7 @@ module Structure def predicate_methods: () -> Hash[Symbol, Symbol] def optional: () -> Array[Symbol] def required: () -> Array[Symbol] + def non_nullable: () -> Array[Symbol] def after_parse_callback: () -> (Proc | nil) def method_missing: (Symbol, *untyped, **untyped) ?{ (*untyped, **untyped) -> untyped } -> untyped diff --git a/test/test_non_nullable.rb b/test/test_non_nullable.rb new file mode 100644 index 0000000..ed955c8 --- /dev/null +++ b/test/test_non_nullable.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require_relative "helper" +require "structure" + +class TestNonNullable < Minitest::Test + def test_required_non_nullable_with_nil_value + person = Structure.new do + attribute(:id, String, null: false) + end + + error = assert_raises(ArgumentError) do + person.parse(id: nil) + end + + assert_match(/cannot be null:/, error.message) + assert_match(/:id/, error.message) + end + + def test_required_non_nullable_with_missing_key + person = Structure.new do + attribute(:id, String, null: false) + end + + error = assert_raises(ArgumentError) do + person.parse({}) + end + + assert_match(/missing keyword/, error.message) + assert_match(/:id/, error.message) + end + + def test_required_non_nullable_with_valid_value + person = Structure.new do + attribute(:id, String, null: false) + end + + result = person.parse(id: "123") + + assert_equal("123", result.id) + end + + def test_optional_non_nullable_with_missing_key + person = Structure.new do + attribute?(:name, String, null: false) + end + + result = person.parse({}) + + assert_nil(result.name) + end + + def test_optional_non_nullable_with_nil_value + person = Structure.new do + attribute?(:name, String, null: false) + end + + error = assert_raises(ArgumentError) do + person.parse(name: nil) + end + + assert_match(/cannot be null:/, error.message) + assert_match(/:name/, error.message) + end + + def test_optional_non_nullable_with_valid_value + person = Structure.new do + attribute?(:name, String, null: false) + end + + result = person.parse(name: "Alice") + + assert_equal("Alice", result.name) + end + + def test_non_nullable_with_nil_default_not_stored + person = Structure.new do + attribute(:status, String, default: nil, null: false) + end + + error = assert_raises(ArgumentError) do + person.parse({}) + end + + assert_match(/missing keyword/, error.message) + assert_match(/:status/, error.message) + end + + def test_non_nullable_with_valid_default + person = Structure.new do + attribute(:status, String, default: "active", null: false) + end + + result = person.parse({}) + + assert_equal("active", result.status) + end + + def test_non_nullable_with_coercion_returning_nil + person = Structure.new do + attribute(:age, Integer, null: false) + end + + error = assert_raises(ArgumentError) do + person.parse(age: nil) + end + + assert_match(/cannot be null:/, error.message) + assert_match(/:age/, error.message) + end + + def test_non_nullable_with_block_returning_nil + person = Structure.new do + attribute(:value, null: false) { |_| nil } + end + + error = assert_raises(ArgumentError) do + person.parse(value: "anything") + end + + assert_match(/cannot be null:/, error.message) + assert_match(/:value/, error.message) + end + + def test_non_nullable_with_block_returning_valid_value + person = Structure.new do + attribute(:value, null: false, &:upcase) + end + + result = person.parse(value: "hello") + + assert_equal("HELLO", result.value) + end + + def test_nullable_by_default_allows_nil + person = Structure.new do + attribute(:name, String) + end + + result = person.parse(name: nil) + + assert_nil(result.name) + end + + def test_explicit_null_true_allows_nil + person = Structure.new do + attribute(:name, String, null: true) + end + + result = person.parse(name: nil) + + assert_nil(result.name) + end + + def test_multiple_non_nullable_attributes + person = Structure.new do + attribute(:id, String, null: false) + attribute(:email, String, null: false) + attribute?(:name, String, null: false) + end + + result = person.parse(id: "123", email: "test@example.com", name: "Alice") + + assert_equal("123", result.id) + assert_equal("test@example.com", result.email) + assert_equal("Alice", result.name) + end + + def test_mixed_nullable_and_non_nullable + person = Structure.new do + attribute(:id, String, null: false) + attribute(:description, String, null: true) + end + + result = person.parse(id: "123", description: nil) + + assert_equal("123", result.id) + assert_nil(result.description) + end + + def test_nested_structure_non_nullable + address = Structure.new do + attribute(:street, String, null: false) + end + + person = Structure.new do + attribute(:address, address, null: false) + end + + error = assert_raises(ArgumentError) do + person.parse(address: nil) + end + + assert_match(/cannot be null:/, error.message) + assert_match(/:address/, error.message) + end + + def test_array_type_non_nullable + person = Structure.new do + attribute(:tags, [String], null: false) + end + + error = assert_raises(ArgumentError) do + person.parse(tags: nil) + end + + assert_match(/cannot be null:/, error.message) + assert_match(/:tags/, error.message) + end + + def test_array_type_non_nullable_with_valid_value + person = Structure.new do + attribute(:tags, [String], null: false) + end + + result = person.parse(tags: ["ruby", "rails"]) + + assert_equal(["ruby", "rails"], result.tags) + end +end From d283f65619d87b457971bb10b7ddf2ef9c954d2b Mon Sep 17 00:00:00 2001 From: Hakan Ensari Date: Thu, 16 Oct 2025 21:11:41 +0200 Subject: [PATCH 2/2] test: add User fixture to smoke test null: false with RBS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a User fixture demonstrating the null: false feature: - id: required + non-null - name: optional + non-null when present - bio: optional + nullable This fixture serves as both a test and documentation of the feature, and ensures Steep type checking works correctly with the new option. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- test/fixtures/usage.rb | 11 +++++++++++ test/fixtures/user.rb | 9 +++++++++ test/fixtures/user.rbs | 17 +++++++++++++++++ test/test_rbs.rb | 8 ++++++++ 4 files changed, 45 insertions(+) create mode 100644 test/fixtures/user.rb create mode 100644 test/fixtures/user.rbs diff --git a/test/fixtures/usage.rb b/test/fixtures/usage.rb index b80ec57..4fb7aec 100644 --- a/test/fixtures/usage.rb +++ b/test/fixtures/usage.rb @@ -5,6 +5,7 @@ require_relative "product" require_relative "tag_collection" require_relative "tree_node" +require_relative "user" # Test that application code using Structure classes type-checks correctly. These should all pass Steep with no # warnings. @@ -48,3 +49,13 @@ tree_node.children tree_node.children&.first&.name tree_node.children&.first&.tags + +user = User.parse(id: "123", name: "Alice", bio: "Developer") +user.id +user.name +user.bio + +user_without_bio = User.parse(id: "456", name: "Bob") +user_without_bio.id +user_without_bio.name +user_without_bio.bio diff --git a/test/fixtures/user.rb b/test/fixtures/user.rb new file mode 100644 index 0000000..c16e249 --- /dev/null +++ b/test/fixtures/user.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "structure" + +User = Structure.new do + attribute(:id, String, null: false) + attribute?(:name, String, null: false) + attribute?(:bio, String) +end diff --git a/test/fixtures/user.rbs b/test/fixtures/user.rbs new file mode 100644 index 0000000..cf06917 --- /dev/null +++ b/test/fixtures/user.rbs @@ -0,0 +1,17 @@ +class User < Data + def self.new: (id: String?, ?name: String?, ?bio: String?) -> User + | (String?, String?, String?) -> User + def self.[]: (id: String?, ?name: String?, ?bio: String?) -> User + | (String?, String?, String?) -> User + + def self.members: () -> [ :id, :name, :bio ] + + def self.parse: (?Hash[String | Symbol, untyped], **untyped) -> User + + attr_reader bio: String? + attr_reader id: String? + attr_reader name: String? + + def members: () -> [ :id, :name, :bio ] + def to_h: () -> { id: String?, name: String?, bio: String? } +end diff --git a/test/test_rbs.rb b/test/test_rbs.rb index a57891a..d0743cd 100644 --- a/test/test_rbs.rb +++ b/test/test_rbs.rb @@ -10,6 +10,7 @@ require_relative "fixtures/product" require_relative "fixtures/tag_collection" require_relative "fixtures/tree_node" +require_relative "fixtures/user" class TestRBS < Minitest::Test def setup @@ -64,6 +65,13 @@ def test_emit_rbs_mixed_array_and_self_referential assert_equal(expected.strip, actual.strip) end + def test_emit_rbs_non_nullable + expected = File.read("test/fixtures/user.rbs") + actual = Structure::RBS.emit(User) + + assert_equal(expected.strip, actual.strip) + end + def test_emit_rbs_non_data_class result = Structure::RBS.emit(String)