activerecord-polytypes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,13 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Layout/LineLength:
13
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## [0.1.0] - 2024-03-15
2
+
3
+ - Initial release
@@ -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,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/activerecord-polytypes/*test.rb"]
8
+ end
9
+
10
+ task default: :test
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordPolytypes
4
+ VERSION = "0.1.0"
5
+ 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: []