poly 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE +21 -0
- data/README.md +234 -0
- data/Rakefile +9 -0
- data/lib/poly/joins.rb +58 -0
- data/lib/poly/migration.rb +56 -0
- data/lib/poly/owners.rb +65 -0
- data/lib/poly/role.rb +32 -0
- data/lib/poly/version.rb +5 -0
- data/lib/poly.rb +11 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 56f2e1989d5977256fd93964e07d62d3a26b696bc469baf3ddf477141eb7f383
|
|
4
|
+
data.tar.gz: 33239362e770c93e2871754f9697763c893960af1e0238aac59ddfd94a8b7399
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 190707ca692b512d914adcd3b5cfbdfab79a14730ce14043a6c361642ebb6b193941ae75909e24a6405b9f96c23b746b61d9b66daa77255cb0a9241cc3b3be5a
|
|
7
|
+
data.tar.gz: 6ae262801aaab5a09416080fb36df87a9ee65918698ab08bc12e1e2b6c612c9d9bee288a396842bc89c6b165e6515d8b50f84009f5670b5111a2c1ff2be1735e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-02-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Poly::Migration` — helpers for declaring polymorphic resource/role/owner columns and indexes
|
|
15
|
+
consistently across `create_table`, `change_table`, and `add_column`-style migrations.
|
|
16
|
+
Helpers: `poly_resource`, `poly_role`, `poly_owner`, `poly_resource_index`, `poly_owner_index`.
|
|
17
|
+
- `Poly::Owners` — stamps `owner_type`/`owner_id` (or custom columns) before validation.
|
|
18
|
+
Supports proc/method/object owner resolution, `allow_nil`, and `immutable` options.
|
|
19
|
+
Validates that the named association is a polymorphic `belongs_to` and that the owner
|
|
20
|
+
is a persisted `ActiveRecord::Base` instance.
|
|
21
|
+
- `poly_role immutable: true` option — raises on update if the role has already been set,
|
|
22
|
+
preventing role changes after create.
|
|
23
|
+
- `poly_resource_index` and `poly_owner_index` migration helpers for consistent index naming
|
|
24
|
+
and uniqueness declarations on polymorphic column pairs.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `Poly::Label` renamed to `Poly::Role`; the role column (e.g. `commentable_role`) replaces
|
|
29
|
+
the former label column (`commentable_label`).
|
|
30
|
+
- `poly_role` now enforces lowercase alphanumeric/underscore format (`/\A[a-z0-9_]+\z/`)
|
|
31
|
+
and normalises values (strip + downcase) before validation and in `for_role` queries.
|
|
32
|
+
- Ruby requirement raised to `>= 3.2.0`.
|
|
33
|
+
|
|
34
|
+
### Removed
|
|
35
|
+
|
|
36
|
+
- `Poly::Label` — fully replaced by `Poly::Role`. Update column names and any
|
|
37
|
+
`for_label` / `poly_label` references to `for_role` / `poly_role` accordingly.
|
|
38
|
+
|
|
39
|
+
## [0.2.0] - 2024
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- `Poly::Role` (originally shipped as `Poly::Label`) — role validation, normalization,
|
|
44
|
+
and `for_role` scope for polymorphic associations.
|
|
45
|
+
|
|
46
|
+
## [0.1.0] - 2024
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- `Poly::Joins` — type-safe polymorphic `INNER JOIN` generation via `define_polymorphic_joins!`.
|
|
51
|
+
Creates methods like `joins_commentable(ClassName)` that validate the reverse
|
|
52
|
+
`has_many`/`has_one` association before building the join SQL.
|
|
53
|
+
|
|
54
|
+
[Unreleased]: https://github.com/leewhittaker/poly/compare/v1.0.0...HEAD
|
|
55
|
+
[1.0.0]: https://github.com/leewhittaker/poly/compare/v0.2.0...v1.0.0
|
|
56
|
+
[0.2.0]: https://github.com/leewhittaker/poly/compare/v0.1.0...v0.2.0
|
|
57
|
+
[0.1.0]: https://github.com/leewhittaker/poly/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Lee Whittaker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Poly
|
|
2
|
+
|
|
3
|
+
Type-safe joins, role identity, and owner identity for polymorphic `belongs_to` associations in Rails.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'poly'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run `bundle install`.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Ruby >= 3.2
|
|
18
|
+
- ActiveRecord >= 7.1
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
### Poly::Joins
|
|
23
|
+
|
|
24
|
+
Generates type-safe `INNER JOIN` methods for polymorphic associations. Include the module in a model that has a polymorphic `belongs_to`, and it will define a `joins_<association>` class method for each one.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class Comment < ApplicationRecord
|
|
28
|
+
belongs_to :commentable, polymorphic: true
|
|
29
|
+
|
|
30
|
+
include Poly::Joins
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Post < ApplicationRecord
|
|
34
|
+
has_many :comments, as: :commentable
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class User < ApplicationRecord
|
|
38
|
+
has_many :comments, as: :commentable
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Now you can join through the polymorphic association by passing the target class:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Join comments to the posts table
|
|
46
|
+
Comment.joins_commentable(Post)
|
|
47
|
+
# => SELECT "comments".* FROM "comments"
|
|
48
|
+
# INNER JOIN "posts"
|
|
49
|
+
# ON "comments"."commentable_id" = "posts"."id"
|
|
50
|
+
# AND "comments"."commentable_type" = 'Post'
|
|
51
|
+
|
|
52
|
+
# Chainable with other scopes
|
|
53
|
+
Comment.joins_commentable(Post).where(posts: { title: 'Hello' })
|
|
54
|
+
|
|
55
|
+
# Join to a different target type
|
|
56
|
+
Comment.joins_commentable(User).where(users: { name: 'Lee' })
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Safety:** The target class must declare the reverse association (`has_many` or `has_one` with `as: :commentable`). If it doesn't, a `PolymorphicJoinError` is raised:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Comment.joins_commentable(Unrelated)
|
|
63
|
+
# => PolymorphicJoinError: Unrelated must declare has_one/has_many as: :commentable
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Poly::Role
|
|
67
|
+
|
|
68
|
+
Adds a validated role column to a polymorphic association. This is useful when a single polymorphic relationship needs to distinguish between different roles or categories.
|
|
69
|
+
|
|
70
|
+
Your table needs a `<association>_role` string column:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
create_table :taggings do |t|
|
|
74
|
+
t.references :taggable, polymorphic: true, null: false
|
|
75
|
+
t.string :taggable_role, null: false
|
|
76
|
+
t.timestamps
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Index: composite on (taggable_type, taggable_id, taggable_role) if uniqueness is required
|
|
80
|
+
add_index :taggings, [:taggable_type, :taggable_id, :taggable_role], unique: true
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then include the module and declare the role-enabled association:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class Tagging < ApplicationRecord
|
|
87
|
+
belongs_to :taggable, polymorphic: true
|
|
88
|
+
|
|
89
|
+
include Poly::Role
|
|
90
|
+
|
|
91
|
+
poly_role :taggable
|
|
92
|
+
# optionally:
|
|
93
|
+
# poly_role :taggable, max_length: 128
|
|
94
|
+
# poly_role :taggable, immutable: true
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This gives you:
|
|
99
|
+
|
|
100
|
+
- **Normalization** — roles are stripped and downcased before validation and before `for_role` queries
|
|
101
|
+
- **Validation** — roles must match `/\A[a-z0-9_]+\z/` and be at most 64 characters (configurable via `max_length:`)
|
|
102
|
+
- **Scope** — `for_role` queries by role, normalizing the input automatically
|
|
103
|
+
- **Immutability** — `immutable: true` adds an `on: :update` validation that prevents role changes after create
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
tagging = Tagging.new(taggable: post, taggable_role: ' Primary ')
|
|
107
|
+
tagging.valid?
|
|
108
|
+
tagging.taggable_role # => "primary"
|
|
109
|
+
|
|
110
|
+
Tagging.for_role(' PRIMARY ')
|
|
111
|
+
# => normalizes to 'primary' before querying
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Poly::Owners
|
|
115
|
+
|
|
116
|
+
Stamps `owner_type`/`owner_id` columns before validation. Useful for recording data ownership at write time without coupling the model to tenancy or policy logic.
|
|
117
|
+
|
|
118
|
+
Your table needs `owner_type` and `owner_id` columns (in addition to your polymorphic resource columns):
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
create_table :coins do |t|
|
|
122
|
+
t.references :ledger, null: false
|
|
123
|
+
t.references :resource, polymorphic: true, null: false
|
|
124
|
+
t.string :resource_role, null: false
|
|
125
|
+
t.string :owner_type
|
|
126
|
+
t.integer :owner_id
|
|
127
|
+
t.timestamps
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Index: always composite — never index owner_type and owner_id separately
|
|
131
|
+
add_index :coins, [:owner_type, :owner_id]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then declare how the owner should be resolved:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class Coin < ApplicationRecord
|
|
138
|
+
belongs_to :ledger
|
|
139
|
+
belongs_to :resource, polymorphic: true
|
|
140
|
+
|
|
141
|
+
include Poly::Owners
|
|
142
|
+
|
|
143
|
+
poly_owner :resource, owner: -> { ledger&.account }
|
|
144
|
+
# optionally:
|
|
145
|
+
# poly_owner :resource, owner: -> { ledger&.account }, allow_nil: false
|
|
146
|
+
# poly_owner :resource, owner: -> { ledger&.account }, immutable: true
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**`owner` resolution** — can be a `Proc` (evaluated in instance context), a `Symbol`/`String` (method name called on the record), or a direct `ActiveRecord::Base` instance. The owner must be persisted; an `ArgumentError` is raised otherwise.
|
|
151
|
+
|
|
152
|
+
**Options:**
|
|
153
|
+
|
|
154
|
+
| Option | Default | Description |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `type_column:` | `:owner_type` | Column to store the owner class name |
|
|
157
|
+
| `id_column:` | `:owner_id` | Column to store the owner id |
|
|
158
|
+
| `allow_nil:` | `true` | When `false`, raises if the owner resolves to `nil` |
|
|
159
|
+
| `immutable:` | `false` | When `true`, prevents owner changes after create via `on: :update` validation |
|
|
160
|
+
|
|
161
|
+
### Poly::Migration
|
|
162
|
+
|
|
163
|
+
Adds migration helpers so polymorphic resource/role/owner columns are declared consistently.
|
|
164
|
+
|
|
165
|
+
Use it in your migration base class:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class ApplicationMigration < ActiveRecord::Migration[7.1]
|
|
169
|
+
include Poly::Migration
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Supported styles:
|
|
174
|
+
|
|
175
|
+
- `create_table` / `change_table` via a table builder (`t`)
|
|
176
|
+
- direct existing-table operations via `add_column` style (pass table name)
|
|
177
|
+
|
|
178
|
+
#### Create Table / Change Table
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
class CreateCoins < ApplicationMigration
|
|
182
|
+
def change
|
|
183
|
+
create_table :coins do |t|
|
|
184
|
+
poly_resource t, :resource, null: false
|
|
185
|
+
poly_role t, :resource, null: false
|
|
186
|
+
poly_owner t, null: false
|
|
187
|
+
t.timestamps
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
poly_resource_index :coins, :resource
|
|
191
|
+
poly_owner_index :coins
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
#### Existing Table (add_column style)
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class AddPolyColumnsToCoins < ApplicationMigration
|
|
200
|
+
def change
|
|
201
|
+
poly_resource :coins, :resource, null: false
|
|
202
|
+
poly_role :coins, :resource, null: false
|
|
203
|
+
poly_owner :coins, null: false
|
|
204
|
+
|
|
205
|
+
poly_resource_index :coins, :resource
|
|
206
|
+
poly_owner_index :coins
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
#### Helper Reference
|
|
212
|
+
|
|
213
|
+
| Helper | Purpose |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `poly_resource(table_or_builder, name, null: true, id_type: :string)` | Adds `<name>_type` and `<name>_id` |
|
|
216
|
+
| `poly_role(table_or_builder, name, null: true)` | Adds `<name>_role` |
|
|
217
|
+
| `poly_owner(table_or_builder, type_column: :owner_type, id_column: :owner_id, id_type: :string, null: true)` | Adds owner type/id columns |
|
|
218
|
+
| `poly_resource_index(table, name, unique: false)` | Adds index on `<name>_type`, `<name>_id` |
|
|
219
|
+
| `poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false)` | Adds index on owner columns |
|
|
220
|
+
|
|
221
|
+
`id_type` defaults to `:string` so owner/resource IDs can store bigint, UUID, ULID, or other identifier formats consistently.
|
|
222
|
+
|
|
223
|
+
## Development
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
bundle install # Install dependencies
|
|
227
|
+
bundle exec rspec # Run tests
|
|
228
|
+
bundle exec rubocop # Lint
|
|
229
|
+
COVERAGE=true bundle exec rspec # Run tests with coverage report
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## License
|
|
233
|
+
|
|
234
|
+
Released under the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/lib/poly/joins.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poly::Joins
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
define_polymorphic_joins!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
def define_polymorphic_joins!
|
|
12
|
+
reflect_on_all_associations(:belongs_to).each do |assoc|
|
|
13
|
+
next unless assoc.options[:polymorphic]
|
|
14
|
+
|
|
15
|
+
reflection = assoc
|
|
16
|
+
assoc_name = reflection.name
|
|
17
|
+
method_name = :"joins_#{assoc_name}"
|
|
18
|
+
|
|
19
|
+
next if singleton_class.method_defined?(method_name)
|
|
20
|
+
|
|
21
|
+
define_singleton_method(method_name) do |klass|
|
|
22
|
+
raise PolymorphicJoinError, 'Expected an ActiveRecord model' unless klass <= ActiveRecord::Base
|
|
23
|
+
|
|
24
|
+
base_klass = klass.base_class
|
|
25
|
+
|
|
26
|
+
unless join_allowed?(klass, as: assoc_name)
|
|
27
|
+
raise PolymorphicJoinError,
|
|
28
|
+
"Polymorphic join requires #{base_klass} to declare: " \
|
|
29
|
+
"has_many :#{name.underscore.pluralize}, as: :#{assoc_name}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
source = arel_table
|
|
33
|
+
target = base_klass.arel_table
|
|
34
|
+
|
|
35
|
+
joins(
|
|
36
|
+
source.join(target)
|
|
37
|
+
.on(source["#{assoc_name}_id"].eq(target[:id])
|
|
38
|
+
.and(source["#{assoc_name}_type"].eq(base_klass.name)))
|
|
39
|
+
.join_sources
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def join_allowed?(klass, as:)
|
|
48
|
+
klass.reflect_on_all_associations.any? do |assoc|
|
|
49
|
+
next false unless assoc.options[:as] == as
|
|
50
|
+
next false unless %i[has_many has_one].include? assoc.macro
|
|
51
|
+
|
|
52
|
+
assoc.klass == self
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class PolymorphicJoinError < StandardError; end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poly::Migration
|
|
4
|
+
# Table-builder helper (create_table/change_table):
|
|
5
|
+
# poly_resource t, :resource
|
|
6
|
+
# Direct helper (add_column style):
|
|
7
|
+
# poly_resource :coins, :resource
|
|
8
|
+
def poly_resource(table_or_builder, name, null: true, id_type: :string)
|
|
9
|
+
if table_builder?(table_or_builder)
|
|
10
|
+
table_or_builder.references name, polymorphic: true, null: null, type: id_type
|
|
11
|
+
else
|
|
12
|
+
add_column table_or_builder, :"#{name}_type", :string, null: null
|
|
13
|
+
add_column table_or_builder, :"#{name}_id", id_type, null: null
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Table-builder helper (create_table/change_table):
|
|
18
|
+
# poly_role t, :resource
|
|
19
|
+
# Direct helper (add_column style):
|
|
20
|
+
# poly_role :coins, :resource
|
|
21
|
+
def poly_role(table_or_builder, name, null: true)
|
|
22
|
+
if table_builder?(table_or_builder)
|
|
23
|
+
table_or_builder.string :"#{name}_role", null: null
|
|
24
|
+
else
|
|
25
|
+
add_column table_or_builder, :"#{name}_role", :string, null: null
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Table-builder helper (create_table/change_table):
|
|
30
|
+
# poly_owner t
|
|
31
|
+
# Direct helper (add_column style):
|
|
32
|
+
# poly_owner :coins
|
|
33
|
+
def poly_owner(table_or_builder, type_column: :owner_type, id_column: :owner_id, id_type: :string, null: true)
|
|
34
|
+
if table_builder?(table_or_builder)
|
|
35
|
+
table_or_builder.string type_column, null: null
|
|
36
|
+
table_or_builder.public_send(id_type, id_column, null: null)
|
|
37
|
+
else
|
|
38
|
+
add_column table_or_builder, type_column, :string, null: null
|
|
39
|
+
add_column table_or_builder, id_column, id_type, null: null
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def poly_resource_index(table, name, unique: false)
|
|
44
|
+
add_index table, [:"#{name}_type", :"#{name}_id"], unique: unique
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false)
|
|
48
|
+
add_index table, [type_column, id_column], unique: unique
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def table_builder?(value)
|
|
54
|
+
value.respond_to?(:references)
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/poly/owners.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poly::Owners
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
# Declares owner columns populated before validation.
|
|
8
|
+
# poly_owner :resource, owner: -> { ledger.account }
|
|
9
|
+
# poly_owner :resource, owner: :account
|
|
10
|
+
def poly_owner(assoc_name, owner:, type_column: :owner_type, id_column: :owner_id,
|
|
11
|
+
allow_nil: true, immutable: false)
|
|
12
|
+
raise ArgumentError, 'owner is required' if owner.nil?
|
|
13
|
+
|
|
14
|
+
assoc = reflect_on_association(assoc_name.to_sym)
|
|
15
|
+
unless assoc&.macro == :belongs_to && assoc.options[:polymorphic]
|
|
16
|
+
raise ArgumentError, "#{name} must declare belongs_to :#{assoc_name}, polymorphic: true"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
before_validation { Poly::Owners.apply_owner(self, owner, type_column, id_column, allow_nil: allow_nil) }
|
|
20
|
+
poly_owner_immutability!(type_column, id_column) if immutable
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def poly_owner_immutability!(type_column, id_column)
|
|
26
|
+
validate(on: :update) do
|
|
27
|
+
if will_save_change_to_attribute?(type_column) || will_save_change_to_attribute?(id_column)
|
|
28
|
+
errors.add(:base, 'owner cannot be changed once set')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.apply_owner(record, owner, type_column, id_column, allow_nil: true)
|
|
35
|
+
resolved = resolve_owner(record, owner)
|
|
36
|
+
if resolved.nil?
|
|
37
|
+
assign_nil_owner(record, type_column, id_column, allow_nil: allow_nil)
|
|
38
|
+
elsif resolved.is_a?(ActiveRecord::Base)
|
|
39
|
+
raise ArgumentError, 'owner must be persisted' unless resolved.persisted?
|
|
40
|
+
|
|
41
|
+
record.public_send(:"#{type_column}=", resolved.class.base_class.name)
|
|
42
|
+
record.public_send(:"#{id_column}=", resolved.id)
|
|
43
|
+
else
|
|
44
|
+
raise ArgumentError, "owner must resolve to an ActiveRecord::Base, got #{resolved.class}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.assign_nil_owner(record, type_column, id_column, allow_nil:)
|
|
49
|
+
raise ArgumentError, 'owner resolved to nil' unless allow_nil
|
|
50
|
+
|
|
51
|
+
record.public_send(:"#{type_column}=", nil)
|
|
52
|
+
record.public_send(:"#{id_column}=", nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.resolve_owner(record, owner)
|
|
56
|
+
case owner
|
|
57
|
+
when Proc
|
|
58
|
+
record.instance_exec(&owner)
|
|
59
|
+
when Symbol, String
|
|
60
|
+
record.public_send(owner)
|
|
61
|
+
else
|
|
62
|
+
owner
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/poly/role.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poly::Role
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
class_methods do
|
|
7
|
+
# Declares a role column on a polymorphic belongs_to.
|
|
8
|
+
# poly_role :schedulable -> expects schedulable_role column
|
|
9
|
+
# poly_role :resource -> expects resource_role column
|
|
10
|
+
def poly_role(assoc_name, max_length: 64, immutable: false)
|
|
11
|
+
role_col = :"#{assoc_name}_role"
|
|
12
|
+
|
|
13
|
+
validates role_col,
|
|
14
|
+
presence: true,
|
|
15
|
+
format: { with: /\A[a-z0-9_]+\z/ },
|
|
16
|
+
length: { maximum: max_length }
|
|
17
|
+
|
|
18
|
+
before_validation { public_send(:"#{role_col}=", public_send(role_col).to_s.strip.downcase.presence) }
|
|
19
|
+
|
|
20
|
+
scope :for_role, ->(role) { where(role_col => role.to_s.strip.downcase) }
|
|
21
|
+
poly_role_immutability!(role_col) if immutable
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def poly_role_immutability!(role_col)
|
|
27
|
+
validate(on: :update) do
|
|
28
|
+
errors.add(role_col, 'cannot be changed once set') if will_save_change_to_attribute?(role_col)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/poly/version.rb
ADDED
data/lib/poly.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: poly
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Lee Whittaker
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-02-19 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: activesupport
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '7.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '7.1'
|
|
41
|
+
description: Type-safe joins and role identity for polymorphic belongs_to associations.
|
|
42
|
+
email:
|
|
43
|
+
- lee@whittakertech.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- LICENSE
|
|
50
|
+
- README.md
|
|
51
|
+
- Rakefile
|
|
52
|
+
- lib/poly.rb
|
|
53
|
+
- lib/poly/joins.rb
|
|
54
|
+
- lib/poly/migration.rb
|
|
55
|
+
- lib/poly/owners.rb
|
|
56
|
+
- lib/poly/role.rb
|
|
57
|
+
- lib/poly/version.rb
|
|
58
|
+
homepage: https://github.com/leewhittaker/poly
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
homepage_uri: https://github.com/leewhittaker/poly
|
|
63
|
+
source_code_uri: https://github.com/leewhittaker/poly
|
|
64
|
+
changelog_uri: https://github.com/leewhittaker/poly/blob/main/CHANGELOG.md
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 3.2.0
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.4.19
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Polymorphic association utilities for ActiveRecord
|
|
85
|
+
test_files: []
|