Model
The model layer of a resource. Includes the Plutonium::Resource::Record module (via inheritance from ResourceRecord) on top of standard ApplicationRecord.
Base class
All resource models inherit from ResourceRecord (created by pu:core:install):
# Main app
class Post < ResourceRecord
end
# Inside a feature package — uses the package's ResourceRecord
module Blogging
class Post < Blogging::ResourceRecord
end
endResourceRecord is abstract and inherits from ApplicationRecord. Standard ActiveRecord features (associations, validations, scopes, callbacks, attribute macros) all work — Plutonium adds capabilities on top.
What Plutonium::Resource::Record adds
| Module | Purpose | Section |
|---|---|---|
HasCents | Money handling — cents column ↔ decimal accessor | has_cents |
Routes | URL parameter customization (slugs, dynamic params) | URL routing |
Labeling | to_label for human-readable record names | Labeling |
FieldNames | Field introspection by category | Field introspection |
Associations | Auto-generated SGID accessors on every association | SGID accessors |
AssociatedWith | Multi-tenant scoping — Model.associated_with(entity) | Tenancy |
Section layout
Scaffolded models follow a strict ordering. Keep new code in the right section so files stay scannable:
- Concerns (
include) - Constants (
TYPES = {...}.freeze) - Enums
- Model configurations (
has_cents) belongs_tohas_onehas_many- Attachments (
has_one_attached,has_many_attached) - Scopes
- Validations
- Callbacks
- Delegations
- Misc macros (
has_rich_text,has_secure_token,has_secure_password) - Public methods, then
private, then private methods
Example:
class Property < ResourceRecord
TYPES = {apartment: "Apartment", house: "House"}.freeze
enum :state, archived: 0, active: 1
has_cents :market_value_cents
belongs_to :company
has_one :address
has_many :units
has_one_attached :photo
scope :active, -> { where(state: :active) }
validates :name, presence: true
validates :property_code, presence: true, uniqueness: {scope: :company_id}
before_validation :generate_code, on: :create
has_rich_text :description
def full_address
address&.to_s
end
private
def generate_code
self.property_code ||= SecureRandom.hex(4).upcase
end
endhas_cents
Stores monetary values as integer cents and exposes a decimal virtual accessor. Use this for money — never store decimals directly.
class Product < ResourceRecord
has_cents :price_cents # column: price_cents (integer); accessor: price (decimal)
has_cents :cost_cents, name: :wholesale # custom accessor name
has_cents :tax_cents, rate: 1000 # 3 decimal places (e.g. for fractional currencies)
has_cents :amount_yen, rate: 1 # currencies with no subunit (JPY)
end
product = Product.new
product.price = 19.99
product.price_cents # => 1999
product.price # => 19.99
# Truncates, never rounds
product.price = 10.999
product.price_cents # => 1099Use the virtual accessor in policies and definitions
Reference :price, NOT :price_cents:
# Policy
def permitted_attributes_for_create
%i[name price] # ✅ virtual name
end
# Definition
field :price, as: :decimal # ✅ virtual nameGenerators sometimes emit the _cents name in the policy — fix by hand (and verify has_cents is declared on the model).
Options
has_cents :field_cents,
name: :custom_name, # accessor name (default: field with _cents stripped)
rate: 100, # conversion rate (default: 100 for 2 decimal places)
suffix: "amount" # suffix for generated name when name pattern matchesValidation propagation
Validations on the cents column automatically mark the virtual accessor invalid too:
class Product < ResourceRecord
has_cents :price_cents
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"]The framework adds an after_validation hook that copies :invalid from price_cents → price automatically — no manual wiring needed.
Introspection
Product.has_cents_attributes
# => { price_cents: { name: :price, rate: 100 } }
Product.has_cents_attribute?(:price_cents) # => trueURL routing
Default
post.to_param # => "1" (numeric id)
# URL: /posts/1path_parameter — use a stable column
Use a column that's unique and human-readable instead of the numeric id:
class User < ResourceRecord
path_parameter :username
end
user = User.create(username: "john_doe")
user.to_param # => "john_doe"
# URL: /users/john_doe
User.from_path_param("john_doe") # finds by usernamepath_parameter is a class-level macro (private class method). The column you pass MUST be unique — Plutonium uses it for lookup.
dynamic_path_parameter — SEO-friendly id + slug
Combines the id (for stable lookup) with a slug from another column (for SEO):
class Article < ResourceRecord
dynamic_path_parameter :title
end
article = Article.create(id: 42, title: "Hello World")
article.to_param # => "42-hello-world"
# URL: /articles/42-hello-world
Article.from_path_param("42-hello-world") # extracts "42", finds by idThe slug is informational — only the id portion is used for lookup, so changing the title doesn't break old URLs.
Labeling
to_label provides a human-readable name for dropdowns, breadcrumbs, and display fallbacks.
Default resolution
- Returns
nameif the model has anameattribute. - Returns
titleif the model has atitleattribute. - Falls back to
"ModelName #id"(e.g."Post #42").
post = Post.new(title: "Hello World")
post.to_label # => "Hello World"
post.title = nil
post.to_label # => "Post #42"Override
class Product < ResourceRecord
def to_label
"#{name} (#{sku})"
end
endSGID accessors
Every association on a resource model gets Signed Global ID accessors automatically — for secure form submission, API payloads, and hidden fields without exposing database ids.
Singular associations (belongs_to, has_one)
class Post < ResourceRecord
belongs_to :user
has_one :featured_image
end
post.user_sgid # get SGID
post.user_sgid = "BAh7..." # set: locates and assigns user from SGID
post.featured_image_sgid
post.featured_image_sgid = "..."Collection associations (has_many, has_and_belongs_to_many)
class User < ResourceRecord
has_many :posts
end
user.post_sgids # => ["...", "..."]
user.post_sgids = [sgid1, sgid2] # bulk replace
user.add_post_sgid(sgid) # append
user.remove_post_sgid(sgid) # removeThese are what secure_association_tag uses in forms — see UI › Forms.
Field introspection
User.resource_field_names # all fields suitable for UI
User.content_column_field_names # database columns
User.belongs_to_association_field_names
User.has_one_association_field_names # excludes attachments
User.has_many_association_field_names # excludes attachments
User.has_one_attached_field_names # ActiveStorage single
User.has_many_attached_field_names # ActiveStorage multipleUsed internally by definitions for auto-detection. You rarely call these directly, but they're useful when writing dynamic UI in customize_fields / custom Phlex pages.
Results are cached outside development (so changing the schema in dev hot-reloads correctly).
Nested attributes introspection
Post.all_nested_attributes_options
# => {
# comments: { allow_destroy: true, limit: 10, macro: :has_many, class: Comment },
# metadata: { update_only: true, macro: :has_one, class: PostMetadata }
# }Returns the configuration for all associations declared with accepts_nested_attributes_for. Used internally by nested_input in the definition.
Multi-tenancy: associated_with
Plutonium::Resource::Record provides Model.associated_with(entity) for multi-tenant queries:
Comment.associated_with(post)
# => Comment.where(post: post)Resolution order, association path requirements, three model shapes, and custom scopes are all covered in Tenancy › Entity scoping.
Standard ActiveRecord features
Everything you'd expect works — associations, validations, scopes, callbacks, delegations, has_rich_text, has_secure_token, has_one_attached, etc. Where Plutonium adds twists:
- Section ordering is by convention, not enforcement — pick the right slot in the layout above so the file stays scannable.
- Compound uniqueness for tenant-scoped resources:
validates :code, uniqueness: {scope: :organization_id}— without the scope, uniqueness leaks across tenants. - Keep models thin — business logic that touches multiple records or has multi-step state changes belongs in interactions, not model methods.
Nested resources
Plutonium auto-generates nested routes from has_many and has_one associations. No model-side change needed beyond the association itself:
class Comment < ResourceRecord
belongs_to :post
endWhen both Post and Comment are registered in a portal, /posts/:post_id/nested_comments exists automatically. See Tenancy › Nested resources.
Table naming in packages
Namespaced models use prefixed tables by default:
module Blogging
class Post < ResourceRecord
# table: blogging_posts
end
endOverride with self.table_name = "posts" if you need a shared table.
Related
- Definition — controls how the model's fields render
- Tenancy › Entity scoping —
associated_with, three model shapes - App › Generators —
pu:res:scaffoldfield syntax
