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 +4 -4
- data/.github/workflows/push_gem.yml +45 -0
- data/.github/workflows/test.yml +23 -0
- data/.gitignore +2 -0
- data/Gemfile +8 -0
- data/README.md +104 -8
- data/Rakefile +17 -0
- data/examples/aggregations.rb +91 -0
- data/examples/complex_joins.rb +69 -0
- data/lib/active_record/refined/ast.rb +171 -54
- data/lib/active_record/refined.rb +1 -7
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +217 -72
- data/test/test_helper.rb +85 -2
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2343da1bdb5b8fdad1b6671f516c3a56173629d2ad433d93c4a22218deeef1fe
|
|
4
|
+
data.tar.gz: f2f2cd301e6541409ffe472c0ec68b8551a5762aba89b64c266bb435df60182f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
+
[](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 {
|
|
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
|
|
72
|
-
Author.where { :
|
|
73
|
-
Author.where { :age
|
|
74
|
-
Author.where { :
|
|
75
|
-
Author.where { :country
|
|
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 { !(
|
|
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
|