appquery 0.1.0 → 0.3.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/.standard.yml +3 -1
- data/Appraisals +15 -0
- data/LICENSE.txt +1 -1
- data/README.md +575 -11
- data/lib/app_query/base.rb +45 -0
- 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 +343 -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/mise.local.toml.example +5 -0
- data/mise.toml +6 -0
- data/sig/appquery.rbs +1 -1
- metadata +45 -21
- data/.envrc +0 -1
@@ -0,0 +1,90 @@
|
|
1
|
+
module AppQuery
|
2
|
+
module RSpec
|
3
|
+
module Helpers
|
4
|
+
def default_binds
|
5
|
+
self.class.default_binds
|
6
|
+
end
|
7
|
+
|
8
|
+
def expand_select(s)
|
9
|
+
s.gsub(":cte", cte_name)
|
10
|
+
end
|
11
|
+
|
12
|
+
def select_all(select: nil, binds: default_binds, **kws)
|
13
|
+
@query_result = described_query(select:).select_all(binds:, **kws)
|
14
|
+
end
|
15
|
+
|
16
|
+
def select_one(select: nil, binds: default_binds, **kws)
|
17
|
+
@query_result = described_query(select:).select_one(binds:, **kws)
|
18
|
+
end
|
19
|
+
|
20
|
+
def select_value(select: nil, binds: default_binds, **kws)
|
21
|
+
@query_result = described_query(select:).select_value(binds:, **kws)
|
22
|
+
end
|
23
|
+
|
24
|
+
def described_query(select: nil)
|
25
|
+
select ||= "SELECT * FROM :cte" if cte_name
|
26
|
+
select &&= expand_select(select) if cte_name
|
27
|
+
self.class.described_query.with_select(select)
|
28
|
+
end
|
29
|
+
|
30
|
+
def cte_name
|
31
|
+
self.class.cte_name
|
32
|
+
end
|
33
|
+
|
34
|
+
def query_name
|
35
|
+
self.class.query_name
|
36
|
+
end
|
37
|
+
|
38
|
+
def query_result
|
39
|
+
@query_result
|
40
|
+
end
|
41
|
+
|
42
|
+
module ClassMethods
|
43
|
+
def described_query
|
44
|
+
AppQuery[query_name]
|
45
|
+
end
|
46
|
+
|
47
|
+
def metadatas
|
48
|
+
scope = is_a?(Class) ? self : self.class
|
49
|
+
metahash = scope.metadata
|
50
|
+
result = []
|
51
|
+
loop do
|
52
|
+
result << metahash
|
53
|
+
metahash = metahash[:parent_example_group]
|
54
|
+
break unless metahash
|
55
|
+
end
|
56
|
+
result
|
57
|
+
end
|
58
|
+
|
59
|
+
def descriptions
|
60
|
+
metadatas.map { _1[:description] }
|
61
|
+
end
|
62
|
+
|
63
|
+
def query_name
|
64
|
+
descriptions.find { _1[/(app)?query\s/i] }&.then { _1.split.last }
|
65
|
+
end
|
66
|
+
|
67
|
+
def cte_name
|
68
|
+
descriptions.find { _1[/cte\s/i] }&.then { _1.split.last }
|
69
|
+
end
|
70
|
+
|
71
|
+
def default_binds
|
72
|
+
metadatas.find { _1[:default_binds] }&.[](:default_binds) || []
|
73
|
+
end
|
74
|
+
|
75
|
+
def included(klass)
|
76
|
+
super
|
77
|
+
# Inject classmethods into the group.
|
78
|
+
klass.extend(ClassMethods)
|
79
|
+
# If the describe block is aimed at string or resource/provider class
|
80
|
+
# then set the default subject to be the Chef run.
|
81
|
+
# if klass.described_class.nil? || klass.described_class.is_a?(Class) && (klass.described_class < Chef::Resource || klass.described_class < Chef::Provider)
|
82
|
+
# klass.subject { chef_run }
|
83
|
+
# end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
extend ClassMethods
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -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