KO
|
EN
gitlite — search
Search
#python
#hacktoberfest
#typescript
#javascript
#nextjs
#ethereum
#machine-learning
#nodejs
#reactjs
#c
#hacktoberfest-accepted
#hacktoberfest2022
structure
★ 31
Open GitHub ↗
Turn hashes into typed data objects
Download README (.md)
Explore Similar Repositories
authsae
:
Simultaneous Authentication of Equals: Unofficial official clone of the now stale http://sourceforge.net/projects/authsae
silverstripe-googleanalytics
:
Google Analytics module for SilverStripe CMS
ifuga
:
iOS component for displaying a fullscreen image gallery
JSONplus
:
JSON with circular reference support
clj-airbrake
:
Clojure client for the Airbrake API
// repository documentation
Was this content helpful?
★ 0
(0 ratings)
Select Rating:
★
★
★
★
★
Submit Feedback
Recent Feedback
×
Download README
Do you want to download the
README.md
file for
structure
?
Download (.md)
# Structure [](https://github.com/hakanensari/structure/actions/workflows/ci.yml)  **Structure your data** Turn unruly hashes into clean [Ruby Data](https://docs.ruby-lang.org/en/3.4/Data.html) objects with type coercion. ```ruby # Before: Hash drilling user_name = response["user"]["name"] user_age = response["user"]["age"].to_i user_active = response["user"]["is_active"] == "true" # After: Clean, typed objects user.name # => "Alice" (String) user.age # => 25 (Integer) user.active? # => true ``` Built on [Ruby Data](https://docs.ruby-lang.org/en/3.4/Data.html) for immutability, pattern matching, and all the other good stuff. Zero dependencies. ## Installation Add to your Gemfile: ```ruby gem "structure" ``` ## Usage ### The Basics ```ruby User = Structure.new do attribute(:name, String) attribute(:age, Integer) attribute(:active, :boolean) end user = User.parse({ "name" => "Alice", "age" => "25", "active" => "true" }) user.name # => "Alice" (String) user.age # => 25 (Integer) user.active # => true (TrueClass) user.active? # => true (predicate method) ``` ### Type Coercion Uses Ruby's built-in coercion methods to convert data: ```ruby Product = Structure.new do attribute(:title, String) # Uses String(val) attribute(:price, Float) # Uses Float(val) attribute(:quantity, Integer) # Uses Integer(val) attribute(:available, :boolean) # Custom boolean logic end product = Product.parse({ "title" => 123, "price" => "19.99", "quantity" => "5", "available" => "1" }) product.title # => "123" product.price # => 19.99 product.quantity # => 5 product.available # => true ``` ### Key Mapping Clean up gnarly keys: ```ruby Person = Structure.new do attribute(:name, String, from: "full_name") attribute(:active, :boolean, from: "is_active") end person = Person.parse({ "full_name" => "Bob Smith", "is_active" => "true" }) person.name # => "Bob Smith" person.active? # => true ``` ### Optional and Non-Null Attributes Structure wraps Data classes. All attributes are required when creating instances, even if their value is `nil`. Use `attribute?` to make a key optional and `null: false` to reject nil values. | DSL | Key | Value | RBS | |---|---|---|---| | `attribute(:foo, String, null: false)` | always present | never nil | `String` | | `attribute(:foo, String)` | always present | can be nil | `String?` | | `attribute?(:foo, String, null: false)` | may be absent | never nil when present | `String?` | | `attribute?(:foo, String)` | may be absent | can be nil | `String?` | The `attribute?` + `null: false` combination maps naturally to GraphQL's non-null type modifier (`String!`): the field must be non-null when present but may be absent from the response entirely. Since absent optional attributes default to `nil`, `null: false` is a parse-time guard rather than a type-level guarantee. Hence, `String?` in the RBS column. ```ruby User = Structure.new do attribute(:name, String) attribute?(:age, Integer) attribute?(:handle, String, null: false) end User.parse(name: "Alice") # works, age defaults to nil User.parse(name: "Alice", handle: "a") # works User.parse(age: 10) # ArgumentError: missing keyword: :name User.parse(name: "Alice", handle: nil) # ArgumentError: cannot be null: :handle ``` ### Default Values Handle missing data: ```ruby Config = Structure.new do attribute(:timeout, Integer, default: 30) attribute(:debug, :boolean, default: false) end config = Config.parse({}) # Empty data config.timeout # => 30 config.debug # => false ``` ### Array Types Arrays with automatic element coercion: ```ruby Order = Structure.new do attribute(:items, [String]) attribute(:quantities, [Integer]) attribute(:flags, [:boolean]) end order = Order.parse({ "items" => [123, 456, "hello"], "quantities" => ["1", "2", 3.5], "flags" => ["true", 0, 1, "false"] }) order.items # => ["123", "456", "hello"] order.quantities # => [1, 2, 3] order.flags # => [true, false, true, false] ``` ### Nested Objects Compose structures for complex data: ```ruby Address = Structure.new do attribute(:street, String) attribute(:city, String) end User = Structure.new do attribute(:name, String) attribute(:address, Address) end user = User.parse({ "name" => "Alice", "address" => { "street" => "123 Main St", "city" => "Boston" } }) user.name # => "Alice" user.address.street # => "123 Main St" user.address.city # => "Boston" ``` ### Arrays of Objects Combine array syntax with nested objects: ```ruby Tag = Structure.new do attribute(:name, String) attribute(:color, String) end Product = Structure.new do attribute(:title, String) attribute(:tags, [Tag]) end product = Product.parse({ "title" => "Laptop", "tags" => [ { "name" => "electronics", "color" => "blue" }, { "name" => "computers", "color" => "green" } ] }) product.title # => "Laptop" product.tags.first.name # => "electronics" ``` ### Lazy Resolution To handle circular dependencies between classes, you can use string class names that are resolved lazily: ```ruby module MyApp Order = Structure.new do attribute(:id, String) attribute(:items, ["OrderItem"]) # String resolved lazily attribute(:customer, "Customer") # String resolved lazily end OrderItem = Structure.new do attribute(:name, String) attribute(:order, "Order") # Circular reference back to Order end Customer = Structure.new do attribute(:name, String) attribute(:orders, ["Order"]) # Circular reference to Order end end # Works despite circular dependencies order = MyApp::Order.parse({ "id" => "123", "customer" => { "name" => "Alice" }, "items" => [{ "name" => "Widget" }] }) order.customer.name # => "Alice" order.items.first.name # => "Widget" ``` ### Custom Transformations When you need custom logic: ```ruby Order = Structure.new do attribute :price do |value| Money.new(value["amount"], value["currency"]) end end order = Order.parse({ "price" => { "amount" => "29.99", "currency" => "USD" } }) order.price # => #<Money:0x... @amount="29.99", @currency="USD"> ``` ### Boolean Conversion Structure follows Rails-style boolean conversion: **Truthy values:** `true`, `1`, `"1"`, `"t"`, `"T"`, `"true"`, `"TRUE"`, `"on"`, `"ON"` **Falsy values:** Everything else (including `false`, `0`, `"0"`, `"false"`, `""`, `nil`) ```ruby User = Structure.new do attribute(:active, :boolean) end User.parse(active: "true").active # => true User.parse(active: "1").active # => true User.parse(active: "false").active # => false User.parse(active: "0").active # => false User.parse(active: "").active # => false ``` ### Supported Types Structure supports Ruby's kernel coercion methods like `String(val)`, `Integer(val)`, `Float(val)`, etc., plus: - `:boolean` - Custom Rails-style boolean conversion - `[Type]` - Arrays with element coercion - Custom classes with `.parse` method - Ruby standard library classes with `.parse`, including: - `Date` - Parses date strings - `Time` - Parses various time formats - `URI` - Parses URLs into URI objects ```ruby Event = Structure.new do attribute(:name, String) attribute(:date, Date) attribute(:starts_at, Time) attribute(:website, URI) end event = Event.parse({ "name" => "RubyConf", "date" => "2024-12-25", "starts_at" => "2024-12-25T09:00:00-05:00", "website" => "https://rubyconf.org" }) event.date # => #<Date: 2024-12-25> event.starts_at # => 2024-12-25 09:00:00 -0500 event.website # => #<URI::HTTPS https://rubyconf.org> ``` ### Custom Types The type system is flexible. Any object that responds to `.call` (procs, lambdas) or `.parse` (classes) can be used as a type: ```ruby # Using a lambda for simple transformations UppercaseString = ->(val) { val.to_s.upcase } # Using a class with .parse for complex types class Money def self.parse(data) return nil unless data amount = data.is_a?(Hash) ? data['amount'] : data new(amount.to_f) end def initialize(amount) @amount = amount end attr_reader :amount end Product = Structure.new do attribute :name, UppercaseString attribute :price, Money end product = Product.parse({ "name" => "widget", "price" => { "amount" => "19.99" } }) product.name # => "WIDGET" product.price.amount # => 19.99 ``` ### Self-Referential Types Build tree structures and other self-referential data: ```ruby Tree = Structure.new do attribute(:id, Integer) attribute(:name, String) attribute(:children, [:self], default: []) end tree = Tree.parse({ "id" => 1, "name" => "Electronics", "children" => [ { "id" => 2, "name" => "Computers" }, { "id" => 3, "name" => "Phones", "children" => [ { "id" => 4, "name" => "Smartphones" } ]} ] }) tree.name # => "Electronics" tree.children.first.name # => "Computers" tree.children[1].children.first.name # => "Smartphones" ``` Use `:self` for single references or `[:self]` for arrays of self-references. Perfect for modeling hierarchical data like navigation menus, comment threads, or organizational charts. ### After Parse Callbacks Add validation or post-processing logic that runs after parsing: ```ruby Order = Structure.new do attribute(:order_id, String) attribute(:total, Float) after_parse do |order| raise "Order ID is required" if order.order_id.nil? raise "Total must be positive" if order.total && order.total <= 0 end end # Raises error for invalid data Order.parse(total: -10) # => RuntimeError: Total must be positive # Works fine with valid data order = Order.parse(order_id: "123", total: 99.99) order.order_id # => "123" ``` The `after_parse` callback receives the parsed instance and runs after all attributes have been coerced. Any exception raised prevents the instance from being returned. ### Serialization Structure classes respond to `load` and `dump` for use with `Marshal`, `YAML`, or Rails `serialize`: ```ruby Settings = Structure.new do attribute(:theme, String, default: "light") attribute(:notifications, :boolean, default: true) end settings = Settings.parse(theme: "dark") # Dump to hash, load back to instance hash = Settings.dump(settings) # => {theme: "dark", notifications: true} Settings.load(hash) # => #<data Settings theme="dark", notifications=true> # Use as Rails serialize coder class User < ApplicationRecord serialize :settings, coder: Settings end ``` ### Custom Methods Define instance and class methods directly in the Structure block, just like `Data.define`: ```ruby User = Structure.new do attribute(:name, String) attribute(:age, Integer) attribute(:active, :boolean) # Instance methods def adult? age >= 18 end def greeting "Hello, I'm #{name}" end def status active ? "online" : "offline" end # Class methods def self.create_guest parse(name: "Guest", age: 0, active: false) end end user = User.parse(name: "Alice", age: 25, active: true) user.adult? # => true user.greeting # => "Hello, I'm Alice" user.status # => "online" guest = User.create_guest guest.name # => "Guest" guest.adult? # => false ``` Custom methods work seamlessly with all Structure features including type coercion, key mapping, defaults, optional attributes, nested structures, and arrays: ```ruby Product = Structure.new do attribute(:name, String) attribute(:price, Float) attribute(:tags, [String]) attribute?(:discount, Float) def discounted_price return price unless discount price * (1 - discount) end def has_tag?(tag) tags.include?(tag) end def self.categories ["electronics", "books", "clothing"] end end product = Product.parse( name: "Laptop", price: "999.99", tags: ["electronics", "computers"], discount: "0.1" ) product.discounted_price # => 899.991 product.has_tag?("electronics") # => true Product.categories # => ["electronics", "books", "clothing"] ``` ### RBS Type Signatures Generate RBS type signatures for your Structure classes: ```ruby require 'structure/rbs' User = Structure.new do attribute(:name, String) attribute(:age, Integer) attribute(:tags, [String]) end # Generate RBS content Structure::RBS.emit(User) # => class User < Data # def self.new: (name: String?, age: Integer?, tags: Array[String]?) -> instance # def self.parse: (?(Hash[String | Symbol, untyped]), **untyped) -> instance # attr_reader name: String? # attr_reader age: Integer? # attr_reader tags: Array[String]? # ... # end # Write RBS to file Structure::RBS.write(User, dir: "sig") # => "sig/user.rbs" ``` #### Custom Methods and Steep `Structure::RBS.emit` generates type signatures for custom methods with parameters and return types defaulting to `untyped`: ```ruby User = Structure.new do attribute(:age, Integer) # steep:ignore:start def adult? age >= 18 end # steep:ignore:end end Structure::RBS.emit(User) # => ... # def adult?: () -> untyped # end ``` The generated signatures work for code that uses your Structure classes, but Steep may report warnings in definition files when custom methods are present. This happens because the `Structure.new` block is evaluated in two different contexts at runtime (once for DSL methods like `attribute`, once for custom methods), but Steep can only analyze one static context. Wrap custom methods with `# steep:ignore:start` and `# steep:ignore:end` comments, or exclude definition files from Steep checking in your `Steepfile`. See also: [RBS Data/Struct documentation](https://github.com/ruby/rbs/blob/master/docs/data_and_struct.md), [RBS issue #654](https://github.com/ruby/rbs/issues/654), [RBS issue #1077](https://github.com/ruby/rbs/issues/1077) ### Sorbet Support (Tapioca) For Sorbet users, Structure includes a Tapioca DSL compiler that automatically generates RBI files: ```bash # In your project using Structure bundle exec tapioca dsl ``` This will generate RBI files for all Structure classes in your project, giving you full type checking with Sorbet. ### Batch RBS Generation Generate RBS files for multiple classes at once: ```ruby require 'structure/rbs' # From an array of classes Structure::RBS.write_all([User, Order, Product], dir: "sig") # From a module namespace Structure::RBS.write_all(MyApp::Models, dir: "sig") ``` ## Development ```bash $ bundle install $ bundle exec rbs collection install $ bundle exec rake ``` ### Performance Considerations String-based method generation with `class_eval` is more performant but also overcomplicates the code. For now, I prioritize legibility.