FieldStruct
provides a lightweight approach to having typed structs in three flavors: Flexible
, Strict
and Mutable
.
It is heavily based on ActiveModel and adds syntactic sugar to make the developer experience much more enjoyable.
All structs can be defined with multiple attributes. Each attribute can:
- Have a known type:
:string
,:integer
,:float
,:date
,:time
,:boolean
, etc. - Have a new type like
:array
,:currency
or a nestedFieldStruct
itself. - Be
:required
or:optional
(default) - Have a
:default
value or proc. - Have a
:format
that it must follow. - Be one of many options (e.g.
:enum
) - Have Have any number of ActiveRecord-like validations.
You can use the required
and optional
aliases to attribute
to skip using the :required
and :optional
argument.
This class enforces validation on instantiation and provides values that cannot be mutated after creation.
class Friend < FieldStruct.strict
required :name, :string
optional :age, :integer
optional :balance_owed, :currency, default: 0.0
optional :gamer_level, :integer, enum: [1,2,3], default: -> { 1 }
optional :zip_code, :string, format: /\A[0-9]{5}?\z/
end
# Minimal
john = Friend.new name: "John"
# => #<Friend name="John" balance_owed=0.0 gamer_level=1>
# Can't modify once created
john.name = "Steven"
# FrozenError: can't modify frozen Hash
# Coercing string amount
eric = Friend.new name: "John", balance_owed: '$4.50'
# => #<Friend name="John" balance_owed=4.5 gamer_level=1>
# Missing required fields - throws an exception
rosie = Friend.new age: 26
# => FieldStruct::BuildError: :name can't be blank
# Invalid gamer level - throws an exception
carl = Friend.new name: "Carl", gamer_level: 11
# => FieldStruct::BuildError: :gamer_level is not included in the list
# Invalid zip code - throws an exception
melanie = Friend.new name: "Melanie", zip_code: '123'
# => FieldStruct::BuildError: :zip_code is invalid
This class does NOT enforce validation on instantiation and provides values that cannot be mutated after creation.
class Friend < FieldStruct.flexible
required :name, :string
optional :age, :integer
optional :balance_owed, :currency, default: 0.0
optional :gamer_level, :integer, enum: [1,2,3], default: -> { 1 }
optional :zip_code, :string, format: /\A[0-9]{5}?\z/
end
# Minimal
john = Friend.new name: "John"
# => #<Friend name="John" balance_owed=0.0 gamer_level=1>
john.valid?
# => true
# Can't modify once created
john.name = "Steven"
# FrozenError: can't modify frozen Hash
# Missing required fields - not valid
rosie = Friend.new age: 26
# => #<Friend name=nil age=26 balance_owed=0.0 gamer_level=1 zip_code=nil>
rosie.valid?
# => false
rosie.errors.to_hash
# => {:name=>["can't be blank"]}
#
# Invalid gamer level - not valid
carl = Friend.new name: "Carl", gamer_level: 11
# => #<Friend name="Carl" balance_owed=0.0 gamer_level=11>
carl.valid?
# => false
carl.errors.to_hash
# => {:gamer_level=>["is not included in the list"]}
# Invalid zip code - not valid
melanie = Friend.new name: "Melanie", zip_code: '123'
# => #<Friend name="Melanie" balance_owed=0.0 gamer_level=1 zip_code="123">
melanie.valid?
# => false
melanie.errors.to_hash
# => {:zip_code=>["is invalid"]}
This class has all the same attribute options as FieldStruct::Value
but it allows to instantiate invalid objects and modify the attributes after creation.
class User < FieldStruct.mutable
required :username, :string
required :password, :string
required :team, :string, enum: %w{ A B C }
optional :last_login_at, :time
end
# A first attempt
john = User.new username: '[email protected]'
# => #<User username="[email protected]">
# Is it valid? What errors do we have?
[john.valid?, john.errors.to_hash]
# => => [false, {:password=>["can't be blank"], :team=>["can't be blank", "is not included in the list"]}]
# Let's fix the first error: missing password
john.password = 'some1234'
# => "some1234"
# Is it valid now? What errors do we still have?
[john.valid?, john.errors.to_hash]
# => [false, {:team=>["can't be blank", "is not included in the list"]}]
# Let's fix the team
john.team = 'X'
# => "X"
# Are we valid now? Do we still have errors?
[john.valid?, john.errors.to_hash]
# => [false, {:team=>["is not included in the list"]}]
# Let's fix the team for real now
john.team = 'B'
# => "B"
# Are we finally valid now? Do we still have errors?
[john.valid?, john.errors.to_hash]
# => [true, {}]
# The final, valid product
john
# => #<User username="[email protected]" password="some1234" team="B">
You can user FieldStruct as parent classes:
class Person < FieldStruct.mutable
required :first_name, :string
required :last_name, :string
end
class Employee < Person
required :title, :string
end
class Developer < Employee
required :language, :string, enum: %w[Ruby Javascript Elixir]
end
person = Person.new first_name: 'John', last_name: 'Doe'
# => #<Person first_name="John" last_name="Doe">
employee = Employee.new first_name: 'John', last_name: 'Doe', title: 'Secretary'
# => #<Employee first_name="John" last_name="Doe" title="Secretary">
developer = Developer.new first_name: 'John', last_name: 'Doe', title: 'Developer', language: 'Ruby'
# #<Developer first_name="John" last_name="Doe" title="Developer" language="Ruby">
You can use your FieldStruct as a nested type definition:
class Employee < FieldStruct.mutable
required :first_name, :string
required :last_name, :string
required :title, :string
end
class Team < FieldStruct.mutable
required :name, :string
optional :manager, Employee
end
manager = Employee.new first_name: 'Some', last_name: 'Leader', title: 'Manager'
# => #<Employee first_name="Some" last_name="Leader" title="Manager">
team = Team.new name: 'Great Team', manager: manager
# => #<Team name="Great Team" manager=#<Employee first_name="Some" last_name="Leader" title="Manager">>
# Or use just hashes to build the whole thing:
team = Team.new name: 'Great Team', manager: { first_name: 'Some', last_name: 'Leader', title: 'Manager' }
# => #<Team name="Great Team" manager=#<Employee first_name="Some" last_name="Leader" title="Manager">>
You can have attributes that are collections of a single type:
class Employee < FieldStruct.mutable
required :first_name, :string
required :last_name, :string
optional :title, :string
end
class Team < FieldStruct.mutable
required :name, :string
optional :manager, Employee
required :members, :array, of: Employee
end
team = Team.new name: 'Great Team',
manager: { first_name: 'Some', last_name: 'Leader' },
members: [
{ first_name: 'Some', last_name: 'Employee' },
{ first_name: 'Another', last_name: 'Employee' }
]
# => #<Team name="Great Team" manager=#<Employee first_name="Some" last_name="Leader"> members=[#<Employee first_name="Some" last_name="Employee">, #<Employee first_name="Another" last_name="Employee">]>
You can create structs from JSON and convert them back to JSON.
class Employee < FieldStruct.mutable
required :first_name, :string
required :last_name, :string
optional :title, :string
end
class Team < FieldStruct.mutable
required :name, :string
optional :manager, Employee
required :members, :array, of: Employee
end
class Company < FieldStruct.mutable
required :legal_name, :string
optional :development_team, Team
optional :marketing_team, Team
end
json = %|
{
"legal_name": "My Company",
"development_team": {
"name": "Dev Team",
"manager": {
"first_name": "Some",
"last_name": "Dev",
"title": "Dev Leader"
},
"members": [
{
"first_name": "Other",
"last_name": "Dev",
"title": "Dev"
}
]
},
"marketing_team": {
"name": "Marketing Team",
"manager": {
"first_name": "Some",
"last_name": "Mark",
"title": "Mark Leader"
},
"members": [
{
"first_name": "Another",
"last_name": "Dev",
"title": "Dev"
}
]
}
}
|
company = Company.from_json json
# => #<Company legal_name="My Company" development_team=#<Team name="Dev Team" manager=#<Employee first_name="Some" last_name="Dev" title="Dev Leader"> members=[#<Employee first_name="Other" last_name="Dev" title="Dev">]> marketing_team=#<Team name="Marketing Team" manager=#<Employee first_name="Some" last_name="Mark" title="Mark Leader"> members=[#<Employee first_name="Another" last_name="Dev" title="Dev">]>>
puts company.to_json
# {"legal_name":"My Company","development_team":{"name":"Dev Team","manager":{"first_name":"Some","last_name":"Dev","title":"Dev Leader"},"members":[{"first_name":"Other","last_name":"Dev","title":"Dev"}]},"marketing_team":{"name":"Marketing Team","manager":{"first_name":"Some","last_name":"Mark","title":"Mark Leader"},"members":[{"first_name":"Another","last_name":"Dev","title":"Dev"}]}}
You can add AR-style validations to your struct. We provide syntactic sugar to make it easy to use common validations in the attribute definition.
# We have syntactic sugar to turn this definition:
class Employee < FieldStruct.mutable
attribute :full_name, :string
attribute :email, :string
attribute :team, :string
validates_presence_of :full_name
validates_format_of :email, allow_nil: true, with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates_inclusion_of :team, allow_nil: true, in: %w{ A B C }
end
# Into this definition:
class Employee < FieldStruct.mutable
required :full_name, :string
optional :email, :string, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
optional :team, :string, enum: %w{ A B C }
end
# But you can also add more validations too:
class Employee < FieldStruct.mutable
validates_length_of :email, allow_nil: true, within: 12...120
validates_each :full_name do |model, attr, value|
model.errors.add(attr, 'must start with upper case') if value =~ /\A[a-z]/
end
validate :check_company_email
def check_company_email
return if email.nil?
errors.add(:email, "has to be a company email") unless email.end_with?("@company.com")
end
end
bad_employee = Employee.new full_name: 'some name', email: 'bad@xyz', team: 'D'
# => #<Employee full_name="some name" email="bad@xyz" team="D">
bad_employee.errors.to_hash
# => {
# :email=>["is invalid", "is too short (minimum is 12 characters)", "has to be a company email"],
# :team=>["is not included in the list"],
# :full_name=>["must start with upper case"]
# }
Add this line to your application's Gemfile:
gem 'field_struct'
And then execute:
$ bundle
Or install it yourself as:
$ gem install field_struct
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/acima-credit/field_struct.
The gem is available as open source under the terms of the MIT License.