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.
- checksums.yaml +4 -4
- data/.yardopts +1 -1
- data/README.md +19 -22
- data/activerecord-refined.gemspec +4 -3
- data/docs/conditions.md +3 -1
- data/docs/ctes.md +2 -2
- data/docs/expressions.md +37 -20
- data/docs/functions.md +14 -10
- data/docs/index.md +2 -17
- data/docs/windows.md +2 -2
- data/examples/expressions.rb +21 -15
- data/lib/active_record/refined/ast/arithmetics.rb +137 -0
- data/lib/active_record/refined/ast/conditions.rb +451 -0
- data/lib/active_record/refined/ast/expressions.rb +304 -0
- data/lib/active_record/refined/ast/functions.rb +254 -0
- data/lib/active_record/refined/ast/grouping.rb +63 -0
- data/lib/active_record/refined/ast/json.rb +530 -0
- data/lib/active_record/refined/ast/node.rb +298 -0
- data/lib/active_record/refined/ast/ordering.rb +104 -0
- data/lib/active_record/refined/ast/predications.rb +384 -0
- data/lib/active_record/refined/ast/windows.rb +126 -0
- data/lib/active_record/refined/ast.rb +39 -2364
- data/lib/active_record/refined/block_context.rb +671 -0
- data/lib/active_record/refined/block_syntax.rb +128 -0
- data/lib/active_record/refined/dialect/mysql_compat.rb +16 -0
- data/lib/active_record/refined/dialect/mysqlish_json_functions.rb +45 -0
- data/lib/active_record/refined/dialect/oracle.rb +1 -7
- data/lib/active_record/refined/dialect/postgresql.rb +8 -0
- data/lib/active_record/refined/dialect/sql_server.rb +29 -5
- data/lib/active_record/refined/dialect/sqlite.rb +23 -0
- data/lib/active_record/refined/dialect.rb +132 -66
- data/lib/active_record/refined/query_methods.rb +444 -0
- data/lib/{activerecord-refined → active_record/refined}/version.rb +3 -2
- data/lib/active_record/refined/writes.rb +72 -0
- data/lib/active_record/refined.rb +7 -1270
- data/lib/activerecord-refined.rb +0 -4
- 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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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)
|