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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +17 -0
  3. data/README.md +93 -965
  4. data/activerecord-refined.gemspec +12 -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/joins.md +73 -0
  11. data/docs/json.md +230 -0
  12. data/docs/ordering.md +55 -0
  13. data/docs/time_zones.md +30 -0
  14. data/docs/windows.md +41 -0
  15. data/docs/writing.md +33 -0
  16. data/examples/aggregations.rb +18 -0
  17. data/examples/expressions.rb +35 -5
  18. data/lib/active_record/refined/ast.rb +461 -246
  19. data/lib/active_record/refined/dialect/mariadb.rb +25 -0
  20. data/lib/active_record/refined/dialect/mysql.rb +18 -0
  21. data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
  22. data/lib/active_record/refined/dialect/oracle.rb +110 -0
  23. data/lib/active_record/refined/dialect/postgresql.rb +120 -0
  24. data/lib/active_record/refined/dialect/sql_server.rb +115 -0
  25. data/lib/active_record/refined/dialect/sqlite.rb +57 -0
  26. data/lib/active_record/refined/dialect.rb +340 -0
  27. data/lib/active_record/refined.rb +682 -192
  28. data/lib/activerecord-refined/version.rb +1 -1
  29. data/lib/activerecord-refined.rb +1 -0
  30. metadata +58 -16
  31. data/.github/workflows/push_gem.yml +0 -45
  32. data/.github/workflows/sandbox.yml +0 -295
  33. data/.github/workflows/test.yml +0 -104
  34. data/.gitignore +0 -19
  35. data/.rubocop.yml +0 -393
  36. data/Gemfile +0 -14
  37. data/Rakefile +0 -53
  38. data/benchmark/query_building.rb +0 -129
  39. data/test/test_block_syntax.rb +0 -2999
  40. 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