c80_active_record_union 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: fd19d96b28ba3d5890207d1e3111a60c75697a45
4
+ data.tar.gz: 73b46e8ed3b35ff0db3887bba2b4bdba39f74182
5
+ SHA512:
6
+ metadata.gz: 07c4b25ed7676c9fb4c8ee2091c5fb07e64f72c75e74e952302b0fdbcc08f4725886a19a2608bd34d64150de22b1fbce132fa26bedec376745dfabd680f96c09
7
+ data.tar.gz: 26b9395b0434eba2098a433dc1f7f6654135e3b97ea5b01cde302592753c5fb8f539dfc2cbe7e6128839b2b94f334ec9893e7d53b57a15353bffe633b7258a6f
@@ -0,0 +1,24 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ *.gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
19
+ *.bundle
20
+ *.so
21
+ *.o
22
+ *.a
23
+ mkmf.log
24
+ .idea/
@@ -0,0 +1,19 @@
1
+ language: ruby
2
+ addons:
3
+ postgresql: "9.4"
4
+ rvm:
5
+ - 2.3.1
6
+ - 2.2.5
7
+ - 2.1.8
8
+ - 2.0.0
9
+ gemfile:
10
+ - rails_4_2.gemfile
11
+ - rails_5_0.gemfile
12
+ matrix:
13
+ exclude:
14
+ # Rails 5 requires Ruby 2.2+:
15
+ - rvm: 2.1.8
16
+ gemfile: rails_5_0.gemfile
17
+ - rvm: 2.0.0
18
+ gemfile: rails_5_0.gemfile
19
+ script: bundle exec rspec
@@ -0,0 +1,3 @@
1
+ ActiveRecordUnion is dedicated to the public domain by its author, Brian Hempel. No rights are reserved. No restrictions are placed on the use of ActiveRecordUnion. That freedom also means, of course, that no warrenty of fitness is claimed; use ActiveRecordUnion at your own risk.
2
+
3
+ This public domain dedication follows the the CC0 1.0 at https://creativecommons.org/publicdomain/zero/1.0/
@@ -0,0 +1,220 @@
1
+ # ActiveRecordUnion
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/active_record_union.svg)](http://badge.fury.io/rb/active_record_union)
4
+ [![Build Status](https://travis-ci.org/brianhempel/active_record_union.svg)](https://travis-ci.org/brianhempel/active_record_union)
5
+
6
+ Use unions on ActiveRecord scopes without ugliness.
7
+
8
+ If you find yourself writing `pluck(:id)` and then feeding that into another query, you may be able to reduce the number of database requests by using a nested query or a UNION without writing crazy JOIN statements.
9
+
10
+ Quick usage examples:
11
+
12
+ ```ruby
13
+ current_user.posts.union(Post.published)
14
+ current_user.posts.union(Post.published).where(id: [6, 7])
15
+ current_user.posts.union("published_at < ?", Time.now)
16
+ user_1.posts.union(user_2.posts).union(Post.published)
17
+ user_1.posts.union_all(user_2.posts)
18
+ ```
19
+
20
+ ActiveRecordUnion is tested against Rails 4.2 and Rails 5.0. It may or may not work on Rails 4.0/4.1.
21
+
22
+ ## Installation
23
+
24
+ Add this line to your application's Gemfile:
25
+
26
+ ```ruby
27
+ gem 'active_record_union'
28
+ ```
29
+
30
+ And then execute:
31
+
32
+ $ bundle
33
+
34
+ Or install it yourself as:
35
+
36
+ $ gem install active_record_union
37
+
38
+ ## Usage
39
+
40
+ ActiveRecordUnion adds `union` and `union_all` methods to `ActiveRecord::Relation` so we can easily gather together queries on mutiple scopes.
41
+
42
+ Consider some users with posts:
43
+
44
+ ```ruby
45
+ class User < ActiveRecord::Base
46
+ has_many :posts
47
+ end
48
+
49
+ class Post < ActiveRecord::Base
50
+ belongs_to :user
51
+
52
+ scope :published, -> { where("published_at < ?", Time.now) }
53
+ end
54
+ ```
55
+
56
+ With ActiveRecordUnion, we can do:
57
+
58
+ ```ruby
59
+ # the current user's (draft) posts and all published posts from anyone
60
+ current_user.posts.union(Post.published)
61
+ ```
62
+
63
+ Which is equivalent to the following SQL:
64
+
65
+ ```sql
66
+ SELECT "posts".* FROM (
67
+ SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
68
+ UNION
69
+ SELECT "posts".* FROM "posts" WHERE (published_at < '2014-07-19 16:04:21.918366')
70
+ ) "posts"
71
+ ```
72
+
73
+ Because the `union` method returns another `ActiveRecord::Relation`, we can run further queries on the union.
74
+
75
+ ```ruby
76
+ current_user.posts.union(Post.published).where(id: [6, 7])
77
+ ```
78
+ ```sql
79
+ SELECT "posts".* FROM (
80
+ SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
81
+ UNION
82
+ SELECT "posts".* FROM "posts" WHERE (published_at < '2014-07-19 16:06:04.460771')
83
+ ) "posts" WHERE "posts"."id" IN (6, 7)
84
+ ```
85
+
86
+ The `union` method can also accept anything that `where` does.
87
+
88
+ ```ruby
89
+ current_user.posts.union("published_at < ?", Time.now)
90
+ # equivalent to...
91
+ current_user.posts.union(Post.where("published_at < ?", Time.now))
92
+ ```
93
+
94
+ We can also chain `union` calls to UNION more than two scopes, though the UNIONs will be nested which may not be the prettiest SQL.
95
+
96
+ ```ruby
97
+ user_1.posts.union(user_2.posts).union(Post.published)
98
+ # equivalent to...
99
+ [user_1.posts, user_2.posts, Post.published].inject(:union)
100
+ ```
101
+ ```sql
102
+ SELECT "posts".* FROM (
103
+ SELECT "posts".* FROM (
104
+ SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
105
+ UNION
106
+ SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 2
107
+ ) "posts"
108
+ UNION
109
+ SELECT "posts".* FROM "posts" WHERE (published_at < '2014-07-19 16:12:45.882648')
110
+ ) "posts"
111
+ ```
112
+
113
+ ### UNION ALL
114
+
115
+ By default, UNION will remove any duplicates from the result set. If you don't care about duplicates or you know that the two queries you are combining will not have duplicates, you call use UNION ALL to tell the database to skip its deduplication step. In some cases this can give significant performance improvements.
116
+
117
+ ```ruby
118
+ user_1.posts.union_all(user_2.posts)
119
+ ```
120
+ ```sql
121
+ SELECT "posts".* FROM (
122
+ SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
123
+ UNION ALL
124
+ SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 2
125
+ ) "posts"
126
+ ```
127
+
128
+ ## Caveats
129
+
130
+ There's a couple things to be aware of when using ActiveRecordUnion:
131
+
132
+ 1. ActiveRecordUnion with raise an error if you try to UNION any relations that do any preloading/eager-loading. There's no sensible way to do the preloading in the subselects. If enough people complain maybe we can change ActiveRecordUnion to let the queries run anyway but without preloading any records.
133
+ 2. There's no easy way to get SQLite to allow ORDER BY in the UNION subselects. If you get a syntax error, you can either write `my_relation.reorder(nil).union(other.reorder(nil))` or switch to Postgres.
134
+
135
+ ## Another nifty way to reduce extra queries
136
+
137
+ ActiveRecord already supports turning scopes into nested queries in WHERE clauses. The nested relation defaults to selecting `id` by default.
138
+
139
+ For example, if a user `has_and_belongs_to_many :favorited_posts`, we can quickly find which of the current user's posts are liked by a certain other user.
140
+
141
+ ```ruby
142
+ current_user.posts.where(id: other_user.favorited_posts)
143
+ ```
144
+ ```sql
145
+ SELECT "posts".* FROM "posts"
146
+ WHERE "posts"."user_id" = 1
147
+ AND "posts"."id" IN (
148
+ SELECT "posts"."id"
149
+ FROM "posts" INNER JOIN "user_favorited_posts" ON "posts"."id" = "user_favorited_posts"."post_id"
150
+ WHERE "user_favorited_posts"."user_id" = 2
151
+ )
152
+ ```
153
+
154
+ If we want to select something other than `id`, we use `select` to specify. The following is equivalent to the above, but the query is done against the join table.
155
+
156
+ ```ruby
157
+ current_user.posts.where(id: UserFavoritedPost.where(user_id: other_user.id).select(:post_id))
158
+ ```
159
+ ```sql
160
+ SELECT "posts".* FROM "posts"
161
+ WHERE "posts"."user_id" = 1
162
+ AND "posts"."id" IN (
163
+ SELECT "user_favorited_posts"."post_id"
164
+ FROM "user_favorited_posts"
165
+ WHERE "user_favorited_posts"."user_id" = 2
166
+ )
167
+ ```
168
+
169
+ (The above example is illustrative only. It might be better with a JOIN.)
170
+
171
+ ## State of the Union in ActiveRecord
172
+
173
+ Why does this gem exist?
174
+
175
+ Right now in ActiveRecord, if we call `scope.union` we get an `Arel::Nodes::Union` object instead of an `ActiveRecord::Relation`.
176
+
177
+ We could call `to_sql` on the Arel object and then use `find_by_sql`, but that's not super clean. Also, on Rails 4.0 and 4.1 if the original scopes included an association then the `to_sql` may produce a query with values that need to be bound (represented by `?`s in the SQL) and we have to provide those ourselves. (E.g. `user.posts.to_sql` produces `SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?`.) Rails 4.2's `to_sql` replaces the bind values before showing the SQL string and thus can more readily be used with `find_by_sql`. (E.g. Rails 4.2 `to_sql` would say `WHERE "posts"."user_id" = 1` instead of `WHERE "posts"."user_id" = ?`.)
178
+
179
+ While ActiveRecord may eventually have the ability to cleanly perform UNIONs, it's currently stalled. If you're interested, the relevant URLs as of July 2014 are:
180
+
181
+ https://github.com/rails/rails/issues/939 and
182
+ https://github.com/rails/arel/pull/239 and
183
+ https://github.com/yakaz/rails/commit/29b8ebd187e0888d5e71b2e1e4a12334860bc76c
184
+
185
+ This is a gem not a Rails pull request because the standard of code quality for a PR is a bit higher, and we'd have to wait for the PR to be merged and relased to use UNIONs. That said, the code here is fairly clean and it may end up in a PR sometime.
186
+
187
+ ## Changelog
188
+
189
+ **1.2.0** - June 26, 2016
190
+ - Ready for Rails 5.0! Updates provided by [@glebm](https://github.com/glebm).
191
+
192
+ **1.1.1** - Mar 19, 2016
193
+ - Fix broken polymorphic associations and joins due to improper handling of bind values. Fix by [@efradelos](https://github.com/efradelos), reported by [@Machiaweliczny](https://github.com/Machiaweliczny) and [@seandougall](https://github.com/seandougall).
194
+ - Quote table name aliases properly. Reported by [@odedniv](https://github.com/odedniv).
195
+
196
+ **1.1.0** - Mar 29, 2015 - Add UNION ALL support, courtesy of [@pic](https://github.com/pic).
197
+
198
+ **1.0.1** - Sept 2, 2014 - Allow ORDER BY in UNION subselects for databases that support it (not SQLite).
199
+
200
+ **1.0.0** - July 24, 2014 - Initial release.
201
+
202
+ ## License
203
+
204
+ ActiveRecordUnion is dedicated to the public domain by its author, Brian Hempel. No rights are reserved. No restrictions are placed on the use of ActiveRecordUnion. That freedom also means, of course, that no warrenty of fitness is claimed; use ActiveRecordUnion at your own risk.
205
+
206
+ This public domain dedication follows the the CC0 1.0 at https://creativecommons.org/publicdomain/zero/1.0/
207
+
208
+ ## Contributing
209
+
210
+ 1. Fork it ( https://github.com/brianhempel/active_record_union/fork )
211
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
212
+ 3. Run the tests:
213
+ 1. Install MySQL and PostgreSQL.
214
+ 2. You may need to create a `test_active_record_union` database on each under the default user.
215
+ 3. Run `rake` to test with all supported Rails versions.
216
+ 4. Run `rake test_rails_4_2` or `rake test_rails_5_0` to test a specific Rails version.
217
+ 4. There is also a `bin/console` command to load up a REPL for playing around
218
+ 5. Commit your changes (`git commit -am 'Add some feature'`)
219
+ 6. Push to the branch (`git push origin my-new-feature`)
220
+ 7. Create a new Pull Request
@@ -0,0 +1,61 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task :default => :test_all_gemfiles
6
+
7
+ module TestTasks
8
+ module_function
9
+
10
+ TEST_CMD = 'bundle exec rspec'
11
+
12
+ def run_all(envs, cmd = "bundle install && #{TEST_CMD}", success_message)
13
+ statuses = envs.map { |env| run(env, cmd) }
14
+ failed = statuses.reject(&:first).map(&:last)
15
+ if failed.empty?
16
+ $stderr.puts success_message
17
+ else
18
+ $stderr.puts "❌ FAILING (#{failed.size}):\n#{failed.map { |env| to_bash_cmd_with_env(cmd, env) } * "\n"}"
19
+ exit 1
20
+ end
21
+ end
22
+
23
+ def run_one(env, cmd = "bundle install && #{TEST_CMD}")
24
+ full_cmd = to_bash_cmd_with_env(cmd, env)
25
+ exec(full_cmd)
26
+ end
27
+
28
+ def run(env, cmd)
29
+ Bundler.with_clean_env do
30
+ full_cmd = to_bash_cmd_with_env(cmd, env)
31
+ $stderr.puts full_cmd
32
+ isSuccess = system(full_cmd)
33
+ [isSuccess, env]
34
+ end
35
+ end
36
+
37
+ def gemfiles
38
+ Dir.glob('*.gemfile').sort
39
+ end
40
+
41
+ def to_bash_cmd_with_env(cmd, env)
42
+ "(export #{env.map { |k, v| "#{k}=#{v}" }.join(' ')}; #{cmd})"
43
+ end
44
+ end
45
+
46
+ desc 'Test all Gemfiles'
47
+ task :test_all_gemfiles do
48
+ envs = TestTasks.gemfiles.map { |gemfile| { 'BUNDLE_GEMFILE' => gemfile } }
49
+ TestTasks.run_all envs, "✓ Tests pass with all #{envs.size} gemfiles"
50
+ end
51
+
52
+
53
+ TestTasks.gemfiles.each do |gemfile|
54
+ rails_version_underscored = gemfile[/rails_(.+)\.gemfile/, 1]
55
+
56
+ desc "Test Rails #{rails_version_underscored.gsub("_", ".")}"
57
+ task :"test_rails_#{rails_version_underscored}" do
58
+ env = { 'BUNDLE_GEMFILE' => gemfile }
59
+ TestTasks.run_one(env)
60
+ end
61
+ end
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # This is for development only.
4
+
5
+ require "bundler/setup"
6
+
7
+ Bundler.require(:development)
8
+ require "c80_active_record_union"
9
+
10
+ ActiveRecord::Base.establish_connection(
11
+ adapter: "sqlite3",
12
+ database: ":memory:"
13
+ )
14
+
15
+ require File.expand_path("../../spec/support/models.rb", __FILE__)
16
+
17
+ # extensions over models.rb to help with making the README
18
+
19
+ class User
20
+ has_and_belongs_to_many :favorited_posts,
21
+ class_name: "Post",
22
+ join_table: "user_favorited_posts"
23
+ end
24
+
25
+ class UserFavoritedPost < ActiveRecord::Base
26
+ connection.create_table :user_favorited_posts, force: true do |t|
27
+ t.integer :post_id
28
+ t.integer :user_id
29
+ end
30
+ end
31
+
32
+ class Post
33
+ scope :published, -> { where("published_at < ?", Time.now) }
34
+ end
35
+
36
+ binding.pry
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'c80_active_record_union/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "c80_active_record_union"
8
+ spec.version = C80ActiveRecordUnion::VERSION
9
+ spec.authors = ["Brian Hempel"]
10
+ spec.email = ["plasticchicken@gmail.com"]
11
+ spec.summary = %q{UNIONs in ActiveRecord! Adds proper union and union_all methods to ActiveRecord::Relation.}
12
+ spec.description = spec.summary
13
+ spec.homepage = "https://github.com/brianhempel/active_record_union"
14
+ spec.license = "Public Domain"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ # spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.files.grep(%r{^bin/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activerecord", ">= 4.0"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.6"
24
+ spec.add_development_dependency "rake"
25
+ spec.add_development_dependency "rspec", "~> 3.0"
26
+ spec.add_development_dependency "pry"
27
+ spec.add_development_dependency "sqlite3"
28
+ spec.add_development_dependency "pg"
29
+ spec.add_development_dependency "mysql2"
30
+ end
@@ -0,0 +1,9 @@
1
+ require "c80_active_record_union/version"
2
+ require "active_record"
3
+ require "c80_active_record_union/active_record/relation/union"
4
+
5
+ module ActiveRecord
6
+ class Relation
7
+ include Union
8
+ end
9
+ end
@@ -0,0 +1,82 @@
1
+ module ActiveRecord
2
+ class Relation
3
+ module Union
4
+
5
+ SET_OPERATION_TO_AREL_CLASS = {
6
+ union: Arel::Nodes::Union,
7
+ union_all: Arel::Nodes::UnionAll
8
+ }
9
+
10
+ def union(relation_or_where_arg, *args)
11
+ set_operation(:union, relation_or_where_arg, *args)
12
+ end
13
+
14
+ def union_all(relation_or_where_arg, *args)
15
+ set_operation(:union_all, relation_or_where_arg, *args)
16
+ end
17
+
18
+ private
19
+
20
+ def set_operation(operation, relation_or_where_arg, *args)
21
+ other = if args.size == 0 && Relation === relation_or_where_arg
22
+ relation_or_where_arg
23
+ else
24
+ @klass.where(relation_or_where_arg, *args)
25
+ end
26
+
27
+ # NB::2016-08-10
28
+ # verify_relations_for_set_operation!(operation, self, other)
29
+
30
+ # Postgres allows ORDER BY in the UNION subqueries if each subquery is surrounded by parenthesis
31
+ # but SQLite does not allow parens around the subqueries; you will have to explicitly do `relation.reorder(nil)` in SQLite
32
+ if Arel::Visitors::SQLite === self.connection.visitor
33
+ left, right = self.ast, other.ast
34
+ else
35
+ left, right = Arel::Nodes::Grouping.new(self.ast), Arel::Nodes::Grouping.new(other.ast)
36
+ end
37
+
38
+ set = SET_OPERATION_TO_AREL_CLASS[operation].new(left, right)
39
+ from = Arel::Nodes::TableAlias.new(set, @klass.arel_table.name)
40
+ if ActiveRecord::VERSION::MAJOR >= 5
41
+ relation = @klass.unscoped.spawn
42
+ relation.from_clause = UnionFromClause.new(from, nil, self.bound_attributes + other.bound_attributes)
43
+ else
44
+ relation = @klass.unscoped.from(from)
45
+ relation.bind_values = self.arel.bind_values + self.bind_values + other.arel.bind_values + other.bind_values
46
+ end
47
+ relation
48
+ end
49
+
50
+ def verify_relations_for_set_operation!(operation, *relations)
51
+ includes_relations = relations.select { |r| r.includes_values.any? }
52
+
53
+ if includes_relations.any?
54
+ raise ArgumentError.new("Cannot #{operation} relation with includes.")
55
+ end
56
+
57
+ preload_relations = relations.select { |r| r.preload_values.any? }
58
+ if preload_relations.any?
59
+ raise ArgumentError.new("Cannot #{operation} relation with preload.")
60
+ end
61
+
62
+ eager_load_relations = relations.select { |r| r.eager_load_values.any? }
63
+ if eager_load_relations.any?
64
+ raise ArgumentError.new("Cannot #{operation} relation with eager load.")
65
+ end
66
+ end
67
+
68
+ if ActiveRecord::VERSION::MAJOR >= 5
69
+ class UnionFromClause < ActiveRecord::Relation::FromClause
70
+ def initialize(value, name, bound_attributes)
71
+ super(value, name)
72
+ @bound_attributes = bound_attributes
73
+ end
74
+
75
+ def binds
76
+ @bound_attributes
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,3 @@
1
+ module C80ActiveRecordUnion
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in active_record_union.gemspec
4
+ gemspec
5
+
6
+ gem 'rails', '~> 4.2.6'
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in active_record_union.gemspec
4
+ gemspec
5
+
6
+ gem 'rails', ['>= 5.0.0.rc2', '< 5.1']
@@ -0,0 +1,41 @@
1
+ require "bundler/setup"
2
+
3
+ Bundler.require(:development)
4
+ require "c80_active_record_union"
5
+
6
+ require "support/databases"
7
+
8
+ Databases.connect_to_sqlite
9
+
10
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
11
+ RSpec.configure do |config|
12
+ # Run specs in random order to surface order dependencies. If you find an
13
+ # order dependency and want to debug it, you can fix the order by providing
14
+ # the seed, which is printed after each run.
15
+ # --seed 1234
16
+ config.order = :random
17
+
18
+ # Seed global randomization in this process using the `--seed` CLI option.
19
+ # Setting this allows you to use `--seed` to deterministically reproduce
20
+ # test failures related to randomization by passing the same `--seed` value
21
+ # as the one that triggered the failure.
22
+ Kernel.srand config.seed
23
+
24
+ config.expect_with :rspec do |expectations|
25
+ # Enable only the newer, non-monkey-patching expect syntax.
26
+ # For more details, see:
27
+ # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax
28
+ expectations.syntax = :expect
29
+ end
30
+
31
+ config.mock_with :rspec do |mocks|
32
+ # Enable only the newer, non-monkey-patching expect syntax.
33
+ # For more details, see:
34
+ # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
35
+ mocks.syntax = :expect
36
+
37
+ # Prevents you from mocking or stubbing a method that does not exist on
38
+ # a real object. This is generally recommended.
39
+ mocks.verify_partial_doubles = true
40
+ end
41
+ end
@@ -0,0 +1,49 @@
1
+ module Databases
2
+ extend self
3
+
4
+ def connect_to_sqlite
5
+ ActiveRecord::Base.establish_connection(
6
+ adapter: "sqlite3",
7
+ database: ":memory:"
8
+ )
9
+ load("support/models.rb")
10
+ end
11
+
12
+ def connect_to_postgres
13
+ ActiveRecord::Base.establish_connection(
14
+ adapter: "postgresql"
15
+ )
16
+ ActiveRecord::Base.connection.recreate_database("test_active_record_union")
17
+ ActiveRecord::Base.establish_connection(
18
+ adapter: "postgresql",
19
+ database: "test_active_record_union"
20
+ )
21
+ load("support/models.rb")
22
+ end
23
+
24
+ def connect_to_mysql
25
+ ActiveRecord::Base.establish_connection(
26
+ adapter: "mysql2"
27
+ )
28
+ ActiveRecord::Base.connection.recreate_database("test_active_record_union")
29
+ ActiveRecord::Base.establish_connection(
30
+ adapter: "mysql2",
31
+ database: "test_active_record_union"
32
+ )
33
+ load("support/models.rb")
34
+ end
35
+
36
+ def with_postgres(&block)
37
+ connect_to_postgres
38
+ yield
39
+ ensure
40
+ connect_to_sqlite
41
+ end
42
+
43
+ def with_mysql(&block)
44
+ connect_to_mysql
45
+ yield
46
+ ensure
47
+ connect_to_sqlite
48
+ end
49
+ end
@@ -0,0 +1,18 @@
1
+ ActiveRecord::Base.connection.create_table :users, force: true do |t|
2
+ end
3
+
4
+ class User < ActiveRecord::Base
5
+ has_many :posts
6
+ has_many :drafts, -> { where draft: true }, class_name: "Post"
7
+ end unless defined?(User)
8
+
9
+ ActiveRecord::Base.connection.create_table :posts, force: true do |t|
10
+ t.integer :user_id
11
+ t.boolean :draft
12
+ t.timestamp :published_at
13
+ t.timestamps null: false
14
+ end
15
+
16
+ class Post < ActiveRecord::Base
17
+ belongs_to :user
18
+ end unless defined?(Post)
@@ -0,0 +1,174 @@
1
+ require "spec_helper"
2
+
3
+ describe ActiveRecord::Relation do
4
+ TIME = Time.utc(2014, 7, 19, 0, 0, 0)
5
+ SQL_TIME = ActiveRecord::VERSION::MAJOR >= 5 ? "2014-07-19 00:00:00" : "2014-07-19 00:00:00.000000"
6
+
7
+ describe ".union" do
8
+ it "returns an ActiveRecord::Relation" do
9
+ expect(User.all.union(User.all)).to be_kind_of(ActiveRecord::Relation)
10
+ end
11
+
12
+ it "requires an argument" do
13
+ expect{User.all.union}.to raise_error(ArgumentError)
14
+ end
15
+
16
+ it "explodes if asked to union a relation with includes" do
17
+ expect{User.all.union(User.includes(:posts))}.to raise_error(ArgumentError)
18
+ expect{User.includes(:posts).union(User.all)}.to raise_error(ArgumentError)
19
+ end
20
+
21
+ it "explodes if asked to union a relation with preload values" do
22
+ expect{User.all.union(User.preload(:posts))}.to raise_error(ArgumentError)
23
+ expect{User.preload(:posts).union(User.all)}.to raise_error(ArgumentError)
24
+ end
25
+
26
+ it "explodes if asked to union a relation with eager loading" do
27
+ expect{User.all.union(User.eager_load(:posts))}.to raise_error(ArgumentError)
28
+ expect{User.eager_load(:posts).union(User.all)}.to raise_error(ArgumentError)
29
+ end
30
+
31
+ it "works" do
32
+ union = User.new(id: 1).posts.union(Post.where("created_at > ?", TIME))
33
+
34
+ expect(union.to_sql.squish).to eq(
35
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 UNION SELECT \"posts\".* FROM \"posts\" WHERE (created_at > '#{SQL_TIME}') ) \"posts\""
36
+ )
37
+ expect(union.arel.to_sql.squish).to eq(
38
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = ? UNION SELECT \"posts\".* FROM \"posts\" WHERE (created_at > '#{SQL_TIME}') ) \"posts\""
39
+ )
40
+ expect{union.to_a}.to_not raise_error
41
+ end
42
+
43
+ def bind_values_from_relation(relation)
44
+ if ActiveRecord::VERSION::MAJOR >= 5
45
+ relation.bound_attributes.map { |a| a.value_for_database }
46
+ else
47
+ (relation.arel.bind_values + relation.bind_values).map { |_column, value| value }
48
+ end
49
+ end
50
+
51
+ it "binds values properly" do
52
+ user1 = User.new(id: 1)
53
+ user2 = User.new(id: 2)
54
+ user3 = User.new(id: 3)
55
+
56
+ union = user1.posts.union(user2.posts).where.not(id: user3.posts)
57
+
58
+ # Inside ActiveRecord the bind value list is
59
+ # (union.arel.bind_values + union.bind_values)
60
+ bind_values = bind_values_from_relation union
61
+
62
+ expect(bind_values).to eq([1, 2, 3])
63
+ end
64
+
65
+ it "binds values properly on joins" do
66
+ union = User.joins(:drafts).union(User.where(id: 11))
67
+
68
+ bind_values = bind_values_from_relation union
69
+ expect(bind_values).to eq([true, 11])
70
+
71
+
72
+ expect(union.to_sql.squish).to eq(
73
+ "SELECT \"users\".* FROM ( SELECT \"users\".* FROM \"users\" INNER JOIN \"posts\" ON \"posts\".\"user_id\" = \"users\".\"id\" AND \"posts\".\"draft\" = 't' UNION SELECT \"users\".* FROM \"users\" WHERE \"users\".\"id\" = 11 ) \"users\""
74
+ )
75
+ expect{union.to_a}.to_not raise_error
76
+ end
77
+
78
+ it "doesn't repeat default scopes" do
79
+ expect(Time).to receive(:now) { Time.utc(2014, 7, 24, 0, 0, 0) }
80
+ sql_now = "2014-07-24 00:00:00#{".000000" if ActiveRecord::VERSION::MAJOR < 5}"
81
+
82
+ class PublishedPost < ActiveRecord::Base
83
+ self.table_name = "posts"
84
+ default_scope { where("published_at < ?", Time.now) }
85
+ end
86
+
87
+ union = PublishedPost.where("created_at > ?", TIME).union(User.new(id: 1).posts)
88
+
89
+ expect(union.to_sql.squish).to eq(
90
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE (published_at < '#{sql_now}') AND (created_at > '#{SQL_TIME}') UNION SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 ) \"posts\""
91
+ )
92
+ expect{union.to_a}.to_not raise_error
93
+ end
94
+
95
+ context "with ORDER BY in subselects" do
96
+ let :union do
97
+ User.new(id: 1).posts.order(:created_at).union(
98
+ Post.where("created_at > ?", TIME).order(:created_at)
99
+ ).order(:created_at)
100
+ end
101
+
102
+ context "in SQLite" do
103
+ it "lets ORDER BY in query subselects throw a syntax error" do
104
+ expect(union.to_sql.squish).to eq(
105
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 ORDER BY \"posts\".\"created_at\" ASC UNION SELECT \"posts\".* FROM \"posts\" WHERE (created_at > '#{SQL_TIME}') ORDER BY \"posts\".\"created_at\" ASC ) \"posts\" ORDER BY \"posts\".\"created_at\" ASC"
106
+ )
107
+ expect{union.to_a}.to raise_error(ActiveRecord::StatementInvalid)
108
+ end
109
+ end
110
+
111
+ context "in Postgres" do
112
+ it "wraps query subselects in parentheses to allow ORDER BY clauses" do
113
+ Databases.with_postgres do
114
+ expect(union.to_sql.squish).to eq(
115
+ "SELECT \"posts\".* FROM ( (SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 ORDER BY \"posts\".\"created_at\" ASC) UNION (SELECT \"posts\".* FROM \"posts\" WHERE (created_at > '#{SQL_TIME}') ORDER BY \"posts\".\"created_at\" ASC) ) \"posts\" ORDER BY \"posts\".\"created_at\" ASC"
116
+ )
117
+ expect{union.to_a}.to_not raise_error
118
+ end
119
+ end
120
+ end
121
+
122
+ context "in MySQL" do
123
+ it "wraps query subselects in parentheses to allow ORDER BY clauses" do
124
+ Databases.with_mysql do
125
+ expect(union.to_sql.squish).to eq(
126
+ "SELECT `posts`.* FROM ( (SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 1 ORDER BY `posts`.`created_at` ASC) UNION (SELECT `posts`.* FROM `posts` WHERE (created_at > '#{SQL_TIME}') ORDER BY `posts`.`created_at` ASC) ) `posts` ORDER BY `posts`.`created_at` ASC"
127
+ )
128
+ expect{union.to_a}.to_not raise_error
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ context "builds a scope when given" do
135
+ it "a hash" do
136
+ union = User.new(id: 1).posts.union(id: 2)
137
+
138
+ expect(union.to_sql.squish).to eq(
139
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 UNION SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"id\" = 2 ) \"posts\""
140
+ )
141
+ expect{union.to_a}.to_not raise_error
142
+ end
143
+
144
+ it "multiple arguments" do
145
+ union = User.new(id: 1).posts.union("created_at > ?", TIME)
146
+
147
+ expect(union.to_sql.squish).to eq(
148
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 UNION SELECT \"posts\".* FROM \"posts\" WHERE (created_at > '#{SQL_TIME}') ) \"posts\""
149
+ )
150
+ expect{union.to_a}.to_not raise_error
151
+ end
152
+
153
+ it "arel" do
154
+ union = User.new(id: 1).posts.union(Post.arel_table[:id].eq(2).or(Post.arel_table[:id].eq(3)))
155
+
156
+ expect(union.to_sql.squish).to eq(
157
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 UNION SELECT \"posts\".* FROM \"posts\" WHERE (\"posts\".\"id\" = 2 OR \"posts\".\"id\" = 3) ) \"posts\""
158
+ )
159
+ expect{union.to_a}.to_not raise_error
160
+ end
161
+ end
162
+ end
163
+
164
+ describe ".union_all" do
165
+ it "works" do
166
+ union = User.new(id: 1).posts.union_all(Post.where("created_at > ?", TIME))
167
+
168
+ expect(union.to_sql.squish).to eq(
169
+ "SELECT \"posts\".* FROM ( SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 UNION ALL SELECT \"posts\".* FROM \"posts\" WHERE (created_at > '#{SQL_TIME}') ) \"posts\""
170
+ )
171
+ expect{union.to_a}.to_not raise_error
172
+ end
173
+ end
174
+ end
metadata ADDED
@@ -0,0 +1,178 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: c80_active_record_union
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Hempel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-08-10 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: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.6'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pg
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: mysql2
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: UNIONs in ActiveRecord! Adds proper union and union_all methods to ActiveRecord::Relation.
126
+ email:
127
+ - plasticchicken@gmail.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".gitignore"
133
+ - ".idea/.rakeTasks"
134
+ - ".travis.yml"
135
+ - LICENSE.txt
136
+ - README.md
137
+ - Rakefile
138
+ - bin/console
139
+ - c80_active_record_union.gemspec
140
+ - lib/c80_active_record_union.rb
141
+ - lib/c80_active_record_union/active_record/relation/union.rb
142
+ - lib/c80_active_record_union/version.rb
143
+ - rails_4_2.gemfile
144
+ - rails_5_0.gemfile
145
+ - spec/spec_helper.rb
146
+ - spec/support/databases.rb
147
+ - spec/support/models.rb
148
+ - spec/union_spec.rb
149
+ homepage: https://github.com/brianhempel/active_record_union
150
+ licenses:
151
+ - Public Domain
152
+ metadata: {}
153
+ post_install_message:
154
+ rdoc_options: []
155
+ require_paths:
156
+ - lib
157
+ required_ruby_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ required_rubygems_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ requirements: []
168
+ rubyforge_project:
169
+ rubygems_version: 2.5.1
170
+ signing_key:
171
+ specification_version: 4
172
+ summary: UNIONs in ActiveRecord! Adds proper union and union_all methods to ActiveRecord::Relation.
173
+ test_files:
174
+ - spec/spec_helper.rb
175
+ - spec/support/databases.rb
176
+ - spec/support/models.rb
177
+ - spec/union_spec.rb
178
+ - bin/console