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.
data/lib/app_query.rb CHANGED
@@ -1,8 +1,350 @@
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
+ class UnrenderedQueryError < StandardError; end
11
+
12
+ Configuration = Struct.new(:query_path)
13
+
14
+ def self.configuration
15
+ @configuration ||= AppQuery::Configuration.new
16
+ end
17
+
18
+ def self.configure
19
+ yield configuration if block_given?
20
+ end
21
+
22
+ def self.reset_configuration!
23
+ configure do |config|
24
+ config.query_path = "app/queries"
25
+ end
26
+ end
27
+ reset_configuration!
28
+
29
+ # Examples:
30
+ # AppQuery[:invoices] # looks for invoices.sql
31
+ # AppQuery["reports/weekly"]
32
+ # AppQuery["invoices.sql.erb"]
33
+ def self.[](query_name, **opts)
34
+ filename = File.extname(query_name.to_s).empty? ? "#{query_name}.sql" : query_name.to_s
35
+ full_path = (Pathname.new(configuration.query_path) / filename).expand_path
36
+ Q.new(full_path.read, name: "AppQuery #{query_name}", filename: full_path.to_s, **opts)
37
+ end
38
+
39
+ class Result < ActiveRecord::Result
40
+ attr_accessor :cast
41
+ alias_method :cast?, :cast
42
+
43
+ def initialize(columns, rows, overrides = nil, cast: false)
44
+ super(columns, rows, overrides)
45
+ @cast = cast
46
+ # Rails v6.1: prevent mutate on frozen object on #first
47
+ @hash_rows = [] if columns.empty?
48
+ end
49
+
50
+ def column(name = nil)
51
+ return [] if empty?
52
+ unless name.nil? || includes_column?(name)
53
+ raise ArgumentError, "Unknown column #{name.inspect}. Should be one of #{columns.inspect}."
54
+ end
55
+ ix = name.nil? ? 0 : columns.index(name)
56
+ rows.map { _1[ix] }
57
+ end
58
+
59
+ def size
60
+ count
61
+ end
62
+
63
+ def self.from_ar_result(r, cast = nil)
64
+ if r.empty?
65
+ EMPTY
66
+ else
67
+ cast &&= case cast
68
+ when Array
69
+ r.columns.zip(cast).to_h
70
+ when Hash
71
+ cast
72
+ else
73
+ {}
74
+ end
75
+ if !cast || (cast.empty? && r.column_types.empty?)
76
+ # nothing to cast
77
+ new(r.columns, r.rows, r.column_types)
78
+ else
79
+ overrides = (r.column_types || {}).merge(cast)
80
+ rows = r.cast_values(overrides)
81
+ # One column is special :( ;(
82
+ # > ActiveRecord::Base.connection.select_all("select array[1,2]").rows
83
+ # => [["{1,2}"]]
84
+ # > ActiveRecord::Base.connection.select_all("select array[1,2]").cast_values
85
+ # => [[1, 2]]
86
+ rows = rows.zip if r.columns.one?
87
+ new(r.columns, rows, overrides, cast: true)
88
+ end
89
+ end
90
+ end
91
+
92
+ empty_array = [].freeze
93
+ EMPTY_HASH = {}.freeze
94
+ private_constant :EMPTY_HASH
95
+
96
+ EMPTY = new(empty_array, empty_array, EMPTY_HASH).freeze
97
+ private_constant :EMPTY
98
+ end
99
+
100
+ class Q
101
+ attr_reader :name, :sql, :binds, :cast
102
+
103
+ def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
104
+ @sql = sql
105
+ @name = name
106
+ @filename = filename
107
+ @binds = binds
108
+ @cast = cast
109
+ end
110
+
111
+ def deep_dup
112
+ super.send(:reset!)
113
+ end
114
+
115
+ def reset!
116
+ (instance_variables - %i[@sql @filename @name @binds @cast]).each do
117
+ instance_variable_set(_1, nil)
118
+ end
119
+ self
120
+ end
121
+ private :reset!
122
+
123
+ def render(vars)
124
+ vars ||= {}
125
+ with_sql(to_erb.result(render_helper(vars).get_binding))
126
+ end
127
+
128
+ def to_erb
129
+ ERB.new(sql, trim_mode: "-").tap { _1.location = [@filename, 0] if @filename }
130
+ end
131
+ private :to_erb
132
+
133
+ def render_helper(vars)
134
+ Module.new do
135
+ extend self
136
+
137
+ vars.each do |k, v|
138
+ define_method(k) { v }
139
+ instance_variable_set(:"@#{k}", v)
140
+ end
141
+
142
+ # Examples
143
+ # <%= order_by({year: :desc, month: :desc}) %>
144
+ # #=> ORDER BY year DESC, month DESC
145
+ #
146
+ # Using variable:
147
+ # <%= order_by(ordering) %>
148
+ # NOTE Raises when ordering not provided or when blank.
149
+ #
150
+ # Make it optional:
151
+ # <%= @ordering.presence && order_by(ordering) %>
152
+ #
153
+ def order_by(hash)
154
+ raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
155
+ "ORDER BY " + hash.map do |k, v|
156
+ v.nil? ? k : [k, v.upcase].join(" ")
157
+ end.join(", ")
158
+ end
159
+
160
+ def get_binding
161
+ binding
162
+ end
163
+ end
164
+ end
165
+ private :render_helper
166
+
167
+ def select_all(binds: [], select: nil, cast: self.cast)
168
+ binds = binds.presence || @binds
169
+ with_select(select).render({}).then do |aq|
170
+ if binds.is_a?(Hash)
171
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
172
+ Arel.sql(aq.to_s, **binds)
173
+ else
174
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
175
+ end
176
+ ActiveRecord::Base.connection.select_all(sql, name).then do |result|
177
+ Result.from_ar_result(result, cast)
178
+ end
179
+ else
180
+ ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
181
+ Result.from_ar_result(result, cast)
182
+ end
183
+ end
184
+ end
185
+ rescue NameError => e
186
+ # Prevent any subclasses, e.g. NoMethodError
187
+ raise e unless e.instance_of?(NameError)
188
+ raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
189
+ end
190
+
191
+ def select_one(binds: [], select: nil, cast: self.cast)
192
+ select_all(binds:, select:, cast:).first
193
+ end
194
+
195
+ def select_value(binds: [], select: nil, cast: self.cast)
196
+ select_one(binds:, select:, cast:)&.values&.first
197
+ end
198
+
199
+ def tokens
200
+ @tokens ||= tokenizer.run
201
+ end
202
+
203
+ def tokenizer
204
+ @tokenizer ||= Tokenizer.new(to_s)
205
+ end
206
+
207
+ def cte_names
208
+ tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
209
+ end
210
+
211
+ def with_binds(binds)
212
+ deep_dup.tap do
213
+ _1.instance_variable_set(:@binds, binds)
214
+ end
215
+ end
216
+
217
+ def with_cast(cast)
218
+ deep_dup.tap do
219
+ _1.instance_variable_set(:@cast, cast)
220
+ end
221
+ end
222
+
223
+ def with_sql(sql)
224
+ deep_dup.tap do
225
+ _1.instance_variable_set(:@sql, sql)
226
+ end
227
+ end
228
+
229
+ def with_select(sql)
230
+ return self if sql.nil?
231
+ if cte_names.include?("_")
232
+ with_sql(tokens.each_with_object([]) do |token, acc|
233
+ v = (token[:t] == "SELECT") ? sql : token[:v]
234
+ acc << v
235
+ end.join)
236
+ else
237
+ append_cte("_ as (\n #{select}\n)").with_select(sql)
238
+ end
239
+ end
240
+
241
+ def select
242
+ tokens.find { _1[:t] == "SELECT" }&.[](:v)
243
+ end
244
+
245
+ def recursive?
246
+ !!tokens.find { _1[:t] == "RECURSIVE" }
247
+ end
248
+
249
+ # example:
250
+ # AppQuery("select 1").prepend_cte("foo as(select 1)")
251
+ def prepend_cte(cte)
252
+ # early raise when cte is not valid sql
253
+ to_append = Tokenizer.tokenize(cte, state: :lex_prepend_cte).then do |tokens|
254
+ recursive? ? tokens.reject { _1[:t] == "RECURSIVE" } : tokens
255
+ end
256
+
257
+ if cte_names.none?
258
+ with_sql("WITH #{cte}\n#{self}")
259
+ else
260
+ split_at_type = recursive? ? "RECURSIVE" : "WITH"
261
+ with_sql(tokens.map do |token|
262
+ if token[:t] == split_at_type
263
+ token[:v] + to_append.map { _1[:v] }.join
264
+ else
265
+ token[:v]
266
+ end
267
+ end.join)
268
+ end
269
+ end
270
+
271
+ # example:
272
+ # AppQuery("select 1").append_cte("foo as(select 1)")
273
+ def append_cte(cte)
274
+ # early raise when cte is not valid sql
275
+ add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_append_cte).then do |tokens|
276
+ [!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
277
+ tokens.reject { _1[:t] == "RECURSIVE" }]
278
+ end
279
+
280
+ if cte_names.none?
281
+ with_sql("WITH #{cte}\n#{self}")
282
+ else
283
+ nof_ctes = cte_names.size
284
+
285
+ with_sql(tokens.map do |token|
286
+ nof_ctes -= 1 if token[:t] == "CTE_SELECT"
287
+
288
+ if nof_ctes.zero?
289
+ nof_ctes -= 1
290
+ token[:v] + to_append.map { _1[:v] }.join
291
+ elsif token[:t] == "WITH" && add_recursive
292
+ token[:v] + add_recursive[:v]
293
+ else
294
+ token[:v]
295
+ end
296
+ end.join)
297
+ end
298
+ end
299
+
300
+ # Replaces an existing cte.
301
+ # Raises `ArgumentError` when cte does not exist.
302
+ def replace_cte(cte)
303
+ add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_recursive_cte).then do |tokens|
304
+ [!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
305
+ tokens.reject { _1[:t] == "RECURSIVE" }]
306
+ end
307
+
308
+ cte_name = to_append.find { _1[:t] == "CTE_IDENTIFIER" }&.[](:v)
309
+ unless cte_names.include?(cte_name)
310
+ raise ArgumentError, "Unknown cte #{cte_name.inspect}. Options: #{cte_names}."
311
+ end
312
+ cte_ix = cte_names.index(cte_name)
313
+
314
+ return self unless cte_ix
315
+
316
+ cte_found = false
317
+
318
+ with_sql(tokens.map do |token|
319
+ if cte_found ||= token[:t] == "CTE_IDENTIFIER" && token[:v] == cte_name
320
+ unless (cte_found = (token[:t] != "CTE_SELECT"))
321
+ next to_append.map { _1[:v] }.join
322
+ end
323
+
324
+ next
325
+ elsif token[:t] == "WITH" && add_recursive
326
+ token[:v] + add_recursive[:v]
327
+ else
328
+ token[:v]
329
+ end
330
+ end.join)
331
+ end
332
+
333
+ def to_s
334
+ @sql
335
+ end
336
+ end
8
337
  end
338
+
339
+ def AppQuery(...)
340
+ AppQuery::Q.new(...)
341
+ end
342
+
343
+ begin
344
+ require "rspec"
345
+ rescue LoadError
346
+ end
347
+
348
+ require_relative "app_query/rspec" if Object.const_defined? :RSpec
349
+
350
+ require "app_query/base" if defined?(ActiveRecord::Base)
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
@@ -0,0 +1,5 @@
1
+ [env]
2
+ # used for tests
3
+ PG_DATABASE_URL="postgres://localhost:5432/some_db
4
+ # used from console
5
+ DATABASE_URL="postgres://localhost:5432/some_db
data/mise.toml ADDED
@@ -0,0 +1,6 @@
1
+ [tools]
2
+ ruby = "3.4"
3
+
4
+ [env]
5
+ _.path = ["bin"]
6
+ PROJECT_ROOT = "{{config_root}}"
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
metadata CHANGED
@@ -1,40 +1,66 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appquery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-14 00:00:00.000000000 Z
12
- dependencies: []
13
- description: |
14
- A query like `AppQuery[:some_query]` is read from app/queries/some_query.sql.
15
-
16
- Querying a CTE used in this query:
17
- `query.replace_select("select * from some_cte").select_all`
18
-
19
- Query the end-result:
20
- `query.as_cte(select: "select COUNT(*) from app_query").select_all`
21
-
22
- Spec-helpers and generators included.
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: appraisal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: "Improving introspection and testability of raw SQL queries in Rails\nThis
27
+ gem improves introspection and testability of raw SQL queries in Rails by:\n- ...providing
28
+ a separate query-folder and easy instantiation \n A query like `AppQuery[:some_query]`
29
+ is read from app/queries/some_query.sql.\n\n- ...providing options for rewriting
30
+ a query:\n\n Query a CTE by replacing the select:\n query.select_all(select: \"select
31
+ * from some_cte\").entries\n\n ...similarly, query the end result (i.e. CTE `_`):\n
32
+ \ query.select_all(select: \"select count(*) from _\").entries\n\n- ...providing
33
+ (custom) casting: \n AppQuery(\"select array[1,2]\").select_value(cast: true)\n\n
34
+ \ custom deserializers:\n AppQuery(\"select '1' id\").select_all(cast: {\"id\"
35
+ => ActiveRecord::Type::Integer.new}).entries\n\n- ...providing spec-helpers and
36
+ generators\n"
23
37
  email:
24
38
  - gert@thinkcreate.dk
25
39
  executables: []
26
40
  extensions: []
27
41
  extra_rdoc_files: []
28
42
  files:
29
- - ".envrc"
30
43
  - ".rspec"
31
44
  - ".standard.yml"
45
+ - Appraisals
32
46
  - CHANGELOG.md
33
47
  - LICENSE.txt
34
48
  - README.md
35
49
  - Rakefile
36
50
  - lib/app_query.rb
51
+ - lib/app_query/base.rb
52
+ - lib/app_query/rspec.rb
53
+ - lib/app_query/rspec/helpers.rb
54
+ - lib/app_query/tokenizer.rb
37
55
  - lib/app_query/version.rb
56
+ - lib/appquery.rb
57
+ - lib/rails/generators/query/USAGE
58
+ - lib/rails/generators/query/query_generator.rb
59
+ - lib/rails/generators/query/templates/query.sql.tt
60
+ - lib/rails/generators/rspec/query_generator.rb
61
+ - lib/rails/generators/rspec/templates/query_spec.rb.tt
62
+ - mise.local.toml.example
63
+ - mise.toml
38
64
  - sig/appquery.rbs
39
65
  homepage: https://github.com/eval/appquery
40
66
  licenses:
@@ -43,7 +69,6 @@ metadata:
43
69
  homepage_uri: https://github.com/eval/appquery
44
70
  source_code_uri: https://github.com/eval/appquery
45
71
  changelog_uri: https://github.com/eval/gem-try/blob/main/CHANGELOG.md
46
- post_install_message:
47
72
  rdoc_options: []
48
73
  require_paths:
49
74
  - lib
@@ -51,16 +76,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
51
76
  requirements:
52
77
  - - ">="
53
78
  - !ruby/object:Gem::Version
54
- version: 3.0.0
79
+ version: 3.2.0
55
80
  required_rubygems_version: !ruby/object:Gem::Requirement
56
81
  requirements:
57
82
  - - ">="
58
83
  - !ruby/object:Gem::Version
59
84
  version: '0'
60
85
  requirements: []
61
- rubygems_version: 3.5.16
62
- signing_key:
86
+ rubygems_version: 3.6.7
63
87
  specification_version: 4
64
- summary: Make working with raw SQL queries convenient by improving their introspection
65
- and testability.
88
+ summary: "raw SQL \U0001F966, cooked \U0001F372 or: make working with raw SQL queries
89
+ in Rails convenient by improving their introspection and testability."
66
90
  test_files: []
data/.envrc DELETED
@@ -1 +0,0 @@
1
- PATH_add bin