appquery 0.4.0 → 0.6.0.alpha
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/.irbrc +9 -0
- data/CHANGELOG.md +123 -0
- data/README.md +84 -92
- data/Rakefile +10 -0
- data/lib/app_query/base_query.rb +182 -0
- data/lib/app_query/mappable.rb +86 -0
- data/lib/app_query/paginatable.rb +152 -0
- data/lib/app_query/render_helpers.rb +49 -11
- data/lib/app_query/rspec/helpers.rb +9 -1
- data/lib/app_query/tokenizer.rb +2 -1
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +317 -138
- data/mise.toml +1 -1
- data/rakelib/gem.rake +22 -22
- metadata +6 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module AppQuery
|
|
6
|
+
# Maps query results to Ruby objects (e.g., Data classes, Structs).
|
|
7
|
+
#
|
|
8
|
+
# By default, looks for an `Item` constant in the query class.
|
|
9
|
+
# Use `map_to` to specify a different class.
|
|
10
|
+
#
|
|
11
|
+
# @example With default Item class
|
|
12
|
+
# class ArticlesQuery < ApplicationQuery
|
|
13
|
+
# include AppQuery::Mappable
|
|
14
|
+
#
|
|
15
|
+
# class Item < Data.define(:title, :url, :published_on)
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# articles = ArticlesQuery.new.entries
|
|
20
|
+
# articles.first.title # => "Hello World"
|
|
21
|
+
#
|
|
22
|
+
# @example With explicit map_to
|
|
23
|
+
# class ArticlesQuery < ApplicationQuery
|
|
24
|
+
# include AppQuery::Mappable
|
|
25
|
+
# map_to :article
|
|
26
|
+
#
|
|
27
|
+
# class Article < Data.define(:title, :url)
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @example Skip mapping with raw
|
|
32
|
+
# articles = ArticlesQuery.new.raw.entries
|
|
33
|
+
# articles.first # => {"title" => "Hello", "url" => "..."}
|
|
34
|
+
module Mappable
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
def map_to(name = nil)
|
|
39
|
+
name ? @map_to = name : @map_to
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def raw
|
|
44
|
+
@raw = true
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def select_all
|
|
49
|
+
map_result(super)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def select_one
|
|
53
|
+
map_one(super)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def map_result(result)
|
|
59
|
+
return result if @raw
|
|
60
|
+
return result unless (klass = resolve_map_klass)
|
|
61
|
+
|
|
62
|
+
attrs = klass.members
|
|
63
|
+
result.transform! { |row| klass.new(**row.symbolize_keys.slice(*attrs)) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def map_one(result)
|
|
67
|
+
return result if @raw
|
|
68
|
+
return result unless (klass = resolve_map_klass)
|
|
69
|
+
return result unless result
|
|
70
|
+
|
|
71
|
+
attrs = klass.members
|
|
72
|
+
klass.new(**result.symbolize_keys.slice(*attrs))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_map_klass
|
|
76
|
+
case (name = self.class.map_to)
|
|
77
|
+
when Symbol
|
|
78
|
+
self.class.const_get(name.to_s.classify)
|
|
79
|
+
when Class
|
|
80
|
+
name
|
|
81
|
+
when nil
|
|
82
|
+
self.class.const_get(:Item) if self.class.const_defined?(:Item)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module AppQuery
|
|
6
|
+
# Adds pagination support to query classes.
|
|
7
|
+
#
|
|
8
|
+
# Provides two modes:
|
|
9
|
+
# - **With count**: Full pagination with page numbers (uses COUNT query)
|
|
10
|
+
# - **Without count**: Simple prev/next for large datasets (uses limit+1 trick)
|
|
11
|
+
#
|
|
12
|
+
# Compatible with Kaminari view helpers.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# class ApplicationQuery < AppQuery::BaseQuery
|
|
16
|
+
# include AppQuery::Paginatable
|
|
17
|
+
# per_page 50
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# class ArticlesQuery < ApplicationQuery
|
|
21
|
+
# per_page 10
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# # With count (full pagination)
|
|
25
|
+
# articles = ArticlesQuery.new.paginate(page: 1).entries
|
|
26
|
+
# articles.total_pages # => 5
|
|
27
|
+
# articles.current_page # => 1
|
|
28
|
+
#
|
|
29
|
+
# # Without count (large datasets)
|
|
30
|
+
# articles = ArticlesQuery.new.paginate(page: 1, without_count: true).entries
|
|
31
|
+
# articles.next_page # => 2 (or nil if last page)
|
|
32
|
+
module Paginatable
|
|
33
|
+
extend ActiveSupport::Concern
|
|
34
|
+
|
|
35
|
+
# Kaminari-compatible wrapper for paginated results.
|
|
36
|
+
class PaginatedResult
|
|
37
|
+
include Enumerable
|
|
38
|
+
|
|
39
|
+
delegate :each, :size, :[], :empty?, :first, :last, to: :@records
|
|
40
|
+
|
|
41
|
+
def initialize(records, page:, per_page:, total_count: nil, has_next: nil)
|
|
42
|
+
@records = records
|
|
43
|
+
@page = page
|
|
44
|
+
@per_page = per_page
|
|
45
|
+
@total_count = total_count
|
|
46
|
+
@has_next = has_next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def current_page = @page
|
|
50
|
+
|
|
51
|
+
def limit_value = @per_page
|
|
52
|
+
|
|
53
|
+
def prev_page = (@page > 1) ? @page - 1 : nil
|
|
54
|
+
|
|
55
|
+
def first_page? = @page == 1
|
|
56
|
+
|
|
57
|
+
def total_count
|
|
58
|
+
@total_count || raise("total_count not available in without_count mode")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def total_pages
|
|
62
|
+
return nil unless @total_count
|
|
63
|
+
(@total_count.to_f / @per_page).ceil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def next_page
|
|
67
|
+
if @total_count
|
|
68
|
+
(@page < total_pages) ? @page + 1 : nil
|
|
69
|
+
else
|
|
70
|
+
@has_next ? @page + 1 : nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def last_page?
|
|
75
|
+
if @total_count
|
|
76
|
+
@page >= total_pages
|
|
77
|
+
else
|
|
78
|
+
!@has_next
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def out_of_range?
|
|
83
|
+
empty? && @page > 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def transform!
|
|
87
|
+
@records = @records.map { |r| yield(r) }
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
included do
|
|
93
|
+
var :page, default: nil
|
|
94
|
+
var :per_page, default: -> { self.class.per_page }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class_methods do
|
|
98
|
+
def per_page(value = nil)
|
|
99
|
+
if value.nil?
|
|
100
|
+
return @per_page if defined?(@per_page)
|
|
101
|
+
superclass.respond_to?(:per_page) ? superclass.per_page : 25
|
|
102
|
+
else
|
|
103
|
+
@per_page = value
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def paginate(page: 1, per_page: self.class.per_page, without_count: false)
|
|
109
|
+
@page = page
|
|
110
|
+
@per_page = per_page
|
|
111
|
+
@without_count = without_count
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def entries
|
|
116
|
+
@_entries ||= build_paginated_result(super)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def total_count
|
|
120
|
+
@_total_count ||= unpaginated_query.count
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def unpaginated_query
|
|
124
|
+
base_query
|
|
125
|
+
.render(**render_vars.except(:page, :per_page))
|
|
126
|
+
.with_binds(**bind_vars)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def build_paginated_result(entries)
|
|
132
|
+
return entries unless @page # No pagination requested
|
|
133
|
+
|
|
134
|
+
if @without_count
|
|
135
|
+
has_next = entries.size > @per_page
|
|
136
|
+
records = has_next ? entries.first(@per_page) : entries
|
|
137
|
+
PaginatedResult.new(records, page: @page, per_page: @per_page, has_next: has_next)
|
|
138
|
+
else
|
|
139
|
+
PaginatedResult.new(entries, page: @page, per_page: @per_page, total_count: total_count)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def render_vars
|
|
144
|
+
vars = super
|
|
145
|
+
# Fetch one extra row in without_count mode to detect if there's more
|
|
146
|
+
if @without_count && vars[:per_page]
|
|
147
|
+
vars = vars.merge(per_page: vars[:per_page] + 1)
|
|
148
|
+
end
|
|
149
|
+
vars
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -155,32 +155,70 @@ module AppQuery
|
|
|
155
155
|
# order_by(year: :desc, month: :desc)
|
|
156
156
|
# #=> "ORDER BY year DESC, month DESC"
|
|
157
157
|
#
|
|
158
|
-
# @example Mixed directions
|
|
159
|
-
# order_by(published_on: :desc, title: :asc)
|
|
160
|
-
# #=> "ORDER BY published_on DESC, title ASC"
|
|
161
|
-
#
|
|
162
158
|
# @example Column without direction (uses database default)
|
|
163
159
|
# order_by(id: nil)
|
|
164
160
|
# #=> "ORDER BY id"
|
|
165
161
|
#
|
|
162
|
+
# @example SQL literal
|
|
163
|
+
# order_by("RANDOM()")
|
|
164
|
+
# #=> "ORDER BY RANDOM()"
|
|
165
|
+
#
|
|
166
166
|
# @example In an ERB template with a variable
|
|
167
167
|
# SELECT * FROM articles
|
|
168
168
|
# <%= order_by(ordering) %>
|
|
169
169
|
#
|
|
170
170
|
# @example Making it optional (when ordering may not be provided)
|
|
171
|
-
# <%= @
|
|
171
|
+
# <%= @order.presence && order_by(ordering) %>
|
|
172
172
|
#
|
|
173
173
|
# @example With default fallback
|
|
174
|
-
# <%= order_by(@order || {id: :desc}) %>
|
|
174
|
+
# <%= order_by(@order.presence || {id: :desc}) %>
|
|
175
175
|
#
|
|
176
176
|
# @raise [ArgumentError] if hash is blank (nil, empty, or not present)
|
|
177
177
|
#
|
|
178
178
|
# @note The hash must not be blank. Use conditional ERB for optional ordering.
|
|
179
|
-
def order_by(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
179
|
+
def order_by(order)
|
|
180
|
+
usage = <<~USAGE
|
|
181
|
+
Provide columns to sort by, e.g. order_by(id: :asc), or SQL-literal, e.g. order_by("RANDOM()") (got #{order.inspect}).
|
|
182
|
+
USAGE
|
|
183
|
+
raise ArgumentError, usage unless order.present?
|
|
184
|
+
|
|
185
|
+
case order
|
|
186
|
+
when String then "ORDER BY #{order}"
|
|
187
|
+
when Hash
|
|
188
|
+
"ORDER BY " + order.map do |k, v|
|
|
189
|
+
v.nil? ? k : [k, v.upcase].join(" ")
|
|
190
|
+
end.join(", ")
|
|
191
|
+
else
|
|
192
|
+
raise ArgumentError, usage
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Generates a LIMIT/OFFSET clause for pagination.
|
|
197
|
+
#
|
|
198
|
+
# @param page [Integer] the page number (1-indexed)
|
|
199
|
+
# @param per_page [Integer] the number of items per page
|
|
200
|
+
# @return [String] the LIMIT/OFFSET clause
|
|
201
|
+
#
|
|
202
|
+
# @example Basic pagination
|
|
203
|
+
# paginate(page: 1, per_page: 25)
|
|
204
|
+
# #=> "LIMIT 25 OFFSET 0"
|
|
205
|
+
#
|
|
206
|
+
# @example Second page
|
|
207
|
+
# paginate(page: 2, per_page: 25)
|
|
208
|
+
# #=> "LIMIT 25 OFFSET 25"
|
|
209
|
+
#
|
|
210
|
+
# @example In an ERB template
|
|
211
|
+
# SELECT * FROM articles
|
|
212
|
+
# ORDER BY created_at DESC
|
|
213
|
+
# <%= paginate(page: page, per_page: per_page) %>
|
|
214
|
+
#
|
|
215
|
+
# @raise [ArgumentError] if page or per_page is not a positive integer
|
|
216
|
+
def paginate(page:, per_page:)
|
|
217
|
+
raise ArgumentError, "page must be a positive integer (got #{page.inspect})" unless page.is_a?(Integer) && page > 0
|
|
218
|
+
raise ArgumentError, "per_page must be a positive integer (got #{per_page.inspect})" unless per_page.is_a?(Integer) && per_page > 0
|
|
219
|
+
|
|
220
|
+
offset = (page - 1) * per_page
|
|
221
|
+
"LIMIT #{per_page} OFFSET #{offset}"
|
|
184
222
|
end
|
|
185
223
|
|
|
186
224
|
private
|
|
@@ -5,6 +5,10 @@ module AppQuery
|
|
|
5
5
|
self.class.default_binds
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
+
def default_vars
|
|
9
|
+
self.class.default_vars
|
|
10
|
+
end
|
|
11
|
+
|
|
8
12
|
def expand_select(s)
|
|
9
13
|
s.gsub(":cte", cte_name)
|
|
10
14
|
end
|
|
@@ -24,7 +28,7 @@ module AppQuery
|
|
|
24
28
|
def described_query(select: nil)
|
|
25
29
|
select ||= "SELECT * FROM :cte" if cte_name
|
|
26
30
|
select &&= expand_select(select) if cte_name
|
|
27
|
-
self.class.described_query.with_select(select)
|
|
31
|
+
self.class.described_query.render(default_vars).with_select(select)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def cte_name
|
|
@@ -72,6 +76,10 @@ module AppQuery
|
|
|
72
76
|
metadatas.find { _1[:default_binds] }&.[](:default_binds) || []
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
def default_vars
|
|
80
|
+
metadatas.find { _1[:default_vars] }&.[](:default_vars) || {}
|
|
81
|
+
end
|
|
82
|
+
|
|
75
83
|
def included(klass)
|
|
76
84
|
super
|
|
77
85
|
# Inject classmethods into the group.
|
data/lib/app_query/tokenizer.rb
CHANGED
|
@@ -95,8 +95,9 @@ module AppQuery
|
|
|
95
95
|
if eos?
|
|
96
96
|
emit_token "COMMA", v: ","
|
|
97
97
|
emit_token "WHITESPACE", v: "\n"
|
|
98
|
+
elsif match?(/\s/)
|
|
99
|
+
push_return :lex_prepend_cte, :lex_whitespace
|
|
98
100
|
else
|
|
99
|
-
# emit_token "WHITESPACE", v: " "
|
|
100
101
|
push_return :lex_prepend_cte, :lex_recursive_cte
|
|
101
102
|
end
|
|
102
103
|
end
|
data/lib/app_query/version.rb
CHANGED