Plutonium Resource Models
🚨 Critical (read first)
- Use
pu:res:scaffold. Never hand-write resource model files — the scaffold sets upPlutonium::Resource::Record, associations, and the expected section layout. - Declare associations for the entity. For multi-tenant apps, add
belongs_to,has_one :through, or anassociated_with_<entity>scope soassociated_withcan resolve. Fix the model, not the policy. - Compound uniqueness — in multi-tenant models, scope unique constraints to the tenant FK (
uniqueness: {scope: :organization_id}), or you leak across tenants. - Keep business logic out of the model. Use interactions for multi-step ops, policies for authorization.
- Related skills:
plutonium-entity-scoping(tenancy mechanics),plutonium-create-resource(scaffold),plutonium-definition(UI),plutonium-policy(authorization).
Quick checklist
Adding/editing a Plutonium model:
- Use
pu:res:scaffoldfor new models; includePlutonium::Resource::Recordon existing ones. - Place associations/enums/validations in the right section (enums → belongs_to → has_one → has_many → scopes → validations → callbacks).
- For monetary fields, use
has_cents :field_cents. - For multi-tenancy, declare an association path to the entity (
belongs_to,has_one :through, or a customassociated_with_<entity>scope). - Add compound uniqueness scoped to the tenant FK.
- For SEO URLs, override
path_parameterordynamic_path_parameter. - Override
to_labelif:name/:titleisn't meaningful. - Verify with
rails runner "puts Model.first.associated_with(entity).count".
Always use generators to create models - never create model files manually:
rails g pu:res:scaffold Post title:string content:text --dest=main_app
See plutonium-create-resource for full field type syntax and generator options.
A model becomes a Plutonium resource by including Plutonium::Resource::Record. This provides enhanced ActiveRecord functionality for routing, labeling, field introspection, associations, and monetary handling.
Setup
Standard Setup
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
include Plutonium::Resource::Record
primary_abstract_class
end
# app/models/resource_record.rb (optional abstract class)
class ResourceRecord < ApplicationRecord
self.abstract_class = true
end
# app/models/property.rb
class Property < ResourceRecord
# Now has access to all Plutonium features
end
What's Included
Plutonium::Resource::Record includes six modules:
| Module | Purpose |
|---|---|
HasCents |
Monetary value handling (cents → decimal) |
Routes |
URL parameters, path customization |
Labeling |
Human-readable to_label method |
FieldNames |
Field introspection and categorization |
Associations |
SGID support for secure serialization |
AssociatedWith |
Entity scoping for multi-tenant apps |
Model Structure
Follow the template structure (comment markers indicate where to add code):
class Property < ResourceRecord
# add concerns above.
TYPES = {apartment: "Apartment", house: "House"}.freeze
# add constants above.
enum :state, archived: 0, active: 1
enum :property_class, residential: 0, commercial: 1
# add enums above.
has_cents :market_value_cents
# add model configurations above.
belongs_to :company
# add belongs_to associations above.
has_one :address
# add has_one associations above.
has_many :units
has_many :amenities, class_name: "PropertyAmenity"
# add has_many associations above.
has_one_attached :photo
has_many_attached :documents
# add attachments above.
scope :active, -> { where(state: :active) }
scope :by_company, ->(company) { where(company: company) }
# add scopes above.
validates :name, presence: true
validates :property_code, presence: true, uniqueness: {scope: :company_id}
# add validations above.
before_validation :generate_code, on: :create
# add callbacks above.
delegate :name, to: :company, prefix: true
# add delegations above.
has_rich_text :description
# add misc attribute macros above.
def full_address
address&.to_s
end
# add methods above. add private methods below.
private
def generate_code
self.property_code ||= SecureRandom.hex(4).upcase
end
end
Section Order
- Concerns -
includestatements - Constants -
TYPES = {...}.freeze, etc. - Enums -
enum :state, ... - Model configurations -
has_cents - belongs_to associations
- has_one associations
- has_many associations
- Attachments -
has_one_attached,has_many_attached - Scopes
- Validations
- Callbacks
- Delegations
- Misc attribute macros -
has_rich_text,has_secure_token,has_secure_password - Methods - Public methods above, private methods below
Monetary Handling (has_cents)
Store monetary values as integers (cents) while exposing decimal interfaces.
Basic Usage
class Product < ResourceRecord
has_cents :price_cents # Creates price getter/setter
has_cents :cost_cents, name: :wholesale # Custom accessor name
has_cents :tax_cents, rate: 1000 # 3 decimal places
has_cents :quantity_cents, rate: 1 # Whole numbers only
end
product = Product.new
product.price = 19.99
product.price_cents # => 1999
product.price # => 19.99
# Truncates (doesn't round)
product.price = 10.999
product.price_cents # => 1099
Options
has_cents :field_cents,
name: :custom_name, # Accessor name (default: field without _cents)
rate: 100, # Conversion rate (default: 100)
suffix: "amount" # Suffix for generated name (default: "amount")
Using has_cents fields in policies and definitions
Always reference the virtual accessor (:price), never the underlying column (:price_cents).
# Model
class Product < ResourceRecord
has_cents :price_cents # exposes virtual :price
end
# ✅ Policy — use the virtual name
class ProductPolicy < ResourcePolicy
def permitted_attributes_for_create
%i[name price] # NOT :price_cents
end
end
# ✅ Definition — use the virtual name
class ProductDefinition < ResourceDefinition
field :price, as: :decimal
end
The virtual accessor handles form input, validation, and display as a decimal. Using :price_cents directly in a policy or definition forces users to enter integer cents and bypasses the conversion. Generators sometimes emit the _cents name in the policy — fix it by hand if you see it (and add has_cents if it's missing from the model).
Validation
class Product < ResourceRecord
has_cents :price_cents
# Validate the cents field
validates :price_cents, numericality: {greater_than: 0}
end
product = Product.new(price: -10)
product.valid? # => false
product.errors[:price_cents] # => ["must be greater than 0"]
product.errors[:price] # => ["is invalid"] (propagated)
Introspection
Product.has_cents_attributes
# => {price_cents: {name: :price, rate: 100}, ...}
Product.has_cents_attribute?(:price_cents) # => true
Association SGID Support
All associations get Signed Global ID (SGID) methods for secure serialization.
Singular Associations (belongs_to, has_one)
class Post < ResourceRecord
belongs_to :user
has_one :featured_image
end
post = Post.first
# Get SGID
post.user_sgid # => "BAh7CEkiCG..."
post.featured_image_sgid # => "BAh7CEkiCG..."
# Set by SGID (finds and assigns)
post.user_sgid = "BAh7CEkiCG..."
post.featured_image_sgid = "BAh7CEkiCG..."
Collection Associations (has_many, has_and_belongs_to_many)
class User < ResourceRecord
has_many :posts
has_and_belongs_to_many :roles
end
user = User.first
# Get SGIDs
user.post_sgids # => ["BAh7CEkiCG...", "BAh7CEkiCG..."]
user.role_sgids # => ["BAh7CEkiCG...", "BAh7CEkiCG..."]
# Bulk assignment
user.post_sgids = ["BAh7CEkiCG...", ...]
# Individual manipulation
user.add_post_sgid("BAh7CEkiCG...") # Add to collection
user.remove_post_sgid("BAh7CEkiCG...") # Remove from collection
Entity Scoping (associated_with)
Plutonium::Resource::Record provides Model.associated_with(entity) for multi-tenant queries. It resolves via a custom associated_with_<entity> scope, a direct belongs_to, or an auto-detected has_one :through chain.
Quick example:
class Comment < ResourceRecord
belongs_to :post
end
Comment.associated_with(post) # => Comment.where(post: post)
For entity scoping details — the three model shapes (direct child, join table, grandchild),
has_one :throughpatterns, custom scopes,default_relation_scope, and how it fits with policies and portals — see the plutonium-entity-scoping skill. It is the single source of truth.
URL Routing
Default Behavior
user = User.find(1)
user.to_param # => "1"
Custom Path Parameters
Use a stable, unique field instead of ID:
class User < ResourceRecord
private
def path_parameter(param_name)
:username # Must be unique
end
end
user = User.create(username: "john_doe")
user.to_param # => "john_doe"
# URLs: /users/john_doe
Dynamic Path Parameters (SEO-friendly)
Include ID prefix for uniqueness with human-readable suffix:
class Article < ResourceRecord
private
def dynamic_path_parameter(param_name)
:title
end
end
article = Article.create(id: 1, title: "My Great Article")
article.to_param # => "1-my-great-article"
# URLs: /articles/1-my-great-article
Path Parameter Lookup
User.from_path_param("john_doe")
Article.from_path_param("1-my-great-article") # Extracts ID
Labeling
The to_label method provides human-readable record labels:
# Automatic - checks :name, then :title, then fallback
user = User.new(name: "John Doe")
user.to_label # => "John Doe"
user = User.create(id: 1)
user.to_label # => "User #1"
# Custom override
class Product < ResourceRecord
def to_label
"#{name} (#{sku})"
end
end
Field Introspection
Access field information programmatically:
# All resource fields
User.resource_field_names
# => [:id, :name, :email, :company, :avatar, ...]
# By category
User.content_column_field_names # Database columns
User.belongs_to_association_field_names # belongs_to associations
User.has_one_association_field_names # has_one associations
User.has_many_association_field_names # has_many associations
User.has_one_attached_field_names # Active Storage single
User.has_many_attached_field_names # Active Storage multiple
Common Patterns
Archiving (State-Based)
class Property < ResourceRecord
enum :state, archived: 0, active: 1
scope :active, -> { where(state: :active) }
scope :archived, -> { where(state: :archived) }
end
Multi-Tenant Scoping
class Property < ResourceRecord
belongs_to :company
# Compound uniqueness for multi-tenant
validates :property_code, uniqueness: {scope: :company_id}
# Custom scope for entity scoping
scope :associated_with_company, ->(company) { where(company: company) }
end
Performance Tips
# Efficient: Direct belongs_to
Comment.associated_with(post) # Simple WHERE
# Less efficient: Reverse has_many (logs warning)
Post.associated_with(comment) # JOIN required
# Optimal: Custom scope when direct isn't possible
scope :associated_with_user, ->(user) { where(user_id: user.id) }
# SGID: Batch assignment over individual adds
user.post_sgids = sgid_array # Single operation
Best Practices
- Use enums for state -
enum :state, archived: 0, active: 1instead of soft-delete - Compound uniqueness - Always scope uniqueness to tenant/parent
- Organize with comments - Use section headers for readability
- Keep models focused - Business logic in interactions, not models
- Validate at boundaries - Validate user input, trust internal code
- Use scopes - Define commonly used queries as scopes
Related Skills
plutonium-create-resource- Scaffold generator for new resourcesplutonium-definition- Definition overview, fields, inputs, displays
Converted and distributed by TomeVault — claim your Tome and manage your conversions.