| name | store-model-coder |
| description | Wrap JSON-backed database columns with ActiveModel-like classes using store_model. Use when creating configuration objects, managing nested JSON attributes, or adding validations to JSON data. Triggers on JSON columns, store_model, typed JSON, configuration objects, or nested attributes. |
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