appquery 0.1.0 → 0.2.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/.envrc +6 -1
- data/.envrc.private.example +2 -0
- data/.standard.yml +3 -1
- data/README.md +458 -11
- data/lib/app_query/rspec/helpers.rb +90 -0
- data/lib/app_query/rspec.rb +5 -0
- data/lib/app_query/tokenizer.rb +356 -0
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +238 -1
- data/lib/appquery.rb +1 -0
- data/lib/rails/generators/query/USAGE +10 -0
- data/lib/rails/generators/query/query_generator.rb +20 -0
- data/lib/rails/generators/query/templates/query.sql.tt +14 -0
- data/lib/rails/generators/rspec/query_generator.rb +20 -0
- data/lib/rails/generators/rspec/templates/query_spec.rb.tt +12 -0
- data/sig/appquery.rbs +1 -1
- data/tmp/.gitkeep +0 -0
- metadata +28 -16
@@ -0,0 +1,356 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AppQuery
|
4
|
+
class Tokenizer
|
5
|
+
class LexError < StandardError; end
|
6
|
+
|
7
|
+
attr_reader :input, :tokens, :pos, :start
|
8
|
+
|
9
|
+
def self.tokenize(...)
|
10
|
+
new(...).run
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(input, state: nil, start: nil, pos: nil)
|
14
|
+
@input = input
|
15
|
+
@tokens = []
|
16
|
+
@start = start || 0
|
17
|
+
@pos = pos || @start
|
18
|
+
@return = Array(state || :lex_sql)
|
19
|
+
end
|
20
|
+
|
21
|
+
def err(msg)
|
22
|
+
linepos = linepos_by_pos[pos] || linepos_by_pos[pos.pred]
|
23
|
+
|
24
|
+
msg += <<~ERR
|
25
|
+
|
26
|
+
#{input}
|
27
|
+
#{" " * linepos}^
|
28
|
+
ERR
|
29
|
+
raise LexError, msg
|
30
|
+
end
|
31
|
+
|
32
|
+
def eos?
|
33
|
+
pos == input.size
|
34
|
+
end
|
35
|
+
|
36
|
+
def chars_read
|
37
|
+
input[start...pos]
|
38
|
+
end
|
39
|
+
|
40
|
+
def read_char(n = 1)
|
41
|
+
@pos = [pos + n, input.size].min
|
42
|
+
self
|
43
|
+
end
|
44
|
+
|
45
|
+
def rest
|
46
|
+
input[pos...]
|
47
|
+
end
|
48
|
+
|
49
|
+
def match?(re)
|
50
|
+
rest[Regexp.new("\\A%s" % re)]
|
51
|
+
end
|
52
|
+
|
53
|
+
def emit_token(t, v: nil)
|
54
|
+
@tokens << {v: v || chars_read, t: t, start: start, end: pos}
|
55
|
+
@start = @pos
|
56
|
+
self
|
57
|
+
end
|
58
|
+
|
59
|
+
def push_return(*steps)
|
60
|
+
(@return ||= []).push(*steps)
|
61
|
+
self
|
62
|
+
end
|
63
|
+
|
64
|
+
def read_until(pattern)
|
65
|
+
loop do
|
66
|
+
break if match?(pattern) || eos?
|
67
|
+
read_char
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def lex_sql
|
72
|
+
if last_emitted? t: "CTE_SELECT", ignore: %w[WHITESPACE COMMENT]
|
73
|
+
push_return :lex_select
|
74
|
+
elsif match?(/\s/)
|
75
|
+
push_return :lex_sql, :lex_whitespace
|
76
|
+
elsif match_comment?
|
77
|
+
push_return :lex_sql, :lex_comment
|
78
|
+
elsif match?(/with/i)
|
79
|
+
push_return :lex_sql, :lex_with
|
80
|
+
else
|
81
|
+
push_return :lex_select
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def lex_with
|
86
|
+
err "Expected 'WITH'" unless match? %r{WITH\s}i
|
87
|
+
read_until(/\s/)
|
88
|
+
read_until(/\S/)
|
89
|
+
emit_token "WITH"
|
90
|
+
|
91
|
+
push_return :lex_recursive_cte
|
92
|
+
end
|
93
|
+
|
94
|
+
def lex_prepend_cte
|
95
|
+
if eos?
|
96
|
+
emit_token "COMMA", v: ","
|
97
|
+
emit_token "WHITESPACE", v: "\n"
|
98
|
+
else
|
99
|
+
# emit_token "WHITESPACE", v: " "
|
100
|
+
push_return :lex_prepend_cte, :lex_recursive_cte
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def lex_append_cte
|
105
|
+
emit_token "COMMA", v: ","
|
106
|
+
emit_token "WHITESPACE", v: "\n "
|
107
|
+
push_return :lex_recursive_cte
|
108
|
+
end
|
109
|
+
|
110
|
+
def lex_recursive_cte
|
111
|
+
if match?(/recursive\s/i)
|
112
|
+
read_until(/\s/)
|
113
|
+
# make trailing whitespace part of next token
|
114
|
+
# this makes adding cte's easier
|
115
|
+
read_until(/\S/)
|
116
|
+
emit_token "RECURSIVE"
|
117
|
+
end
|
118
|
+
|
119
|
+
push_return :lex_cte
|
120
|
+
end
|
121
|
+
|
122
|
+
def last_emitted(ignore:)
|
123
|
+
if ignore.none?
|
124
|
+
@tokens.last
|
125
|
+
else
|
126
|
+
t = @tokens.dup
|
127
|
+
while (result = t.pop)
|
128
|
+
break if !ignore.include?(result[:t])
|
129
|
+
end
|
130
|
+
result
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def last_emitted?(ignore_whitespace: true, ignore: [], **kws)
|
135
|
+
ignore = if ignore.any?
|
136
|
+
ignore
|
137
|
+
elsif ignore_whitespace
|
138
|
+
%w[COMMENT WHITESPACE]
|
139
|
+
else
|
140
|
+
[]
|
141
|
+
end
|
142
|
+
last_emitted(ignore:)&.slice(*kws.keys) == kws
|
143
|
+
end
|
144
|
+
|
145
|
+
def lex_cte
|
146
|
+
if match_comment?
|
147
|
+
push_return :lex_cte, :lex_comment
|
148
|
+
elsif last_emitted? t: "CTE_IDENTIFIER", ignore_whitespace: true
|
149
|
+
if match?(/AS(\s|\()/i)
|
150
|
+
read_char 2
|
151
|
+
emit_token "AS"
|
152
|
+
|
153
|
+
push_return :lex_cte, :lex_cte_select, :lex_maybe_materialized, :lex_whitespace
|
154
|
+
elsif match?(%r{\(})
|
155
|
+
# "foo " "(id)"
|
156
|
+
push_return :lex_cte, :lex_cte_columns
|
157
|
+
else
|
158
|
+
err "Expected 'AS' or CTE columns following CTE-identifier, e.g. 'foo AS' 'foo()'"
|
159
|
+
end
|
160
|
+
elsif last_emitted? t: "CTE_COLUMNS_CLOSE", ignore_whitespace: true
|
161
|
+
if match?(/AS(\s|\()/i)
|
162
|
+
read_char 2
|
163
|
+
emit_token "AS"
|
164
|
+
|
165
|
+
push_return :lex_cte, :lex_cte_select, :lex_maybe_materialized, :lex_whitespace
|
166
|
+
else
|
167
|
+
err "Expected 'AS' following CTE-columns"
|
168
|
+
end
|
169
|
+
elsif last_emitted? t: "CTE_SELECT", ignore_whitespace: true
|
170
|
+
if match?(/,/)
|
171
|
+
# but wait, there's more!
|
172
|
+
read_char
|
173
|
+
emit_token "CTE_COMMA"
|
174
|
+
push_return :lex_cte, :lex_whitespace
|
175
|
+
end
|
176
|
+
else
|
177
|
+
push_return :lex_cte, :lex_cte_identifier
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def lex_maybe_materialized
|
182
|
+
if match?(/materialized/i)
|
183
|
+
read_until(/\(/)
|
184
|
+
emit_token "MATERIALIZED"
|
185
|
+
elsif match?(%r{\(})
|
186
|
+
# done
|
187
|
+
elsif match?(/not\s/i)
|
188
|
+
read_char 3
|
189
|
+
read_until(/\S/)
|
190
|
+
emit_token "NOT_MATERIALIZED"
|
191
|
+
err "Expected 'MATERIALIZED'" unless match?(/materialized/i)
|
192
|
+
|
193
|
+
push_return :lex_maybe_materialized
|
194
|
+
else
|
195
|
+
err "Expected CTE select or NOT? MATERIALIZED"
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def match_comment?
|
200
|
+
match?(%r{--|/\*})
|
201
|
+
end
|
202
|
+
|
203
|
+
def lex_cte_columns
|
204
|
+
err "Expected CTE columns, e.g. '(id, other)'" unless match? %r{\(}
|
205
|
+
|
206
|
+
read_char
|
207
|
+
read_until(/\S/)
|
208
|
+
emit_token "CTE_COLUMNS_OPEN"
|
209
|
+
|
210
|
+
loop do
|
211
|
+
if match?(/\)/)
|
212
|
+
err "Expected a column name" unless last_emitted? t: "CTE_COLUMN"
|
213
|
+
|
214
|
+
read_char
|
215
|
+
emit_token "CTE_COLUMNS_CLOSE"
|
216
|
+
break
|
217
|
+
elsif match?(/,/)
|
218
|
+
# "( " ","
|
219
|
+
err "Expected a column name" unless last_emitted? t: "CTE_COLUMN"
|
220
|
+
read_char # ','
|
221
|
+
|
222
|
+
read_until(/\S/)
|
223
|
+
emit_token "CTE_COLUMN_DIV"
|
224
|
+
elsif match?(/"/)
|
225
|
+
unless last_emitted? t: "CTE_COLUMNS_OPEN"
|
226
|
+
err "Expected comma" unless last_emitted? t: "CTE_COLUMN_DIV"
|
227
|
+
end
|
228
|
+
|
229
|
+
read_char
|
230
|
+
read_until(/"/)
|
231
|
+
read_char
|
232
|
+
|
233
|
+
emit_token "CTE_COLUMN"
|
234
|
+
elsif match?(/[_A-Za-z]/)
|
235
|
+
unless last_emitted? t: "CTE_COLUMNS_OPEN"
|
236
|
+
err "Expected comma" unless last_emitted? t: "CTE_COLUMN_DIV"
|
237
|
+
end
|
238
|
+
|
239
|
+
read_until %r{,|\s|\)}
|
240
|
+
|
241
|
+
emit_token "CTE_COLUMN"
|
242
|
+
elsif match?(/\s/)
|
243
|
+
read_until(/\S/)
|
244
|
+
else
|
245
|
+
# e.g. "(id," "1)" or eos?
|
246
|
+
err "Expected valid column name"
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
push_return :lex_whitespace
|
251
|
+
end
|
252
|
+
|
253
|
+
def lex_cte_select
|
254
|
+
err "Expected CTE select, e.g. '(select 1)'" unless match? %r{\(}
|
255
|
+
read_char
|
256
|
+
|
257
|
+
level = 1
|
258
|
+
loop do
|
259
|
+
read_until(/\)|\(/)
|
260
|
+
if eos?
|
261
|
+
err "CTE select ended prematurely"
|
262
|
+
elsif match?(/\(/)
|
263
|
+
level += 1
|
264
|
+
elsif match?(/\)/)
|
265
|
+
level -= 1
|
266
|
+
break if level.zero?
|
267
|
+
end
|
268
|
+
read_char
|
269
|
+
end
|
270
|
+
|
271
|
+
err "Expected non-empty CTE select, e.g. '(select 1)'" if chars_read.strip == "("
|
272
|
+
read_char
|
273
|
+
emit_token "CTE_SELECT"
|
274
|
+
|
275
|
+
push_return :lex_whitespace
|
276
|
+
end
|
277
|
+
|
278
|
+
def lex_cte_identifier
|
279
|
+
err "Expected CTE identifier, e.g. 'foo', '\"foo bar\"' " unless match? %r{[_"A-Za-z]}
|
280
|
+
|
281
|
+
if match?(/"/)
|
282
|
+
read_char
|
283
|
+
read_until(/"/)
|
284
|
+
read_char
|
285
|
+
else
|
286
|
+
read_until %r{\s|\(}
|
287
|
+
end
|
288
|
+
emit_token "CTE_IDENTIFIER"
|
289
|
+
|
290
|
+
push_return :lex_whitespace
|
291
|
+
end
|
292
|
+
|
293
|
+
# there should always be a SELECT
|
294
|
+
def lex_select
|
295
|
+
read_until(/\Z/)
|
296
|
+
read_char
|
297
|
+
|
298
|
+
if last_emitted? t: "COMMENT", ignore_whitespace: false
|
299
|
+
emit_token "WHITESPACE", v: "\n"
|
300
|
+
end
|
301
|
+
emit_token "SELECT"
|
302
|
+
end
|
303
|
+
|
304
|
+
def lex_comment
|
305
|
+
err "Expected comment, i.e. '--' or '/*'" unless match_comment?
|
306
|
+
|
307
|
+
if match?("--")
|
308
|
+
read_until(/\n/)
|
309
|
+
else
|
310
|
+
read_until %r{\*/}
|
311
|
+
err "Expected comment close '*/'." if eos?
|
312
|
+
read_char 2
|
313
|
+
end
|
314
|
+
|
315
|
+
emit_token "COMMENT"
|
316
|
+
push_return :lex_whitespace
|
317
|
+
end
|
318
|
+
|
319
|
+
# optional
|
320
|
+
def lex_whitespace
|
321
|
+
if match?(/\s/)
|
322
|
+
read_until(/\S/)
|
323
|
+
|
324
|
+
emit_token "WHITESPACE"
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
def run(pos: nil)
|
329
|
+
loop do
|
330
|
+
break if step.nil?
|
331
|
+
end
|
332
|
+
eos? ? tokens : self
|
333
|
+
end
|
334
|
+
|
335
|
+
def step
|
336
|
+
if (state = @return.pop)
|
337
|
+
method(state).call
|
338
|
+
self
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
private
|
343
|
+
|
344
|
+
def linepos_by_pos
|
345
|
+
linepos = 0
|
346
|
+
input.each_char.each_with_index.each_with_object([]) do |(c, ix), acc|
|
347
|
+
acc[ix] = linepos
|
348
|
+
if c == "\n"
|
349
|
+
linepos = 0
|
350
|
+
else
|
351
|
+
linepos += 1
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
@@ -1,8 +1,245 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "app_query/version"
|
4
|
+
require_relative "app_query/tokenizer"
|
5
|
+
require "active_record"
|
4
6
|
|
5
7
|
module AppQuery
|
6
8
|
class Error < StandardError; end
|
7
|
-
|
9
|
+
|
10
|
+
Configuration = Struct.new(:query_path)
|
11
|
+
|
12
|
+
def self.configuration
|
13
|
+
@configuration ||= AppQuery::Configuration.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.configure
|
17
|
+
yield configuration if block_given?
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.reset_configuration!
|
21
|
+
configure do |config|
|
22
|
+
config.query_path = "app/queries"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
reset_configuration!
|
26
|
+
|
27
|
+
def self.[](v)
|
28
|
+
query_name = v.to_s
|
29
|
+
full_path = (Pathname.new(configuration.query_path) / "#{query_name}.sql").expand_path
|
30
|
+
Q.new(full_path.read, name: "AppQuery #{query_name}")
|
31
|
+
end
|
32
|
+
|
33
|
+
class Result < ActiveRecord::Result
|
34
|
+
attr_accessor :cast
|
35
|
+
alias_method :cast?, :cast
|
36
|
+
|
37
|
+
def initialize(columns, rows, overrides = nil, cast: false)
|
38
|
+
super(columns, rows, overrides)
|
39
|
+
@cast = cast
|
40
|
+
# Rails v6.1: prevent mutate on frozen object on #first
|
41
|
+
@hash_rows = [] if columns.empty?
|
42
|
+
end
|
43
|
+
|
44
|
+
def column(name = nil)
|
45
|
+
return [] if empty?
|
46
|
+
unless name.nil? || includes_column?(name)
|
47
|
+
raise ArgumentError, "Unknown column #{name.inspect}. Should be one of #{columns.inspect}."
|
48
|
+
end
|
49
|
+
ix = name.nil? ? 0 : columns.index(name)
|
50
|
+
rows.map { _1[ix] }
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.from_ar_result(r, cast = nil)
|
54
|
+
if r.empty?
|
55
|
+
EMPTY
|
56
|
+
else
|
57
|
+
cast &&= case cast
|
58
|
+
when Array
|
59
|
+
r.columns.zip(cast).to_h
|
60
|
+
when Hash
|
61
|
+
cast
|
62
|
+
else
|
63
|
+
{}
|
64
|
+
end
|
65
|
+
if !cast || (cast.empty? && r.column_types.empty?)
|
66
|
+
# nothing to cast
|
67
|
+
new(r.columns, r.rows, r.column_types)
|
68
|
+
else
|
69
|
+
overrides = (r.column_types || {}).merge(cast)
|
70
|
+
rows = r.cast_values(overrides)
|
71
|
+
# One column is special :( ;(
|
72
|
+
# > ActiveRecord::Base.connection.select_all("select array[1,2]").rows
|
73
|
+
# => [["{1,2}"]]
|
74
|
+
# > ActiveRecord::Base.connection.select_all("select array[1,2]").cast_values
|
75
|
+
# => [[1, 2]]
|
76
|
+
rows = rows.map { [_1] } if r.columns.one?
|
77
|
+
new(r.columns, rows, overrides, cast: true)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
empty_array = [].freeze
|
83
|
+
EMPTY_HASH = {}.freeze
|
84
|
+
private_constant :EMPTY_HASH
|
85
|
+
|
86
|
+
EMPTY = new(empty_array, empty_array, EMPTY_HASH).freeze
|
87
|
+
private_constant :EMPTY
|
88
|
+
end
|
89
|
+
|
90
|
+
class Q
|
91
|
+
attr_reader :name, :sql
|
92
|
+
|
93
|
+
def initialize(sql, name: nil)
|
94
|
+
@sql = sql
|
95
|
+
@name = name
|
96
|
+
end
|
97
|
+
|
98
|
+
def select_all(binds: [], select: nil, cast: false)
|
99
|
+
with_select(select).then do |aq|
|
100
|
+
ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
|
101
|
+
Result.from_ar_result(result, cast)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def select_one(binds: [], select: nil, cast: false)
|
107
|
+
select_all(binds:, select:, cast:).first || {}
|
108
|
+
end
|
109
|
+
|
110
|
+
def select_value(binds: [], select: nil, cast: false)
|
111
|
+
select_one(binds:, select:, cast:).values.first
|
112
|
+
end
|
113
|
+
|
114
|
+
def tokens
|
115
|
+
@tokens ||= tokenizer.run
|
116
|
+
end
|
117
|
+
|
118
|
+
def tokenizer
|
119
|
+
@tokenizer ||= Tokenizer.new(to_s)
|
120
|
+
end
|
121
|
+
|
122
|
+
def cte_names
|
123
|
+
tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
|
124
|
+
end
|
125
|
+
|
126
|
+
def with_select(sql)
|
127
|
+
return self unless sql
|
128
|
+
if cte_names.include?("_")
|
129
|
+
self.class.new(tokens.each_with_object([]) do |token, acc|
|
130
|
+
v = (token[:t] == "SELECT") ? sql : token[:v]
|
131
|
+
acc << v
|
132
|
+
end.join, name: name)
|
133
|
+
else
|
134
|
+
append_cte("_ as (\n #{select}\n)").with_select(sql)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def select
|
139
|
+
tokens.find { _1[:t] == "SELECT" }&.[](:v)
|
140
|
+
end
|
141
|
+
|
142
|
+
def recursive?
|
143
|
+
!!tokens.find { _1[:t] == "RECURSIVE" }
|
144
|
+
end
|
145
|
+
|
146
|
+
# example:
|
147
|
+
# AppQuery("select 1").prepend_cte("foo as(select 1)")
|
148
|
+
def prepend_cte(cte)
|
149
|
+
# early raise when cte is not valid sql
|
150
|
+
to_append = Tokenizer.tokenize(cte, state: :lex_prepend_cte).then do |tokens|
|
151
|
+
recursive? ? tokens.reject { _1[:t] == "RECURSIVE" } : tokens
|
152
|
+
end
|
153
|
+
|
154
|
+
if cte_names.none?
|
155
|
+
self.class.new("WITH #{cte}\n#{self}")
|
156
|
+
else
|
157
|
+
split_at_type = recursive? ? "RECURSIVE" : "WITH"
|
158
|
+
self.class.new(tokens.map do |token|
|
159
|
+
if token[:t] == split_at_type
|
160
|
+
token[:v] + to_append.map { _1[:v] }.join
|
161
|
+
else
|
162
|
+
token[:v]
|
163
|
+
end
|
164
|
+
end.join)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# example:
|
169
|
+
# AppQuery("select 1").append_cte("foo as(select 1)")
|
170
|
+
def append_cte(cte)
|
171
|
+
# early raise when cte is not valid sql
|
172
|
+
add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_append_cte).then do |tokens|
|
173
|
+
[!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
|
174
|
+
tokens.reject { _1[:t] == "RECURSIVE" }]
|
175
|
+
end
|
176
|
+
|
177
|
+
if cte_names.none?
|
178
|
+
self.class.new("WITH #{cte}\n#{self}")
|
179
|
+
else
|
180
|
+
nof_ctes = cte_names.size
|
181
|
+
|
182
|
+
self.class.new(tokens.map do |token|
|
183
|
+
nof_ctes -= 1 if token[:t] == "CTE_SELECT"
|
184
|
+
|
185
|
+
if nof_ctes.zero?
|
186
|
+
nof_ctes -= 1
|
187
|
+
token[:v] + to_append.map { _1[:v] }.join
|
188
|
+
elsif token[:t] == "WITH" && add_recursive
|
189
|
+
token[:v] + add_recursive[:v]
|
190
|
+
else
|
191
|
+
token[:v]
|
192
|
+
end
|
193
|
+
end.join)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
# Replaces an existing cte.
|
198
|
+
# Raises `ArgumentError` when cte does not exist.
|
199
|
+
def replace_cte(cte)
|
200
|
+
add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_recursive_cte).then do |tokens|
|
201
|
+
[!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
|
202
|
+
tokens.reject { _1[:t] == "RECURSIVE" }]
|
203
|
+
end
|
204
|
+
|
205
|
+
cte_name = to_append.find { _1[:t] == "CTE_IDENTIFIER" }&.[](:v)
|
206
|
+
unless cte_names.include?(cte_name)
|
207
|
+
raise ArgumentError, "Unknown cte #{cte_name.inspect}. Options: #{cte_names}."
|
208
|
+
end
|
209
|
+
cte_ix = cte_names.index(cte_name)
|
210
|
+
|
211
|
+
return self unless cte_ix
|
212
|
+
|
213
|
+
cte_found = false
|
214
|
+
|
215
|
+
self.class.new(tokens.map do |token|
|
216
|
+
if cte_found ||= token[:t] == "CTE_IDENTIFIER" && token[:v] == cte_name
|
217
|
+
unless (cte_found = (token[:t] != "CTE_SELECT"))
|
218
|
+
next to_append.map { _1[:v] }.join
|
219
|
+
end
|
220
|
+
|
221
|
+
next
|
222
|
+
elsif token[:t] == "WITH" && add_recursive
|
223
|
+
token[:v] + add_recursive[:v]
|
224
|
+
else
|
225
|
+
token[:v]
|
226
|
+
end
|
227
|
+
end.join)
|
228
|
+
end
|
229
|
+
|
230
|
+
def to_s
|
231
|
+
@sql
|
232
|
+
end
|
233
|
+
end
|
8
234
|
end
|
235
|
+
|
236
|
+
def AppQuery(...)
|
237
|
+
AppQuery::Q.new(...)
|
238
|
+
end
|
239
|
+
|
240
|
+
begin
|
241
|
+
require "rspec"
|
242
|
+
rescue LoadError
|
243
|
+
end
|
244
|
+
|
245
|
+
require_relative "app_query/rspec" if Object.const_defined? :RSpec
|
data/lib/appquery.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "app_query"
|
@@ -0,0 +1,10 @@
|
|
1
|
+
Description:
|
2
|
+
Generates a new query-file and invokes your template
|
3
|
+
engine and test framework generators.
|
4
|
+
|
5
|
+
Example:
|
6
|
+
`bin/rails generate query reports/weekly`
|
7
|
+
|
8
|
+
creates an SQL file and test:
|
9
|
+
Query: app/queries/reports/weekly.sql
|
10
|
+
Spec: spec/queries/reports/weekly_query_spec.rb
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Rails
|
2
|
+
module Generators
|
3
|
+
class QueryGenerator < NamedBase
|
4
|
+
source_root File.expand_path("templates", __dir__)
|
5
|
+
|
6
|
+
def create_query_file
|
7
|
+
template "query.sql",
|
8
|
+
File.join(AppQuery.configuration.query_path, class_path, "#{file_name}.sql")
|
9
|
+
|
10
|
+
# in_root do
|
11
|
+
# if behavior == :invoke && !File.exist?(application_mailbox_file_name)
|
12
|
+
# template "application_mailbox.rb", application_mailbox_file_name
|
13
|
+
# end
|
14
|
+
# end
|
15
|
+
end
|
16
|
+
|
17
|
+
hook_for :test_framework
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
-- Instantiate this query with AppQuery[<%= (class_path << file_name).join("/").inspect %>]
|
2
|
+
|
3
|
+
WITH
|
4
|
+
articles(article_id, article_title) AS (
|
5
|
+
VALUES (1, 'Some title'),
|
6
|
+
(2, 'Another article')
|
7
|
+
),
|
8
|
+
authors(author_id, author_name) AS (
|
9
|
+
VALUES (1, 'Some Author'),
|
10
|
+
(2, 'Another Author')
|
11
|
+
)
|
12
|
+
|
13
|
+
SELECT *
|
14
|
+
FROM artciles
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Rspec
|
2
|
+
module Generators
|
3
|
+
class QueryGenerator < ::Rails::Generators::NamedBase
|
4
|
+
source_root File.expand_path("templates", __dir__)
|
5
|
+
|
6
|
+
def create_test_file
|
7
|
+
template "query_spec.rb",
|
8
|
+
File.join("spec/queries", class_path, "#{file_name}_query_spec.rb")
|
9
|
+
end
|
10
|
+
|
11
|
+
hide!
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def query_path
|
16
|
+
AppQuery.configuration.query_path
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails_helper"
|
4
|
+
|
5
|
+
RSpec.describe "AppQuery <%= (class_path << file_name).join("/") %>", type: :query, default_binds: nil do
|
6
|
+
describe "CTE articles" do
|
7
|
+
specify do
|
8
|
+
expect(select_all(select: "select * from :cte").cast_entries).to \
|
9
|
+
include(a_hash_including("article_id" => 1))
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
data/sig/appquery.rbs
CHANGED
data/tmp/.gitkeep
ADDED
File without changes
|