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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ca8349f2df1ddf7e2248314238dd72ff5623d2924f1265f385cb1dfde8b274d
4
- data.tar.gz: 12440ed32a10ca28acd01df89175906e2ed2f77105905ed44f905bc54def4c5d
3
+ metadata.gz: 6f00b1819f60c555c6400c5843dd4682e27be590a967593eeaaabb3cca267789
4
+ data.tar.gz: 9539d6caab140091640ffc7174745921a296005d58bc545c99c7492cdb1356be
5
5
  SHA512:
6
- metadata.gz: 8154cfbf83c7327cc6bdfbe501430007fb4ed9a5ecfb65f6d751358e3721ca853b9d4655968955147fa2e3aa4ea898db9f71bd3da19647e4e550d2887121daab
7
- data.tar.gz: a51fc511fa7b5da2bf03c4d63910abb1bb980b95fc300bf188a8749e726e965d781d14d28ffa3240dbca515c7d2bb0f5e83041eae61bb6ea7e815a1f7a13d19f
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: {"metadata" => ActiveRecord::Type::Json.new})
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.to_a
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.day.ago, ts2: Time.now}).column("series")
89
- SELECT
90
- generate_series(:ts1::timestamp, COALESCE(:ts2, :ts2::timestamp, COALESCE(:interval, '5 minutes')::interval)
91
- AS series
92
- SQL
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')}).select_first
98
+ [postgresql]> AppQuery(%{select date('now')}).select_one
97
99
  => {"today" => Sat, 10 May 2025}
98
100
  ## compare ActiveRecord
99
- [postgresql]> ActiveRecord::Base.connection.select_first(%{select date('now') as today})
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
- casts = {"today" => ActiveRecord::Type::Date.new}
107
- [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: casts)
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: {"published_on" => ActiveRecord::Type::Date.new}).render(articles:)
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}).to_a
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
- types = {"tags" => ActiveRecord::Type::Json.new}
277
- AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast: types)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0.alpha"
5
5
  end
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(select: "select * from articles where id = 1")
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 created_at FROM users")
347
- # .select_all(cast: {created_at: ActiveRecord::Type::DateTime.new})
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(select: "COUNT(*)")
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
- if args[:version]
4
- version = args[:version].split("=").last
5
- version_file = File.expand_path("../../lib/app_query/version.rb", __FILE__)
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
- 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
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.5.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.9
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."