activerecord-refined 0.3.1 → 0.3.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 244cb4019b7a281420222cdb796acac66c917b2178ae8a18126bcb67fca38b9d
4
- data.tar.gz: 7e2120422a33273b64568ef1609a7e648a12c6190bf85ded8cc2ff281f292a7c
3
+ metadata.gz: f20876b4eb0b36ece84fc2f6a1c828461846b29bd829e279d31b22735cbf0c15
4
+ data.tar.gz: 4a717ffaf4991fa3a113ea4a6a619b16d431e8308e1869e22c988885022d450a
5
5
  SHA512:
6
- metadata.gz: 8652b0d6ed1f957a72f014521ca3b6a9fedf2c8735771c81784724d7255a9714926a20411f7adb978211fe5ff05bddb64f41e2acc547df98ae3c0ab8ed48a308
7
- data.tar.gz: ef23c519fa95df0a88f133d8e2f4161493a2879297e1de1d409e5578ebe74ca4597a8811fe89d5ee7ea806987d6a1fdda71e1cec487caaed9becfc7fcca9e26a
6
+ metadata.gz: 56734a22e7667270892abf1d6ab9fb0816a37043c5f1fe61a7367c40403f782af6dff7875b0f520ada5aef6ce89155f39919414a71d3abec279ccf3c538db578
7
+ data.tar.gz: f0aa350bbf6b80d1e36b156d2e997930195f2fcfe99c53ae449f3d0f44efe3870671c45bae52a7eac5f39c5f39adf129cbe5a1a1d3776b07eb8d29b1cfa470d2
@@ -0,0 +1,45 @@
1
+ name: push gem
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ push:
10
+ if: github.repository == 'shugo/activerecord-refined'
11
+ runs-on: ubuntu-latest
12
+
13
+ environment:
14
+ name: release
15
+ url: https://rubygems.org/gems/activerecord-refined
16
+
17
+ permissions:
18
+ # release-gem pushes the tag, and needs id-token to identify itself to
19
+ # RubyGems.org instead of an API key.
20
+ contents: write
21
+ id-token: write
22
+
23
+ env:
24
+ # Nothing here loads pg or mysql2, and building them needs client
25
+ # libraries the runner would have to install first.
26
+ BUNDLE_WITHOUT: db
27
+
28
+ steps:
29
+ - uses: actions/checkout@v5
30
+ with:
31
+ # release-gem sets up its own short-lived git credentials.
32
+ persist-credentials: false
33
+
34
+ - uses: ruby/setup-ruby@v1
35
+ with:
36
+ # Not `ruby`: the gemspec requires 4.1.0.dev for Proc#refined, so
37
+ # bundler refuses to resolve on a released Ruby.
38
+ ruby-version: head
39
+ bundler-cache: true
40
+
41
+ # Runs `rake release`, which builds the gem and pushes it. The tag it
42
+ # would create already exists here, so that step is skipped.
43
+ - uses: rubygems/release-gem@v1
44
+ with:
45
+ attestations: false
@@ -0,0 +1,23 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ env:
13
+ # SQLite only here, so skip building the pg and mysql2 extensions.
14
+ BUNDLE_WITHOUT: db
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ - uses: ruby/setup-ruby@v1
18
+ with:
19
+ # Proc#refined has not been released yet, so master is the only
20
+ # Ruby that can run this.
21
+ ruby-version: head
22
+ bundler-cache: true
23
+ - run: bundle exec rake test
data/.gitignore CHANGED
@@ -2,6 +2,7 @@
2
2
  *.rbc
3
3
  .bundle
4
4
  .config
5
+ .ruby-version
5
6
  .yardoc
6
7
  Gemfile.lock
7
8
  InstalledFiles
@@ -12,6 +13,7 @@ lib/bundler/man
12
13
  pkg
13
14
  rdoc
14
15
  spec/reports
16
+ t.rb
15
17
  test/tmp
16
18
  test/version_tmp
17
19
  tmp
data/Gemfile CHANGED
@@ -2,3 +2,11 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in activerecord-refined.gemspec
4
4
  gemspec
5
+
6
+ # Only ADAPTER=postgresql and ADAPTER=mysql2 need these, and building them
7
+ # needs the client libraries installed. `rake test` runs on SQLite, so a
8
+ # checkout without them is still usable: bundle config set --local without db
9
+ group :db do
10
+ gem 'mysql2'
11
+ gem 'pg'
12
+ end
data/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # ActiveRecord::Refined
2
2
 
3
+ [![test](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml/badge.svg)](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml)
4
+
3
5
  Adding clean and powerful query syntax on ActiveRecord using refinements.
4
6
 
5
7
  ```ruby
6
8
  Author.
7
9
  joins(:posts) { :posts[:author_id] == :authors[:id] }.
8
- where { (:authors[:age] == (20..40)) & (:posts[:published] == true) }
10
+ where { :authors[:age].in?(20..40) & (:posts[:published] == true) }
9
11
  # SELECT "authors".* FROM "authors"
10
12
  # INNER JOIN "posts" ON "posts"."author_id" = "authors"."id"
11
13
  # WHERE "authors"."age" BETWEEN 20 AND 40 AND "posts"."published" = TRUE
@@ -68,20 +70,108 @@ denotes a qualified column.
68
70
 
69
71
  ```ruby
70
72
  Author.where { :age >= 18 }
71
- Author.where { :name =~ 'A%' } # LIKE
72
- Author.where { :name !~ '%test%' } # NOT LIKE
73
- Author.where { :age == (20..40) } # BETWEEN
74
- Author.where { :country == %w[JP US] } # IN
75
- Author.where { :country != %w[JP US] } # NOT IN
73
+ Author.where { :name.like?('A%') } # LIKE
74
+ Author.where { :age.in?(20..40) } # BETWEEN
75
+ Author.where { :age.between?(20, 40) } # BETWEEN
76
+ Author.where { :age.in?(18..) } # >= 18
77
+ Author.where { :country.in?(%w[JP US]) } # IN
76
78
  Author.where { :country.null? } # IS NULL
77
79
  ```
78
80
 
81
+ `in?` also takes a relation as a subquery. Without an explicit select list the
82
+ subquery selects the relation's primary key, the same way ActiveRecord's own
83
+ `where(id: relation)` does:
84
+
85
+ ```ruby
86
+ Author.where { :id.in?(Post.published.select(:author_id)) }
87
+ # "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
88
+ ```
89
+
90
+ `exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
91
+ subquery with the outer table through qualified columns — its `where` block
92
+ goes through the DSL like any other:
93
+
94
+ ```ruby
95
+ Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
96
+ # EXISTS (SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = "authors"."id")
97
+
98
+ Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
99
+ # NOT (EXISTS (...))
100
+ ```
101
+
102
+ `like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
103
+ Arel would otherwise reach for `ILIKE`.
104
+
105
+ `start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
106
+ patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
107
+ and `_` in it are escaped rather than matched as wildcards:
108
+
109
+ ```ruby
110
+ Author.where { :name.start_with?('A') } # LIKE 'A%'
111
+ Author.where { :name.end_with?('son') } # LIKE '%son'
112
+ Author.where { :name.include?('test') } # LIKE '%test%'
113
+ ```
114
+
115
+ Like their String namesakes, `start_with?` and `end_with?` take any number of
116
+ literals; matching any one of them is enough:
117
+
118
+ ```ruby
119
+ Author.where { :name.start_with?('A', 'B') }
120
+ # (name LIKE 'A%' OR name LIKE 'B%')
121
+ ```
122
+
123
+ `member?` tests containment in a PostgreSQL array column. The two flavors of
124
+ "does it contain this?" split by name the way Ruby's own classes do: `include?`
125
+ is String's substring match, `member?` is Enumerable's element test, which
126
+ String does not have. Pass an array to require every element:
127
+
128
+ ```ruby
129
+ Article.where { :tags.member?('ruby') } # "tags" @> '{ruby}'
130
+ Article.where { :tags.member?(%w[ruby rails]) } # "tags" @> '{ruby,rails}'
131
+ Article.where { :scores.member?(80) } # "scores" @> '{80}'
132
+ ```
133
+
134
+ `=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
135
+ `~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
136
+ raises there.
137
+
138
+ ```ruby
139
+ Author.where { :name =~ '^A' } # REGEXP / ~
140
+ Author.where { :name !~ '^A' } # NOT REGEXP / !~
141
+ Author.where { :name =~ /son$/ } # a Regexp literal works too
142
+ ```
143
+
144
+ Only a literal's source crosses over; the database has its own dialect and no
145
+ equivalent of Ruby's flags. Dropping one would silently change what the query
146
+ matches, so `/son$/i` raises instead — pass the pattern as a string if the
147
+ database can express what you mean.
148
+
149
+ `==` always means SQL `=`, and passes its value through untouched. A Range or an
150
+ Array therefore compares against a PostgreSQL range or array column, the same
151
+ way ActiveRecord's own `where(period: from...to)` does for those column types:
152
+
153
+ ```ruby
154
+ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
155
+ Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
156
+ ```
157
+
158
+ For the same reason `== nil` raises `ArgumentError`: `= NULL` is never true in
159
+ SQL, so a NULL test has to be spelled as one. Use `null?`:
160
+
161
+ ```ruby
162
+ Author.where { :country.null? } # country IS NULL
163
+ Author.where { !:country.null? } # NOT (country IS NULL)
164
+ ```
165
+
79
166
  Combine predicates with `&`, `|` and `!`. Ruby's operator precedence makes the
80
- parentheses around each comparison necessary:
167
+ parentheses around each comparison necessary, though the `?` methods above need
168
+ none:
81
169
 
82
170
  ```ruby
83
171
  Author.where { (:age >= 18) & ((:country == 'JP') | (:country == 'US')) }
84
- Author.where { !((:age == (0..17)) | :country.null?) }
172
+ Author.where { !(:age.in?(0..17) | :country.null?) }
173
+ Author.where { !:country.in?(%w[JP US]) } # NOT (country IN ('JP', 'US'))
174
+ Author.where { !:name.like?('%test%') } # NOT (name LIKE '%test%')
85
175
  ```
86
176
 
87
177
  ### Joins
@@ -103,6 +193,13 @@ functions `upper`, `lower`, `length`, `trim`, `coalesce`, `abs` and `round`. Use
103
193
  for a column alias, and `.asc` / `.desc` for the sort direction. Return an array to
104
194
  select or order by multiple expressions.
105
195
 
196
+ Pass `:*` to `count` for `COUNT(*)`:
197
+
198
+ ```ruby
199
+ Author.group { :country }.having { count(:*) > 1 }
200
+ # SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
201
+ ```
202
+
106
203
  ```ruby
107
204
  Author.
108
205
  joins(:posts) { :posts[:author_id] == :authors[:id] }.
@@ -121,6 +218,42 @@ Author.
121
218
 
122
219
  See `examples/` for complete, runnable scripts.
123
220
 
221
+ ## Running the tests
222
+
223
+ The tests only build SQL, but they need a live connection to do it. SQLite is
224
+ the default; set `ADAPTER` to run the same suite against another one.
225
+
226
+ ```sh
227
+ rake test # sqlite3
228
+ ADAPTER=postgresql rake test
229
+ ADAPTER=mysql2 rake test
230
+ rake test:all # all three in turn
231
+ ```
232
+
233
+ PostgreSQL and MySQL are reached on `127.0.0.1` as the current user with no
234
+ password, which is how the devcontainer sets them up. Override with
235
+ `DB_HOST`, `DB_USERNAME` and `DB_PASSWORD`. The `activerecord_refined_test`
236
+ database is created on first use.
237
+
238
+ The `pg` and `mysql2` gems are in the Gemfile's `db` group, since building them
239
+ needs the client libraries installed. Skip them if SQLite is all you need,
240
+ which is what CI does:
241
+
242
+ ```sh
243
+ bundle config set --local without db
244
+ ```
245
+
246
+ ## Releasing
247
+
248
+ Pushing a `v*` tag runs `.github/workflows/push_gem.yml`, which builds the gem
249
+ and publishes it through RubyGems.org's trusted publishing, so no API key is
250
+ stored anywhere.
251
+
252
+ ```sh
253
+ bump patch --tag # or bump {major,minor} etc.
254
+ git push --follow-tags
255
+ ```
256
+
124
257
  ## Contributing
125
258
 
126
259
  1. Fork it
data/Rakefile CHANGED
@@ -1,8 +1,25 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rake/testtask"
3
3
 
4
+ ADAPTERS = %w[sqlite3 postgresql mysql2].freeze
5
+
4
6
  Rake::TestTask.new do |t|
5
7
  t.test_files = FileList['test/test_*.rb']
6
8
  end
7
9
 
10
+ namespace :test do
11
+ ADAPTERS.each do |adapter|
12
+ desc "Run the tests against #{adapter}"
13
+ task adapter do
14
+ puts "==== #{adapter} ===="
15
+ ENV['ADAPTER'] = adapter
16
+ Rake::Task[:test].reenable
17
+ Rake::Task[:test].invoke
18
+ end
19
+ end
20
+
21
+ desc "Run the tests against every adapter in turn"
22
+ task all: ADAPTERS
23
+ end
24
+
8
25
  task default: :test
@@ -0,0 +1,91 @@
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; t.integer :age; t.string :country }
12
+ create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
13
+ create_table(:comments){|t| t.string :body; t.integer :post_id; t.integer :score }
14
+ end
15
+ end
16
+ Setup.new.up
17
+
18
+ class Author < ActiveRecord::Base
19
+ has_many :posts
20
+ end
21
+
22
+ class Post < ActiveRecord::Base
23
+ belongs_to :author
24
+ has_many :comments
25
+ end
26
+
27
+ class Comment < ActiveRecord::Base
28
+ belongs_to :post
29
+ end
30
+
31
+ # 1. Post statistics per author
32
+ # JOIN + GROUP BY + aggregate functions + AS + HAVING + ORDER BY
33
+ query1 =
34
+ Author.
35
+ joins(:posts) { :posts[:author_id] == :authors[:id] }.
36
+ where { :posts[:published] == true }.
37
+ group { :authors[:id] }.
38
+ having { count(:posts[:id]) > 1 }.
39
+ order { count(:posts[:id]).desc }.
40
+ select {
41
+ [
42
+ upper(:authors[:name]).as(:author),
43
+ count(:posts[:id]).as(:post_count),
44
+ avg(:posts[:likes]).as(:avg_likes),
45
+ max(:posts[:likes]).as(:top_likes),
46
+ ]
47
+ }
48
+
49
+ puts "--- 1. Per-author post stats (GROUP BY / aggregates / AS / HAVING / ORDER BY) ---"
50
+ puts query1.to_sql
51
+ puts
52
+
53
+ # 2. Author count per country (NULL is folded into 'unknown')
54
+ # coalesce function + GROUP BY + aggregates + multiple ORDER BY
55
+ query2 =
56
+ Author.
57
+ group { coalesce(:country, 'unknown') }.
58
+ order { [count(:id).desc, coalesce(:country, 'unknown').asc] }.
59
+ select {
60
+ [
61
+ coalesce(:country, 'unknown').as(:country),
62
+ count(:id).as(:author_count),
63
+ avg(:age).as(:avg_age),
64
+ ]
65
+ }
66
+
67
+ puts "--- 2. Authors per country (coalesce / GROUP BY / multi ORDER BY) ---"
68
+ puts query2.to_sql
69
+ puts
70
+
71
+ # 3. Aggregate comment scores across a multi-level JOIN
72
+ # authors -> posts -> comments, compound WHERE + GROUP BY + HAVING + ORDER BY
73
+ query3 =
74
+ Author.
75
+ joins(:posts) { :posts[:author_id] == :authors[:id] }.
76
+ joins(:comments) { :comments[:post_id] == :posts[:id] }.
77
+ where { !:posts[:title].include?('draft') & (:comments[:score] >= 0) }.
78
+ group { :authors[:id] }.
79
+ having { sum(:comments[:score]) > 10 }.
80
+ order { sum(:comments[:score]).desc }.
81
+ select {
82
+ [
83
+ :authors[:name].as(:author),
84
+ count(:comments[:id]).as(:comment_count),
85
+ sum(:comments[:score]).as(:total_score),
86
+ ]
87
+ }
88
+
89
+ puts "--- 3. Comment score aggregation across multi-table JOIN ---"
90
+ puts query3.to_sql
91
+ puts
@@ -0,0 +1,69 @@
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; t.integer :age; t.string :country }
12
+ create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
13
+ create_table(:comments){|t| t.string :body; t.integer :post_id; t.integer :score }
14
+ end
15
+ end
16
+ Setup.new.up
17
+
18
+ class Author < ActiveRecord::Base
19
+ has_many :posts
20
+ end
21
+
22
+ class Post < ActiveRecord::Base
23
+ belongs_to :author
24
+ has_many :comments
25
+ end
26
+
27
+ class Comment < ActiveRecord::Base
28
+ belongs_to :post
29
+ end
30
+
31
+ # 1. INNER JOIN + compound WHERE conditions (AND / OR / BETWEEN / IN)
32
+ query1 =
33
+ Author.
34
+ joins(:posts) { :posts[:author_id] == :authors[:id] }.
35
+ where {
36
+ (:authors[:age].in?(20..40) & (:posts[:published] == true)) |
37
+ :authors[:country].in?(%w[JP US])
38
+ }
39
+
40
+ puts "--- 1. INNER JOIN with compound conditions ---"
41
+ puts query1.to_sql
42
+ puts
43
+
44
+ # 2. Multi-level JOIN (authors -> posts -> comments) + negated LIKE / IN
45
+ query2 =
46
+ Author.
47
+ joins(:posts) { :posts[:author_id] == :authors[:id] }.
48
+ joins(:comments) { :comments[:post_id] == :posts[:id] }.
49
+ where {
50
+ !:posts[:title].include?('draft') &
51
+ !:comments[:score].in?([0, -1]) &
52
+ (:authors[:age] >= 18)
53
+ }
54
+
55
+ puts "--- 2. Multi-table JOIN with negated LIKE / IN ---"
56
+ puts query2.to_sql
57
+ puts
58
+
59
+ # 3. LEFT OUTER JOIN + NOT / range conditions
60
+ query3 =
61
+ Author.
62
+ left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }.
63
+ where {
64
+ !(:posts[:likes].in?(0..9) | (:posts[:published] == false))
65
+ }
66
+
67
+ puts "--- 3. LEFT OUTER JOIN with negation ---"
68
+ puts query3.to_sql
69
+ puts