activerecord-refined 0.9.0 → 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +17 -0
  3. data/README.md +93 -965
  4. data/activerecord-refined.gemspec +12 -7
  5. data/docs/conditions.md +206 -0
  6. data/docs/ctes.md +65 -0
  7. data/docs/expressions.md +125 -0
  8. data/docs/functions.md +219 -0
  9. data/docs/grouping.md +55 -0
  10. data/docs/joins.md +73 -0
  11. data/docs/json.md +230 -0
  12. data/docs/ordering.md +55 -0
  13. data/docs/time_zones.md +30 -0
  14. data/docs/windows.md +41 -0
  15. data/docs/writing.md +33 -0
  16. data/examples/aggregations.rb +18 -0
  17. data/examples/expressions.rb +35 -5
  18. data/lib/active_record/refined/ast.rb +461 -246
  19. data/lib/active_record/refined/dialect/mariadb.rb +25 -0
  20. data/lib/active_record/refined/dialect/mysql.rb +18 -0
  21. data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
  22. data/lib/active_record/refined/dialect/oracle.rb +110 -0
  23. data/lib/active_record/refined/dialect/postgresql.rb +120 -0
  24. data/lib/active_record/refined/dialect/sql_server.rb +115 -0
  25. data/lib/active_record/refined/dialect/sqlite.rb +57 -0
  26. data/lib/active_record/refined/dialect.rb +340 -0
  27. data/lib/active_record/refined.rb +682 -192
  28. data/lib/activerecord-refined/version.rb +1 -1
  29. data/lib/activerecord-refined.rb +1 -0
  30. metadata +58 -16
  31. data/.github/workflows/push_gem.yml +0 -45
  32. data/.github/workflows/sandbox.yml +0 -295
  33. data/.github/workflows/test.yml +0 -104
  34. data/.gitignore +0 -19
  35. data/.rubocop.yml +0 -393
  36. data/Gemfile +0 -14
  37. data/Rakefile +0 -53
  38. data/benchmark/query_building.rb +0 -129
  39. data/test/test_block_syntax.rb +0 -2999
  40. data/test/test_helper.rb +0 -238
@@ -13,14 +13,12 @@ Gem::Specification.new do |gem|
13
13
  gem.description = "Adding clean and powerful query syntax on Active Record using refinements"
14
14
  gem.summary = "Write Active Record queries as Ruby expressions"
15
15
  gem.homepage = "https://github.com/shugo/activerecord-refined"
16
+ gem.license = "MIT"
17
+ gem.metadata = {
18
+ "documentation_uri" => "https://rubydoc.info/gems/activerecord-refined",
19
+ }
16
20
 
17
- # sandbox/ is a site, not part of the library: its Gemfile.lock and
18
- # package-lock.json have no business in anyone's bundle. CLAUDE.md and
19
- # .claude/ are addressed to whoever is working on the repository, not to
20
- # anyone using it.
21
- gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$|^\.claude/})
22
- gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
23
- gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
21
+ gem.files = `git ls-files -- lib docs examples README.md LICENSE.txt .yardopts activerecord-refined.gemspec`.split($/)
24
22
  gem.require_paths = ["lib"]
25
23
 
26
24
  # Proc#refined is available since Ruby 4.1. 4.1.0.dev is required to allow
@@ -44,4 +42,11 @@ Gem::Specification.new do |gem|
44
42
  gem.add_development_dependency "rubocop-performance", [">= 0"]
45
43
  gem.add_development_dependency "rubocop-rails", [">= 0"]
46
44
  gem.add_development_dependency "rubocop-md", [">= 0"]
45
+ # What renders the reference: `yard server --reload` serves it locally, and
46
+ # rubydoc.info renders the same .yardopts. The plugin turns the README's
47
+ # relative links to docs/*.md -- which GitHub follows as they are -- into
48
+ # links to YARD's file pages; where it is not installed, rubydoc.info among
49
+ # them, YARD says so and leaves the links relative.
50
+ gem.add_development_dependency "yard", [">= 0"]
51
+ gem.add_development_dependency "yard-markdown-relative-links", [">= 0"]
47
52
  end
@@ -0,0 +1,206 @@
1
+ # Conditions
2
+
3
+
4
+ ```ruby
5
+ Author.where { :age >= 18 }
6
+ Author.where { :name.like?("A%") } # LIKE
7
+ Author.where { :age.in?(20..40) } # BETWEEN
8
+ Author.where { :age.between?(20, 40) } # BETWEEN
9
+ Author.where { :age.in?(18..) } # >= 18
10
+ Author.where { :country.in?(%w[JP US]) } # IN
11
+ Author.where { :country.null? } # IS NULL
12
+ ```
13
+
14
+ `!` negates any of these. Where SQL has a negative of its own, so does the
15
+ block, which is the same rows written the way they would be written by hand:
16
+
17
+ ```ruby
18
+ Author.where { :country.not_null? } # IS NOT NULL
19
+ Author.where { :country.not_in?(%w[JP US]) } # NOT IN
20
+ Author.where { :age.not_between?(20, 40) } # not between 20 and 40
21
+ Author.where { :name.not_like?("A%") } # NOT LIKE
22
+ Author.where { :name.not_ilike?("a%") } # NOT ILIKE / NOT LIKE
23
+
24
+ Author.where { !:name.start_with?("A") } # NOT (name LIKE 'A%')
25
+ ```
26
+
27
+ Nothing turns on the choice: `NOT (country IS NULL)` and `country IS NOT NULL`
28
+ select the same rows, NULLs included. `not_between?` is the one whose SQL
29
+ looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
30
+ age > 40`, which is again the same rows.
31
+
32
+ A number compares as itself, the way a bound `?` does: `:age >= 99.5` says
33
+ `>= 99.5`, where `where(age: 99.5..)` casts to the column's type and says
34
+ `>= 99`, letting an age of 99 through a bound it does not satisfy. Everything
35
+ that is not an `Integer`, `Float` or `BigDecimal` keeps the column's own
36
+ serialization — an enum's name, a time's zone, a custom type's scaling — so a
37
+ custom type that scales a number, money kept in cents, is the one place the
38
+ number has to be written as the column stores it.
39
+
40
+ A boolean column has `true?` and `false?`, which become SQL's `IS TRUE` and
41
+ `IS FALSE`, and the two negations to go with them:
42
+
43
+ ```ruby
44
+ Post.where { :published.true? } # IS TRUE
45
+ Post.where { :published.not_true? } # IS NOT TRUE
46
+ Post.where { :published.false? } # IS FALSE
47
+ Post.where { :published.not_false? } # IS NOT FALSE
48
+ ```
49
+
50
+ `published = TRUE` selects the same rows as `published IS TRUE`, so the
51
+ difference is in the negation: `published = TRUE` is itself NULL for a row
52
+ where the column is, and a NULL predicate selects nothing, while `IS TRUE`
53
+ answers false there. `not_true?` is therefore "false or never set" and
54
+ `!(:published == true)` only "false". Every adapter spells all four the same
55
+ way and answers them alike.
56
+
57
+ `in?` also takes a relation as a subquery. Without an explicit select list the
58
+ subquery selects the relation's primary key, the same way Active Record's own
59
+ `where(id: relation)` does:
60
+
61
+ ```ruby
62
+ Author.where { :id.in?(Post.published.select(:author_id)) }
63
+ # "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
64
+ ```
65
+
66
+ A relation on the right of a comparison is a scalar subquery. It has to select
67
+ one value, so unlike `in?` there is no default select list and one is
68
+ required:
69
+
70
+ ```ruby
71
+ Author.where { :age >= Author.select { avg(:age) } }
72
+ # "authors"."age" >= (SELECT AVG("authors"."age") FROM "authors")
73
+ ```
74
+
75
+ `any` and `all` quantify that comparison instead, which is what lifts the
76
+ one-row rule: `> any` asks whether the subquery holds a smaller value anywhere,
77
+ `>= all` whether it holds a larger one nowhere.
78
+
79
+ ```ruby
80
+ Author.where { :age > any(Author.where(country: "JP").select(:age)) }
81
+ # "authors"."age" > ANY(SELECT "authors"."age" FROM "authors" WHERE ...)
82
+
83
+ Author.where { :age >= all(Author.select(:age)) }
84
+ # "authors"."age" >= ALL(SELECT "authors"."age" FROM "authors")
85
+ ```
86
+
87
+ The select list follows `in?`'s rule rather than the scalar one: without an
88
+ explicit select the subquery selects the primary key. `== any` is what `IN`
89
+ says and `!= all` what `NOT IN` says, so what the quantifiers add is the four
90
+ comparisons `IN` has no spelling for. SQLite has neither quantifier, and says
91
+ so with `NotImplementedError` rather than leaving its parser to.
92
+
93
+ `exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
94
+ subquery with the outer table through qualified columns — its `where` block
95
+ goes through the DSL like any other:
96
+
97
+ ```ruby
98
+ Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
99
+ # EXISTS (SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = "authors"."id")
100
+
101
+ Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
102
+ # NOT (EXISTS (...))
103
+ ```
104
+
105
+ `like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
106
+ Arel would otherwise reach for `ILIKE`. `ilike?` is the one that asks for
107
+ `ILIKE`; off PostgreSQL it is plain `LIKE`, which those adapters already match
108
+ case-insensitively under their default collations. `casecmp?` is
109
+ case-insensitive equality, folded on both sides rather than left to the
110
+ collation, so it means the same thing everywhere:
111
+
112
+ ```ruby
113
+ Author.where { :name.ilike?("ma%") } # ILIKE 'ma%' / LIKE 'ma%'
114
+ Author.where { :name.casecmp?("Alice") } # LOWER(name) = LOWER('Alice')
115
+ ```
116
+
117
+ `not_distinct_from?` and `distinct_from?` compare with NULL treated as a
118
+ value, rather than as the unknown that makes `=` and `<>` neither true nor
119
+ false. PostgreSQL spells this `IS [NOT] DISTINCT FROM`, SQLite `IS` / `IS NOT`
120
+ and MySQL `<=>`, and the rows that come back are the same on all three:
121
+
122
+ ```ruby
123
+ Author.where { :country.not_distinct_from?(params[:country]) } # matches NULL to nil
124
+ Author.where { :country.distinct_from?("JP") } # keeps the NULL rows
125
+ ```
126
+
127
+ `start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
128
+ patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
129
+ and `_` in it are escaped rather than matched as wildcards:
130
+
131
+ ```ruby
132
+ Author.where { :name.start_with?("A") } # LIKE 'A%'
133
+ Author.where { :name.end_with?("son") } # LIKE '%son'
134
+ Author.where { :name.include?("test") } # LIKE '%test%'
135
+ ```
136
+
137
+ Like their String namesakes, `start_with?` and `end_with?` take any number of
138
+ literals; matching any one of them is enough:
139
+
140
+ ```ruby
141
+ Author.where { :name.start_with?("A", "B") }
142
+ # (name LIKE 'A%' OR name LIKE 'B%')
143
+ ```
144
+
145
+ `member?`, `superset?`, `subset?` and `intersect?` compare against a
146
+ PostgreSQL array column, each carrying the meaning of its Ruby namesake:
147
+ `member?` is Enumerable's element test (which String does not have — that is
148
+ what separates it from `include?`), `superset?` and `subset?` are Set's
149
+ whole-array containment, and `intersect?` is Array's "any element in common":
150
+
151
+ ```ruby
152
+ Article.where { :tags.member?("ruby") } # tags @> '{ruby}'
153
+ Article.where { :scores.member?(80) } # scores @> '{80}'
154
+ Article.where { :tags.superset?(%w[ruby rails]) } # tags @> '{ruby,rails}'
155
+ Article.where { :tags.subset?(%w[ruby rails go]) } # tags <@ '{ruby,rails,go}'
156
+ Article.where { :tags.intersect?(%w[ruby go]) } # tags && '{ruby,go}'
157
+ ```
158
+
159
+ Like its namesake, `member?` takes one element — `[1, 2].member?([1])` is
160
+ false in Ruby, so an Array argument raises rather than quietly meaning
161
+ something `Array#member?` does not. Requiring every element is `superset?`.
162
+
163
+ `=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
164
+ `~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
165
+ raises there.
166
+
167
+ ```ruby
168
+ Author.where { :name =~ "^A" } # REGEXP / ~
169
+ Author.where { :name !~ "^A" } # NOT REGEXP / !~
170
+ Author.where { :name =~ /son$/ } # a Regexp literal works too
171
+ ```
172
+
173
+ Only a literal's source crosses over; the database has its own dialect and no
174
+ equivalent of Ruby's flags. Dropping one would silently change what the query
175
+ matches, so `/son$/i` raises instead — pass the pattern as a string if the
176
+ database can express what you mean.
177
+
178
+ `==` always means SQL `=`, and passes its value through untouched. A Range or an
179
+ Array therefore compares against a PostgreSQL range or array column, the same
180
+ way Active Record's own `where(period: from...to)` does for those column types:
181
+
182
+ ```ruby
183
+ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
184
+ Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
185
+ ```
186
+
187
+ `!=` is SQL `!=` under the same rules, value passed through untouched.
188
+
189
+ For the same reason `== nil` and `!= nil` raise `ArgumentError`: `= NULL` is
190
+ never true in SQL, so a NULL test has to be spelled as one. Use `null?`:
191
+
192
+ ```ruby
193
+ Author.where { :country.null? } # country IS NULL
194
+ Author.where { !:country.null? } # NOT (country IS NULL)
195
+ ```
196
+
197
+ Combine predicates with `&`, `|` and `!`. Ruby's operator precedence makes the
198
+ parentheses around each comparison necessary, though the `?` methods above need
199
+ none:
200
+
201
+ ```ruby
202
+ Author.where { (:age >= 18) & ((:country == "JP") | (:country == "US")) }
203
+ Author.where { !(:age.in?(0..17) | :country.null?) }
204
+ Author.where { !:country.in?(%w[JP US]) } # NOT (country IN ('JP', 'US'))
205
+ Author.where { !:name.like?("%test%") } # NOT (name LIKE '%test%')
206
+ ```
data/docs/ctes.md ADDED
@@ -0,0 +1,65 @@
1
+ # Common table expressions
2
+
3
+
4
+ Active Record's `with` and `with_recursive` need nothing from this gem: a CTE
5
+ is joined by name like any other table, so its `ON` clause is a block, where
6
+ Rails' own documentation reaches for a string join.
7
+
8
+ `from_cte` takes the CTE's name and selects it under the model's own table
9
+ name, so the model's columns resolve:
10
+
11
+ ```ruby
12
+ Node.with_recursive(
13
+ tree: [
14
+ Node.where { :id == root.id }.
15
+ select { [:id, :name, :parent_id, 0.as(:depth)] },
16
+ Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
17
+ select { [:id, :name, :parent_id, (:tree[:depth] + 1).as(:depth)] },
18
+ ]
19
+ ).from_cte(:tree)
20
+ # WITH RECURSIVE "tree" AS (
21
+ # SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id", 0 AS depth
22
+ # FROM "nodes" WHERE "nodes"."id" = 1
23
+ # UNION ALL
24
+ # SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id",
25
+ # ("tree"."depth" + 1) AS depth
26
+ # FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
27
+ # ) SELECT "nodes".* FROM "tree" AS "nodes"
28
+ ```
29
+
30
+ The anchor starts the count and the recursive member adds one, which is how
31
+ the shape of a tree comes out of a flat table. The `0` is a value rather than
32
+ SQL — see [`value`](functions.md) for why a number can say `.as` directly.
33
+
34
+ The alias on the last line is there for Active Record's sake, not SQL's:
35
+ written by hand that line would be `SELECT * FROM tree`. Active Record goes on qualifying
36
+ columns with the model's table name, so without the alias that name is not in
37
+ the query and anything qualifying a column fails:
38
+
39
+ ```ruby
40
+ Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
41
+ # PG::UndefinedTable: missing FROM-clause entry for table "nodes"
42
+ ```
43
+
44
+ Since the model's name is the only one that works, `from_cte` takes it from
45
+ the model rather than asking. It also checks that the name is one `with`
46
+ declares, so a typo is an `ArgumentError` here rather than a query against a
47
+ table nobody has — checked when the SQL is built, so the CTE may be declared
48
+ later in the chain or by a scope merged into it.
49
+
50
+ `from(:tree, as: :nodes)` is the same thing spelled out, without the check,
51
+ and is what to reach for when the name wanted is not the model's.
52
+
53
+ What makes this worth spelling out is how selectively it breaks. `count`,
54
+ `order` and `select` never qualify, so they work without the alias on every
55
+ adapter; it is `where` and `find_by` that stop. A query can therefore look
56
+ right until the day a condition is added to it.
57
+
58
+ A non-recursive CTE joins the same way:
59
+
60
+ ```ruby
61
+ Node.with(roots: Node.where { :parent_id.null? }).
62
+ joins(:roots) { :roots[:id] == :nodes[:parent_id] }
63
+ ```
64
+
65
+ `examples/ctes.rb` walks a category tree with these.
@@ -0,0 +1,125 @@
1
+ # Expressions
2
+
3
+
4
+ `+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
5
+ operators, so an expression groups the way it reads:
6
+
7
+ ```ruby
8
+ Item.where { :price * :quantity > 1000 }
9
+ Item.select { sum(:price * :quantity).as(:total) }
10
+ ```
11
+
12
+ The number may stand on the left — only a column or an expression on the
13
+ right builds a query, so Ruby's own arithmetic is untouched — and
14
+ `BigDecimal` is a number here, being what a decimal column's values are,
15
+ quoted as the exact decimal on either side. A `Rational` is refused: no
16
+ decimal spells `1/3r` exactly, and `to_d` is what says the decimal meant.
17
+
18
+ ```ruby
19
+ Item.select { greatest(20 - :quantity, 0).as(:shortfall) }
20
+ Item.where { BigDecimal("1.08") * :price > 500 }
21
+ ```
22
+
23
+ `&`, `|`, `^`, `~`, `<<` and `>>` are SQL's bitwise operators. Between
24
+ conditions `&` and `|` are AND and OR, and that is where they are defined,
25
+ which leaves them free to mean here what SQL means by them:
26
+
27
+ ```ruby
28
+ Post.where { :flags & 4 > 0 }
29
+ # WHERE ("posts"."flags" & 4) > 0
30
+
31
+ Post.select { (:flags | 4).as(:flags) }
32
+ Post.select { (~:flags).as(:inverted) }
33
+ ```
34
+
35
+ Each parenthesises itself, which is what keeps Ruby's grouping: PostgreSQL
36
+ gives `&` and `|` the same precedence and reads `a | b & c` from the left,
37
+ where Ruby reads the `&` first.
38
+
39
+ A boolean column is refused rather than taken for the one bit it is stored as.
40
+ MySQL and SQLite would quietly answer as `AND` would, PostgreSQL has no such
41
+ operator at all, and one block meaning two things is worse than an
42
+ `ArgumentError` saying that `true?` is what makes a boolean column a
43
+ condition. A condition as an operand is refused for the same reason.
44
+
45
+ XOR is the one the three do not share, and the one where guessing costs most:
46
+ MySQL spells it `^`, which is exponentiation to PostgreSQL, and PostgreSQL
47
+ spells it `#`, which is where a comment starts on MySQL — either way a wrong
48
+ answer rather than an error. Each adapter gets its own, and SQLite, which has
49
+ no XOR at all, gets the two operations it is made of, `(a | b) - (a & b)`.
50
+ That names each operand twice, so keep them cheap.
51
+
52
+ `bit_and`, `bit_or` and `bit_xor` are the aggregates of the first three, and
53
+ `bit_count` counts the bits that are set. SQLite has none of the four.
54
+ PostgreSQL counts the bits of a bit string rather than of a number, so the
55
+ argument is cast there, to `bit(64)` because that is what makes a negative
56
+ count as it does on MySQL:
57
+
58
+ ```ruby
59
+ Post.group { :author_id }.select { bit_or(:flags).as(:flags) }
60
+ # SELECT BIT_OR("posts"."flags") AS "flags" ... GROUP BY "posts"."author_id"
61
+
62
+ Post.select { bit_count(:flags).as(:bits) }
63
+ # MySQL: BIT_COUNT("posts"."flags")
64
+ # PostgreSQL: BIT_COUNT(CAST("posts"."flags" AS bit(64)))
65
+ ```
66
+
67
+ `bit_xor` arrived in PostgreSQL 14. `~` is where the three disagree about the
68
+ answer rather than the question: MySQL reads it back as the unsigned 64-bit
69
+ number, the others as a negative one, and the bits are the same either way.
70
+
71
+ One place asks for a value to be said out loud: the top of a select list.
72
+ Everywhere else a bare literal is already a value — `where { :age > 18 }`,
73
+ `concat(:name, '-x')` — but a bare string at the top of the list would be SQL
74
+ to Active Record and a value everywhere else in the block, so it is refused
75
+ rather than read either way: `sql` says the SQL, `value` the value. `value`
76
+ carries the predications and arithmetic with it, so a literal can be compared
77
+ and combined like anything else, and numbers and strings have a shorthand,
78
+ since a literal that has been sent `as` has already said it is a value:
79
+
80
+ ```ruby
81
+ Node.select { [:id, value(0).as(:depth)] }
82
+ # SELECT "nodes"."id", 0 AS depth FROM "nodes"
83
+
84
+ Node.select { [:id, 0.as(:depth)] } # the same thing
85
+
86
+ Post.select { [:title, "draft".as(:state)] }
87
+ # SELECT "posts"."title", 'draft' AS state FROM "posts"
88
+ ```
89
+
90
+ What the shorthand does not cover, `value` still spells: `value(true)`,
91
+ `value(nil)`, or a literal that goes on to be compared rather than selected.
92
+
93
+ `CASE` is grammar rather than a function, and has two shapes. With an operand, each `when` is
94
+ something to compare it against; without one, each `when` carries a condition
95
+ of its own. `case` is a Ruby keyword, so the method behind both is only
96
+ reachable through the receiver — `self.case` — and each shape has a shorthand
97
+ that does not need it:
98
+
99
+ ```ruby
100
+ Author.select { :country.when("JP").then("Japan").else("elsewhere").as(:where) }
101
+ # CASE "country" WHEN 'JP' THEN 'Japan' ELSE 'elsewhere' END AS where
102
+
103
+ Author.select { case_when { :age >= 60 }.then("senior").else("adult").as(:band) }
104
+ # CASE WHEN "age" >= 60 THEN 'senior' ELSE 'adult' END AS band
105
+
106
+ Author.select { self.case(mod(:age, 10)).when(0).then("round").else("not").as(:v) }
107
+ ```
108
+
109
+ A `when` takes a value or a block, and so do `then` and `else`; the block is
110
+ there to read like the blocks around it, since an argument works just as well
111
+ — `:age >= 60` has already become an expression by the time it is passed.
112
+ Leaving the `else` off is SQL's own default, which is NULL. `when` and `then`
113
+ come in pairs, and one without the other is an `ArgumentError` rather than
114
+ something that reaches the database:
115
+
116
+ ```ruby
117
+ Author.select {
118
+ case_when { :age < 18 }.then("minor").
119
+ when { :age >= 60 }.then("senior").
120
+ else("adult").as(:band)
121
+ }
122
+
123
+ Author.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:seniors) }
124
+ # SUM(CASE WHEN "age" >= 60 THEN 1 ELSE 0 END) AS seniors
125
+ ```
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.