poly 1.1.0 → 1.3.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 +70 -2
- data/README.md +73 -6
- data/Rakefile +5 -0
- data/lib/poly/joins.rb +2 -2
- data/lib/poly/polymorphic_join_error.rb +6 -1
- data/lib/poly/stack.rb +18 -2
- data/lib/poly/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '07925273fda2c3e5292b1f56d776f448d7542c1c28bc5f210e6738e2d682fa39'
|
|
4
|
+
data.tar.gz: a5797ce40826d0f0ee5f3a9835e38ebaae0e85219e6b7c0877effac315640d0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '01933f6479724033886a320905e2149d8487d3a3dcee193550cebe7b8b9b38fa38a4b477b9c92ba2fd455d83087dccec3a70b664fed5e603489d91e48ef33114'
|
|
7
|
+
data.tar.gz: cade850271da454356ecce2ac07e316ccf5c4b36b6f03f164827c27f2af986abc4c4804b9198115cbf38502111bd32fef8e08f3eec8cbcbfb8e7323bb87d1577
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,75 @@ 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.3.0] - 2026-08-22
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Lowered the supported ActiveRecord/ActiveSupport floor from `>= 7.1` to
|
|
13
|
+
`>= 6.1`.** No library code changed: a full scan of `lib/poly/*.rb` found no
|
|
14
|
+
Rails 7.1+ APIs, and the only ActiveRecord internals Poly touches
|
|
15
|
+
(`arel_table`, `reflect_on_all_associations`, `reflect_on_association`,
|
|
16
|
+
`base_class`) have been stable since well before 6.1. The previous floor was
|
|
17
|
+
an end-of-life policy choice, not a technical constraint. Lowered so
|
|
18
|
+
hellodancerrails (Rails 6.1.7.10 / Ruby 3.3.11) can adopt Midas, which
|
|
19
|
+
depends on Poly.
|
|
20
|
+
- CI gains a `6.1` cell on both the SQLite and PostgreSQL lanes, pinned to Ruby
|
|
21
|
+
3.3 -- the pairing hellodancerrails actually runs. 6.1 predates Ruby 3.4
|
|
22
|
+
entirely, so that combination is excluded.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- The PostgreSQL CI lane's `ruby/setup-ruby` step in `.gitea/workflows/ci.yml`
|
|
27
|
+
hardcoded `ruby-version: '3.4'` instead of reading `${{ matrix.ruby }}`, so
|
|
28
|
+
the matrix's Ruby axis was ignored on that lane.
|
|
29
|
+
- `spec/models/poly/migration_spec.rb` hardcoded
|
|
30
|
+
`ActiveRecord::Migration[7.1]`, which raises `Unknown migration version` on
|
|
31
|
+
Rails 6.1. Test migrations now build against `POLY_MIGRATION_VERSION`,
|
|
32
|
+
derived from the ActiveRecord actually under test.
|
|
33
|
+
- Shortened the `where:`-passthrough spec's table name, whose generated index
|
|
34
|
+
name (`index_poly_migration_where_indexes_on_resource_type_and_resource_id`,
|
|
35
|
+
67 chars) exceeded Rails 6.1's SQLite 64-character limit -- and PostgreSQL's
|
|
36
|
+
63-character limit on every version.
|
|
37
|
+
|
|
38
|
+
### Development
|
|
39
|
+
|
|
40
|
+
- The `6.1` bundle lane pins `concurrent-ruby < 1.3.5` (1.3.5 dropped the
|
|
41
|
+
implicit `require 'logger'` that ActiveSupport 6.1 relies on) and
|
|
42
|
+
`sqlite3 ~> 1.4` (Rails 6.1's SQLite3Adapter requires it; sqlite3 2.x needs
|
|
43
|
+
Rails 7.2+). Both are Gemfile-only -- neither is a runtime requirement of the
|
|
44
|
+
gem.
|
|
45
|
+
|
|
46
|
+
## [1.2.0] - 2026-08-16
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- `Poly::PolymorphicJoinError` (`lib/poly/polymorphic_join_error.rb`) — the
|
|
51
|
+
join-validation error raised from `lib/poly/joins.rb` is now namespaced
|
|
52
|
+
under `Poly`. The bare top-level `PolymorphicJoinError` constant remains as
|
|
53
|
+
a deprecated alias, so existing `rescue PolymorphicJoinError` call sites
|
|
54
|
+
keep working.
|
|
55
|
+
- CI now covers a database axis (SQLite and PostgreSQL, the latter via a GitHub
|
|
56
|
+
Actions `services:` Postgres container) and an ActiveRecord/Rails version axis
|
|
57
|
+
(7.1, 7.2, 8.x), each matrix cell pinning the loaded AR version via the new
|
|
58
|
+
`ACTIVERECORD_VERSION` env var consumed by the `Gemfile`.
|
|
59
|
+
- `spec/spec_helper.rb`'s database adapter is now parameterized via
|
|
60
|
+
`POLY_TEST_ADAPTER` (`sqlite3` default, or `postgresql`) instead of
|
|
61
|
+
hardcoding an in-memory SQLite connection.
|
|
62
|
+
- README "Supported Databases" section documenting that MySQL is explicitly
|
|
63
|
+
unsupported (`AbstractMysqlAdapter` doesn't implement `supports_partial_index?`,
|
|
64
|
+
silently degrading `poly_prime_index` to a full-table unique index).
|
|
65
|
+
- README and new specs (`spec/models/poly/stack_spec.rb`) documenting
|
|
66
|
+
`Poly::Stack`'s concurrency boundary — the `poly_stack_seize_prime`
|
|
67
|
+
demote-then-insert sequence in `lib/poly/stack.rb`, and the
|
|
68
|
+
`ActiveRecord::RecordNotUnique` failure mode it can hit under concurrent
|
|
69
|
+
writers. No new public API was added.
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
|
|
73
|
+
- README §5 ("Poly::Stack")'s "append-only" wording corrected: the contract
|
|
74
|
+
is immutable payload with mutable linkage/index metadata (`is_prime`,
|
|
75
|
+
`superseded_by_id` are mutated in place on supersession), not literally
|
|
76
|
+
immutable/append-only rows.
|
|
9
77
|
|
|
10
78
|
## [1.1.0] - 2026-07-07
|
|
11
79
|
|
|
@@ -70,7 +138,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
70
138
|
Creates methods like `joins_commentable(ClassName)` that validate the reverse
|
|
71
139
|
`has_many`/`has_one` association before building the join SQL.
|
|
72
140
|
|
|
73
|
-
[
|
|
141
|
+
[1.2.0]: https://github.com/whittakertech/poly/compare/v1.1.0...v1.2.0
|
|
74
142
|
[1.1.0]: https://github.com/whittakertech/poly/compare/v1.0.0...v1.1.0
|
|
75
143
|
[1.0.0]: https://github.com/whittakertech/poly/compare/v0.2.0...v1.0.0
|
|
76
144
|
[0.2.0]: https://github.com/whittakertech/poly/compare/v0.1.0...v0.2.0
|
data/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Poly
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/poly)
|
|
4
|
+
[](https://github.com/whittakertech/poly/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/whittakertech/poly/blob/master/LICENSE)
|
|
6
|
+
[](https://poly.whittakertech.com)
|
|
7
|
+
|
|
3
8
|
Type-safe joins, role identity, owner identity, and migration discipline for polymorphic `belongs_to` associations in Rails.
|
|
4
9
|
|
|
5
10
|
---
|
|
@@ -59,7 +64,35 @@ bundle install
|
|
|
59
64
|
## Requirements
|
|
60
65
|
|
|
61
66
|
- Ruby >= 3.2
|
|
62
|
-
- ActiveRecord >=
|
|
67
|
+
- ActiveRecord >= 6.1
|
|
68
|
+
|
|
69
|
+
Rails 6.1 is supported for legacy consumers and is exercised in CI on Ruby 3.3
|
|
70
|
+
only (6.1 predates Ruby 3.4). On 6.1 the SQLite3 adapter enforces a 64-character
|
|
71
|
+
index-name limit, so long table names may need an explicit `index_name:` on the
|
|
72
|
+
`poly_*_index` helpers -- PostgreSQL applies a 63-character limit on every
|
|
73
|
+
version, so this is good practice regardless.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Supported Databases
|
|
78
|
+
|
|
79
|
+
Poly is tested in CI against **SQLite** and **PostgreSQL** (both Ruby x
|
|
80
|
+
ActiveRecord 6.1/7.1/7.2/8.x combinations -- see `.github/workflows/ci.yml`).
|
|
81
|
+
|
|
82
|
+
**MySQL is explicitly not supported.** `Poly::Migration#poly_prime_index`
|
|
83
|
+
(see below) relies on a partial/conditional unique index --
|
|
84
|
+
`add_index table, [...], unique: true, where: 'is_prime'` -- to enforce
|
|
85
|
+
"exactly one prime row per resource+role, many non-primes allowed."
|
|
86
|
+
PostgreSQL's and SQLite3's ActiveRecord adapters both override
|
|
87
|
+
`supports_partial_index?` to `true`; `ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter`
|
|
88
|
+
does **not** override it, so it inherits the abstract default of `false`.
|
|
89
|
+
Because `schema_creation.rb` only emits the index's `WHERE` clause when
|
|
90
|
+
`supports_partial_index?` is true, MySQL silently drops the clause instead
|
|
91
|
+
of raising -- producing a full-table unique index instead of a partial one,
|
|
92
|
+
and quietly breaking the single-prime-per-role invariant (it would instead
|
|
93
|
+
forbid more than one row total per resource+role). This is a silent
|
|
94
|
+
correctness bug, not just reduced support, so running Poly against MySQL is
|
|
95
|
+
unsupported rather than merely uncautioned-against.
|
|
63
96
|
|
|
64
97
|
---
|
|
65
98
|
|
|
@@ -143,7 +176,7 @@ AND "comments"."commentable_type" = 'Post'
|
|
|
143
176
|
> # has_one :comment, as: :commentable — also valid
|
|
144
177
|
> ```
|
|
145
178
|
>
|
|
146
|
-
> Otherwise `PolymorphicJoinError` is raised.
|
|
179
|
+
> Otherwise `Poly::PolymorphicJoinError` is raised.
|
|
147
180
|
|
|
148
181
|
### Join Flow
|
|
149
182
|
|
|
@@ -332,13 +365,17 @@ Supports:
|
|
|
332
365
|
# 5. Poly::Stack
|
|
333
366
|
|
|
334
367
|
A polymorphic, role-discriminated **history** where one entry is the current
|
|
335
|
-
**prime** — the top of
|
|
368
|
+
**prime** — the top of the stack. `Poly::Role` is
|
|
336
369
|
the same idea at cardinality 1; `Poly::Stack` opens it up to many entries per
|
|
337
370
|
`(resource, role)`, with the most-recently created always prime.
|
|
338
371
|
|
|
339
|
-
It is **
|
|
340
|
-
|
|
341
|
-
model — Poly::Stack
|
|
372
|
+
It is **payload agnostic** with an **immutable payload, mutable linkage/index
|
|
373
|
+
metadata** contract: it manages the prime marker and an audit edge only. The
|
|
374
|
+
payload column, the actor, and the reason belong to your model — Poly::Stack
|
|
375
|
+
never reads or rewrites them. What Poly::Stack *does* mutate in place, on
|
|
376
|
+
supersession, are the prior prime row's own `is_prime` and `superseded_by_id`
|
|
377
|
+
columns — historical rows are never deleted, but their linkage/index metadata
|
|
378
|
+
is updated, so this is not a literally append-only/immutable-row contract.
|
|
342
379
|
|
|
343
380
|
## Schema
|
|
344
381
|
|
|
@@ -474,6 +511,36 @@ scope :trashed, -> { where_status(:trash) }
|
|
|
474
511
|
> [!NOTE]
|
|
475
512
|
> Prefer explicit scopes over `default_scope` for soft-delete.
|
|
476
513
|
|
|
514
|
+
## Concurrency Boundary
|
|
515
|
+
|
|
516
|
+
`poly_stack_seize_prime` (the `before_create` callback that demotes the prior
|
|
517
|
+
prime and claims the new one) is **not** wrapped in an explicit row lock or
|
|
518
|
+
transaction. Two writers racing the same `(resource, role)` at the same time
|
|
519
|
+
can both read the same prior prime, both demote it, and both attempt to
|
|
520
|
+
insert with `is_prime: true`.
|
|
521
|
+
|
|
522
|
+
When that happens, it is the partial unique index (`add_index ..., unique:
|
|
523
|
+
true, where: 'is_prime'`, built by `poly_prime_index`) — not the
|
|
524
|
+
callback — that enforces "at most one prime per `(resource, role)`". The
|
|
525
|
+
losing writer's `INSERT` raises `ActiveRecord::RecordNotUnique`, at the
|
|
526
|
+
`INSERT` itself, after the callback has already run.
|
|
527
|
+
|
|
528
|
+
Poly::Stack does not catch or retry this internally. **Callers that may write
|
|
529
|
+
concurrently to the same `(resource, role)` should be prepared to rescue
|
|
530
|
+
`ActiveRecord::RecordNotUnique` around the create call and retry** — e.g.
|
|
531
|
+
re-fetch the current prime and re-attempt the create — rather than assuming a
|
|
532
|
+
single `create!` is race-safe:
|
|
533
|
+
|
|
534
|
+
```ruby
|
|
535
|
+
begin
|
|
536
|
+
post.statuses.create!(state: 'public')
|
|
537
|
+
rescue ActiveRecord::RecordNotUnique
|
|
538
|
+
# another writer won the race for this (resource, role); re-fetch and
|
|
539
|
+
# decide whether to retry, merge, or surface a conflict to the caller.
|
|
540
|
+
retry_or_handle_conflict
|
|
541
|
+
end
|
|
542
|
+
```
|
|
543
|
+
|
|
477
544
|
## Priming Flow
|
|
478
545
|
|
|
479
546
|
```mermaid
|
data/Rakefile
CHANGED
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
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class PolymorphicJoinError < StandardError; end
|
|
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
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Card-side concern. Turns a polymorphic, role-discriminated model (the "card"
|
|
4
|
-
# model, e.g. Status) into
|
|
5
|
-
#
|
|
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.
|
|
6
10
|
#
|
|
7
11
|
# class Status < ApplicationRecord
|
|
8
12
|
# belongs_to :resource, polymorphic: true
|
|
@@ -50,6 +54,18 @@ module Poly::Stack
|
|
|
50
54
|
# remember it so the supersession edge can be linked once we have an id.
|
|
51
55
|
# Runs before insert: at INSERT time there is exactly one is_prime row, so
|
|
52
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.
|
|
53
69
|
def poly_stack_seize_prime
|
|
54
70
|
cols = self.class.poly_stack_columns
|
|
55
71
|
@poly_stack_superseded = self.class
|
data/lib/poly/version.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.3.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-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -16,28 +16,28 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '6.1'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '6.1'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: activesupport
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - ">="
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
33
|
+
version: '6.1'
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
40
|
+
version: '6.1'
|
|
41
41
|
description: Type-safe joins, role identity, owner stamping, and append-only golden-child
|
|
42
42
|
history (Poly::Stack) for polymorphic belongs_to associations.
|
|
43
43
|
email:
|