appquery 0.7.0 → 0.8.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 +4 -4
- data/.yard/templates/default/layout/html/footer.erb +7 -0
- data/CHANGELOG.md +5 -0
- data/lib/app_query/base_query.rb +1 -5
- data/lib/app_query/paginatable.rb +79 -2
- data/lib/app_query/railtie.rb +12 -0
- data/lib/app_query/rspec/helpers.rb +85 -59
- data/lib/app_query/rspec.rb +12 -0
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +195 -11
- data/lib/generators/app_query/USAGE +28 -0
- data/lib/generators/app_query/example_generator.rb +38 -0
- data/lib/generators/app_query/query_generator.rb +43 -0
- data/lib/generators/app_query/templates/application_query.rb.tt +7 -0
- data/lib/generators/app_query/templates/example.sql.erb.tt +41 -0
- data/lib/generators/app_query/templates/example_query.rb.tt +37 -0
- data/lib/generators/app_query/templates/query.rb.tt +9 -0
- data/lib/generators/app_query/templates/query.sql.tt +1 -0
- data/lib/generators/query_generator.rb +10 -0
- data/lib/generators/rspec/app_query_example_generator.rb +13 -0
- data/lib/{rails/generators/rspec/query_generator.rb → generators/rspec/app_query_generator.rb} +4 -10
- data/lib/generators/rspec/query_generator.rb +13 -0
- data/lib/generators/rspec/templates/app_query_example_spec.rb.tt +140 -0
- data/lib/generators/rspec/templates/app_query_spec.rb.tt +9 -0
- metadata +18 -7
- data/lib/rails/generators/query/USAGE +0 -10
- data/lib/rails/generators/query/query_generator.rb +0 -20
- data/lib/rails/generators/query/templates/query.sql.tt +0 -14
- data/lib/rails/generators/rspec/templates/query_spec.rb.tt +0 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 220f74299300b813e054b9ef57172083795b14209322ee9d5fbcb9d3de608a04
|
|
4
|
+
data.tar.gz: '064268f658576f5bf82e05dc97bd51138ccf4c903800296f18eee1c1c4f1c91f'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b79adb014601b0f5c414c2f22935010bcac7b98ca9b452bbdb474ff9208d93f4868866f4a4740c5a8cbcf4bf652559bfc6f53927974b747559ae5aa9379fed8c
|
|
7
|
+
data.tar.gz: b30ce78b6188bb08b9013655980e54b9e49243f9a69c2a9e5dddc5384ed73508374cea1cdd35e2fa6fd36c792ca0f594f26180b9ed9ac93e5d0d772c003e5344
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div id="footer">
|
|
2
|
+
AppQuery <%= ENV["APPQUERY_VERSION"] || `git describe --tags --abbrev=0 2>/dev/null`.strip %>
|
|
3
|
+
—
|
|
4
|
+
Generated on <%= Time.now.strftime("%c") %> by
|
|
5
|
+
<a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
|
|
6
|
+
<%= YARD::VERSION %> (ruby-<%= RUBY_VERSION %>).
|
|
7
|
+
</div>
|
data/CHANGELOG.md
CHANGED
data/lib/app_query/base_query.rb
CHANGED
|
@@ -154,11 +154,7 @@ module AppQuery
|
|
|
154
154
|
end
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
delegate :select_all, :select_one, :count, :to_s, :column, :first, :ids, to: :query
|
|
158
|
-
|
|
159
|
-
def entries
|
|
160
|
-
select_all
|
|
161
|
-
end
|
|
157
|
+
delegate :cte, :entries, :with_select, :select_all, :select_one, :count, :to_s, :column, :first, :ids, :copy_to, to: :query
|
|
162
158
|
|
|
163
159
|
def query
|
|
164
160
|
@query ||= base_query
|
|
@@ -46,11 +46,23 @@ module AppQuery
|
|
|
46
46
|
extend ActiveSupport::Concern
|
|
47
47
|
|
|
48
48
|
# Kaminari-compatible wrapper for paginated results.
|
|
49
|
+
#
|
|
50
|
+
# Wraps an array of records with pagination metadata, providing a consistent
|
|
51
|
+
# interface for both counted and uncounted pagination modes.
|
|
52
|
+
#
|
|
53
|
+
# Includes Enumerable, so all standard iteration methods work directly.
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# result = ArticlesQuery.new.paginate(page: 2).entries
|
|
57
|
+
# result.each { |article| puts article["title"] }
|
|
58
|
+
# result.current_page # => 2
|
|
59
|
+
# result.total_pages # => 5
|
|
49
60
|
class PaginatedResult
|
|
50
61
|
include Enumerable
|
|
51
62
|
|
|
52
63
|
delegate :each, :size, :[], :empty?, :first, :last, to: :@records
|
|
53
64
|
|
|
65
|
+
# @api private
|
|
54
66
|
def initialize(records, page:, per_page:, total_count: nil, has_next: nil)
|
|
55
67
|
@records = records
|
|
56
68
|
@page = page
|
|
@@ -59,23 +71,31 @@ module AppQuery
|
|
|
59
71
|
@has_next = has_next
|
|
60
72
|
end
|
|
61
73
|
|
|
74
|
+
# @return [Integer] the current page number
|
|
62
75
|
def current_page = @page
|
|
63
76
|
|
|
77
|
+
# @return [Integer] the number of records per page
|
|
64
78
|
def limit_value = @per_page
|
|
65
79
|
|
|
80
|
+
# @return [Integer, nil] the previous page number, or nil if on first page
|
|
66
81
|
def prev_page = (@page > 1) ? @page - 1 : nil
|
|
67
82
|
|
|
83
|
+
# @return [Boolean] true if this is the first page
|
|
68
84
|
def first_page? = @page == 1
|
|
69
85
|
|
|
86
|
+
# @return [Integer] the total number of records across all pages
|
|
87
|
+
# @raise [RuntimeError] if called in +without_count+ mode
|
|
70
88
|
def total_count
|
|
71
89
|
@total_count || raise("total_count not available in without_count mode")
|
|
72
90
|
end
|
|
73
91
|
|
|
92
|
+
# @return [Integer, nil] the total number of pages, or nil in +without_count+ mode
|
|
74
93
|
def total_pages
|
|
75
94
|
return nil unless @total_count
|
|
76
95
|
(@total_count.to_f / @per_page).ceil
|
|
77
96
|
end
|
|
78
97
|
|
|
98
|
+
# @return [Integer, nil] the next page number, or nil if on last page
|
|
79
99
|
def next_page
|
|
80
100
|
if @total_count
|
|
81
101
|
(@page < total_pages) ? @page + 1 : nil
|
|
@@ -84,6 +104,7 @@ module AppQuery
|
|
|
84
104
|
end
|
|
85
105
|
end
|
|
86
106
|
|
|
107
|
+
# @return [Boolean] true if this is the last page
|
|
87
108
|
def last_page?
|
|
88
109
|
if @total_count
|
|
89
110
|
@page >= total_pages
|
|
@@ -92,10 +113,20 @@ module AppQuery
|
|
|
92
113
|
end
|
|
93
114
|
end
|
|
94
115
|
|
|
116
|
+
# @return [Boolean] true if the requested page is beyond available data
|
|
95
117
|
def out_of_range?
|
|
96
118
|
empty? && @page > 1
|
|
97
119
|
end
|
|
98
120
|
|
|
121
|
+
# Transforms each record in place using the given block.
|
|
122
|
+
#
|
|
123
|
+
# @yield [record] Block to transform each record
|
|
124
|
+
# @yieldparam record [Hash] the record to transform
|
|
125
|
+
# @yieldreturn [Object] the transformed record
|
|
126
|
+
# @return [self] for chaining
|
|
127
|
+
#
|
|
128
|
+
# @example
|
|
129
|
+
# result.transform! { |row| OpenStruct.new(row) }
|
|
99
130
|
def transform!
|
|
100
131
|
@records = @records.map { |r| yield(r) }
|
|
101
132
|
self
|
|
@@ -108,6 +139,20 @@ module AppQuery
|
|
|
108
139
|
end
|
|
109
140
|
|
|
110
141
|
class_methods do
|
|
142
|
+
# Gets or sets the default number of records per page.
|
|
143
|
+
#
|
|
144
|
+
# When called without arguments, returns the current per_page value
|
|
145
|
+
# (inheriting from superclass if not set, defaulting to 25).
|
|
146
|
+
#
|
|
147
|
+
# @param value [Integer, nil] the number of records per page (setter)
|
|
148
|
+
# @return [Integer] the current per_page value (getter)
|
|
149
|
+
#
|
|
150
|
+
# @example
|
|
151
|
+
# class ArticlesQuery < ApplicationQuery
|
|
152
|
+
# per_page 10
|
|
153
|
+
# end
|
|
154
|
+
#
|
|
155
|
+
# ArticlesQuery.per_page # => 10
|
|
111
156
|
def per_page(value = nil)
|
|
112
157
|
if value.nil?
|
|
113
158
|
return @per_page if defined?(@per_page)
|
|
@@ -118,6 +163,18 @@ module AppQuery
|
|
|
118
163
|
end
|
|
119
164
|
end
|
|
120
165
|
|
|
166
|
+
# Enables pagination for this query.
|
|
167
|
+
#
|
|
168
|
+
# @param page [Integer] page number, starting at 1
|
|
169
|
+
# @param per_page [Integer] records per page (defaults to class setting)
|
|
170
|
+
# @param without_count [Boolean] skip COUNT query for large datasets
|
|
171
|
+
# @return [self] for chaining
|
|
172
|
+
#
|
|
173
|
+
# @example Standard pagination with total count
|
|
174
|
+
# ArticlesQuery.new.paginate(page: 2, per_page: 20).entries
|
|
175
|
+
#
|
|
176
|
+
# @example Fast pagination without count (for large tables)
|
|
177
|
+
# ArticlesQuery.new.paginate(page: 1, without_count: true).entries
|
|
121
178
|
def paginate(page: 1, per_page: self.class.per_page, without_count: false)
|
|
122
179
|
@page = page
|
|
123
180
|
@per_page = per_page
|
|
@@ -125,28 +182,48 @@ module AppQuery
|
|
|
125
182
|
self
|
|
126
183
|
end
|
|
127
184
|
|
|
185
|
+
# Disables pagination, returning all results.
|
|
186
|
+
#
|
|
187
|
+
# @return [self] for chaining
|
|
188
|
+
#
|
|
189
|
+
# @example
|
|
190
|
+
# ArticlesQuery.new.unpaginated.entries # => all records
|
|
128
191
|
def unpaginated
|
|
129
192
|
@page = nil
|
|
130
193
|
@per_page = nil
|
|
131
194
|
self
|
|
132
195
|
end
|
|
133
196
|
|
|
197
|
+
# Executes the query and returns paginated results.
|
|
198
|
+
#
|
|
199
|
+
# @return [PaginatedResult] when pagination is enabled
|
|
200
|
+
# @return [Array<Hash>] when unpaginated
|
|
134
201
|
def entries
|
|
135
202
|
@_entries ||= build_paginated_result(super)
|
|
136
203
|
end
|
|
137
204
|
|
|
205
|
+
# Returns the total count of records (without pagination).
|
|
206
|
+
#
|
|
207
|
+
# Executes a separate COUNT query. Result is memoized.
|
|
208
|
+
#
|
|
209
|
+
# @return [Integer] total number of records
|
|
138
210
|
def total_count
|
|
139
211
|
@_total_count ||= unpaginated_query.count
|
|
140
212
|
end
|
|
141
213
|
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Returns the underlying query without pagination applied.
|
|
217
|
+
#
|
|
218
|
+
# Useful for getting total counts or building derived queries.
|
|
219
|
+
#
|
|
220
|
+
# @return [AppQuery::Q] the unpaginated query object
|
|
142
221
|
def unpaginated_query
|
|
143
222
|
base_query
|
|
144
223
|
.render(**render_vars, page: nil)
|
|
145
224
|
.with_binds(**bind_vars)
|
|
146
225
|
end
|
|
147
226
|
|
|
148
|
-
private
|
|
149
|
-
|
|
150
227
|
def build_paginated_result(entries)
|
|
151
228
|
return entries unless @page # No pagination requested
|
|
152
229
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module AppQuery
|
|
2
|
+
class Railtie < ::Rails::Railtie # :nodoc:
|
|
3
|
+
generators do
|
|
4
|
+
require_relative "../generators/app_query/query_generator"
|
|
5
|
+
require_relative "../generators/app_query/example_generator"
|
|
6
|
+
require_relative "../generators/query_generator"
|
|
7
|
+
require_relative "../generators/rspec/app_query_generator"
|
|
8
|
+
require_relative "../generators/rspec/app_query_example_generator"
|
|
9
|
+
require_relative "../generators/rspec/query_generator"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -1,56 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module AppQuery
|
|
2
4
|
module RSpec
|
|
5
|
+
# RSpec helpers for testing query classes.
|
|
6
|
+
#
|
|
7
|
+
# @example Basic usage
|
|
8
|
+
# RSpec.describe ProductsQuery, type: :query do
|
|
9
|
+
# it "returns products" do
|
|
10
|
+
# expect(described_query.entries).to be_present
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Testing a specific CTE
|
|
15
|
+
# RSpec.describe ProductsQuery, type: :query do
|
|
16
|
+
# describe "cte active_products" do
|
|
17
|
+
# it "only contains active products" do
|
|
18
|
+
# expect(described_query.entries).to all(include("active" => true))
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example With required binds
|
|
24
|
+
# RSpec.describe UsersQuery, type: :query, binds: {company_id: 1} do
|
|
25
|
+
# it "returns users for company" do
|
|
26
|
+
# expect(described_query.entries).to be_present
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# @example With vars
|
|
31
|
+
# RSpec.describe ProductsQuery, type: :query do
|
|
32
|
+
# describe "as admin", vars: {admin: true} do
|
|
33
|
+
# it "returns all products" do
|
|
34
|
+
# expect(described_query.entries.size).to eq(3)
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
3
38
|
module Helpers
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@query_result = described_query(select:).select_all(binds:, **kws)
|
|
39
|
+
# Returns the query instance, optionally focused on a CTE.
|
|
40
|
+
#
|
|
41
|
+
# When inside a `describe "cte xxx"` block, returns a query
|
|
42
|
+
# that selects from that CTE instead of the full query.
|
|
43
|
+
#
|
|
44
|
+
# @param kwargs [Hash] arguments passed to {#build_query}
|
|
45
|
+
# @return [AppQuery::BaseQuery, AppQuery::Q] the query instance
|
|
46
|
+
#
|
|
47
|
+
# @example Override binds per-test
|
|
48
|
+
# expect(described_query(user_id: 123).entries).to include(...)
|
|
49
|
+
def described_query(**kwargs)
|
|
50
|
+
query = build_query(**kwargs)
|
|
51
|
+
cte_name ? query.query.cte(cte_name) : query
|
|
18
52
|
end
|
|
19
53
|
|
|
20
|
-
|
|
21
|
-
|
|
54
|
+
# Builds the query instance. Override this to customize instantiation.
|
|
55
|
+
#
|
|
56
|
+
# @param kwargs [Hash] merged with {#query_binds} and {#query_vars}
|
|
57
|
+
# @return [AppQuery::BaseQuery] the query instance
|
|
58
|
+
#
|
|
59
|
+
# @example Custom build method
|
|
60
|
+
# def build_query(**kwargs)
|
|
61
|
+
# described_class.build(**query_binds.merge(query_vars).merge(kwargs))
|
|
62
|
+
# end
|
|
63
|
+
def build_query(**kwargs)
|
|
64
|
+
described_class.new(**query_binds.merge(query_vars).merge(kwargs))
|
|
22
65
|
end
|
|
23
66
|
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
# Returns binds from RSpec metadata.
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash] the binds hash
|
|
70
|
+
def query_binds
|
|
71
|
+
metadata_value(:binds) || {}
|
|
26
72
|
end
|
|
27
73
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
74
|
+
# Returns vars from RSpec metadata.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash] the vars hash
|
|
77
|
+
def query_vars
|
|
78
|
+
metadata_value(:vars) || {}
|
|
32
79
|
end
|
|
33
80
|
|
|
81
|
+
# Returns the CTE name if inside a "cte xxx" describe block.
|
|
82
|
+
#
|
|
83
|
+
# @return [String, nil] the CTE name
|
|
34
84
|
def cte_name
|
|
35
85
|
self.class.cte_name
|
|
36
86
|
end
|
|
37
87
|
|
|
38
|
-
def
|
|
39
|
-
|
|
88
|
+
def self.included(klass)
|
|
89
|
+
klass.extend(ClassMethods)
|
|
40
90
|
end
|
|
41
91
|
|
|
42
|
-
|
|
43
|
-
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def metadata_value(key)
|
|
95
|
+
self.class.metadata_value(key)
|
|
44
96
|
end
|
|
45
97
|
|
|
46
98
|
module ClassMethods
|
|
47
|
-
def
|
|
48
|
-
|
|
99
|
+
def cte_name
|
|
100
|
+
descriptions.find { _1[/\Acte\s/i] }&.split&.last
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def metadata_value(key)
|
|
104
|
+
metadatas.find { _1[key] }&.[](key)
|
|
49
105
|
end
|
|
50
106
|
|
|
51
107
|
def metadatas
|
|
52
|
-
|
|
53
|
-
metahash = scope.metadata
|
|
108
|
+
metahash = metadata
|
|
54
109
|
result = []
|
|
55
110
|
loop do
|
|
56
111
|
result << metahash
|
|
@@ -63,36 +118,7 @@ module AppQuery
|
|
|
63
118
|
def descriptions
|
|
64
119
|
metadatas.map { _1[:description] }
|
|
65
120
|
end
|
|
66
|
-
|
|
67
|
-
def query_name
|
|
68
|
-
descriptions.find { _1[/(app)?query\s/i] }&.then { _1.split.last }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def cte_name
|
|
72
|
-
descriptions.find { _1[/cte\s/i] }&.then { _1.split.last }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def default_binds
|
|
76
|
-
metadatas.find { _1[:default_binds] }&.[](:default_binds) || []
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def default_vars
|
|
80
|
-
metadatas.find { _1[:default_vars] }&.[](:default_vars) || {}
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def included(klass)
|
|
84
|
-
super
|
|
85
|
-
# Inject classmethods into the group.
|
|
86
|
-
klass.extend(ClassMethods)
|
|
87
|
-
# If the describe block is aimed at string or resource/provider class
|
|
88
|
-
# then set the default subject to be the Chef run.
|
|
89
|
-
# if klass.described_class.nil? || klass.described_class.is_a?(Class) && (klass.described_class < Chef::Resource || klass.described_class < Chef::Provider)
|
|
90
|
-
# klass.subject { chef_run }
|
|
91
|
-
# end
|
|
92
|
-
end
|
|
93
121
|
end
|
|
94
|
-
|
|
95
|
-
extend ClassMethods
|
|
96
122
|
end
|
|
97
123
|
end
|
|
98
124
|
end
|
data/lib/app_query/rspec.rb
CHANGED
|
@@ -2,4 +2,16 @@ require_relative "rspec/helpers"
|
|
|
2
2
|
|
|
3
3
|
RSpec.configure do |config|
|
|
4
4
|
config.include AppQuery::RSpec::Helpers, type: :query
|
|
5
|
+
|
|
6
|
+
# Enable SQL logging with `log: true` metadata
|
|
7
|
+
config.around(:each, type: :query) do |example|
|
|
8
|
+
if example.metadata[:log]
|
|
9
|
+
old_logger = ActiveRecord::Base.logger
|
|
10
|
+
ActiveRecord::Base.logger = Logger.new($stdout)
|
|
11
|
+
example.run
|
|
12
|
+
ActiveRecord::Base.logger = old_logger
|
|
13
|
+
else
|
|
14
|
+
example.run
|
|
15
|
+
end
|
|
16
|
+
end
|
|
5
17
|
end
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
|
@@ -116,6 +116,26 @@ module AppQuery
|
|
|
116
116
|
Q.new(full_path.read, name: "AppQuery #{query_name}", filename: full_path.to_s, **opts)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# Creates a query that selects all columns from a table.
|
|
120
|
+
#
|
|
121
|
+
# Convenience method for quickly querying a table without writing SQL.
|
|
122
|
+
#
|
|
123
|
+
# @param name [Symbol, String] the table name
|
|
124
|
+
# @param opts [Hash] additional options passed to {Q#initialize}
|
|
125
|
+
# @return [Q] a new query object selecting from the table
|
|
126
|
+
#
|
|
127
|
+
# @example Basic usage
|
|
128
|
+
# AppQuery.table(:products).count
|
|
129
|
+
# AppQuery.table(:products).take(5)
|
|
130
|
+
#
|
|
131
|
+
# @example With binds
|
|
132
|
+
# AppQuery.table(:users, binds: {active: true})
|
|
133
|
+
# .select_all("SELECT * FROM :_ WHERE active = :active")
|
|
134
|
+
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)
|
|
137
|
+
end
|
|
138
|
+
|
|
119
139
|
class Result < ActiveRecord::Result
|
|
120
140
|
attr_accessor :cast
|
|
121
141
|
alias_method :cast?, :cast
|
|
@@ -127,13 +147,13 @@ module AppQuery
|
|
|
127
147
|
@hash_rows = [] if columns.empty?
|
|
128
148
|
end
|
|
129
149
|
|
|
130
|
-
def column(name = nil)
|
|
150
|
+
def column(name = nil, unique: false)
|
|
131
151
|
return [] if empty?
|
|
132
152
|
unless name.nil? || includes_column?(name)
|
|
133
153
|
raise ArgumentError, "Unknown column #{name.inspect}. Should be one of #{columns.inspect}."
|
|
134
154
|
end
|
|
135
155
|
ix = name.nil? ? 0 : columns.index(name)
|
|
136
|
-
rows.map { _1[ix] }
|
|
156
|
+
rows.map { _1[ix] }.then { unique ? _1.uniq! : _1 }
|
|
137
157
|
end
|
|
138
158
|
|
|
139
159
|
def size
|
|
@@ -184,7 +204,7 @@ module AppQuery
|
|
|
184
204
|
|
|
185
205
|
def self.from_ar_result(r, cast = nil)
|
|
186
206
|
if r.empty?
|
|
187
|
-
EMPTY
|
|
207
|
+
r.columns.empty? ? EMPTY : new(r.columns, [], r.column_types)
|
|
188
208
|
else
|
|
189
209
|
cast &&= case cast
|
|
190
210
|
when Array
|
|
@@ -433,6 +453,24 @@ module AppQuery
|
|
|
433
453
|
end
|
|
434
454
|
alias_method :first, :select_one
|
|
435
455
|
|
|
456
|
+
# Executes the query and returns the first n rows.
|
|
457
|
+
#
|
|
458
|
+
# @param n [Integer] the number of rows to return
|
|
459
|
+
# @param s [String, nil] optional SELECT to apply before taking
|
|
460
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
461
|
+
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
462
|
+
# @return [Array<Hash>] the first n rows as an array of hashes
|
|
463
|
+
#
|
|
464
|
+
# @example
|
|
465
|
+
# AppQuery("SELECT * FROM users ORDER BY created_at").take(5)
|
|
466
|
+
# # => [{"id" => 1, ...}, {"id" => 2, ...}, ...]
|
|
467
|
+
#
|
|
468
|
+
# @see #first
|
|
469
|
+
def take(n, s = nil, binds: {}, cast: self.cast)
|
|
470
|
+
with_select(s).select_all("SELECT * FROM :_ LIMIT #{n.to_i}", binds:, cast:).entries
|
|
471
|
+
end
|
|
472
|
+
alias_method :limit, :take
|
|
473
|
+
|
|
436
474
|
# Executes the query and returns the first value of the first row.
|
|
437
475
|
#
|
|
438
476
|
# @param binds [Hash, nil] named bind parameters
|
|
@@ -515,6 +553,7 @@ module AppQuery
|
|
|
515
553
|
# @param c [String, Symbol] the column name to extract
|
|
516
554
|
# @param s [String, nil] optional SELECT to apply before extracting
|
|
517
555
|
# @param binds [Hash, nil] bind parameters to add
|
|
556
|
+
# @param unique [Boolean] whether to have unique values
|
|
518
557
|
# @return [Array] the column values
|
|
519
558
|
#
|
|
520
559
|
# @example Extract a single column
|
|
@@ -524,9 +563,32 @@ module AppQuery
|
|
|
524
563
|
# @example With additional filtering
|
|
525
564
|
# AppQuery("SELECT * FROM users").column(:email, "SELECT * FROM :_ WHERE active")
|
|
526
565
|
# # => ["alice@example.com", "bob@example.com"]
|
|
527
|
-
|
|
566
|
+
#
|
|
567
|
+
# @example Extract unique values
|
|
568
|
+
# AppQuery("SELECT * FROM products").column(:category, unique: true)
|
|
569
|
+
# # => ["Electronics", "Clothing", "Home"]
|
|
570
|
+
def column(c, s = nil, binds: {}, unique: false)
|
|
528
571
|
quoted_column = ActiveRecord::Base.connection.quote_column_name(c)
|
|
529
|
-
with_select(s).select_all("SELECT #{quoted_column} AS column FROM :_", binds:).column("column")
|
|
572
|
+
with_select(s).select_all("SELECT #{unique ? "DISTINCT" : ""} #{quoted_column} AS column FROM :_", binds:).column("column")
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Returns the column names from the query without fetching any rows.
|
|
576
|
+
#
|
|
577
|
+
# Uses `LIMIT 0` to get column metadata efficiently.
|
|
578
|
+
#
|
|
579
|
+
# @param s [String, nil] optional SELECT to apply before extracting
|
|
580
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
581
|
+
# @return [Array<String>] the column names
|
|
582
|
+
#
|
|
583
|
+
# @example Get column names
|
|
584
|
+
# AppQuery("SELECT id, name, email FROM users").columns
|
|
585
|
+
# # => ["id", "name", "email"]
|
|
586
|
+
#
|
|
587
|
+
# @example From a CTE
|
|
588
|
+
# AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").columns
|
|
589
|
+
# # => ["a", "b"]
|
|
590
|
+
def columns(s = nil, binds: {})
|
|
591
|
+
with_select(s).select_all("SELECT * FROM :_ LIMIT 0", binds:).columns
|
|
530
592
|
end
|
|
531
593
|
|
|
532
594
|
# Returns an array of id values from the query.
|
|
@@ -571,11 +633,6 @@ module AppQuery
|
|
|
571
633
|
# @param returning [String, nil] columns to return (Rails 7.1+ only)
|
|
572
634
|
# @return [Integer, Object] the inserted ID or returning value
|
|
573
635
|
#
|
|
574
|
-
# @example With positional binds
|
|
575
|
-
# AppQuery(<<~SQL).insert(binds: ["Let's learn SQL!"])
|
|
576
|
-
# INSERT INTO videos(title, created_at, updated_at) VALUES($1, now(), now())
|
|
577
|
-
# SQL
|
|
578
|
-
#
|
|
579
636
|
# @example With values helper
|
|
580
637
|
# articles = [{title: "First", created_at: Time.current}]
|
|
581
638
|
# AppQuery(<<~SQL).render(articles:).insert
|
|
@@ -666,6 +723,104 @@ module AppQuery
|
|
|
666
723
|
raise UnrenderedQueryError, "Query is ERB. Use #render before deleting."
|
|
667
724
|
end
|
|
668
725
|
|
|
726
|
+
# Executes COPY TO STDOUT for efficient data export.
|
|
727
|
+
#
|
|
728
|
+
# PostgreSQL-only. Uses raw connection for streaming. Raises an error
|
|
729
|
+
# when used with SQLite or other non-PostgreSQL adapters.
|
|
730
|
+
#
|
|
731
|
+
# @param s [String, nil] optional SELECT to apply before extracting
|
|
732
|
+
# @param format [:csv, :text, :binary] output format (default: :csv)
|
|
733
|
+
# @param header [Boolean] include column headers (default: true, CSV only)
|
|
734
|
+
# @param delimiter [Symbol, nil] field delimiter - :tab, :comma, :pipe, :semicolon (default: format's default)
|
|
735
|
+
# @param dest [String, IO, nil] destination - file path, IO object, or nil to return string
|
|
736
|
+
# @param binds [Hash] bind parameters
|
|
737
|
+
# @return [String, Integer, nil] CSV string if dest: nil, bytes written if dest: path, nil if dest: IO
|
|
738
|
+
#
|
|
739
|
+
# @example Return as string
|
|
740
|
+
# csv = AppQuery[:users].copy_to
|
|
741
|
+
#
|
|
742
|
+
# @example Write to file path
|
|
743
|
+
# AppQuery[:users].copy_to(dest: "export.csv")
|
|
744
|
+
#
|
|
745
|
+
# @example Write to IO object
|
|
746
|
+
# File.open("export.csv", "w") { |f| query.copy_to(dest: f) }
|
|
747
|
+
#
|
|
748
|
+
# @example Export in Rails controller
|
|
749
|
+
# respond_to do |format|
|
|
750
|
+
# format.html do
|
|
751
|
+
# @invoices = query.entries
|
|
752
|
+
#
|
|
753
|
+
# render :index
|
|
754
|
+
# end
|
|
755
|
+
#
|
|
756
|
+
# format.csv do
|
|
757
|
+
# response.headers['Content-Type'] = 'text/csv'
|
|
758
|
+
# response.headers['Content-Disposition'] = 'attachment; filename="invoices.csv"'
|
|
759
|
+
#
|
|
760
|
+
# query.unpaginated.copy_to(dest: response.stream)
|
|
761
|
+
# end
|
|
762
|
+
# end
|
|
763
|
+
#
|
|
764
|
+
# @raise [AppQuery::Error] if adapter is not PostgreSQL
|
|
765
|
+
def copy_to(s = nil, format: :csv, header: true, delimiter: nil, dest: nil, binds: {})
|
|
766
|
+
raw_conn = ActiveRecord::Base.connection.raw_connection
|
|
767
|
+
unless raw_conn.respond_to?(:copy_data)
|
|
768
|
+
raise Error, "copy_to requires PostgreSQL (current adapter does not support COPY)"
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
allowed_formats = %i[csv text binary]
|
|
772
|
+
unless allowed_formats.include?(format)
|
|
773
|
+
raise ArgumentError, "Invalid format: #{format.inspect}. Allowed: #{allowed_formats.join(", ")}"
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
delimiters = {tab: "\t", comma: ",", pipe: "|", semicolon: ";"}
|
|
777
|
+
if delimiter
|
|
778
|
+
if !delimiters.key?(delimiter)
|
|
779
|
+
raise ArgumentError, "Invalid delimiter: #{delimiter.inspect}. Allowed: #{delimiters.keys.join(", ")}"
|
|
780
|
+
elsif format == :binary
|
|
781
|
+
raise ArgumentError, "Delimiter not allowed for format :binary"
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
add_binds(**binds).with_select(s).render({}).then do |aq|
|
|
786
|
+
options = ["FORMAT #{format.to_s.upcase}"]
|
|
787
|
+
options << "HEADER" if header && format == :csv
|
|
788
|
+
options << "DELIMITER E'#{delimiters[delimiter]}'" if delimiter
|
|
789
|
+
|
|
790
|
+
inner_sql = ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
|
|
791
|
+
copy_sql = "COPY (#{inner_sql}) TO STDOUT WITH (#{options.join(", ")})"
|
|
792
|
+
|
|
793
|
+
case dest
|
|
794
|
+
when NilClass
|
|
795
|
+
output = +""
|
|
796
|
+
raw_conn.copy_data(copy_sql) do
|
|
797
|
+
while (row = raw_conn.get_copy_data)
|
|
798
|
+
output << row
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
# pg returns ASCII-8BIT, but CSV/text is UTF-8; binary stays as-is
|
|
802
|
+
(format == :binary) ? output : output.force_encoding(Encoding::UTF_8)
|
|
803
|
+
when String
|
|
804
|
+
bytes = 0
|
|
805
|
+
File.open(dest, "wb") do |f|
|
|
806
|
+
raw_conn.copy_data(copy_sql) do
|
|
807
|
+
while (row = raw_conn.get_copy_data)
|
|
808
|
+
bytes += f.write(row)
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
bytes
|
|
813
|
+
else
|
|
814
|
+
raw_conn.copy_data(copy_sql) do
|
|
815
|
+
while (row = raw_conn.get_copy_data)
|
|
816
|
+
dest.write(row)
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
nil
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
669
824
|
# @!group Query Introspection
|
|
670
825
|
|
|
671
826
|
# Returns the tokenized representation of the SQL.
|
|
@@ -690,8 +845,12 @@ module AppQuery
|
|
|
690
845
|
# @example
|
|
691
846
|
# AppQuery("WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a, b").cte_names
|
|
692
847
|
# # => ["a", "b"]
|
|
848
|
+
#
|
|
849
|
+
# @example Quoted identifiers are returned without quotes
|
|
850
|
+
# AppQuery('WITH "special*name" AS (SELECT 1) SELECT * FROM "special*name"').cte_names
|
|
851
|
+
# # => ["special*name"]
|
|
693
852
|
def cte_names
|
|
694
|
-
tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
|
|
853
|
+
tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v].delete_prefix('"').delete_suffix('"') }
|
|
695
854
|
end
|
|
696
855
|
|
|
697
856
|
# @!group Query Transformation
|
|
@@ -812,6 +971,30 @@ module AppQuery
|
|
|
812
971
|
|
|
813
972
|
# @!group CTE Manipulation
|
|
814
973
|
|
|
974
|
+
# Returns a new query focused on the specified CTE.
|
|
975
|
+
#
|
|
976
|
+
# Wraps the query to select from the named CTE, allowing you to
|
|
977
|
+
# inspect or test individual CTEs in isolation.
|
|
978
|
+
#
|
|
979
|
+
# @param name [Symbol, String] the CTE name to select from
|
|
980
|
+
# @return [Q] a new query selecting from the CTE
|
|
981
|
+
# @raise [ArgumentError] if the CTE doesn't exist
|
|
982
|
+
#
|
|
983
|
+
# @example Focus on a specific CTE
|
|
984
|
+
# query = AppQuery("WITH published AS (SELECT * FROM articles WHERE published) SELECT * FROM published")
|
|
985
|
+
# query.cte(:published).entries
|
|
986
|
+
#
|
|
987
|
+
# @example Chain with other methods
|
|
988
|
+
# ArticleQuery.new.cte(:active_articles).take(5)
|
|
989
|
+
def cte(name)
|
|
990
|
+
name = name.to_s
|
|
991
|
+
unless cte_names.include?(name)
|
|
992
|
+
raise ArgumentError, "Unknown CTE #{name.inspect}. Available: #{cte_names.inspect}"
|
|
993
|
+
end
|
|
994
|
+
quoted = ActiveRecord::Base.connection.quote_table_name(name)
|
|
995
|
+
with_select("SELECT * FROM #{quoted}")
|
|
996
|
+
end
|
|
997
|
+
|
|
815
998
|
# Prepends a CTE to the beginning of the WITH clause.
|
|
816
999
|
#
|
|
817
1000
|
# If the query has no CTEs, wraps it with WITH. If the query already has
|
|
@@ -964,3 +1147,4 @@ rescue LoadError
|
|
|
964
1147
|
end
|
|
965
1148
|
|
|
966
1149
|
require_relative "app_query/rspec" if Object.const_defined? :RSpec
|
|
1150
|
+
require_relative "app_query/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generates a query class with SQL file and spec.
|
|
3
|
+
|
|
4
|
+
Also generates ApplicationQuery if it doesn't exist.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
rails generate app_query:query products
|
|
8
|
+
rails generate query products # shorthand alias
|
|
9
|
+
|
|
10
|
+
This creates:
|
|
11
|
+
Base: app/queries/application_query.rb (if not exists)
|
|
12
|
+
Class: app/queries/products_query.rb
|
|
13
|
+
SQL: app/queries/products.sql
|
|
14
|
+
Spec: spec/queries/products_query_spec.rb
|
|
15
|
+
|
|
16
|
+
With namespace:
|
|
17
|
+
rails generate query admin/reports
|
|
18
|
+
|
|
19
|
+
Creates:
|
|
20
|
+
Class: app/queries/admin/reports_query.rb
|
|
21
|
+
SQL: app/queries/admin/reports.sql
|
|
22
|
+
Spec: spec/queries/admin/reports_query_spec.rb
|
|
23
|
+
|
|
24
|
+
See also:
|
|
25
|
+
rails generate app_query:example
|
|
26
|
+
|
|
27
|
+
Generates a fully annotated example query demonstrating
|
|
28
|
+
binds, vars, CTEs, and RSpec testing patterns.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AppQuery
|
|
4
|
+
module Generators
|
|
5
|
+
class ExampleGenerator < Rails::Generators::Base
|
|
6
|
+
desc <<~DESC
|
|
7
|
+
Generates annotated example query demonstrating binds, vars, CTEs, and testing patterns.
|
|
8
|
+
|
|
9
|
+
See also:
|
|
10
|
+
rails generate query --help
|
|
11
|
+
DESC
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def create_application_query
|
|
15
|
+
return if File.exist?(application_query_path)
|
|
16
|
+
|
|
17
|
+
template "application_query.rb", application_query_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_example_files
|
|
21
|
+
template "example_query.rb", File.join(query_path, "example_query.rb")
|
|
22
|
+
template "example.sql.erb", File.join(query_path, "example.sql.erb")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
hook_for :test_framework, as: :app_query_example
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def query_path
|
|
30
|
+
::AppQuery.configuration.query_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def application_query_path
|
|
34
|
+
File.join(query_path, "application_query.rb")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AppQuery
|
|
4
|
+
module Generators
|
|
5
|
+
class QueryGenerator < Rails::Generators::NamedBase
|
|
6
|
+
desc <<~DESC
|
|
7
|
+
Generates a query class with SQL file and spec.
|
|
8
|
+
|
|
9
|
+
See also:
|
|
10
|
+
rails generate query --help
|
|
11
|
+
DESC
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def create_application_query
|
|
15
|
+
return if File.exist?(application_query_path)
|
|
16
|
+
|
|
17
|
+
template "application_query.rb", application_query_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_query_class
|
|
21
|
+
template "query.rb",
|
|
22
|
+
File.join(query_path, class_path, "#{file_name}_query.rb")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_query_file
|
|
26
|
+
template "query.sql",
|
|
27
|
+
File.join(query_path, class_path, "#{file_name}.sql")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
hook_for :test_framework
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def query_path
|
|
35
|
+
::AppQuery.configuration.query_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def application_query_path
|
|
39
|
+
File.join(query_path, "application_query.rb")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- ExampleQuery SQL Template
|
|
3
|
+
--
|
|
4
|
+
-- This file demonstrates AppQuery SQL patterns. Delete once familiar.
|
|
5
|
+
--
|
|
6
|
+
-- Key concepts:
|
|
7
|
+
-- - CTEs (WITH clauses) for organizing complex queries
|
|
8
|
+
-- - ∶bind_name for safe parameter binding
|
|
9
|
+
-- - ERB for conditional SQL generation
|
|
10
|
+
-- =============================================================================
|
|
11
|
+
|
|
12
|
+
-- == CTEs (Common Table Expressions)
|
|
13
|
+
-- Break complex queries into readable, testable pieces.
|
|
14
|
+
-- Each CTE can be tested independently in specs.
|
|
15
|
+
WITH products(id, name, supplier_id, active) AS (
|
|
16
|
+
-- In real code, this would be: SELECT * FROM products
|
|
17
|
+
VALUES (1, 'Widget', 100, true),
|
|
18
|
+
(2, 'Gadget', 100, false),
|
|
19
|
+
(3, 'Gizmo', 200, true)
|
|
20
|
+
),
|
|
21
|
+
-- == Derived CTEs
|
|
22
|
+
-- Build on previous CTEs to filter/transform data.
|
|
23
|
+
active_products AS (
|
|
24
|
+
SELECT * FROM products WHERE active
|
|
25
|
+
)
|
|
26
|
+
-- == Final SELECT
|
|
27
|
+
-- The main query that uses the CTEs above.
|
|
28
|
+
SELECT * FROM active_products
|
|
29
|
+
-- == Conditional SQL with ERB
|
|
30
|
+
-- Use vars (like @admin) to conditionally include SQL.
|
|
31
|
+
-- Remember: vars are for trusted values only, not user input!
|
|
32
|
+
<%% unless @admin -%>
|
|
33
|
+
-- == Bind Parameters
|
|
34
|
+
-- Use :name syntax for safe parameter binding.
|
|
35
|
+
-- Binds are defined in the query class with `bind :supplier_id`.
|
|
36
|
+
-- non-admins: MUST bring supplier_id
|
|
37
|
+
WHERE (:supplier_id::INTEGER IS NOT NULL AND supplier_id = :supplier_id)
|
|
38
|
+
<%% else -%>
|
|
39
|
+
-- admin: MAY bring supplier_id
|
|
40
|
+
WHERE (:supplier_id::INTEGER IS NULL OR supplier_id = :supplier_id)
|
|
41
|
+
<%% end -%>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ExampleQuery demonstrates all AppQuery::BaseQuery features.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# ExampleQuery.new(supplier_id: 1).entries
|
|
7
|
+
# ExampleQuery.new(supplier_id: 1, admin: true).entries
|
|
8
|
+
# ExampleQuery.new(supplier_id: 1).query.to_s # inspect SQL
|
|
9
|
+
#
|
|
10
|
+
# Delete this file once you're familiar with the patterns.
|
|
11
|
+
#
|
|
12
|
+
class ExampleQuery < ApplicationQuery
|
|
13
|
+
# == Binds
|
|
14
|
+
#
|
|
15
|
+
# Bind parameters are safe from SQL injection. Use for WHERE clauses.
|
|
16
|
+
# In SQL: WHERE supplier_id = :supplier_id
|
|
17
|
+
#
|
|
18
|
+
bind :supplier_id, default: nil
|
|
19
|
+
|
|
20
|
+
# == Vars
|
|
21
|
+
#
|
|
22
|
+
# Template variables for dynamic SQL generation (ERB).
|
|
23
|
+
# In SQL: <%% if @admin %>...<%% end %>
|
|
24
|
+
#
|
|
25
|
+
# WARNING: vars are NOT safe from injection - only use for trusted values
|
|
26
|
+
# like sorting options, not user input.
|
|
27
|
+
#
|
|
28
|
+
var :admin, default: false
|
|
29
|
+
|
|
30
|
+
# == Casts
|
|
31
|
+
#
|
|
32
|
+
# Automatically cast result columns to Ruby types.
|
|
33
|
+
# Supported: :date, :datetime, :time, :integer, :float, :decimal,
|
|
34
|
+
# :boolean, :json, :array (PostgreSQL)
|
|
35
|
+
#
|
|
36
|
+
# cast metadata: :json, published_at: :datetime
|
|
37
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% module_namespacing do -%>
|
|
4
|
+
class <%= class_name %>Query < ApplicationQuery
|
|
5
|
+
# bind :user_id # SQL: WHERE user_id = :user_id
|
|
6
|
+
# var :admin, default: false # ERB: <%% if @admin %>
|
|
7
|
+
# cast published_at: :date # Cast column to Ruby type
|
|
8
|
+
end
|
|
9
|
+
<% end -%>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SELECT * FROM <%= file_name %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "app_query/query_generator"
|
|
4
|
+
|
|
5
|
+
class QueryGenerator < AppQuery::Generators::QueryGenerator
|
|
6
|
+
# Hidden alias for app_query:query
|
|
7
|
+
# Usage: rails g query products
|
|
8
|
+
source_root AppQuery::Generators::QueryGenerator.source_root
|
|
9
|
+
hide!
|
|
10
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rspec
|
|
4
|
+
module Generators
|
|
5
|
+
class AppQueryExampleGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def create_test_file
|
|
9
|
+
template "app_query_example_spec.rb", "spec/queries/example_query_spec.rb"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/{rails/generators/rspec/query_generator.rb → generators/rspec/app_query_generator.rb}
RENAMED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Rspec
|
|
2
4
|
module Generators
|
|
3
|
-
class
|
|
5
|
+
class AppQueryGenerator < Rails::Generators::NamedBase
|
|
4
6
|
source_root File.expand_path("templates", __dir__)
|
|
5
7
|
|
|
6
8
|
def create_test_file
|
|
7
|
-
template "
|
|
9
|
+
template "app_query_spec.rb",
|
|
8
10
|
File.join("spec/queries", class_path, "#{file_name}_query_spec.rb")
|
|
9
11
|
end
|
|
10
|
-
|
|
11
|
-
hide!
|
|
12
|
-
|
|
13
|
-
private
|
|
14
|
-
|
|
15
|
-
def query_path
|
|
16
|
-
AppQuery.configuration.query_path
|
|
17
|
-
end
|
|
18
12
|
end
|
|
19
13
|
end
|
|
20
14
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "app_query_generator"
|
|
4
|
+
|
|
5
|
+
module Rspec
|
|
6
|
+
module Generators
|
|
7
|
+
class QueryGenerator < AppQueryGenerator
|
|
8
|
+
# Hidden hook for query generator alias
|
|
9
|
+
source_root AppQueryGenerator.source_root
|
|
10
|
+
hide!
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
|
|
5
|
+
# ExampleQuery Spec
|
|
6
|
+
#
|
|
7
|
+
# This demonstrates all AppQuery RSpec helpers. Delete once familiar.
|
|
8
|
+
#
|
|
9
|
+
# Setup: Add to spec/rails_helper.rb:
|
|
10
|
+
# require "app_query/rspec"
|
|
11
|
+
#
|
|
12
|
+
RSpec.describe ExampleQuery, type: :query do
|
|
13
|
+
# ============================================================================
|
|
14
|
+
# Basic Usage
|
|
15
|
+
# ============================================================================
|
|
16
|
+
#
|
|
17
|
+
# `described_query` instantiates the query class and returns results.
|
|
18
|
+
# It automatically uses binds/vars from metadata.
|
|
19
|
+
|
|
20
|
+
describe "basic query" do
|
|
21
|
+
it "returns an array of hashes" do
|
|
22
|
+
expect(described_query(admin: true).entries).to be_an(Array)
|
|
23
|
+
expect(described_query(admin: true).first).to be_a(Hash)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ============================================================================
|
|
28
|
+
# Testing CTEs
|
|
29
|
+
# ============================================================================
|
|
30
|
+
#
|
|
31
|
+
# Use `describe "cte <name>"` to test a specific CTE in isolation.
|
|
32
|
+
# `described_query` will SELECT from that CTE instead of the full query.
|
|
33
|
+
|
|
34
|
+
describe "cte products" do
|
|
35
|
+
it "contains all products (including inactive)" do
|
|
36
|
+
expect(described_query.count).to eq(3)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "has the expected columns" do
|
|
40
|
+
expect(described_query.columns).to contain_exactly(
|
|
41
|
+
"id", "name", "supplier_id", "active"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe "cte active_products" do
|
|
47
|
+
it "only contains active products" do
|
|
48
|
+
expect(described_query.entries).to all(include("active" => true))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "excludes inactive products" do
|
|
52
|
+
expect(described_query.entries.size).to eq(2)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ============================================================================
|
|
57
|
+
# Passing Binds via Metadata
|
|
58
|
+
# ============================================================================
|
|
59
|
+
#
|
|
60
|
+
# Use `binds: {key: value}` in describe/context metadata.
|
|
61
|
+
# These are passed to the query class constructor.
|
|
62
|
+
|
|
63
|
+
describe "filtered by supplier", binds: {supplier_id: 100} do
|
|
64
|
+
it "returns only products for that supplier" do
|
|
65
|
+
expect(described_query.entries).to all(include("supplier_id" => 100))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ============================================================================
|
|
70
|
+
# Passing Vars via Metadata
|
|
71
|
+
# ============================================================================
|
|
72
|
+
#
|
|
73
|
+
# Use `vars: {key: value}` for template variables.
|
|
74
|
+
# These control conditional SQL generation.
|
|
75
|
+
|
|
76
|
+
describe "as admin", vars: {admin: true} do
|
|
77
|
+
it "returns all active products regardless of supplier" do
|
|
78
|
+
expect(described_query.entries.size).to eq(2)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "includes products from multiple suppliers" do
|
|
82
|
+
expect(described_query.column(:supplier_id, unique: true).count).to be > 1
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ============================================================================
|
|
87
|
+
# Combining Binds and Vars
|
|
88
|
+
# ============================================================================
|
|
89
|
+
|
|
90
|
+
describe "non-admin with supplier", binds: {supplier_id: 100}, vars: {admin: false} do
|
|
91
|
+
it "filters by supplier" do
|
|
92
|
+
expect(described_query.entries).to all(include("supplier_id" => 100))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ============================================================================
|
|
97
|
+
# Per-Test Overrides
|
|
98
|
+
# ============================================================================
|
|
99
|
+
#
|
|
100
|
+
# Pass arguments to `described_query()` to override for a single test.
|
|
101
|
+
|
|
102
|
+
describe "per-test customization" do
|
|
103
|
+
it "can override binds inline" do
|
|
104
|
+
result = described_query(supplier_id: 200, admin: false)
|
|
105
|
+
expect(result.entries).to all(include("supplier_id" => 200))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ============================================================================
|
|
110
|
+
# Custom Query Building
|
|
111
|
+
# ============================================================================
|
|
112
|
+
#
|
|
113
|
+
# Override `build_query` for custom instantiation patterns.
|
|
114
|
+
|
|
115
|
+
describe "custom build" do
|
|
116
|
+
def build_query(**kwargs)
|
|
117
|
+
# Example: always set admin to true in this context
|
|
118
|
+
described_class.new(admin: true, **kwargs)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "uses the custom build method" do
|
|
122
|
+
# This will use admin: true from build_query
|
|
123
|
+
expect(described_query.entries.size).to eq(2)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ============================================================================
|
|
128
|
+
# SQL Logging
|
|
129
|
+
# ============================================================================
|
|
130
|
+
#
|
|
131
|
+
# Add `log: true` to see the actual SQL being executed.
|
|
132
|
+
# Useful for debugging.
|
|
133
|
+
|
|
134
|
+
describe "with logging", log: true do
|
|
135
|
+
it "logs SQL to stdout" do
|
|
136
|
+
# Check your terminal - you'll see the SQL query
|
|
137
|
+
described_query.entries
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: appquery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- ".yard/templates/default/fulldoc/html/css/dark.css"
|
|
47
47
|
- ".yard/templates/default/fulldoc/html/js/app.js"
|
|
48
48
|
- ".yard/templates/default/fulldoc/html/setup.rb"
|
|
49
|
+
- ".yard/templates/default/layout/html/footer.erb"
|
|
49
50
|
- ".yard/templates/default/layout/html/setup.rb"
|
|
50
51
|
- ".yardopts"
|
|
51
52
|
- Appraisals
|
|
@@ -59,17 +60,27 @@ files:
|
|
|
59
60
|
- lib/app_query/base_query.rb
|
|
60
61
|
- lib/app_query/mappable.rb
|
|
61
62
|
- lib/app_query/paginatable.rb
|
|
63
|
+
- lib/app_query/railtie.rb
|
|
62
64
|
- lib/app_query/render_helpers.rb
|
|
63
65
|
- lib/app_query/rspec.rb
|
|
64
66
|
- lib/app_query/rspec/helpers.rb
|
|
65
67
|
- lib/app_query/tokenizer.rb
|
|
66
68
|
- lib/app_query/version.rb
|
|
67
69
|
- lib/appquery.rb
|
|
68
|
-
- lib/
|
|
69
|
-
- lib/
|
|
70
|
-
- lib/
|
|
71
|
-
- lib/
|
|
72
|
-
- lib/
|
|
70
|
+
- lib/generators/app_query/USAGE
|
|
71
|
+
- lib/generators/app_query/example_generator.rb
|
|
72
|
+
- lib/generators/app_query/query_generator.rb
|
|
73
|
+
- lib/generators/app_query/templates/application_query.rb.tt
|
|
74
|
+
- lib/generators/app_query/templates/example.sql.erb.tt
|
|
75
|
+
- lib/generators/app_query/templates/example_query.rb.tt
|
|
76
|
+
- lib/generators/app_query/templates/query.rb.tt
|
|
77
|
+
- lib/generators/app_query/templates/query.sql.tt
|
|
78
|
+
- lib/generators/query_generator.rb
|
|
79
|
+
- lib/generators/rspec/app_query_example_generator.rb
|
|
80
|
+
- lib/generators/rspec/app_query_generator.rb
|
|
81
|
+
- lib/generators/rspec/query_generator.rb
|
|
82
|
+
- lib/generators/rspec/templates/app_query_example_spec.rb.tt
|
|
83
|
+
- lib/generators/rspec/templates/app_query_spec.rb.tt
|
|
73
84
|
- mise.local.toml.example
|
|
74
85
|
- mise.toml
|
|
75
86
|
- rakelib/gem.rake
|
|
@@ -89,7 +100,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
89
100
|
requirements:
|
|
90
101
|
- - ">="
|
|
91
102
|
- !ruby/object:Gem::Version
|
|
92
|
-
version: 3.
|
|
103
|
+
version: 3.3.0
|
|
93
104
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
105
|
requirements:
|
|
95
106
|
- - ">="
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
Description:
|
|
2
|
-
Generates a new query-file and invokes your template
|
|
3
|
-
engine and test framework generators.
|
|
4
|
-
|
|
5
|
-
Example:
|
|
6
|
-
`bin/rails generate query reports/weekly`
|
|
7
|
-
|
|
8
|
-
creates an SQL file and test:
|
|
9
|
-
Query: app/queries/reports/weekly.sql
|
|
10
|
-
Spec: spec/queries/reports/weekly_query_spec.rb
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
module Rails
|
|
2
|
-
module Generators
|
|
3
|
-
class QueryGenerator < NamedBase
|
|
4
|
-
source_root File.expand_path("templates", __dir__)
|
|
5
|
-
|
|
6
|
-
def create_query_file
|
|
7
|
-
template "query.sql",
|
|
8
|
-
File.join(AppQuery.configuration.query_path, class_path, "#{file_name}.sql")
|
|
9
|
-
|
|
10
|
-
# in_root do
|
|
11
|
-
# if behavior == :invoke && !File.exist?(application_mailbox_file_name)
|
|
12
|
-
# template "application_mailbox.rb", application_mailbox_file_name
|
|
13
|
-
# end
|
|
14
|
-
# end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
hook_for :test_framework
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
-- Instantiate this query with AppQuery[<%= (class_path << file_name).join("/").inspect %>]
|
|
2
|
-
|
|
3
|
-
WITH
|
|
4
|
-
articles(article_id, article_title) AS (
|
|
5
|
-
VALUES (1, 'Some title'),
|
|
6
|
-
(2, 'Another article')
|
|
7
|
-
),
|
|
8
|
-
authors(author_id, author_name) AS (
|
|
9
|
-
VALUES (1, 'Some Author'),
|
|
10
|
-
(2, 'Another Author')
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
SELECT *
|
|
14
|
-
FROM artciles
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rails_helper"
|
|
4
|
-
|
|
5
|
-
RSpec.describe "AppQuery <%= (class_path << file_name).join("/") %>", type: :query, default_binds: nil do
|
|
6
|
-
describe "CTE articles" do
|
|
7
|
-
specify do
|
|
8
|
-
expect(select_all(select: "select * from :cte").cast_entries).to \
|
|
9
|
-
include(a_hash_including("article_id" => 1))
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
end
|