appquery 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.2.0"
5
5
  end
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
- # Your code goes here...
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
@@ -1,4 +1,4 @@
1
- module Appquery
1
+ module AppQuery
2
2
  VERSION: String
3
3
  # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
4
  end
data/tmp/.gitkeep ADDED
File without changes