appquery 0.8.0.rc3 โ†’ 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0dacb58820ea6f11ec37fa1e73f7aeb9eec507d92b4e302cd8134f2c768abeba
4
- data.tar.gz: d371f97ca509a6c9dde67f2aec9edc67a7690b00081a5d596003608ae4958748
3
+ metadata.gz: 631dc9e88f2e84a72e51c4f03843aedc1a187c18a410fc4c8b519800c24c5ab2
4
+ data.tar.gz: 3f006856e51702d2483598b94647e63f00fd9989d67851e6bf31dcbc2497165f
5
5
  SHA512:
6
- metadata.gz: bd8b8d0c01fdf4e9434cfe85218d6b772d839b263ae7c91f667fa2eb7aaedba48825bcace882028ed80b6cda8ff241c0fbae4764ca1cc067d3f320d95c5e81b8
7
- data.tar.gz: 11ee7b651b2fa20e2f40935e674d190681159a2fecf716741ca6392fabde3b7ed2a3e1e1b29b86344215db58b80f9765ba98ea81ec93c3e31022e2859b96f7bc
6
+ metadata.gz: 6fe8f0822ea850a912a03eceec3e6cc7db6b4df52ce7734a67dc0bf242983720867b98dc63e37afce4ff6bd2daba600170a7eb1e90c85c97978b7e959fe7f70e
7
+ data.tar.gz: 9617c613888fd722b258d0d956bb0cd92dabbd9d4b4a1f1cb04a38aa37b69b2e7d2b0cc25fd306923f94cc9953aa36634171a7b7ecd95698d884118eb09c50ee
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,76 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## 0.8.0
4
+
5
+ **Releasedate**: 14-1-2026
6
+ **Rubygems**: https://rubygems.org/gems/appquery/versions/0.8.0
7
+
8
+ ### ๐Ÿ’ฅ Breaking Changes
9
+
10
+ - โš ๏ธ **RSpec helpers refactored**
11
+ Query under test is expected to be a class, `select_*` are no longer separate helpers:
12
+ ```ruby
13
+ expect(described_query.first).to \
14
+ include("id" => be_a(Integer), ...)
15
+ expect(described_query.entries).to include(a_hash_including("item_code" => "123456"))
16
+ ```
17
+
18
+ ### โœจ Features
19
+
20
+ - ๐Ÿ“ค **`copy_to`** โ€” efficient PostgreSQL COPY export to CSV/text/binary
21
+ ```ruby
22
+ # Return as string
23
+ csv = AppQuery[:users].copy_to
24
+
25
+ # Write to file
26
+ AppQuery[:users].copy_to(dest: "export.csv")
27
+
28
+ # Stream to IO (e.g., Rails response)
29
+ query.copy_to(dest: response.stream)
30
+ ```
31
+
32
+ - ๐ŸŽฏ **`cte(:name)`** โ€” focus a query on a specific CTE for testing or inspection
33
+ ```ruby
34
+ query = AppQuery("WITH active AS (...), admins AS (...) SELECT ...")
35
+ query.cte(:active).entries # select from the active CTE
36
+ query.cte(:admins).count # count rows in admins CTE
37
+ ```
38
+
39
+ - ๐Ÿ—ƒ๏ธ **`AppQuery.table(:name)`** โ€” quick query from a table
40
+ ```ruby
41
+ AppQuery.table(:products).count
42
+ AppQuery.table(:users).take(5)
43
+ ```
44
+
45
+ - ๐Ÿ”ข **`take(n)` / `take_last(n)`** โ€” fetch first or last n rows
46
+ ```ruby
47
+ query.take(5) # first 5 rows
48
+ query.take_last(5) # last 5 rows
49
+ ```
50
+
51
+ - โฎ๏ธ **`last`** โ€” fetch the last row (counterpart to `first`)
52
+ ```ruby
53
+ query.last # => {"id" => 42, "name" => "Zoe"}
54
+ ```
55
+
56
+ - ๐Ÿ“‹ **`column_names`** โ€” get column names without fetching rows
57
+ ```ruby
58
+ query.column_names # => ["id", "name", "email"]
59
+ ```
60
+
61
+ - ๐Ÿฆ„ **`unique:` keyword for `Q#column`** โ€” return distinct values
62
+ ```ruby
63
+ query.column(:status, unique: true) # => ["active", "pending"]
64
+ ```
65
+
66
+ - ๐Ÿ—๏ธ **Overhauled generators** โ€” moved to `AppQuery::` namespace
67
+ ```bash
68
+ rails g app_query:example # annotated example query
69
+ rails g app_query:query Products
70
+ rails g query Products # hidden alias
71
+ rails g query --help # details
72
+ ```
73
+
3
74
  ## 0.7.0
4
75
 
5
76
  **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,26 @@ 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
+
265
293
  ## API Documentation
266
294
 
267
295
  See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
@@ -289,6 +317,9 @@ bin/run rails_head console
289
317
 
290
318
  # Run tests
291
319
  rake spec
320
+
321
+ # YARD with reload (requires entr and overmind/foreman)
322
+ bin/yard-dev
292
323
  ```
293
324
 
294
325
  Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
@@ -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
@@ -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.8.0"
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,8 +152,7 @@ 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)
137
156
  end
138
157
 
139
158
  class Result < ActiveRecord::Result
@@ -147,8 +166,30 @@ module AppQuery
147
166
  @hash_rows = [] if columns.empty?
148
167
  end
149
168
 
169
+ # Returns an array of values for a single column.
170
+ #
171
+ # @note If you only need a single column, prefer {Q#column} which selects
172
+ # only that column from the database, avoiding fetching all columns.
173
+ #
174
+ # @param name [String, Symbol, nil] the column name (nil returns first column)
175
+ # @param unique [Boolean] whether to return only unique values
176
+ # @return [Array] the column values
177
+ # @raise [ArgumentError] if the column doesn't exist
178
+ #
179
+ # @example Get values by column name
180
+ # result.column(:name) # => ["Alice", "Bob"]
181
+ # result.column("name") # => ["Alice", "Bob"]
182
+ #
183
+ # @example Get first column (no name)
184
+ # result.column # => [1, 2, 3]
185
+ #
186
+ # @example Get unique values
187
+ # result.column(:status, unique: true) # => ["active", "pending"]
188
+ #
189
+ # @see Q#column
150
190
  def column(name = nil, unique: false)
151
191
  return [] if empty?
192
+ name = name&.to_s
152
193
  unless name.nil? || includes_column?(name)
153
194
  raise ArgumentError, "Unknown column #{name.inspect}. Should be one of #{columns.inspect}."
154
195
  end
@@ -453,6 +494,26 @@ module AppQuery
453
494
  end
454
495
  alias_method :first, :select_one
455
496
 
497
+ # Executes the query and returns the last row.
498
+ #
499
+ # Uses OFFSET to skip to the last row without changing the query order.
500
+ # Note: This requires counting all rows first, so it's less efficient
501
+ # than {#first} for large result sets.
502
+ #
503
+ # @param s [String, nil] optional SELECT to apply before fetching
504
+ # @param binds [Hash, nil] bind parameters to add
505
+ # @param cast [Boolean, Hash, Array] type casting configuration
506
+ # @return [Hash, nil] the last row as a hash, or nil if no results
507
+ #
508
+ # @example
509
+ # AppQuery("SELECT * FROM users ORDER BY created_at").last
510
+ # # => {"id" => 42, "name" => "Zoe"}
511
+ #
512
+ # @see #first
513
+ def last(s = nil, binds: {}, cast: self.cast)
514
+ take_last(1, s, binds:, cast:).first
515
+ end
516
+
456
517
  # Executes the query and returns the first n rows.
457
518
  #
458
519
  # @param n [Integer] the number of rows to return
@@ -471,6 +532,30 @@ module AppQuery
471
532
  end
472
533
  alias_method :limit, :take
473
534
 
535
+ # Executes the query and returns the last n rows.
536
+ #
537
+ # Uses OFFSET to skip to the last n rows without changing the query order.
538
+ # Note: This requires counting all rows first, so it's less efficient
539
+ # than {#take} for large result sets.
540
+ #
541
+ # @param n [Integer] the number of rows to return
542
+ # @param s [String, nil] optional SELECT to apply before taking
543
+ # @param binds [Hash, nil] bind parameters to add
544
+ # @param cast [Boolean, Hash, Array] type casting configuration
545
+ # @return [Array<Hash>] the last n rows as an array of hashes
546
+ #
547
+ # @example
548
+ # AppQuery("SELECT * FROM users ORDER BY created_at").take_last(5)
549
+ # # => [{"id" => 38, ...}, {"id" => 39, ...}, ...]
550
+ #
551
+ # @see #last
552
+ def take_last(n, s = nil, binds: {}, cast: self.cast)
553
+ with_select(s).select_all(
554
+ "SELECT * FROM :_ LIMIT #{n.to_i} OFFSET GREATEST((SELECT COUNT(*) FROM :_) - #{n.to_i}, 0)",
555
+ binds:, cast:
556
+ ).entries
557
+ end
558
+
474
559
  # Executes the query and returns the first value of the first row.
475
560
  #
476
561
  # @param binds [Hash, nil] named bind parameters
@@ -568,8 +653,9 @@ module AppQuery
568
653
  # AppQuery("SELECT * FROM products").column(:category, unique: true)
569
654
  # # => ["Electronics", "Clothing", "Home"]
570
655
  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")
656
+ quoted = quote_column(c)
657
+ select_expr = unique ? "DISTINCT #{quoted}" : quoted
658
+ with_select(s).select_all("SELECT #{select_expr} AS column FROM :_", binds:).column("column")
573
659
  end
574
660
 
575
661
  # Returns the column names from the query without fetching any rows.
@@ -581,13 +667,13 @@ module AppQuery
581
667
  # @return [Array<String>] the column names
582
668
  #
583
669
  # @example Get column names
584
- # AppQuery("SELECT id, name, email FROM users").columns
670
+ # AppQuery("SELECT id, name, email FROM users").column_names
585
671
  # # => ["id", "name", "email"]
586
672
  #
587
673
  # @example From a CTE
588
- # AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").columns
674
+ # AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").column_names
589
675
  # # => ["a", "b"]
590
- def columns(s = nil, binds: {})
676
+ def column_names(s = nil, binds: {})
591
677
  with_select(s).select_all("SELECT * FROM :_ LIMIT 0", binds:).columns
592
678
  end
593
679
 
@@ -761,6 +847,9 @@ module AppQuery
761
847
  # end
762
848
  # end
763
849
  #
850
+ # @example Rails runner
851
+ # bin/rails runner "puts Export::ProductsQuery.new.copy_to" > tmp/products.csv
852
+ #
764
853
  # @raise [AppQuery::Error] if adapter is not PostgreSQL
765
854
  def copy_to(s = nil, format: :csv, header: true, delimiter: nil, dest: nil, binds: {})
766
855
  raw_conn = ActiveRecord::Base.connection.raw_connection
@@ -991,8 +1080,7 @@ module AppQuery
991
1080
  unless cte_names.include?(name)
992
1081
  raise ArgumentError, "Unknown CTE #{name.inspect}. Available: #{cte_names.inspect}"
993
1082
  end
994
- quoted = ActiveRecord::Base.connection.quote_table_name(name)
995
- with_select("SELECT * FROM #{quoted}")
1083
+ with_select("SELECT * FROM #{quote_table(name)}")
996
1084
  end
997
1085
 
998
1086
  # Prepends a CTE to the beginning of the WITH clause.
@@ -1124,6 +1212,16 @@ module AppQuery
1124
1212
  def to_s
1125
1213
  @sql
1126
1214
  end
1215
+
1216
+ private
1217
+
1218
+ def quote_table(name)
1219
+ AppQuery.quote_table(name)
1220
+ end
1221
+
1222
+ def quote_column(name)
1223
+ AppQuery.quote_column(name)
1224
+ end
1127
1225
  end
1128
1226
  end
1129
1227
 
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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
@@ -52,6 +52,7 @@ files:
52
52
  - Appraisals
53
53
  - CHANGELOG.md
54
54
  - LICENSE.txt
55
+ - Procfile.yard
55
56
  - README.md
56
57
  - Rakefile
57
58
  - assets/banner-dark.svg