appquery 0.8.0.rc3 → 0.9.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: 0dacb58820ea6f11ec37fa1e73f7aeb9eec507d92b4e302cd8134f2c768abeba
4
- data.tar.gz: d371f97ca509a6c9dde67f2aec9edc67a7690b00081a5d596003608ae4958748
3
+ metadata.gz: de70b2690d884b36f8d29f8a2e76c7edefba25ca9f9fcc9785e70389c35f3aa0
4
+ data.tar.gz: 3c81f2fb6dd4679a86a99df91485df234ccfd449c6de0cb5317c6ca8f8e5751b
5
5
  SHA512:
6
- metadata.gz: bd8b8d0c01fdf4e9434cfe85218d6b772d839b263ae7c91f667fa2eb7aaedba48825bcace882028ed80b6cda8ff241c0fbae4764ca1cc067d3f320d95c5e81b8
7
- data.tar.gz: 11ee7b651b2fa20e2f40935e674d190681159a2fecf716741ca6392fabde3b7ed2a3e1e1b29b86344215db58b80f9765ba98ea81ec93c3e31022e2859b96f7bc
6
+ metadata.gz: e2a0d5f7c2a320426cf292e4f5afdcbe8ca9d499eca85cb8139d7c44d0dc2498443fc70853bc274c6c912e9753ee7a176ad552d9e4f6fe0d0f1adc4d2ad00bba
7
+ data.tar.gz: 7a038760466d79804b06e64ba18d6161a2070fe728b2ec529a1dbd62c04729866b8af6def94f9dafcec7965d060710c46bbe3e874b6cb67df2e32413a4a62cc1
@@ -0,0 +1,40 @@
1
+ # Worktree configuration for bonchi.
2
+ # See https://github.com/eval/bonchi
3
+
4
+ # Minimum bonchi version required.
5
+ # min_version: 0.6.0
6
+
7
+ # Files to copy from the main worktree before setup.
8
+ copy:
9
+ - mise.toml
10
+ - mise.local.toml
11
+ - _irbrc
12
+ - bin/worktree-dev
13
+
14
+ # Files to symlink from the main worktree (useful for large directories).
15
+ # link:
16
+ # - node_modules
17
+
18
+ # Env var names to allocate unique ports for (from global pool).
19
+ # ports:
20
+ # - PORT
21
+
22
+ # Regex replacements in copied files. Env vars ($VAR) are expanded.
23
+ # Short form:
24
+ # replace:
25
+ # .env.local:
26
+ # - "^PORT=.*": "PORT=$PORT"
27
+ # Full form (with optional missing: warn, default: halt):
28
+ # replace:
29
+ # .env.local:
30
+ # - match: "^PORT=.*"
31
+ # with: "PORT=$PORT"
32
+ # missing: warn
33
+
34
+ # Commands to run before the setup command (port env vars are available).
35
+ pre_setup:
36
+ - mise trust
37
+ - bin/setup
38
+
39
+ # The setup command to run (default: bin/setup).
40
+ setup: bin/worktree-dev
data/.yardopts CHANGED
@@ -5,7 +5,7 @@
5
5
  --no-private
6
6
  --output-dir doc
7
7
  --exclude tmp/
8
- --exclude spec/
8
+ --exclude ^spec/
9
9
  --exclude examples/
10
10
  --exclude gemfiles/
11
11
  lib/**/*.rb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### 💥 Breaking Changes
4
+
5
+ - ⚠️ **`AppQuery::Mappable` extension API changed.**
6
+ Row-level middleware now appends a transformer to the underlying `Q`'s `row_builder` pipeline via an overridden `#query`, instead of overriding `select_all`/`select_one`. The previous pattern of overriding those two methods will silently do nothing on row-returning paths it didn't cover (`entries`, `first`, `last`, `take`, `with_select(non_nil).first`, …). Any custom middleware that overrode `select_all`/`select_one` should migrate to:
7
+ ```ruby
8
+ def query
9
+ @query ||= super.tap { |q| q.row_builder << method(:build_row) }
10
+ end
11
+ ```
12
+ - ⚠️ **`Q#column` now raises `ArgumentError` for unknown columns.**
13
+ Previously, on SQLite, `q.column(:typo)` silently returned a row per record containing the *string* `"typo"` (the SQLite "double-quoted strings are identifiers OR string literals" quirk masked the missing column). It now pre-validates against `column_names` and raises with the available column list — consistently across SQLite and PostgreSQL.
14
+
15
+ ### ✨ Features
16
+
17
+ - 🧩 **`AppQuery::RowBuilder`** — composable pipeline of row transformers exposed as `Q#row_builder`. Append with `q.row_builder << callable`; transformers run in registration order. Multiple row-level middlewares stack cleanly in `include` order. The pipeline is applied everywhere `Q` exposes rows (`entries`, `first`, `last`, `take`, `take_last`, `with_select(...).first`, …) and is independently copied across `deep_dup` so chained queries don't mutate their parent.
18
+ - 🎯 **`Mappable` is now one method.** Maps everywhere — including `entries`, `last`, `take(n)`, `with_select("…").first` paths that previously slipped through. `raw` bypass still works.
19
+ - 🐛 **`Q#column` typo protection** — see breaking-change note above.
20
+ - 🐛 **Comments inside CTE selects** no longer break tokenization; the whole `(SELECT … -- foo … )` is preserved as a single `CTE_SELECT` token.
21
+ - Publishing gem requires MFA
22
+
23
+ ## 0.8.0
24
+
25
+ **Releasedate**: 14-1-2026
26
+ **Rubygems**: https://rubygems.org/gems/appquery/versions/0.8.0
27
+
28
+ ### 💥 Breaking Changes
29
+
30
+ - ⚠️ **RSpec helpers refactored**
31
+ Query under test is expected to be a class, `select_*` are no longer separate helpers:
32
+ ```ruby
33
+ expect(described_query.first).to \
34
+ include("id" => be_a(Integer), ...)
35
+ expect(described_query.entries).to include(a_hash_including("item_code" => "123456"))
36
+ ```
37
+
38
+ ### ✨ Features
39
+
40
+ - 📤 **`copy_to`** — efficient PostgreSQL COPY export to CSV/text/binary
41
+ ```ruby
42
+ # Return as string
43
+ csv = AppQuery[:users].copy_to
44
+
45
+ # Write to file
46
+ AppQuery[:users].copy_to(dest: "export.csv")
47
+
48
+ # Stream to IO (e.g., Rails response)
49
+ query.copy_to(dest: response.stream)
50
+ ```
51
+
52
+ - 🎯 **`cte(:name)`** — focus a query on a specific CTE for testing or inspection
53
+ ```ruby
54
+ query = AppQuery("WITH active AS (...), admins AS (...) SELECT ...")
55
+ query.cte(:active).entries # select from the active CTE
56
+ query.cte(:admins).count # count rows in admins CTE
57
+ ```
58
+
59
+ - 🗃️ **`AppQuery.table(:name)`** — quick query from a table
60
+ ```ruby
61
+ AppQuery.table(:products).count
62
+ AppQuery.table(:users).take(5)
63
+ ```
64
+
65
+ - 🔢 **`take(n)` / `take_last(n)`** — fetch first or last n rows
66
+ ```ruby
67
+ query.take(5) # first 5 rows
68
+ query.take_last(5) # last 5 rows
69
+ ```
70
+
71
+ - ⏮️ **`last`** — fetch the last row (counterpart to `first`)
72
+ ```ruby
73
+ query.last # => {"id" => 42, "name" => "Zoe"}
74
+ ```
75
+
76
+ - 📋 **`column_names`** — get column names without fetching rows
77
+ ```ruby
78
+ query.column_names # => ["id", "name", "email"]
79
+ ```
80
+
81
+ - 🦄 **`unique:` keyword for `Q#column`** — return distinct values
82
+ ```ruby
83
+ query.column(:status, unique: true) # => ["active", "pending"]
84
+ ```
85
+
86
+ - 🏗️ **Overhauled generators** — moved to `AppQuery::` namespace
87
+ ```bash
88
+ rails g app_query:example # annotated example query
89
+ rails g app_query:query Products
90
+ rails g query Products # hidden alias
91
+ rails g query --help # details
92
+ ```
93
+
3
94
  ## 0.7.0
4
95
 
5
96
  **Releasedate**: 8-1-2026
data/Procfile.yard ADDED
@@ -0,0 +1,4 @@
1
+ # YARD development server with auto-rebuild
2
+ # Usage: bin/yard-dev
3
+ web: ruby -run -ehttpd doc -p8809
4
+ watch: find lib README.md -name '*.rb' -o -name '*.md' | entr -s 'bundle exec yard doc'
data/README.md CHANGED
@@ -87,6 +87,8 @@ That introduces new problems: the not-so-intuitive `select_all`/`select_one`/`se
87
87
  - Easy inspection and testing—especially for CTE-based queries
88
88
  - Clean parameterization via named binds and ERB
89
89
 
90
+ Read [this blog post](https://www.gertgoet.com/appquery.html) for additional context and an overview.
91
+
90
92
  ## Installation
91
93
 
92
94
  ```bash
@@ -117,6 +119,31 @@ AppQuery[:weekly_sales].select_all(binds: {week: 1, year: 2025})
117
119
  #=> [{"week" => 1, "category" => "Electronics", "revenue" => 12500}, ...]
118
120
  ```
119
121
 
122
+ Even better:
123
+
124
+ Use the query-class and define binds, vars, casts, middleware etc.
125
+
126
+ ```ruby
127
+ class WeeklySalesQuery < ApplicationQuery
128
+ include AppQuery::Paginatable
129
+ per_page 25
130
+
131
+ bind :week
132
+ bind :year, default: 2026
133
+
134
+ cast metadata: :json
135
+
136
+ # add factory methods for specific purposes
137
+ def self.build(page: 1, week:, year: 2026)
138
+ new(week:, year:).paginate(page:)
139
+ end
140
+ end
141
+
142
+ WeeklySalesQuery.build(week: 1).entries
143
+ ```
144
+
145
+ Read more about the query-class in [the API docs](https://eval.github.io/appquery/AppQuery/BaseQuery.html).
146
+
120
147
  ## Usage
121
148
 
122
149
  > [!NOTE]
@@ -243,25 +270,135 @@ File.open("users.csv.gz", "wb") do |f|
243
270
  end
244
271
  ```
245
272
 
273
+ See [the method docs](https://eval.github.io/appquery/AppQuery/Q.html#copy_to-instance_method) for more (Rails) examples.
274
+
246
275
  ### RSpec Integration
247
276
 
248
277
  Generated spec files include helpers:
249
278
 
250
279
  ```ruby
251
280
  # spec/queries/reports/weekly_query_spec.rb
252
- RSpec.describe "AppQuery reports/weekly", type: :query, default_binds: [] do
281
+ RSpec.describe Reports::WeeklyQuery, type: :query, binds: {since: 3.weeks.ago} do
253
282
  describe "CTE articles" do
254
283
  specify do
255
- expect(described_query.select_all("SELECT * FROM :cte")).to \
284
+ expect(described_query.entries).to \
256
285
  include(a_hash_including("article_id" => 1))
257
-
258
- # Short version: query, cte and select are implied from descriptions
259
- expect(select_all).to include(a_hash_including("article_id" => 1))
260
286
  end
261
287
  end
262
288
  end
263
289
  ```
264
290
 
291
+ See [the API docs](https://eval.github.io/appquery/AppQuery/RSpec/Helpers.html) for more RSpec examples.
292
+
293
+ ### Writing a Middleware
294
+
295
+ A `BaseQuery` middleware is a `Module` you `include` into a query class. There are three layers to extend at, depending on *where* you want to act. The three compose cleanly on the same query class.
296
+
297
+ | You want to… | Layer | How |
298
+ |---|---|---|
299
+ | change/decorate each row | **row-level** | append to `q.row_builder` |
300
+ | filter, wrap, cap, paginate, cache the collection | **result-level** | override `#entries` (or `#first`, `#last`, …) |
301
+ | change the SQL/binds before it runs | **query-level** | override `#query`, return a different `Q` |
302
+
303
+ #### Row-level (the `row_builder` pipeline)
304
+
305
+ `Q#row_builder` is a composable pipeline of callables that each receive a row Hash and return whatever should replace it — a Hash, a `Data`, a Struct, your own model.
306
+
307
+ ```ruby
308
+ module Stamping
309
+ extend ActiveSupport::Concern
310
+
311
+ def query
312
+ @query ||= super.tap { |q| q.row_builder << ->(row) { row.merge("stamped_at" => Time.now) } }
313
+ end
314
+ end
315
+
316
+ class ArticlesQuery < ApplicationQuery
317
+ include Stamping
318
+ end
319
+
320
+ ArticlesQuery.new.first # => {"id" => 1, ..., "stamped_at" => 2026-...}
321
+ ArticlesQuery.new.entries.first # same — every row-returning path flows through row_builder
322
+ ```
323
+
324
+ The pipeline propagates through `with_select(non_nil)`, `add_binds`, `with_binds`, `with_cast`, `with_sql`, and CTE focusing (`#cte`), so chained calls keep the same mapping. Each child gets an independent copy — mutating it doesn't affect the parent.
325
+
326
+ **Stacking row-level middlewares.** Transformers run in **`include` order** — earliest `include` first, latest `include` last. With
327
+
328
+ ```ruby
329
+ class MyQuery < ApplicationQuery
330
+ include Stamping # runs first — its row goes into…
331
+ include AppQuery::Mappable # …which sees the stamped hash and builds an Item
332
+ end
333
+ ```
334
+
335
+ `Stamping`'s lambda runs first, then `Mappable.build_row` consumes the already-stamped hash. ⚠️ Once a transformer returns a non-Hash (e.g. a `Data`), downstream transformers see that object — so a hash-merging transformer placed *after* `Mappable` would fail. Order the chain accordingly.
336
+
337
+ **Doesn't fit row_builder:** filtering ("drop rows the viewer can't see") and collapsing rows. Both act on the collection, not a single row — use the result-level layer instead.
338
+
339
+ #### Result-level (wrap a row-returning method)
340
+
341
+ When you want to act on the whole collection — wrap, cap, cache, filter, paginate — override the row-returning method and call `super`. `super` returns rows that have *already* been through `row_builder`, so this composes with row-level middleware without thinking.
342
+
343
+ `Paginatable` is the canonical example (wraps `#entries` in a `PaginatedResult`). Some others:
344
+
345
+ ```ruby
346
+ module Caching
347
+ # memoise the whole result on the instance
348
+ def entries = @_entries ||= super
349
+ def first = @_first ||= super
350
+ end
351
+
352
+ module ScopedToTenant
353
+ def entries = super.select { |r| r["tenant_id"] == Current.tenant.id }
354
+ end
355
+
356
+ module Capped
357
+ CapResult = Data.define(:records, :hit_cap?) do
358
+ include Enumerable
359
+ def each(&b) = records.each(&b)
360
+ end
361
+
362
+ def entries
363
+ rows = super
364
+ CapResult.new(records: rows.first(self.class.cap), hit_cap?: rows.size > self.class.cap)
365
+ end
366
+ end
367
+ ```
368
+
369
+ #### Query-level (rewrite SQL/binds before execution)
370
+
371
+ When you want to change what the database actually sees — tenant scoping, soft-delete filtering, default ordering — override `#query` and return a transformed `Q`. Use `with_select` / `with_binds` / `add_binds` etc.; they propagate the `row_builder` pipeline via `deep_dup`, so row-level middleware keeps working.
372
+
373
+ ```ruby
374
+ module TenantScoped
375
+ def query
376
+ @query ||= super.add_binds(tenant_id: Current.tenant.id)
377
+ end
378
+ end
379
+
380
+ module HidesDeleted
381
+ def query
382
+ @query ||= super.with_select("SELECT * FROM :_ WHERE deleted_at IS NULL")
383
+ end
384
+ end
385
+ ```
386
+
387
+ #### Putting it together
388
+
389
+ All three layers can sit on one class:
390
+
391
+ ```ruby
392
+ class ArticlesQuery < ApplicationQuery
393
+ include HidesDeleted # query-level: rewrites SQL
394
+ include Stamping # row-level: adds "stamped_at"
395
+ include AppQuery::Mappable # row-level: builds Item (must come after row-mutating middleware)
396
+ include AppQuery::Paginatable # result-level: wraps entries
397
+ end
398
+ ```
399
+
400
+ Pipeline at run time, top to bottom: SQL is rewritten to filter `deleted_at IS NULL` → DB returns rows → each row gets `stamped_at` → each row becomes an `Item` → the array of Items is wrapped in a `PaginatedResult`.
401
+
265
402
  ## API Documentation
266
403
 
267
404
  See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
@@ -289,9 +426,13 @@ bin/run rails_head console
289
426
 
290
427
  # Run tests
291
428
  rake spec
429
+
430
+ # YARD with reload (requires entr and overmind/foreman)
431
+ bin/yard-dev
292
432
  ```
293
433
 
294
- Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
434
+ Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
435
+ Using [bonchi](https://rubygems.org/gems/bonchi) allows for agentic working via git worktrees. See `.worktree.yml.example` for a config.
295
436
 
296
437
  ### Releasing
297
438
 
@@ -154,7 +154,8 @@ module AppQuery
154
154
  end
155
155
  end
156
156
 
157
- delegate :cte, :entries, :with_select, :select_all, :select_one, :count, :to_s, :column, :first, :ids, :copy_to, to: :query
157
+ delegate :cte, :entries, :with_select, :select_all, :select_one, :count, :to_s,
158
+ :column, :first, :last, :take, :take_last, :ids, :copy_to, to: :query
158
159
 
159
160
  def query
160
161
  @query ||= base_query
@@ -66,31 +66,20 @@ module AppQuery
66
66
  self
67
67
  end
68
68
 
69
- def select_all
70
- map_result(super)
71
- end
72
-
73
- def select_one
74
- map_one(super)
69
+ # Append our transform to the underlying Q's RowBuilder pipeline so every
70
+ # row-returning path (entries, first, last, take, with_select(...).first,
71
+ # …) sees mapped rows. Stacks with other row-level middlewares in
72
+ # include-order — earlier `include`s run first.
73
+ def query
74
+ @query ||= super.tap { |q| q.row_builder << method(:build_row) }
75
75
  end
76
76
 
77
77
  private
78
78
 
79
- def map_result(result)
80
- return result if @raw
81
- return result unless (klass = resolve_map_klass)
82
-
83
- attrs = klass.members
84
- result.transform! { |row| klass.new(**row.symbolize_keys.slice(*attrs)) }
85
- end
86
-
87
- def map_one(result)
88
- return result if @raw
89
- return result unless (klass = resolve_map_klass)
90
- return result unless result
91
-
92
- attrs = klass.members
93
- klass.new(**result.symbolize_keys.slice(*attrs))
79
+ def build_row(row)
80
+ return row if @raw
81
+ return row unless (klass = resolve_map_klass)
82
+ klass.new(**row.symbolize_keys.slice(*klass.members))
94
83
  end
95
84
 
96
85
  def resolve_map_klass
@@ -31,7 +31,17 @@ module AppQuery
31
31
  # RSpec.describe ProductsQuery, type: :query do
32
32
  # describe "as admin", vars: {admin: true} do
33
33
  # it "returns all products" do
34
- # expect(described_query.entries.size).to eq(3)
34
+ # expect(described_query.count).to eq(3)
35
+ # end
36
+ # end
37
+ # end
38
+ #
39
+ # @example SQL logging for debugging
40
+ # RSpec.describe ProductsQuery, type: :query do
41
+ # describe "debugging", log: true do
42
+ # it "logs SQL to stdout" do
43
+ # # SQL queries will be printed to the console
44
+ # described_query.entries
35
45
  # end
36
46
  # end
37
47
  # end
@@ -1,6 +1,26 @@
1
- require_relative "rspec/helpers"
1
+ module AppQuery
2
+ # RSpec integration for testing query classes.
3
+ #
4
+ # Provides helpers for testing queries, including CTE isolation,
5
+ # bind/var metadata, and SQL logging.
6
+ #
7
+ # @example Setup in spec/rails_helper.rb
8
+ # require "app_query/rspec"
9
+ #
10
+ # @example Basic query spec
11
+ # RSpec.describe ProductsQuery, type: :query do
12
+ # it "returns products" do
13
+ # expect(described_query.entries).to be_present
14
+ # end
15
+ # end
16
+ #
17
+ # @see AppQuery::RSpec::Helpers
18
+ module RSpec
19
+ autoload :Helpers, "app_query/rspec/helpers"
20
+ end
21
+ end
2
22
 
3
- RSpec.configure do |config|
23
+ ::RSpec.configure do |config|
4
24
  config.include AppQuery::RSpec::Helpers, type: :query
5
25
 
6
26
  # Enable SQL logging with `log: true` metadata
@@ -257,9 +257,15 @@ module AppQuery
257
257
 
258
258
  level = 1
259
259
  loop do
260
- read_until(/\)|\(|'/)
260
+ read_until(%r{\)|\(|'|--|/\*})
261
261
  if eos?
262
262
  err "CTE select ended prematurely"
263
+ elsif match?("--")
264
+ read_until(/\n/)
265
+ elsif match?(%r{/\*})
266
+ read_until(%r{\*/})
267
+ err "CTE select ended prematurely" if eos?
268
+ read_char 2
263
269
  elsif match?(/'/)
264
270
  # Skip string literal (handle escaped quotes '')
265
271
  read_char
@@ -3,5 +3,5 @@
3
3
  module AppQuery
4
4
  # This should just contain the .dev of the upcoming version.
5
5
  # When doing the actual release, CI will write the tag here before pushing the gem.
6
- VERSION = "0.8.0.rc3"
6
+ VERSION = "0.9.0.rc1"
7
7
  end
data/lib/app_query.rb CHANGED
@@ -73,6 +73,26 @@ module AppQuery
73
73
  end
74
74
  reset_configuration!
75
75
 
76
+ # @!group Quoting Helpers
77
+
78
+ # Quotes a table name for safe use in SQL.
79
+ #
80
+ # @param name [String, Symbol] the table name
81
+ # @return [String] the quoted table name
82
+ def self.quote_table(name)
83
+ ActiveRecord::Base.connection.quote_table_name(name)
84
+ end
85
+
86
+ # Quotes a column name for safe use in SQL.
87
+ #
88
+ # @param name [String, Symbol] the column name
89
+ # @return [String] the quoted column name
90
+ def self.quote_column(name)
91
+ ActiveRecord::Base.connection.quote_column_name(name)
92
+ end
93
+
94
+ # @!endgroup
95
+
76
96
  # Loads a query from a file in the configured query path.
77
97
  #
78
98
  # When no extension is provided, tries `.sql` first, then `.sql.erb`.
@@ -132,23 +152,89 @@ module AppQuery
132
152
  # AppQuery.table(:users, binds: {active: true})
133
153
  # .select_all("SELECT * FROM :_ WHERE active = :active")
134
154
  def self.table(name, **opts)
135
- quoted = ActiveRecord::Base.connection.quote_table_name(name)
136
- Q.new("SELECT * FROM #{quoted}", name: "AppQuery.table(#{name})", **opts)
155
+ Q.new("SELECT * FROM #{quote_table(name)}", name: "AppQuery.table(#{name})", **opts)
156
+ end
157
+
158
+ # Composable pipeline of row transformers.
159
+ #
160
+ # Appended via `<<` and applied in registration order — earliest pushed
161
+ # runs first, latest pushed runs last (so its return value is what callers
162
+ # see). An empty pipeline is a no-op.
163
+ #
164
+ # @example
165
+ # rb = AppQuery::RowBuilder.new
166
+ # rb << ->(row) { row.merge("a" => 1) }
167
+ # rb << ->(row) { row.merge("b" => 2) }
168
+ # rb.call({}) # => {"a" => 1, "b" => 2}
169
+ class RowBuilder
170
+ def initialize(procs = [])
171
+ @procs = procs
172
+ end
173
+
174
+ # Append a transformer. Returns self so `q.row_builder << proc` reads
175
+ # naturally and `<<=` also works.
176
+ def <<(callable)
177
+ @procs << callable
178
+ self
179
+ end
180
+
181
+ def call(row)
182
+ @procs.reduce(row) { |acc, p| p.call(acc) }
183
+ end
184
+
185
+ def empty? = @procs.empty?
186
+
187
+ def size = @procs.size
188
+
189
+ # Independent copy — used by Q#deep_dup so chained queries don't share
190
+ # the parent's pipeline.
191
+ def dup
192
+ self.class.new(@procs.dup)
193
+ end
137
194
  end
138
195
 
139
196
  class Result < ActiveRecord::Result
140
- attr_accessor :cast
197
+ attr_accessor :cast, :row_builder
141
198
  alias_method :cast?, :cast
142
199
 
143
- def initialize(columns, rows, overrides = nil, cast: false)
200
+ def initialize(columns, rows, overrides = nil, cast: false, row_builder: nil)
144
201
  super(columns, rows, overrides)
145
202
  @cast = cast
203
+ @row_builder = row_builder
146
204
  # Rails v6.1: prevent mutate on frozen object on #first
147
205
  @hash_rows = [] if columns.empty?
148
206
  end
149
207
 
208
+ # AR::Result#first reads @hash_rows directly when not yet memoized,
209
+ # bypassing our hash_rows override. Force it through.
210
+ def first
211
+ hash_rows.first
212
+ end
213
+
214
+ # Returns an array of values for a single column.
215
+ #
216
+ # @note If you only need a single column, prefer {Q#column} which selects
217
+ # only that column from the database, avoiding fetching all columns.
218
+ #
219
+ # @param name [String, Symbol, nil] the column name (nil returns first column)
220
+ # @param unique [Boolean] whether to return only unique values
221
+ # @return [Array] the column values
222
+ # @raise [ArgumentError] if the column doesn't exist
223
+ #
224
+ # @example Get values by column name
225
+ # result.column(:name) # => ["Alice", "Bob"]
226
+ # result.column("name") # => ["Alice", "Bob"]
227
+ #
228
+ # @example Get first column (no name)
229
+ # result.column # => [1, 2, 3]
230
+ #
231
+ # @example Get unique values
232
+ # result.column(:status, unique: true) # => ["active", "pending"]
233
+ #
234
+ # @see Q#column
150
235
  def column(name = nil, unique: false)
151
236
  return [] if empty?
237
+ name = name&.to_s
152
238
  unless name.nil? || includes_column?(name)
153
239
  raise ArgumentError, "Unknown column #{name.inspect}. Should be one of #{columns.inspect}."
154
240
  end
@@ -163,9 +249,13 @@ module AppQuery
163
249
  private
164
250
 
165
251
  # Override to provide indifferent access (string or symbol keys).
252
+ # Applies the RowBuilder pipeline lazily so callers see built rows
253
+ # everywhere (first, last, each, entries, to_a, [] …). An empty/absent
254
+ # builder is a no-op.
166
255
  def hash_rows
167
256
  @hash_rows ||= rows.map do |row|
168
- columns.zip(row).to_h.with_indifferent_access
257
+ hash = columns.zip(row).to_h.with_indifferent_access
258
+ row_builder ? row_builder.call(hash) : hash
169
259
  end
170
260
  end
171
261
 
@@ -202,9 +292,9 @@ module AppQuery
202
292
  end
203
293
  end
204
294
 
205
- def self.from_ar_result(r, cast = nil)
295
+ def self.from_ar_result(r, cast = nil, row_builder: nil)
206
296
  if r.empty?
207
- r.columns.empty? ? EMPTY : new(r.columns, [], r.column_types)
297
+ r.columns.empty? ? EMPTY : new(r.columns, [], r.column_types, row_builder:)
208
298
  else
209
299
  cast &&= case cast
210
300
  when Array
@@ -216,7 +306,7 @@ module AppQuery
216
306
  end
217
307
  if !cast || (cast.empty? && r.column_types.empty?)
218
308
  # nothing to cast
219
- new(r.columns, r.rows, r.column_types)
309
+ new(r.columns, r.rows, r.column_types, row_builder:)
220
310
  else
221
311
  overrides = (r.column_types || {}).merge(cast)
222
312
  rows = r.cast_values(overrides)
@@ -226,7 +316,7 @@ module AppQuery
226
316
  # > ActiveRecord::Base.connection.select_all("select array[1,2]").cast_values
227
317
  # => [[1, 2]]
228
318
  rows = rows.zip if r.columns.one?
229
- new(r.columns, rows, overrides, cast: true)
319
+ new(r.columns, rows, overrides, cast: true, row_builder:)
230
320
  end
231
321
  end
232
322
  end
@@ -276,6 +366,15 @@ module AppQuery
276
366
  # @return [Boolean, Hash, Array] casting configuration
277
367
  attr_reader :sql, :name, :filename, :binds, :cast
278
368
 
369
+ # Middleware extension point. The {RowBuilder} is a composable pipeline:
370
+ # middlewares append transformers with `q.row_builder << ->(row) { … }`
371
+ # and the result is applied to every row everywhere Q exposes rows
372
+ # (entries, first, last, take, take_last, with_select(...).first, …).
373
+ # Propagated through {#deep_dup} (with an independent copy) so chained
374
+ # queries inherit the pipeline but don't mutate the parent's.
375
+ # @return [RowBuilder]
376
+ attr_accessor :row_builder
377
+
279
378
  # Creates a new query object.
280
379
  #
281
380
  # @param sql [String] the SQL query string (may contain ERB)
@@ -289,13 +388,14 @@ module AppQuery
289
388
  #
290
389
  # @example With ERB and binds
291
390
  # Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
292
- def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0)
391
+ def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0, row_builder: nil)
293
392
  @sql = sql
294
393
  @name = name
295
394
  @filename = filename
296
395
  @binds = binds
297
396
  @cast = cast
298
397
  @cte_depth = cte_depth
398
+ @row_builder = row_builder || RowBuilder.new
299
399
  @binds = binds_with_defaults(sql, binds)
300
400
  end
301
401
 
@@ -318,8 +418,8 @@ module AppQuery
318
418
  end
319
419
  end
320
420
 
321
- def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth)
322
- self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:)
421
+ def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth, row_builder: self.row_builder.dup)
422
+ self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:, row_builder:)
323
423
  end
324
424
 
325
425
  # @!group Rendering
@@ -427,7 +527,7 @@ module AppQuery
427
527
  ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
428
528
  end
429
529
  ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result|
430
- Result.from_ar_result(result, cast)
530
+ Result.from_ar_result(result, cast, row_builder: aq.row_builder)
431
531
  end
432
532
  end
433
533
  rescue NameError => e
@@ -451,7 +551,28 @@ module AppQuery
451
551
  def select_one(s = nil, binds: {}, cast: self.cast)
452
552
  with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first
453
553
  end
454
- alias_method :first, :select_one
554
+
555
+ def first(...) = select_one(...)
556
+
557
+ # Executes the query and returns the last row.
558
+ #
559
+ # Uses OFFSET to skip to the last row without changing the query order.
560
+ # Note: This requires counting all rows first, so it's less efficient
561
+ # than {#first} for large result sets.
562
+ #
563
+ # @param s [String, nil] optional SELECT to apply before fetching
564
+ # @param binds [Hash, nil] bind parameters to add
565
+ # @param cast [Boolean, Hash, Array] type casting configuration
566
+ # @return [Hash, nil] the last row as a hash, or nil if no results
567
+ #
568
+ # @example
569
+ # AppQuery("SELECT * FROM users ORDER BY created_at").last
570
+ # # => {"id" => 42, "name" => "Zoe"}
571
+ #
572
+ # @see #first
573
+ def last(s = nil, binds: {}, cast: self.cast)
574
+ take_last(1, s, binds:, cast:).first
575
+ end
455
576
 
456
577
  # Executes the query and returns the first n rows.
457
578
  #
@@ -471,6 +592,31 @@ module AppQuery
471
592
  end
472
593
  alias_method :limit, :take
473
594
 
595
+ # Executes the query and returns the last n rows.
596
+ #
597
+ # Uses OFFSET to skip to the last n rows without changing the query order.
598
+ # Note: This requires counting all rows first, so it's less efficient
599
+ # than {#take} for large result sets.
600
+ #
601
+ # @param n [Integer] the number of rows to return
602
+ # @param s [String, nil] optional SELECT to apply before taking
603
+ # @param binds [Hash, nil] bind parameters to add
604
+ # @param cast [Boolean, Hash, Array] type casting configuration
605
+ # @return [Array<Hash>] the last n rows as an array of hashes
606
+ #
607
+ # @example
608
+ # AppQuery("SELECT * FROM users ORDER BY created_at").take_last(5)
609
+ # # => [{"id" => 38, ...}, {"id" => 39, ...}, ...]
610
+ #
611
+ # @see #last
612
+ def take_last(n, s = nil, binds: {}, cast: self.cast)
613
+ offset_expr = greatest("(SELECT COUNT(*) FROM :_) - #{n.to_i}", "0")
614
+ with_select(s).select_all(
615
+ "SELECT * FROM :_ LIMIT #{n.to_i} OFFSET #{offset_expr}",
616
+ binds:, cast:
617
+ ).entries
618
+ end
619
+
474
620
  # Executes the query and returns the first value of the first row.
475
621
  #
476
622
  # @param binds [Hash, nil] named bind parameters
@@ -567,9 +713,19 @@ module AppQuery
567
713
  # @example Extract unique values
568
714
  # AppQuery("SELECT * FROM products").column(:category, unique: true)
569
715
  # # => ["Electronics", "Clothing", "Home"]
716
+ #
717
+ # @raise [ArgumentError] if the column doesn't exist in the (optionally
718
+ # selected) query. Pre-validating catches typos consistently across
719
+ # databases — e.g. without this, SQLite's "double-quoted strings"
720
+ # quirk would silently return rows of the column-name as a string.
570
721
  def column(c, s = nil, binds: {}, unique: false)
571
- quoted_column = ActiveRecord::Base.connection.quote_column_name(c)
572
- with_select(s).select_all("SELECT #{unique ? "DISTINCT" : ""} #{quoted_column} AS column FROM :_", binds:).column("column")
722
+ available = column_names(s, binds:)
723
+ unless available.include?(c.to_s)
724
+ raise ArgumentError, "Unknown column #{c.inspect}. Available: #{available.inspect}."
725
+ end
726
+ quoted = quote_column(c)
727
+ select_expr = unique ? "DISTINCT #{quoted}" : quoted
728
+ with_select(s).select_all("SELECT #{select_expr} AS column FROM :_", binds:).column("column")
573
729
  end
574
730
 
575
731
  # Returns the column names from the query without fetching any rows.
@@ -581,13 +737,13 @@ module AppQuery
581
737
  # @return [Array<String>] the column names
582
738
  #
583
739
  # @example Get column names
584
- # AppQuery("SELECT id, name, email FROM users").columns
740
+ # AppQuery("SELECT id, name, email FROM users").column_names
585
741
  # # => ["id", "name", "email"]
586
742
  #
587
743
  # @example From a CTE
588
- # AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").columns
744
+ # AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").column_names
589
745
  # # => ["a", "b"]
590
- def columns(s = nil, binds: {})
746
+ def column_names(s = nil, binds: {})
591
747
  with_select(s).select_all("SELECT * FROM :_ LIMIT 0", binds:).columns
592
748
  end
593
749
 
@@ -761,6 +917,9 @@ module AppQuery
761
917
  # end
762
918
  # end
763
919
  #
920
+ # @example Rails runner
921
+ # bin/rails runner "puts Export::ProductsQuery.new.copy_to" > tmp/products.csv
922
+ #
764
923
  # @raise [AppQuery::Error] if adapter is not PostgreSQL
765
924
  def copy_to(s = nil, format: :csv, header: true, delimiter: nil, dest: nil, binds: {})
766
925
  raw_conn = ActiveRecord::Base.connection.raw_connection
@@ -991,8 +1150,7 @@ module AppQuery
991
1150
  unless cte_names.include?(name)
992
1151
  raise ArgumentError, "Unknown CTE #{name.inspect}. Available: #{cte_names.inspect}"
993
1152
  end
994
- quoted = ActiveRecord::Base.connection.quote_table_name(name)
995
- with_select("SELECT * FROM #{quoted}")
1153
+ with_select("SELECT * FROM #{quote_table(name)}")
996
1154
  end
997
1155
 
998
1156
  # Prepends a CTE to the beginning of the WITH clause.
@@ -1124,6 +1282,26 @@ module AppQuery
1124
1282
  def to_s
1125
1283
  @sql
1126
1284
  end
1285
+
1286
+ private
1287
+
1288
+ def quote_table(name)
1289
+ AppQuery.quote_table(name)
1290
+ end
1291
+
1292
+ def quote_column(name)
1293
+ AppQuery.quote_column(name)
1294
+ end
1295
+
1296
+ # Returns SQL for max(a, b) that works across adapters.
1297
+ # PostgreSQL uses GREATEST, SQLite uses MAX for scalar comparison.
1298
+ def greatest(a, b)
1299
+ if /sqlite/i.match?(ActiveRecord::Base.connection.adapter_name)
1300
+ "MAX(#{a}, #{b})"
1301
+ else
1302
+ "GREATEST(#{a}, #{b})"
1303
+ end
1304
+ end
1127
1305
  end
1128
1306
  end
1129
1307
 
@@ -1,5 +1,5 @@
1
1
  [env]
2
2
  # used for tests
3
- PG_DATABASE_URL="postgres://localhost:5432/some_db
3
+ SPEC_DATABASE_URL="postgres://localhost:5432/some_db"
4
4
  # used from console
5
- DATABASE_URL="postgres://localhost:5432/some_db
5
+ CONSOLE_DATABASE_URL="postgres://localhost:5432/some_db"
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.8.0.rc3
4
+ version: 0.9.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: appraisal
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -43,6 +57,7 @@ files:
43
57
  - ".irbrc"
44
58
  - ".rspec"
45
59
  - ".standard.yml"
60
+ - ".worktree.yml.example"
46
61
  - ".yard/templates/default/fulldoc/html/css/dark.css"
47
62
  - ".yard/templates/default/fulldoc/html/js/app.js"
48
63
  - ".yard/templates/default/fulldoc/html/setup.rb"
@@ -52,6 +67,7 @@ files:
52
67
  - Appraisals
53
68
  - CHANGELOG.md
54
69
  - LICENSE.txt
70
+ - Procfile.yard
55
71
  - README.md
56
72
  - Rakefile
57
73
  - assets/banner-dark.svg
@@ -93,6 +109,7 @@ metadata:
93
109
  homepage_uri: https://github.com/eval/appquery
94
110
  source_code_uri: https://github.com/eval/appquery
95
111
  changelog_uri: https://github.com/eval/appquery/blob/main/CHANGELOG.md
112
+ rubygems_mfa_required: 'true'
96
113
  rdoc_options: []
97
114
  require_paths:
98
115
  - lib