pax_global_header00006660000000000000000000000064137651160600014517gustar00rootroot0000000000000052 comment=d68caa5afee56f9762954486d01826bccebf1839 joiner-0.6.0/000077500000000000000000000000001376511606000130105ustar00rootroot00000000000000joiner-0.6.0/.github/000077500000000000000000000000001376511606000143505ustar00rootroot00000000000000joiner-0.6.0/.github/workflows/000077500000000000000000000000001376511606000164055ustar00rootroot00000000000000joiner-0.6.0/.github/workflows/ci.yml000066400000000000000000000006711376511606000175270ustar00rootroot00000000000000name: CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: ruby: [ '2.5', '2.6', '2.7' ] steps: - name: Check out code uses: actions/checkout@v2 - name: Set up ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Test run: "bundle exec rspec" joiner-0.6.0/.gitignore000066400000000000000000000002501376511606000147750ustar00rootroot00000000000000*.gem *.rbc .bundle .config .yardoc Gemfile.lock InstalledFiles _yardoc coverage doc/ lib/bundler/man pkg rdoc spec/internal/db/combustion_test.sqlite spec/reports tmp joiner-0.6.0/.travis.yml000066400000000000000000000001751376511606000151240ustar00rootroot00000000000000language: ruby dist: xenial sudo: false rvm: - 2.5.8 - 2.6.6 before_install: - gem update --system script: bundle exec rspec joiner-0.6.0/Gemfile000066400000000000000000000000471376511606000143040ustar00rootroot00000000000000source 'https://rubygems.org' gemspec joiner-0.6.0/LICENSE.txt000066400000000000000000000020521376511606000146320ustar00rootroot00000000000000Copyright (c) 2014 Pat Allan MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. joiner-0.6.0/README.md000066400000000000000000000044531376511606000142750ustar00rootroot00000000000000# Joiner This gem, abstracted out from [Thinking Sphinx](http://pat.github.io/thinking-sphinx), turns a bunch of association trees from the perspective of a single model and builds a bunch of OUTER JOINs that can be passed into ActiveRecord::Relation's `join` method. You can also find out the generated table aliases for each join, in case you're referring to columns from those joins at some other point. If this gem is used by anyone other than myself/Thinking Sphinx, I'll be surprised. My reason for pulling it out is so I can more cleanly support Rails' changing approaches to join generation (see v3.1-v4.0 compared to v4.1-v5.1 compared to v5.2). ## Installation It's a gem - so you can either install it yourself, or add it to the appropriate Gemfile or gemspec. ```term gem install joiner --version 0.6.0 ``` ## Usage First, create a join collection, based on an ActiveRecord model: ```ruby joiner = Joiner::Joins.new User ``` Then you can add joins for a given association path. For example, if User has many articles, and articles have many comments: ```ruby joiner.add_join_to [:articles] joiner.add_join_to [:articles, :comments] ``` If you need the table/join alias for a given association path, just ask for it: ```ruby joiner.alias_for([:articles, :comments]) ``` And once you've loaded up all the joins, you'll want something you can push out into `ActiveRecord::Relation#joins`: ```ruby User.joins(joiner.join_values) ``` You can also check if a given association path will return potentially more than one record (thus perhaps requiring aggregation), or find out what the model at the end of the path is: ```ruby path = Joiner::Path.new(User, [:articles, :comments]) path.aggregate? #=> true path.model #=> Comment ``` ## Contributing Please note that this project now has a [Contributor Code of Conduct](http://contributor-covenant.org/version/1/0/0/). By participating in this project you agree to abide by its terms. 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create new Pull Request ## Licence Copyright (c) 2013-2020, Joiner is developed and maintained by [Pat Allan](http://freelancing-gods.com), and is released under the open MIT Licence. joiner-0.6.0/Rakefile000066400000000000000000000001561376511606000144570ustar00rootroot00000000000000require 'bundler/gem_tasks' require 'rspec/core/rake_task' RSpec::Core::RakeTask.new task :default => :spec joiner-0.6.0/joiner.gemspec000066400000000000000000000016211376511606000156430ustar00rootroot00000000000000# coding: utf-8 Gem::Specification.new do |spec| spec.name = 'joiner' spec.version = '0.6.0' spec.authors = ['Pat Allan'] spec.email = ['pat@freelancing-gods.com'] spec.summary = %q{Builds ActiveRecord joins from association paths} spec.description = %q{Builds ActiveRecord outer joins from association paths and provides references to table aliases.} spec.homepage = 'https://github.com/pat/joiner' spec.license = 'MIT' spec.files = `git ls-files`.split($/) spec.test_files = spec.files.grep(%r{^(spec)/}) spec.require_paths = ['lib'] spec.add_runtime_dependency 'activerecord', '>= 6.1.0' spec.add_development_dependency 'combustion', '~> 1.1' spec.add_development_dependency 'rails', '>= 6.1.0' spec.add_development_dependency 'rspec-rails', '~> 4' spec.add_development_dependency 'sqlite3', '~> 1.4' end joiner-0.6.0/lib/000077500000000000000000000000001376511606000135565ustar00rootroot00000000000000joiner-0.6.0/lib/joiner.rb000066400000000000000000000003671376511606000153770ustar00rootroot00000000000000require 'set' require 'active_record' module Joiner class AssociationNotFound < StandardError end end require 'joiner/alias_tracker' require 'joiner/join_aliaser' require 'joiner/join_dependency' require 'joiner/joins' require 'joiner/path' joiner-0.6.0/lib/joiner/000077500000000000000000000000001376511606000150445ustar00rootroot00000000000000joiner-0.6.0/lib/joiner/alias_tracker.rb000066400000000000000000000050111376511606000201720ustar00rootroot00000000000000# frozen_string_literal: true require "active_support/core_ext/string/conversions" # This code is taken straight from Rails, prior to v6.1.0. # I'm maintaining a copy here to save myself having to work through aliasing # logic myself - there's a good chance I don't need all of thiis, but it'll do # to get this gem working with Rails 6.1. class Joiner::AliasTracker # :nodoc: def self.create(connection, initial_table, joins, aliases = nil) if joins.empty? aliases ||= Hash.new(0) elsif aliases default_proc = aliases.default_proc || proc { 0 } aliases.default_proc = proc { |h, k| h[k] = initial_count_for(connection, k, joins) + default_proc.call(h, k) } else aliases = Hash.new { |h, k| h[k] = initial_count_for(connection, k, joins) } end aliases[initial_table] = 1 new(connection, aliases) end def self.initial_count_for(connection, name, table_joins) quoted_name = nil counts = table_joins.map do |join| if join.is_a?(Arel::Nodes::StringJoin) # quoted_name should be case ignored as some database adapters (Oracle) return quoted name in uppercase quoted_name ||= connection.quote_table_name(name) # Table names + table aliases join.left.scan( /JOIN(?:\s+\w+)?\s+(?:\S+\s+)?(?:#{quoted_name}|#{name})\sON/i ).size elsif join.is_a?(Arel::Nodes::Join) join.left.name == name ? 1 : 0 else raise ArgumentError, "joins list should be initialized by list of Arel::Nodes::Join" end end counts.sum end # table_joins is an array of arel joins which might conflict with the aliases we assign here def initialize(connection, aliases) @aliases = aliases @connection = connection end def aliased_table_for(table_name, aliased_name, type_caster) if aliases[table_name].zero? # If it's zero, we can have our table_name aliases[table_name] = 1 Arel::Table.new(table_name, type_caster: type_caster) else # Otherwise, we need to use an alias aliased_name = @connection.table_alias_for(aliased_name) # Update the count aliases[aliased_name] += 1 table_alias = if aliases[aliased_name] > 1 "#{truncate(aliased_name)}_#{aliases[aliased_name]}" else aliased_name end Arel::Table.new(table_name, type_caster: type_caster).alias(table_alias) end end attr_reader :aliases private def truncate(name) name.slice(0, @connection.table_alias_length - 2) end end joiner-0.6.0/lib/joiner/join_aliaser.rb000066400000000000000000000022261376511606000200320ustar00rootroot00000000000000# The core logic of this class is old Rails behaviour, replicated here because # their own alias logic has evolved, but I haven't yet found a way to make use # of it - and besides, this is only used to generate Thinking Sphinx's # configuration rarely - not in any web requests, so performance issues are less # critical here. class Joiner::JoinAliaser def self.call(join_root, alias_tracker) new(join_root, alias_tracker).call end def initialize(join_root, alias_tracker) @join_root = join_root @alias_tracker = alias_tracker end def call join_root.each_children do |parent, child| child.table = table_aliases_for(parent, child).first end end private attr_reader :join_root, :alias_tracker def table_aliases_for(parent, node) node.reflection.chain.map { |reflection| alias_tracker.aliased_table_for( reflection.table_name, table_alias_for(reflection, parent, reflection != node.reflection), reflection.klass.type_caster ) } end def table_alias_for(reflection, parent, join) name = reflection.alias_candidate(parent.table_name) join ? "#{name}_join" : name end end joiner-0.6.0/lib/joiner/join_dependency.rb000066400000000000000000000005371376511606000205330ustar00rootroot00000000000000class Joiner::JoinDependency < ActiveRecord::Associations::JoinDependency def join_association_for(path, alias_tracker = nil) @alias_tracker = alias_tracker Joiner::JoinAliaser.call join_root, alias_tracker path.inject(join_root) do |node, piece| node.children.detect { |child| child.reflection.name == piece } end end end joiner-0.6.0/lib/joiner/joins.rb000066400000000000000000000016461376511606000165220ustar00rootroot00000000000000require 'active_record' require 'active_support/ordered_hash' class Joiner::Joins attr_reader :model def initialize(model) @model = model @joins_cache = Set.new end def add_join_to(path) return if path.empty? joins_cache.add path_as_hash(path) end def alias_for(path) return model.table_name if path.empty? add_join_to path association_for(path).table.name end def join_values Joiner::JoinDependency.new( model, table, joins_cache.to_a, Arel::Nodes::OuterJoin ) end private attr_reader :joins_cache def alias_tracker Joiner::AliasTracker.create( model.connection, table.name, [] ) end def association_for(path) join_values.join_association_for path, alias_tracker end def path_as_hash(path) path[0..-2].reverse.inject(path.last) { |key, item| {item => key} } end def table @table ||= model.arel_table end end joiner-0.6.0/lib/joiner/path.rb000066400000000000000000000013421376511606000163250ustar00rootroot00000000000000class Joiner::Path AGGREGATE_MACROS = [:has_many, :has_and_belongs_to_many] def initialize(base, path) @base, @path = base, path end def aggregate? macros.any? { |macro| AGGREGATE_MACROS.include? macro } end def macros reflections.collect(&:macro) end def model path.empty? ? base : reflections.last.try(:klass) end private attr_reader :base, :path def reflections klass = base path.collect { |reference| klass.reflect_on_association(reference).tap { |reflection| if reflection.nil? raise Joiner::AssociationNotFound, "No association matching #{base.name}, #{path.join(', ')}" end klass = reflection.klass } } end end joiner-0.6.0/spec/000077500000000000000000000000001376511606000137425ustar00rootroot00000000000000joiner-0.6.0/spec/acceptance/000077500000000000000000000000001376511606000160305ustar00rootroot00000000000000joiner-0.6.0/spec/acceptance/joiner_spec.rb000066400000000000000000000046511376511606000206630ustar00rootroot00000000000000require 'spec_helper' describe 'Joiner' do it "handles has many associations" do joiner = Joiner::Joins.new User joiner.add_join_to [:articles] sql = User.joins(joiner.join_values).to_sql expect(sql).to match(/LEFT OUTER JOIN \"articles\"/) end it "handles multiple has many associations separately" do joiner = Joiner::Joins.new User joiner.add_join_to [:articles] joiner.add_join_to [:articles, :comments] sql = User.joins(joiner.join_values).to_sql expect(sql).to match(/LEFT OUTER JOIN \"articles\"/) expect(sql).to match(/LEFT OUTER JOIN \"comments\"/) end it "handles multiple has many associations together" do joiner = Joiner::Joins.new User joiner.add_join_to [:articles, :comments] sql = User.joins(joiner.join_values).to_sql expect(sql).to match(/LEFT OUTER JOIN \"articles\"/) expect(sql).to match(/LEFT OUTER JOIN \"comments\"/) end it "handles a belongs to association" do joiner = Joiner::Joins.new Comment joiner.add_join_to [:article] sql = Comment.joins(joiner.join_values).to_sql expect(sql).to match(/LEFT OUTER JOIN \"articles\"/) end it "handles both belongs to and has many associations separately" do joiner = Joiner::Joins.new Article joiner.add_join_to [:user] joiner.add_join_to [:comments] sql = Article.joins(joiner.join_values).to_sql expect(sql).to match(/LEFT OUTER JOIN \"users\"/) expect(sql).to match(/LEFT OUTER JOIN \"comments\"/) end it "handles both belongs to and has many associations together" do joiner = Joiner::Joins.new Article joiner.add_join_to [:user, :comments] sql = Article.joins(joiner.join_values).to_sql expect(sql).to match(/LEFT OUTER JOIN \"users\"/) expect(sql).to match(/LEFT OUTER JOIN \"comments\"/) end it "distinguishes joins via different relationships" do joiner = Joiner::Joins.new Article joiner.add_join_to [:comments] joiner.add_join_to [:user, :comments] expect(joiner.alias_for([:comments])).to eq('comments') expect(joiner.alias_for([:user, :comments])).to eq('comments_users') end it 'handles simple and deep chains' do joiner = Joiner::Joins.new Article joiner.add_join_to [:comments] joiner.add_join_to [:comments, :user, :articles] expect(joiner.alias_for([:comments])).to eq('comments') expect(joiner.alias_for([:comments, :user, :articles])).to eq( 'articles_users' ) end end joiner-0.6.0/spec/acceptance/paths_spec.rb000066400000000000000000000021001376511606000204770ustar00rootroot00000000000000require 'spec_helper' describe 'Paths' do describe 'Aggregations' do it "indicates aggregation for has many associations" do path = Joiner::Path.new User, [:articles] expect(path).to be_aggregate end it "indicates non-aggregation for belongs to association" do path = Joiner::Path.new Article, [:user] expect(path).to_not be_aggregate end it "indicates non-aggregation when the path is empty" do path = Joiner::Path.new Article, [] expect(path).to_not be_aggregate end end describe 'models' do it "determines the underlying model for an association path" do path = Joiner::Path.new User, [:articles, :comments] expect(path.model).to eq(Comment) end it "returns the base model if the path is empty" do path = Joiner::Path.new User, [] expect(path.model).to eq(User) end it "raises an exception if the path is invalid" do path = Joiner::Path.new User, [:articles, :likes] expect { path.model }.to raise_error(Joiner::AssociationNotFound) end end end joiner-0.6.0/spec/internal/000077500000000000000000000000001376511606000155565ustar00rootroot00000000000000joiner-0.6.0/spec/internal/app/000077500000000000000000000000001376511606000163365ustar00rootroot00000000000000joiner-0.6.0/spec/internal/app/models/000077500000000000000000000000001376511606000176215ustar00rootroot00000000000000joiner-0.6.0/spec/internal/app/models/article.rb000066400000000000000000000001171376511606000215700ustar00rootroot00000000000000class Article < ActiveRecord::Base has_many :comments belongs_to :user end joiner-0.6.0/spec/internal/app/models/comment.rb000066400000000000000000000001201376511606000216010ustar00rootroot00000000000000class Comment < ActiveRecord::Base belongs_to :article belongs_to :user end joiner-0.6.0/spec/internal/app/models/user.rb000066400000000000000000000001161376511606000211220ustar00rootroot00000000000000class User < ActiveRecord::Base has_many :articles has_many :comments end joiner-0.6.0/spec/internal/config/000077500000000000000000000000001376511606000170235ustar00rootroot00000000000000joiner-0.6.0/spec/internal/config/database.yml000066400000000000000000000001001376511606000213010ustar00rootroot00000000000000test: adapter: sqlite3 database: db/combustion_test.sqlite joiner-0.6.0/spec/internal/db/000077500000000000000000000000001376511606000161435ustar00rootroot00000000000000joiner-0.6.0/spec/internal/db/schema.rb000066400000000000000000000004771376511606000177400ustar00rootroot00000000000000ActiveRecord::Schema.define do create_table :articles, :force => true do |t| t.integer :user_id t.timestamps end create_table :comments, :force => true do |t| t.integer :article_id t.integer :user_id t.timestamps end create_table :users, :force => true do |t| t.timestamps end end joiner-0.6.0/spec/internal/log/000077500000000000000000000000001376511606000163375ustar00rootroot00000000000000joiner-0.6.0/spec/internal/log/.gitignore000066400000000000000000000000051376511606000203220ustar00rootroot00000000000000*.logjoiner-0.6.0/spec/spec_helper.rb000066400000000000000000000003131376511606000165550ustar00rootroot00000000000000require 'rubygems' require 'bundler/setup' require 'combustion' Combustion.initialize! :active_record require 'rspec/rails' RSpec.configure do |config| config.use_transactional_fixtures = true end