activerecord-refined 0.3.2 → 0.4.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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +56 -2
- data/LICENSE.txt +2 -1
- data/README.md +260 -21
- data/benchmark/query_building.rb +129 -0
- data/examples/complex_joins.rb +14 -1
- data/examples/ctes.rb +82 -0
- data/examples/expressions.rb +87 -0
- data/examples/postgresql.rb +105 -0
- data/examples/predicates.rb +93 -0
- data/examples/subqueries.rb +67 -0
- data/lib/active_record/refined/ast.rb +335 -20
- data/lib/active_record/refined.rb +119 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +579 -0
- data/test/test_helper.rb +21 -1
- metadata +7 -1
|
@@ -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: 'matz', country: 'JP', age: 60)
|
|
20
|
+
Account.create!(login: 'nobu', country: 'JP', age: 50)
|
|
21
|
+
Account.create!(login: 'tenderlove', 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?('%love') },
|
|
67
|
+
Account.where { :login.like?('%love') }.pluck(:login))
|
|
68
|
+
|
|
69
|
+
show('start_with? takes any number of literals, like String#start_with?',
|
|
70
|
+
Account.where { :login.start_with?('ma', 'no') },
|
|
71
|
+
Account.where { :login.start_with?('ma', 'no') }.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?('MaTz') },
|
|
87
|
+
Account.where { :login.casecmp?('MaTz') }.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
|
+
matz = Author.create!(name: 'matz')
|
|
28
|
+
nobu = Author.create!(name: 'nobu')
|
|
29
|
+
quiet = Author.create!(name: 'quiet')
|
|
30
|
+
|
|
31
|
+
Post.create!(title: 'refinements', author_id: matz.id, likes: 100, published: true)
|
|
32
|
+
Post.create!(title: 'parser', author_id: matz.id, likes: 40, published: true)
|
|
33
|
+
Post.create!(title: 'draft', author_id: nobu.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)) })
|