appquery 0.2.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: c63ba7b9e16d2f5b7071f8dd9203695f3c46b956d0302ff27a5cab0970cc598f
4
- data.tar.gz: 71bebdc43197fce0e8917a3e6f4be0615bcb28215ef004b2496ce0554241c54d
3
+ metadata.gz: 585ecafd973b1dd9fc8c944b93c64998a8b983438250cbd1ec81317d9e2362d4
4
+ data.tar.gz: 8197dc68853e7299266a0f8c2dd7fa102bbbf7e743680786bce4c7c2eedcd719
5
5
  SHA512:
6
- metadata.gz: 4f372fb1b1e524e05a2cdf5338d1114f76e08df3b2ec2028e9ec42b515dcbfe4c4c6d9d318a367351e884cfc3ed8e0c670f215d2ccdb072753271fa248993eed
7
- data.tar.gz: d91262c52aac1d7cb753eab122c2fbf7d352bd160f5112e3139cf1b5bda848e05456644d3e8e37b2c038ba0795fcfe707470c5bb903cc05e0d0d81a30725de80
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 ADDED
@@ -0,0 +1,25 @@
1
+ appraise "rails-70" do
2
+ gem "rails", "~> 7.0.0"
3
+ gem "sqlite3", "~> 1.4"
4
+ end
5
+
6
+ appraise "rails-71" do
7
+ gem "rails", "~> 7.1.0"
8
+ end
9
+
10
+ appraise "rails-72" do
11
+ gem "rails", "~> 7.2.0"
12
+ end
13
+
14
+ appraise "rails-80" do
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"
25
+ end
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2024 Gert Goet
3
+ Copyright (c) 2025 Gert Goet
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/appquery.svg)](https://badge.fury.io/rb/appquery)
4
4
 
5
- A Rubygem :gem: that makes working with raw SQL queries in Rails projects more convenient.
5
+ A Rubygem :gem: that makes working with raw SQL (READ) queries in Rails projects more convenient.
6
6
  Specifically it provides:
7
7
  - **...a dedicated folder for queries**
8
8
  e.g. `app/queries/reports/weekly.sql` is instantiated via `AppQuery["reports/weekly"]`.
@@ -13,6 +13,31 @@ Specifically it provides:
13
13
  invoke rspec
14
14
  create spec/queries/reports/weekly_query_spec.rb
15
15
  ```
16
+ - **...ERB templating**
17
+ Simple ERB templating with helper-functions:
18
+ ```sql
19
+ -- app/queries/contracts.sql.erb
20
+ SELECT * FROM contracts
21
+ <%= order_by(order) %>
22
+ ```
23
+ ```ruby
24
+ AppQuery["contracts.sql.erb"].render(order: {year: :desc, month: :desc}).select_all
25
+ ```
26
+ - **...positional and named binds**
27
+ Intuitive binds:
28
+ ```ruby
29
+ AppQuery(%{select now() - (:interval)::interval as some_date}).select_value(binds: {interval: '1 day'})
30
+ AppQuery(<<~SQL).select_all(binds: [2.day.ago, Time.now, '5 minutes']).column("series")
31
+ select generate_series($1::timestamp, $2::timestamp, $3::interval) as series
32
+ SQL
33
+ ```
34
+ - **...casting**
35
+ Automatic and custom casting:
36
+ ```ruby
37
+ AppQuery(%{select array[1,2]}).select_value #=> [1,2]
38
+ cast = {"data" => ActiveRecord::Type::Json.new}
39
+ AppQuery(%{select '{"a": 1}' as data}).select_value(cast:)
40
+ ```
16
41
  - **...helpers to rewrite a query for introspection during development and testing**
17
42
  See what a CTE yields: `query.select_all(select: "SELECT * FROM some_cte")`.
18
43
  Query the end result: `query.select_one(select: "SELECT COUNT(*) FROM _ WHERE ...")`.
@@ -38,7 +63,12 @@ Specifically it provides:
38
63
 
39
64
  > [!IMPORTANT]
40
65
  > **Status**: alpha. API might change. See the CHANGELOG for breaking changes when upgrading.
41
- >
66
+ >
67
+
68
+ ## Rationale
69
+
70
+ Sometimes ActiveRecord doesn't cut it, and you'd rather use raw SQL to get the right data out. That, however, introduces some new problems. First of all, you'll run into the not-so-intuitive use of [select_(all|one|value)](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-select_all) — for example, how they differ with respect to type casting, and how their behavior can vary between ActiveRecord versions. Then there's the testability, introspection, and maintainability of the resulting SQL queries.
71
+ This library aims to alleviate all of these issues by providing a consistent interface across select_* methods and ActiveRecord versions. It should make inspecting and testing queries easier—especially when they're built from CTEs.
42
72
 
43
73
  ## Installation
44
74
 
@@ -53,7 +83,88 @@ bundle add appquery
53
83
  > [!NOTE]
54
84
  > The following (trivial) examples are not meant to convince you to ditch your ORM, but just to show how this gem handles raw SQL queries.
55
85
 
56
- ### Create
86
+ ### ...from console
87
+
88
+ Testdriving can be easily done from the console. Either by cloning this repository (recommended, see `Development`-section) or installing the gem in an existing Rails project.
89
+ <details>
90
+ <summary>Database setup (the `bin/console`-script does this for your)</summary>
91
+
92
+ ```ruby
93
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
94
+ ActiveRecord::Base.establish_connection(url: 'postgres://localhost:5432/some_db')
95
+ ```
96
+ </details>
97
+
98
+ The prompt indicates what adapter the example uses:
99
+
100
+ ```ruby
101
+ # showing select_(all|one|value)
102
+ [postgresql]> AppQuery(%{select date('now') as today}).select_all.to_a
103
+ => [{"today" => "2025-05-10"}]
104
+ [postgresql]> AppQuery(%{select date('now') as today}).select_one
105
+ => {"today" => "2025-05-10"}
106
+ [postgresql]> AppQuery(%{select date('now') as today}).select_value
107
+ => "2025-05-10"
108
+
109
+ # binds
110
+ # positional binds
111
+ [postgresql]> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
112
+ # named binds
113
+ [postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
114
+
115
+ # casting
116
+ [postgresql]> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
117
+ => [{"today" => Sat, 10 May 2025}]
118
+
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)
121
+ => {"today" => "2025-05-12"}
122
+ ## Providing per-column-casts fixes this:
123
+ casts = {"today" => ActiveRecord::Type::Date.new}
124
+ [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: casts)
125
+ => {"today" => Mon, 12 May 2025}
126
+
127
+
128
+ # rewriting queries (using CTEs)
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) %>)
136
+ select * from articles order by id DESC
137
+ SQL
138
+
139
+ ## query the articles-CTE
140
+ [postgresql]> q.select_all(select: %{select * from articles where id < 2}).to_a
141
+
142
+ ## query the end-result (available as the CTE named '_')
143
+ [postgresql]> q.select_one(select: %{select * from _ limit 1})
144
+
145
+ ## ERB templating
146
+ # Extract a query from q that can be sorted dynamically:
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
+
150
+ # shows latest articles first, and titles sorted alphabetically
151
+ # for articles published on the same date.
152
+ # order_by raises when it's passed something that would result in just `ORDER BY`:
153
+ [postgresql]> q2.render(order: {})
154
+
155
+ # doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
156
+ [postgresql]> q2.select_all.entries
157
+
158
+ # NOTE you can use both `order` and `@order`: local variables like `order` are required,
159
+ # while instance variables like `@order` are optional.
160
+ # To skip the order-part when provided:
161
+ <%= @order.presence && order_by(order) %>
162
+ # or use a default when order-part is always wanted but not always provided:
163
+ <%= order_by(@order || {id: :desc}) %>
164
+ ```
165
+
166
+
167
+ ### ...in a Rails project
57
168
 
58
169
  > [!NOTE]
59
170
  > The included [example Rails app](./examples/ror) contains all data and queries described below.
@@ -143,7 +254,8 @@ AppQuery[:recent_articles].select_all.entries
143
254
  # we can provide a different cut off date via binds^1:
144
255
  AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
145
256
 
146
- 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).
147
259
  For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
148
260
  ```
149
261
 
@@ -461,18 +573,33 @@ query.replace_cte("recent_articles as (select values(1, 'Some article'))")
461
573
  ## Compatibility
462
574
 
463
575
  - 💾 tested with **SQLite** and **PostgreSQL**
464
- - 🚆 tested with Rails **v6.1**, **v7** and **v8.0**
465
- - 💎 requires Ruby **>v3.1**
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**
466
578
  Goal is to support [maintained Ruby versions](https://www.ruby-lang.org/en/downloads/branches/).
467
579
 
468
580
  ## Development
469
581
 
470
582
  After checking out the repo, run `bin/setup` to install dependencies. **Make sure to check it exits with status code 0.**
471
583
 
472
- Using [direnv](https://direnv.net/) for env-vars recommended.
584
+ Using [mise](https://mise.jdx.dev/) for env-vars recommended.
585
+
586
+ ### console
587
+
588
+ The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
589
+ ```bash
590
+ $ bin/console sqlite3::memory:
591
+ $ bin/console postgres://localhost:5432/some_db
592
+
593
+ # more details
594
+ $ bin/console -h
473
595
 
596
+ # when needing an appraisal, use bin/run (this ensures signals are handled correctly):
597
+ $ bin/run rails_head console
598
+ ```
474
599
 
475
- Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
600
+ ### various
601
+
602
+ Run `rake spec` to run the tests.
476
603
 
477
604
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
478
605
 
@@ -483,4 +610,3 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/eval/a
483
610
  ## License
484
611
 
485
612
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
486
-
@@ -0,0 +1,45 @@
1
+ module AppQuery
2
+ class Base
3
+ class_attribute :_cast, default: true, instance_predicate: false
4
+ class_attribute :_default_binds, default: {}, instance_predicate: false
5
+
6
+ class << self
7
+ def run(build_only: false, binds: {}, vars: {}, cast: self.cast, select: nil, **)
8
+ _build(binds:, vars:, cast:, select:).then do
9
+ build_only ? _1 : _1.select_all
10
+ end
11
+ end
12
+
13
+ def build(**opts)
14
+ run(build_only: true, **opts)
15
+ end
16
+
17
+ def default_binds(v = nil)
18
+ return _default_binds if v.nil?
19
+ self._default_binds = v
20
+ end
21
+
22
+ def cast(v = nil)
23
+ return _cast if v.nil?
24
+ self._cast = v
25
+ end
26
+
27
+ def query_name
28
+ derive_query_name unless defined?(@query_name)
29
+ @query_name
30
+ end
31
+
32
+ attr_writer :query_name
33
+
34
+ private
35
+
36
+ def _build(cast:, binds: {}, select: nil, vars: {})
37
+ AppQuery[query_name, binds:, cast:].render(vars).with_select(select)
38
+ end
39
+
40
+ def derive_query_name
41
+ self.query_name = name.underscore.sub(/_query$/, "")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0.rc1"
5
5
  end
data/lib/app_query.rb CHANGED
@@ -7,6 +7,8 @@ require "active_record"
7
7
  module AppQuery
8
8
  class Error < StandardError; end
9
9
 
10
+ class UnrenderedQueryError < StandardError; end
11
+
10
12
  Configuration = Struct.new(:query_path)
11
13
 
12
14
  def self.configuration
@@ -24,10 +26,14 @@ module AppQuery
24
26
  end
25
27
  reset_configuration!
26
28
 
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}")
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)
31
37
  end
32
38
 
33
39
  class Result < ActiveRecord::Result
@@ -50,6 +56,10 @@ module AppQuery
50
56
  rows.map { _1[ix] }
51
57
  end
52
58
 
59
+ def size
60
+ count
61
+ end
62
+
53
63
  def self.from_ar_result(r, cast = nil)
54
64
  if r.empty?
55
65
  EMPTY
@@ -73,7 +83,7 @@ module AppQuery
73
83
  # => [["{1,2}"]]
74
84
  # > ActiveRecord::Base.connection.select_all("select array[1,2]").cast_values
75
85
  # => [[1, 2]]
76
- rows = rows.map { [_1] } if r.columns.one?
86
+ rows = rows.zip if r.columns.one?
77
87
  new(r.columns, rows, overrides, cast: true)
78
88
  end
79
89
  end
@@ -88,27 +98,282 @@ module AppQuery
88
98
  end
89
99
 
90
100
  class Q
91
- attr_reader :name, :sql
101
+ attr_reader :name, :sql, :binds, :cast
92
102
 
93
- def initialize(sql, name: nil)
103
+ def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
94
104
  @sql = sql
95
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
+ 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
135
+ end
136
+
137
+ def to_erb
138
+ ERB.new(sql, trim_mode: "-").tap { _1.location = [@filename, 0] if @filename }
139
+ end
140
+ private :to_erb
141
+
142
+ def render_helper(vars)
143
+ Module.new do
144
+ extend self
145
+
146
+ @collected_binds = {}
147
+ @placeholder_counter = 0
148
+
149
+ vars.each do |k, v|
150
+ define_method(k) { v }
151
+ instance_variable_set(:"@#{k}", v)
152
+ end
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
+
224
+ # Examples
225
+ # <%= order_by({year: :desc, month: :desc}) %>
226
+ # #=> ORDER BY year DESC, month DESC
227
+ #
228
+ # Using variable:
229
+ # <%= order_by(ordering) %>
230
+ # NOTE Raises when ordering not provided or when blank.
231
+ #
232
+ # Make it optional:
233
+ # <%= @ordering.presence && order_by(ordering) %>
234
+ #
235
+ def order_by(hash)
236
+ raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
237
+ "ORDER BY " + hash.map do |k, v|
238
+ v.nil? ? k : [k, v.upcase].join(" ")
239
+ end.join(", ")
240
+ end
241
+
242
+ def get_binding
243
+ binding
244
+ end
245
+ end
246
+ end
247
+ private :render_helper
248
+
249
+ # TODO: have aliases for common casts: select_all(cast: {"today" => :date})
250
+ def select_all(binds: nil, select: nil, cast: self.cast)
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."
96
284
  end
97
285
 
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)
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|
313
+ if binds.is_a?(Hash)
314
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
315
+ Arel.sql(aq.to_s, **binds)
316
+ else
317
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
318
+ end
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)
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:)
327
+ else
328
+ ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds)
102
329
  end
103
330
  end
331
+ rescue NameError => e
332
+ # Prevent any subclasses, e.g. NoMethodError
333
+ raise e unless e.instance_of?(NameError)
334
+ raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
104
335
  end
105
336
 
106
- def select_one(binds: [], select: nil, cast: false)
107
- 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."
108
356
  end
109
357
 
110
- def select_value(binds: [], select: nil, cast: false)
111
- 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."
112
377
  end
113
378
 
114
379
  def tokens
@@ -123,13 +388,31 @@ module AppQuery
123
388
  tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
124
389
  end
125
390
 
391
+ def with_binds(binds)
392
+ deep_dup.tap do
393
+ _1.instance_variable_set(:@binds, binds)
394
+ end
395
+ end
396
+
397
+ def with_cast(cast)
398
+ deep_dup.tap do
399
+ _1.instance_variable_set(:@cast, cast)
400
+ end
401
+ end
402
+
403
+ def with_sql(sql)
404
+ deep_dup.tap do
405
+ _1.instance_variable_set(:@sql, sql)
406
+ end
407
+ end
408
+
126
409
  def with_select(sql)
127
- return self unless sql
410
+ return self if sql.nil?
128
411
  if cte_names.include?("_")
129
- self.class.new(tokens.each_with_object([]) do |token, acc|
412
+ with_sql(tokens.each_with_object([]) do |token, acc|
130
413
  v = (token[:t] == "SELECT") ? sql : token[:v]
131
414
  acc << v
132
- end.join, name: name)
415
+ end.join)
133
416
  else
134
417
  append_cte("_ as (\n #{select}\n)").with_select(sql)
135
418
  end
@@ -152,10 +435,10 @@ module AppQuery
152
435
  end
153
436
 
154
437
  if cte_names.none?
155
- self.class.new("WITH #{cte}\n#{self}")
438
+ with_sql("WITH #{cte}\n#{self}")
156
439
  else
157
440
  split_at_type = recursive? ? "RECURSIVE" : "WITH"
158
- self.class.new(tokens.map do |token|
441
+ with_sql(tokens.map do |token|
159
442
  if token[:t] == split_at_type
160
443
  token[:v] + to_append.map { _1[:v] }.join
161
444
  else
@@ -175,11 +458,11 @@ module AppQuery
175
458
  end
176
459
 
177
460
  if cte_names.none?
178
- self.class.new("WITH #{cte}\n#{self}")
461
+ with_sql("WITH #{cte}\n#{self}")
179
462
  else
180
463
  nof_ctes = cte_names.size
181
464
 
182
- self.class.new(tokens.map do |token|
465
+ with_sql(tokens.map do |token|
183
466
  nof_ctes -= 1 if token[:t] == "CTE_SELECT"
184
467
 
185
468
  if nof_ctes.zero?
@@ -212,7 +495,7 @@ module AppQuery
212
495
 
213
496
  cte_found = false
214
497
 
215
- self.class.new(tokens.map do |token|
498
+ with_sql(tokens.map do |token|
216
499
  if cte_found ||= token[:t] == "CTE_IDENTIFIER" && token[:v] == cte_name
217
500
  unless (cte_found = (token[:t] != "CTE_SELECT"))
218
501
  next to_append.map { _1[:v] }.join
@@ -243,3 +526,5 @@ rescue LoadError
243
526
  end
244
527
 
245
528
  require_relative "app_query/rspec" if Object.const_defined? :RSpec
529
+
530
+ require "app_query/base" if defined?(ActiveRecord::Base)
@@ -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/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,15 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appquery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-11-13 00:00:00.000000000 Z
12
- dependencies: []
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'
13
26
  description: "Improving introspection and testability of raw SQL queries in Rails\nThis
14
27
  gem improves introspection and testability of raw SQL queries in Rails by:\n- ...providing
15
28
  a separate query-folder and easy instantiation \n A query like `AppQuery[:some_query]`
@@ -27,15 +40,16 @@ executables: []
27
40
  extensions: []
28
41
  extra_rdoc_files: []
29
42
  files:
30
- - ".envrc"
31
- - ".envrc.private.example"
43
+ - ".irbrc"
32
44
  - ".rspec"
33
45
  - ".standard.yml"
46
+ - Appraisals
34
47
  - CHANGELOG.md
35
48
  - LICENSE.txt
36
49
  - README.md
37
50
  - Rakefile
38
51
  - lib/app_query.rb
52
+ - lib/app_query/base.rb
39
53
  - lib/app_query/rspec.rb
40
54
  - lib/app_query/rspec/helpers.rb
41
55
  - lib/app_query/tokenizer.rb
@@ -46,8 +60,10 @@ files:
46
60
  - lib/rails/generators/query/templates/query.sql.tt
47
61
  - lib/rails/generators/rspec/query_generator.rb
48
62
  - lib/rails/generators/rspec/templates/query_spec.rb.tt
63
+ - mise.local.toml.example
64
+ - mise.toml
65
+ - rakelib/gem.rake
49
66
  - sig/appquery.rbs
50
- - tmp/.gitkeep
51
67
  homepage: https://github.com/eval/appquery
52
68
  licenses:
53
69
  - MIT
@@ -55,7 +71,6 @@ metadata:
55
71
  homepage_uri: https://github.com/eval/appquery
56
72
  source_code_uri: https://github.com/eval/appquery
57
73
  changelog_uri: https://github.com/eval/gem-try/blob/main/CHANGELOG.md
58
- post_install_message:
59
74
  rdoc_options: []
60
75
  require_paths:
61
76
  - lib
@@ -63,15 +78,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
63
78
  requirements:
64
79
  - - ">="
65
80
  - !ruby/object:Gem::Version
66
- version: 3.1.0
81
+ version: 3.2.0
67
82
  required_rubygems_version: !ruby/object:Gem::Requirement
68
83
  requirements:
69
84
  - - ">="
70
85
  - !ruby/object:Gem::Version
71
86
  version: '0'
72
87
  requirements: []
73
- rubygems_version: 3.5.22
74
- signing_key:
88
+ rubygems_version: 3.6.9
75
89
  specification_version: 4
76
90
  summary: "raw SQL \U0001F966, cooked \U0001F372 or: make working with raw SQL queries
77
91
  in Rails convenient by improving their introspection and testability."
data/.envrc DELETED
@@ -1,6 +0,0 @@
1
- # direnv config
2
- PATH_add bin
3
-
4
- export APP_ROOT=$(pwd)
5
-
6
- source_env_if_exists .envrc.private
@@ -1,2 +0,0 @@
1
- # copy this to .envrc.private to use with direnv
2
- export PG_DATABASE_URL=postgres://localhost:5432/some_db
data/tmp/.gitkeep DELETED
File without changes