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.
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
references/advanced-patterns.mdWrap JSON-backed database columns with ActiveModel-like classes for type safety, validations, and clean separation of concerns.
| 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 |
# Gemfile
gem "store_model", "~> 3.0"
# 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
# app/models/product.rb
class Product < ApplicationRecord
attribute :configuration, Configuration.to_type
end
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")
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"
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
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"]
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
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
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" }
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
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
For advanced patterns:
references/advanced-patterns.md - Nested attributes, custom types, one-of types, parent tracking