Module: ActiveRecordPolytypes
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/activerecord-polytypes.rb,
lib/activerecord-polytypes/version.rb
Overview
This documentation and code example is illustrative of how ActiveRecordPolytypes can be integrated into an ActiveRecord model to leverage polymorphism efficiently.
ActiveRecordPolytypes extends ActiveRecord models with the capability to efficiently fetch, query, and aggregate across collections of subtypes without the need for:
-
Creating wide tables with many nullable columns (Single Table Inheritance).
-
Performing separate queries for each subtype and aggregating or filtering in memory (Abstract Classes, Delegated Types).
-
Encoding type information into data, which can lead to data integrity issues (Delegated Types).
-
Repeating common column definitions and constraints across multiple tables (Abstract Classes).
-
Relying on database-specific features like Postgres’ table inheritance.
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.
# An entity, is either a user or an organisation.
# It is also a container for a collection of legal documents,
# and it has an associated billing plan.
class Entity
belongs_to :billing_plan
has_many :documents
end
class User < ApplicationRecord;end
class Organisation < ApplicationRecord;end
At times you may wish to fetch all entities, and their associated documents and billing plans but then vary in behaviour or processing, based on the subtype of entity.
What are our options?
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:
-
With each new type, our table becomes more and more bloated.
-
We leak rigid, code-specific type strings into our database, making it more difficult to shuffle types in the future.
-
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.
Use separate tables for each subtype. Make Entity an abstract class.
-
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)
-
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.
-
We have to repeat common column definitions and constraints across multiple tables, because subtypes no longer share a supertype table.
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.
-
We can query across all entities in a single hit (and also preload subtypes to load these reasonably efficiently)
-
We can allow User and Organisation to be delegated to (i.e. act as subtype to) more than one class.
-
We have a logical home for common column and constraints, and separate homes for subtype specific columns and constraints.
-
But:
-
We still have to perform separate queries to load subtype details when addressing a collection
-
-
We still have to perform aggregations or filters on subtype attributes in memory.
-
-
We leak rigid code-specific strings into our database, making it more difficult to shuffle types.
-
This is where ActiveRecordPolytypes provides an alternative mechanism. With ActiveRecordPolytypes you can easily query across all subtypes like this:
Entity::Subtype.where("user_created_at > ? OR organisation_country IN (?)", 1.day.ago, %(US)).order(billing_plan_renewal_date: :desc)
To e.g. fetch all entities that are either users created in the last day, or organisations in the US. and order by a common attribute:
=> [
#<Entity::User...
#<Entity::Organisation...
#<Entity::User...
#<Entity::User...
#<Entity::Organisation...
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. The instantiated subtypes also provide an interface that combines the interface of joined supertype + subtype. E.g. in the above, for each:
-
Entity::User object, you can access the full set of methods and attributes from both Entity and User.
-
Entity::Organisation object, you can access the full set of methods and attributes from both Entity and User.
You can even create or update supertype + subtype objects in a single hit. E.g.
Entity::User.create(
billing_plan_id: 3, # Entity attributes
user_name: # User attributes
)
Entity::User.find(1).update(
billing_plan_id: 3, # Entity attributes
user_name: # User attributes
)
You can also limit queries to specific subtypes when applicable E.g.
Limit to specific subtypes
Entity::User.where(user_name: "Bob")
Entity::Organisation.where(organisation_country: "US")
Vs query across all subtypes
Entity::Subtype.where(...)
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. It will work on any existing belongs_to
or has_one
association (respecting any non conventional foreign keys, class overrides etc.)
class Entity < ApplicationRecord
belongs_to :billing_plan
has_many :documents
belongs_to :user
belongs_to :organisation
polymorphic_supertype_of :user, :organisation
end
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 they might also act as SearchableItems within a search API. Inheriting from multiple supertypes is as easy as repeating the pattern above, per supertype. E.g.
class Searchable < ApplicationRecord
validates :searchable_index_string, presence: true
belongs_to :user
belongs_to :post
belongs_to :category
polymorphic_supertype_of :user, :post, :category
end
Searchable::Subtype.all # => [#<Searchable::User..., #<Searchable::Post.., #<Searchable::Category.., #<Searchable::User..]
Constant Summary collapse
- VERSION =
"0.1.3"
Class Method Summary collapse
-
.polymorphic_supertype_of(*associations) ⇒ Object
Sets up the ActiveRecord model as a polymorphic supertype of the specified associations.
Class Method Details
.polymorphic_supertype_of(*associations) ⇒ Object
Sets up the ActiveRecord model as a polymorphic supertype of the specified associations
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 |
# File 'lib/activerecord-polytypes.rb', line 138 class_methods do def polymorphic_supertype_of(*associations) associations = associations.map { |a| reflect_on_association(a) }.compact return unless associations.any? supertype_type = self # Remove any previously defined constant to avoid constant redefinition warnings. self.send(:remove_const, :Subtype) if self.constants.include?(:Subtype) # Define a new class inherited from the current class acting as the subtype. subtype_class = self.const_set(:Subtype, Class.new(self)) subtype_class.class_eval do attribute :type select_components_by_type = {} case_components_by_type = {} join_components_by_type = {} # Prepare SQL components to construct a query that joins subtypes and selects their attributes and type. associations.each do |association| base_type = association.compute_class(association.class_name) base_type.reflect_on_all_associations.each do |assoc| case assoc.macro when :belongs_to subtype_class.belongs_to :"#{association.name}_#{assoc.name}", assoc.scope, **assoc., class_name: assoc.class_name when :has_one, :has_many scope = if assoc..key?(:as) refined = ->{ where(assoc.type => base_type.name) } if assoc.scope ->{ instance_exec(&refined).instance_exec(&assoc.scope) } else refined end else assoc.scope end self.send(assoc.macro, :"#{association.name}_#{assoc.name}", scope, **assoc..except(:inverse_of, :destroy, :as), primary_key: "#{association.name}_#{base_type.primary_key}", foreign_key: assoc.foreign_key, class_name: "::#{assoc.class_name}") end end end associations.each do |association| base_type = association.compute_class(association.class_name) # Generate a class name for the subtype proxy. subtype_class_name = "#{supertype_type.name}::#{base_type.name}" # Dynamically create a proxy class for the multi-table inheritance. build_mti_proxy_class!(association, base_type, supertype_type, subtype_class) select_components_by_type[association.name] = base_type.columns.map do |column| column_name = "#{association.name}_#{column.name}" "#{association.table_name}.#{column.name} as #{column_name}" end.join(",") case_components_by_type[association.name] = "WHEN #{association.table_name}.#{association.join_primary_key} IS NOT NULL THEN '#{subtype_class_name}'" join_components_by_type[association.name] = if association.belongs_to? ["LEFT", association.table_name, "%s JOIN %s ON #{table_name}.#{association.foreign_key} = #{association.table_name}.#{association.join_primary_key}"] else ["LEFT", association.table_name, "%s JOIN %s ON #{table_name}.#{association.association_primary_key} = #{association.table_name}.#{association.join_primary_key}"] end end # Define a scope `with_subtypes` that enriches the base query with subtype information. scope :with_subtypes, ->(*typenames, **join_sources){ select_components, case_components, join_components = typenames.map do |typename| [ select_components_by_type[typename], case_components_by_type[typename], join_components_by_type[typename] ] end.transpose from(<<~SQL) ( SELECT #{table_name}.*,#{select_components * ","}, CASE #{case_components * " "} ELSE '#{name}' END AS type FROM #{table_name} #{typenames.map do |typename| join_type, join_source, join_string, = join_components_by_type[typename] join_string % join_sources.fetch(typename, [join_type, join_source]) end * " "} ) #{table_name} SQL } # Automatically apply `with_subtypes` scope to all queries if specified. default_scope -> { with_subtypes(*associations.map(&:name)) } end end # Dynamically builds a proxy class for a given association to handle multi-table inheritance. def build_mti_proxy_class!(association, base_type, supertype_type, subtype_class) # Remove any previously defined constant to avoid constant redefinition warnings. supertype_type.send(:remove_const, base_type.name) if supertype_type.constants.include?(base_type.name.to_sym) # Define a new class inherited from the current class acting as the subtype. subtype_class = supertype_type.const_set(base_type.name, Class.new(subtype_class)) subtype_class.class_eval do # Only include records of this subtype in the default scope. default_scope ->{ with_subtypes(association.name, **{ association.name => ["INNER", "#{association.table_name}"] }) } # Define callbacks and methods for initializing and saving the inner object. after_initialize :inner if association.belongs_to? before_save :save_inner_object_if_changed else after_save :save_inner_object_if_changed end after_save :reload_inner!, if: :previously_new_record? # Define attributes and delegation methods for columns inherited from the base type. base_type.reflect_on_all_associations.each do |assoc| case assoc.macro when :belongs_to belongs_to assoc.name, assoc.scope, **assoc., class_name: assoc.class_name when :has_one, :has_many scope = if assoc..key?(:as) refined = ->{ where(assoc.type => base_type.name) } if assoc.scope ->{ instance_exec(&refined).instance_exec(&assoc.scope) } else refined end else assoc.scope end self.send(assoc.macro, assoc.name, scope, **assoc..except(:inverse_of, :destroy, :as), primary_key: "#{association.name}_#{base_type.primary_key}", foreign_key: assoc.foreign_key, class_name: "::#{assoc.class_name}") end end base_type.enum_index&.each do |_, kwargs, _| kwargs = kwargs.dup enum_type = kwargs.keys.first enum_values = kwargs.delete(enum_type) namespaced_attribute = "#{association.name}_#{enum_type}" attribute namespaced_attribute, :integer kwargs.merge!(namespaced_attribute => enum_values) self.enum(**kwargs) end base_type.scope_index&.each do |args, kwargs, blk| self.scope(args[0], proc do |*scope_args| inner_scope = base_type.instance_exec(*scope_args, &args[1]).to_sql unscope(:from).with_subtypes(association.name, **{ association.name => ["INNER", "(#{inner_scope}) #{association.table_name}"] }) end) end base_type.columns.each do |column| column_name = "#{association.name}_#{column.name}" attribute column_name delegate column.name, to: :@inner, allow_nil: true, prefix: association.name define_method :"#{column_name}=" do |value| case when @inner then @inner.send(:"#{column.name}=", value) else (@assigned_attributes ||= {})[column.name] = value end end end # Provide a mechanism to handle methods not explicitly defined in the proxy class, delegating them to the @inner object if possible. def method_missing(m, *args, &block) if @inner.respond_to?(m) @inner.send(m, *args, &block) else super end end def inner @inner ||= initialize_inner_object end # Initialize the inner object based on the association's attributes or build a new association instance. define_method :initialize_inner_object do return if @inner # Prepare attributes for instantiation. @inner_attributes ||= base_type.columns.each_with_object({}) do |c, attrs| attrs[c.name.to_s] = self["#{association.name}_#{c.name}"] end # Instantiate or build the inner object based on current record state. if @assigned_attributes && @assigned_attributes[association.association_primary_key] @inner = base_type.instantiate(association.association_primary_key => @assigned_attributes[association.association_primary_key]) self.send(:"#{association.name}=", @inner) @inner.assign_attributes(@assigned_attributes) @assigned_attributes.each do |name, attribute| self["#{association.name}_#{name}"] = attribute end elsif !new_record? @inner = base_type.instantiate(@inner_attributes) else @inner = self.association(association.name).build(@assigned_attributes) end end # Override `as_json` to include attributes from both the outer and inner objects. define_method :as_json do |={}| only = base_type.column_names + ["type"] + ( || {}).fetch(:only,[]) outer = super(**( || {}), only:) @inner.as_json().merge(outer) end # Save the inner object if it has changed before saving the outer object. def save_inner_object_if_changed @inner.save if @inner.changed? || @inner.new_record? self.errors.merge!(@inner.errors) end # Check if an attribute exists in either the outer or inner object. def _has_attribute?(attribute) super || @inner._has_attribute?(attribute) end define_method :_assign_attribute do |name, value| inner.has_attribute?(name) ? inner.send(:_assign_attribute, name, value) : super(name, value) end define_method :update_column do |key, value| return super if self.class.column_names.include?(key.to_s) return inner.update_column(key, value) if inner.class.column_names.include?(key.to_s) key = key.to_s.gsub(%r{^#{association.name}_}, '') return inner.update_column(key.to_sym, value) if inner.class.column_names.include?(key) end define_method :reload_inner! do inner.reload if inner.persisted? # Update attributes from the reloaded inner object. base_type.columns.each_with_object({}) do |c, attrs| self["#{association.name}_#{c.name}"] = @inner[c.name.to_s] end end # Reload both the outer and inner objects to ensure consistency. define_method :reload do super() reload_inner! self end end end end |