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 +4 -4
- data/.yardopts +1 -1
- data/CHANGELOG.md +71 -0
- data/Procfile.yard +4 -0
- data/README.md +36 -5
- data/lib/app_query/rspec/helpers.rb +11 -1
- data/lib/app_query/rspec.rb +22 -2
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +107 -9
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 631dc9e88f2e84a72e51c4f03843aedc1a187c18a410fc4c8b519800c24c5ab2
|
|
4
|
+
data.tar.gz: 3f006856e51702d2483598b94647e63f00fd9989d67851e6bf31dcbc2497165f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6fe8f0822ea850a912a03eceec3e6cc7db6b4df52ce7734a67dc0bf242983720867b98dc63e37afce4ff6bd2daba600170a7eb1e90c85c97978b7e959fe7f70e
|
|
7
|
+
data.tar.gz: 9617c613888fd722b258d0d956bb0cd92dabbd9d4b4a1f1cb04a38aa37b69b2e7d2b0cc25fd306923f94cc9953aa36634171a7b7ecd95698d884118eb09c50ee
|
data/.yardopts
CHANGED
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
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
|
|
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.
|
|
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.
|
|
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
|
data/lib/app_query/rspec.rb
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
|
|
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
|
data/lib/app_query/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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").
|
|
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").
|
|
674
|
+
# AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").column_names
|
|
589
675
|
# # => ["a", "b"]
|
|
590
|
-
def
|
|
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
|
-
|
|
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
|
|
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
|