diff --git a/.travis.yml b/.travis.yml index 5b76dd6a4..eec9e1882 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,10 @@ env: - RAILS=5-2-stable DB=mysql - RAILS=5-2-stable DB=postgres + - RAILS=v5.2.0 DB=sqlite3 + - RAILS=v5.2.0 DB=mysql + - RAILS=v5.2.0 DB=postgres + - RAILS=5-0-stable DB=sqlite3 - RAILS=5-0-stable DB=mysql - RAILS=5-0-stable DB=postgres @@ -18,7 +22,11 @@ env: - RAILS=4-2-stable DB=sqlite3 - RAILS=4-2-stable DB=mysql - RAILS=4-2-stable DB=postgres - +matrix: + allow_failures: + - env: RAILS=5-2-stable DB=sqlite3 + - env: RAILS=5-2-stable DB=mysql + - env: RAILS=5-2-stable DB=postgres before_script: - mysql -e 'create database ransack collate utf8_general_ci;' - mysql -e 'use ransack;show variables like "%character%";show variables like "%collation%";' diff --git a/Gemfile b/Gemfile index 74094b100..21d60359b 100644 --- a/Gemfile +++ b/Gemfile @@ -5,12 +5,6 @@ gem 'rake' rails = ENV['RAILS'] || '5-0-stable' -if rails == 'master' - gem 'polyamorous', github: 'activerecord-hackery/polyamorous' -else - gem 'polyamorous', '~> 1.3' -end - gem 'pry' # Provide timezone information on Windows @@ -28,6 +22,9 @@ when /^v/ # A tagged version gem 'activerecord', require: false gem 'actionpack' end + if rails == 'v5.2.0' + gem 'mysql2', '~> 0.4.4' + end else git 'git://github.com/rails/rails.git', :branch => rails do gem 'activesupport' diff --git a/lib/polyamorous.rb b/lib/polyamorous.rb new file mode 100644 index 000000000..82841ff82 --- /dev/null +++ b/lib/polyamorous.rb @@ -0,0 +1,55 @@ +if defined?(::ActiveRecord) + module Polyamorous + if defined?(Arel::InnerJoin) + InnerJoin = Arel::InnerJoin + OuterJoin = Arel::OuterJoin + else + InnerJoin = Arel::Nodes::InnerJoin + OuterJoin = Arel::Nodes::OuterJoin + end + + if defined?(::ActiveRecord::Associations::JoinDependency) + JoinDependency = ::ActiveRecord::Associations::JoinDependency + JoinAssociation = ::ActiveRecord::Associations::JoinDependency::JoinAssociation + JoinBase = ::ActiveRecord::Associations::JoinDependency::JoinBase + else + JoinDependency = ::ActiveRecord::Associations::ClassMethods::JoinDependency + JoinAssociation = ::ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation + JoinBase = ::ActiveRecord::Associations::ClassMethods::JoinDependency::JoinBase + end + end + + require 'polyamorous/tree_node' + require 'polyamorous/join' + require 'polyamorous/swapping_reflection_class' + + ar_version = ::ActiveRecord::VERSION::STRING[0,3] + ar_version = '3_and_4.0' if ar_version < '4.1' + ar_version = ::ActiveRecord::VERSION::STRING[0,5] if ar_version == '5.2' + + method, ruby_version = + if RUBY_VERSION >= '2.0' && ar_version >= '4.1' + # Ruby 2; we can use `prepend` to patch Active Record cleanly. + [:prepend, '2'] + else + # Ruby 1.9; we must use `alias_method` to patch Active Record. + [:include, '1.9'] + end + + %w(join_association join_dependency).each do |file| + require "polyamorous/activerecord_#{ar_version}_ruby_#{ruby_version}/#{file}" + end + + Polyamorous::JoinDependency.send(method, Polyamorous::JoinDependencyExtensions) + if method == :prepend + Polyamorous::JoinDependency.singleton_class + .send(:prepend, Polyamorous::JoinDependencyExtensions::ClassMethods) + end + Polyamorous::JoinAssociation.send(method, Polyamorous::JoinAssociationExtensions) + + Polyamorous::JoinBase.class_eval do + if method_defined?(:active_record) + alias_method :base_klass, :active_record + end + end +end diff --git a/lib/polyamorous/activerecord_3_and_4.0_ruby_1.9/join_association.rb b/lib/polyamorous/activerecord_3_and_4.0_ruby_1.9/join_association.rb new file mode 100644 index 000000000..3b4c303d7 --- /dev/null +++ b/lib/polyamorous/activerecord_3_and_4.0_ruby_1.9/join_association.rb @@ -0,0 +1,76 @@ +# active_record_3_and_4.0_ruby_1.9/join_association.rb +module Polyamorous + module JoinAssociationExtensions + include SwappingReflectionClass + def self.included(base) + base.class_eval do + alias_method_chain :initialize, :polymorphism + alias_method :equality_without_polymorphism, :== + alias_method :==, :equality_with_polymorphism + if base.method_defined?(:active_record) + alias_method :base_klass, :active_record + end + + if ActiveRecord::VERSION::STRING =~ /^3\.0\./ + alias_method_chain :association_join, :polymorphism + else + alias_method_chain :build_constraint, :polymorphism + end + end + end + + def initialize_with_polymorphism( + reflection, join_dependency, parent = nil, polymorphic_class = nil + ) + if polymorphic_class && ::ActiveRecord::Base > polymorphic_class + swapping_reflection_klass(reflection, polymorphic_class) do |reflection| + initialize_without_polymorphism(reflection, join_dependency, parent) + self.reflection.options[:polymorphic] = true + end + else + initialize_without_polymorphism(reflection, join_dependency, parent) + end + end + + def equality_with_polymorphism(other) + equality_without_polymorphism(other) && base_klass == other.base_klass + end + + def build_constraint_with_polymorphism( + reflection, table, key, foreign_table, foreign_key + ) + if reflection.options[:polymorphic] + build_constraint_without_polymorphism( + reflection, table, key, foreign_table, foreign_key + ) + .and(foreign_table[reflection.foreign_type].eq(reflection.klass.name)) + else + build_constraint_without_polymorphism( + reflection, table, key, foreign_table, foreign_key + ) + end + end + + def association_join_with_polymorphism + return @join if @Join + @join = association_join_without_polymorphism + if reflection.macro == :belongs_to && reflection.options[:polymorphic] + aliased_table = Arel::Table.new( + table_name, + as: @aliased_table_name, + engine: arel_engine, + columns: klass.columns + ) + parent_table = Arel::Table.new( + parent.table_name, + as: parent.aliased_table_name, + engine: arel_engine, + columns: parent.base_klass.columns + ) + @join << parent_table[reflection.options[:foreign_type]] + .eq(reflection.klass.name) + end + @join + end + end +end diff --git a/lib/polyamorous/activerecord_3_and_4.0_ruby_1.9/join_dependency.rb b/lib/polyamorous/activerecord_3_and_4.0_ruby_1.9/join_dependency.rb new file mode 100644 index 000000000..b67e26fb2 --- /dev/null +++ b/lib/polyamorous/activerecord_3_and_4.0_ruby_1.9/join_dependency.rb @@ -0,0 +1,96 @@ +# active_record_3_and_4.0_ruby_1.9/join_dependency.rb +module Polyamorous + module JoinDependencyExtensions + def self.included(base) + base.class_eval do + alias_method_chain :build, :polymorphism + alias_method_chain :graft, :polymorphism + if base.method_defined?(:active_record) + alias_method :base_klass, :active_record + end + end + end + + def graft_with_polymorphism(*associations) + associations.each do |association| + unless join_associations.detect { |a| association == a } + if association.reflection.options[:polymorphic] + build( + Join.new( + association.reflection.name, + association.join_type, + association.reflection.klass + ), + association.find_parent_in(self) || join_base, + association.join_type + ) + else + build( + association.reflection.name, + association.find_parent_in(self) || join_base, + association.join_type + ) + end + end + end + self + end + + if ActiveRecord::VERSION::STRING =~ /^3\.0\./ + def _join_parts + @joins + end + else + def _join_parts + @join_parts + end + end + + def build_with_polymorphism( + associations, parent = nil, join_type = InnerJoin + ) + case associations + when Join + parent ||= _join_parts.last + reflection = parent.reflections[associations.name] or + raise ::ActiveRecord::ConfigurationError, + "Association named '#{associations.name + }' was not found; perhaps you misspelled it?" + + unless join_association = find_join_association_respecting_polymorphism( + reflection, parent, associations.klass + ) + @reflections << reflection + join_association = build_join_association_respecting_polymorphism( + reflection, parent, associations.klass + ) + join_association.join_type = associations.type + _join_parts << join_association + cache_joined_association(join_association) + end + + join_association + else + build_without_polymorphism(associations, parent, join_type) + end + end + + def find_join_association_respecting_polymorphism(reflection, parent, klass) + if association = find_join_association(reflection, parent) + unless reflection.options[:polymorphic] + association + else + association if association.base_klass == klass + end + end + end + + def build_join_association_respecting_polymorphism(reflection, parent, klass) + if reflection.options[:polymorphic] && klass + JoinAssociation.new(reflection, self, parent, klass) + else + JoinAssociation.new(reflection, self, parent) + end + end + end +end diff --git a/lib/polyamorous/activerecord_4.1_ruby_1.9/join_association.rb b/lib/polyamorous/activerecord_4.1_ruby_1.9/join_association.rb new file mode 100644 index 000000000..b56ac7f07 --- /dev/null +++ b/lib/polyamorous/activerecord_4.1_ruby_1.9/join_association.rb @@ -0,0 +1,2 @@ +# active_record_4.1_ruby_1.9/join_association.rb +require 'polyamorous/activerecord_4.2_ruby_1.9/join_association' diff --git a/lib/polyamorous/activerecord_4.1_ruby_1.9/join_dependency.rb b/lib/polyamorous/activerecord_4.1_ruby_1.9/join_dependency.rb new file mode 100644 index 000000000..e3fd908f7 --- /dev/null +++ b/lib/polyamorous/activerecord_4.1_ruby_1.9/join_dependency.rb @@ -0,0 +1,4 @@ +# active_record_4.1_ruby_1.9/join_dependency.rb +require 'polyamorous/activerecord_4.2_ruby_2/join_dependency' +require 'polyamorous/activerecord_4.2_ruby_1.9/join_dependency' +require 'polyamorous/activerecord_4.1_ruby_2/make_polyamorous_inner_joins' diff --git a/lib/polyamorous/activerecord_4.1_ruby_2/join_association.rb b/lib/polyamorous/activerecord_4.1_ruby_2/join_association.rb new file mode 100644 index 000000000..c7495ad5c --- /dev/null +++ b/lib/polyamorous/activerecord_4.1_ruby_2/join_association.rb @@ -0,0 +1,2 @@ +# active_record_4.1_ruby_2/join_association.rb +require 'polyamorous/activerecord_5.0_ruby_2/join_association' diff --git a/lib/polyamorous/activerecord_4.1_ruby_2/join_dependency.rb b/lib/polyamorous/activerecord_4.1_ruby_2/join_dependency.rb new file mode 100644 index 000000000..967899eac --- /dev/null +++ b/lib/polyamorous/activerecord_4.1_ruby_2/join_dependency.rb @@ -0,0 +1,3 @@ +# active_record_4.1_ruby_2/join_dependency.rb +require 'polyamorous/activerecord_4.2_ruby_2/join_dependency' +require 'polyamorous/activerecord_4.1_ruby_2/make_polyamorous_inner_joins' diff --git a/lib/polyamorous/activerecord_4.1_ruby_2/make_polyamorous_inner_joins.rb b/lib/polyamorous/activerecord_4.1_ruby_2/make_polyamorous_inner_joins.rb new file mode 100644 index 000000000..b2ab5acfb --- /dev/null +++ b/lib/polyamorous/activerecord_4.1_ruby_2/make_polyamorous_inner_joins.rb @@ -0,0 +1,14 @@ +module Polyamorous + module JoinDependencyExtensions + # Replaces ActiveRecord::Associations::JoinDependency#make_inner_joins + # + def make_polyamorous_inner_joins(parent, child) + make_constraints( + parent, child, child.tables, child.join_type || Arel::Nodes::InnerJoin + ) + .concat child.children.flat_map { |c| + make_polyamorous_inner_joins(child, c) + } + end + end +end diff --git a/lib/polyamorous/activerecord_4.2_ruby_1.9/join_association.rb b/lib/polyamorous/activerecord_4.2_ruby_1.9/join_association.rb new file mode 100644 index 000000000..4b1ec47e3 --- /dev/null +++ b/lib/polyamorous/activerecord_4.2_ruby_1.9/join_association.rb @@ -0,0 +1,46 @@ +# active_record_4.2_ruby_1.9/join_association.rb +module Polyamorous + module JoinAssociationExtensions + include SwappingReflectionClass + def self.included(base) + base.class_eval do + attr_reader :join_type + alias_method_chain :initialize, :polymorphism + alias_method_chain :build_constraint, :polymorphism + end + end + + def initialize_with_polymorphism(reflection, children, + polymorphic_class = nil, join_type = Arel::Nodes::InnerJoin) + @join_type = join_type + if polymorphic_class && ::ActiveRecord::Base > polymorphic_class + swapping_reflection_klass(reflection, polymorphic_class) do |reflection| + initialize_without_polymorphism(reflection, children) + self.reflection.options[:polymorphic] = true + end + else + initialize_without_polymorphism(reflection, children) + end + end + + # Reference https://github.com/rails/rails/commit/9b15db51b78028bfecdb85595624de4b838adbd1 + def ==(other) + base_klass == other.base_klass + end + + def build_constraint_with_polymorphism( + klass, table, key, foreign_table, foreign_key + ) + if reflection.polymorphic? + build_constraint_without_polymorphism( + klass, table, key, foreign_table, foreign_key + ) + .and(foreign_table[reflection.foreign_type].eq(reflection.klass.name)) + else + build_constraint_without_polymorphism( + klass, table, key, foreign_table, foreign_key + ) + end + end + end +end diff --git a/lib/polyamorous/activerecord_4.2_ruby_1.9/join_dependency.rb b/lib/polyamorous/activerecord_4.2_ruby_1.9/join_dependency.rb new file mode 100644 index 000000000..4252c8b8a --- /dev/null +++ b/lib/polyamorous/activerecord_4.2_ruby_1.9/join_dependency.rb @@ -0,0 +1,87 @@ +# active_record_4.2_ruby_1.9/join_dependency.rb +require 'polyamorous/activerecord_4.2_ruby_2/join_dependency' + +module Polyamorous + module JoinDependencyExtensions + def self.included(base) + base.extend ClassMethods + base.class_eval do + class << self + alias_method :walk_tree_without_polymorphism, :walk_tree + alias_method :walk_tree, :walk_tree_with_polymorphism + end + + alias_method :build_without_polymorphism, :build + alias_method :build, :build_with_polymorphism + + alias_method :join_constraints_without_polymorphism, :join_constraints + alias_method :join_constraints, :join_constraints_with_polymorphism + end + end + + # Replaces ActiveRecord::Associations::JoinDependency#build + # + def build_with_polymorphism(associations, base_klass) + associations.map do |name, right| + if name.is_a? Join + reflection = find_reflection base_klass, name.name + reflection.check_validity! + klass = if reflection.polymorphic? + name.klass || base_klass + else + reflection.klass + end + JoinAssociation.new(reflection, build(right, klass), name.klass, name.type) + else + reflection = find_reflection base_klass, name + reflection.check_validity! + if reflection.polymorphic? + raise ActiveRecord::EagerLoadPolymorphicError.new(reflection) + end + JoinAssociation.new reflection, build(right, reflection.klass) + end + end + end + + # Replaces ActiveRecord::Associations::JoinDependency#join_constraints + # to call #make_polyamorous_inner_joins instead of #make_inner_joins + # + def join_constraints_with_polymorphism(outer_joins) + joins = join_root.children.flat_map { |child| + make_polyamorous_inner_joins join_root, child + } + joins.concat outer_joins.flat_map { |oj| + if join_root.match? oj.join_root + walk(join_root, oj.join_root) + else + oj.join_root.children.flat_map { |child| + make_outer_joins(oj.join_root, child) + } + end + } + end + + module ClassMethods + # Replaces ActiveRecord::Associations::JoinDependency#self.walk_tree + # + def walk_tree_with_polymorphism(associations, hash) + case associations + when TreeNode + associations.add_to_tree(hash) + when Hash + associations.each do |k, v| + cache = + if TreeNode === k + k.add_to_tree(hash) + else + hash[k] ||= {} + end + walk_tree(v, cache) + end + else + walk_tree_without_polymorphism(associations, hash) + end + end + end + end +end diff --git a/lib/polyamorous/activerecord_4.2_ruby_2/join_association.rb b/lib/polyamorous/activerecord_4.2_ruby_2/join_association.rb new file mode 100644 index 000000000..f5e23cf76 --- /dev/null +++ b/lib/polyamorous/activerecord_4.2_ruby_2/join_association.rb @@ -0,0 +1,2 @@ +# active_record_4.2_ruby_2/join_association.rb +require 'polyamorous/activerecord_5.0_ruby_2/join_association' diff --git a/lib/polyamorous/activerecord_4.2_ruby_2/join_dependency.rb b/lib/polyamorous/activerecord_4.2_ruby_2/join_dependency.rb new file mode 100644 index 000000000..5dfb15ca3 --- /dev/null +++ b/lib/polyamorous/activerecord_4.2_ruby_2/join_dependency.rb @@ -0,0 +1,24 @@ +# active_record_4.2_ruby_2/join_dependency.rb +require 'polyamorous/activerecord_5.0_ruby_2/join_dependency' + +module Polyamorous + module JoinDependencyExtensions + # Replaces ActiveRecord::Associations::JoinDependency#join_constraints + # to call #make_polyamorous_inner_joins instead of #make_inner_joins. + # + def join_constraints(outer_joins) + joins = join_root.children.flat_map { |child| + make_polyamorous_inner_joins join_root, child + } + joins.concat outer_joins.flat_map { |oj| + if join_root.match? oj.join_root + walk(join_root, oj.join_root) + else + oj.join_root.children.flat_map { |child| + make_outer_joins(oj.join_root, child) + } + end + } + end + end +end diff --git a/lib/polyamorous/activerecord_5.0_ruby_2/join_association.rb b/lib/polyamorous/activerecord_5.0_ruby_2/join_association.rb new file mode 100644 index 000000000..794f2da92 --- /dev/null +++ b/lib/polyamorous/activerecord_5.0_ruby_2/join_association.rb @@ -0,0 +1,2 @@ +# active_record_5.0_ruby_2/join_association.rb +require 'polyamorous/activerecord_5.1_ruby_2/join_association' diff --git a/lib/polyamorous/activerecord_5.0_ruby_2/join_dependency.rb b/lib/polyamorous/activerecord_5.0_ruby_2/join_dependency.rb new file mode 100644 index 000000000..03423b268 --- /dev/null +++ b/lib/polyamorous/activerecord_5.0_ruby_2/join_dependency.rb @@ -0,0 +1,2 @@ +# active_record_5.0_ruby_2/join_dependency.rb +require 'polyamorous/activerecord_5.1_ruby_2/join_dependency' diff --git a/lib/polyamorous/activerecord_5.1_ruby_2/join_association.rb b/lib/polyamorous/activerecord_5.1_ruby_2/join_association.rb new file mode 100644 index 000000000..59e618c2a --- /dev/null +++ b/lib/polyamorous/activerecord_5.1_ruby_2/join_association.rb @@ -0,0 +1,39 @@ +# active_record_5.1_ruby_2/join_association.rb + +module Polyamorous + module JoinAssociationExtensions + include SwappingReflectionClass + def self.prepended(base) + base.class_eval { attr_reader :join_type } + end + + def initialize(reflection, children, polymorphic_class = nil, + join_type = Arel::Nodes::InnerJoin) + @join_type = join_type + if polymorphic_class && ::ActiveRecord::Base > polymorphic_class + swapping_reflection_klass(reflection, polymorphic_class) do |reflection| + super(reflection, children) + self.reflection.options[:polymorphic] = true + end + else + super(reflection, children) + end + end + + # Reference: https://github.com/rails/rails/commit/9b15db5 + # NOTE: Not sure we still need it? + # + def ==(other) + base_klass == other.base_klass + end + + def build_constraint(klass, table, key, foreign_table, foreign_key) + if reflection.polymorphic? + super(klass, table, key, foreign_table, foreign_key) + .and(foreign_table[reflection.foreign_type].eq(reflection.klass.name)) + else + super(klass, table, key, foreign_table, foreign_key) + end + end + end +end diff --git a/lib/polyamorous/activerecord_5.1_ruby_2/join_dependency.rb b/lib/polyamorous/activerecord_5.1_ruby_2/join_dependency.rb new file mode 100644 index 000000000..dfa565f8f --- /dev/null +++ b/lib/polyamorous/activerecord_5.1_ruby_2/join_dependency.rb @@ -0,0 +1,130 @@ +# active_record_5.1_ruby_2/join_dependency.rb + +module Polyamorous + module JoinDependencyExtensions + # Replaces ActiveRecord::Associations::JoinDependency#build + # + def build(associations, base_klass) + associations.map do |name, right| + if name.is_a? Join + reflection = find_reflection base_klass, name.name + reflection.check_validity! + reflection.check_eager_loadable! if ActiveRecord::VERSION::MAJOR >= 5 + + klass = if reflection.polymorphic? + name.klass || base_klass + else + reflection.klass + end + JoinAssociation.new(reflection, build(right, klass), name.klass, name.type) + else + reflection = find_reflection base_klass, name + reflection.check_validity! + reflection.check_eager_loadable! if ActiveRecord::VERSION::MAJOR >= 5 + + if reflection.polymorphic? + raise ActiveRecord::EagerLoadPolymorphicError.new(reflection) + end + JoinAssociation.new reflection, build(right, reflection.klass) + end + end + end + + def find_join_association_respecting_polymorphism(reflection, parent, klass) + if association = parent.children.find { |j| j.reflection == reflection } + unless reflection.polymorphic? + association + else + association if association.base_klass == klass + end + end + end + + def build_join_association_respecting_polymorphism(reflection, parent, klass) + if reflection.polymorphic? && klass + JoinAssociation.new(reflection, self, klass) + else + JoinAssociation.new(reflection, self) + end + end + + # Replaces ActiveRecord::Associations::JoinDependency#join_constraints + # + # This internal method was changed in Rails 5.0 by commit + # https://github.com/rails/rails/commit/e038975 which added + # left_outer_joins (see #make_polyamorous_left_outer_joins below) and added + # passing an additional argument, `join_type`, to #join_constraints. + # + def join_constraints(outer_joins, join_type) + joins = join_root.children.flat_map { |child| + if join_type == Arel::Nodes::OuterJoin + make_polyamorous_left_outer_joins join_root, child + else + make_polyamorous_inner_joins join_root, child + end + } + + joins.concat outer_joins.flat_map { |oj| + if join_root.match? oj.join_root + walk(join_root, oj.join_root) + else + oj.join_root.children.flat_map { |child| + make_outer_joins(oj.join_root, child) + } + end + } + end + + # Replaces ActiveRecord::Associations::JoinDependency#make_left_outer_joins, + # a new method that was added in Rails 5.0 with the following commit: + # https://github.com/rails/rails/commit/e038975 + # + def make_polyamorous_left_outer_joins(parent, child) + tables = child.tables + join_type = Arel::Nodes::OuterJoin + info = make_constraints parent, child, tables, join_type + + [info] + child.children.flat_map { |c| + make_polyamorous_left_outer_joins(child, c) + } + end + + # Replaces ActiveRecord::Associations::JoinDependency#make_inner_joins + # + def make_polyamorous_inner_joins(parent, child) + tables = child.tables + join_type = child.join_type || Arel::Nodes::InnerJoin + info = make_constraints parent, child, tables, join_type + + [info] + child.children.flat_map { |c| + make_polyamorous_inner_joins(child, c) + } + end + + private :make_polyamorous_inner_joins, :make_polyamorous_left_outer_joins + + module ClassMethods + # Prepended before ActiveRecord::Associations::JoinDependency#walk_tree + # + def walk_tree(associations, hash) + case associations + when TreeNode + associations.add_to_tree(hash) + when Hash + associations.each do |k, v| + cache = + if TreeNode === k + k.add_to_tree(hash) + else + hash[k] ||= {} + end + walk_tree(v, cache) + end + else + super(associations, hash) + end + end + end + + end +end diff --git a/lib/polyamorous/activerecord_5.2.0_ruby_2/join_association.rb b/lib/polyamorous/activerecord_5.2.0_ruby_2/join_association.rb new file mode 100644 index 000000000..a3a273325 --- /dev/null +++ b/lib/polyamorous/activerecord_5.2.0_ruby_2/join_association.rb @@ -0,0 +1,39 @@ +# active_record_5.2_ruby_2/join_association.rb + +module Polyamorous + module JoinAssociationExtensions + include SwappingReflectionClass + def self.prepended(base) + base.class_eval { attr_reader :join_type } + end + + def initialize(reflection, children, alias_tracker, polymorphic_class = nil, + join_type = Arel::Nodes::InnerJoin) + @join_type = join_type + if polymorphic_class && ::ActiveRecord::Base > polymorphic_class + swapping_reflection_klass(reflection, polymorphic_class) do |reflection| + super(reflection, children, alias_tracker) + self.reflection.options[:polymorphic] = true + end + else + super(reflection, children, alias_tracker) + end + end + + # Reference: https://github.com/rails/rails/commit/9b15db5 + # NOTE: Not sure we still need it? + # + def ==(other) + base_klass == other.base_klass + end + + def build_constraint(klass, table, key, foreign_table, foreign_key) + if reflection.polymorphic? + super(klass, table, key, foreign_table, foreign_key) + .and(foreign_table[reflection.foreign_type].eq(reflection.klass.name)) + else + super(klass, table, key, foreign_table, foreign_key) + end + end + end +end diff --git a/lib/polyamorous/activerecord_5.2.0_ruby_2/join_dependency.rb b/lib/polyamorous/activerecord_5.2.0_ruby_2/join_dependency.rb new file mode 100644 index 000000000..99116f3f4 --- /dev/null +++ b/lib/polyamorous/activerecord_5.2.0_ruby_2/join_dependency.rb @@ -0,0 +1,131 @@ +# active_record_5.2_ruby_2/join_dependency.rb + +module Polyamorous + module JoinDependencyExtensions + # Replaces ActiveRecord::Associations::JoinDependency#build + # + def build(associations, base_klass) + associations.map do |name, right| + if name.is_a? Join + reflection = find_reflection base_klass, name.name + reflection.check_validity! + reflection.check_eager_loadable! if ActiveRecord::VERSION::MAJOR >= 5 + + klass = if reflection.polymorphic? + name.klass || base_klass + else + reflection.klass + end + JoinAssociation.new(reflection, build(right, klass), alias_tracker, name.klass, name.type) + else + reflection = find_reflection base_klass, name + reflection.check_validity! + reflection.check_eager_loadable! if ActiveRecord::VERSION::MAJOR >= 5 + + if reflection.polymorphic? + raise ActiveRecord::EagerLoadPolymorphicError.new(reflection) + end + JoinAssociation.new(reflection, build(right, reflection.klass), alias_tracker) + end + end + end + + def find_join_association_respecting_polymorphism(reflection, parent, klass) + if association = parent.children.find { |j| j.reflection == reflection } + unless reflection.polymorphic? + association + else + association if association.base_klass == klass + end + end + end + + def build_join_association_respecting_polymorphism(reflection, parent, klass) + if reflection.polymorphic? && klass + JoinAssociation.new(reflection, self, alias_tracker, klass) + else + JoinAssociation.new(reflection, self, alias_tracker) + end + end + + # Replaces ActiveRecord::Associations::JoinDependency#join_constraints + # + # This internal method was changed in Rails 5.0 by commit + # https://github.com/rails/rails/commit/e038975 which added + # left_outer_joins (see #make_polyamorous_left_outer_joins below) and added + # passing an additional argument, `join_type`, to #join_constraints. + # + def join_constraints(outer_joins, join_type) + @alias_tracker = alias_tracker + joins = join_root.children.flat_map { |child| + if join_type == Arel::Nodes::OuterJoin + make_polyamorous_left_outer_joins join_root, child + else + make_polyamorous_inner_joins join_root, child + end + } + + joins.concat outer_joins.flat_map { |oj| + if join_root.match? oj.join_root + walk(join_root, oj.join_root) + else + oj.join_root.children.flat_map { |child| + make_outer_joins(oj.join_root, child) + } + end + } + end + + # Replaces ActiveRecord::Associations::JoinDependency#make_left_outer_joins, + # a new method that was added in Rails 5.0 with the following commit: + # https://github.com/rails/rails/commit/e038975 + # + def make_polyamorous_left_outer_joins(parent, child) + tables = child.tables + join_type = Arel::Nodes::OuterJoin + info = make_constraints parent, child, tables, join_type + + info + child.children.flat_map { |c| + make_polyamorous_left_outer_joins(child, c) + } + end + + # Replaces ActiveRecord::Associations::JoinDependency#make_inner_joins + # + def make_polyamorous_inner_joins(parent, child) + tables = child.tables + join_type = child.join_type || Arel::Nodes::InnerJoin + info = make_constraints parent, child, tables, join_type + + info + child.children.flat_map { |c| + make_polyamorous_inner_joins(child, c) + } + end + + private :make_polyamorous_inner_joins, :make_polyamorous_left_outer_joins + + module ClassMethods + # Prepended before ActiveRecord::Associations::JoinDependency#walk_tree + # + def walk_tree(associations, hash) + case associations + when TreeNode + associations.add_to_tree(hash) + when Hash + associations.each do |k, v| + cache = + if TreeNode === k + k.add_to_tree(hash) + else + hash[k] ||= {} + end + walk_tree(v, cache) + end + else + super(associations, hash) + end + end + end + + end +end diff --git a/lib/polyamorous/join.rb b/lib/polyamorous/join.rb new file mode 100644 index 000000000..45ce4dac3 --- /dev/null +++ b/lib/polyamorous/join.rb @@ -0,0 +1,70 @@ +module Polyamorous + class Join + include TreeNode + + attr_accessor :name + attr_reader :type, :klass + + def initialize(name, type = InnerJoin, klass = nil) + @name = name + @type = convert_to_arel_join_type(type) + @klass = convert_to_class(klass) if klass + end + + def klass=(klass) + @klass = convert_to_class(klass) if klass + end + + def type=(type) + @type = convert_to_arel_join_type(type) if type + end + + def hash + [@name, @type, @klass].hash + end + + def eql?(other) + self.class == other.class && + self.name == other.name && + self.type == other.type && + self.klass == other.klass + end + + alias :== :eql? + + def add_to_tree(hash) + hash[self] ||= {} + end + + private + + def convert_to_arel_join_type(type) + case type + when 'inner', :inner + InnerJoin + when 'outer', :outer + OuterJoin + when Class + if [InnerJoin, OuterJoin].include? type + type + else + raise ArgumentError, "#{type} cannot be converted to an ARel join type" + end + else + raise ArgumentError, "#{type} cannot be converted to an ARel join type" + end + end + + def convert_to_class(value) + case value + when String, Symbol + Kernel.const_get(value) + when Class + value + else + raise ArgumentError, "#{value} cannot be converted to a Class" + end + end + + end +end diff --git a/lib/polyamorous/swapping_reflection_class.rb b/lib/polyamorous/swapping_reflection_class.rb new file mode 100644 index 000000000..31cd3fe8a --- /dev/null +++ b/lib/polyamorous/swapping_reflection_class.rb @@ -0,0 +1,11 @@ +module Polyamorous + module SwappingReflectionClass + def swapping_reflection_klass(reflection, klass) + new_reflection = reflection.clone + new_reflection.instance_variable_set(:@options, reflection.options.clone) + new_reflection.options.delete(:polymorphic) + new_reflection.instance_variable_set(:@klass, klass) + yield new_reflection + end + end +end diff --git a/lib/polyamorous/tree_node.rb b/lib/polyamorous/tree_node.rb new file mode 100644 index 000000000..f77cefa44 --- /dev/null +++ b/lib/polyamorous/tree_node.rb @@ -0,0 +1,7 @@ +module Polyamorous + module TreeNode + def add_to_tree(hash) + raise NotImplementedError + end + end +end diff --git a/ransack.gemspec b/ransack.gemspec index 7af145e0d..29e1ac5dd 100644 --- a/ransack.gemspec +++ b/ransack.gemspec @@ -20,7 +20,6 @@ Gem::Specification.new do |s| s.add_dependency 'activerecord', '>= 3.0' s.add_dependency 'activesupport', '>= 3.0' s.add_dependency 'i18n' - s.add_dependency 'polyamorous', '~> 1.3.2' s.add_development_dependency 'rspec', '~> 3' s.add_development_dependency 'machinist', '~> 1.0.6' s.add_development_dependency 'faker', '~> 0.9.5' diff --git a/spec/helpers/polyamorous_helper.rb b/spec/helpers/polyamorous_helper.rb new file mode 100644 index 000000000..8dff2699b --- /dev/null +++ b/spec/helpers/polyamorous_helper.rb @@ -0,0 +1,26 @@ +module PolyamorousHelper + if ActiveRecord::VERSION::STRING >= "4.1" + def new_join_association(reflection, children, klass) + Polyamorous::JoinAssociation.new reflection, children, klass + end + else + def new_join_association(reflection, join_dependency, parent, klass) + Polyamorous::JoinAssociation.new reflection, join_dependency, parent, klass + end + end + + if ActiveRecord::VERSION::STRING >= "5.2" + def new_join_dependency(klass, associations = {}) + alias_tracker = ::ActiveRecord::Associations::AliasTracker.create(klass.connection, klass.table_name, []) + Polyamorous::JoinDependency.new klass, klass.arel_table, associations, alias_tracker + end + else + def new_join_dependency(klass, associations = {}) + Polyamorous::JoinDependency.new klass, associations, [] + end + end + + def new_join(name, type = Polyamorous::InnerJoin, klass = nil) + Polyamorous::Join.new name, type, klass + end +end diff --git a/spec/ransack/join_association_spec.rb b/spec/ransack/join_association_spec.rb new file mode 100644 index 000000000..ab8ed5614 --- /dev/null +++ b/spec/ransack/join_association_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +module Polyamorous + describe JoinAssociation do + + join_base, join_association_args, polymorphic = + if ActiveRecord::VERSION::STRING >= '4.1' + [:join_root, 'parent.children', 'reflection.options[:polymorphic]'] + else + [:join_base, 'join_dependency, parent', 'options[:polymorphic]'] + end + + let(:join_dependency) { new_join_dependency Note, {} } + let(:reflection) { Note.reflect_on_association(:notable) } + let(:parent) { join_dependency.send(join_base) } + let(:join_association) { + eval("new_join_association(reflection, #{join_association_args}, Article)") + } + + subject { + join_dependency.build_join_association_respecting_polymorphism( + reflection, parent, Person + ) + } + + it 'respects polymorphism on equality test' do + expect(subject).to eq( + join_dependency.build_join_association_respecting_polymorphism( + reflection, parent, Person + ) + ) + expect(subject).not_to eq( + join_dependency.build_join_association_respecting_polymorphism( + reflection, parent, Article + ) + ) + end + + it 'leaves the orginal reflection intact for thread safety' do + reflection.instance_variable_set(:@klass, Article) + join_association + .swapping_reflection_klass(reflection, Person) do |new_reflection| + expect(new_reflection.options).not_to equal reflection.options + expect(new_reflection.options).not_to have_key(:polymorphic) + expect(new_reflection.klass).to eq(Person) + expect(reflection.klass).to eq(Article) + end + end + + it 'sets the polymorphic option to true after initializing' do + expect(join_association.instance_eval(polymorphic)).to be true + end + end +end diff --git a/spec/ransack/join_dependency_spec.rb b/spec/ransack/join_dependency_spec.rb new file mode 100644 index 000000000..c9f96ab9b --- /dev/null +++ b/spec/ransack/join_dependency_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +module Polyamorous + describe JoinDependency do + + method, join_associations, join_base = + if ActiveRecord::VERSION::STRING >= '4.1' + [:instance_eval, 'join_root.drop(1)', :join_root] + else + [:send, 'join_associations', :join_base] + end + + context 'with symbol joins' do + subject { new_join_dependency Person, articles: :comments } + + specify { expect(subject.send(method, join_associations).size) + .to eq(2) } + specify { expect(subject.send(method, join_associations).map(&:join_type)) + .to be_all { Polyamorous::InnerJoin } } + end + + context 'with has_many :through association' do + subject { new_join_dependency Person, :authored_article_comments } + + specify { expect(subject.send(method, join_associations).size) + .to eq 1 } + specify { expect(subject.send(method, join_associations).first.table_name) + .to eq 'comments' } + end + + context 'with outer join' do + subject { new_join_dependency Person, new_join(:articles, :outer) } + + specify { expect(subject.send(method, join_associations).size) + .to eq 1 } + specify { expect(subject.send(method, join_associations).first.join_type) + .to eq Polyamorous::OuterJoin } + end + + context 'with nested outer joins' do + subject { new_join_dependency Person, + new_join(:articles, :outer) => new_join(:comments, :outer) } + + specify { expect(subject.send(method, join_associations).size) + .to eq 2 } + specify { expect(subject.send(method, join_associations).map(&:join_type)) + .to eq [Polyamorous::OuterJoin, Polyamorous::OuterJoin] } + specify { expect(subject.send(method, join_associations).map(&:join_type)) + .to be_all { Polyamorous::OuterJoin } } + end + + context 'with polymorphic belongs_to join' do + subject { new_join_dependency Note, new_join(:notable, :inner, Person) } + + specify { expect(subject.send(method, join_associations).size) + .to eq 1 } + specify { expect(subject.send(method, join_associations).first.join_type) + .to eq Polyamorous::InnerJoin } + specify { expect(subject.send(method, join_associations).first.table_name) + .to eq 'people' } + + it 'finds a join association respecting polymorphism' do + parent = subject.send(join_base) + reflection = Note.reflect_on_association(:notable) + + expect(subject.find_join_association_respecting_polymorphism( + reflection, parent, Person)) + .to eq subject.send(method, join_associations).first + end + end + + context 'with polymorphic belongs_to join and nested symbol join' do + subject { new_join_dependency Note, + new_join(:notable, :inner, Person) => :comments } + + specify { expect(subject.send(method, join_associations).size) + .to eq 2 } + specify { expect(subject.send(method, join_associations).map(&:join_type)) + .to be_all { Polyamorous::InnerJoin } } + specify { expect(subject.send(method, join_associations).first.table_name) + .to eq 'people' } + specify { expect(subject.send(method, join_associations)[1].table_name) + .to eq 'comments' } + end + + context '#left_outer_join in Rails 5 overrides join type specified', + if: ActiveRecord::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR < 2 do + + let(:join_type_class) do + new_join_dependency( + Person, + new_join(:articles) + ).join_constraints( + [], + Arel::Nodes::OuterJoin + ).first.joins.map(&:class) + end + + specify { expect(join_type_class).to eq [Arel::Nodes::OuterJoin] } + end + end +end diff --git a/spec/ransack/join_spec.rb b/spec/ransack/join_spec.rb new file mode 100644 index 000000000..5e2bfc3ec --- /dev/null +++ b/spec/ransack/join_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +module Polyamorous + describe Join do + it 'is a tree node' do + join = new_join(:articles, :outer) + expect(join).to be_kind_of(TreeNode) + end + + it 'can be added to a tree' do + join = new_join(:articles, :outer) + + tree_hash = {} + join.add_to_tree(tree_hash) + + expect(tree_hash[join]).to be {} + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ab3b430ed..e1f57c1cb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -44,6 +44,7 @@ config.before(:each) { Sham.reset(:before_each) } config.include RansackHelper + config.include PolyamorousHelper end RSpec::Matchers.define :be_like do |expected|