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.
@@ -3,10 +3,8 @@ module ActiveRecord
3
3
  module BlockSyntax
4
4
  refine Symbol do
5
5
  import_methods AST::Predications
6
-
7
- %i[count sum average maximum minimum].each do |func|
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
- count: :count, sum: :sum, avg: :average, min: :minimum, max: :maximum,
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
- SCALAR_FUNCTIONS = %i[upper lower length trim coalesce abs round].freeze
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
- SCALAR_FUNCTIONS.each do |name|
41
- define_method(name) {|*args| AST::Function.new(name.to_s.upcase, args) }
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
- def joins(*args, &block)
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
- super
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
- super
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 build_join_node(target_table, join_class, &block)
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
- join_class.new(
130
- Arel::Table.new(target_table),
131
- Arel::Nodes::On.new(ast.to_arel(table))
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
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.3.3'
3
+ VERSION = '0.5.0'
4
4
  end
5
5
  end