activerecord-refined 0.10.2 → 0.12.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -1
  3. data/README.md +19 -22
  4. data/activerecord-refined.gemspec +4 -3
  5. data/docs/conditions.md +3 -1
  6. data/docs/ctes.md +2 -2
  7. data/docs/expressions.md +37 -20
  8. data/docs/functions.md +14 -10
  9. data/docs/index.md +2 -17
  10. data/docs/windows.md +2 -2
  11. data/examples/expressions.rb +21 -15
  12. data/lib/active_record/refined/ast/arithmetics.rb +137 -0
  13. data/lib/active_record/refined/ast/conditions.rb +451 -0
  14. data/lib/active_record/refined/ast/expressions.rb +304 -0
  15. data/lib/active_record/refined/ast/functions.rb +254 -0
  16. data/lib/active_record/refined/ast/grouping.rb +63 -0
  17. data/lib/active_record/refined/ast/json.rb +530 -0
  18. data/lib/active_record/refined/ast/node.rb +298 -0
  19. data/lib/active_record/refined/ast/ordering.rb +104 -0
  20. data/lib/active_record/refined/ast/predications.rb +384 -0
  21. data/lib/active_record/refined/ast/windows.rb +126 -0
  22. data/lib/active_record/refined/ast.rb +39 -2364
  23. data/lib/active_record/refined/block_context.rb +671 -0
  24. data/lib/active_record/refined/block_syntax.rb +128 -0
  25. data/lib/active_record/refined/dialect/mysql_compat.rb +16 -0
  26. data/lib/active_record/refined/dialect/mysqlish_json_functions.rb +45 -0
  27. data/lib/active_record/refined/dialect/oracle.rb +1 -7
  28. data/lib/active_record/refined/dialect/postgresql.rb +8 -0
  29. data/lib/active_record/refined/dialect/sql_server.rb +29 -5
  30. data/lib/active_record/refined/dialect/sqlite.rb +23 -0
  31. data/lib/active_record/refined/dialect.rb +132 -66
  32. data/lib/active_record/refined/query_methods.rb +444 -0
  33. data/lib/{activerecord-refined → active_record/refined}/version.rb +3 -2
  34. data/lib/active_record/refined/writes.rb +72 -0
  35. data/lib/active_record/refined.rb +7 -1270
  36. data/lib/activerecord-refined.rb +0 -4
  37. metadata +36 -5
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # BigDecimal is refined below and is a bundled gem, so nothing loads it
4
+ # before this file does.
5
+ require "bigdecimal"
6
+ require "active_record/refined/ast"
7
+
8
+ module ActiveRecord
9
+ module Refined
10
+ # What a symbol answers to inside a block. A symbol names a column
11
+ # there -- `:age` is `"users"."age"` -- and the methods below build a
12
+ # condition, an expression or an ordering from it. Every one of them is
13
+ # a refinement, so it exists inside a `where`, `select`, `having`,
14
+ # `order`, `group`, `joins`, `update_all` or `upsert_all` block and
15
+ # nowhere else.
16
+ #
17
+ # The comparisons and the rest of the conditions are listed under
18
+ # {AST::Predications}, the arithmetic under {AST::Arithmetics}; a number
19
+ # or a string in a block takes `as` too, for a literal in a select list
20
+ # -- `0.as(:depth)`. A number may stand on the left of an operator when
21
+ # an expression stands on the right; before a bare column it is written
22
+ # `value(20) - :quantity`.
23
+ #
24
+ # @example A column compared, aliased and ordered
25
+ # Author.where { :age >= 18 }
26
+ # Author.select { :name.as(:author) }
27
+ # Author.order { :age.desc.nulls_last }
28
+ # @example A column of another table, and a collation
29
+ # Author.joins(:posts) { :posts[:author_id] == :authors[:id] }
30
+ # Author.where { :name.collate(:nocase) == "alice" }
31
+ module BlockSyntax
32
+ # @!parse include AST::Predications
33
+ # @!parse include AST::Arithmetics
34
+
35
+ # @!method as(alias_name, quote: true)
36
+ # The column under an alias: `AS "name"`. A number or a string takes
37
+ # it too, for a literal in a select list; {BlockContext#value} carries
38
+ # the literals that have no `as` of their own. The alias is quoted,
39
+ # so the name asked for is the name that comes back on every adapter;
40
+ # `quote: false` writes it bare, for a schema that wants the folding,
41
+ # and then it has to be a plain name.
42
+ # @param alias_name [Symbol, String]
43
+ # @param quote [Boolean]
44
+ # @return [AST::As]
45
+ # @example
46
+ # Author.select { :name.as(:author) } # "authors"."name" AS "author"
47
+ # Node.select { [:id, 0.as(:depth)] } # 0 AS "depth"
48
+ # Post.select { [:title, "draft".as(:state)] } # 'draft' AS "state"
49
+
50
+ # @!method asc
51
+ # An ascending ordering, which takes `nulls_first` and `nulls_last`.
52
+ # @return [AST::Ordering]
53
+ # @example
54
+ # Author.order { :country.asc.nulls_last }
55
+
56
+ # @!method desc
57
+ # A descending ordering, which takes `nulls_first` and `nulls_last`.
58
+ # @return [AST::Ordering]
59
+ # @example
60
+ # Post.order { :likes.desc }
61
+
62
+ # @!method collate(name)
63
+ # The column under a collation, for a comparison or an ordering:
64
+ # `"name" COLLATE nocase`. The name is the database's own and not
65
+ # portable; PostgreSQL quotes it, the others take it bare and refuse
66
+ # one that is not a plain identifier.
67
+ # @param name [Symbol, String] the collation's name
68
+ # @return [AST::Collate]
69
+ # @example
70
+ # Author.where { :name.collate(:nocase) == "alice" }
71
+ # Author.order { :name.collate(:"en-US-x-icu").asc } # PostgreSQL
72
+
73
+ # @!method [](column_name)
74
+ # A column of another table: `:posts[:author_id]` is
75
+ # `"posts"."author_id"`, for a join condition or a query over a join.
76
+ # @param column_name [Symbol]
77
+ # @return [AST::Column]
78
+ # @example
79
+ # Author.joins(:posts) { :posts[:author_id] == :authors[:id] }
80
+
81
+ refine Symbol do
82
+ import_methods AST::Predications
83
+ import_methods AST::Arithmetics
84
+
85
+ def as(alias_name, quote: true)
86
+ AST::As.new(self, alias_name, quote: quote)
87
+ end
88
+
89
+ def asc
90
+ AST::Ordering.new(self, :asc)
91
+ end
92
+
93
+ def desc
94
+ AST::Ordering.new(self, :desc)
95
+ end
96
+
97
+ def collate(name)
98
+ AST::Collate.new(self, name)
99
+ end
100
+
101
+ def [](column_name)
102
+ AST::Column.new(self, column_name)
103
+ end
104
+ end
105
+
106
+ # Shorthand for `value(0).as(:depth)` and the like, and the named
107
+ # bitwise operations with the number on the left. BigDecimal is a
108
+ # number here because that is what a decimal column's values are.
109
+ [Integer, Float, BigDecimal].each do |klass|
110
+ refine klass do
111
+ import_methods AST::NumericArithmetics
112
+
113
+ def as(alias_name, quote: true)
114
+ AST::As.new(AST::Value.new(self), alias_name, quote: quote)
115
+ end
116
+ end
117
+ end
118
+
119
+ # A string is a value here as it is in every other position of a block;
120
+ # SQL is asked for by name, with sql().
121
+ refine String do
122
+ def as(alias_name, quote: true)
123
+ AST::As.new(AST::Value.new(self), alias_name, quote: quote)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -6,10 +6,14 @@ module ActiveRecord
6
6
  # What MySQL and MariaDB share, which is most of it; the two part company
7
7
  # only in the Mysql and Mariadb subclasses.
8
8
  class MysqlCompat < Dialect
9
+ include MysqlishJsonFunctions
10
+
11
+ # @private
9
12
  FUNCTIONS = { trunc: "TRUNCATE", date_trunc: nil, format: nil }.freeze
10
13
 
11
14
  def full_outer_join_supported? = false
12
15
  def filter_supported? = false
16
+ def bitwise_operators_supported? = true
13
17
 
14
18
  def bit_count(expr, _model)
15
19
  AST::Function.new("BIT_COUNT", [expr])
@@ -41,6 +45,18 @@ module ActiveRecord
41
45
  "JSON_CONTAINS_PATH", [document, Arel::Nodes.build_quoted("one"), path])
42
46
  end
43
47
 
48
+ def json_keys(document, _model)
49
+ Arel::Nodes::NamedFunction.new("JSON_KEYS", [document])
50
+ end
51
+
52
+ # A Ruby document or boolean written where the JSON functions want
53
+ # JSON is marked as it by reading it out whole: JSON_EXTRACT($).
54
+ def json_argument(value, _model)
55
+ json = Arel::Nodes.build_quoted(JSON.generate(value))
56
+ Arel::Nodes::NamedFunction.new(
57
+ "JSON_EXTRACT", [json, Arel::Nodes.build_quoted("$")])
58
+ end
59
+
44
60
  def json_aggregate_filter_supported? = false
45
61
 
46
62
  # GROUP_CONCAT, its separator a keyword after the operand and after
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # The JSON editing and building functions MySQL and SQLite spell
7
+ # alike: JSON_SET and JSON_REMOVE over a $ path, and JSON_ARRAY and
8
+ # JSON_OBJECT with each key alternating with its value. The spellings
9
+ # are MySQL's, which SQLite's json1 took over, and standard in
10
+ # neither sense of the word -- SQL:2016 gives JSON_OBJECT a KEY k
11
+ # VALUE v syntax and has no editing functions at all -- so they live
12
+ # here as a family likeness for the two to include, not in the base
13
+ # as a default for everyone.
14
+ module MysqlishJsonFunctions
15
+ def json_set(document, _steps, dollar_path, value, expression, model)
16
+ Arel::Nodes::NamedFunction.new(
17
+ "JSON_SET",
18
+ [document, Arel::Nodes.build_quoted(dollar_path),
19
+ json_set_value(value, expression, model)])
20
+ end
21
+
22
+ def json_remove(document, dollar_paths, _steps, _model)
23
+ Arel::Nodes::NamedFunction.new(
24
+ "JSON_REMOVE",
25
+ [document, *dollar_paths.map { |path| Arel::Nodes.build_quoted(path) }])
26
+ end
27
+
28
+ def json_build(kind, keys, args, _model)
29
+ Arel::Nodes::NamedFunction.new(
30
+ kind == :array ? "JSON_ARRAY" : "JSON_OBJECT",
31
+ json_build_body(kind, keys, args))
32
+ end
33
+
34
+ private
35
+ # The value beside a path in JSON_SET: an expression as it is, a
36
+ # document or boolean as JSON, a bare scalar quoted.
37
+ def json_set_value(value, expression, model)
38
+ return expression if expression
39
+ return Arel::Nodes.build_quoted(value) unless json_document_value?(value)
40
+ json_argument(value, model)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -6,6 +6,7 @@ module ActiveRecord
6
6
  # Oracle, reached through oracle_enhanced. Loaded only when a query is
7
7
  # built for it.
8
8
  class Oracle < Dialect
9
+ # @private
9
10
  FUNCTIONS = {
10
11
  char_length: "LENGTH",
11
12
  degrees: nil, radians: nil, pi: nil, log2: nil, log10: nil,
@@ -44,13 +45,6 @@ module ActiveRecord
44
45
  Arel::Nodes::NamedFunction.new("JSON_EXISTS", [document, path])
45
46
  end
46
47
 
47
- # No JSON_KEYS, and the keys reach only through a JSON_TABLE unnest not
48
- # written yet.
49
- def json_keys(_document, model)
50
- raise NotImplementedError,
51
- "keys has no equivalent on #{model.connection_db_config.adapter}"
52
- end
53
-
54
48
  def json_argument(value, model)
55
49
  Arel.sql("#{quote(JSON.generate(value), model)} FORMAT JSON")
56
50
  end
@@ -5,6 +5,10 @@ module ActiveRecord
5
5
  class Dialect
6
6
  # PostgreSQL, and the adapters that answer for the same server.
7
7
  class Postgresql < Dialect
8
+ def array_comparisons_supported? = true
9
+ def bitwise_operators_supported? = true
10
+
11
+ # @private
8
12
  FUNCTIONS = { log2: nil, rand: "RANDOM" }.freeze
9
13
 
10
14
  # PostgreSQL counts the bits of a bit string rather than a number, so
@@ -19,6 +23,10 @@ module ActiveRecord
19
23
  Arel::Nodes::InfixOperation.new("#", left, right)
20
24
  end
21
25
 
26
+ def excluded(column, _model)
27
+ AST::Column.new(:excluded, column)
28
+ end
29
+
22
30
  def json_path(document, _dollar_path, steps, json_value, _model)
23
31
  Arel::Nodes::InfixOperation.new(
24
32
  json_value ? :"#>" : :"#>>", document, Arel::Nodes.build_quoted(steps))
@@ -6,12 +6,21 @@ module ActiveRecord
6
6
  # Microsoft SQL Server, reached through the sqlserver adapter over
7
7
  # tiny_tds. Loaded only when a query is built for it.
8
8
  class SqlServer < Dialect
9
- # LEN is its length; it has no printf FORMAT, no per-row random it
10
- # would spell RAND, no date_trunc, and none of the bit aggregates.
11
- # Of the datetime value functions it has CURRENT_TIMESTAMP alone:
12
- # the other four are reserved words there that stand for nothing.
9
+ # LEN is its length, CEILING its ceil, ATN2 its atan2, and LOG --
10
+ # natural by default -- its ln; SUBSTRING insists on the length, so
11
+ # substr wants all three arguments here. It has no printf FORMAT, no
12
+ # per-row random it would spell RAND, no date_trunc, no NOW or LOG2,
13
+ # none of the bit aggregates, no MOD beside the % operator, and no
14
+ # numeric TRUNC. log is nil for a sharper reason: its LOG(x, base)
15
+ # takes the arguments in the other order, so a rename would quietly
16
+ # swap them. Of the datetime value functions it has
17
+ # CURRENT_TIMESTAMP alone: the other four are reserved words there
18
+ # that stand for nothing.
19
+ # @private
13
20
  FUNCTIONS = {
14
- char_length: "LEN",
21
+ char_length: "LEN", length: "LEN", substr: "SUBSTRING",
22
+ ceil: "CEILING", atan2: "ATN2", ln: "LOG",
23
+ mod: nil, trunc: nil, log: nil, log2: nil, now: nil,
15
24
  format: nil, rand: nil, date_trunc: nil,
16
25
  bit_and: nil, bit_or: nil, bit_xor: nil,
17
26
  current_date: nil, current_time: nil, localtime: nil, localtimestamp: nil,
@@ -20,6 +29,15 @@ module ActiveRecord
20
29
  # No FILTER clause, so the aggregate node builds the CASE instead.
21
30
  def filter_supported? = false
22
31
 
32
+ # The operators are all here, the shifts included -- CI executes them.
33
+ def bitwise_operators_supported? = true
34
+
35
+ # ^ is XOR here as on MySQL, sparing the (a | b) - (a & b) the base
36
+ # spells for SQLite's sake.
37
+ def bitwise_xor(left, right)
38
+ Arel::Nodes::BitwiseXor.new(left, right)
39
+ end
40
+
23
41
  # No EXTRACT (it has DATEPART), and its datetime value functions take
24
42
  # no precision.
25
43
  def extract_supported? = false
@@ -60,6 +78,12 @@ module ActiveRecord
60
78
  "string_agg over a window has no equivalent on #{model.connection_db_config.adapter}"
61
79
  end
62
80
 
81
+ # No JSON aggregates, so the SQL:2016 names the base keeps would
82
+ # reach the server as functions it does not have.
83
+ def json_aggregate_name(kind)
84
+ raise NotImplementedError, "json_#{kind} has no equivalent on SQL Server"
85
+ end
86
+
63
87
  # dig_text reads a scalar out with JSON_VALUE; dig keeps JSON with
64
88
  # JSON_QUERY, which returns a fragment and NULL for a scalar leaf.
65
89
  def json_path(document, dollar_path, _steps, json_value, _model)
@@ -6,6 +6,9 @@ module ActiveRecord
6
6
  # SQLite: the fewest of the extras, and a JSON path through its own
7
7
  # operators.
8
8
  class Sqlite < Dialect
9
+ include MysqlishJsonFunctions
10
+
11
+ # @private
9
12
  FUNCTIONS = {
10
13
  char_length: "LENGTH", greatest: "MAX", least: "MIN",
11
14
  now: nil, date_trunc: nil, rand: "RANDOM",
@@ -16,6 +19,7 @@ module ActiveRecord
16
19
  def datetime_precision_supported? = false
17
20
  def extract_supported? = false
18
21
  def quantifiers_supported? = false
22
+ def bitwise_operators_supported? = true
19
23
 
20
24
  def check_lateral(model)
21
25
  raise NotImplementedError,
@@ -32,6 +36,21 @@ module ActiveRecord
32
36
  date_only ? "date" : "datetime", [date, Arel::Nodes.build_quoted(modifier)])
33
37
  end
34
38
 
39
+ # -> keeps the value's type where ->> gives text, so dig_text casts
40
+ # to text only to make the comparison portable.
41
+ def json_path(document, dollar_path, _steps, json_value, _model)
42
+ extracted = Arel::Nodes::InfixOperation.new(
43
+ json_value ? :"->" : :"->>", document, Arel::Nodes.build_quoted(dollar_path))
44
+ return extracted if json_value
45
+ Arel::Nodes::NamedFunction.new(
46
+ "CAST", [Arel::Nodes::As.new(extracted, Arel::Nodes::SqlLiteral.new("text"))])
47
+ end
48
+
49
+ # json_type at the path answers NULL where nothing stands there.
50
+ def json_has_key(document, _name, path, _model)
51
+ Arel::Nodes::NamedFunction.new("json_type", [document, path]).not_eq(nil)
52
+ end
53
+
35
54
  def json_keys(document, model)
36
55
  sql = compile(document, model)
37
56
  Arel.sql("CASE WHEN json_type(#{sql}) = 'object' " \
@@ -47,6 +66,10 @@ module ActiveRecord
47
66
  kind == :arrayagg ? "json_group_array" : "json_group_object"
48
67
  end
49
68
 
69
+ def excluded(column, _model)
70
+ AST::Column.new(:excluded, column)
71
+ end
72
+
50
73
  # group_concat, with the ORDER BY inside the call from 3.44 on.
51
74
  def string_agg(operand, separator, orders, _string, model)
52
75
  string_agg_call("group_concat", operand, separator, orders, model)