activerecord-polytypes 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rubocop.yml +13 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/Rakefile +10 -0
- data/activerecord-polytypes.gemspec +38 -0
- data/lib/activerecord-polytypes/version.rb +5 -0
- data/lib/activerecord-polytypes.rb +298 -0
- metadata +186 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: de50052f2a84d18714886f41876bfe80b9d9b9b4e7fd426338c4029fad5b925b
|
4
|
+
data.tar.gz: d94bdc1f419a3f1aed49965f8c09fe36fabf833ffaf8dae2837a38d2cc760d2b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e3c518408bf522adf86567c0f4a6f9bc0696bcf385b89159c29678a51de2d9660494c4a69ace25049920a67bac957fee425396b53526df71bfe4458f36ada2d0
|
7
|
+
data.tar.gz: ce13ce16d5239f575a98b2acd29cdd50d69bf36001e481cef893a76fb96f01ba27b2e790095d0d2a2c4c6e991b19e1dc8dd59e0be599856bac6381f98ade7633
|
data/.rubocop.yml
ADDED
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
6
|
+
|
7
|
+
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
8
|
+
|
9
|
+
## Our Standards
|
10
|
+
|
11
|
+
Examples of behavior that contributes to a positive environment for our community include:
|
12
|
+
|
13
|
+
* Demonstrating empathy and kindness toward other people
|
14
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
15
|
+
* Giving and gracefully accepting constructive feedback
|
16
|
+
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
17
|
+
* Focusing on what is best not just for us as individuals, but for the overall community
|
18
|
+
|
19
|
+
Examples of unacceptable behavior include:
|
20
|
+
|
21
|
+
* The use of sexualized language or imagery, and sexual attention or
|
22
|
+
advances of any kind
|
23
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
24
|
+
* Public or private harassment
|
25
|
+
* Publishing others' private information, such as a physical or email
|
26
|
+
address, without their explicit permission
|
27
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
28
|
+
professional setting
|
29
|
+
|
30
|
+
## Enforcement Responsibilities
|
31
|
+
|
32
|
+
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
33
|
+
|
34
|
+
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
35
|
+
|
36
|
+
## Scope
|
37
|
+
|
38
|
+
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
39
|
+
|
40
|
+
## Enforcement
|
41
|
+
|
42
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at wouter.coppieters@employmenthero.com. All complaints will be reviewed and investigated promptly and fairly.
|
43
|
+
|
44
|
+
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
45
|
+
|
46
|
+
## Enforcement Guidelines
|
47
|
+
|
48
|
+
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
49
|
+
|
50
|
+
### 1. Correction
|
51
|
+
|
52
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
53
|
+
|
54
|
+
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
55
|
+
|
56
|
+
### 2. Warning
|
57
|
+
|
58
|
+
**Community Impact**: A violation through a single incident or series of actions.
|
59
|
+
|
60
|
+
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
61
|
+
|
62
|
+
### 3. Temporary Ban
|
63
|
+
|
64
|
+
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
65
|
+
|
66
|
+
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
67
|
+
|
68
|
+
### 4. Permanent Ban
|
69
|
+
|
70
|
+
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
71
|
+
|
72
|
+
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
73
|
+
|
74
|
+
## Attribution
|
75
|
+
|
76
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
77
|
+
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
78
|
+
|
79
|
+
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
80
|
+
|
81
|
+
[homepage]: https://www.contributor-covenant.org
|
82
|
+
|
83
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
84
|
+
https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Wouter Coppieters
|
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
|
13
|
+
all 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
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
# ActiveRecord Polytypes
|
2
|
+
|
3
|
+
ActiveRecord Polytypes adds features to ActiveRecord to combinine the best of multiple inheritance, multi-table inheritance, and polymorphic relationships, without the need for schema changes. It supports PostgreSQL, MySQL and SQLite.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- Efficient Polymorphic Queries: Load a polymorphic list of subtypes with a single query, eliminating the need for multiple database hits.
|
8
|
+
- Intuitive Query Capabilities: Leverage ordinary ActiveRecord queries to filter across supertype and subtype attributes in a single query.
|
9
|
+
- Schema-Friendly: Integrates into existing ActiveRecord relationships without requiring schema changes.
|
10
|
+
- Enjoy the benefits of STI, while avoiding wide, sparse tables
|
11
|
+
- Enjoy the benefits of abstract classes, without giving up the ability to query subtypes in a single collection
|
12
|
+
- Enjoy the benefits of delegated types, while supporting filtering, ordering and aggregating on subtype and supertype attributes simultaneously and without needing to infiltrate your data with typename strings.
|
13
|
+
|
14
|
+
## Anti-goals
|
15
|
+
|
16
|
+
- Creating wide tables with many nullable columns (Single Table Inheritance).
|
17
|
+
- Performing separate queries for each subtype and aggregating or filtering in memory (Abstract Classes, Delegated Types).
|
18
|
+
- Encoding type information into data, which can lead to data integrity issues (Delegated Types).
|
19
|
+
- Repeating common column definitions and constraints across multiple tables (Abstract Classes).
|
20
|
+
- Relying on database-specific features like Postgres' table inheritance.
|
21
|
+
|
22
|
+
Install the gem and add to the application's Gemfile by executing:
|
23
|
+
|
24
|
+
$ bundle add activerecord-polytypes
|
25
|
+
|
26
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
27
|
+
|
28
|
+
$ gem install activerecord-polytypes
|
29
|
+
|
30
|
+
## Usage
|
31
|
+
|
32
|
+
Imagine you have a simple Entity model that can be either a User or an Organisation, and it has associated legal documents and a billing plan.
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
# An entity, is either a user or an organisation.
|
36
|
+
# It is also a container for a collection of legal documents,
|
37
|
+
# and it has an associated billing plan.
|
38
|
+
class Entity
|
39
|
+
belongs_to :billing_plan
|
40
|
+
has_many :documents
|
41
|
+
end
|
42
|
+
|
43
|
+
class User < ApplicationRecord; end
|
44
|
+
class Organisation < ApplicationRecord; end
|
45
|
+
```
|
46
|
+
|
47
|
+
At times you may wish to fetch all entities, and their associated documents and billing plans
|
48
|
+
but then vary in behaviour or processing, based on the subtype of entity.
|
49
|
+
|
50
|
+
What are our options?
|
51
|
+
|
52
|
+
## 1. Use a wide table, with a 'type' column with many nullable columns to implement STI. Then add user and organisation specific columns to it. However:
|
53
|
+
|
54
|
+
- With each new type, our table becomes more and more bloated.
|
55
|
+
- We leak rigid, code-specific type strings into our database, making it more difficult to shuffle types in the future.
|
56
|
+
- Because we use native Ruby inheritance, User and Organisation are strictly coupled to Entity. I.e. they can't also be a subtype of another class.
|
57
|
+
|
58
|
+
## 2. Use separate tables for each subtype. Make Entity an abstract class.
|
59
|
+
|
60
|
+
- We now have lean tables, but lose the ability to query across all entities in a single hit (e.g. useful for any operations where we would like to treat subtypes as part of a uniform collection)
|
61
|
+
- Because we use native Ruby inheritance, User and Organisation are strictly coupled to Entity. I.e. they can't also be a subtype of another class.
|
62
|
+
- We have to repeat common column definitions and constraints across multiple tables, because subtypes no longer share a supertype table.
|
63
|
+
|
64
|
+
## 3. Use delegated types, so that we can store superclass specific info on the Entity class, and subclass specific info on the User and Organisation classes.
|
65
|
+
|
66
|
+
- We can query across all entities in a single hit (and also preload subtypes to load these reasonably efficiently)
|
67
|
+
- We can allow User and Organisation to be delegated to (i.e. act as subtype to) more than one class.
|
68
|
+
- We have a logical home for common column and constraints, and separate homes for subtype specific columns and constraints.
|
69
|
+
- But:
|
70
|
+
- - We still have to perform separate queries to load subtype details when addressing a collection
|
71
|
+
- - We still have to perform aggregations or filters on subtype attributes in memory.
|
72
|
+
- - We leak rigid code-specific strings into our database, making it more difficult to shuffle types.
|
73
|
+
|
74
|
+
# This is where ActiveRecordPolytypes provides an alternative mechanism.
|
75
|
+
|
76
|
+
# With ActiveRecordPolytypes you can easily query across all subtypes like this:
|
77
|
+
|
78
|
+
```ruby
|
79
|
+
Entity::Subtype.where("user_created_at > ? OR organisation_country IN (?)", 1.day.ago, %(US)).order(billing_plan_renewal_date: :desc)
|
80
|
+
```
|
81
|
+
|
82
|
+
To e.g. fetch all entities that are either users created in the last day, or organisations in the US.
|
83
|
+
and order by a common attribute:
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
=> [
|
87
|
+
#<Entity::User...
|
88
|
+
#<Entity::Organisation...
|
89
|
+
#<Entity::User...
|
90
|
+
#<Entity::User...
|
91
|
+
#<Entity::Organisation...
|
92
|
+
```
|
93
|
+
|
94
|
+
It constructs the needed joins so that you can query across all subtypes in a single hit, performing aggregations and filters on subtype attributes, directly in the database.
|
95
|
+
The instantiated subtypes also provide an interface that combines supertype + subtype.
|
96
|
+
E.g. in the above, for each:
|
97
|
+
|
98
|
+
- _Entity::User_ object, you can access the full set of methods and attributes from both Entity and User.
|
99
|
+
- _Entity::Organisation_ object, you can access the full set of methods and attributes from both Entity and User.
|
100
|
+
|
101
|
+
You can also create or update supertype + subtype objects in a single hit.
|
102
|
+
E.g.
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
Entity::User.create(
|
106
|
+
billing_plan_id: 3, # Entity attributes
|
107
|
+
user_name: # User attributes
|
108
|
+
)
|
109
|
+
Entity::User.find(1).update(
|
110
|
+
billing_plan_id: 3, # Entity attributes
|
111
|
+
user_name: # User attributes
|
112
|
+
)
|
113
|
+
```
|
114
|
+
|
115
|
+
You can also limit queries (which limit the joined tables) to specific subtypes when applicable.
|
116
|
+
E.g.
|
117
|
+
Limit to specific subtypes
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
Entity::User.where(user_name: "Bob")
|
121
|
+
Entity::Organisation.where(organisation_country: "US")
|
122
|
+
```
|
123
|
+
|
124
|
+
Vs query across all subtypes
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
Entity::Subtype.where(...)
|
128
|
+
```
|
129
|
+
|
130
|
+
All you need to do to install ActiveRecordPolytypes into an existing model, is make a call to `polymorphic_supertype_of` in the model, and pass in the names of the associations that you want to act as subtypes.
|
131
|
+
It will work on any existing `belongs_to` or `has_one` association (respecting any non conventional foreign keys, class overrides etc.)
|
132
|
+
|
133
|
+
```ruby
|
134
|
+
class Entity < ApplicationRecord
|
135
|
+
belongs_to :billing_plan
|
136
|
+
has_many :documents
|
137
|
+
belongs_to :user
|
138
|
+
belongs_to :organisation
|
139
|
+
polymorphic_supertype_of :user, :organisation
|
140
|
+
end
|
141
|
+
```
|
142
|
+
|
143
|
+
An entity can act as a subtype to any number of supertypes, so e.g. while Users and Organisations might act as Entities, within a billing context
|
144
|
+
they might also act as SearchableItems within a search API. Inheriting from multiple supertypes is as easy as repeating the pattern above, per supertype.
|
145
|
+
E.g.
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
class Searchable < ApplicationRecord
|
149
|
+
validates :searchable_index_string, presence: true
|
150
|
+
belongs_to :user
|
151
|
+
belongs_to :post
|
152
|
+
belongs_to :category
|
153
|
+
polymorphic_supertype_of :user, :post, :category
|
154
|
+
end
|
155
|
+
|
156
|
+
Searchable::Subtype.all # => [#<Searchable::User..., #<Searchable::Post.., #<Searchable::Category.., #<Searchable::User..]
|
157
|
+
```
|
158
|
+
|
159
|
+
## Development
|
160
|
+
|
161
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
162
|
+
|
163
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
164
|
+
|
165
|
+
## Contributing
|
166
|
+
|
167
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/wouterken/activerecord-polytypes. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/wouterken/activerecord-polytypes/blob/master/CODE_OF_CONDUCT.md).
|
168
|
+
|
169
|
+
## License
|
170
|
+
|
171
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
172
|
+
|
173
|
+
## Code of Conduct
|
174
|
+
|
175
|
+
Everyone interacting in the Activerecord::Mti project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/wouterken/activerecord-polytypes/blob/master/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/activerecord-polytypes/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "activerecord-polytypes"
|
7
|
+
spec.version = ActiveRecordPolytypes::VERSION
|
8
|
+
spec.authors = ["Wouter Coppieters"]
|
9
|
+
spec.email = ["wc@pico.net.nz"]
|
10
|
+
|
11
|
+
spec.summary = "Enable ActiveRecord models to act like Polymorphic supertypes."
|
12
|
+
spec.description = "This gem provides an extension to ActiveRecord, enabling efficient Multi-Table, Multiple-Inheritance for ActiveRecord models."
|
13
|
+
spec.homepage = "https://github.com/wouterken/activerecord-polytypes"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = ">= 3.0.0"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/wouterken/activerecord-polytypes"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/wouterken/activerecord-polytypes"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(__dir__) do
|
24
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
25
|
+
(File.expand_path(f) == __FILE__) ||
|
26
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
|
27
|
+
end
|
28
|
+
end
|
29
|
+
spec.require_paths = ["lib"]
|
30
|
+
spec.add_dependency "activerecord", "~>7", ">7"
|
31
|
+
spec.add_development_dependency "minitest", "~>5.16"
|
32
|
+
spec.add_development_dependency "minitest-around", "0.4.1"
|
33
|
+
spec.add_development_dependency "minitest-reporters", "~> 1.1", ">= 1.1.0"
|
34
|
+
spec.add_development_dependency "mysql2", "~> 0.5"
|
35
|
+
spec.add_development_dependency "pg", "~> 1", "> 1.0"
|
36
|
+
spec.add_development_dependency "pry-byebug", "~> 3.0"
|
37
|
+
spec.add_development_dependency "sqlite3", "~> 1.3"
|
38
|
+
end
|
@@ -0,0 +1,298 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "activerecord-polytypes/version"
|
4
|
+
|
5
|
+
# ActiveRecordPolytypes extends ActiveRecord models with the capability to efficiently fetch, query, and aggregate across collections of subtypes without the need for:
|
6
|
+
# - Creating wide tables with many nullable columns (Single Table Inheritance).
|
7
|
+
# - Performing separate queries for each subtype and aggregating or filtering in memory (Abstract Classes, Delegated Types).
|
8
|
+
# - Encoding type information into data, which can lead to data integrity issues (Delegated Types).
|
9
|
+
# - Repeating common column definitions and constraints across multiple tables (Abstract Classes).
|
10
|
+
# - Relying on database-specific features like Postgres' table inheritance.
|
11
|
+
#
|
12
|
+
# Imagine you have a simple Entity model that can represent either a User or an Organisation, and it has associated legal documents and a billing plan.
|
13
|
+
#
|
14
|
+
# # An entity, is either a user or an organisation.
|
15
|
+
# # It is also a container for a collection of legal documents,
|
16
|
+
# # and it has an associated billing plan.
|
17
|
+
# class Entity
|
18
|
+
# belongs_to :billing_plan
|
19
|
+
# has_many :documents
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# class User < ApplicationRecord;end
|
23
|
+
# class Organisation < ApplicationRecord;end
|
24
|
+
#
|
25
|
+
# At times you may wish to fetch all entities, and their associated documents and billing plans
|
26
|
+
# but then vary in behaviour or processing, based on the subtype of entity.
|
27
|
+
#
|
28
|
+
# What are our options?
|
29
|
+
#
|
30
|
+
# == Use a wide table, with a 'type' column with many nullable columns to implement STI. Then add user and organisation specific columns to it. However:
|
31
|
+
# * With each new type, our table becomes more and more bloated.
|
32
|
+
# * We leak rigid, code-specific type strings into our database, making it more difficult to shuffle types in the future.
|
33
|
+
# * Because we use native Ruby inheritance, User and Organisation are strictly coupled to Entity. I.e. they can't also be a subtype of another class.
|
34
|
+
#
|
35
|
+
# == Use separate tables for each subtype. Make Entity an abstract class.
|
36
|
+
# * We now have lean tables, but lose the ability to query across all entities in a single hit (e.g. useful for any operations where we would like to treat subtypes as part of a uniform collection)
|
37
|
+
# * Because we use native Ruby inheritance, User and Organisation are strictly coupled to Entity. I.e. they can't also be a subtype of another class.
|
38
|
+
# * We have to repeat common column definitions and constraints across multiple tables, because subtypes no longer share a supertype table.
|
39
|
+
#
|
40
|
+
# == Use delegated types, so that we can store superclass specific info on the Entity class, and subclass specific info on the User and Organisation classes.
|
41
|
+
# * We can query across all entities in a single hit (and also preload subtypes to load these reasonably efficiently)
|
42
|
+
# * We can allow User and Organisation to be delegated to (i.e. act as subtype to) more than one class.
|
43
|
+
# * We have a logical home for common column and constraints, and separate homes for subtype specific columns and constraints.
|
44
|
+
# * But:
|
45
|
+
# * * We still have to perform separate queries to load subtype details when addressing a collection
|
46
|
+
# * * We still have to perform aggregations or filters on subtype attributes in memory.
|
47
|
+
# * * We leak rigid code-specific strings into our database, making it more difficult to shuffle types.
|
48
|
+
#
|
49
|
+
# This is where ActiveRecordPolytypes provides an alternative mechanism.
|
50
|
+
# With ActiveRecordPolytypes you can easily query across all subtypes like this:
|
51
|
+
#
|
52
|
+
# Entity::Subtype.where("user_created_at > ? OR organisation_country IN (?)", 1.day.ago, %(US)).order(billing_plan_renewal_date: :desc)
|
53
|
+
# To e.g. fetch all entities that are either users created in the last day, or organisations in the US.
|
54
|
+
# and order by a common attribute:
|
55
|
+
# => [
|
56
|
+
# #<Entity::User...
|
57
|
+
# #<Entity::Organisation...
|
58
|
+
# #<Entity::User...
|
59
|
+
# #<Entity::User...
|
60
|
+
# #<Entity::Organisation...
|
61
|
+
#
|
62
|
+
# It constructs the needed joins so that you can query across all subtypes in a single hit, performing aggregations and filters on subtype attributes, directly in the database.
|
63
|
+
# The instantiated subtypes also provide an interface that combines the interface of joined supertype + subtype.
|
64
|
+
# E.g. in the above, for each:
|
65
|
+
# * *Entity::User* object, you can access the full set of methods and attributes from both Entity and User.
|
66
|
+
# * *Entity::Organisation* object, you can access the full set of methods and attributes from both Entity and User.
|
67
|
+
#
|
68
|
+
# You can even create or update supertype + subtype objects in a single hit.
|
69
|
+
# E.g.
|
70
|
+
#
|
71
|
+
# Entity::User.create(
|
72
|
+
# billing_plan_id: 3, # Entity attributes
|
73
|
+
# user_name: # User attributes
|
74
|
+
# )
|
75
|
+
#
|
76
|
+
# Entity::User.find(1).update(
|
77
|
+
# billing_plan_id: 3, # Entity attributes
|
78
|
+
# user_name: # User attributes
|
79
|
+
# )
|
80
|
+
#
|
81
|
+
# You can also limit queries to specific subtypes when applicable
|
82
|
+
# E.g.
|
83
|
+
#
|
84
|
+
# Limit to specific subtypes
|
85
|
+
#
|
86
|
+
# Entity::User.where(user_name: "Bob")
|
87
|
+
# Entity::Organisation.where(organisation_country: "US")
|
88
|
+
#
|
89
|
+
# Vs query across all subtypes
|
90
|
+
#
|
91
|
+
# Entity::Subtype.where(...)
|
92
|
+
#
|
93
|
+
#
|
94
|
+
# All you need to do to install ActiveRecordPolytypes into an existing model, is make a call to <tt>polymorphic_supertype_of</tt> in the model, and pass in the names of the associations that you want to act as subtypes.
|
95
|
+
# It will work on any existing <tt>belongs_to</tt> or <tt>has_one</tt> association (respecting any non conventional foreign keys, class overrides etc.)
|
96
|
+
#
|
97
|
+
# class Entity < ApplicationRecord
|
98
|
+
# belongs_to :billing_plan
|
99
|
+
# has_many :documents
|
100
|
+
# belongs_to :user
|
101
|
+
# belongs_to :organisation
|
102
|
+
# polymorphic_supertype_of :user, :organisation
|
103
|
+
# end
|
104
|
+
#
|
105
|
+
# An entity can act as a subtype to any number of supertypes, so e.g. while Users and Organisations might act as Entities, within a billing context
|
106
|
+
# they might also act as SearchableItems within a search API. Inheriting from multiple supertypes is as easy as repeating the pattern above, per supertype.
|
107
|
+
# E.g.
|
108
|
+
#
|
109
|
+
# class Searchable < ApplicationRecord
|
110
|
+
# validates :searchable_index_string, presence: true
|
111
|
+
# belongs_to :user
|
112
|
+
# belongs_to :post
|
113
|
+
# belongs_to :category
|
114
|
+
# polymorphic_supertype_of :user, :post, :category
|
115
|
+
# end
|
116
|
+
#
|
117
|
+
# Searchable::Subtype.all # => [#<Searchable::User..., #<Searchable::Post.., #<Searchable::Category.., #<Searchable::User..]
|
118
|
+
#
|
119
|
+
# @note This documentation and code example is illustrative of how ActiveRecordPolytypes can be integrated into an ActiveRecord model to leverage polymorphism efficiently.
|
120
|
+
module ActiveRecordPolytypes
|
121
|
+
extend ActiveSupport::Concern
|
122
|
+
|
123
|
+
|
124
|
+
# @!method polymorphic_supertype_of(*associations)
|
125
|
+
# Sets up the ActiveRecord model as a polymorphic supertype of the specified associations
|
126
|
+
# @!scope class
|
127
|
+
# @param associations [Array<Symbol>] the names of the associations that should act as subtypes.
|
128
|
+
# These associations are expected to be either `:belongs_to` or `:has_one` associations.
|
129
|
+
#
|
130
|
+
# @example Adding polymorphic supertype to an Entity model
|
131
|
+
# class Entity < ApplicationRecord
|
132
|
+
# belongs_to :billing_plan
|
133
|
+
# has_many :documents
|
134
|
+
# belongs_to :user
|
135
|
+
# belongs_to :organisation
|
136
|
+
# polymorphic_supertype_of :user, :organisation
|
137
|
+
# end
|
138
|
+
class_methods do
|
139
|
+
|
140
|
+
def polymorphic_supertype_of(*associations)
|
141
|
+
associations = associations.map { |a| reflect_on_association(a) }.compact
|
142
|
+
return unless associations.any?
|
143
|
+
|
144
|
+
supertype_type = self
|
145
|
+
# Remove any previously defined constant to avoid constant redefinition warnings.
|
146
|
+
self.send(:remove_const, :Subtype) if self.constants.include?(:Subtype)
|
147
|
+
|
148
|
+
# Define a new class inherited from the current class acting as the subtype.
|
149
|
+
subtype_class = self.const_set(:Subtype, Class.new(self))
|
150
|
+
subtype_class.class_eval do
|
151
|
+
|
152
|
+
attribute :type
|
153
|
+
|
154
|
+
select_components_by_type = {}
|
155
|
+
case_components_by_type = {}
|
156
|
+
join_components_by_type = {}
|
157
|
+
|
158
|
+
# Prepare SQL components to construct a query that joins subtypes and selects their attributes and type.
|
159
|
+
associations.each do |association|
|
160
|
+
base_type = association.compute_class(association.class_name)
|
161
|
+
|
162
|
+
# Generate a class name for the subtype proxy.
|
163
|
+
subtype_class_name = "#{supertype_type.name}::#{base_type.name}"
|
164
|
+
# Dynamically create a proxy class for the multi-table inheritance.
|
165
|
+
build_mti_proxy_class!(association, base_type, supertype_type)
|
166
|
+
|
167
|
+
select_components_by_type[association.name] = base_type.columns.map do |column|
|
168
|
+
column_name = "#{association.name}_#{column.name}"
|
169
|
+
"#{association.table_name}.#{column.name} as #{column_name}"
|
170
|
+
end.join(",")
|
171
|
+
|
172
|
+
case_components_by_type[association.name] = "WHEN #{association.table_name}.#{association.join_primary_key} IS NOT NULL THEN '#{subtype_class_name}'"
|
173
|
+
join_components_by_type[association.name] = if association.belongs_to?
|
174
|
+
"LEFT JOIN #{association.table_name} ON #{table_name}.#{association.foreign_key} = #{association.table_name}.#{association.join_primary_key}"
|
175
|
+
else
|
176
|
+
"LEFT JOIN #{association.table_name} ON #{table_name}.#{association.association_primary_key} = #{association.table_name}.#{association.join_primary_key}"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Define a scope `with_subtypes` that enriches the base query with subtype information.
|
181
|
+
scope :with_subtypes, ->(*typenames){
|
182
|
+
select_components, case_components, join_components = typenames.map do |typename|
|
183
|
+
[
|
184
|
+
select_components_by_type[typename],
|
185
|
+
case_components_by_type[typename],
|
186
|
+
join_components_by_type[typename]
|
187
|
+
]
|
188
|
+
end.transpose
|
189
|
+
from(<<~SQL)
|
190
|
+
(
|
191
|
+
SELECT #{table_name}.*,#{select_components * ","}, CASE #{case_components * " "} ELSE '#{name}' END AS type
|
192
|
+
FROM #{table_name} #{join_components * " "}
|
193
|
+
) #{table_name}
|
194
|
+
SQL
|
195
|
+
}
|
196
|
+
|
197
|
+
# Automatically apply `with_subtypes` scope to all queries if specified.
|
198
|
+
default_scope -> { with_subtypes(*associations.map(&:name)) }
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
# Dynamically builds a proxy class for a given association to handle multi-table inheritance.
|
203
|
+
def build_mti_proxy_class!(association, base_type, supertype_type)
|
204
|
+
# Remove any previously defined constant to avoid constant redefinition warnings.
|
205
|
+
supertype_type.send(:remove_const, base_type.name) if supertype_type.constants.include?(base_type.name.to_sym)
|
206
|
+
|
207
|
+
# Define a new class inherited from the current class acting as the subtype.
|
208
|
+
subtype_class = supertype_type.const_set(base_type.name, Class.new(self))
|
209
|
+
subtype_class.class_eval do
|
210
|
+
attr_reader :inner
|
211
|
+
|
212
|
+
# Only include records of this subtype in the default scope.
|
213
|
+
default_scope ->{ with_subtypes(association.name) }
|
214
|
+
# Define callbacks and methods for initializing and saving the inner object.
|
215
|
+
after_initialize :initialize_inner_object
|
216
|
+
before_save :save_inner_object_if_changed
|
217
|
+
after_save :reload, if: :previously_new_record?
|
218
|
+
|
219
|
+
# Define attributes and delegation methods for columns inherited from the base type.
|
220
|
+
base_type.columns.each do |column|
|
221
|
+
column_name = "#{association.name}_#{column.name}"
|
222
|
+
attribute column_name, column.type
|
223
|
+
delegate column.name, to: :@inner, allow_nil: true, prefix: association.name
|
224
|
+
define_method :"#{column_name}=" do |value|
|
225
|
+
case
|
226
|
+
when @inner then @inner.send(:"#{column.name}=", value)
|
227
|
+
else
|
228
|
+
(@assigned_attributes ||= {})[column.name] = value
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Provide a mechanism to handle methods not explicitly defined in the proxy class, delegating them to the @inner object if possible.
|
234
|
+
def method_missing(m, *args, &block)
|
235
|
+
if @inner.respond_to?(m)
|
236
|
+
@inner.send(m, *args, &block)
|
237
|
+
else
|
238
|
+
super
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# Initialize the inner object based on the association's attributes or build a new association instance.
|
243
|
+
define_method :initialize_inner_object do
|
244
|
+
# Prepare attributes for instantiation.
|
245
|
+
@inner_attributes ||= base_type.columns.each_with_object({}) do |c, attrs|
|
246
|
+
attrs[c.name.to_s] = self["#{association.name}_#{c.name}"]
|
247
|
+
end
|
248
|
+
# Instantiate or build the inner object based on current record state.
|
249
|
+
if @assigned_attributes && @assigned_attributes[association.association_primary_key]
|
250
|
+
@inner = base_type.instantiate(association.association_primary_key => @assigned_attributes[association.association_primary_key])
|
251
|
+
self.send(:"#{association.name}=", @inner)
|
252
|
+
@inner.assign_attributes(@assigned_attributes)
|
253
|
+
@assigned_attributes.each do |name, attribute|
|
254
|
+
self["#{association.name}_#{name}"] = attribute
|
255
|
+
end
|
256
|
+
elsif !new_record?
|
257
|
+
@inner = base_type.instantiate(@inner_attributes)
|
258
|
+
else
|
259
|
+
@inner = self.association(association.name).build(@assigned_attributes)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
# Override `as_json` to include attributes from both the outer and inner objects.
|
264
|
+
define_method :as_json do |options={}|
|
265
|
+
only = base_type.column_names + ["type"] + (options || {}).fetch(:only,[])
|
266
|
+
outer = super(**(options || {}), only: )
|
267
|
+
@inner.as_json(options).merge(outer)
|
268
|
+
end
|
269
|
+
|
270
|
+
# Save the inner object if it has changed before saving the outer object.
|
271
|
+
def save_inner_object_if_changed
|
272
|
+
@inner.save if @inner.changed?
|
273
|
+
end
|
274
|
+
|
275
|
+
# Check if an attribute exists in either the outer or inner object.
|
276
|
+
def _has_attribute?(attribute)
|
277
|
+
super || @inner._has_attribute?(attribute)
|
278
|
+
end
|
279
|
+
|
280
|
+
# Reload both the outer and inner objects to ensure consistency.
|
281
|
+
define_method :reload do
|
282
|
+
super()
|
283
|
+
@inner.reload
|
284
|
+
# Update attributes from the reloaded inner object.
|
285
|
+
base_type.columns.each_with_object({}) do |c, attrs|
|
286
|
+
self["#{association.name}_#{c.name}"] = @inner[c.name.to_s]
|
287
|
+
end
|
288
|
+
self
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
# Hook into ActiveSupport's on_load mechanism to automatically include this functionality into ActiveRecord.
|
296
|
+
ActiveSupport.on_load(:active_record) do
|
297
|
+
include ActiveRecordPolytypes
|
298
|
+
end
|
metadata
ADDED
@@ -0,0 +1,186 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activerecord-polytypes
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Wouter Coppieters
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-03-15 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'
|
20
|
+
- - ">"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '7'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '7'
|
30
|
+
- - ">"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '7'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: minitest
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '5.16'
|
40
|
+
type: :development
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '5.16'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: minitest-around
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - '='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 0.4.1
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - '='
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: 0.4.1
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: minitest-reporters
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '1.1'
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: 1.1.0
|
71
|
+
type: :development
|
72
|
+
prerelease: false
|
73
|
+
version_requirements: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - "~>"
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '1.1'
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: 1.1.0
|
81
|
+
- !ruby/object:Gem::Dependency
|
82
|
+
name: mysql2
|
83
|
+
requirement: !ruby/object:Gem::Requirement
|
84
|
+
requirements:
|
85
|
+
- - "~>"
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: '0.5'
|
88
|
+
type: :development
|
89
|
+
prerelease: false
|
90
|
+
version_requirements: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - "~>"
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '0.5'
|
95
|
+
- !ruby/object:Gem::Dependency
|
96
|
+
name: pg
|
97
|
+
requirement: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - "~>"
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '1'
|
102
|
+
- - ">"
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '1.0'
|
105
|
+
type: :development
|
106
|
+
prerelease: false
|
107
|
+
version_requirements: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - "~>"
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '1'
|
112
|
+
- - ">"
|
113
|
+
- !ruby/object:Gem::Version
|
114
|
+
version: '1.0'
|
115
|
+
- !ruby/object:Gem::Dependency
|
116
|
+
name: pry-byebug
|
117
|
+
requirement: !ruby/object:Gem::Requirement
|
118
|
+
requirements:
|
119
|
+
- - "~>"
|
120
|
+
- !ruby/object:Gem::Version
|
121
|
+
version: '3.0'
|
122
|
+
type: :development
|
123
|
+
prerelease: false
|
124
|
+
version_requirements: !ruby/object:Gem::Requirement
|
125
|
+
requirements:
|
126
|
+
- - "~>"
|
127
|
+
- !ruby/object:Gem::Version
|
128
|
+
version: '3.0'
|
129
|
+
- !ruby/object:Gem::Dependency
|
130
|
+
name: sqlite3
|
131
|
+
requirement: !ruby/object:Gem::Requirement
|
132
|
+
requirements:
|
133
|
+
- - "~>"
|
134
|
+
- !ruby/object:Gem::Version
|
135
|
+
version: '1.3'
|
136
|
+
type: :development
|
137
|
+
prerelease: false
|
138
|
+
version_requirements: !ruby/object:Gem::Requirement
|
139
|
+
requirements:
|
140
|
+
- - "~>"
|
141
|
+
- !ruby/object:Gem::Version
|
142
|
+
version: '1.3'
|
143
|
+
description: This gem provides an extension to ActiveRecord, enabling efficient Multi-Table,
|
144
|
+
Multiple-Inheritance for ActiveRecord models.
|
145
|
+
email:
|
146
|
+
- wc@pico.net.nz
|
147
|
+
executables: []
|
148
|
+
extensions: []
|
149
|
+
extra_rdoc_files: []
|
150
|
+
files:
|
151
|
+
- ".rubocop.yml"
|
152
|
+
- CHANGELOG.md
|
153
|
+
- CODE_OF_CONDUCT.md
|
154
|
+
- LICENSE.txt
|
155
|
+
- README.md
|
156
|
+
- Rakefile
|
157
|
+
- activerecord-polytypes.gemspec
|
158
|
+
- lib/activerecord-polytypes.rb
|
159
|
+
- lib/activerecord-polytypes/version.rb
|
160
|
+
homepage: https://github.com/wouterken/activerecord-polytypes
|
161
|
+
licenses:
|
162
|
+
- MIT
|
163
|
+
metadata:
|
164
|
+
homepage_uri: https://github.com/wouterken/activerecord-polytypes
|
165
|
+
source_code_uri: https://github.com/wouterken/activerecord-polytypes
|
166
|
+
changelog_uri: https://github.com/wouterken/activerecord-polytypes
|
167
|
+
post_install_message:
|
168
|
+
rdoc_options: []
|
169
|
+
require_paths:
|
170
|
+
- lib
|
171
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
172
|
+
requirements:
|
173
|
+
- - ">="
|
174
|
+
- !ruby/object:Gem::Version
|
175
|
+
version: 3.0.0
|
176
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
181
|
+
requirements: []
|
182
|
+
rubygems_version: 3.5.6
|
183
|
+
signing_key:
|
184
|
+
specification_version: 4
|
185
|
+
summary: Enable ActiveRecord models to act like Polymorphic supertypes.
|
186
|
+
test_files: []
|