activerecord-refined 0.8.1 → 0.10.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/.yardopts +17 -0
- data/README.md +111 -815
- data/activerecord-refined.gemspec +36 -17
- data/docs/conditions.md +206 -0
- data/docs/ctes.md +65 -0
- data/docs/expressions.md +125 -0
- data/docs/functions.md +219 -0
- data/docs/grouping.md +55 -0
- data/docs/joins.md +73 -0
- data/docs/json.md +230 -0
- data/docs/ordering.md +55 -0
- data/docs/time_zones.md +30 -0
- data/docs/windows.md +41 -0
- data/docs/writing.md +33 -0
- data/examples/aggregations.rb +31 -11
- data/examples/complex_joins.rb +12 -10
- data/examples/ctes.rb +22 -20
- data/examples/expressions.rb +110 -45
- data/examples/json.rb +77 -38
- data/examples/postgresql.rb +64 -53
- data/examples/predicates.rb +35 -33
- data/examples/subqueries.rb +20 -18
- data/examples/windows.rb +23 -21
- data/examples/writes.rb +26 -24
- data/lib/active_record/refined/ast.rb +1117 -373
- data/lib/active_record/refined/dialect/mariadb.rb +25 -0
- data/lib/active_record/refined/dialect/mysql.rb +18 -0
- data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
- data/lib/active_record/refined/dialect/oracle.rb +110 -0
- data/lib/active_record/refined/dialect/postgresql.rb +120 -0
- data/lib/active_record/refined/dialect/sql_server.rb +115 -0
- data/lib/active_record/refined/dialect/sqlite.rb +57 -0
- data/lib/active_record/refined/dialect.rb +340 -0
- data/lib/active_record/refined.rb +877 -307
- data/lib/activerecord-refined/version.rb +3 -1
- data/lib/activerecord-refined.rb +9 -5
- metadata +186 -15
- data/.github/workflows/push_gem.yml +0 -45
- data/.github/workflows/sandbox.yml +0 -295
- data/.github/workflows/test.yml +0 -90
- data/.gitignore +0 -19
- data/Gemfile +0 -12
- data/Rakefile +0 -25
- data/benchmark/query_building.rb +0 -129
- data/test/test_block_syntax.rb +0 -2493
- data/test/test_helper.rb +0 -221
data/docs/functions.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Aggregates and functions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
5
|
+
bit aggregates and the scalar functions below, with `fn` for anything else. Return an array to select
|
|
6
|
+
or order by multiple expressions.
|
|
7
|
+
|
|
8
|
+
`filter` takes the aggregate over the rows a condition holds for, as a value
|
|
9
|
+
or a block:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
Author.select { count(:*).filter { :age < 50 }.as(:young) }
|
|
13
|
+
# COUNT(*) FILTER (WHERE "age" < 50) AS "young"
|
|
14
|
+
|
|
15
|
+
Author.select {
|
|
16
|
+
[count(:*).as(:all), sum(:age).filter { :country == "JP" }.as(:jp_years)]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
MySQL has no `FILTER` clause, and gets the case that means the same thing —
|
|
21
|
+
`COUNT(CASE WHEN "age" < 50 THEN 1 END)`. An aggregate passes over a NULL, so
|
|
22
|
+
a row the condition misses is a row it does not see, and the number that comes
|
|
23
|
+
back is the same on all three.
|
|
24
|
+
|
|
25
|
+
Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
|
|
26
|
+
`COUNT(DISTINCT ...)`. `sum` and `avg` take `distinct: true` as well, for the
|
|
27
|
+
aggregate over each value once; `min` and `max` would give the same with or
|
|
28
|
+
without it, so they take no such thing:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Author.group { :country }.having { count(:*) > 1 }
|
|
32
|
+
# SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
|
|
33
|
+
|
|
34
|
+
Post.select { count(:author_id, distinct: true) } # COUNT(DISTINCT "author_id")
|
|
35
|
+
Post.select { sum(:likes, distinct: true) } # SUM(DISTINCT "likes")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`string_agg` joins the strings of a group into one, a separator between —
|
|
39
|
+
`,` unless another is given — and `.order` says the order they are joined
|
|
40
|
+
in. Each database has it under a name of its own, with the `ORDER BY` in a
|
|
41
|
+
place of its own:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Post.group { :author_id }.select { string_agg(:title, ", ").order(:title).as(:titles) }
|
|
45
|
+
# STRING_AGG("title", ', ' ORDER BY "title") PostgreSQL
|
|
46
|
+
# group_concat("title", ', ' ORDER BY "title") SQLite
|
|
47
|
+
# GROUP_CONCAT("title" ORDER BY "title" SEPARATOR ', ') MySQL
|
|
48
|
+
# STRING_AGG("title", ', ') WITHIN GROUP (ORDER BY "title") SQL Server
|
|
49
|
+
# LISTAGG("title", ', ') WITHIN GROUP (ORDER BY "title") Oracle
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`filter` and `over` come along as with any aggregate, `over` where the
|
|
53
|
+
database takes it — the MySQL family and SQL Server do not, and raise.
|
|
54
|
+
Oracle's insists on an order and is given the values' own when none is asked
|
|
55
|
+
for. What is joined is text: PostgreSQL's `STRING_AGG` takes nothing else, so
|
|
56
|
+
a column the model does not declare a string is cast there, where the others
|
|
57
|
+
convert for themselves. MySQL cuts the result at `group_concat_max_len`, 1024
|
|
58
|
+
bytes unless the session says otherwise, and the `ORDER BY` inside SQLite's
|
|
59
|
+
call needs SQLite 3.44.
|
|
60
|
+
|
|
61
|
+
The scalar functions are real methods rather than anything caught dynamically,
|
|
62
|
+
so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
63
|
+
answers to — `rand` — means the SQL one inside a block:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
abs acos asin atan atan2 bit_and bit_count bit_or bit_xor cast
|
|
67
|
+
ceil char_length coalesce concat
|
|
68
|
+
cos current_date current_time current_timestamp date_trunc degrees
|
|
69
|
+
exp extract floor format greatest least length ln localtime
|
|
70
|
+
localtimestamp log log10 log2 lower ltrim mod now nullif pi
|
|
71
|
+
power radians rand replace round rtrim sign sin sqrt substr tan
|
|
72
|
+
trim trunc upper
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Most are spelled the same everywhere. Where they are not, the method names one
|
|
76
|
+
meaning and each adapter gets its own spelling: `char_length`, `greatest` and
|
|
77
|
+
`least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
|
|
78
|
+
MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
|
|
79
|
+
insists on the second argument the others default to zero — SQLite's takes
|
|
80
|
+
only the one. Where an adapter has no equivalent — `date_trunc` outside
|
|
81
|
+
PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
|
|
82
|
+
whose spelling is `log(2, x)`, the four `bit_*` on SQLite — the block raises
|
|
83
|
+
`NotImplementedError` rather than leaving the database to reject the SQL.
|
|
84
|
+
|
|
85
|
+
`format` is printf formatting, and raises on MySQL, where a function of the
|
|
86
|
+
same name does something else entirely: it puts separators in a number, and
|
|
87
|
+
reads a printf template as the number zero rather than complaining. `fn` still
|
|
88
|
+
reaches it, spelled as the different thing it is:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`fn` reaches functions without a method of their own. Its name is emitted as
|
|
95
|
+
written, so a case-sensitive one can be spelled exactly:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Post.select { fn(:date_trunc, "day", :created_at).as(:day) }
|
|
99
|
+
# SELECT date_trunc('day', "posts"."created_at") AS day
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`op` is the same escape hatch for operators — PostgreSQL alone has dozens
|
|
103
|
+
with no method here, `<@` and `&&` and the geometric ones among them. The
|
|
104
|
+
operator is emitted as written and, like `fn`'s names, whether the adapter
|
|
105
|
+
has it is your assertion; it is checked against the characters PostgreSQL
|
|
106
|
+
allows an operator, so a letter, a space or a quote is refused rather than
|
|
107
|
+
written into the SQL. Both sides take what `fn`'s arguments take — a
|
|
108
|
+
column, an expression, a value quoted by the adapter — and a value is
|
|
109
|
+
spelled in the adapter's own syntax, `to_json` saying a document, `'{a,b}'`
|
|
110
|
+
an array; a Ruby Hash or Array is refused rather than guessed at. The
|
|
111
|
+
result is parenthesized, its precedence being unknown, and so is an
|
|
112
|
+
expression on either side, so a dug value cannot be re-grouped out from
|
|
113
|
+
under it:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
Post.where { op("&&", :tags, "{ruby,sql}") }
|
|
117
|
+
# WHERE ("posts"."tags" && '{ruby,sql}')
|
|
118
|
+
|
|
119
|
+
Post.where { op("<@", :meta.dig(:author), { name: "alice" }.to_json) }
|
|
120
|
+
# WHERE (("meta" #> '{author}') <@ '{"name":"alice"}')
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`sql` is the last resort, for what neither `fn` nor `op` can spell: the
|
|
124
|
+
statement goes out as written. It is the one way a string means SQL inside a
|
|
125
|
+
block — everywhere else a string is a value — so writing SQL is always asked
|
|
126
|
+
for by name, and an interpolation has a spelling that is not it: `?` and
|
|
127
|
+
`:name` placeholders take values quoted by the adapter, through
|
|
128
|
+
`sanitize_sql_array`. A `?` is rewritten only when there are positional binds
|
|
129
|
+
to put in it, so PostgreSQL's `?` operators can share a statement with named
|
|
130
|
+
binds, or with none. The result carries the predications and arithmetic, and
|
|
131
|
+
is parenthesized where it stands inside a larger expression — its precedence
|
|
132
|
+
is whatever was written — but comes out bare at the top of a select list,
|
|
133
|
+
where parentheses would refuse an alias written into the string:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
Post.where { sql("length(title) > ?", 10) }
|
|
137
|
+
# WHERE (length(title) > 10)
|
|
138
|
+
|
|
139
|
+
Post.where { sql("score + ?", 10) * 2 >= 60 }
|
|
140
|
+
# WHERE (score + 10) * 2 >= 60
|
|
141
|
+
|
|
142
|
+
Post.select { sql("count(*) FILTER (WHERE score > 0) AS positive") }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Values are quoted by the adapter wherever they appear, as they are in
|
|
146
|
+
Active Record, and so is a column alias. That is what makes the name asked for
|
|
147
|
+
the name that comes back: unquoted, PostgreSQL folds a capital away where the
|
|
148
|
+
other two keep it, so one block would mean two things. It also leaves nothing
|
|
149
|
+
to refuse — a name that would have been SQL becomes an identifier with a
|
|
150
|
+
strange name instead:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
Author.select { count(:*).as(:postCount) } # AS "postCount" everywhere
|
|
154
|
+
Author.select { count(:*).as(:'total sales') } # AS "total sales"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`quote: false` asks for the name as written, for a schema that wants the
|
|
158
|
+
folding. Nothing quotes it then, so a name that is not plain is refused:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
Author.select { count(:*).as(:post_count, quote: false) } # AS post_count
|
|
162
|
+
Author.select { count(:*).as(:'total sales', quote: false) } # ArgumentError
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`fn`'s function name is the one that cannot be quoted: quoting stops
|
|
166
|
+
PostgreSQL folding it, and `"UPPER"(x)` is a function that does not exist.
|
|
167
|
+
That one, `cast`'s type and `extract`'s field are neither values nor
|
|
168
|
+
identifiers, so they have to be plain names and anything else raises
|
|
169
|
+
`ArgumentError` rather than reaching the query.
|
|
170
|
+
|
|
171
|
+
`current_date`, `current_time`, `current_timestamp`, `localtime` and
|
|
172
|
+
`localtimestamp` come out without parentheses, as the grammar has them —
|
|
173
|
+
written as calls, PostgreSQL and SQLite would reject them. What does go into
|
|
174
|
+
parentheses is an optional precision — `current_timestamp(3)` — which
|
|
175
|
+
`current_date` never takes and SQLite never accepts. `current_timestamp` is
|
|
176
|
+
the portable spelling of what `now` means, and reaches SQLite where `now`
|
|
177
|
+
does not — and SQL Server, which has none of the other four:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
Post.where { :published_at <= current_timestamp }
|
|
181
|
+
# SELECT "posts".* FROM "posts" WHERE "posts"."published_at" <= CURRENT_TIMESTAMP
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`extract` and `cast` are grammar as well: the field and the type go where no
|
|
185
|
+
value could. The field has to be a plain name, and the type has to look like
|
|
186
|
+
a type — a plain name, at most parenthesized with lengths, so the adapters'
|
|
187
|
+
own spellings like `double precision` or `decimal(10,2)` pass; anything else
|
|
188
|
+
raises `ArgumentError`. The type is the adapter's own name for the type, and
|
|
189
|
+
whether it exists is the database's to say. SQLite spells everything
|
|
190
|
+
`extract` does as `strftime` formats, which no renaming carries, so `extract`
|
|
191
|
+
raises there:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
Post.where { extract(:year, :created_at) == 2026 }
|
|
195
|
+
# SELECT "posts".* FROM "posts" WHERE EXTRACT(YEAR FROM "posts"."created_at") = 2026
|
|
196
|
+
|
|
197
|
+
Post.select { cast(:price, "decimal(10,2)").as(:price) }
|
|
198
|
+
# SELECT CAST("posts"."price" AS decimal(10,2)) AS price
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A duration moves a date. Active Support's `7.days` and the rest go on the
|
|
202
|
+
right of `+` or `-`, and the move comes out the way the adapter spells it — an
|
|
203
|
+
`INTERVAL` literal on PostgreSQL and MySQL, `DATEADD` on SQL Server,
|
|
204
|
+
`datetime(x, '-7 day')` on SQLite:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
Post.where { :created_at > current_timestamp - 7.days }
|
|
208
|
+
# WHERE "posts"."created_at" > (CURRENT_TIMESTAMP - INTERVAL '7' DAY)
|
|
209
|
+
|
|
210
|
+
Post.select { (:published_on + 1.month).as(:review_on) }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A duration of several parts, `1.month + 2.days`, is applied a part at a time
|
|
214
|
+
in the order Active Support keeps them, and a week is seven days, which is
|
|
215
|
+
what SQLite and Oracle have of one. Each part has to be a whole number, since
|
|
216
|
+
it is written into the SQL as one: `1.5.days` raises `ArgumentError`. SQLite
|
|
217
|
+
has no date type, so there a column the model declares a date is moved by
|
|
218
|
+
`date()` and stays a date; everything else goes through `datetime()` and comes
|
|
219
|
+
back with a time of day.
|
data/docs/grouping.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Grouping
|
|
2
|
+
|
|
3
|
+
## Keeping one row per group
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
`distinct_on` is PostgreSQL's `DISTINCT ON`: the first row of each group the
|
|
7
|
+
order brings up.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
Post.distinct_on { :author_id }.order { [:author_id, :likes.desc] }
|
|
11
|
+
# SELECT DISTINCT ON ( "author_id" ) "posts".* FROM "posts"
|
|
12
|
+
# ORDER BY "author_id", "likes" DESC
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Arel carries the node and refuses to write it for the others, the way it does
|
|
16
|
+
a regexp, so it raises `NotImplementedError` on SQLite and MySQL. The shape
|
|
17
|
+
that runs everywhere is a `row_number` window in a subquery, which says the
|
|
18
|
+
same thing at more length:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
ranked = Post.select {
|
|
22
|
+
[:author_id, :likes, row_number.over.partition(:author_id).order(:likes.desc).as(:rn)]
|
|
23
|
+
}
|
|
24
|
+
Post.from(ranked, :posts).where { :rn == 1 }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The subquery is named after the model's own table for the reason `from_cte`
|
|
28
|
+
is: Active Record goes on qualifying columns with that name, so `where` needs
|
|
29
|
+
to find it.
|
|
30
|
+
|
|
31
|
+
## Grouping several ways at once
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
`grouping_sets`, `rollup` and `cube` ask for more than one grouping in a
|
|
35
|
+
single query, the totals of each coming back beside the rows. Each set is a
|
|
36
|
+
list of its own, and an empty one is the grand total:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
Sale.group { grouping_sets([:region], [:product], []) }.
|
|
40
|
+
select { [:region, :product, sum(:amount).as(:total)] }
|
|
41
|
+
# GROUP BY GROUPING SETS( ( "region" ), ( "product" ), ( ) )
|
|
42
|
+
|
|
43
|
+
Sale.group { rollup(:region, :product) } # GROUP BY ROLLUP( "region", "product" )
|
|
44
|
+
Sale.group { cube(:region, :product) } # GROUP BY CUBE( "region", "product" )
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A row that a set did not group by comes back with NULL there, which is also
|
|
48
|
+
what a real NULL looks like; `fn(:grouping, :region)` tells the two apart.
|
|
49
|
+
|
|
50
|
+
`grouping_sets` and `cube` are PostgreSQL's; SQLite has none of the three and
|
|
51
|
+
both raise `NotImplementedError` elsewhere. `rollup` runs on MySQL and
|
|
52
|
+
MariaDB too, spelled as their `WITH ROLLUP` — which trails the whole group
|
|
53
|
+
list, so there a rollup cannot stand beside other group entries the way
|
|
54
|
+
`ROLLUP(...)` can, and the block says so. MariaDB is also the one that
|
|
55
|
+
refuses `ORDER BY` next to it, and the one without `fn(:grouping, ...)`.
|
data/docs/joins.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Joins
|
|
2
|
+
|
|
3
|
+
## Joins
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
The block is the `ON` clause:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
Author.
|
|
10
|
+
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
11
|
+
joins(:comments) { :comments[:post_id] == :posts[:id] }
|
|
12
|
+
|
|
13
|
+
Author.left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`as` names the table within the query, which is what makes a self join
|
|
17
|
+
expressible — the qualified columns in the block go by that name:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manager_id] }
|
|
21
|
+
# SELECT "employees".* FROM "employees"
|
|
22
|
+
# INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`right_outer_joins` and `full_outer_joins` are the two Active Record has no
|
|
26
|
+
method for, and they take what `joins` takes. An association name is not among
|
|
27
|
+
it: what Active Record reads out of one is an inner or a left join and nothing
|
|
28
|
+
else, so these two want the block that says how to join.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Author.right_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
32
|
+
Author.full_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
MySQL has no `FULL OUTER JOIN` and neither has MariaDB, so `full_outer_joins`
|
|
36
|
+
raises `NotImplementedError` there. SQLite has had one since 3.39.
|
|
37
|
+
|
|
38
|
+
`cross_joins` is every row of one table against every row of the other. There
|
|
39
|
+
is no condition to give, so it takes no block — `as` still names the table:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
Post.cross_joins(:authors) # FROM "posts" CROSS JOIN "authors"
|
|
43
|
+
Post.cross_joins(:posts, as: :others) # FROM "posts" CROSS JOIN "posts" "others"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Lateral joins
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
A relation marked `lateral` joins in place of a table, and sees the row being
|
|
50
|
+
joined to — in SQL the keyword modifies the subquery, not the join, so that is
|
|
51
|
+
where it is written. It is what makes the top row of each group reachable in
|
|
52
|
+
one query:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
top_post = Post.select { :title }.
|
|
56
|
+
where { :posts[:author_id] == :authors[:id] }.
|
|
57
|
+
order { :likes.desc }.limit(1)
|
|
58
|
+
|
|
59
|
+
Author.left_outer_joins(top_post.lateral, as: :top).
|
|
60
|
+
select { [:name, :top[:title].as(:top_post)] }
|
|
61
|
+
# SELECT "name", "top"."title" AS "top_post" FROM "authors"
|
|
62
|
+
# LEFT OUTER JOIN LATERAL (SELECT "title" FROM "posts"
|
|
63
|
+
# WHERE "posts"."author_id" = "authors"."id" ORDER BY "likes" DESC LIMIT 1) "top" ON TRUE
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`as` is required — the relation has no name of its own to qualify with. Without
|
|
67
|
+
a block the join is `ON TRUE`, which is the usual shape: what the subquery is
|
|
68
|
+
allowed to see is said inside it. A block writes a real `ON` clause.
|
|
69
|
+
|
|
70
|
+
PostgreSQL has `LATERAL` and so has MySQL, from 8.0.14. SQLite has none, and
|
|
71
|
+
neither has MariaDB, which answers to the same adapter as MySQL; both raise
|
|
72
|
+
`NotImplementedError`. Arel has a node for it but only PostgreSQL's visitor
|
|
73
|
+
writes it, so the SQL is written here instead.
|
data/docs/json.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# JSON
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
`dig` reads inside a JSON document, by the name of what `Hash` does. A string
|
|
5
|
+
or symbol steps into an object, an integer into an array, and what comes back
|
|
6
|
+
is still JSON — the way `Hash#dig` hands back the structure itself — for a
|
|
7
|
+
document to be dug into further or asked the JSON questions. `dig_text` gives
|
|
8
|
+
the value as text instead, which is what a comparison wants:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
Post.where { :meta.dig_text(:author, :name) == "alice" }
|
|
12
|
+
Post.select { :meta.dig(:author).as(:author) }
|
|
13
|
+
Post.where { :meta.key?(:draft) }
|
|
14
|
+
Post.where { :meta.contains?(status: "open") }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
No two adapters spell any of this alike, and the block is the same on all
|
|
18
|
+
three:
|
|
19
|
+
|
|
20
|
+
| | PostgreSQL | SQLite | MySQL |
|
|
21
|
+
| --- | --- | --- | --- |
|
|
22
|
+
| `dig(:a, :b)` | `#> '{a,b}'` | `-> '$.a.b'` | `JSON_EXTRACT(…, '$.a.b')` |
|
|
23
|
+
| `dig_text(:a, :b)` | `#>> '{a,b}'` | `->> '$.a.b'` | `JSON_UNQUOTE(JSON_EXTRACT(…, '$.a.b'))` |
|
|
24
|
+
| `key?(:a)` | `? 'a'` | `json_type(…, '$.a') IS NOT NULL` | `JSON_CONTAINS_PATH(…, 'one', '$.a')` |
|
|
25
|
+
| `contains?(…)` | `@>` | — | `JSON_CONTAINS` |
|
|
26
|
+
|
|
27
|
+
MariaDB answers to the `mysql2` adapter and has none of `->` or `->>`, so the
|
|
28
|
+
MySQL family goes through the functions, which both have.
|
|
29
|
+
|
|
30
|
+
`dig_text` gives text everywhere. SQLite's `->>` would otherwise hand back the
|
|
31
|
+
value with its type, so a comparison that worked there would fail on the other
|
|
32
|
+
two; a number is compared through a `cast` on all three:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Post.where { :meta.dig_text(:n) == "5" }
|
|
36
|
+
Post.where { cast(:meta.dig_text(:n), "integer") > 6 } # 'signed' on MySQL
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The type is the adapter's own name for it, here as everywhere `cast` is used.
|
|
40
|
+
|
|
41
|
+
Strings and numbers are where the adapters agree. A JSON boolean comes back as
|
|
42
|
+
`"1"` on SQLite, which turns `true` into SQL's `1` before the text cast, and
|
|
43
|
+
as `"true"` on the other two; a JSON `null` is SQL `NULL` everywhere but
|
|
44
|
+
MariaDB, which spells it `"null"`. A key that is not there is `NULL` on all
|
|
45
|
+
three.
|
|
46
|
+
|
|
47
|
+
Comparing `dig_text`'s value with anything but a string raises
|
|
48
|
+
`ArgumentError` rather than being left to the adapters, which answer it three
|
|
49
|
+
ways: `dig_text(:n) == 5` is true on SQLite, an error on PostgreSQL and true
|
|
50
|
+
on MySQL, and `dig_text(:flag) == true` is true, an error and false. `cast`
|
|
51
|
+
is what says which type was meant, and then all three agree.
|
|
52
|
+
|
|
53
|
+
A JSON comparison — `dig`'s side, and `bury`'s and `except`'s — belongs to
|
|
54
|
+
the JSON types: on PostgreSQL's `jsonb` and MySQL's `JSON` alike, numbers
|
|
55
|
+
compare as numbers and documents structurally, key order and spelling aside,
|
|
56
|
+
so a dug value compares with a Ruby one directly. SQLite and MariaDB have
|
|
57
|
+
only the text of each, which is a different question, and raise
|
|
58
|
+
`NotImplementedError` as the SQL is written. `in?` and `between?` are the
|
|
59
|
+
two MySQL leaves out of its JSON comparisons, so there they are spelled as
|
|
60
|
+
the comparisons they mean — the range as its bounds, the list as one
|
|
61
|
+
equality per element, which names the dug value once per element the way
|
|
62
|
+
SQLite's XOR names its operands twice:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
Post.where { :meta.dig(:stars) >= 10 } # PostgreSQL and MySQL
|
|
66
|
+
Post.where { :meta.dig(:author) == { "name" => "alice" } }
|
|
67
|
+
Post.where { :meta.dig(:stars).in?([5, 10]) }
|
|
68
|
+
Post.where { cast(:meta.dig_text(:stars), "integer") >= 10 } # everywhere
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A column, a function or another dug value on the right goes through untouched
|
|
72
|
+
on every adapter. Arithmetic and the bit operators are refused outright on
|
|
73
|
+
both sides — `dig_text(:n) + 1` is 6 on SQLite, an error on PostgreSQL and
|
|
74
|
+
6.0 on MariaDB — and `cast` settles those too.
|
|
75
|
+
|
|
76
|
+
`bury` sets what `dig` reads: the last argument is the value and the rest are
|
|
77
|
+
the path to it. The document comes back changed rather than being written
|
|
78
|
+
anywhere, so `update_all` is what makes it stick:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
Post.update_all { { meta: :meta.bury(:author, :name, "alice") } }
|
|
82
|
+
# SET "meta" = jsonb_set("meta", '{author,name}', '"alice"')
|
|
83
|
+
# ... JSON_SET("meta", '$.author.name', 'alice') elsewhere
|
|
84
|
+
|
|
85
|
+
Post.update_all { { meta: :meta.bury(:tags, ["ruby", "sql"]) } }
|
|
86
|
+
Post.update_all { { meta: :meta.bury(:copy, :meta.dig(:n)) } }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A whole document goes in as one — an object or an array rather than the string
|
|
90
|
+
that spells it — which each adapter takes its own way round, and a boolean
|
|
91
|
+
goes in as JSON too, which SQLite would otherwise write as its `1`. `bury` is
|
|
92
|
+
not a Ruby method; it is the name Ruby considered for the other end of `dig`,
|
|
93
|
+
and
|
|
94
|
+
SQL has no one name to borrow here, since PostgreSQL says `jsonb_set` where
|
|
95
|
+
the others say `JSON_SET`.
|
|
96
|
+
|
|
97
|
+
`except` takes keys out again, and takes them as `Hash#except` does — keys of
|
|
98
|
+
the document, however many, rather than a path, which is `bury`'s way of
|
|
99
|
+
reaching further in. It gives back the document changed, so it chains with
|
|
100
|
+
`bury` and goes where `bury` goes:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
Post.update_all { { meta: :meta.except(:draft) } }
|
|
104
|
+
# SET "meta" = "meta" - CAST('{"draft"}' AS text[])
|
|
105
|
+
# ... JSON_REMOVE("meta", '$.draft') elsewhere
|
|
106
|
+
|
|
107
|
+
Post.update_all { { meta: :meta.bury(:author, :name, "alice").except(:tmp) } }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A key that is not there is not an error, as it is not to `Hash#except`. The
|
|
111
|
+
cast is not decoration: `jsonb` has three subtractions — a key, an array of
|
|
112
|
+
keys, an element by index — and an array literal written without a type is
|
|
113
|
+
read as the first of them, so `"meta" - '{draft}'` takes out the key spelled
|
|
114
|
+
`{draft}`, which is nothing, and says nothing about it.
|
|
115
|
+
|
|
116
|
+
A key deeper in is reached through the chain: `dig` reads the part out,
|
|
117
|
+
`except` takes the key from it, and `bury` puts it back:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
Post.update_all { { meta: :meta.bury(:author, :meta.dig(:author).except(:email)) } }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
What `dig` gives is a document, so the JSON operations read it — the same
|
|
124
|
+
question asked of a part of the document rather than of all of it:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
Post.where { :meta.dig(:author).key?(:email) }
|
|
128
|
+
Post.where { :meta.dig(:author).dig_text(:name) == "alice" }
|
|
129
|
+
Post.update_all { { meta: :meta.dig(:author).bury(:name, "alice") } }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Containment reads it too, on the adapters that have containment at all:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
Post.where { :meta.dig(:tags).contains?(["ruby"]) }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Asking the same of `dig_text` raises `ArgumentError`: what it gives is text,
|
|
139
|
+
and reading text back as a document is where the adapters part company —
|
|
140
|
+
SQLite parses it, MySQL takes it as written, and PostgreSQL has no such
|
|
141
|
+
function for text at all.
|
|
142
|
+
|
|
143
|
+
`contains?` has no equivalent on SQLite and raises `NotImplementedError`
|
|
144
|
+
there — later than the rest, since the adapter is only known when the SQL is
|
|
145
|
+
built. On PostgreSQL, `dig` and `dig_text` are all the `json` type carries;
|
|
146
|
+
`key?`, `contains?`, `bury` and `except` want a `jsonb` column.
|
|
147
|
+
|
|
148
|
+
A key that is not a plain name travels as itself rather than being refused:
|
|
149
|
+
`dig(:'odd key')` becomes `'{odd key}'` or `$."odd key"`.
|
|
150
|
+
|
|
151
|
+
`keys` gives the keys of the document, as `Hash#keys` does — a JSON array
|
|
152
|
+
of them. Only the MySQL family has a function for it; the other two reach
|
|
153
|
+
the same array through a subquery over their key-listing functions, guarded
|
|
154
|
+
by type so that all four answer alike: the keys of anything that is not an
|
|
155
|
+
object are `NULL` — rather than SQLite's array indices or PostgreSQL's
|
|
156
|
+
error — and the keys of `{}` are `[]` rather than PostgreSQL's `NULL`:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
Post.select { :meta.keys.as(:fields) }
|
|
160
|
+
Post.select { :meta.dig(:author).keys.as(:author_fields) }
|
|
161
|
+
# JSON_KEYS("meta") MySQL
|
|
162
|
+
# CASE WHEN jsonb_typeof("meta") = 'object' THEN COALESCE((…)) PostgreSQL
|
|
163
|
+
# CASE WHEN json_type("meta") = 'object' THEN (SELECT …) SQLite
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The order the keys come in is the adapters' own: the JSON types give their
|
|
167
|
+
normalized order and the text ones the stored order — the same divide every
|
|
168
|
+
JSON comparison here rides on.
|
|
169
|
+
|
|
170
|
+
`json_array` and `json_object` build a document in the row — `json_array`
|
|
171
|
+
from the values given, `json_object` from a Ruby hash. The names are the
|
|
172
|
+
standard's, which SQLite and the MySQL family say as written; PostgreSQL is
|
|
173
|
+
asked to build `jsonb`. A hash rather than SQL's alternating keys and
|
|
174
|
+
values, because a bare symbol means a column in every block here: the keys
|
|
175
|
+
are Ruby's and the values are expressions, so `title: :title` reads the
|
|
176
|
+
column in under its own name with no rule to remember:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
Post.select { json_object(title: :title, stars: :meta.dig(:stars)).as(:summary) }
|
|
180
|
+
# jsonb_build_object('title', "title", 'stars', "meta" #> '{stars}') PostgreSQL
|
|
181
|
+
# JSON_OBJECT('title', "title", 'stars', JSON_EXTRACT(…)) elsewhere
|
|
182
|
+
|
|
183
|
+
Post.where { :meta.dig(:author) == json_object(name: :name) }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A Ruby value among the arguments goes in as its JSON self — a string or a
|
|
187
|
+
number as themselves, `nil` as `null`, and a boolean or a whole document
|
|
188
|
+
through the same route `bury` takes them, so SQLite's `true` is not its
|
|
189
|
+
`1`. A key that is not a string or a symbol is refused, before the
|
|
190
|
+
adapters answer a NULL key three ways. The empty calls stand —
|
|
191
|
+
`json_array()` is `[]` and `json_object()` is `{}` on all four — and what
|
|
192
|
+
comes back is JSON as `dig`'s is, so the operations and comparisons above
|
|
193
|
+
read it.
|
|
194
|
+
|
|
195
|
+
`json_arrayagg` and `json_objectagg` gather rows into one JSON document — a
|
|
196
|
+
value from each row into an array, a key and a value into an object. The
|
|
197
|
+
names are the SQL standard's, which the MySQL family says as written;
|
|
198
|
+
PostgreSQL is asked the `jsonb` pair and SQLite its own:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
Post.group { :author_id }.select { json_arrayagg(:title).as(:titles) }
|
|
202
|
+
# jsonb_agg("title") PostgreSQL
|
|
203
|
+
# json_group_array("title") SQLite
|
|
204
|
+
# JSON_ARRAYAGG("title") MySQL
|
|
205
|
+
|
|
206
|
+
Post.select { json_objectagg(:title, :meta.dig(:stars)).as(:stars) }
|
|
207
|
+
|
|
208
|
+
Post.group { :author_id }.
|
|
209
|
+
select { json_arrayagg(json_object(title: :title, stars: :meta.dig(:stars))).as(:posts) }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
What they give is JSON as `dig`'s is, so it compares the way a dug value
|
|
213
|
+
does, and `filter` and `over` come along as with any aggregate — with two
|
|
214
|
+
refusals where a respelling would change the meaning rather than the
|
|
215
|
+
spelling. The MySQL family has no `FILTER`, and the `CASE` that stands in
|
|
216
|
+
for it elsewhere would leave a JSON `null` in the document for every row it
|
|
217
|
+
drops, so there `filter` raises `NotImplementedError`; MariaDB takes every
|
|
218
|
+
other aggregate as a window function but not these two, so `over` raises
|
|
219
|
+
there too.
|
|
220
|
+
|
|
221
|
+
The documents agree across adapters, up to the edges of their JSON types.
|
|
222
|
+
Over no rows at all SQLite answers `[]` and `{}` where the others answer
|
|
223
|
+
`NULL`, as their aggregates do. A key aggregated twice keeps the last pair
|
|
224
|
+
on the JSON types — `jsonb` and MySQL's — and every pair on the text ones,
|
|
225
|
+
SQLite and MariaDB, and a `NULL` key is an error on the former pair and a
|
|
226
|
+
dropped pair on the latter. And a bare JSON *column* is text to SQLite's
|
|
227
|
+
`json_group_array`, so it lands as the string that spells the document
|
|
228
|
+
rather than nesting as it does on the other three; a dug value nests
|
|
229
|
+
everywhere, so `json_arrayagg(:meta.dig(:author))` is the portable way to
|
|
230
|
+
collect part of a document.
|
data/docs/ordering.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Aliases, ordering and collation
|
|
2
|
+
|
|
3
|
+
## Aliases and ordering
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
`.as` gives an expression a column alias, and `.asc` / `.desc` give an
|
|
7
|
+
ordering its direction. The orderings take `.nulls_first` / `.nulls_last` as
|
|
8
|
+
well. MySQL has no such syntax, but Arel emulates it there, so the resulting
|
|
9
|
+
order is the same everywhere:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
Author.order { :country.asc.nulls_last }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Together:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Author.
|
|
19
|
+
joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
20
|
+
where { :posts[:published] == true }.
|
|
21
|
+
group { :authors[:id] }.
|
|
22
|
+
having { count(:posts[:id]) > 1 }.
|
|
23
|
+
order { count(:posts[:id]).desc }.
|
|
24
|
+
select {
|
|
25
|
+
[
|
|
26
|
+
upper(:authors[:name]).as(:author),
|
|
27
|
+
count(:posts[:id]).as(:post_count),
|
|
28
|
+
avg(:posts[:likes]).as(:avg_likes),
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Collation
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
`.collate` names a collation for a comparison or an ordering — how the
|
|
37
|
+
database decides two strings are equal and which comes first — and gives back
|
|
38
|
+
an expression, so the collation carries into either:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Author.where { :name.collate(:nocase) == "alice" }
|
|
42
|
+
# WHERE "authors"."name" COLLATE nocase = 'alice'
|
|
43
|
+
|
|
44
|
+
Author.order { :name.collate(:nocase).asc }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The collation names are the database's own, so they are not portable — SQLite's
|
|
48
|
+
`nocase`, PostgreSQL's `"C"`, MySQL's `utf8mb4_bin`. PostgreSQL folds an
|
|
49
|
+
unquoted name to lower case, where its built-in names are upper, so it quotes
|
|
50
|
+
the name for you.
|
|
51
|
+
|
|
52
|
+
On the databases that take the name bare, it has to be a plain identifier: a
|
|
53
|
+
hyphen would read as a subtraction, so a name with one is refused. PostgreSQL
|
|
54
|
+
quotes the name, so a hyphen is safe there, and its ICU collations —
|
|
55
|
+
`en-US-x-icu` and the rest — are spelled with them.
|
data/docs/time_zones.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Time zones
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
Active Record stores a datetime in UTC unless `ActiveRecord.default_timezone`
|
|
5
|
+
says `:local`, and a value a block quotes follows it: a `Time` on the right of
|
|
6
|
+
a comparison is converted the way Active Record converts one, so
|
|
7
|
+
`where { :created_at > Time.current - 1.day }` is right whatever `Time.zone`
|
|
8
|
+
is. What is not converted is what the database says the time is.
|
|
9
|
+
|
|
10
|
+
`current_timestamp` and its relatives are the server's clock in the session's
|
|
11
|
+
zone. Active Record sets PostgreSQL's session to UTC and SQLite's clock is UTC
|
|
12
|
+
already, so on those two the clock and the stored values agree. MySQL's
|
|
13
|
+
session keeps the server's own zone, so a server that sits in Tokyo answers
|
|
14
|
+
`CURRENT_TIMESTAMP` nine hours off the values Active Record stored; set the
|
|
15
|
+
session in `database.yml` — `variables: { time_zone: "+00:00" }` — or write
|
|
16
|
+
`Time.current` in the block instead, which is right everywhere. SQL Server
|
|
17
|
+
answers in its operating system's zone and Oracle in the client's, where
|
|
18
|
+
`Time.current` is again the one to reach for. `current_date` is today in UTC,
|
|
19
|
+
which in Tokyo is still yesterday until nine in the morning; `Date.current`
|
|
20
|
+
says the day meant.
|
|
21
|
+
|
|
22
|
+
`extract`, `date_trunc` and a `group` by day cut at UTC's midnight. A zone
|
|
23
|
+
without daylight saving is a fixed offset away —
|
|
24
|
+
`date_trunc("day", :created_at + 9.hours)` — and one with it needs the
|
|
25
|
+
database's own `AT TIME ZONE`, through `sql`.
|
|
26
|
+
|
|
27
|
+
A datetime computed in the query, `(:created_at + 1.hour).as(:later)`, has no
|
|
28
|
+
column to take a type from, so it comes back as a `Time` in UTC — as text on
|
|
29
|
+
SQLite — with no `Time.zone` applied; `in_time_zone` on the Ruby side does
|
|
30
|
+
that.
|