appquery 0.5.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/CHANGELOG.md +77 -0
- data/README.md +17 -15
- 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/version.rb +1 -1
- data/lib/app_query.rb +90 -8
- data/rakelib/gem.rake +22 -22
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f00b1819f60c555c6400c5843dd4682e27be590a967593eeaaabb3cca267789
|
|
4
|
+
data.tar.gz: 9539d6caab140091640ffc7174745921a296005d58bc545c99c7492cdb1356be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc7edf38ce15320b3bb58acc67460f7431e44f521be1b2a7dec6bd57cadbe8ba3c7c3570f8125bed6429e44a2e7c5f48ffbc64402f8bb5c00a2d6654d9350796
|
|
7
|
+
data.tar.gz: 01b227941d0aa8065c7d787ee7af22a1f6251860473a5fb11130965fd073f1dfdffb4f0cb3ce1569fa4be80728982f6f122efd9d85766c9ca245f19b1bbb5925
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### β¨ Features
|
|
4
|
+
|
|
5
|
+
- ποΈ **`AppQuery::BaseQuery`** β structured query objects with explicit parameter declaration
|
|
6
|
+
```ruby
|
|
7
|
+
class ArticlesQuery < AppQuery::BaseQuery
|
|
8
|
+
bind :author_id
|
|
9
|
+
bind :status, default: nil
|
|
10
|
+
var :order_by, default: "created_at DESC"
|
|
11
|
+
cast published_at: :datetime
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
ArticlesQuery.new(author_id: 1).entries
|
|
15
|
+
ArticlesQuery.new(author_id: 1, status: "draft").first
|
|
16
|
+
```
|
|
17
|
+
Benefits over `AppQuery[:my_query]`:
|
|
18
|
+
- Explicit `bind` and `var` declarations with defaults
|
|
19
|
+
- Unknown parameter validation (catches typos)
|
|
20
|
+
- Self-documenting: `ArticlesQuery.binds`, `ArticlesQuery.vars`
|
|
21
|
+
- Middleware support via concerns
|
|
22
|
+
|
|
23
|
+
- π **`AppQuery::Paginatable`** β pagination middleware (Kaminari-compatible)
|
|
24
|
+
```ruby
|
|
25
|
+
class ApplicationQuery < AppQuery::BaseQuery
|
|
26
|
+
include AppQuery::Paginatable
|
|
27
|
+
per_page 25
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# With count (full pagination)
|
|
31
|
+
articles = ArticlesQuery.new.paginate(page: 1).entries
|
|
32
|
+
articles.total_pages # => 5
|
|
33
|
+
|
|
34
|
+
# Without count (large datasets, uses limit+1 trick)
|
|
35
|
+
articles = ArticlesQuery.new.paginate(page: 1, without_count: true).entries
|
|
36
|
+
articles.next_page # => 2 or nil
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- πΊοΈ **`AppQuery::Mappable`** β map results to Ruby objects
|
|
40
|
+
```ruby
|
|
41
|
+
class ArticlesQuery < ApplicationQuery
|
|
42
|
+
include AppQuery::Mappable
|
|
43
|
+
|
|
44
|
+
class Item < Data.define(:title, :url, :published_on)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
articles = ArticlesQuery.new.entries
|
|
49
|
+
articles.first.title # => "Hello World"
|
|
50
|
+
articles.first.class # => ArticlesQuery::Item
|
|
51
|
+
|
|
52
|
+
# Skip mapping
|
|
53
|
+
ArticlesQuery.new.raw.entries.first # => {"title" => "Hello", ...}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- π **`Result#transform!`** β transform result records in-place
|
|
57
|
+
```ruby
|
|
58
|
+
result = AppQuery[:users].select_all
|
|
59
|
+
result.transform! { |row| row.merge("full_name" => "#{row['first']} #{row['last']}") }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- Add `any?`, `none?` - efficient ways to see if there's any results for a query.
|
|
63
|
+
- π― **Cast type shorthands** β use symbols instead of explicit type classes
|
|
64
|
+
```ruby
|
|
65
|
+
query.select_all(cast: {"published_on" => :date})
|
|
66
|
+
# instead of
|
|
67
|
+
query.select_all(cast: {"published_on" => ActiveRecord::Type::Date.new})
|
|
68
|
+
```
|
|
69
|
+
Supports all ActiveRecord types including adapter-specific ones (`:uuid`, `:jsonb`, etc.).
|
|
70
|
+
- π **Indifferent access** β for rows and cast keys
|
|
71
|
+
```ruby
|
|
72
|
+
row = query.select_one
|
|
73
|
+
row["name"] # works
|
|
74
|
+
row[:name] # also works
|
|
75
|
+
|
|
76
|
+
# cast keys can be symbols too
|
|
77
|
+
query.select_all(cast: {published_on: :date})
|
|
78
|
+
```
|
|
79
|
+
|
|
3
80
|
## [0.5.0] - 2025-12-21
|
|
4
81
|
|
|
5
82
|
### π₯ Breaking Changes
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ AppQuery("SELECT * FROM contracts <%= order_by(ordering) %>")
|
|
|
27
27
|
.render(ordering: {year: :desc}).select_all
|
|
28
28
|
|
|
29
29
|
# Custom type casting
|
|
30
|
-
AppQuery("SELECT metadata FROM products").select_all(cast: {
|
|
30
|
+
AppQuery("SELECT metadata FROM products").select_all(cast: {metadata: :json})
|
|
31
31
|
|
|
32
32
|
# Inspect/mock CTEs for testing
|
|
33
33
|
query.prepend_cte("sales AS (SELECT * FROM mock_data)")
|
|
@@ -73,7 +73,7 @@ The prompt indicates what adapter the example uses:
|
|
|
73
73
|
|
|
74
74
|
```ruby
|
|
75
75
|
# showing select_(all|one|value)
|
|
76
|
-
[postgresql]> AppQuery(%{select date('now') as today}).select_all.
|
|
76
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all.entries
|
|
77
77
|
=> [{"today" => "2025-05-10"}]
|
|
78
78
|
[postgresql]> AppQuery(%{select date('now') as today}).select_one
|
|
79
79
|
=> {"today" => "2025-05-10"}
|
|
@@ -85,26 +85,28 @@ The prompt indicates what adapter the example uses:
|
|
|
85
85
|
[postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
86
86
|
|
|
87
87
|
## not all binds need to be provided (ie they are nil by default) - so defaults can be added in SQL:
|
|
88
|
-
[postgresql]> AppQuery(<<~SQL).select_all(binds: {ts1: 2.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
[postgresql]> AppQuery(<<~SQL).select_all(binds: {ts1: 2.days.ago, ts2: Time.now, interval: '1 hour'}).column("series")
|
|
89
|
+
SELECT generate_series(
|
|
90
|
+
:ts1::timestamp,
|
|
91
|
+
:ts2::timestamp,
|
|
92
|
+
COALESCE(:interval, '5 minutes')::interval
|
|
93
|
+
) AS series
|
|
94
|
+
SQL
|
|
93
95
|
|
|
94
96
|
# casting
|
|
95
97
|
## Cast values are used by default:
|
|
96
|
-
[postgresql]> AppQuery(%{select date('now')}).
|
|
98
|
+
[postgresql]> AppQuery(%{select date('now')}).select_one
|
|
97
99
|
=> {"today" => Sat, 10 May 2025}
|
|
98
100
|
## compare ActiveRecord
|
|
99
|
-
[postgresql]> ActiveRecord::Base.connection.
|
|
101
|
+
[postgresql]> ActiveRecord::Base.connection.select_one(%{select date('now') as today})
|
|
100
102
|
=> {"today" => "2025-12-20"}
|
|
101
103
|
|
|
102
104
|
## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
|
|
103
105
|
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
104
106
|
=> {"today" => "2025-05-12"}
|
|
105
107
|
## Providing per-column-casts fixes this:
|
|
106
|
-
|
|
107
|
-
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast:
|
|
108
|
+
cast = {today: :date}
|
|
109
|
+
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast:)
|
|
108
110
|
=> {"today" => Mon, 12 May 2025}
|
|
109
111
|
|
|
110
112
|
|
|
@@ -114,13 +116,13 @@ casts = {"today" => ActiveRecord::Type::Date.new}
|
|
|
114
116
|
[2, "Let's learn SQL", 1.month.ago.to_date],
|
|
115
117
|
[3, "Another article", 2.weeks.ago.to_date]
|
|
116
118
|
]
|
|
117
|
-
[postgresql]> q = AppQuery(<<~SQL, cast: {
|
|
119
|
+
[postgresql]> q = AppQuery(<<~SQL, cast: {published_on: :date}).render(articles:)
|
|
118
120
|
WITH articles(id,title,published_on) AS (<%= values(articles) %>)
|
|
119
121
|
select * from articles order by id DESC
|
|
120
122
|
SQL
|
|
121
123
|
|
|
122
124
|
## query the articles-CTE
|
|
123
|
-
[postgresql]> q.select_all(%{select * from articles where id < 2}).
|
|
125
|
+
[postgresql]> q.select_all(%{select * from articles where id::integer < 2}).entries
|
|
124
126
|
|
|
125
127
|
## query the end-result (available via the placeholder ':_')
|
|
126
128
|
[postgresql]> q.select_one(%{select * from :_ limit 1})
|
|
@@ -273,8 +275,8 @@ AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article")
|
|
|
273
275
|
...]
|
|
274
276
|
|
|
275
277
|
# NOTE how the tags are json strings. Casting allows us to turn these into proper arrays^1:
|
|
276
|
-
|
|
277
|
-
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast:
|
|
278
|
+
cast = {tags: :json}
|
|
279
|
+
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast:)
|
|
278
280
|
|
|
279
281
|
1) unlike SQLite, PostgreSQL has json and array types. Just casting suffices:
|
|
280
282
|
AppQuery("select json_build_object('a', 1, 'b', true)").select_one(cast: true)
|
data/Rakefile
CHANGED
|
@@ -8,3 +8,13 @@ RSpec::Core::RakeTask.new(:spec)
|
|
|
8
8
|
require "standard/rake"
|
|
9
9
|
|
|
10
10
|
task default: %i[spec standard]
|
|
11
|
+
|
|
12
|
+
# version.rb is written at CI which prevents guard_clean from passing.
|
|
13
|
+
# Redefine guard_clean to make it a noop.
|
|
14
|
+
if ENV["CI"]
|
|
15
|
+
Rake::Task["release:guard_clean"].clear
|
|
16
|
+
task "release:guard_clean"
|
|
17
|
+
|
|
18
|
+
Rake::Task["release:source_control_push"].clear
|
|
19
|
+
task "release:source_control_push"
|
|
20
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/class/attribute" # class_attribute
|
|
4
|
+
require "active_support/core_ext/module/delegation" # delegate
|
|
5
|
+
|
|
6
|
+
module AppQuery
|
|
7
|
+
# Base class for query objects that wrap SQL files.
|
|
8
|
+
#
|
|
9
|
+
# BaseQuery provides a structured way to work with SQL queries compared to
|
|
10
|
+
# using `AppQuery[:my_query]` directly.
|
|
11
|
+
#
|
|
12
|
+
# ## Benefits over AppQuery[:my_query]
|
|
13
|
+
#
|
|
14
|
+
# ### 1. Explicit parameter declaration
|
|
15
|
+
# Declare required binds and vars upfront with defaults:
|
|
16
|
+
#
|
|
17
|
+
# class ArticlesQuery < AppQuery::BaseQuery
|
|
18
|
+
# bind :author_id # required
|
|
19
|
+
# bind :status, default: nil # optional
|
|
20
|
+
# var :order_by, default: "created_at DESC"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# ### 2. Unknown parameter validation
|
|
24
|
+
# Raises ArgumentError for typos or unknown parameters:
|
|
25
|
+
#
|
|
26
|
+
# ArticlesQuery.new(athor_id: 1) # => ArgumentError: Unknown param(s): athor_id
|
|
27
|
+
#
|
|
28
|
+
# ### 3. Self-documenting queries
|
|
29
|
+
# Query classes show exactly what parameters are available:
|
|
30
|
+
#
|
|
31
|
+
# ArticlesQuery.binds # => {author_id: {default: nil}, status: {default: nil}}
|
|
32
|
+
# ArticlesQuery.vars # => {order_by: {default: "created_at DESC"}}
|
|
33
|
+
#
|
|
34
|
+
# ### 4. Middleware support
|
|
35
|
+
# Include concerns to add functionality:
|
|
36
|
+
#
|
|
37
|
+
# class ApplicationQuery < AppQuery::BaseQuery
|
|
38
|
+
# include AppQuery::Paginatable
|
|
39
|
+
# include AppQuery::Mappable
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# ### 5. Casts
|
|
43
|
+
# Define casts for columns:
|
|
44
|
+
#
|
|
45
|
+
# class ApplicationQuery < AppQuery::BaseQuery
|
|
46
|
+
# cast metadata: :json
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# ## Parameter types
|
|
50
|
+
#
|
|
51
|
+
# - **bind**: SQL bind parameters (safe from injection, used in WHERE clauses)
|
|
52
|
+
# - **var**: ERB template variables (for dynamic SQL generation like ORDER BY)
|
|
53
|
+
#
|
|
54
|
+
# ## Naming convention
|
|
55
|
+
#
|
|
56
|
+
# Query class name maps to SQL file:
|
|
57
|
+
# - `ArticlesQuery` -> `articles.sql.erb`
|
|
58
|
+
# - `Reports::MonthlyQuery` -> `reports/monthly.sql.erb`
|
|
59
|
+
#
|
|
60
|
+
# ## Example
|
|
61
|
+
#
|
|
62
|
+
# # app/queries/articles.sql.erb
|
|
63
|
+
# SELECT * FROM articles
|
|
64
|
+
# WHERE author_id = :author_id
|
|
65
|
+
# <% if @status %>AND status = :status<% end %>
|
|
66
|
+
# ORDER BY <%= @order_by %>
|
|
67
|
+
#
|
|
68
|
+
# # app/queries/articles_query.rb
|
|
69
|
+
# class ArticlesQuery < AppQuery::BaseQuery
|
|
70
|
+
# bind :author_id
|
|
71
|
+
# bind :status, default: nil
|
|
72
|
+
# var :order_by, default: "created_at DESC"
|
|
73
|
+
# cast published_at: :datetime
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# # Usage
|
|
77
|
+
# ArticlesQuery.new(author_id: 1).entries
|
|
78
|
+
# ArticlesQuery.new(author_id: 1, status: "draft", order_by: "title").first
|
|
79
|
+
#
|
|
80
|
+
class BaseQuery
|
|
81
|
+
class_attribute :_binds, default: {}
|
|
82
|
+
class_attribute :_vars, default: {}
|
|
83
|
+
class_attribute :_casts, default: {}
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
# Declares a bind parameter for the query.
|
|
87
|
+
#
|
|
88
|
+
# Bind parameters are passed to the database driver and are safe from
|
|
89
|
+
# SQL injection. Use for values in WHERE, HAVING, etc.
|
|
90
|
+
#
|
|
91
|
+
# @param name [Symbol] parameter name (used as :name in SQL)
|
|
92
|
+
# @param default [Object, Proc] default value (Proc is evaluated at instantiation)
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# bind :user_id
|
|
96
|
+
# bind :status, default: "active"
|
|
97
|
+
# bind :since, default: -> { 1.week.ago }
|
|
98
|
+
def bind(name, default: nil)
|
|
99
|
+
self._binds = _binds.merge(name => {default:})
|
|
100
|
+
attr_reader name
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Declares a template variable for the query.
|
|
104
|
+
#
|
|
105
|
+
# Vars are available in ERB as both local variables and instance variables
|
|
106
|
+
# (@var). Use for dynamic SQL generation (ORDER BY, column selection, etc.)
|
|
107
|
+
#
|
|
108
|
+
# @param name [Symbol] variable name
|
|
109
|
+
# @param default [Object, Proc] default value (Proc is evaluated at instantiation)
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# var :order_by, default: "created_at DESC"
|
|
113
|
+
# var :columns, default: "*"
|
|
114
|
+
def var(name, default: nil)
|
|
115
|
+
self._vars = _vars.merge(name => {default:})
|
|
116
|
+
attr_reader name
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Sets type casting for result columns.
|
|
120
|
+
#
|
|
121
|
+
# @param casts [Hash{Symbol => Symbol}] column name to type mapping
|
|
122
|
+
# @return [Hash] current cast configuration when called without arguments
|
|
123
|
+
#
|
|
124
|
+
# @example
|
|
125
|
+
# cast published_at: :datetime, metadata: :json
|
|
126
|
+
def cast(casts = nil)
|
|
127
|
+
return _casts if casts.nil?
|
|
128
|
+
self._casts = casts
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Hash] declared bind parameters with their options
|
|
132
|
+
def binds = _binds
|
|
133
|
+
|
|
134
|
+
# @return [Hash] declared template variables with their options
|
|
135
|
+
def vars = _vars
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def initialize(**params)
|
|
139
|
+
all_known = self.class.binds.keys + self.class.vars.keys
|
|
140
|
+
unknown = params.keys - all_known
|
|
141
|
+
raise ArgumentError, "Unknown param(s): #{unknown.join(", ")}" if unknown.any?
|
|
142
|
+
|
|
143
|
+
self.class.binds.merge(self.class.vars).each do |name, options|
|
|
144
|
+
value = params.fetch(name) {
|
|
145
|
+
default = options[:default]
|
|
146
|
+
default.is_a?(Proc) ? instance_exec(&default) : default
|
|
147
|
+
}
|
|
148
|
+
instance_variable_set(:"@#{name}", value)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
delegate :select_all, :select_one, :count, :to_s, :column, :first, :ids, to: :query
|
|
153
|
+
|
|
154
|
+
def entries
|
|
155
|
+
select_all
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def query
|
|
159
|
+
@query ||= base_query
|
|
160
|
+
.render(**render_vars)
|
|
161
|
+
.with_binds(**bind_vars)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def base_query
|
|
165
|
+
AppQuery[query_name, cast: self.class.cast]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def query_name
|
|
171
|
+
self.class.name.underscore.sub(/_query$/, "")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def render_vars
|
|
175
|
+
self.class.vars.keys.to_h { [_1, send(_1)] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def bind_vars
|
|
179
|
+
self.class.binds.keys.to_h { [_1, send(_1)] }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -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
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "app_query/version"
|
|
4
|
+
require_relative "app_query/base_query"
|
|
5
|
+
require_relative "app_query/paginatable"
|
|
6
|
+
require_relative "app_query/mappable"
|
|
4
7
|
require_relative "app_query/tokenizer"
|
|
5
8
|
require_relative "app_query/render_helpers"
|
|
6
9
|
require "active_record"
|
|
@@ -22,7 +25,7 @@ require "active_record"
|
|
|
22
25
|
# end
|
|
23
26
|
#
|
|
24
27
|
# @example CTE manipulation
|
|
25
|
-
# AppQuery(<<~SQL).select_all(
|
|
28
|
+
# AppQuery(<<~SQL).select_all("select * from articles where id = 1")
|
|
26
29
|
# WITH articles AS(...)
|
|
27
30
|
# SELECT * FROM articles
|
|
28
31
|
# ORDER BY id
|
|
@@ -137,6 +140,48 @@ module AppQuery
|
|
|
137
140
|
count
|
|
138
141
|
end
|
|
139
142
|
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Override to provide indifferent access (string or symbol keys).
|
|
146
|
+
def hash_rows
|
|
147
|
+
@hash_rows ||= rows.map do |row|
|
|
148
|
+
columns.zip(row).to_h.with_indifferent_access
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
public
|
|
153
|
+
|
|
154
|
+
# Transforms each record in-place using the provided block.
|
|
155
|
+
#
|
|
156
|
+
# @yield [Hash] each record as a hash with indifferent access
|
|
157
|
+
# @yieldreturn [Hash] the transformed record
|
|
158
|
+
# @return [self] the result object for chaining
|
|
159
|
+
#
|
|
160
|
+
# @example Add a computed field
|
|
161
|
+
# result = AppQuery[:users].select_all
|
|
162
|
+
# result.transform! { |r| r.merge("full_name" => "#{r['first']} #{r['last']}") }
|
|
163
|
+
def transform!
|
|
164
|
+
@hash_rows = hash_rows.map { |r| yield(r) } unless empty?
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Resolves a cast type value, converting symbols to ActiveRecord types.
|
|
169
|
+
#
|
|
170
|
+
# @param value [Symbol, Object] the cast type (symbol shorthand or type instance)
|
|
171
|
+
# @return [Object] the resolved type instance
|
|
172
|
+
#
|
|
173
|
+
# @example
|
|
174
|
+
# resolve_cast_type(:date) #=> ActiveRecord::Type::Date instance
|
|
175
|
+
# resolve_cast_type(ActiveRecord::Type::Json.new) #=> returns as-is
|
|
176
|
+
def self.resolve_cast_type(value)
|
|
177
|
+
case value
|
|
178
|
+
when Symbol
|
|
179
|
+
ActiveRecord::Type.lookup(value)
|
|
180
|
+
else
|
|
181
|
+
value
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
140
185
|
def self.from_ar_result(r, cast = nil)
|
|
141
186
|
if r.empty?
|
|
142
187
|
EMPTY
|
|
@@ -145,7 +190,7 @@ module AppQuery
|
|
|
145
190
|
when Array
|
|
146
191
|
r.columns.zip(cast).to_h
|
|
147
192
|
when Hash
|
|
148
|
-
cast
|
|
193
|
+
cast.transform_keys(&:to_s).transform_values { |v| resolve_cast_type(v) }
|
|
149
194
|
else
|
|
150
195
|
{}
|
|
151
196
|
end
|
|
@@ -342,16 +387,18 @@ module AppQuery
|
|
|
342
387
|
# @example (Named) binds
|
|
343
388
|
# AppQuery("SELECT * FROM users WHERE id = :id").select_all(binds: {id: 1})
|
|
344
389
|
#
|
|
345
|
-
# @example With type casting
|
|
346
|
-
# AppQuery("SELECT
|
|
347
|
-
# .select_all(cast: {
|
|
390
|
+
# @example With type casting (shorthand)
|
|
391
|
+
# AppQuery("SELECT published_on FROM articles")
|
|
392
|
+
# .select_all(cast: {"published_on" => :date})
|
|
393
|
+
#
|
|
394
|
+
# @example With type casting (explicit)
|
|
395
|
+
# AppQuery("SELECT metadata FROM products")
|
|
396
|
+
# .select_all(cast: {"metadata" => ActiveRecord::Type::Json.new})
|
|
348
397
|
#
|
|
349
398
|
# @example Override SELECT clause
|
|
350
|
-
# AppQuery("SELECT * FROM users").select_all(
|
|
399
|
+
# AppQuery("SELECT * FROM users").select_all("COUNT(*)")
|
|
351
400
|
#
|
|
352
401
|
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
353
|
-
#
|
|
354
|
-
# TODO: have aliases for common casts: select_all(cast: {"today" => :date})
|
|
355
402
|
def select_all(s = nil, binds: {}, cast: self.cast)
|
|
356
403
|
add_binds(**binds).with_select(s).render({}).then do |aq|
|
|
357
404
|
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
@@ -424,6 +471,41 @@ module AppQuery
|
|
|
424
471
|
with_select(s).select_all("SELECT COUNT(*) c FROM :_", binds:).column("c").first
|
|
425
472
|
end
|
|
426
473
|
|
|
474
|
+
# Returns whether any rows exist in the query result.
|
|
475
|
+
#
|
|
476
|
+
# Uses `EXISTS` which stops at the first matching row, making it more
|
|
477
|
+
# efficient than `count > 0` for large result sets.
|
|
478
|
+
#
|
|
479
|
+
# @param s [String, nil] optional SELECT to apply before checking
|
|
480
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
481
|
+
# @return [Boolean] true if at least one row exists
|
|
482
|
+
#
|
|
483
|
+
# @example Check if query has results
|
|
484
|
+
# AppQuery("SELECT * FROM users").any?
|
|
485
|
+
# # => true
|
|
486
|
+
#
|
|
487
|
+
# @example Check with filtering
|
|
488
|
+
# AppQuery("SELECT * FROM users").any?("SELECT * FROM :_ WHERE admin")
|
|
489
|
+
# # => false
|
|
490
|
+
def any?(s = nil, binds: {})
|
|
491
|
+
with_select(s).select_all("SELECT EXISTS(SELECT 1 FROM :_) e", binds:).column("e").first
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Returns whether no rows exist in the query result.
|
|
495
|
+
#
|
|
496
|
+
# Inverse of {#any?}. Uses `EXISTS` for efficiency.
|
|
497
|
+
#
|
|
498
|
+
# @param s [String, nil] optional SELECT to apply before checking
|
|
499
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
500
|
+
# @return [Boolean] true if no rows exist
|
|
501
|
+
#
|
|
502
|
+
# @example Check if query is empty
|
|
503
|
+
# AppQuery("SELECT * FROM users WHERE admin").none?
|
|
504
|
+
# # => true
|
|
505
|
+
def none?(s = nil, binds: {})
|
|
506
|
+
!any?(s, binds:)
|
|
507
|
+
end
|
|
508
|
+
|
|
427
509
|
# Returns an array of values for a single column.
|
|
428
510
|
#
|
|
429
511
|
# Wraps the query in a CTE and selects only the specified column, which is
|
data/rakelib/gem.rake
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
namespace :gem do
|
|
2
|
-
task "write_version", [:version] do |_task, args|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
# task "write_version", [:version] do |_task, args|
|
|
3
|
+
# if args[:version]
|
|
4
|
+
# version = args[:version].split("=").last
|
|
5
|
+
# version_file = File.expand_path("../../lib/app_query/version.rb", __FILE__)
|
|
6
|
+
#
|
|
7
|
+
# system(<<~CMD, exception: true)
|
|
8
|
+
# ruby -pi -e 'gsub(/VERSION = ".*"/, %{VERSION = "#{version}"})' #{version_file}
|
|
9
|
+
# CMD
|
|
10
|
+
# Bundler.ui.confirm "Version #{version} written to #{version_file}."
|
|
11
|
+
# else
|
|
12
|
+
# Bundler.ui.warn "No version provided, keeping version.rb as is."
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
desc "Build [version]"
|
|
17
|
-
task "build", [:version] => %w[write_version] do
|
|
18
|
-
Rake::Task["build"].invoke
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
desc "Build and push [version] to rubygems"
|
|
22
|
-
task "release", [:version] => %w[build] do
|
|
23
|
-
Rake::Task["release:rubygem_push"].invoke
|
|
24
|
-
end
|
|
16
|
+
# desc "Build [version]"
|
|
17
|
+
# task "build", [:version] => %w[write_version] do
|
|
18
|
+
# Rake::Task["build"].invoke
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# desc "Build and push [version] to rubygems"
|
|
22
|
+
# task "release", [:version] => %w[build] do
|
|
23
|
+
# Rake::Task["release:rubygem_push"].invoke
|
|
24
|
+
# end
|
|
25
25
|
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.6.0.alpha
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
@@ -51,6 +51,9 @@ files:
|
|
|
51
51
|
- Rakefile
|
|
52
52
|
- lib/app_query.rb
|
|
53
53
|
- lib/app_query/base.rb
|
|
54
|
+
- lib/app_query/base_query.rb
|
|
55
|
+
- lib/app_query/mappable.rb
|
|
56
|
+
- lib/app_query/paginatable.rb
|
|
54
57
|
- lib/app_query/render_helpers.rb
|
|
55
58
|
- lib/app_query/rspec.rb
|
|
56
59
|
- lib/app_query/rspec/helpers.rb
|
|
@@ -88,7 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
88
91
|
- !ruby/object:Gem::Version
|
|
89
92
|
version: '0'
|
|
90
93
|
requirements: []
|
|
91
|
-
rubygems_version: 3.6.
|
|
94
|
+
rubygems_version: 3.6.7
|
|
92
95
|
specification_version: 4
|
|
93
96
|
summary: "raw SQL \U0001F966, cooked \U0001F372 or: make working with raw SQL queries
|
|
94
97
|
in Rails convenient by improving their introspection and testability."
|