Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 16 additions & 6 deletions lib/structure.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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|
Expand All @@ -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]
Expand All @@ -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

Expand Down
19 changes: 16 additions & 3 deletions lib/structure/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
#
Expand All @@ -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}"
Expand All @@ -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
#
Expand All @@ -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

Expand All @@ -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) }
Expand Down
10 changes: 6 additions & 4 deletions sig/structure/builder.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions test/fixtures/usage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions test/fixtures/user.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions test/fixtures/user.rbs
Original file line number Diff line number Diff line change
@@ -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
Loading