diff --git a/Gemfile.lock b/Gemfile.lock index 61c7bad..418312c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: active_record_extended (0.1.0) activerecord (>= 5.1, < 6.0) + ar_outer_joins (~> 0.2) pg (~> 0.18) GEM @@ -19,6 +20,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + ar_outer_joins (0.2.0) + activerecord (>= 3.2) arel (9.0.0) ast (2.4.0) byebug (10.0.2) diff --git a/Rakefile b/Rakefile index 580008e..a9bdc1f 100644 --- a/Rakefile +++ b/Rakefile @@ -66,7 +66,17 @@ namespace :db do end create_table :tags, force: true do |t| - t.integer "person_id" + t.belongs_to :person + end + + create_table :profile_ls, force: true do |t| + t.belongs_to :person + t.integer :likes + end + + create_table :profile_rs, force: true do |t| + t.belongs_to :person + t.integer :dislikes end end diff --git a/active_record_extended.gemspec b/active_record_extended.gemspec index 6497fe8..8fa87a2 100644 --- a/active_record_extended.gemspec +++ b/active_record_extended.gemspec @@ -22,6 +22,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "activerecord", ">= 5.1", "< 6.0" + spec.add_dependency "ar_outer_joins", "~> 0.2" spec.add_dependency "pg", "~> 0.18" spec.add_development_dependency "bundler", "~> 1.16" diff --git a/lib/active_record_extended/active_record.rb b/lib/active_record_extended/active_record.rb index 32288a8..26b7e90 100644 --- a/lib/active_record_extended/active_record.rb +++ b/lib/active_record_extended/active_record.rb @@ -2,8 +2,9 @@ require "active_record" -require "active_record_extended/query_methods_decorator" require "active_record_extended/predicate_builder/array_handler_decorator" +require "active_record_extended/query_methods/where_chain" +require "active_record_extended/query_methods/either" if ActiveRecord::VERSION::MAJOR >= 5 if ActiveRecord::VERSION::MINOR >= 2 diff --git a/lib/active_record_extended/query_methods/either.rb b/lib/active_record_extended/query_methods/either.rb new file mode 100644 index 0000000..cf1574e --- /dev/null +++ b/lib/active_record_extended/query_methods/either.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "ar_outer_joins" + +module ActiveRecordExtended + module QueryMethods + module Either + XOR_FIELD_SQL = "(CASE WHEN %s.%s IS NULL THEN %s.%s ELSE %s.%s END) " + XOR_FIELD_KEYS = %i[t1 c1 t2 c2].freeze + + def either_join(initial_association, fallback_association) + associations = [initial_association, fallback_association] + association_options = xor_field_options_for_associations(associations) + condition__query = xor_field_sql(association_options) + "= #{table_name}.#{primary_key}" + outer_joins(associations).where(Arel.sql(condition__query)) + end + + def either_order(direction, **associations_and_columns) + reflected_columns = map_columns_to_tables(associations_and_columns) + conditional_query = xor_field_sql(reflected_columns) + sort_order_sql(direction) + outer_joins(associations_and_columns.keys).order(Arel.sql(conditional_query)) + end + + private + + def xor_field_sql(options) + XOR_FIELD_SQL % Hash[xor_field_options(options)] + end + + def sort_order_sql(dir) + %w[asc desc].include?(dir.to_s) ? dir.to_s : "asc" + end + + def xor_field_options(options) + str_args = options.flatten.take(XOR_FIELD_KEYS.size).map(&:to_s) + Hash[XOR_FIELD_KEYS.zip(str_args)] + end + + def map_columns_to_tables(associations_and_columns) + if associations_and_columns.respond_to?(:transform_keys) + associations_and_columns.transform_keys { |assc| reflect_on_association(assc).table_name } + else + associations_and_columns.each_with_object({}) do |(assc, value), key_table| + reflect_table = reflect_on_association(assc).table_name + key_table[reflect_table] = value + end + end + end + + def xor_field_options_for_associations(associations) + associations.each_with_object({}) do |association_name, options| + reflection = reflect_on_association(association_name) + options[reflection.table_name] = reflection.foreign_key + end + end + end + end +end + +ActiveRecord::Base.extend(ActiveRecordExtended::QueryMethods::Either) diff --git a/lib/active_record_extended/query_methods_decorator.rb b/lib/active_record_extended/query_methods/where_chain.rb similarity index 97% rename from lib/active_record_extended/query_methods_decorator.rb rename to lib/active_record_extended/query_methods/where_chain.rb index 0e01d0f..4a57c81 100644 --- a/lib/active_record_extended/query_methods_decorator.rb +++ b/lib/active_record_extended/query_methods/where_chain.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActiveRecordExtended - module QueryMethodsDecorator + module WhereChain def overlap(opts, *rest) substitute_comparisons(opts, rest, Arel::Nodes::Overlap, "overlap") end @@ -92,7 +92,7 @@ def substitute_comparisons(opts, rest, arel_node_class, method) module ActiveRecord module QueryMethods class WhereChain - prepend ActiveRecordExtended::QueryMethodsDecorator + prepend ActiveRecordExtended::WhereChain def build_where_chain(opts, rest, &block) where_clause = @scope.send(:where_clause_factory).build(opts, rest) diff --git a/spec/query_methods/either_spec.rb b/spec/query_methods/either_spec.rb new file mode 100644 index 0000000..8098e5c --- /dev/null +++ b/spec/query_methods/either_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Active Record Either Methods" do + let!(:one) { Person.create! } + let!(:two) { Person.create! } + let!(:three) { Person.create! } + let!(:profile_l) { ProfileL.create!(person_id: one.id, likes: 100) } + let!(:profile_r) { ProfileR.create!(person_id: two.id, dislikes: 50) } + + describe ".either_join/2" do + it "Should only only return records that belong to profile L or profile R" do + query = Person.either_join(:profile_l, :profile_r) + expect(query).to include(one, two) + expect(query).to_not include(three) + end + end + + describe ".either_order/2" do + it "Should not exclude anyone who does not have a relationship" do + query = Person.either_order(:asc, profile_l: :likes, profile_r: :dislikes) + expect(query).to include(one, two, three) + end + + it "Should order people based on their likes and dislikes in ascended order" do + query = Person.either_order(:asc, profile_l: :likes, profile_r: :dislikes).where(id: [one.id, two.id]) + expect(query).to match_array([two, one]) + end + + it "Should order people based on their likes and dislikes in descending order" do + query = Person.either_order(:desc, profile_l: :likes, profile_r: :dislikes).where(id: [one.id, two.id]) + expect(query).to match_array([one, two]) + end + end +end diff --git a/spec/query_methods/hash_query_spec.rb b/spec/query_methods/hash_query_spec.rb index bd43d1b..6a1269b 100644 --- a/spec/query_methods/hash_query_spec.rb +++ b/spec/query_methods/hash_query_spec.rb @@ -8,7 +8,7 @@ let!(:three) { Person.create!(data: { nickname: "georgey" }) } describe "#contains" do - context "HStore Column " do + context "HStore Column Type" do it "returns records that contain hash elements in joined tables" do tag_one = Tag.create!(person_id: one.id) tag_two = Tag.create!(person_id: two.id) @@ -25,7 +25,7 @@ end end - context "JSONB Column" do + context "JSONB Column Type" do it "returns records that contains a json hashed value" do query = Person.where.contains(jsonb_data: { payment: "zip" }) expect(query).to include(one) diff --git a/spec/sql_inspections/either_sql_spec.rb b/spec/sql_inspections/either_sql_spec.rb new file mode 100644 index 0000000..cf664d7 --- /dev/null +++ b/spec/sql_inspections/either_sql_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Either Methods SQL Queries" do + let(:contains_array_regex) { /\"people\"\.\"tag_ids\" @> '\{1,2\}'/ } + let(:profile_l_outer_join) { /LEFT OUTER JOIN \"profile_ls\" ON \"profile_ls\".\"person_id\" = \"people\".\"id\"/ } + let(:profile_r_outer_join) { /LEFT OUTER JOIN \"profile_rs\" ON \"profile_rs\".\"person_id\" = \"people\".\"id\"/ } + let(:where_join_case) do + "WHERE ((CASE WHEN profile_ls.person_id IS NULL"\ + " THEN profile_rs.person_id"\ + " ELSE profile_ls.person_id END) "\ + "= people.id)" + end + + let(:order_case) do + "ORDER BY "\ + "(CASE WHEN profile_ls.likes IS NULL"\ + " THEN profile_rs.dislikes"\ + " ELSE profile_ls.likes END)" + end + + describe ".either_join/2" do + it "Should contain outer joins on the provided relationships" do + query = Person.either_join(:profile_l, :profile_r).to_sql + expect(query).to match_regex(profile_l_outer_join) + expect(query).to match_regex(profile_r_outer_join) + end + + it "Should contain a case statement that will conditionally alternative between tables" do + query = Person.either_join(:profile_l, :profile_r).to_sql + expect(query).to include(where_join_case) + end + end + + describe ".either_order/2" do + let(:ascended_order) { Person.either_order(:asc, profile_l: :likes, profile_r: :dislikes).to_sql } + let(:descended_order) { Person.either_order(:desc, profile_l: :likes, profile_r: :dislikes).to_sql } + + it "Should contain outer joins on the provided relationships" do + expect(ascended_order).to match_regex(profile_l_outer_join) + expect(ascended_order).to match_regex(profile_r_outer_join) + expect(descended_order).to match_regex(profile_l_outer_join) + expect(descended_order).to match_regex(profile_r_outer_join) + end + + it "Should contain a relational ordering case statement for a relations column" do + expect(ascended_order).to include(order_case) + expect(ascended_order).to end_with("asc") + + expect(descended_order).to include(order_case) + expect(descended_order).to end_with("desc") + end + end +end diff --git a/spec/support/models.rb b/spec/support/models.rb index 5860cc9..4fbd5cc 100644 --- a/spec/support/models.rb +++ b/spec/support/models.rb @@ -3,8 +3,18 @@ class Person < ActiveRecord::Base has_many :hm_tags, class_name: "Tag" + has_one :profile_l, class_name: "ProfileL" + has_one :profile_r, class_name: "ProfileR" end class Tag < ActiveRecord::Base belongs_to :person end + +class ProfileL < ActiveRecord::Base + belongs_to :person +end + +class ProfileR < ActiveRecord::Base + belongs_to :person +end