activerecord-refined 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ # Compares the cost of building the same queries through this gem's block
2
+ # DSL and through ActiveRecord's other argument styles: hash conditions,
3
+ # string conditions, raw Arel, and relation and/or chains. Only query
4
+ # construction (through to_sql) is measured; every style produces the same
5
+ # SQL, which the script prints first as a sanity check.
6
+ #
7
+ # Run without bundler, so the profiling gems don't need to live in the
8
+ # Gemfile:
9
+ #
10
+ # gem install benchmark-ips memory_profiler
11
+ # ruby -Ilib benchmark/query_building.rb
12
+
13
+ require "benchmark/ips"
14
+ require "memory_profiler"
15
+ require "active_record"
16
+ require "activerecord-refined"
17
+
18
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
19
+ ActiveRecord::Schema.verbose = false
20
+ ActiveRecord::Schema.define do
21
+ create_table(:users) {|t| t.string :name; t.integer :age }
22
+ end
23
+
24
+ class User < ActiveRecord::Base; end
25
+
26
+ T = User.arel_table
27
+
28
+ # Each variant must generate equivalent SQL; sanity-print once.
29
+ VARIANTS = {
30
+ "simple equality" => {
31
+ "hash" => -> { User.where(name: "alice").to_sql },
32
+ "string" => -> { User.where("name = ?", "alice").to_sql },
33
+ "arel" => -> { User.where(T[:name].eq("alice")).to_sql },
34
+ "block" => -> { User.where { :name == "alice" }.to_sql },
35
+ },
36
+ "range (BETWEEN)" => {
37
+ "hash" => -> { User.where(age: 20..40).to_sql },
38
+ "arel" => -> { User.where(T[:age].between(20..40)).to_sql },
39
+ "block" => -> { User.where { :age.in?(20..40) }.to_sql },
40
+ },
41
+ "LIKE" => {
42
+ "string" => -> { User.where("name LIKE ?", "ma%").to_sql },
43
+ "arel" => -> { User.where(T[:name].matches("ma%", nil, true)).to_sql },
44
+ "block" => -> { User.where { :name.like?("al%") }.to_sql },
45
+ },
46
+ "compound AND/OR" => {
47
+ "string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "alice", "bob").to_sql },
48
+ "arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("alice").or(T[:name].eq("bob")))).to_sql },
49
+ "relation" => -> { User.where(age: 18..).and(User.where(name: "alice").or(User.where(name: "bob"))).to_sql },
50
+ "block" => -> { User.where { (:age >= 18) & ((:name == "alice") | (:name == "bob")) }.to_sql },
51
+ },
52
+ }
53
+
54
+ puts "=== generated SQL (sanity) ==="
55
+ VARIANTS.each do |group, variants|
56
+ puts "--- #{group} ---"
57
+ variants.each {|name, thunk| puts " #{name.ljust(8)} #{thunk.call}" }
58
+ end
59
+
60
+ puts
61
+ puts "=== speed (queries built per second) ==="
62
+ VARIANTS.each do |group, variants|
63
+ puts "--- #{group} ---"
64
+ Benchmark.ips do |x|
65
+ x.config(warmup: 0.5, time: 2)
66
+ variants.each {|name, thunk| x.report(name, &thunk) }
67
+ x.compare!
68
+ end
69
+ end
70
+
71
+ puts
72
+ puts "=== memory (per single call) ==="
73
+ fmt = "%-18s %-9s %12s %12s"
74
+ puts format(fmt, "group", "variant", "allocated B", "objects")
75
+ VARIANTS.each do |group, variants|
76
+ variants.each do |name, thunk|
77
+ thunk.call # warm caches (schema, statement caches) outside the report
78
+ report = MemoryProfiler.report { thunk.call }
79
+ puts format(fmt, group, name, report.total_allocated_memsize, report.total_allocated)
80
+ end
81
+ end
82
+
83
+ puts
84
+ puts "=== Proc#refined ISeq copy (memory) ==="
85
+ require "objspace"
86
+
87
+ # Proc#refined runs the block under the refinements by deep-copying its
88
+ # instruction sequence, nested blocks included. The copy is made lazily on
89
+ # the refined proc's first call and memoized per source iseq and refinement
90
+ # list for the life of the VM, so it is paid once per block call site, not
91
+ # per query.
92
+ iseq_count = -> {
93
+ counts = ObjectSpace.count_imemo_objects
94
+ counts[:imemo_iseq] || counts[:iseq]
95
+ }
96
+ context = ActiveRecord::Refined::BlockContext.new
97
+ syntax = ActiveRecord::Refined::BlockSyntax
98
+
99
+ {
100
+ "simple equality" => proc { :name == "alice" },
101
+ "compound AND/OR" => proc { (:age >= 18) & ((:name == "alice") | (:name == "bob")) },
102
+ }.each do |label, blk|
103
+ refined = blk.refined(syntax)
104
+ context.instance_exec(&refined) # the copy is made here, on the first call
105
+ original = ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(blk))
106
+ copy = ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(refined))
107
+ puts "#{label}: original iseq #{original} B, refined copy #{copy} B"
108
+ end
109
+
110
+ make_proc = -> { proc { :age > 20 } }
111
+ context.instance_exec(&make_proc.call.refined(syntax))
112
+ before = iseq_count.call
113
+ 1000.times { context.instance_exec(&make_proc.call.refined(syntax)) }
114
+ puts "1000 more calls from the same call site copied #{iseq_count.call - before} iseqs"
115
+
116
+ puts
117
+ puts "=== where the block path spends its time ==="
118
+ block = proc { :name == "alice" }
119
+ refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
120
+ context = ActiveRecord::Refined::BlockContext.new
121
+ Benchmark.ips do |x|
122
+ x.config(warmup: 0.5, time: 2)
123
+ x.report("Proc#refined alone") { block.refined(ActiveRecord::Refined::BlockSyntax) }
124
+ x.report("instance_exec of pre-refined proc") { context.instance_exec(&refined_block) }
125
+ x.report("refined + instance_exec") {
126
+ ActiveRecord::Refined::BlockContext.new.instance_exec(&block.refined(ActiveRecord::Refined::BlockSyntax))
127
+ }
128
+ x.compare!
129
+ end
@@ -8,7 +8,7 @@ ActiveRecord::Migration.verbose = false
8
8
 
9
9
  class Setup < ActiveRecord::Migration[8.1]
10
10
  def up
11
- create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country }
11
+ create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country; t.integer :mentor_id }
12
12
  create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
13
13
  create_table(:comments){|t| t.string :body; t.integer :post_id; t.integer :score }
14
14
  end
@@ -67,3 +67,16 @@ query3 =
67
67
  puts "--- 3. LEFT OUTER JOIN with negation ---"
68
68
  puts query3.to_sql
69
69
  puts
70
+
71
+ # 4. Self join. `as` names the table within the query, and the block's
72
+ # qualified columns go by that name, which is what makes a table joinable
73
+ # to itself.
74
+ query4 =
75
+ Author.
76
+ joins(:authors, as: :mentors) { :mentors[:id] == :authors[:mentor_id] }.
77
+ where { :mentors[:country] != :authors[:country] }.
78
+ select { [:authors[:name].as(:author), :mentors[:name].as(:mentor)] }
79
+
80
+ puts "--- 4. Self join through an alias ---"
81
+ puts query4.to_sql
82
+ puts
data/examples/ctes.rb ADDED
@@ -0,0 +1,82 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ ActiveRecord::Migration.verbose = false
8
+
9
+ class Setup < ActiveRecord::Migration[8.1]
10
+ def up
11
+ create_table(:categories) {|t| t.string :name; t.integer :parent_id }
12
+ create_table(:products) {|t| t.string :name; t.integer :category_id; t.integer :price }
13
+ end
14
+ end
15
+ Setup.new.up
16
+
17
+ class Category < ActiveRecord::Base
18
+ end
19
+
20
+ class Product < ActiveRecord::Base
21
+ end
22
+
23
+ electronics = Category.create!(name: 'electronics')
24
+ computers = Category.create!(name: 'computers', parent_id: electronics.id)
25
+ laptops = Category.create!(name: 'laptops', parent_id: computers.id)
26
+ groceries = Category.create!(name: 'groceries')
27
+
28
+ Product.create!(name: 'ultrabook', category_id: laptops.id, price: 1200)
29
+ Product.create!(name: 'keyboard', category_id: computers.id, price: 80)
30
+ Product.create!(name: 'apple', category_id: groceries.id, price: 2)
31
+
32
+ # 1. Recursive CTE: every category below 'electronics', itself included.
33
+ # The recursive member joins the CTE by name, so its ON clause is a block
34
+ # rather than the string join Rails' own documentation reaches for. `from`
35
+ # then selects the CTE under the model's table name, which is what lets
36
+ # Category's own columns resolve against it.
37
+ subtree =
38
+ Category.with_recursive(
39
+ tree: [
40
+ Category.where { :id == electronics.id },
41
+ Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
42
+ ]
43
+ ).from(:tree, as: :categories)
44
+
45
+ puts '--- 1. Recursive CTE walking a category tree ---'
46
+ puts subtree.to_sql
47
+ puts subtree.order { :name }.pluck(:name).inspect
48
+ puts
49
+
50
+ # 2. The same CTE as a subquery: products anywhere under 'electronics'.
51
+ # The outer query joins the CTE by name like any other table.
52
+ products_below =
53
+ Product.with_recursive(
54
+ tree: [
55
+ Category.where { :id == electronics.id },
56
+ Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
57
+ ]
58
+ ).joins(:tree) { :tree[:id] == :products[:category_id] }
59
+
60
+ puts '--- 2. Recursive CTE joined from the outer query ---'
61
+ puts products_below.to_sql
62
+ puts products_below.order { :name }.pluck(:name).inspect
63
+ puts
64
+
65
+ # 3. A plain CTE, named once and used twice: categories that hold something
66
+ # expensive, and the count of products in each.
67
+ expensive =
68
+ Category.with(pricey: Product.where { :price >= 100 }).
69
+ joins(:pricey) { :pricey[:category_id] == :categories[:id] }.
70
+ group { :categories[:id] }.
71
+ select {
72
+ [
73
+ :categories[:name].as(:category),
74
+ count(:pricey[:id]).as(:pricey_count),
75
+ max(:pricey[:price]).as(:top_price),
76
+ ]
77
+ }
78
+
79
+ puts '--- 3. Plain CTE joined and aggregated ---'
80
+ puts expensive.to_sql
81
+ puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
82
+ puts
@@ -0,0 +1,87 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ ActiveRecord::Migration.verbose = false
8
+
9
+ class Setup < ActiveRecord::Migration[8.1]
10
+ def up
11
+ create_table(:line_items) {|t| t.string :sku; t.string :category; t.integer :price; t.integer :quantity }
12
+ end
13
+ end
14
+ Setup.new.up
15
+
16
+ class LineItem < ActiveRecord::Base
17
+ end
18
+
19
+ LineItem.create!(sku: 'A-1', category: 'tools', price: 1200, quantity: 2)
20
+ LineItem.create!(sku: 'A-2', category: 'tools', price: 300, quantity: 5)
21
+ LineItem.create!(sku: 'B-1', category: 'paper', price: 80, quantity: 10)
22
+ LineItem.create!(sku: 'C-1', category: nil, price: 50, quantity: 1)
23
+
24
+ def show(title, relation, rows = nil)
25
+ puts "--- #{title} ---"
26
+ puts relation.to_sql
27
+ puts rows.inspect unless rows.nil?
28
+ puts
29
+ end
30
+
31
+ # 1. Arithmetic. Ruby puts + - * / above the comparison operators, so an
32
+ # expression groups the way it reads, without parentheses.
33
+ show('arithmetic in a condition',
34
+ LineItem.where { :price * :quantity > 1000 },
35
+ LineItem.where { :price * :quantity > 1000 }.pluck(:sku))
36
+
37
+ show('arithmetic in a select list, and aggregated',
38
+ LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] },
39
+ LineItem.select { [:sku, (:price * :quantity).as(:subtotal)] }.
40
+ map {|i| [i.sku, i.subtotal] })
41
+
42
+ show('an aggregate over an expression',
43
+ LineItem.select { sum(:price * :quantity).as(:total) },
44
+ LineItem.select { sum(:price * :quantity).as(:total) }.first.total)
45
+
46
+ # 2. Aggregates. count takes :* for COUNT(*) and distinct: true for
47
+ # COUNT(DISTINCT ...); the rest are sum, avg, min and max.
48
+ show('COUNT(*) and COUNT(DISTINCT ...)',
49
+ LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] },
50
+ LineItem.select { [count(:*).as(:rows), count(:category, distinct: true).as(:categories)] }.
51
+ map {|i| [i.rows, i.categories] })
52
+
53
+ # 3. Functions. Seven scalar ones have methods of their own; fn reaches
54
+ # anything else, emitting the name as written.
55
+ show('built-in functions and the fn escape hatch',
56
+ LineItem.
57
+ where { length(:sku) == 3 }.
58
+ select { [upper(:sku).as(:sku), coalesce(:category, 'unsorted').as(:category)] },
59
+ LineItem.
60
+ where { length(:sku) == 3 }.
61
+ select { [upper(:sku).as(:sku), coalesce(:category, 'unsorted').as(:category)] }.
62
+ map {|i| [i.sku, i.category] })
63
+
64
+ show('fn, for a function without a method of its own',
65
+ LineItem.select { fn(:hex, :price).as(:hex_price) },
66
+ LineItem.select { fn(:hex, :price).as(:hex_price) }.map(&:hex_price))
67
+
68
+ # 4. Ordering. asc and desc take nulls_first / nulls_last. MySQL has no
69
+ # such syntax, but Arel emulates it there, so the order is the same
70
+ # everywhere.
71
+ show('NULLS LAST',
72
+ LineItem.order { [:category.asc.nulls_last, :sku.asc] },
73
+ LineItem.order { [:category.asc.nulls_last, :sku.asc] }.pluck(:category, :sku))
74
+
75
+ # Aggregates and expressions can be ordered by, too.
76
+ show('grouped, aggregated and ordered by the aggregate',
77
+ LineItem.
78
+ group { :category }.
79
+ having { count(:*) > 1 }.
80
+ order { sum(:price * :quantity).desc }.
81
+ select { [coalesce(:category, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] },
82
+ LineItem.
83
+ group { :category }.
84
+ having { count(:*) > 1 }.
85
+ order { sum(:price * :quantity).desc }.
86
+ select { [coalesce(:category, 'unsorted').as(:category), sum(:price * :quantity).as(:total)] }.
87
+ map {|i| [i.category, i.total] })
@@ -0,0 +1,105 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+ require 'etc'
6
+
7
+ # The features here are PostgreSQL's, so unlike the other examples this one
8
+ # needs a server. It connects the way the test suite does: on 127.0.0.1 as
9
+ # the current user with no password, overridable through DB_HOST, DB_USERNAME
10
+ # and DB_PASSWORD.
11
+ begin
12
+ ActiveRecord::Base.establish_connection(
13
+ adapter: 'postgresql',
14
+ host: ENV.fetch('DB_HOST', '127.0.0.1'),
15
+ username: ENV.fetch('DB_USERNAME') { Etc.getlogin },
16
+ password: ENV['DB_PASSWORD'],
17
+ database: ENV.fetch('DB_NAME', 'postgres'))
18
+ ActiveRecord::Base.lease_connection
19
+ rescue StandardError => e
20
+ abort "needs a PostgreSQL server: #{e.message}"
21
+ end
22
+
23
+ ActiveRecord::Migration.verbose = false
24
+
25
+ class Setup < ActiveRecord::Migration[8.1]
26
+ def up
27
+ create_table(:articles, force: true) do |t|
28
+ t.string :title
29
+ t.string :tags, array: true, default: []
30
+ t.integer :scores, array: true, default: []
31
+ end
32
+ end
33
+ end
34
+ Setup.new.up
35
+
36
+ class Article < ActiveRecord::Base
37
+ end
38
+
39
+ Article.delete_all
40
+ Article.create!(title: 'Refinements', tags: %w[ruby lang], scores: [5, 4])
41
+ Article.create!(title: 'Rails 8', tags: %w[ruby rails], scores: [5])
42
+ Article.create!(title: 'Postgres CTEs', tags: %w[sql db], scores: [3])
43
+ Article.create!(title: '100% pure', tags: ['100%', 'a,b'], scores: [])
44
+
45
+ def show(title, relation, rows = nil)
46
+ puts "--- #{title} ---"
47
+ puts relation.to_sql
48
+ puts rows.inspect unless rows.nil?
49
+ puts
50
+ end
51
+
52
+ # 1. Array columns. Each name carries the meaning of its Ruby namesake:
53
+ # member? is Enumerable's element test, superset? and subset? are Set's
54
+ # containment, and intersect? is Array's "any element in common".
55
+ show('member? tests one element',
56
+ Article.where { :tags.member?('ruby') },
57
+ Article.where { :tags.member?('ruby') }.pluck(:title))
58
+
59
+ show('superset? requires every element',
60
+ Article.where { :tags.superset?(%w[ruby rails]) },
61
+ Article.where { :tags.superset?(%w[ruby rails]) }.pluck(:title))
62
+
63
+ show('subset? and intersect?',
64
+ Article.where { :tags.intersect?(%w[sql lang]) },
65
+ [
66
+ Article.where { :tags.subset?(%w[ruby lang extra]) }.pluck(:title),
67
+ Article.where { :tags.intersect?(%w[sql lang]) }.pluck(:title),
68
+ ])
69
+
70
+ # The element is serialized into an array literal, so commas, quotes and %
71
+ # are matched literally rather than parsed or treated as wildcards.
72
+ show('elements are matched literally',
73
+ Article.where { :tags.member?('a,b') },
74
+ Article.where { :tags.member?('a,b') }.pluck(:title))
75
+
76
+ # member? works on any element type; the literal is coerced to the column's.
77
+ show('a numeric array',
78
+ Article.where { :scores.member?(4) },
79
+ Article.where { :scores.member?(4) }.pluck(:title))
80
+
81
+ # 2. Regular expressions. =~ and !~ become ~ and !~ here, REGEXP on MySQL.
82
+ # SQLite has no regexp operator, which is why this example is not one of
83
+ # the portable ones.
84
+ show('=~ matches a regular expression',
85
+ Article.where { :title =~ '^R' },
86
+ Article.where { :title =~ '^R' }.pluck(:title))
87
+
88
+ show('a Regexp literal works too, and !~ negates',
89
+ Article.where { :title !~ /s$/ },
90
+ Article.where { :title !~ /s$/ }.pluck(:title))
91
+
92
+ # 3. Case. like? is case-sensitive LIKE everywhere, including here, where
93
+ # Arel would otherwise reach for ILIKE. ilike? is the one that asks for
94
+ # it, and casecmp? is case-insensitive equality.
95
+ show('like? stays case-sensitive; ilike? does not',
96
+ Article.where { :title.ilike?('r%') },
97
+ [
98
+ Article.where { :title.like?('r%') }.pluck(:title),
99
+ Article.where { :title.ilike?('r%') }.pluck(:title),
100
+ ])
101
+
102
+ # 4. NULL as a value. PostgreSQL spells this IS [NOT] DISTINCT FROM.
103
+ show('null-safe comparison',
104
+ Article.where { :title.not_distinct_from?('Rails 8') },
105
+ Article.where { :title.not_distinct_from?('Rails 8') }.pluck(:title))
@@ -0,0 +1,93 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ ActiveRecord::Migration.verbose = false
8
+
9
+ class Setup < ActiveRecord::Migration[8.1]
10
+ def up
11
+ create_table(:accounts) {|t| t.string :login; t.string :country; t.integer :age }
12
+ end
13
+ end
14
+ Setup.new.up
15
+
16
+ class Account < ActiveRecord::Base
17
+ end
18
+
19
+ Account.create!(login: 'alice', country: 'JP', age: 60)
20
+ Account.create!(login: 'bob', country: 'JP', age: 50)
21
+ Account.create!(login: 'carol', country: 'US', age: 45)
22
+ Account.create!(login: '100%_pure', country: nil, age: 30)
23
+ Account.create!(login: '1002000', country: 'US', age: 25)
24
+
25
+ def show(title, relation, rows)
26
+ puts "--- #{title} ---"
27
+ puts relation.to_sql
28
+ puts rows.inspect
29
+ puts
30
+ end
31
+
32
+ # 1. Ranges and sets. in? is one name for "belongs to this set": a Range
33
+ # becomes BETWEEN, an endless Range a bare comparison, a list an IN.
34
+ show('in? with a Range becomes BETWEEN',
35
+ Account.where { :age.in?(40..55) },
36
+ Account.where { :age.in?(40..55) }.pluck(:login))
37
+
38
+ show('endless range and IN',
39
+ Account.where { :age.in?(50..) | :country.in?(%w[US]) },
40
+ Account.where { :age.in?(50..) | :country.in?(%w[US]) }.pluck(:login))
41
+
42
+ # 2. NULL. = NULL is never true in SQL, so == nil raises and the test is
43
+ # spelled null?. not_distinct_from? is the null-safe equality, which is
44
+ # what to reach for when the value may or may not be nil.
45
+ show('null? and its negation',
46
+ Account.where { :country.null? },
47
+ Account.where { :country.null? }.pluck(:login))
48
+
49
+ wanted = nil
50
+ show('not_distinct_from? matches NULL to nil',
51
+ Account.where { :country.not_distinct_from?(wanted) },
52
+ Account.where { :country.not_distinct_from?(wanted) }.pluck(:login))
53
+
54
+ # Unlike !=, distinct_from? keeps the NULL row.
55
+ show('distinct_from? keeps NULLs, != drops them',
56
+ Account.where { :country.distinct_from?('JP') },
57
+ [
58
+ Account.where { :country.distinct_from?('JP') }.pluck(:login),
59
+ Account.where { :country != 'JP' }.pluck(:login),
60
+ ])
61
+
62
+ # 3. Text. like? takes a pattern; start_with?, end_with? and include? take
63
+ # literals, so % and _ in them are escaped rather than matched as
64
+ # wildcards. Note the last row matches only the literal-minded one.
65
+ show('like? takes a pattern',
66
+ Account.where { :login.like?('%rol') },
67
+ Account.where { :login.like?('%rol') }.pluck(:login))
68
+
69
+ show('start_with? takes any number of literals, like String#start_with?',
70
+ Account.where { :login.start_with?('al', 'bo') },
71
+ Account.where { :login.start_with?('al', 'bo') }.pluck(:login))
72
+
73
+ # The % in the argument is escaped, so only the account whose login really
74
+ # contains "100%" matches; the same pattern spelled with like? treats it as a
75
+ # wildcard and catches 1002000 as well.
76
+ show('include? escapes wildcards; like? does not',
77
+ Account.where { :login.include?('100%') },
78
+ [
79
+ Account.where { :login.include?('100%') }.pluck(:login),
80
+ Account.where { :login.like?('%100%%') }.pluck(:login),
81
+ ])
82
+
83
+ # casecmp? folds both sides rather than trusting the collation, so it means
84
+ # the same thing on every adapter.
85
+ show('casecmp? is case-insensitive equality',
86
+ Account.where { :login.casecmp?('AlIcE') },
87
+ Account.where { :login.casecmp?('AlIcE') }.pluck(:login))
88
+
89
+ # 4. Combining. & | ! build the tree; Ruby's precedence puts & and | above
90
+ # the comparison operators, hence the parentheses around each comparison.
91
+ show('compound conditions',
92
+ Account.where { (:age >= 40) & (:country.in?(%w[JP US]) | :country.null?) },
93
+ Account.where { (:age >= 40) & (:country.in?(%w[JP US]) | :country.null?) }.pluck(:login))
@@ -0,0 +1,67 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'active_record'
4
+ require 'activerecord-refined'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ ActiveRecord::Migration.verbose = false
8
+
9
+ class Setup < ActiveRecord::Migration[8.1]
10
+ def up
11
+ create_table(:authors) {|t| t.string :name }
12
+ create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
13
+ end
14
+ end
15
+ Setup.new.up
16
+
17
+ class Author < ActiveRecord::Base
18
+ has_many :posts
19
+ end
20
+
21
+ class Post < ActiveRecord::Base
22
+ belongs_to :author
23
+
24
+ def self.published = where { :published == true }
25
+ end
26
+
27
+ alice = Author.create!(name: 'alice')
28
+ bob = Author.create!(name: 'bob')
29
+ quiet = Author.create!(name: 'quiet')
30
+
31
+ Post.create!(title: 'refinements', author_id: alice.id, likes: 100, published: true)
32
+ Post.create!(title: 'parser', author_id: alice.id, likes: 40, published: true)
33
+ Post.create!(title: 'draft', author_id: bob.id, likes: 5, published: false)
34
+
35
+ def show(title, relation)
36
+ puts "--- #{title} ---"
37
+ puts relation.to_sql
38
+ puts relation.pluck(relation.model.table_name == 'authors' ? :name : :title).inspect
39
+ puts
40
+ end
41
+
42
+ # 1. in? takes a relation as a subquery. With an explicit select list the
43
+ # subquery selects that column; without one it selects the primary key,
44
+ # the same way ActiveRecord's own where(id: relation) does.
45
+ show('in? with a subquery',
46
+ Author.where { :id.in?(Post.published.select(:author_id)) })
47
+
48
+ # 2. exists? asks whether the subquery returns a row. Correlate it with the
49
+ # outer table through qualified columns; the inner where block is the same
50
+ # DSL as the outer one.
51
+ show('exists?',
52
+ Author.where { exists?(Post.published.where { :posts[:author_id] == :authors[:id] }) })
53
+
54
+ show('!exists? finds what has nothing to show',
55
+ Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) })
56
+
57
+ # 3. A relation on the right of a comparison is a scalar subquery. It has to
58
+ # yield a single value, so unlike in? there is no default select list and
59
+ # one is required.
60
+ show('a scalar subquery on the right of a comparison',
61
+ Post.where { :likes >= Post.select { avg(:likes) } })
62
+
63
+ # The three compose like any other predicate.
64
+ show('combined with the rest of the vocabulary',
65
+ Author.
66
+ where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }.
67
+ where { !:id.in?(Post.where { :published == false }.select(:author_id)) })