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.
@@ -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