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.
@@ -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
- # <%= @ordering.presence && order_by(ordering) %>
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(hash)
180
- raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
181
- "ORDER BY " + hash.map do |k, v|
182
- v.nil? ? k : [k, v.upcase].join(" ")
183
- end.join(", ")
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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0.alpha"
5
5
  end