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.
@@ -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,5 @@
1
+ require_relative "rspec/helpers"
2
+
3
+ RSpec.configure do |config|
4
+ config.include AppQuery::RSpec::Helpers, type: :query
5
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end