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.
@@ -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)) })