activerecord-refined 0.3.1 → 0.3.2

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: 2343da1bdb5b8fdad1b6671f516c3a56173629d2ad433d93c4a22218deeef1fe
4
+ data.tar.gz: f2f2cd301e6541409ffe472c0ec68b8551a5762aba89b64c266bb435df60182f
5
5
  SHA512:
6
- metadata.gz: 8652b0d6ed1f957a72f014521ca3b6a9fedf2c8735771c81784724d7255a9714926a20411f7adb978211fe5ff05bddb64f41e2acc547df98ae3c0ab8ed48a308
7
- data.tar.gz: ef23c519fa95df0a88f133d8e2f4161493a2879297e1de1d409e5578ebe74ca4597a8811fe89d5ee7ea806987d6a1fdda71e1cec487caaed9becfc7fcca9e26a
6
+ metadata.gz: 4fe091dac0872b9cfbffbdb5bb88d75dfb627e5cf26b29d6256e7bbc9845f5dad4fae4b41de66562ca4079160442f21849b04d0736df20340ba0c317b9ee5f3d
7
+ data.tar.gz: fba70971662ad06f2383d51aa53e81a681f2716108a40a0f2f6ebda278526e10517be1159bd2b5715e2da963a33fdc5615ee5acfcd9253782b0526b9cc3cbacb
@@ -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,60 @@ 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
+ `like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
82
+ Arel would otherwise reach for `ILIKE`.
83
+
84
+ `start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
85
+ patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
86
+ and `_` in it are escaped rather than matched as wildcards:
87
+
88
+ ```ruby
89
+ Author.where { :name.start_with?('A') } # LIKE 'A%'
90
+ Author.where { :name.end_with?('son') } # LIKE '%son'
91
+ Author.where { :name.include?('test') } # LIKE '%test%'
92
+ ```
93
+
94
+ `=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
95
+ `~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
96
+ raises there.
97
+
98
+ ```ruby
99
+ Author.where { :name =~ '^A' } # REGEXP / ~
100
+ Author.where { :name !~ '^A' } # NOT REGEXP / !~
101
+ Author.where { :name =~ /son$/ } # a Regexp literal works too
102
+ ```
103
+
104
+ Only a literal's source crosses over; the database has its own dialect and no
105
+ equivalent of Ruby's flags. Dropping one would silently change what the query
106
+ matches, so `/son$/i` raises instead — pass the pattern as a string if the
107
+ database can express what you mean.
108
+
109
+ `==` always means SQL `=`, and passes its value through untouched. A Range or an
110
+ Array therefore compares against a PostgreSQL range or array column, the same
111
+ way ActiveRecord's own `where(period: from...to)` does for those column types:
112
+
113
+ ```ruby
114
+ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
115
+ Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
116
+ ```
117
+
79
118
  Combine predicates with `&`, `|` and `!`. Ruby's operator precedence makes the
80
- parentheses around each comparison necessary:
119
+ parentheses around each comparison necessary, though the `?` methods above need
120
+ none:
81
121
 
82
122
  ```ruby
83
123
  Author.where { (:age >= 18) & ((:country == 'JP') | (:country == 'US')) }
84
- Author.where { !((:age == (0..17)) | :country.null?) }
124
+ Author.where { !(:age.in?(0..17) | :country.null?) }
125
+ Author.where { !:country.in?(%w[JP US]) } # NOT (country IN ('JP', 'US'))
126
+ Author.where { !:name.like?('%test%') } # NOT (name LIKE '%test%')
85
127
  ```
86
128
 
87
129
  ### Joins
@@ -103,6 +145,13 @@ functions `upper`, `lower`, `length`, `trim`, `coalesce`, `abs` and `round`. Use
103
145
  for a column alias, and `.asc` / `.desc` for the sort direction. Return an array to
104
146
  select or order by multiple expressions.
105
147
 
148
+ Pass `:*` to `count` for `COUNT(*)`:
149
+
150
+ ```ruby
151
+ Author.group { :country }.having { count(:*) > 1 }
152
+ # SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
153
+ ```
154
+
106
155
  ```ruby
107
156
  Author.
108
157
  joins(:posts) { :posts[:author_id] == :authors[:id] }.
@@ -121,6 +170,53 @@ Author.
121
170
 
122
171
  See `examples/` for complete, runnable scripts.
123
172
 
173
+ ## Running the tests
174
+
175
+ The tests only build SQL, but they need a live connection to do it. SQLite is
176
+ the default; set `ADAPTER` to run the same suite against another one.
177
+
178
+ ```sh
179
+ rake test # sqlite3
180
+ ADAPTER=postgresql rake test
181
+ ADAPTER=mysql2 rake test
182
+ rake test:all # all three in turn
183
+ ```
184
+
185
+ PostgreSQL and MySQL are reached on `127.0.0.1` as the current user with no
186
+ password, which is how the devcontainer sets them up. Override with
187
+ `DB_HOST`, `DB_USERNAME` and `DB_PASSWORD`. The `activerecord_refined_test`
188
+ database is created on first use.
189
+
190
+ The `pg` and `mysql2` gems are in the Gemfile's `db` group, since building them
191
+ needs the client libraries installed. Skip them if SQLite is all you need,
192
+ which is what CI does:
193
+
194
+ ```sh
195
+ bundle config set --local without db
196
+ ```
197
+
198
+ ## Releasing
199
+
200
+ Pushing a `v*` tag runs `.github/workflows/push_gem.yml`, which builds the gem
201
+ and publishes it through RubyGems.org's trusted publishing, so no API key is
202
+ stored anywhere.
203
+
204
+ ```sh
205
+ # bump Activerecord::Refined::VERSION and commit it, then
206
+ git tag v0.4.0
207
+ git push origin v0.4.0
208
+ ```
209
+
210
+ This needs a trusted publisher registered once at
211
+ <https://rubygems.org/gems/activerecord-refined/trusted_publishers>:
212
+
213
+ | Field | Value |
214
+ | --- | --- |
215
+ | Repository owner | `shugo` |
216
+ | Repository name | `activerecord-refined` |
217
+ | Workflow filename | `push_gem.yml` |
218
+ | Environment | `release` |
219
+
124
220
  ## Contributing
125
221
 
126
222
  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