appquery 0.3.0 → 0.4.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64e74167bbafa7217db5f9c0bab6efc8b55655abbbcb7e2fccefca3dfe1afae8
4
- data.tar.gz: 16f6870106206b547ed307fb6cbcd8d9250610239ccf9dc046e6e6c9719d76dd
3
+ metadata.gz: 585ecafd973b1dd9fc8c944b93c64998a8b983438250cbd1ec81317d9e2362d4
4
+ data.tar.gz: 8197dc68853e7299266a0f8c2dd7fa102bbbf7e743680786bce4c7c2eedcd719
5
5
  SHA512:
6
- metadata.gz: 74ce3c5a8d22b2b41bc477069e9720c7b91cad52909ee3e4db8533c0a6f357227ec7e9485f513b8306a0d4994b53556971ae09284c72c2c5370bee45a0c244e3
7
- data.tar.gz: 61b01b05753f3a6f27194e47b40bb91aa822c5c263dbe78d0328f1b09aad02d95acbfc7ba191d4fb773b536c1b1ad03465e05c87c4ecf1dc279ed3e461ba97b2
6
+ metadata.gz: 37b5baf0f42dbad9ee1f356e0541b5677cb93a934d38ad851931142f79a041337f77cbbd4f56c1e880b4db9140460237afa8627eb0f6f9c73461fc1f6b3843cb
7
+ data.tar.gz: 3edc3e28e09a648a3d14634beab10cdde1be8d1b31c6c8163fd2f19844fab31a5ecf762ddcef9311f18d1356fe6e2c765f99dd2d08706284c687e94d21c36261
data/.irbrc ADDED
@@ -0,0 +1,20 @@
1
+ puts "Loading #{__FILE__}"
2
+ # put overrides/additions in '_irbrc'
3
+
4
+ IRB.conf[:HISTORY_FILE] = "#{ENV["PROJECT_ROOT"]}/tmp/.irb_history"
5
+
6
+ # Custom IRB prompt showing database adapter
7
+ db_indicator = begin
8
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
9
+ "\e[33m[#{adapter}]\e[0m "
10
+ rescue ActiveRecord::ConnectionNotEstablished, NameError
11
+ "\e[34m[no-db]\e[0m "
12
+ end
13
+
14
+ IRB.conf[:PROMPT][:APPQUERY] = {
15
+ PROMPT_I: "#{db_indicator}appquery> ",
16
+ PROMPT_S: "#{db_indicator}appquery%l ",
17
+ PROMPT_C: "#{db_indicator}appquery* ",
18
+ RETURN: "=> %s\n"
19
+ }
20
+ IRB.conf[:PROMPT_MODE] = :APPQUERY
data/Appraisals CHANGED
@@ -1,15 +1,25 @@
1
1
  appraise "rails-70" do
2
- gem "rails", "~> 7.0"
2
+ gem "rails", "~> 7.0.0"
3
+ gem "sqlite3", "~> 1.4"
3
4
  end
4
5
 
5
6
  appraise "rails-71" do
6
- gem "rails", "~> 7.1"
7
+ gem "rails", "~> 7.1.0"
7
8
  end
8
9
 
9
10
  appraise "rails-72" do
10
- gem "rails", "~> 7.2"
11
+ gem "rails", "~> 7.2.0"
11
12
  end
12
13
 
13
14
  appraise "rails-80" do
14
- gem "rails", "~> 8.0"
15
+ gem "rails", "~> 8.0.0"
16
+ end
17
+
18
+ appraise "rails-81" do
19
+ gem "rails", "~> 8.1.0"
20
+ end
21
+
22
+ appraise "rails-head" do
23
+ gem "activerecord", github: "rails/rails"
24
+ gem "rails", github: "rails/rails"
15
25
  end
data/README.md CHANGED
@@ -95,60 +95,65 @@ Testdriving can be easily done from the console. Either by cloning this reposito
95
95
  ```
96
96
  </details>
97
97
 
98
- The following examples assume PostgreSQL (SQLite where stated):
98
+ The prompt indicates what adapter the example uses:
99
99
 
100
100
  ```ruby
101
101
  # showing select_(all|one|value)
102
- > AppQuery(%{select date('now') as today}).select_all.to_a
102
+ [postgresql]> AppQuery(%{select date('now') as today}).select_all.to_a
103
103
  => [{"today" => "2025-05-10"}]
104
- > AppQuery(%{select date('now') as today}).select_one
104
+ [postgresql]> AppQuery(%{select date('now') as today}).select_one
105
105
  => {"today" => "2025-05-10"}
106
- > AppQuery(%{select date('now') as today}).select_value
106
+ [postgresql]> AppQuery(%{select date('now') as today}).select_value
107
107
  => "2025-05-10"
108
108
 
109
109
  # binds
110
110
  # positional binds
111
- > AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
111
+ [postgresql]> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
112
112
  # named binds
113
- > AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
113
+ [postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
114
114
 
115
115
  # casting
116
- > AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
116
+ [postgresql]> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
117
117
  => [{"today" => Sat, 10 May 2025}]
118
118
 
119
119
  ## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
120
- sqlite> AppQuery(%{select date('now') as today}).select_one(cast: true)
120
+ [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
121
121
  => {"today" => "2025-05-12"}
122
122
  ## Providing per-column-casts fixes this:
123
123
  casts = {"today" => ActiveRecord::Type::Date.new}
124
- sqlite> AppQuery(%{select date('now') as today}).select_one(cast: casts)
124
+ [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: casts)
125
125
  => {"today" => Mon, 12 May 2025}
126
126
 
127
+
127
128
  # rewriting queries (using CTEs)
128
- q = AppQuery(<<~SQL)
129
- WITH articles(id,title,published_on) AS (
130
- values(1, 'Some title', '2024-3-31'),
131
- (2, 'Other title', '2024-10-31'),
132
- (3, 'Same title?', '2024-3-31'))
129
+ [postgresql]> articles = [
130
+ [1, "Using my new static site generator", 2.months.ago.to_date],
131
+ [2, "Let's learn SQL", 1.month.ago.to_date],
132
+ [3, "Another article", 2.weeks.ago.to_date]
133
+ ]
134
+ [postgresql]> q = AppQuery(<<~SQL, cast: {"published_on" => ActiveRecord::Type::Date.new}).render(articles:)
135
+ WITH articles(id,title,published_on) AS (<%= values(articles) %>)
133
136
  select * from articles order by id DESC
134
137
  SQL
135
138
 
136
139
  ## query the articles-CTE
137
- q.select_all(select: %{select * from articles where id < 2}).to_a
140
+ [postgresql]> q.select_all(select: %{select * from articles where id < 2}).to_a
138
141
 
139
142
  ## query the end-result (available as the CTE named '_')
140
- q.select_one(select: %{select * from _ limit 1})
143
+ [postgresql]> q.select_one(select: %{select * from _ limit 1})
141
144
 
142
145
  ## ERB templating
143
146
  # Extract a query from q that can be sorted dynamically:
144
- q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
145
- q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
147
+ [postgresql]> q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
148
+ [postgresql]> q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
149
+
146
150
  # shows latest articles first, and titles sorted alphabetically
147
151
  # for articles published on the same date.
148
152
  # order_by raises when it's passed something that would result in just `ORDER BY`:
149
- q2.render(order: {})
153
+ [postgresql]> q2.render(order: {})
154
+
150
155
  # doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
151
- q2.select_all.entries
156
+ [postgresql]> q2.select_all.entries
152
157
 
153
158
  # NOTE you can use both `order` and `@order`: local variables like `order` are required,
154
159
  # while instance variables like `@order` are optional.
@@ -249,7 +254,8 @@ AppQuery[:recent_articles].select_all.entries
249
254
  # we can provide a different cut off date via binds^1:
250
255
  AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
251
256
 
252
- 1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for $1 and $2 (which our query can deal with).
257
+ 1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for
258
+ $1 and $2 (which our query can deal with).
253
259
  For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
254
260
  ```
255
261
 
@@ -567,8 +573,8 @@ query.replace_cte("recent_articles as (select values(1, 'Some article'))")
567
573
  ## Compatibility
568
574
 
569
575
  - 💾 tested with **SQLite** and **PostgreSQL**
570
- - 🚆 tested with Rails **v6.1**, **v7** and **v8.0**
571
- - 💎 requires Ruby **>v3.2**
576
+ - 🚆 tested with Rails v7.x and v8.x (might still work with v6.1, but is no longer included in the test-matrix)
577
+ - 💎 requires Ruby **>=v3.2**
572
578
  Goal is to support [maintained Ruby versions](https://www.ruby-lang.org/en/downloads/branches/).
573
579
 
574
580
  ## Development
@@ -581,11 +587,14 @@ Using [mise](https://mise.jdx.dev/) for env-vars recommended.
581
587
 
582
588
  The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
583
589
  ```bash
584
- $ ./bin/console sqlite3::memory:
585
- $ ./bin/console postgres://localhost:5432/some_db
590
+ $ bin/console sqlite3::memory:
591
+ $ bin/console postgres://localhost:5432/some_db
586
592
 
587
593
  # more details
588
- $ ./bin/console -h
594
+ $ bin/console -h
595
+
596
+ # when needing an appraisal, use bin/run (this ensures signals are handled correctly):
597
+ $ bin/run rails_head console
589
598
  ```
590
599
 
591
600
  ### various
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0.rc1"
5
5
  end
data/lib/app_query.rb CHANGED
@@ -122,7 +122,16 @@ module AppQuery
122
122
 
123
123
  def render(vars)
124
124
  vars ||= {}
125
- with_sql(to_erb.result(render_helper(vars).get_binding))
125
+ helper = render_helper(vars)
126
+ sql = to_erb.result(helper.get_binding)
127
+ collected = helper.collected_binds
128
+
129
+ with_sql(sql).tap do |q|
130
+ # Merge collected binds with existing binds (convert array to hash if needed)
131
+ existing = @binds.is_a?(Hash) ? @binds : {}
132
+ new_binds = existing.merge(collected)
133
+ q.instance_variable_set(:@binds, new_binds) if new_binds.any?
134
+ end
126
135
  end
127
136
 
128
137
  def to_erb
@@ -134,11 +143,84 @@ module AppQuery
134
143
  Module.new do
135
144
  extend self
136
145
 
146
+ @collected_binds = {}
147
+ @placeholder_counter = 0
148
+
137
149
  vars.each do |k, v|
138
150
  define_method(k) { v }
139
151
  instance_variable_set(:"@#{k}", v)
140
152
  end
141
153
 
154
+ def collect_bind(value)
155
+ @placeholder_counter += 1
156
+ key = :"b#{@placeholder_counter}"
157
+ @collected_binds[key] = value
158
+ ":#{key}"
159
+ end
160
+
161
+ attr_reader :collected_binds
162
+
163
+ # Examples
164
+ # quote("Let's learn Ruby") #=> 'Let''s learn Ruby'
165
+ def quote(...)
166
+ ActiveRecord::Base.connection.quote(...)
167
+ end
168
+
169
+ # Examples
170
+ # <%= bind(title) %> #=> :b1 (with title added to binds)
171
+ def bind(value)
172
+ collect_bind(value)
173
+ end
174
+
175
+ # Examples
176
+ # <%= values([[1, "Some video"], [2, "Another video"]]) %>
177
+ # #=> VALUES (:b1, :b2), (:b3, :b4) with binds {b1: 1, b2: "Some video", ...}
178
+ #
179
+ # <%= values([{id: 1, title: "Some video"}]) %>
180
+ # #=> (id, title) VALUES (:b1, :b2) with binds {b1: 1, b2: "Some video"}
181
+ #
182
+ # <%= values([{title: "A"}, {title: "B", published_on: "2024-01-01"}]) %>
183
+ # #=> (title, published_on) VALUES (:b1, NULL), (:b2, :b3)
184
+ #
185
+ # Skip column names (e.g. for UNION ALL or CTEs):
186
+ # with articles as(
187
+ # <%= values([[1, "title"]], skip_columns: true) %>
188
+ # )
189
+ # #=> with articles as (VALUES (:b1, :b2))
190
+ #
191
+ # With block (mix bind() and quote()):
192
+ # <%= values(videos) { |v| [bind(v[:id]), quote(v[:title]), 'now()'] } %>
193
+ # #=> VALUES (:b1, 'Some title', now()), (:b2, 'Other title', now())
194
+ def values(coll, skip_columns: false, &block)
195
+ first = coll.first
196
+
197
+ # For hash collections, collect all unique keys
198
+ if first.is_a?(Hash) && !block
199
+ all_keys = coll.flat_map(&:keys).uniq
200
+
201
+ rows = coll.map do |row|
202
+ vals = all_keys.map { |k| row.key?(k) ? collect_bind(row[k]) : "NULL" }
203
+ "(#{vals.join(", ")})"
204
+ end
205
+
206
+ columns = skip_columns ? "" : "(#{all_keys.join(", ")}) "
207
+ "#{columns}VALUES #{rows.join(",\n")}"
208
+ else
209
+ # Arrays or block - current behavior
210
+ rows = coll.map do |item|
211
+ vals = if block
212
+ block.call(item)
213
+ elsif item.is_a?(Array)
214
+ item.map { |v| collect_bind(v) }
215
+ else
216
+ [collect_bind(item)]
217
+ end
218
+ "(#{vals.join(", ")})"
219
+ end
220
+ "VALUES #{rows.join(",\n")}"
221
+ end
222
+ end
223
+
142
224
  # Examples
143
225
  # <%= order_by({year: :desc, month: :desc}) %>
144
226
  # #=> ORDER BY year DESC, month DESC
@@ -164,22 +246,86 @@ module AppQuery
164
246
  end
165
247
  private :render_helper
166
248
 
167
- def select_all(binds: [], select: nil, cast: self.cast)
168
- binds = binds.presence || @binds
249
+ # TODO: have aliases for common casts: select_all(cast: {"today" => :date})
250
+ def select_all(binds: nil, select: nil, cast: self.cast)
169
251
  with_select(select).render({}).then do |aq|
252
+ # Support both positional (array) and named (hash) binds
253
+ if binds.is_a?(Array)
254
+ if @binds.is_a?(Hash) && @binds.any?
255
+ raise ArgumentError, "Cannot use positional binds (Array) when query has collected named binds from values()/bind() helpers. Use named binds (Hash) instead."
256
+ end
257
+ # Positional binds using $1, $2, etc.
258
+ ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
259
+ Result.from_ar_result(result, cast)
260
+ end
261
+ else
262
+ # Named binds - merge collected binds with explicitly passed binds
263
+ merged_binds = (@binds.is_a?(Hash) ? @binds : {}).merge(binds || {})
264
+ if merged_binds.any?
265
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
266
+ Arel.sql(aq.to_s, **merged_binds)
267
+ else
268
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **merged_binds])
269
+ end
270
+ ActiveRecord::Base.connection.select_all(sql, name).then do |result|
271
+ Result.from_ar_result(result, cast)
272
+ end
273
+ else
274
+ ActiveRecord::Base.connection.select_all(aq.to_s, name).then do |result|
275
+ Result.from_ar_result(result, cast)
276
+ end
277
+ end
278
+ end
279
+ end
280
+ rescue NameError => e
281
+ # Prevent any subclasses, e.g. NoMethodError
282
+ raise e unless e.instance_of?(NameError)
283
+ raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
284
+ end
285
+
286
+ def select_one(binds: nil, select: nil, cast: self.cast)
287
+ select_all(binds:, select:, cast:).first
288
+ end
289
+
290
+ def select_value(binds: nil, select: nil, cast: self.cast)
291
+ select_one(binds:, select:, cast:)&.values&.first
292
+ end
293
+
294
+ # Examples
295
+ # AppQuery(<<~SQL).insert(binds: ["Let's learn SQL!"])
296
+ # INSERT INTO videos(title, created_at, updated_at) values($1, now(), now())
297
+ # SQL
298
+ #
299
+ # articles = [
300
+ # {title: "First article"}
301
+ # ].map { it.merge(created_at: Time.current)}
302
+ # AppQuery(<<~SQL).render(articles:)
303
+ # INSERT INTO articles(title, created_at) <%= values(articles) %>
304
+ # SQL
305
+ def insert(binds: [], returning: nil)
306
+ # ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
307
+ if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
308
+ raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}"
309
+ end
310
+
311
+ binds = binds.presence || @binds
312
+ render({}).then do |aq|
170
313
  if binds.is_a?(Hash)
171
314
  sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
172
315
  Arel.sql(aq.to_s, **binds)
173
316
  else
174
317
  ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
175
318
  end
176
- ActiveRecord::Base.connection.select_all(sql, name).then do |result|
177
- Result.from_ar_result(result, cast)
319
+ if ActiveRecord::VERSION::STRING.to_f >= 7.1
320
+ ActiveRecord::Base.connection.insert(sql, name, returning:)
321
+ else
322
+ ActiveRecord::Base.connection.insert(sql, name)
178
323
  end
324
+ elsif ActiveRecord::VERSION::STRING.to_f >= 7.1
325
+ # pk is the less flexible returning
326
+ ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning:)
179
327
  else
180
- ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
181
- Result.from_ar_result(result, cast)
182
- end
328
+ ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds)
183
329
  end
184
330
  end
185
331
  rescue NameError => e
@@ -188,12 +334,46 @@ module AppQuery
188
334
  raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
189
335
  end
190
336
 
191
- def select_one(binds: [], select: nil, cast: self.cast)
192
- select_all(binds:, select:, cast:).first
337
+ # Examples:
338
+ # AppQuery("UPDATE videos SET title = 'New' WHERE id = :id").update(binds: {id: 1})
339
+ def update(binds: [])
340
+ binds = binds.presence || @binds
341
+ render({}).then do |aq|
342
+ if binds.is_a?(Hash)
343
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
344
+ Arel.sql(aq.to_s, **binds)
345
+ else
346
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
347
+ end
348
+ ActiveRecord::Base.connection.update(sql, name)
349
+ else
350
+ ActiveRecord::Base.connection.update(aq.to_s, name, binds)
351
+ end
352
+ end
353
+ rescue NameError => e
354
+ raise e unless e.instance_of?(NameError)
355
+ raise UnrenderedQueryError, "Query is ERB. Use #render before updating."
193
356
  end
194
357
 
195
- def select_value(binds: [], select: nil, cast: self.cast)
196
- select_one(binds:, select:, cast:)&.values&.first
358
+ # Examples:
359
+ # AppQuery("DELETE FROM videos WHERE id = :id").delete(binds: {id: 1})
360
+ def delete(binds: [])
361
+ binds = binds.presence || @binds
362
+ render({}).then do |aq|
363
+ if binds.is_a?(Hash)
364
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
365
+ Arel.sql(aq.to_s, **binds)
366
+ else
367
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
368
+ end
369
+ ActiveRecord::Base.connection.delete(sql, name)
370
+ else
371
+ ActiveRecord::Base.connection.delete(aq.to_s, name, binds)
372
+ end
373
+ end
374
+ rescue NameError => e
375
+ raise e unless e.instance_of?(NameError)
376
+ raise UnrenderedQueryError, "Query is ERB. Use #render before deleting."
197
377
  end
198
378
 
199
379
  def tokens
data/rakelib/gem.rake ADDED
@@ -0,0 +1,25 @@
1
+ namespace :gem do
2
+ task "write_version", [:version] do |_task, args|
3
+ if args[:version]
4
+ version = args[:version].split("=").last
5
+ version_file = File.expand_path("../../lib/app_query/version.rb", __FILE__)
6
+
7
+ system(<<~CMD, exception: true)
8
+ ruby -pi -e 'gsub(/VERSION = ".*"/, %{VERSION = "#{version}"})' #{version_file}
9
+ CMD
10
+ Bundler.ui.confirm "Version #{version} written to #{version_file}."
11
+ else
12
+ Bundler.ui.warn "No version provided, keeping version.rb as is."
13
+ end
14
+ end
15
+
16
+ desc "Build [version]"
17
+ task "build", [:version] => %w[write_version] do
18
+ Rake::Task["build"].invoke
19
+ end
20
+
21
+ desc "Build and push [version] to rubygems"
22
+ task "release", [:version] => %w[build] do
23
+ Rake::Task["release:rubygem_push"].invoke
24
+ end
25
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appquery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
@@ -40,6 +40,7 @@ executables: []
40
40
  extensions: []
41
41
  extra_rdoc_files: []
42
42
  files:
43
+ - ".irbrc"
43
44
  - ".rspec"
44
45
  - ".standard.yml"
45
46
  - Appraisals
@@ -61,6 +62,7 @@ files:
61
62
  - lib/rails/generators/rspec/templates/query_spec.rb.tt
62
63
  - mise.local.toml.example
63
64
  - mise.toml
65
+ - rakelib/gem.rake
64
66
  - sig/appquery.rbs
65
67
  homepage: https://github.com/eval/appquery
66
68
  licenses:
@@ -83,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
85
  - !ruby/object:Gem::Version
84
86
  version: '0'
85
87
  requirements: []
86
- rubygems_version: 3.6.7
88
+ rubygems_version: 3.6.9
87
89
  specification_version: 4
88
90
  summary: "raw SQL \U0001F966, cooked \U0001F372 or: make working with raw SQL queries
89
91
  in Rails convenient by improving their introspection and testability."