Poly
Type-safe joins, role identity, owner identity, and migration discipline for polymorphic belongs_to associations in Rails.
What Is Poly?
Poly is a structural identity substrate for Rails polymorphism.
It provides:
- Type-safe polymorphic joins
- Role semantics for polymorphic relationships
- Owner stamping for write-time identity projection
- Migration helpers for consistent schema topology
Poly does not implement tenancy, policy, or business logic.
Mental Model
flowchart LR
A[ActiveRecord Model]
B[Polymorphic belongs_to]
C[Role Column]
D[Owner Columns]
E[Composite Indexes]
A --> B
B --> C
B --> D
C --> E
D --> E
Poly strengthens the edges around polymorphic identity.
Installation
Add to your Gemfile:
gem "poly"
Then:
bundle install
Requirements
- Ruby >= 3.2
- ActiveRecord >= 7.1
Quickstart
Migration
class CreateItems < ActiveRecord::Migration[7.1]
include Poly::Migration
def change
create_table :items do |t|
poly_resource t, :itemable, null: false
poly_role t, :itemable, null: false
poly_owner t, null: false
t.
end
poly_resource_index :items, :itemable
poly_owner_index :items
end
end
Model
class Item < ApplicationRecord
belongs_to :itemable, polymorphic: true
include Poly::Role
include Poly::Owners
poly_role :itemable
poly_owner :itemable, owner: -> { account }
end
Modules
1. Poly::Joins
Generates type-safe INNER JOIN methods for polymorphic associations.
Example
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
include Poly::Joins
end
Now:
Comment.joins_commentable(Post)
Comment.joins_commentable(User)
Generated SQL:
INNER JOIN "posts"
ON "comments"."commentable_id" = "posts"."id"
AND "comments"."commentable_type" = 'Post'
[!IMPORTANT] The target class must declare the reverse association:
has_many :comments, as: :commentable # has_one :comment, as: :commentable — also validOtherwise
PolymorphicJoinErroris raised.
Join Flow
sequenceDiagram
participant M as Model
participant PJ as Poly::Joins
participant T as Target
M->>PJ: joins_commentable(Post)
PJ->>T: Validate reverse association
PJ->>M: Generate INNER JOIN
2. Poly::Role
Adds semantic identity to polymorphic relationships.
Schema
create_table :taggings do |t|
t.references :taggable, polymorphic: true, null: false
t.string :taggable_role, null: false
t.
end
add_index :taggings,
[:taggable_type, :taggable_id, :taggable_role],
unique: true
Model
class Tagging < ApplicationRecord
belongs_to :taggable, polymorphic: true
include Poly::Role
poly_role :taggable
# poly_role :taggable, immutable: true
end
What You Get
- Normalization (
strip + downcase) - Format validation (
/\A[a-z0-9_]+\z/) - Length validation (
max_length:, default64) for_rolescope- Optional immutability
Tagging.for_role(" PRIMARY ")
# => matches "primary"
[!NOTE]
immutable: trueprevents role changes after create.
Role Identity Model
flowchart TD
A[Polymorphic Edge]
B[Role Column]
C[Semantic Meaning]
A --> B
B --> C
3. Poly::Owners
Stamps root ownership at write time.
No traversal. No tenancy logic. Just identity projection.
Schema
create_table :coins do |t|
t.references :resource, polymorphic: true, null: false
t.string :owner_type
t.string :owner_id
t.
end
add_index :coins, [:owner_type, :owner_id]
[!WARNING] Never index
owner_typeandowner_idseparately. Always composite.
Model
class Coin < ApplicationRecord
belongs_to :resource, polymorphic: true
include Poly::Owners
poly_owner :resource, owner: -> { ledger&.account }
end
Owner Resolution Options
| Option | Default | Description |
|---|---|---|
type_column: |
:owner_type |
Column storing class name |
id_column: |
:owner_id |
Column storing owner ID |
allow_nil: |
true |
Allow owner to resolve to nil; if false, raise instead |
immutable: |
false |
Prevent owner changes after create |
[!IMPORTANT] Owner must resolve to a persisted
ActiveRecord::Base.Otherwise an
ArgumentErroris raised.
Ownership Flow
sequenceDiagram
participant Record
participant PO as Poly::Owners
participant Owner
Record->>PO: before_validation
PO->>Owner: resolve owner
PO->>Record: stamp owner_type + owner_id
4. Poly::Migration
Migration helpers for consistent polymorphic topology.
Usage
class ApplicationMigration < ActiveRecord::Migration[7.1]
include Poly::Migration
end
Supports:
create_tablechange_table- direct
add_columnstyle
Helpers
| Helper | Purpose |
|---|---|
poly_resource |
Adds <name>_type + <name>_id |
poly_role |
Adds <name>_role |
poly_owner |
Adds owner columns |
poly_stack |
Adds is_prime + superseded_by_id |
poly_resource_index |
Composite resource index (supports where:, unique:, index_name:) |
poly_owner_index |
Composite owner index (supports where:, unique:, index_name:) |
poly_prime_index |
Partial unique index (one prime per resource/role); sugar for poly_resource_index(..., where: 'is_prime') |
ID Flexibility
id_type defaults to :string.
Supports:
- UUID
- ULID
- bigint
- custom identifiers
5. Poly::Stack
A polymorphic, role-discriminated history where one entry is the current
prime — the top of an append-only stack. Poly::Role is
the same idea at cardinality 1; Poly::Stack opens it up to many entries per
(resource, role), with the most-recently created always prime.
It is append-only and payload agnostic: it manages the prime marker and an audit edge only. The payload column, the actor, and the reason belong to your model — Poly::Stack never reads them.
Schema
create_table :statuses do |t|
t.references :resource, polymorphic: true, null: false
t.string :resource_role, null: false
t.string :state, null: false # your payload — Poly::Stack is agnostic about it
t.boolean :is_prime, null: false, default: false
t.integer :superseded_by_id # audit edge (unconstrained)
t.
end
# Exactly one prime per resource per role, enforced by the database.
add_index :statuses, %i[resource_type resource_id resource_role],
unique: true, where: 'is_prime', name: 'index_statuses_prime'
Or with Poly::Migration:
create_table :statuses do |t|
poly_resource t, :resource, null: false
poly_role t, :resource, null: false
t.string :state, null: false
poly_stack t
t.
end
poly_prime_index :statuses, :resource
The Entry Model
Poly::Stack is an entry-side concern — the externality includes it, exactly
as Coin includes Poly::Role:
class Status < ApplicationRecord
belongs_to :resource, polymorphic: true
include Poly::Joins
include Poly::Stack
poly_stack :resource
end
That gives the prime scope and append-only priming — the newest entry per
(resource, role) becomes prime, the prior prime is demoted, and its
superseded_by_id is linked:
Status.create!(resource: post, resource_role: 'status', state: 'draft')
Status.create!(resource: post, resource_role: 'status', state: 'public')
Status.where(resource: post, resource_role: 'status').prime # => the 'public' entry
Wiring a Parent
Poly ships only the entry concern. The parent-side accessor macro is yours to
write — the way Midas writes has_coin on Poly::Role. It's just for_role
(from Poly::Role) plus the prime scope, composed into associations and
scopes:
# in your app — a Stackable concern providing a has_stack macro
module Stackable
extend ActiveSupport::Concern
class_methods do
def has_stack(name, class_name: name.to_s.classify, value: :state, dependent: :destroy)
label = name.to_s
plural = label.pluralize.to_sym
stamp = :"stack_stamp_#{name}"
# so `post.statuses << Status.new(...)` stamps the role on add
define_method(stamp) do |entry|
entry.resource_role = label if entry.respond_to?(:resource_role=) && entry.resource_role.blank?
end
has_many plural, -> { for_role(label).order(created_at: :desc) },
as: :resource, class_name: class_name, dependent: dependent, before_add: stamp
has_one name, -> { for_role(label).prime },
as: :resource, class_name: class_name
entries = -> { class_name.constantize }
scope :"where_#{name}", ->(*v) { joins(name).merge(entries.call.where(value => v.flatten)) }
scope :"ever_#{name}", ->(*v) { where(id: joins(plural).merge(entries.call.where(value => v.flatten)).select(:id)) }
scope :"without_#{name}", ->(*v) { where.not(id: public_send(:"where_#{name}", *v).select(:id)) }
scope :"never_#{name}", ->(*v) { where.not(id: public_send(:"ever_#{name}", *v).select(:id)) }
end
end
end
class Post < ApplicationRecord
include Stackable
has_stack :status
has_stack :visibility, class_name: 'Status' # second stack, same table, own prime
end
Which gives you:
post.status # => prime Status entry (preloadable has_one)
post.statuses # => full stack, newest-first (has_many)
post.statuses.create!(state: 'trash') # append an entry; it becomes prime
post.statuses << Status.new(state: 'public') # also appends; role stamped on add
Post.where_status('public') # prime state = public
Post.without_status('trash') # prime != trash, OR no entry yet
Post.ever_status('trash') # any entry in history = trash (de-duped)
Post.never_status('trash') # no entry ever = trash
Post.includes(:status) # preload the prime to avoid N+1
Soft-Delete
Poly::Stack is the primitive behind soft-delete-as-history: a trash entry is a
deletion, a later entry is a restore, and the stack is the audit trail. The
meaning stays in your app — one-liners over your has_stack scopes:
scope :live, -> { without_status(:trash) }
scope :trashed, -> { where_status(:trash) }
[!NOTE] Prefer explicit scopes over
default_scopefor soft-delete.
Priming Flow
sequenceDiagram
participant Entry as New Entry
participant PS as Poly::Stack
participant Prior as Prior Prime
Entry->>PS: before_create
PS->>Prior: demote (is_prime = false)
PS->>Entry: claim prime (is_prime = true)
Entry->>PS: after_create
PS->>Prior: link superseded_by_id
Design Principles
Poly is intentionally minimal.
It does not:
- Implement tenancy
- Infer ownership
- Traverse associations
- Inject business logic
- Generate constraints automatically
- Enforce policy
It provides structure only.
Development
bundle install
bundle exec rspec
bundle exec rubocop
COVERAGE=true bundle exec rspec
Stability
Poly v1.0.0 declares:
- Public API is stable
- Breaking changes follow SemVer
- New features are additive
- No structural refactors planned
License
MIT — see LICENSE