activerecord-refined 0.3.3 → 0.5.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/.github/workflows/sandbox.yml +158 -0
- data/.github/workflows/test.yml +56 -2
- data/LICENSE.txt +2 -1
- data/README.md +263 -17
- data/activerecord-refined.gemspec +3 -1
- data/benchmark/query_building.rb +129 -0
- data/examples/complex_joins.rb +14 -1
- data/examples/ctes.rb +82 -0
- data/examples/expressions.rb +87 -0
- data/examples/postgresql.rb +105 -0
- data/examples/predicates.rb +93 -0
- data/examples/subqueries.rb +67 -0
- data/lib/active_record/refined/ast.rb +320 -33
- data/lib/active_record/refined.rb +180 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +597 -20
- data/test/test_helper.rb +12 -0
- metadata +8 -1
|
@@ -3,10 +3,8 @@ module ActiveRecord
|
|
|
3
3
|
module BlockSyntax
|
|
4
4
|
refine Symbol do
|
|
5
5
|
import_methods AST::Predications
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
define_method(func) { AST::Aggregate.new(self, func) }
|
|
9
|
-
end
|
|
6
|
+
import_methods AST::Arithmetics
|
|
7
|
+
import_methods AST::Aggregations
|
|
10
8
|
|
|
11
9
|
def as(alias_name)
|
|
12
10
|
AST::As.new(self, alias_name)
|
|
@@ -27,23 +25,161 @@ module ActiveRecord
|
|
|
27
25
|
end
|
|
28
26
|
|
|
29
27
|
class BlockContext
|
|
28
|
+
# The model is only consulted to learn which adapter the query is being
|
|
29
|
+
# built for, which is what decides how a scalar function is spelled.
|
|
30
|
+
def initialize(model)
|
|
31
|
+
@model = model
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
AGGREGATE_FUNCTIONS = {
|
|
31
|
-
|
|
35
|
+
sum: :sum, avg: :average, min: :minimum, max: :maximum,
|
|
32
36
|
}.freeze
|
|
33
37
|
|
|
34
38
|
AGGREGATE_FUNCTIONS.each do |name, arel_func|
|
|
35
39
|
define_method(name) {|column| AST::Aggregate.new(column, arel_func) }
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
def count(column, distinct: false)
|
|
43
|
+
AST::Aggregate.new(column, :count, distinct: distinct)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Scalar functions, defined as real methods so that a typo is a
|
|
47
|
+
# NoMethodError and a name Kernel also answers to (format, hash, test)
|
|
48
|
+
# cannot quietly mean something else.
|
|
49
|
+
#
|
|
50
|
+
# The value lists the adapters that differ: a string is what the
|
|
51
|
+
# function is called there, nil says the adapter has no equivalent. An
|
|
52
|
+
# adapter that is not listed spells it like the method. The families
|
|
53
|
+
# are what the entries key on, so trilogy reads the mysql column.
|
|
54
|
+
#
|
|
55
|
+
# Availability was checked by calling each one; the SQLite figures
|
|
56
|
+
# assume the math functions its build usually enables.
|
|
57
|
+
SCALAR_FUNCTIONS = {
|
|
58
|
+
abs: {}, acos: {}, asin: {}, atan: {}, atan2: {}, ceil: {},
|
|
59
|
+
coalesce: {}, concat: {}, cos: {}, degrees: {}, exp: {}, floor: {},
|
|
60
|
+
length: {}, ln: {}, log: {}, log10: {}, lower: {}, ltrim: {},
|
|
61
|
+
mod: {}, nullif: {}, pi: {}, power: {}, radians: {}, replace: {},
|
|
62
|
+
round: {}, rtrim: {}, sign: {}, sin: {}, sqrt: {}, substr: {},
|
|
63
|
+
tan: {}, trim: {}, upper: {},
|
|
64
|
+
char_length: {sqlite: 'LENGTH'},
|
|
65
|
+
greatest: {sqlite: 'MAX'},
|
|
66
|
+
least: {sqlite: 'MIN'},
|
|
67
|
+
# PostgreSQL spells log2(x) as log(2, x), which no renaming carries.
|
|
68
|
+
log2: {postgresql: nil},
|
|
69
|
+
# MySQL's TRUNCATE insists on the second argument, where the others
|
|
70
|
+
# default it to zero; SQLite's trunc takes only the one.
|
|
71
|
+
trunc: {mysql: 'TRUNCATE'},
|
|
72
|
+
now: {sqlite: nil},
|
|
73
|
+
date_trunc: {sqlite: nil, mysql: nil},
|
|
74
|
+
# Named for Kernel#rand, which it also takes back: a block calling
|
|
75
|
+
# rand would otherwise get Ruby's and never reach the database.
|
|
76
|
+
rand: {sqlite: 'RANDOM', postgresql: 'RANDOM'},
|
|
77
|
+
# Two different functions share this name: printf formatting here, and
|
|
78
|
+
# on MySQL the one that puts separators in a number, which reads a
|
|
79
|
+
# printf template as the number zero rather than complaining. The
|
|
80
|
+
# name keeps the one meaning; fn(:format, ...) reaches MySQL's.
|
|
81
|
+
format: {mysql: nil},
|
|
82
|
+
}.freeze
|
|
39
83
|
|
|
40
|
-
|
|
41
|
-
|
|
84
|
+
ADAPTER_FAMILIES = {
|
|
85
|
+
'sqlite3' => :sqlite,
|
|
86
|
+
'postgresql' => :postgresql,
|
|
87
|
+
'postgis' => :postgresql,
|
|
88
|
+
'mysql2' => :mysql,
|
|
89
|
+
'trilogy' => :mysql,
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
SCALAR_FUNCTIONS.each_key do |name|
|
|
93
|
+
define_method(name) do |*args|
|
|
94
|
+
AST::Function.new(function_name(name, SCALAR_FUNCTIONS), args)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The datetime value functions, as the SQL grammar calls them. These
|
|
99
|
+
# the grammar has bare -- PostgreSQL and SQLite reject them written with
|
|
100
|
+
# parentheses -- and the one thing that does go into parentheses is an
|
|
101
|
+
# optional precision, current_timestamp(3), which current_date never
|
|
102
|
+
# takes and SQLite never accepts. The table reads like
|
|
103
|
+
# SCALAR_FUNCTIONS; current_timestamp is the portable spelling of what
|
|
104
|
+
# now means, reaching SQLite where now does not.
|
|
105
|
+
DATETIME_VALUE_FUNCTIONS = {
|
|
106
|
+
current_date: {},
|
|
107
|
+
current_time: {},
|
|
108
|
+
current_timestamp: {},
|
|
109
|
+
localtime: {sqlite: nil},
|
|
110
|
+
localtimestamp: {sqlite: nil},
|
|
111
|
+
}.freeze
|
|
112
|
+
|
|
113
|
+
def current_date
|
|
114
|
+
AST::DatetimeValueFunction.new(
|
|
115
|
+
function_name(:current_date, DATETIME_VALUE_FUNCTIONS))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
(DATETIME_VALUE_FUNCTIONS.keys - [:current_date]).each do |name|
|
|
119
|
+
define_method(name) do |precision = nil|
|
|
120
|
+
# Built first so that a precision of the wrong type is an
|
|
121
|
+
# ArgumentError on every adapter, before SQLite gets to say it takes
|
|
122
|
+
# none at all.
|
|
123
|
+
node = AST::DatetimeValueFunction.new(
|
|
124
|
+
function_name(name, DATETIME_VALUE_FUNCTIONS), precision)
|
|
125
|
+
if precision && adapter_family == :sqlite
|
|
126
|
+
raise NotImplementedError,
|
|
127
|
+
"#{name} takes no precision on #{@model.connection_db_config.adapter}"
|
|
128
|
+
end
|
|
129
|
+
node
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# EXTRACT(field FROM expr). The field is a keyword, not a value, so it
|
|
134
|
+
# has to be a plain name; the node checks it. SQLite spells all of
|
|
135
|
+
# this as strftime formats, which no renaming carries, so it raises
|
|
136
|
+
# there -- after the node is built, so that a bad field is an
|
|
137
|
+
# ArgumentError on every adapter.
|
|
138
|
+
def extract(field, expr)
|
|
139
|
+
node = AST::Extract.new(field, expr)
|
|
140
|
+
if adapter_family == :sqlite
|
|
141
|
+
raise NotImplementedError,
|
|
142
|
+
"extract has no equivalent on #{@model.connection_db_config.adapter}"
|
|
143
|
+
end
|
|
144
|
+
node
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# CAST(expr AS type). The type is the adapter's own name for it,
|
|
148
|
+
# checked for shape by the node; whether it exists is the database's to
|
|
149
|
+
# say.
|
|
150
|
+
def cast(expr, type)
|
|
151
|
+
AST::Cast.new(expr, type)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Escape hatch for functions without a method of their own. The name is
|
|
155
|
+
# emitted as written, so a case-sensitive one can be spelled exactly,
|
|
156
|
+
# and for that reason it has to be a plain name, optionally qualified by
|
|
157
|
+
# a schema; anything else is refused rather than written into the SQL.
|
|
158
|
+
def fn(name, *args)
|
|
159
|
+
AST::Function.new(
|
|
160
|
+
AST.check_name(name, AST::FUNCTION_NAME, "function name").to_s, args)
|
|
42
161
|
end
|
|
43
162
|
|
|
44
163
|
def exists?(relation)
|
|
45
164
|
AST::Exists.new(relation)
|
|
46
165
|
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def function_name(name, functions)
|
|
170
|
+
spellings = functions.fetch(name)
|
|
171
|
+
return name.to_s.upcase unless spellings.key?(adapter_family)
|
|
172
|
+
spellings.fetch(adapter_family) ||
|
|
173
|
+
raise(NotImplementedError,
|
|
174
|
+
"#{name} has no equivalent on #{@model.connection_db_config.adapter}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# An adapter nobody has classified keeps the standard spellings, and is
|
|
178
|
+
# left to say for itself what it cannot do.
|
|
179
|
+
def adapter_family
|
|
180
|
+
@adapter_family ||=
|
|
181
|
+
ADAPTER_FAMILIES[@model.connection_db_config.adapter] || :unknown
|
|
182
|
+
end
|
|
47
183
|
end
|
|
48
184
|
|
|
49
185
|
module QueryMethods
|
|
@@ -93,19 +229,39 @@ module ActiveRecord
|
|
|
93
229
|
end
|
|
94
230
|
end
|
|
95
231
|
|
|
96
|
-
|
|
232
|
+
# A symbol names a table, which ActiveRecord's own from only takes as a
|
|
233
|
+
# string. With `as` it is selected under another name, which is how a
|
|
234
|
+
# CTE stands in for the model's own table:
|
|
235
|
+
# with_recursive(tree: [...]).from(:tree, as: :nodes)
|
|
236
|
+
def from(value, subquery_name = nil, as: nil)
|
|
237
|
+
unless value.is_a?(Symbol)
|
|
238
|
+
if as
|
|
239
|
+
raise ArgumentError, "as: needs the table named as a symbol"
|
|
240
|
+
end
|
|
241
|
+
return super(value, subquery_name)
|
|
242
|
+
end
|
|
243
|
+
arel_table = Arel::Table.new(value)
|
|
244
|
+
arel_table = arel_table.alias(as) if as
|
|
245
|
+
super(arel_table, subquery_name)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# `as` names the table within the query, which is what makes a self
|
|
249
|
+
# join expressible: joins(:employees, as: :managers) { ... }.
|
|
250
|
+
def joins(*args, as: nil, &block)
|
|
97
251
|
if block
|
|
98
|
-
super(build_join_node(args.first, Arel::Nodes::InnerJoin, &block))
|
|
252
|
+
super(build_join_node(args.first, Arel::Nodes::InnerJoin, as, &block))
|
|
99
253
|
else
|
|
100
|
-
|
|
254
|
+
reject_join_alias(as)
|
|
255
|
+
super(*args, &block)
|
|
101
256
|
end
|
|
102
257
|
end
|
|
103
258
|
|
|
104
|
-
def left_outer_joins(*args, &block)
|
|
259
|
+
def left_outer_joins(*args, as: nil, &block)
|
|
105
260
|
if block
|
|
106
|
-
joins(build_join_node(args.first, Arel::Nodes::OuterJoin, &block))
|
|
261
|
+
joins(build_join_node(args.first, Arel::Nodes::OuterJoin, as, &block))
|
|
107
262
|
else
|
|
108
|
-
|
|
263
|
+
reject_join_alias(as)
|
|
264
|
+
super(*args, &block)
|
|
109
265
|
end
|
|
110
266
|
end
|
|
111
267
|
|
|
@@ -113,7 +269,7 @@ module ActiveRecord
|
|
|
113
269
|
|
|
114
270
|
def evaluate_block(&block)
|
|
115
271
|
refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
|
|
116
|
-
BlockContext.new.instance_exec(&refined_block)
|
|
272
|
+
BlockContext.new(klass).instance_exec(&refined_block)
|
|
117
273
|
end
|
|
118
274
|
|
|
119
275
|
def to_arel_field(node)
|
|
@@ -124,12 +280,16 @@ module ActiveRecord
|
|
|
124
280
|
end
|
|
125
281
|
end
|
|
126
282
|
|
|
127
|
-
def
|
|
283
|
+
def reject_join_alias(alias_name)
|
|
284
|
+
return unless alias_name
|
|
285
|
+
raise ArgumentError, "as: needs a block to write the ON clause with"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def build_join_node(target_table, join_class, alias_name, &block)
|
|
128
289
|
ast = evaluate_block(&block)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
)
|
|
290
|
+
arel_table = Arel::Table.new(target_table)
|
|
291
|
+
arel_table = arel_table.alias(alias_name) if alias_name
|
|
292
|
+
join_class.new(arel_table, Arel::Nodes::On.new(ast.to_arel(table)))
|
|
133
293
|
end
|
|
134
294
|
end
|
|
135
295
|
end
|