StoreModel: JSON-Backed ActiveRecord Attributes
Wrap JSON-backed database columns with ActiveModel-like classes for type safety, validations, and clean separation of concerns.
When to Use This Skill
-
Configuration objects with validated fields
-
Nested attributes stored in JSON columns
-
JSON data requiring type safety and ActiveModel behavior
-
Separating JSON logic from parent ActiveRecord model
When NOT to Use StoreModel
Scenario Better Alternative
Simple key-value settings ActiveRecord::Store or store_accessor
Need database-level queries on JSON Raw jsonb with PostgreSQL operators
Data needs relationships/joins Normalize into separate tables
Truly simple JSON without validation Plain JSON column access
Setup
Gemfile
gem "store_model", "~> 3.0"
Basic Usage
Define a StoreModel Class
app/models/configuration.rb
class Configuration include StoreModel::Model
attribute :model, :string attribute :color, :string attribute :max_speed, :integer, default: 100
validates :model, presence: true validates :max_speed, numericality: { greater_than: 0 } end
Register in ActiveRecord
app/models/product.rb
class Product < ApplicationRecord attribute :configuration, Configuration.to_type end
Usage
product = Product.new product.configuration = { model: "rocket", color: "red" } product.configuration.model # => "rocket" product.configuration.color = "blue" product.save!
Also accepts StoreModel instances
product.configuration = Configuration.new(model: "shuttle")
Enums
class Configuration include StoreModel::Model
attribute :model, :string
enum :status, %i[draft active archived], default: :draft
With custom values
enum :priority, { low: 0, medium: 1, high: 2 }, default: :medium end
config = Configuration.new config.status # => "draft" config.active? # => false config.active! # Sets status to :active config.status # => "active"
Validations
class Address include StoreModel::Model
attribute :street, :string attribute :city, :string attribute :zip, :string attribute :country, :string, default: "US"
validates :street, :city, :zip, presence: true validates :zip, format: { with: /\A\d{5}(-\d{4})?\z/ }, if: -> { country == "US" } end
Merging Errors to Parent
class User < ApplicationRecord attribute :address, Address.to_type
validates :address, store_model: { merge_errors: true } end
user = User.new(address: { street: "", city: "" }) user.valid? user.errors.full_messages
=> ["Address street can't be blank", "Address city can't be blank", "Address zip can't be blank"]
Nested Models
class Coordinate include StoreModel::Model
attribute :latitude, :float attribute :longitude, :float
validates :latitude, :longitude, presence: true end
class Location include StoreModel::Model
attribute :name, :string attribute :coordinate, Coordinate.to_type
validates :name, presence: true validates :coordinate, store_model: { merge_errors: true } end
Array of Models
class LineItem include StoreModel::Model
attribute :name, :string attribute :quantity, :integer, default: 1 attribute :price_cents, :integer
validates :name, :price_cents, presence: true end
class Order < ApplicationRecord attribute :line_items, LineItem.to_array_type
validates :line_items, store_model: { merge_array_errors: true } end
order = Order.new order.line_items = [ { name: "Widget", quantity: 2, price_cents: 1000 }, { name: "Gadget", quantity: 1, price_cents: 2500 } ] order.line_items.first.name # => "Widget" order.line_items.sum(&:price_cents) # => 3500
Dirty Tracking
StoreModel doesn't automatically detect nested changes. Use one of these approaches:
Option 1: Reassign the entire object
product.configuration = product.configuration.dup.tap { |c| c.color = "green" }
Option 2: Mark as changed explicitly
product.configuration.color = "green" product.configuration_will_change! product.save!
Option 3: Use attribute assignment
product.configuration = { **product.configuration.attributes, color: "green" }
Controller Pattern
class ProductsController < ApplicationController def create @product = Product.new(product_params)
if @product.save
redirect_to @product
else
render :new, status: :unprocessable_entity
end
end
private
def product_params params.require(:product).permit( :name, configuration: [:model, :color, :max_speed, :status] ) end end
Testing
RSpec.describe Configuration do describe "validations" do it "requires model" do config = described_class.new(model: nil) expect(config).not_to be_valid expect(config.errors[:model]).to include("can't be blank") end end
describe "enums" do it "defaults to draft status" do config = described_class.new expect(config).to be_draft end
it "transitions status" do
config = described_class.new
config.active!
expect(config).to be_active
end
end end
RSpec.describe Product do describe "configuration" do it "accepts hash" do product = described_class.new(configuration: { model: "rocket" }) expect(product.configuration.model).to eq("rocket") end
it "merges validation errors" do
product = described_class.new(configuration: { model: nil })
expect(product).not_to be_valid
expect(product.errors[:configuration]).to be_present
end
end end
Detailed References
For advanced patterns:
- references/advanced-patterns.md
- Nested attributes, custom types, one-of types, parent tracking