poly 1.0.0 → 1.2.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 +4 -4
- data/CHANGELOG.md +55 -5
- data/README.md +466 -109
- data/lib/poly/joins.rb +2 -4
- data/lib/poly/migration.rb +29 -4
- data/lib/poly/polymorphic_join_error.rb +8 -0
- data/lib/poly/stack.rb +86 -0
- data/lib/poly/version.rb +1 -1
- data/lib/poly.rb +2 -0
- metadata +10 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba17496c0d7031f1877d3aa61200842efd24cb1381c12ecb597e5b4c80ac65d7
|
|
4
|
+
data.tar.gz: 9bf0d37b9106badbf5672f3b1534e5c8e8161e3934ead590b56415a17196eda5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf46bda44abfaccfb137c84cf1a6da3a6d3a6d0ad901e5354b3a3031769454777a90d03b2b09f0a5d5f81d4d26fd91458a58ea1de4cb083756fa4cfae6328684
|
|
7
|
+
data.tar.gz: 62b7997d9a53c0ee472f745edaae46e78b6df6d54ff77af8ca4465bbb85afd642636c164863cf77eff4aaa537ed00c3779582203c935b34fa692853457c5b239
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,56 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [1.2.0] - 2026-08-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Poly::PolymorphicJoinError` (`lib/poly/polymorphic_join_error.rb`) — the
|
|
13
|
+
join-validation error raised from `lib/poly/joins.rb` is now namespaced
|
|
14
|
+
under `Poly`. The bare top-level `PolymorphicJoinError` constant remains as
|
|
15
|
+
a deprecated alias, so existing `rescue PolymorphicJoinError` call sites
|
|
16
|
+
keep working.
|
|
17
|
+
- CI now covers a database axis (SQLite and PostgreSQL, the latter via a GitHub
|
|
18
|
+
Actions `services:` Postgres container) and an ActiveRecord/Rails version axis
|
|
19
|
+
(7.1, 7.2, 8.x), each matrix cell pinning the loaded AR version via the new
|
|
20
|
+
`ACTIVERECORD_VERSION` env var consumed by the `Gemfile`.
|
|
21
|
+
- `spec/spec_helper.rb`'s database adapter is now parameterized via
|
|
22
|
+
`POLY_TEST_ADAPTER` (`sqlite3` default, or `postgresql`) instead of
|
|
23
|
+
hardcoding an in-memory SQLite connection.
|
|
24
|
+
- README "Supported Databases" section documenting that MySQL is explicitly
|
|
25
|
+
unsupported (`AbstractMysqlAdapter` doesn't implement `supports_partial_index?`,
|
|
26
|
+
silently degrading `poly_prime_index` to a full-table unique index).
|
|
27
|
+
- README and new specs (`spec/models/poly/stack_spec.rb`) documenting
|
|
28
|
+
`Poly::Stack`'s concurrency boundary — the `poly_stack_seize_prime`
|
|
29
|
+
demote-then-insert sequence in `lib/poly/stack.rb`, and the
|
|
30
|
+
`ActiveRecord::RecordNotUnique` failure mode it can hit under concurrent
|
|
31
|
+
writers. No new public API was added.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- README §5 ("Poly::Stack")'s "append-only" wording corrected: the contract
|
|
36
|
+
is immutable payload with mutable linkage/index metadata (`is_prime`,
|
|
37
|
+
`superseded_by_id` are mutated in place on supersession), not literally
|
|
38
|
+
immutable/append-only rows.
|
|
39
|
+
|
|
40
|
+
## [1.1.0] - 2026-07-07
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- `Poly::Stack` — polymorphic, role-discriminated append-only history with a single
|
|
45
|
+
"prime" (golden-child) card per `(resource, role)`, enforced by a database-level
|
|
46
|
+
partial unique index. Payload-agnostic: manages only the prime marker
|
|
47
|
+
(`is_prime`) and audit edge (`superseded_by_id`); the payload column, actor, and
|
|
48
|
+
reason belong to the consuming model.
|
|
49
|
+
- `where:` option on `poly_resource_index` and `poly_owner_index` migration
|
|
50
|
+
helpers — passes a partial-index condition through to `add_index`.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- `poly_prime_index` is now implemented as sugar on top of `poly_resource_index`
|
|
55
|
+
(`where: 'is_prime'`) instead of duplicating its own `add_index` call. The
|
|
56
|
+
generated index name (`index_<table>_prime`) and indexed columns are
|
|
57
|
+
unchanged, so this is not a breaking change for existing schemas.
|
|
9
58
|
|
|
10
59
|
## [1.0.0] - 2026-02-18
|
|
11
60
|
|
|
@@ -51,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
51
100
|
Creates methods like `joins_commentable(ClassName)` that validate the reverse
|
|
52
101
|
`has_many`/`has_one` association before building the join SQL.
|
|
53
102
|
|
|
54
|
-
[
|
|
55
|
-
[1.
|
|
56
|
-
[0.
|
|
57
|
-
[0.
|
|
103
|
+
[1.2.0]: https://github.com/whittakertech/poly/compare/v1.1.0...v1.2.0
|
|
104
|
+
[1.1.0]: https://github.com/whittakertech/poly/compare/v1.0.0...v1.1.0
|
|
105
|
+
[1.0.0]: https://github.com/whittakertech/poly/compare/v0.2.0...v1.0.0
|
|
106
|
+
[0.2.0]: https://github.com/whittakertech/poly/compare/v0.1.0...v0.2.0
|
|
107
|
+
[0.1.0]: https://github.com/whittakertech/poly/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,73 +1,192 @@
|
|
|
1
1
|
# Poly
|
|
2
2
|
|
|
3
|
-
Type-safe joins, role identity,
|
|
3
|
+
Type-safe joins, role identity, owner identity, and migration discipline for polymorphic `belongs_to` associations in Rails.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## What Is Poly?
|
|
8
|
+
|
|
9
|
+
Poly is a structural identity substrate for Rails polymorphism.
|
|
10
|
+
|
|
11
|
+
It provides:
|
|
12
|
+
|
|
13
|
+
- Type-safe polymorphic joins
|
|
14
|
+
- Role semantics for polymorphic relationships
|
|
15
|
+
- Owner stamping for write-time identity projection
|
|
16
|
+
- Migration helpers for consistent schema topology
|
|
17
|
+
|
|
18
|
+
Poly does **not** implement tenancy, policy, or business logic.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Mental Model
|
|
23
|
+
|
|
24
|
+
```mermaid
|
|
25
|
+
flowchart LR
|
|
26
|
+
A[ActiveRecord Model]
|
|
27
|
+
B[Polymorphic belongs_to]
|
|
28
|
+
C[Role Column]
|
|
29
|
+
D[Owner Columns]
|
|
30
|
+
E[Composite Indexes]
|
|
31
|
+
|
|
32
|
+
A --> B
|
|
33
|
+
B --> C
|
|
34
|
+
B --> D
|
|
35
|
+
C --> E
|
|
36
|
+
D --> E
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Poly strengthens the edges around polymorphic identity.
|
|
40
|
+
|
|
41
|
+
---
|
|
4
42
|
|
|
5
43
|
## Installation
|
|
6
44
|
|
|
7
45
|
Add to your Gemfile:
|
|
8
46
|
|
|
9
47
|
```ruby
|
|
10
|
-
gem
|
|
48
|
+
gem "poly"
|
|
11
49
|
```
|
|
12
50
|
|
|
13
|
-
Then
|
|
51
|
+
Then:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bundle install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
14
58
|
|
|
15
59
|
## Requirements
|
|
16
60
|
|
|
17
61
|
- Ruby >= 3.2
|
|
18
62
|
- ActiveRecord >= 7.1
|
|
19
63
|
|
|
20
|
-
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Supported Databases
|
|
21
67
|
|
|
22
|
-
|
|
68
|
+
Poly is tested in CI against **SQLite** and **PostgreSQL** (both Ruby x
|
|
69
|
+
ActiveRecord 7.1/7.2/8.x combinations -- see `.github/workflows/ci.yml`).
|
|
23
70
|
|
|
24
|
-
|
|
71
|
+
**MySQL is explicitly not supported.** `Poly::Migration#poly_prime_index`
|
|
72
|
+
(see below) relies on a partial/conditional unique index --
|
|
73
|
+
`add_index table, [...], unique: true, where: 'is_prime'` -- to enforce
|
|
74
|
+
"exactly one prime row per resource+role, many non-primes allowed."
|
|
75
|
+
PostgreSQL's and SQLite3's ActiveRecord adapters both override
|
|
76
|
+
`supports_partial_index?` to `true`; `ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter`
|
|
77
|
+
does **not** override it, so it inherits the abstract default of `false`.
|
|
78
|
+
Because `schema_creation.rb` only emits the index's `WHERE` clause when
|
|
79
|
+
`supports_partial_index?` is true, MySQL silently drops the clause instead
|
|
80
|
+
of raising -- producing a full-table unique index instead of a partial one,
|
|
81
|
+
and quietly breaking the single-prime-per-role invariant (it would instead
|
|
82
|
+
forbid more than one row total per resource+role). This is a silent
|
|
83
|
+
correctness bug, not just reduced support, so running Poly against MySQL is
|
|
84
|
+
unsupported rather than merely uncautioned-against.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
# Quickstart
|
|
89
|
+
|
|
90
|
+
### Migration
|
|
25
91
|
|
|
26
92
|
```ruby
|
|
27
|
-
class
|
|
28
|
-
|
|
93
|
+
class CreateItems < ActiveRecord::Migration[7.1]
|
|
94
|
+
include Poly::Migration
|
|
29
95
|
|
|
30
|
-
|
|
96
|
+
def change
|
|
97
|
+
create_table :items do |t|
|
|
98
|
+
poly_resource t, :itemable, null: false
|
|
99
|
+
poly_role t, :itemable, null: false
|
|
100
|
+
poly_owner t, null: false
|
|
101
|
+
t.timestamps
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
poly_resource_index :items, :itemable
|
|
105
|
+
poly_owner_index :items
|
|
106
|
+
end
|
|
31
107
|
end
|
|
108
|
+
```
|
|
32
109
|
|
|
33
|
-
|
|
34
|
-
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### Model
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class Item < ApplicationRecord
|
|
116
|
+
belongs_to :itemable, polymorphic: true
|
|
117
|
+
|
|
118
|
+
include Poly::Role
|
|
119
|
+
include Poly::Owners
|
|
120
|
+
|
|
121
|
+
poly_role :itemable
|
|
122
|
+
poly_owner :itemable, owner: -> { account }
|
|
35
123
|
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
# Modules
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
# 1. Poly::Joins
|
|
133
|
+
|
|
134
|
+
Generates type-safe `INNER JOIN` methods for polymorphic associations.
|
|
135
|
+
|
|
136
|
+
### Example
|
|
36
137
|
|
|
37
|
-
|
|
38
|
-
|
|
138
|
+
```ruby
|
|
139
|
+
class Comment < ApplicationRecord
|
|
140
|
+
belongs_to :commentable, polymorphic: true
|
|
141
|
+
include Poly::Joins
|
|
39
142
|
end
|
|
40
143
|
```
|
|
41
144
|
|
|
42
|
-
Now
|
|
145
|
+
Now:
|
|
43
146
|
|
|
44
147
|
```ruby
|
|
45
|
-
# Join comments to the posts table
|
|
46
148
|
Comment.joins_commentable(Post)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# ON "comments"."commentable_id" = "posts"."id"
|
|
50
|
-
# AND "comments"."commentable_type" = 'Post'
|
|
149
|
+
Comment.joins_commentable(User)
|
|
150
|
+
```
|
|
51
151
|
|
|
52
|
-
|
|
53
|
-
Comment.joins_commentable(Post).where(posts: { title: 'Hello' })
|
|
152
|
+
Generated SQL:
|
|
54
153
|
|
|
55
|
-
|
|
56
|
-
|
|
154
|
+
```sql
|
|
155
|
+
INNER JOIN "posts"
|
|
156
|
+
ON "comments"."commentable_id" = "posts"."id"
|
|
157
|
+
AND "comments"."commentable_type" = 'Post'
|
|
57
158
|
```
|
|
58
159
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
160
|
+
> [!IMPORTANT]
|
|
161
|
+
> The target class must declare the reverse association:
|
|
162
|
+
>
|
|
163
|
+
> ```ruby
|
|
164
|
+
> has_many :comments, as: :commentable
|
|
165
|
+
> # has_one :comment, as: :commentable — also valid
|
|
166
|
+
> ```
|
|
167
|
+
>
|
|
168
|
+
> Otherwise `Poly::PolymorphicJoinError` is raised.
|
|
169
|
+
|
|
170
|
+
### Join Flow
|
|
171
|
+
|
|
172
|
+
```mermaid
|
|
173
|
+
sequenceDiagram
|
|
174
|
+
participant M as Model
|
|
175
|
+
participant PJ as Poly::Joins
|
|
176
|
+
participant T as Target
|
|
177
|
+
|
|
178
|
+
M->>PJ: joins_commentable(Post)
|
|
179
|
+
PJ->>T: Validate reverse association
|
|
180
|
+
PJ->>M: Generate INNER JOIN
|
|
64
181
|
```
|
|
65
182
|
|
|
66
|
-
|
|
183
|
+
---
|
|
67
184
|
|
|
68
|
-
|
|
185
|
+
# 2. Poly::Role
|
|
69
186
|
|
|
70
|
-
|
|
187
|
+
Adds semantic identity to polymorphic relationships.
|
|
188
|
+
|
|
189
|
+
## Schema
|
|
71
190
|
|
|
72
191
|
```ruby
|
|
73
192
|
create_table :taggings do |t|
|
|
@@ -76,11 +195,12 @@ create_table :taggings do |t|
|
|
|
76
195
|
t.timestamps
|
|
77
196
|
end
|
|
78
197
|
|
|
79
|
-
|
|
80
|
-
|
|
198
|
+
add_index :taggings,
|
|
199
|
+
[:taggable_type, :taggable_id, :taggable_role],
|
|
200
|
+
unique: true
|
|
81
201
|
```
|
|
82
202
|
|
|
83
|
-
|
|
203
|
+
## Model
|
|
84
204
|
|
|
85
205
|
```ruby
|
|
86
206
|
class Tagging < ApplicationRecord
|
|
@@ -89,80 +209,110 @@ class Tagging < ApplicationRecord
|
|
|
89
209
|
include Poly::Role
|
|
90
210
|
|
|
91
211
|
poly_role :taggable
|
|
92
|
-
# optionally:
|
|
93
|
-
# poly_role :taggable, max_length: 128
|
|
94
212
|
# poly_role :taggable, immutable: true
|
|
95
213
|
end
|
|
96
214
|
```
|
|
97
215
|
|
|
98
|
-
|
|
216
|
+
## What You Get
|
|
99
217
|
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
-
|
|
218
|
+
- Normalization (`strip + downcase`)
|
|
219
|
+
- Format validation (`/\A[a-z0-9_]+\z/`)
|
|
220
|
+
- Length validation (`max_length:`, default `64`)
|
|
221
|
+
- `for_role` scope
|
|
222
|
+
- Optional immutability
|
|
104
223
|
|
|
105
224
|
```ruby
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
225
|
+
Tagging.for_role(" PRIMARY ")
|
|
226
|
+
# => matches "primary"
|
|
227
|
+
```
|
|
109
228
|
|
|
110
|
-
|
|
111
|
-
|
|
229
|
+
> [!NOTE]
|
|
230
|
+
> `immutable: true` prevents role changes after create.
|
|
231
|
+
|
|
232
|
+
### Role Identity Model
|
|
233
|
+
|
|
234
|
+
```mermaid
|
|
235
|
+
flowchart TD
|
|
236
|
+
A[Polymorphic Edge]
|
|
237
|
+
B[Role Column]
|
|
238
|
+
C[Semantic Meaning]
|
|
239
|
+
|
|
240
|
+
A --> B
|
|
241
|
+
B --> C
|
|
112
242
|
```
|
|
113
243
|
|
|
114
|
-
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
# 3. Poly::Owners
|
|
247
|
+
|
|
248
|
+
Stamps root ownership at write time.
|
|
115
249
|
|
|
116
|
-
|
|
250
|
+
No traversal.
|
|
251
|
+
No tenancy logic.
|
|
252
|
+
Just identity projection.
|
|
117
253
|
|
|
118
|
-
|
|
254
|
+
## Schema
|
|
119
255
|
|
|
120
256
|
```ruby
|
|
121
257
|
create_table :coins do |t|
|
|
122
|
-
t.references :ledger, null: false
|
|
123
258
|
t.references :resource, polymorphic: true, null: false
|
|
124
|
-
t.string
|
|
125
|
-
t.string
|
|
126
|
-
t.integer :owner_id
|
|
259
|
+
t.string :owner_type
|
|
260
|
+
t.string :owner_id
|
|
127
261
|
t.timestamps
|
|
128
262
|
end
|
|
129
263
|
|
|
130
|
-
# Index: always composite — never index owner_type and owner_id separately
|
|
131
264
|
add_index :coins, [:owner_type, :owner_id]
|
|
132
265
|
```
|
|
133
266
|
|
|
134
|
-
|
|
267
|
+
> [!WARNING]
|
|
268
|
+
> Never index `owner_type` and `owner_id` separately. Always composite.
|
|
269
|
+
|
|
270
|
+
## Model
|
|
135
271
|
|
|
136
272
|
```ruby
|
|
137
273
|
class Coin < ApplicationRecord
|
|
138
|
-
belongs_to :ledger
|
|
139
274
|
belongs_to :resource, polymorphic: true
|
|
140
275
|
|
|
141
276
|
include Poly::Owners
|
|
142
277
|
|
|
143
278
|
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
279
|
end
|
|
148
280
|
```
|
|
149
281
|
|
|
150
|
-
|
|
282
|
+
## Owner Resolution Options
|
|
283
|
+
|
|
284
|
+
| Option | Default | Description |
|
|
285
|
+
|----------------|---------------|------------------------------------|
|
|
286
|
+
| `type_column:` | `:owner_type` | Column storing class name |
|
|
287
|
+
| `id_column:` | `:owner_id` | Column storing owner ID |
|
|
288
|
+
| `allow_nil:` | `true` | Allow owner to resolve to nil; if `false`, raise instead |
|
|
289
|
+
| `immutable:` | `false` | Prevent owner changes after create |
|
|
290
|
+
|
|
291
|
+
> [!IMPORTANT]
|
|
292
|
+
> Owner must resolve to a persisted `ActiveRecord::Base`.
|
|
293
|
+
>
|
|
294
|
+
> Otherwise an `ArgumentError` is raised.
|
|
295
|
+
|
|
296
|
+
### Ownership Flow
|
|
151
297
|
|
|
152
|
-
|
|
298
|
+
```mermaid
|
|
299
|
+
sequenceDiagram
|
|
300
|
+
participant Record
|
|
301
|
+
participant PO as Poly::Owners
|
|
302
|
+
participant Owner
|
|
153
303
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
304
|
+
Record->>PO: before_validation
|
|
305
|
+
PO->>Owner: resolve owner
|
|
306
|
+
PO->>Record: stamp owner_type + owner_id
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
160
310
|
|
|
161
|
-
|
|
311
|
+
# 4. Poly::Migration
|
|
162
312
|
|
|
163
|
-
|
|
313
|
+
Migration helpers for consistent polymorphic topology.
|
|
164
314
|
|
|
165
|
-
|
|
315
|
+
## Usage
|
|
166
316
|
|
|
167
317
|
```ruby
|
|
168
318
|
class ApplicationMigration < ActiveRecord::Migration[7.1]
|
|
@@ -170,65 +320,272 @@ class ApplicationMigration < ActiveRecord::Migration[7.1]
|
|
|
170
320
|
end
|
|
171
321
|
```
|
|
172
322
|
|
|
173
|
-
|
|
323
|
+
Supports:
|
|
324
|
+
|
|
325
|
+
- `create_table`
|
|
326
|
+
- `change_table`
|
|
327
|
+
- direct `add_column` style
|
|
328
|
+
|
|
329
|
+
## Helpers
|
|
330
|
+
|
|
331
|
+
| Helper | Purpose |
|
|
332
|
+
|--------|----------|
|
|
333
|
+
| `poly_resource` | Adds `<name>_type` + `<name>_id` |
|
|
334
|
+
| `poly_role` | Adds `<name>_role` |
|
|
335
|
+
| `poly_owner` | Adds owner columns |
|
|
336
|
+
| `poly_stack` | Adds `is_prime` + `superseded_by_id` |
|
|
337
|
+
| `poly_resource_index` | Composite resource index (supports `where:`, `unique:`, `index_name:`) |
|
|
338
|
+
| `poly_owner_index` | Composite owner index (supports `where:`, `unique:`, `index_name:`) |
|
|
339
|
+
| `poly_prime_index` | Partial unique index (one prime per resource/role); sugar for `poly_resource_index(..., where: 'is_prime')` |
|
|
340
|
+
|
|
341
|
+
## ID Flexibility
|
|
342
|
+
|
|
343
|
+
`id_type` defaults to `:string`.
|
|
344
|
+
|
|
345
|
+
Supports:
|
|
346
|
+
|
|
347
|
+
- UUID
|
|
348
|
+
- ULID
|
|
349
|
+
- bigint
|
|
350
|
+
- custom identifiers
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
# 5. Poly::Stack
|
|
355
|
+
|
|
356
|
+
A polymorphic, role-discriminated **history** where one entry is the current
|
|
357
|
+
**prime** — the top of the stack. `Poly::Role` is
|
|
358
|
+
the same idea at cardinality 1; `Poly::Stack` opens it up to many entries per
|
|
359
|
+
`(resource, role)`, with the most-recently created always prime.
|
|
360
|
+
|
|
361
|
+
It is **payload agnostic** with an **immutable payload, mutable linkage/index
|
|
362
|
+
metadata** contract: it manages the prime marker and an audit edge only. The
|
|
363
|
+
payload column, the actor, and the reason belong to your model — Poly::Stack
|
|
364
|
+
never reads or rewrites them. What Poly::Stack *does* mutate in place, on
|
|
365
|
+
supersession, are the prior prime row's own `is_prime` and `superseded_by_id`
|
|
366
|
+
columns — historical rows are never deleted, but their linkage/index metadata
|
|
367
|
+
is updated, so this is not a literally append-only/immutable-row contract.
|
|
368
|
+
|
|
369
|
+
## Schema
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
create_table :statuses do |t|
|
|
373
|
+
t.references :resource, polymorphic: true, null: false
|
|
374
|
+
t.string :resource_role, null: false
|
|
375
|
+
t.string :state, null: false # your payload — Poly::Stack is agnostic about it
|
|
376
|
+
t.boolean :is_prime, null: false, default: false
|
|
377
|
+
t.integer :superseded_by_id # audit edge (unconstrained)
|
|
378
|
+
t.timestamps
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Exactly one prime per resource per role, enforced by the database.
|
|
382
|
+
add_index :statuses, %i[resource_type resource_id resource_role],
|
|
383
|
+
unique: true, where: 'is_prime', name: 'index_statuses_prime'
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Or with `Poly::Migration`:
|
|
174
387
|
|
|
175
|
-
|
|
176
|
-
|
|
388
|
+
```ruby
|
|
389
|
+
create_table :statuses do |t|
|
|
390
|
+
poly_resource t, :resource, null: false
|
|
391
|
+
poly_role t, :resource, null: false
|
|
392
|
+
t.string :state, null: false
|
|
393
|
+
poly_stack t
|
|
394
|
+
t.timestamps
|
|
395
|
+
end
|
|
177
396
|
|
|
178
|
-
|
|
397
|
+
poly_prime_index :statuses, :resource
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## The Entry Model
|
|
401
|
+
|
|
402
|
+
`Poly::Stack` is an **entry-side** concern — the externality includes it, exactly
|
|
403
|
+
as `Coin` includes `Poly::Role`:
|
|
179
404
|
|
|
180
405
|
```ruby
|
|
181
|
-
class
|
|
182
|
-
|
|
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
|
|
406
|
+
class Status < ApplicationRecord
|
|
407
|
+
belongs_to :resource, polymorphic: true
|
|
189
408
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
409
|
+
include Poly::Joins
|
|
410
|
+
include Poly::Stack
|
|
411
|
+
|
|
412
|
+
poly_stack :resource
|
|
193
413
|
end
|
|
194
414
|
```
|
|
195
415
|
|
|
196
|
-
|
|
416
|
+
That gives the `prime` scope and append-only priming — the newest entry per
|
|
417
|
+
`(resource, role)` becomes prime, the prior prime is demoted, and its
|
|
418
|
+
`superseded_by_id` is linked:
|
|
197
419
|
|
|
198
420
|
```ruby
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
421
|
+
Status.create!(resource: post, resource_role: 'status', state: 'draft')
|
|
422
|
+
Status.create!(resource: post, resource_role: 'status', state: 'public')
|
|
423
|
+
|
|
424
|
+
Status.where(resource: post, resource_role: 'status').prime # => the 'public' entry
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## Wiring a Parent
|
|
428
|
+
|
|
429
|
+
Poly ships **only the entry concern**. The parent-side accessor macro is yours to
|
|
430
|
+
write — the way Midas writes `has_coin` on `Poly::Role`. It's just `for_role`
|
|
431
|
+
(from `Poly::Role`) plus the `prime` scope, composed into associations and
|
|
432
|
+
scopes:
|
|
204
433
|
|
|
205
|
-
|
|
206
|
-
|
|
434
|
+
```ruby
|
|
435
|
+
# in your app — a Stackable concern providing a has_stack macro
|
|
436
|
+
module Stackable
|
|
437
|
+
extend ActiveSupport::Concern
|
|
438
|
+
|
|
439
|
+
class_methods do
|
|
440
|
+
def has_stack(name, class_name: name.to_s.classify, value: :state, dependent: :destroy)
|
|
441
|
+
label = name.to_s
|
|
442
|
+
plural = label.pluralize.to_sym
|
|
443
|
+
stamp = :"stack_stamp_#{name}"
|
|
444
|
+
|
|
445
|
+
# so `post.statuses << Status.new(...)` stamps the role on add
|
|
446
|
+
define_method(stamp) do |entry|
|
|
447
|
+
entry.resource_role = label if entry.respond_to?(:resource_role=) && entry.resource_role.blank?
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
has_many plural, -> { for_role(label).order(created_at: :desc) },
|
|
451
|
+
as: :resource, class_name: class_name, dependent: dependent, before_add: stamp
|
|
452
|
+
has_one name, -> { for_role(label).prime },
|
|
453
|
+
as: :resource, class_name: class_name
|
|
454
|
+
|
|
455
|
+
entries = -> { class_name.constantize }
|
|
456
|
+
scope :"where_#{name}", ->(*v) { joins(name).merge(entries.call.where(value => v.flatten)) }
|
|
457
|
+
scope :"ever_#{name}", ->(*v) { where(id: joins(plural).merge(entries.call.where(value => v.flatten)).select(:id)) }
|
|
458
|
+
scope :"without_#{name}", ->(*v) { where.not(id: public_send(:"where_#{name}", *v).select(:id)) }
|
|
459
|
+
scope :"never_#{name}", ->(*v) { where.not(id: public_send(:"ever_#{name}", *v).select(:id)) }
|
|
460
|
+
end
|
|
207
461
|
end
|
|
208
462
|
end
|
|
463
|
+
|
|
464
|
+
class Post < ApplicationRecord
|
|
465
|
+
include Stackable
|
|
466
|
+
|
|
467
|
+
has_stack :status
|
|
468
|
+
has_stack :visibility, class_name: 'Status' # second stack, same table, own prime
|
|
469
|
+
end
|
|
209
470
|
```
|
|
210
471
|
|
|
211
|
-
|
|
472
|
+
Which gives you:
|
|
212
473
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
| `poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false)` | Adds index on owner columns |
|
|
474
|
+
```ruby
|
|
475
|
+
post.status # => prime Status entry (preloadable has_one)
|
|
476
|
+
post.statuses # => full stack, newest-first (has_many)
|
|
477
|
+
|
|
478
|
+
post.statuses.create!(state: 'trash') # append an entry; it becomes prime
|
|
479
|
+
post.statuses << Status.new(state: 'public') # also appends; role stamped on add
|
|
220
480
|
|
|
221
|
-
|
|
481
|
+
Post.where_status('public') # prime state = public
|
|
482
|
+
Post.without_status('trash') # prime != trash, OR no entry yet
|
|
483
|
+
Post.ever_status('trash') # any entry in history = trash (de-duped)
|
|
484
|
+
Post.never_status('trash') # no entry ever = trash
|
|
222
485
|
|
|
223
|
-
|
|
486
|
+
Post.includes(:status) # preload the prime to avoid N+1
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## Soft-Delete
|
|
490
|
+
|
|
491
|
+
`Poly::Stack` is the primitive behind soft-delete-as-history: a `trash` entry is a
|
|
492
|
+
deletion, a later entry is a restore, and the stack is the audit trail. The
|
|
493
|
+
*meaning* stays in your app — one-liners over your `has_stack` scopes:
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
scope :live, -> { without_status(:trash) }
|
|
497
|
+
scope :trashed, -> { where_status(:trash) }
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
> [!NOTE]
|
|
501
|
+
> Prefer explicit scopes over `default_scope` for soft-delete.
|
|
502
|
+
|
|
503
|
+
## Concurrency Boundary
|
|
504
|
+
|
|
505
|
+
`poly_stack_seize_prime` (the `before_create` callback that demotes the prior
|
|
506
|
+
prime and claims the new one) is **not** wrapped in an explicit row lock or
|
|
507
|
+
transaction. Two writers racing the same `(resource, role)` at the same time
|
|
508
|
+
can both read the same prior prime, both demote it, and both attempt to
|
|
509
|
+
insert with `is_prime: true`.
|
|
510
|
+
|
|
511
|
+
When that happens, it is the partial unique index (`add_index ..., unique:
|
|
512
|
+
true, where: 'is_prime'`, built by `poly_prime_index`) — not the
|
|
513
|
+
callback — that enforces "at most one prime per `(resource, role)`". The
|
|
514
|
+
losing writer's `INSERT` raises `ActiveRecord::RecordNotUnique`, at the
|
|
515
|
+
`INSERT` itself, after the callback has already run.
|
|
516
|
+
|
|
517
|
+
Poly::Stack does not catch or retry this internally. **Callers that may write
|
|
518
|
+
concurrently to the same `(resource, role)` should be prepared to rescue
|
|
519
|
+
`ActiveRecord::RecordNotUnique` around the create call and retry** — e.g.
|
|
520
|
+
re-fetch the current prime and re-attempt the create — rather than assuming a
|
|
521
|
+
single `create!` is race-safe:
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
begin
|
|
525
|
+
post.statuses.create!(state: 'public')
|
|
526
|
+
rescue ActiveRecord::RecordNotUnique
|
|
527
|
+
# another writer won the race for this (resource, role); re-fetch and
|
|
528
|
+
# decide whether to retry, merge, or surface a conflict to the caller.
|
|
529
|
+
retry_or_handle_conflict
|
|
530
|
+
end
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
## Priming Flow
|
|
534
|
+
|
|
535
|
+
```mermaid
|
|
536
|
+
sequenceDiagram
|
|
537
|
+
participant Entry as New Entry
|
|
538
|
+
participant PS as Poly::Stack
|
|
539
|
+
participant Prior as Prior Prime
|
|
540
|
+
|
|
541
|
+
Entry->>PS: before_create
|
|
542
|
+
PS->>Prior: demote (is_prime = false)
|
|
543
|
+
PS->>Entry: claim prime (is_prime = true)
|
|
544
|
+
Entry->>PS: after_create
|
|
545
|
+
PS->>Prior: link superseded_by_id
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
# Design Principles
|
|
551
|
+
|
|
552
|
+
Poly is intentionally minimal.
|
|
553
|
+
|
|
554
|
+
It does not:
|
|
555
|
+
|
|
556
|
+
- Implement tenancy
|
|
557
|
+
- Infer ownership
|
|
558
|
+
- Traverse associations
|
|
559
|
+
- Inject business logic
|
|
560
|
+
- Generate constraints automatically
|
|
561
|
+
- Enforce policy
|
|
562
|
+
|
|
563
|
+
It provides structure only.
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
# Development
|
|
224
568
|
|
|
225
569
|
```bash
|
|
226
|
-
bundle install
|
|
227
|
-
bundle exec rspec
|
|
228
|
-
bundle exec rubocop
|
|
229
|
-
COVERAGE=true bundle exec rspec
|
|
570
|
+
bundle install
|
|
571
|
+
bundle exec rspec
|
|
572
|
+
bundle exec rubocop
|
|
573
|
+
COVERAGE=true bundle exec rspec
|
|
230
574
|
```
|
|
231
575
|
|
|
232
|
-
|
|
576
|
+
---
|
|
577
|
+
|
|
578
|
+
# Stability
|
|
579
|
+
|
|
580
|
+
Poly v1.0.0 declares:
|
|
581
|
+
|
|
582
|
+
- Public API is stable
|
|
583
|
+
- Breaking changes follow SemVer
|
|
584
|
+
- New features are additive
|
|
585
|
+
- No structural refactors planned
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
589
|
+
# License
|
|
233
590
|
|
|
234
|
-
|
|
591
|
+
MIT — see [LICENSE](LICENSE)
|
data/lib/poly/joins.rb
CHANGED
|
@@ -19,12 +19,12 @@ module Poly::Joins
|
|
|
19
19
|
next if singleton_class.method_defined?(method_name)
|
|
20
20
|
|
|
21
21
|
define_singleton_method(method_name) do |klass|
|
|
22
|
-
raise PolymorphicJoinError, 'Expected an ActiveRecord model' unless klass <= ActiveRecord::Base
|
|
22
|
+
raise Poly::PolymorphicJoinError, 'Expected an ActiveRecord model' unless klass <= ActiveRecord::Base
|
|
23
23
|
|
|
24
24
|
base_klass = klass.base_class
|
|
25
25
|
|
|
26
26
|
unless join_allowed?(klass, as: assoc_name)
|
|
27
|
-
raise PolymorphicJoinError,
|
|
27
|
+
raise Poly::PolymorphicJoinError,
|
|
28
28
|
"Polymorphic join requires #{base_klass} to declare: " \
|
|
29
29
|
"has_many :#{name.underscore.pluralize}, as: :#{assoc_name}"
|
|
30
30
|
end
|
|
@@ -54,5 +54,3 @@ module Poly::Joins
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
|
-
|
|
58
|
-
class PolymorphicJoinError < StandardError; end
|
data/lib/poly/migration.rb
CHANGED
|
@@ -40,12 +40,37 @@ module Poly::Migration
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# Stack columns (golden-child history): the prime marker and the audit edge.
|
|
44
|
+
# Table-builder helper (create_table/change_table):
|
|
45
|
+
# poly_stack t
|
|
46
|
+
# Direct helper (add_column style):
|
|
47
|
+
# poly_stack :statuses
|
|
48
|
+
def poly_stack(table_or_builder, id_type: :string)
|
|
49
|
+
if table_builder?(table_or_builder)
|
|
50
|
+
table_or_builder.boolean :is_prime, null: false, default: false
|
|
51
|
+
table_or_builder.public_send(id_type, :superseded_by_id, null: true)
|
|
52
|
+
else
|
|
53
|
+
add_column table_or_builder, :is_prime, :boolean, null: false, default: false
|
|
54
|
+
add_column table_or_builder, :superseded_by_id, id_type, null: true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def poly_resource_index(table, name, unique: false, where: nil, index_name: nil, columns: nil)
|
|
59
|
+
add_index table, columns || [:"#{name}_type", :"#{name}_id"],
|
|
60
|
+
unique: unique, where: where, name: index_name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Partial unique index enforcing exactly one prime per (resource, role).
|
|
64
|
+
def poly_prime_index(table, name = :resource)
|
|
65
|
+
poly_resource_index table, name,
|
|
66
|
+
unique: true, where: 'is_prime',
|
|
67
|
+
index_name: "index_#{table}_prime",
|
|
68
|
+
columns: [:"#{name}_type", :"#{name}_id", :"#{name}_role"]
|
|
45
69
|
end
|
|
46
70
|
|
|
47
|
-
def poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false
|
|
48
|
-
|
|
71
|
+
def poly_owner_index(table, type_column: :owner_type, id_column: :owner_id, unique: false, where: nil,
|
|
72
|
+
index_name: nil)
|
|
73
|
+
add_index table, [type_column, id_column], unique: unique, where: where, name: index_name
|
|
49
74
|
end
|
|
50
75
|
|
|
51
76
|
private
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Poly::PolymorphicJoinError < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Deprecated: use Poly::PolymorphicJoinError. Kept as an alias so existing
|
|
6
|
+
# `rescue PolymorphicJoinError` / `is_a?(PolymorphicJoinError)` callers keep
|
|
7
|
+
# working across the 1.2.0 rename.
|
|
8
|
+
PolymorphicJoinError = Poly::PolymorphicJoinError
|
data/lib/poly/stack.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Card-side concern. Turns a polymorphic, role-discriminated model (the "card"
|
|
4
|
+
# model, e.g. Status) into a stack where the most-recently created card per
|
|
5
|
+
# (resource, role) is the current "prime" — the top of the stack. The
|
|
6
|
+
# contract is immutable payload, mutable linkage/index metadata: prior rows
|
|
7
|
+
# are never deleted, but their `is_prime` and `superseded_by_id` columns are
|
|
8
|
+
# mutated in place when a new card supersedes them — this is not a literally
|
|
9
|
+
# append-only/immutable-row stack.
|
|
10
|
+
#
|
|
11
|
+
# class Status < ApplicationRecord
|
|
12
|
+
# belongs_to :resource, polymorphic: true
|
|
13
|
+
# include Poly::Joins
|
|
14
|
+
# include Poly::Stack
|
|
15
|
+
# poly_stack :resource
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Poly::Stack builds on Poly::Role (the resource_role discriminator) and adds
|
|
19
|
+
# only `is_prime` (the enforced golden-child marker) and `superseded_by_id`
|
|
20
|
+
# (an unconstrained audit edge). It is deliberately payload agnostic: it does
|
|
21
|
+
# not define the payload column, the actor, or the reason — those belong to the
|
|
22
|
+
# host model. The parent-side accessor macro (`has_stack`) is the consumer's to
|
|
23
|
+
# write, the way Midas writes `has_coin` on Poly::Role — see the README.
|
|
24
|
+
module Poly::Stack
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
include Poly::Role
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
class_attribute :poly_stack_association, instance_accessor: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# Declares the stack. `assoc_name` is the polymorphic belongs_to whose
|
|
34
|
+
# *_role column discriminates independent stacks on the same table.
|
|
35
|
+
def poly_stack(assoc_name, max_length: 64)
|
|
36
|
+
poly_role(assoc_name, max_length: max_length)
|
|
37
|
+
self.poly_stack_association = assoc_name.to_sym
|
|
38
|
+
|
|
39
|
+
scope :prime, -> { where(is_prime: true) }
|
|
40
|
+
|
|
41
|
+
before_create :poly_stack_seize_prime
|
|
42
|
+
after_create :poly_stack_chain_superseded
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def poly_stack_columns
|
|
46
|
+
assoc = poly_stack_association
|
|
47
|
+
{ type: :"#{assoc}_type", id: :"#{assoc}_id", role: :"#{assoc}_role" }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Demote the current prime (if any) so this card can take its place, and
|
|
54
|
+
# remember it so the supersession edge can be linked once we have an id.
|
|
55
|
+
# Runs before insert: at INSERT time there is exactly one is_prime row, so
|
|
56
|
+
# the partial unique index is satisfied (zero primes momentarily is legal).
|
|
57
|
+
#
|
|
58
|
+
# Concurrency boundary: this demote-then-insert sequence is NOT wrapped in
|
|
59
|
+
# an explicit row lock or transaction. Two concurrent writers racing the
|
|
60
|
+
# same (resource, role) can both read the same prior prime, both demote it
|
|
61
|
+
# via #update_columns, and both attempt to insert with is_prime: true. The
|
|
62
|
+
# second writer's INSERT then raises ActiveRecord::RecordNotUnique — not
|
|
63
|
+
# here in the callback, but afterward, once ActiveRecord issues the actual
|
|
64
|
+
# INSERT. It is the partial unique index (`poly_prime_index` /
|
|
65
|
+
# `index_#{table}_prime`, see lib/poly/migration.rb), not this callback,
|
|
66
|
+
# that actually enforces "at most one prime per (resource, role)" under a
|
|
67
|
+
# race. Poly::Stack does not retry internally; see README's "Poly::Stack"
|
|
68
|
+
# section for the recommended caller-side rescue-and-retry pattern.
|
|
69
|
+
def poly_stack_seize_prime
|
|
70
|
+
cols = self.class.poly_stack_columns
|
|
71
|
+
@poly_stack_superseded = self.class
|
|
72
|
+
.where(cols[:type] => self[cols[:type]],
|
|
73
|
+
cols[:id] => self[cols[:id]],
|
|
74
|
+
cols[:role] => self[cols[:role]])
|
|
75
|
+
.prime
|
|
76
|
+
.first
|
|
77
|
+
@poly_stack_superseded&.update_columns(is_prime: false)
|
|
78
|
+
self.is_prime = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Audit edge (decoration, unconstrained): the old prime points forward to the
|
|
82
|
+
# card that replaced it. Safe to run after insert since it carries no index.
|
|
83
|
+
def poly_stack_chain_superseded
|
|
84
|
+
@poly_stack_superseded&.update_columns(superseded_by_id: id)
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/poly/version.rb
CHANGED
data/lib/poly.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: poly
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lee Whittaker
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -38,7 +38,8 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '7.1'
|
|
41
|
-
description: Type-safe joins
|
|
41
|
+
description: Type-safe joins, role identity, owner stamping, and append-only golden-child
|
|
42
|
+
history (Poly::Stack) for polymorphic belongs_to associations.
|
|
42
43
|
email:
|
|
43
44
|
- lee@whittakertech.com
|
|
44
45
|
executables: []
|
|
@@ -53,15 +54,17 @@ files:
|
|
|
53
54
|
- lib/poly/joins.rb
|
|
54
55
|
- lib/poly/migration.rb
|
|
55
56
|
- lib/poly/owners.rb
|
|
57
|
+
- lib/poly/polymorphic_join_error.rb
|
|
56
58
|
- lib/poly/role.rb
|
|
59
|
+
- lib/poly/stack.rb
|
|
57
60
|
- lib/poly/version.rb
|
|
58
|
-
homepage: https://github.com/
|
|
61
|
+
homepage: https://github.com/whittakertech/poly
|
|
59
62
|
licenses:
|
|
60
63
|
- MIT
|
|
61
64
|
metadata:
|
|
62
|
-
homepage_uri: https://github.com/
|
|
63
|
-
source_code_uri: https://github.com/
|
|
64
|
-
changelog_uri: https://github.com/
|
|
65
|
+
homepage_uri: https://github.com/whittakertech/poly
|
|
66
|
+
source_code_uri: https://github.com/whittakertech/poly
|
|
67
|
+
changelog_uri: https://github.com/whittakertech/poly/blob/master/CHANGELOG.md
|
|
65
68
|
rubygems_mfa_required: 'true'
|
|
66
69
|
post_install_message:
|
|
67
70
|
rdoc_options: []
|