activerecord-refined 0.9.0 → 0.10.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +17 -0
  3. data/README.md +61 -1064
  4. data/activerecord-refined.gemspec +8 -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/index.md +70 -0
  11. data/docs/joins.md +73 -0
  12. data/docs/json.md +230 -0
  13. data/docs/ordering.md +55 -0
  14. data/docs/time_zones.md +30 -0
  15. data/docs/windows.md +41 -0
  16. data/docs/writing.md +33 -0
  17. data/examples/aggregations.rb +18 -0
  18. data/examples/expressions.rb +35 -5
  19. data/lib/active_record/refined/ast.rb +461 -246
  20. data/lib/active_record/refined/dialect/mariadb.rb +25 -0
  21. data/lib/active_record/refined/dialect/mysql.rb +18 -0
  22. data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
  23. data/lib/active_record/refined/dialect/oracle.rb +110 -0
  24. data/lib/active_record/refined/dialect/postgresql.rb +120 -0
  25. data/lib/active_record/refined/dialect/sql_server.rb +115 -0
  26. data/lib/active_record/refined/dialect/sqlite.rb +57 -0
  27. data/lib/active_record/refined/dialect.rb +340 -0
  28. data/lib/active_record/refined.rb +682 -192
  29. data/lib/activerecord-refined/version.rb +1 -1
  30. data/lib/activerecord-refined.rb +1 -0
  31. metadata +43 -16
  32. data/.github/workflows/push_gem.yml +0 -45
  33. data/.github/workflows/sandbox.yml +0 -295
  34. data/.github/workflows/test.yml +0 -104
  35. data/.gitignore +0 -19
  36. data/.rubocop.yml +0 -393
  37. data/Gemfile +0 -14
  38. data/Rakefile +0 -53
  39. data/benchmark/query_building.rb +0 -129
  40. data/test/test_block_syntax.rb +0 -2999
  41. data/test/test_helper.rb +0 -238
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # MariaDB, which answers to the MySQL adapter but has no JSON type and no
7
+ # LATERAL.
8
+ class Mariadb < MysqlCompat
9
+ def check_lateral(_model)
10
+ raise NotImplementedError, "a lateral join has no equivalent on MariaDB"
11
+ end
12
+
13
+ # MariaDB has no JSON type, so a JSON comparison is refused.
14
+ def json_literal(_json, _model)
15
+ raise NotImplementedError,
16
+ "a JSON comparison has no equivalent on MariaDB; dig_text gives the value"
17
+ end
18
+
19
+ def check_json_aggregate_window(source, _model)
20
+ raise NotImplementedError, "#{source} over a window has no equivalent on MariaDB"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # MySQL proper, which has a JSON type of its own where MariaDB has only
7
+ # the text.
8
+ class Mysql < MysqlCompat
9
+ # MySQL has a JSON type, so a Ruby value is cast to it; a bare string
10
+ # beside JSON would be a JSON string, outranking every number.
11
+ def json_literal(json, _model)
12
+ Arel::Nodes::NamedFunction.new(
13
+ "CAST", [Arel::Nodes::As.new(json, Arel::Nodes::SqlLiteral.new("JSON"))])
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # What MySQL and MariaDB share, which is most of it; the two part company
7
+ # only in the Mysql and Mariadb subclasses.
8
+ class MysqlCompat < Dialect
9
+ FUNCTIONS = { trunc: "TRUNCATE", date_trunc: nil, format: nil }.freeze
10
+
11
+ def full_outer_join_supported? = false
12
+ def filter_supported? = false
13
+
14
+ def bit_count(expr, _model)
15
+ AST::Function.new("BIT_COUNT", [expr])
16
+ end
17
+
18
+ # The excluded row is VALUES(column), which takes the column bare.
19
+ def excluded(column, model)
20
+ quoted = model.with_connection { |connection| connection.quote_column_name(column) }
21
+ AST::Function.new("VALUES", [Arel::Nodes::SqlLiteral.new(quoted)])
22
+ end
23
+
24
+ def bitwise_xor(left, right)
25
+ Arel::Nodes::BitwiseXor.new(left, right)
26
+ end
27
+
28
+ def json_path(document, dollar_path, _steps, json_value, _model)
29
+ extracted = Arel::Nodes::NamedFunction.new(
30
+ "JSON_EXTRACT", [document, Arel::Nodes.build_quoted(dollar_path)])
31
+ return extracted if json_value
32
+ Arel::Nodes::NamedFunction.new("JSON_UNQUOTE", [extracted])
33
+ end
34
+
35
+ def json_contains(document, json, _model)
36
+ Arel::Nodes::NamedFunction.new("JSON_CONTAINS", [document, json])
37
+ end
38
+
39
+ def json_has_key(document, _name, path, _model)
40
+ Arel::Nodes::NamedFunction.new(
41
+ "JSON_CONTAINS_PATH", [document, Arel::Nodes.build_quoted("one"), path])
42
+ end
43
+
44
+ def json_aggregate_filter_supported? = false
45
+
46
+ # GROUP_CONCAT, its separator a keyword after the operand and after
47
+ # the ORDER BY when there is one.
48
+ def string_agg(operand, separator, orders, _string, model)
49
+ order = orders.empty? ? "" : " ORDER BY #{compile_list(orders, model)}"
50
+ Arel.sql("GROUP_CONCAT(#{compile(operand, model)}#{order} " \
51
+ "SEPARATOR #{quote(separator, model)})")
52
+ end
53
+
54
+ def check_string_aggregate_window(model)
55
+ raise NotImplementedError,
56
+ "string_agg over a window has no equivalent on #{model.connection_db_config.adapter}"
57
+ end
58
+
59
+ def json_list_by_element? = true
60
+
61
+ def grouping_supported?(kind) = kind == :rollup
62
+
63
+ def grouping_by_with_rollup? = true
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # Oracle, reached through oracle_enhanced. Loaded only when a query is
7
+ # built for it.
8
+ class Oracle < Dialect
9
+ FUNCTIONS = {
10
+ char_length: "LENGTH",
11
+ degrees: nil, radians: nil, pi: nil, log2: nil, log10: nil,
12
+ now: nil, date_trunc: nil, rand: nil, format: nil,
13
+ bit_and: nil, bit_or: nil, bit_xor: nil,
14
+ }.freeze
15
+
16
+ # An INTERVAL literal would do, but its leading field is two digits
17
+ # wide unless told otherwise, so INTERVAL '100' DAY is an error. The
18
+ # functions that make an interval of a number take any size, one for
19
+ # the year-month intervals and one for the day-second.
20
+ def add_interval(date, amount, unit, subtract, _date_only)
21
+ interval = Arel::Nodes::NamedFunction.new(
22
+ unit == :year || unit == :month ? "NUMTOYMINTERVAL" : "NUMTODSINTERVAL",
23
+ [Arel::Nodes.build_quoted(amount), Arel::Nodes.build_quoted(unit.to_s.upcase)])
24
+ Arel::Nodes::Grouping.new(
25
+ Arel::Nodes::InfixOperation.new(subtract ? :- : :+, date, interval))
26
+ end
27
+
28
+ # Oracle keeps scalars and structures in different functions: JSON_VALUE
29
+ # reads a scalar out as text, JSON_QUERY a fragment as JSON. dig keeps
30
+ # JSON, so it takes JSON_QUERY with 23ai's ALLOW SCALARS, which returns
31
+ # a scalar leaf as itself rather than wrapping or refusing it.
32
+ def json_path(document, dollar_path, _steps, json_value, model)
33
+ unless json_value
34
+ return Arel::Nodes::NamedFunction.new(
35
+ "JSON_VALUE", [document, Arel::Nodes.build_quoted(dollar_path)])
36
+ end
37
+ Arel.sql(
38
+ "JSON_QUERY(#{compile(document, model)}, " \
39
+ "#{quote(dollar_path, model)} RETURNING VARCHAR2(4000) ALLOW SCALARS " \
40
+ "NULL ON EMPTY)")
41
+ end
42
+
43
+ def json_has_key(document, _name, path, _model)
44
+ Arel::Nodes::NamedFunction.new("JSON_EXISTS", [document, path])
45
+ end
46
+
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
+ def json_argument(value, model)
55
+ Arel.sql("#{quote(JSON.generate(value), model)} FORMAT JSON")
56
+ end
57
+
58
+ # JSON_TRANSFORM is Oracle's one editing function; its SET and the value
59
+ # beside it are grammar, so the call is written out, RETURNING text that
60
+ # ruby-oci8 can fetch.
61
+ def json_set(document, _steps, dollar_path, value, expression, model)
62
+ model.with_connection do |connection|
63
+ set =
64
+ if expression
65
+ connection.visitor.compile(expression)
66
+ elsif json_document_value?(value)
67
+ "#{connection.quote(JSON.generate(value))} FORMAT JSON"
68
+ else
69
+ connection.quote(value)
70
+ end
71
+ Arel.sql(
72
+ "JSON_TRANSFORM(#{connection.visitor.compile(document)}, " \
73
+ "SET #{connection.quote(dollar_path)} = #{set} RETURNING VARCHAR2(4000))")
74
+ end
75
+ end
76
+
77
+ def json_remove(document, dollar_paths, _steps, model)
78
+ model.with_connection do |connection|
79
+ removes = dollar_paths.map { |path| "REMOVE #{connection.quote(path)}" }
80
+ Arel.sql(
81
+ "JSON_TRANSFORM(#{connection.visitor.compile(document)}, " \
82
+ "#{removes.join(', ')} RETURNING VARCHAR2(4000))")
83
+ end
84
+ end
85
+
86
+ # Oracle pairs a key with its value by the VALUE keyword rather than by
87
+ # position, keeps a NULL only when told NULL ON NULL, and returns the
88
+ # native JSON type unless a RETURNING asks for text ruby-oci8 can fetch.
89
+ def json_build(kind, keys, args, model)
90
+ model.with_connection do |connection|
91
+ compiled = args.map { |arg| connection.visitor.compile(arg) }
92
+ body =
93
+ if kind == :array
94
+ compiled.join(", ")
95
+ else
96
+ keys.zip(compiled).map { |key, arg| "#{connection.quote(key)} VALUE #{arg}" }.join(", ")
97
+ end
98
+ Arel.sql("JSON_#{kind.to_s.upcase}(#{body} NULL ON NULL RETURNING VARCHAR2(4000))")
99
+ end
100
+ end
101
+
102
+ def check_json_aggregate_window(source, model)
103
+ raise NotImplementedError,
104
+ "#{source} over a window has no equivalent on " \
105
+ "#{model.connection_db_config.adapter}"
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # PostgreSQL, and the adapters that answer for the same server.
7
+ class Postgresql < Dialect
8
+ FUNCTIONS = { log2: nil, rand: "RANDOM" }.freeze
9
+
10
+ # PostgreSQL counts the bits of a bit string rather than a number, so
11
+ # the argument is cast, to bit(64) for a negative to come back as the
12
+ # MySQL family has it.
13
+ def bit_count(expr, _model)
14
+ AST::Function.new("BIT_COUNT", [AST::Cast.new(expr, "bit(64)")])
15
+ end
16
+
17
+ # PostgreSQL's XOR is #, where ^ is exponentiation.
18
+ def bitwise_xor(left, right)
19
+ Arel::Nodes::InfixOperation.new("#", left, right)
20
+ end
21
+
22
+ def json_path(document, _dollar_path, steps, json_value, _model)
23
+ Arel::Nodes::InfixOperation.new(
24
+ json_value ? :"#>" : :"#>>", document, Arel::Nodes.build_quoted(steps))
25
+ end
26
+
27
+ # An untyped JSON literal beside a jsonb operand coerces to jsonb, so
28
+ # it passes as it is.
29
+ def json_literal(json, _model)
30
+ json
31
+ end
32
+
33
+ def json_contains(document, json, _model)
34
+ Arel::Nodes::Contains.new(document, json)
35
+ end
36
+
37
+ # The ? operator, which a GIN index matches where jsonb_exists never is.
38
+ def json_has_key(document, name, _path, _model)
39
+ Arel::Nodes::InfixOperation.new(:"?", document, name)
40
+ end
41
+
42
+ def json_keys(document, model)
43
+ sql = compile(document, model)
44
+ Arel.sql("CASE WHEN jsonb_typeof(#{sql}) = 'object' " \
45
+ "THEN COALESCE((SELECT jsonb_agg(k) FROM jsonb_object_keys(#{sql}) k), " \
46
+ "CAST('[]' AS jsonb)) END")
47
+ end
48
+
49
+ # jsonb_set takes jsonb: an expression is turned into it, and a Ruby
50
+ # value goes in as the JSON that says it -- '"x"' rather than 'x'.
51
+ def json_set(document, steps, _dollar_path, value, expression, _model)
52
+ set = expression ? Arel::Nodes::NamedFunction.new("to_jsonb", [expression]) :
53
+ Arel::Nodes.build_quoted(JSON.generate(value))
54
+ Arel::Nodes::NamedFunction.new(
55
+ "jsonb_set", [document, Arel::Nodes.build_quoted(steps), set])
56
+ end
57
+
58
+ # jsonb subtracts an array of keys; an untyped array literal would be
59
+ # read as a single key, so it is cast to text[].
60
+ def json_remove(document, _dollar_paths, steps, _model)
61
+ keys = Arel::Nodes::NamedFunction.new(
62
+ "CAST", [Arel::Nodes::As.new(
63
+ Arel::Nodes.build_quoted(steps), Arel::Nodes::SqlLiteral.new("text[]"))])
64
+ # Grouped because - binds tighter than #>.
65
+ Arel::Nodes::InfixOperation.new(:-, Arel::Nodes::Grouping.new(document), keys)
66
+ end
67
+
68
+ def json_build(kind, keys, args, _model)
69
+ Arel::Nodes::NamedFunction.new(
70
+ kind == :array ? "jsonb_build_array" : "jsonb_build_object",
71
+ json_build_body(kind, keys, args))
72
+ end
73
+
74
+ # jsonb_build_* takes typed arguments, so a JSON literal is cast; left
75
+ # untyped it would be text, and land as a string.
76
+ def json_build_argument(value, _model)
77
+ Arel::Nodes::NamedFunction.new(
78
+ "CAST", [Arel::Nodes::As.new(
79
+ Arel::Nodes.build_quoted(JSON.generate(value)),
80
+ Arel::Nodes::SqlLiteral.new("jsonb"))])
81
+ end
82
+
83
+ def json_aggregate_name(kind)
84
+ kind == :arrayagg ? "jsonb_agg" : "jsonb_object_agg"
85
+ end
86
+
87
+ # STRING_AGG takes text and nothing else -- over an integer column it
88
+ # is a function that does not exist -- so the operand is cast unless
89
+ # the model says it is a string already.
90
+ def string_agg(operand, separator, orders, string, model)
91
+ unless string
92
+ operand = Arel::Nodes::NamedFunction.new(
93
+ "CAST", [Arel::Nodes::As.new(operand, Arel::Nodes::SqlLiteral.new("text"))])
94
+ end
95
+ string_agg_call("STRING_AGG", operand, separator, orders, model)
96
+ end
97
+
98
+ def grouping_supported?(_kind) = true
99
+
100
+ # The names PostgreSQL knows, letters and digits and the _ . - its
101
+ # catalog spells them with. Wider than the bare families' plain
102
+ # identifier because the name is quoted, so a hyphen is safe -- and
103
+ # every ICU collation, en-US-x-icu among them, is spelled with one.
104
+ # @private
105
+ COLLATION_NAME = /\A[[:alnum:]_.-]+\z/
106
+
107
+ # PostgreSQL's own collation names are case-sensitive and upper, "C",
108
+ # "POSIX", so the name is quoted as an identifier to keep it from
109
+ # folding to lower case. The quoter is asked to spell it, as
110
+ # excluded's VALUES(column) is on MySQL.
111
+ def collate(operand, name, model)
112
+ AST.check_name(name, COLLATION_NAME, "collation name")
113
+ quoted = model.with_connection { |connection| connection.quote_column_name(name) }
114
+ Arel::Nodes::InfixOperation.new(
115
+ "COLLATE", operand, Arel::Nodes::SqlLiteral.new(quoted))
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # Microsoft SQL Server, reached through the sqlserver adapter over
7
+ # tiny_tds. Loaded only when a query is built for it.
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.
13
+ FUNCTIONS = {
14
+ char_length: "LEN",
15
+ format: nil, rand: nil, date_trunc: nil,
16
+ bit_and: nil, bit_or: nil, bit_xor: nil,
17
+ current_date: nil, current_time: nil, localtime: nil, localtimestamp: nil,
18
+ }.freeze
19
+
20
+ # No FILTER clause, so the aggregate node builds the CASE instead.
21
+ def filter_supported? = false
22
+
23
+ # No EXTRACT (it has DATEPART), and its datetime value functions take
24
+ # no precision.
25
+ def extract_supported? = false
26
+ def datetime_precision_supported? = false
27
+
28
+ # No boolean type: the column is a bit. ISNULL reads a NULL as the
29
+ # value the test is not looking for -- 0 for true?, 1 for false? --
30
+ # so the plain form drops it and, being never NULL itself, the
31
+ # negation is exactly NOT of the plain form, matching what ! builds.
32
+ def truth_value(operand, value, negated, _model)
33
+ equals = Arel::Nodes::Equality.new(
34
+ Arel::Nodes::NamedFunction.new("ISNULL", [operand, value ? 0 : 1]),
35
+ value ? 1 : 0)
36
+ negated ? Arel::Nodes::Not.new(equals) : equals
37
+ end
38
+
39
+ # DATEADD(day, 3, x), the unit a bare keyword; a subtraction is a
40
+ # negative amount, there being no DATESUB.
41
+ def add_interval(date, amount, unit, subtract, _date_only)
42
+ Arel::Nodes::NamedFunction.new(
43
+ "DATEADD",
44
+ [Arel::Nodes::SqlLiteral.new(unit.to_s),
45
+ Arel::Nodes.build_quoted(subtract ? -amount : amount), date])
46
+ end
47
+
48
+ # STRING_AGG, with the WITHIN GROUP only when there is an order to
49
+ # put in it.
50
+ def string_agg(operand, separator, orders, _string, model)
51
+ call = Arel::Nodes::NamedFunction.new(
52
+ "STRING_AGG", [operand, Arel::Nodes.build_quoted(separator)])
53
+ return call if orders.empty?
54
+ Arel.sql("#{compile(call, model)} WITHIN GROUP " \
55
+ "(ORDER BY #{compile_list(orders, model)})")
56
+ end
57
+
58
+ def check_string_aggregate_window(model)
59
+ raise NotImplementedError,
60
+ "string_agg over a window has no equivalent on #{model.connection_db_config.adapter}"
61
+ end
62
+
63
+ # dig_text reads a scalar out with JSON_VALUE; dig keeps JSON with
64
+ # JSON_QUERY, which returns a fragment and NULL for a scalar leaf.
65
+ def json_path(document, dollar_path, _steps, json_value, _model)
66
+ Arel::Nodes::NamedFunction.new(
67
+ json_value ? "JSON_QUERY" : "JSON_VALUE",
68
+ [document, Arel::Nodes.build_quoted(dollar_path)])
69
+ end
70
+
71
+ # JSON_PATH_EXISTS returns a bit, which the condition compares to 1.
72
+ def json_has_key(document, _name, path, _model)
73
+ Arel::Nodes::Equality.new(
74
+ Arel::Nodes::NamedFunction.new("JSON_PATH_EXISTS", [document, path]),
75
+ Arel::Nodes.build_quoted(1))
76
+ end
77
+
78
+ # A document, a boolean or a bare scalar written where JSON is wanted
79
+ # rides in through JSON_QUERY as the JSON it spells.
80
+ def json_argument(value, _model)
81
+ Arel::Nodes::NamedFunction.new(
82
+ "JSON_QUERY", [Arel::Nodes.build_quoted(JSON.generate(value))])
83
+ end
84
+
85
+ # bury: JSON_MODIFY sets a value at a path.
86
+ def json_set(document, _steps, dollar_path, value, expression, model)
87
+ Arel::Nodes::NamedFunction.new(
88
+ "JSON_MODIFY",
89
+ [document, Arel::Nodes.build_quoted(dollar_path),
90
+ json_modify_value(value, expression, model)])
91
+ end
92
+
93
+ # except: JSON_MODIFY deletes a key when it sets it to a literal NULL,
94
+ # one nested call per key.
95
+ def json_remove(document, dollar_paths, _steps, _model)
96
+ dollar_paths.reduce(document) do |doc, path|
97
+ Arel::Nodes::NamedFunction.new(
98
+ "JSON_MODIFY",
99
+ [doc, Arel::Nodes.build_quoted(path), Arel::Nodes::SqlLiteral.new("NULL")])
100
+ end
101
+ end
102
+
103
+ private
104
+ # A document or array goes in through JSON_QUERY, which wants an
105
+ # object or an array and refuses a scalar; a bare scalar is quoted.
106
+ # A boolean, a null or a dug scalar is bury's business the tests skip.
107
+ def json_modify_value(value, expression, model)
108
+ return expression if expression
109
+ return json_argument(value, model) if value.is_a?(::Hash) || value.is_a?(::Array)
110
+ Arel::Nodes.build_quoted(value)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ class Dialect
6
+ # SQLite: the fewest of the extras, and a JSON path through its own
7
+ # operators.
8
+ class Sqlite < Dialect
9
+ FUNCTIONS = {
10
+ char_length: "LENGTH", greatest: "MAX", least: "MIN",
11
+ now: nil, date_trunc: nil, rand: "RANDOM",
12
+ bit_and: nil, bit_or: nil, bit_xor: nil,
13
+ localtime: nil, localtimestamp: nil,
14
+ }.freeze
15
+
16
+ def datetime_precision_supported? = false
17
+ def extract_supported? = false
18
+ def quantifiers_supported? = false
19
+
20
+ def check_lateral(model)
21
+ raise NotImplementedError,
22
+ "a lateral join has no equivalent on #{model.connection_db_config.adapter}"
23
+ end
24
+
25
+ # A date is moved by a modifier to date() or datetime(), '+3 day';
26
+ # the unit's own name reads there, singular. datetime() answers with a
27
+ # time of day whatever it is given, so a date column, as the node
28
+ # tells one, goes through date() and stays a date.
29
+ def add_interval(date, amount, unit, subtract, date_only)
30
+ modifier = format("%+d %s", subtract ? -amount : amount, unit)
31
+ Arel::Nodes::NamedFunction.new(
32
+ date_only ? "date" : "datetime", [date, Arel::Nodes.build_quoted(modifier)])
33
+ end
34
+
35
+ def json_keys(document, model)
36
+ sql = compile(document, model)
37
+ Arel.sql("CASE WHEN json_type(#{sql}) = 'object' " \
38
+ "THEN (SELECT json_group_array(key) FROM json_each(#{sql})) END")
39
+ end
40
+
41
+ def json_argument(value, _model)
42
+ Arel::Nodes::NamedFunction.new(
43
+ "json", [Arel::Nodes.build_quoted(JSON.generate(value))])
44
+ end
45
+
46
+ def json_aggregate_name(kind)
47
+ kind == :arrayagg ? "json_group_array" : "json_group_object"
48
+ end
49
+
50
+ # group_concat, with the ORDER BY inside the call from 3.44 on.
51
+ def string_agg(operand, separator, orders, _string, model)
52
+ string_agg_call("group_concat", operand, separator, orders, model)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end