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.
- checksums.yaml +4 -4
- data/.yardopts +17 -0
- data/README.md +93 -965
- data/activerecord-refined.gemspec +12 -7
- data/docs/conditions.md +206 -0
- data/docs/ctes.md +65 -0
- data/docs/expressions.md +125 -0
- data/docs/functions.md +219 -0
- data/docs/grouping.md +55 -0
- data/docs/joins.md +73 -0
- data/docs/json.md +230 -0
- data/docs/ordering.md +55 -0
- data/docs/time_zones.md +30 -0
- data/docs/windows.md +41 -0
- data/docs/writing.md +33 -0
- data/examples/aggregations.rb +18 -0
- data/examples/expressions.rb +35 -5
- data/lib/active_record/refined/ast.rb +461 -246
- data/lib/active_record/refined/dialect/mariadb.rb +25 -0
- data/lib/active_record/refined/dialect/mysql.rb +18 -0
- data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
- data/lib/active_record/refined/dialect/oracle.rb +110 -0
- data/lib/active_record/refined/dialect/postgresql.rb +120 -0
- data/lib/active_record/refined/dialect/sql_server.rb +115 -0
- data/lib/active_record/refined/dialect/sqlite.rb +57 -0
- data/lib/active_record/refined/dialect.rb +340 -0
- data/lib/active_record/refined.rb +682 -192
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +1 -0
- metadata +58 -16
- data/.github/workflows/push_gem.yml +0 -45
- data/.github/workflows/sandbox.yml +0 -295
- data/.github/workflows/test.yml +0 -104
- data/.gitignore +0 -19
- data/.rubocop.yml +0 -393
- data/Gemfile +0 -14
- data/Rakefile +0 -53
- data/benchmark/query_building.rb +0 -129
- data/test/test_block_syntax.rb +0 -2999
- data/test/test_helper.rb +0 -238
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/map"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Refined
|
|
7
|
+
# One class per family of SQL spellings, resolved from the model's adapter
|
|
8
|
+
# and asked, rather than branched on, for whatever a query builds
|
|
9
|
+
# differently from one database to the next. The base is the standard
|
|
10
|
+
# spelling an unclassified adapter keeps; each subclass overrides only
|
|
11
|
+
# where its family departs from it.
|
|
12
|
+
#
|
|
13
|
+
# The families are loaded as they are met: an application on PostgreSQL
|
|
14
|
+
# never loads the Oracle class, whose adapter it will never resolve to.
|
|
15
|
+
class Dialect
|
|
16
|
+
autoload :Sqlite, "active_record/refined/dialect/sqlite"
|
|
17
|
+
autoload :Postgresql, "active_record/refined/dialect/postgresql"
|
|
18
|
+
autoload :MysqlCompat, "active_record/refined/dialect/mysql_compat"
|
|
19
|
+
autoload :Mysql, "active_record/refined/dialect/mysql"
|
|
20
|
+
autoload :Mariadb, "active_record/refined/dialect/mariadb"
|
|
21
|
+
autoload :Oracle, "active_record/refined/dialect/oracle"
|
|
22
|
+
autoload :SqlServer, "active_record/refined/dialect/sql_server"
|
|
23
|
+
|
|
24
|
+
# One instance per family, shared across threads. The dialect carries no
|
|
25
|
+
# state, so sharing is safe; the map is a concurrent one so that building
|
|
26
|
+
# the instance the first time is too, on a Ruby whose threads run in
|
|
27
|
+
# parallel as much as on one whose do not.
|
|
28
|
+
@instances = Concurrent::Map.new
|
|
29
|
+
|
|
30
|
+
@registry = Concurrent::Map.new
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Names an adapter's dialect. An adapter's gem, or an application,
|
|
34
|
+
# registers its own -- a Dialect subclass overriding only where its
|
|
35
|
+
# family departs from the standard:
|
|
36
|
+
#
|
|
37
|
+
# ActiveRecord::Refined::Dialect.register("exampledb", ExampleDialect)
|
|
38
|
+
#
|
|
39
|
+
# A block registers an adapter whose dialect only the connection can
|
|
40
|
+
# name, as mysql2's is either MySQL's or MariaDB's: it receives the
|
|
41
|
+
# model and returns the class. The built-in families register with
|
|
42
|
+
# blocks too, which is what leaves each autoloaded until an adapter
|
|
43
|
+
# first resolves to it.
|
|
44
|
+
def register(adapter, dialect = nil, &block)
|
|
45
|
+
unless dialect || block
|
|
46
|
+
raise ArgumentError, "register takes a dialect class or a block"
|
|
47
|
+
end
|
|
48
|
+
@registry[adapter.to_s] = dialect || block
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The dialect a model's queries are built for. An adapter nobody has
|
|
52
|
+
# registered keeps the standard spellings and is left to say for
|
|
53
|
+
# itself what it cannot do. The instances are cached by class rather
|
|
54
|
+
# than by adapter, so a re-registration takes effect on the next
|
|
55
|
+
# query.
|
|
56
|
+
def for(model)
|
|
57
|
+
klass = class_for(model.connection_db_config.adapter, model)
|
|
58
|
+
@instances.compute_if_absent(klass) { klass.new }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
def class_for(adapter, model)
|
|
63
|
+
entry = @registry[adapter] or return Dialect
|
|
64
|
+
entry.is_a?(Proc) ? entry.call(model) : entry
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
register("sqlite3") { Sqlite }
|
|
69
|
+
register("postgresql") { Postgresql }
|
|
70
|
+
register("postgis") { Postgresql }
|
|
71
|
+
register("pglite") { Postgresql }
|
|
72
|
+
# MariaDB and MySQL answer to one adapter apiece and part company only
|
|
73
|
+
# at the connection, so those two are told apart by asking it.
|
|
74
|
+
%w[mysql2 trilogy].each do |adapter|
|
|
75
|
+
register(adapter) do |model|
|
|
76
|
+
model.with_connection { |connection| connection.mariadb? } ? Mariadb : Mysql
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
register("oracle_enhanced") { Oracle }
|
|
80
|
+
register("sqlserver") { SqlServer }
|
|
81
|
+
|
|
82
|
+
# --- Capabilities. The standard has them; a family without one says so
|
|
83
|
+
# by overriding to false, and the block raises where it is asked for.
|
|
84
|
+
|
|
85
|
+
# The scalar and datetime functions a family spells differently, or has
|
|
86
|
+
# none of. A name it does not list it spells like the method, upper
|
|
87
|
+
# cased; a nil says it has no equivalent, and the block raises. The base
|
|
88
|
+
# -- an unclassified adapter -- keeps every standard name.
|
|
89
|
+
FUNCTIONS = {}.freeze
|
|
90
|
+
|
|
91
|
+
def function_name(name, model)
|
|
92
|
+
functions = self.class::FUNCTIONS
|
|
93
|
+
return name.to_s.upcase unless functions.key?(name)
|
|
94
|
+
functions.fetch(name) ||
|
|
95
|
+
raise(NotImplementedError,
|
|
96
|
+
"#{name} has no equivalent on #{model.connection_db_config.adapter}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def datetime_precision_supported? = true
|
|
100
|
+
def extract_supported? = true
|
|
101
|
+
def quantifiers_supported? = true
|
|
102
|
+
def full_outer_join_supported? = true
|
|
103
|
+
|
|
104
|
+
# The FILTER clause, which restricts an aggregate to the rows a condition
|
|
105
|
+
# holds for. A family without it gets the CASE that means the same,
|
|
106
|
+
# built by the aggregate node.
|
|
107
|
+
def filter_supported? = true
|
|
108
|
+
|
|
109
|
+
# A lateral join is allowed to stand unless the family refuses it here.
|
|
110
|
+
def check_lateral(_model); end
|
|
111
|
+
|
|
112
|
+
# --- Expressions built differently per family.
|
|
113
|
+
|
|
114
|
+
# BIT_COUNT of a number. The standard has no equivalent; the families
|
|
115
|
+
# that do override.
|
|
116
|
+
def bit_count(_expr, model)
|
|
117
|
+
raise NotImplementedError,
|
|
118
|
+
"bit_count has no equivalent on #{model.connection_db_config.adapter}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The row an upsert could not insert. PostgreSQL and SQLite name it;
|
|
122
|
+
# MySQL spells the same thing VALUES(column) and overrides.
|
|
123
|
+
def excluded(column, _model)
|
|
124
|
+
AST::Column.new(:excluded, column)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# true? / false? and their negations. The standard spells them with the
|
|
128
|
+
# boolean IS [NOT] TRUE/FALSE, which keeps a NULL out of the plain form
|
|
129
|
+
# and in of the negation; a family without a boolean type overrides.
|
|
130
|
+
def truth_value(operand, value, negated, _model)
|
|
131
|
+
literal = value ? Arel::Nodes::True.new : Arel::Nodes::False.new
|
|
132
|
+
Arel::Nodes::InfixOperation.new(negated ? "IS NOT" : "IS", operand, literal)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# COLLATE, which every family spells `expr COLLATE name` -- the name a
|
|
136
|
+
# bare identifier, no Arel node for it. Written bare it has to be a
|
|
137
|
+
# plain one, so it is checked here; PostgreSQL quotes it and widens what
|
|
138
|
+
# it takes, overriding. PostgreSQL also folds an unquoted name to lower
|
|
139
|
+
# case, where its built-in names are upper -- "C", "POSIX" -- another
|
|
140
|
+
# reason it quotes rather than inheriting this.
|
|
141
|
+
def collate(operand, name, _model)
|
|
142
|
+
AST.check_name(name, AST::COLLATION_NAME, "collation name")
|
|
143
|
+
Arel::Nodes::InfixOperation.new(
|
|
144
|
+
"COLLATE", operand, Arel::Nodes::SqlLiteral.new(name))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A date moved by a duration: `:due_on + 3.days`. The standard adds an
|
|
148
|
+
# interval literal, `x + INTERVAL '3' DAY`, which PostgreSQL and the
|
|
149
|
+
# MySQL family both read; SQLite, SQL Server and Oracle each spell the
|
|
150
|
+
# move their own way and override. The amount is a whole number and
|
|
151
|
+
# the unit one of six names, both checked by the node, so both are
|
|
152
|
+
# written into the SQL as they are. date_only says the operand is a
|
|
153
|
+
# date rather than a datetime, which only SQLite has to be told.
|
|
154
|
+
def add_interval(date, amount, unit, subtract, _date_only)
|
|
155
|
+
interval = Arel::Nodes::SqlLiteral.new("INTERVAL '#{amount}' #{unit.to_s.upcase}")
|
|
156
|
+
Arel::Nodes::Grouping.new(
|
|
157
|
+
Arel::Nodes::InfixOperation.new(subtract ? :- : :+, date, interval))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# XOR, which no two families spell alike. The standard is the two
|
|
161
|
+
# operations it is made of, naming each operand twice, as SQLite needs;
|
|
162
|
+
# PostgreSQL and the MySQL family have an operator and override.
|
|
163
|
+
def bitwise_xor(left, right)
|
|
164
|
+
Arel::Nodes::Subtraction.new(
|
|
165
|
+
Arel::Nodes::Grouping.new(Arel::Nodes::BitwiseOr.new(left, right)),
|
|
166
|
+
Arel::Nodes::Grouping.new(Arel::Nodes::BitwiseAnd.new(left, right)))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# --- Reading JSON. The defaults are what an unclassified adapter gets,
|
|
170
|
+
# which is SQLite's operators for a path and its functions elsewhere.
|
|
171
|
+
|
|
172
|
+
# dig / dig_text. The standard is SQLite's -> and ->>, whose ->> keeps
|
|
173
|
+
# the value's type, so dig_text casts to text for a portable comparison.
|
|
174
|
+
def json_path(document, dollar_path, _steps, json_value, _model)
|
|
175
|
+
extracted = Arel::Nodes::InfixOperation.new(
|
|
176
|
+
json_value ? :"->" : :"->>", document, Arel::Nodes.build_quoted(dollar_path))
|
|
177
|
+
return extracted if json_value
|
|
178
|
+
Arel::Nodes::NamedFunction.new(
|
|
179
|
+
"CAST", [Arel::Nodes::As.new(extracted, Arel::Nodes::SqlLiteral.new("text"))])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A Ruby value on the JSON side of a comparison belongs to a JSON type;
|
|
183
|
+
# a family without one refuses it.
|
|
184
|
+
def json_literal(_json, model)
|
|
185
|
+
raise NotImplementedError,
|
|
186
|
+
"a JSON comparison has no equivalent on " \
|
|
187
|
+
"#{model.connection_db_config.adapter}; dig_text gives the value"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def json_contains(_document, _json, model)
|
|
191
|
+
raise NotImplementedError,
|
|
192
|
+
"contains? has no equivalent on #{model.connection_db_config.adapter}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def json_has_key(document, _name, path, _model)
|
|
196
|
+
Arel::Nodes::NamedFunction.new("json_type", [document, path]).not_eq(nil)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def json_keys(document, _model)
|
|
200
|
+
Arel::Nodes::NamedFunction.new("JSON_KEYS", [document])
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# --- Writing JSON. A Ruby document or boolean has to be told apart from
|
|
204
|
+
# a bare scalar, and embedded as the JSON it spells.
|
|
205
|
+
|
|
206
|
+
def json_document_value?(value)
|
|
207
|
+
value.is_a?(::Hash) || value.is_a?(::Array) || value == true || value == false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A Ruby document or boolean written where one of the JSON functions
|
|
211
|
+
# wants JSON. The standard marks the literal with JSON_EXTRACT($);
|
|
212
|
+
# SQLite has json() and Oracle FORMAT JSON, and both override.
|
|
213
|
+
def json_argument(value, _model)
|
|
214
|
+
json = Arel::Nodes.build_quoted(JSON.generate(value))
|
|
215
|
+
Arel::Nodes::NamedFunction.new("JSON_EXTRACT", [json, Arel::Nodes.build_quoted("$")])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# bury: setting a value at a path. The standard is JSON_SET; PostgreSQL
|
|
219
|
+
# has jsonb_set and Oracle JSON_TRANSFORM, and both override.
|
|
220
|
+
def json_set(document, _steps, dollar_path, value, expression, model)
|
|
221
|
+
Arel::Nodes::NamedFunction.new(
|
|
222
|
+
"JSON_SET",
|
|
223
|
+
[document, Arel::Nodes.build_quoted(dollar_path),
|
|
224
|
+
json_set_value(value, expression, model)])
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# except: removing keys. The standard removes a path apiece with
|
|
228
|
+
# JSON_REMOVE; PostgreSQL subtracts an array of keys and Oracle removes
|
|
229
|
+
# through JSON_TRANSFORM, and both override.
|
|
230
|
+
def json_remove(document, dollar_paths, _steps, _model)
|
|
231
|
+
Arel::Nodes::NamedFunction.new(
|
|
232
|
+
"JSON_REMOVE",
|
|
233
|
+
[document, *dollar_paths.map { |path| Arel::Nodes.build_quoted(path) }])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# json_array / json_object built in the row. The standard says JSON_ARRAY
|
|
237
|
+
# and JSON_OBJECT with the values (and keys alternating); PostgreSQL has
|
|
238
|
+
# the jsonb_build_* pair and Oracle a keyword syntax, and both override.
|
|
239
|
+
def json_build(kind, keys, args, _model)
|
|
240
|
+
Arel::Nodes::NamedFunction.new(
|
|
241
|
+
kind == :array ? "JSON_ARRAY" : "JSON_OBJECT", json_build_body(kind, keys, args))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# A document or boolean built into json_array/json_object. The standard
|
|
245
|
+
# marks it JSON as bury does; PostgreSQL casts to jsonb and overrides.
|
|
246
|
+
def json_build_argument(value, model)
|
|
247
|
+
json_argument(value, model)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# json_arrayagg / json_objectagg gather rows into a document. The
|
|
251
|
+
# standard names are JSON_ARRAYAGG and JSON_OBJECTAGG; SQLite and
|
|
252
|
+
# PostgreSQL have their own and override.
|
|
253
|
+
def json_aggregate_name(kind)
|
|
254
|
+
kind == :arrayagg ? "JSON_ARRAYAGG" : "JSON_OBJECTAGG"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# These two keep a NULL as JSON null rather than passing over it, so the
|
|
258
|
+
# CASE that stands in for FILTER would leave one in the document; a
|
|
259
|
+
# family without FILTER for them refuses it.
|
|
260
|
+
def json_aggregate_filter_supported? = true
|
|
261
|
+
|
|
262
|
+
# A family that cannot take these two as window functions refuses one.
|
|
263
|
+
def check_json_aggregate_window(_source, _model); end
|
|
264
|
+
|
|
265
|
+
# string_agg: the strings of a group joined into one. The standard's is
|
|
266
|
+
# LISTAGG(x, ', ') WITHIN GROUP (ORDER BY ...), which Oracle reads, and
|
|
267
|
+
# the ORDER BY is not optional there: an aggregate asked for no order
|
|
268
|
+
# is given the values' own, which costs the caller nothing to have.
|
|
269
|
+
# PostgreSQL and SQLite carry the ORDER BY inside the call, SQL Server
|
|
270
|
+
# takes the WITHIN GROUP only when there is an order, and the MySQL
|
|
271
|
+
# family has GROUP_CONCAT; every one of them overrides. `string` says
|
|
272
|
+
# the operand is a column declared one, which PostgreSQL alone asks.
|
|
273
|
+
def string_agg(operand, separator, orders, _string, model)
|
|
274
|
+
orders = [operand] if orders.empty?
|
|
275
|
+
Arel.sql("LISTAGG(#{compile(operand, model)}, #{quote(separator, model)}) " \
|
|
276
|
+
"WITHIN GROUP (ORDER BY #{compile_list(orders, model)})")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A family that cannot take string_agg as a window function refuses one.
|
|
280
|
+
def check_string_aggregate_window(_model); end
|
|
281
|
+
|
|
282
|
+
# A JSON value in an IN list or a range is compared per element on the
|
|
283
|
+
# MySQL family, which overrides; the standard leaves it to the IN.
|
|
284
|
+
def json_list_by_element? = false
|
|
285
|
+
|
|
286
|
+
# --- Grouping. GROUPING SETS, ROLLUP and CUBE, which the standard has
|
|
287
|
+
# none of; PostgreSQL has all three and the MySQL family rollup alone.
|
|
288
|
+
|
|
289
|
+
def grouping_supported?(_kind) = false
|
|
290
|
+
|
|
291
|
+
# The MySQL family spells rollup WITH ROLLUP, trailing the group list
|
|
292
|
+
# rather than wrapping a list of its own, and overrides.
|
|
293
|
+
def grouping_by_with_rollup? = false
|
|
294
|
+
|
|
295
|
+
protected
|
|
296
|
+
# The value beside a path in JSON_SET: an expression as it is, a
|
|
297
|
+
# document or boolean as JSON, a bare scalar quoted.
|
|
298
|
+
def json_set_value(value, expression, model)
|
|
299
|
+
return expression if expression
|
|
300
|
+
return Arel::Nodes.build_quoted(value) unless json_document_value?(value)
|
|
301
|
+
json_argument(value, model)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The arguments to JSON_ARRAY/JSON_OBJECT: an array's values as they
|
|
305
|
+
# are, an object's keys alternating with them.
|
|
306
|
+
def json_build_body(kind, keys, args)
|
|
307
|
+
return args if kind == :array
|
|
308
|
+
keys.zip(args).flat_map { |key, arg| [Arel::Nodes.build_quoted(key), arg] }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The name(x, ', ' ORDER BY ...) shape of string_agg, PostgreSQL's and
|
|
312
|
+
# SQLite's; a node while there is no order, since Arel has one for
|
|
313
|
+
# that much.
|
|
314
|
+
def string_agg_call(name, operand, separator, orders, model)
|
|
315
|
+
if orders.empty?
|
|
316
|
+
return Arel::Nodes::NamedFunction.new(
|
|
317
|
+
name, [operand, Arel::Nodes.build_quoted(separator)])
|
|
318
|
+
end
|
|
319
|
+
Arel.sql("#{name}(#{compile(operand, model)}, #{quote(separator, model)} " \
|
|
320
|
+
"ORDER BY #{compile_list(orders, model)})")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The connection's own visitor and quoting, for the families that write
|
|
324
|
+
# a call out because its grammar no Arel node carries.
|
|
325
|
+
def compile(node, model)
|
|
326
|
+
model.with_connection { |connection| connection.visitor.compile(node) }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def compile_list(nodes, model)
|
|
330
|
+
model.with_connection do |connection|
|
|
331
|
+
nodes.map { |node| connection.visitor.compile(node) }.join(", ")
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def quote(value, model)
|
|
336
|
+
model.with_connection { |connection| connection.quote(value) }
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|