quo 0.6.0 → 1.0.0.beta1

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +4 -1
  3. data/Appraisals +15 -0
  4. data/CHANGELOG.md +78 -0
  5. data/Gemfile +6 -4
  6. data/LICENSE.txt +1 -1
  7. data/README.md +222 -232
  8. data/Steepfile +0 -2
  9. data/gemfiles/rails_7.0.gemfile +15 -0
  10. data/gemfiles/rails_7.1.gemfile +15 -0
  11. data/gemfiles/rails_7.2.gemfile +15 -0
  12. data/gemfiles/rails_8.0.gemfile +15 -0
  13. data/lib/quo/collection_backed_query.rb +87 -0
  14. data/lib/quo/collection_results.rb +44 -0
  15. data/lib/quo/composed_query.rb +278 -0
  16. data/lib/quo/engine.rb +11 -0
  17. data/lib/quo/minitest/helpers.rb +41 -0
  18. data/lib/quo/preloadable.rb +46 -0
  19. data/lib/quo/query.rb +97 -215
  20. data/lib/quo/relation_backed_query.rb +150 -0
  21. data/lib/quo/relation_backed_query_specification.rb +154 -0
  22. data/lib/quo/relation_results.rb +58 -0
  23. data/lib/quo/results.rb +48 -44
  24. data/lib/quo/rspec/helpers.rb +31 -9
  25. data/lib/quo/testing/collection_backed_fake.rb +29 -0
  26. data/lib/quo/testing/relation_backed_fake.rb +52 -0
  27. data/lib/quo/version.rb +3 -1
  28. data/lib/quo.rb +23 -30
  29. data/rbs_collection.yaml +0 -2
  30. data/sig/generated/quo/collection_backed_query.rbs +39 -0
  31. data/sig/generated/quo/collection_results.rbs +30 -0
  32. data/sig/generated/quo/composed_query.rbs +112 -0
  33. data/sig/generated/quo/engine.rbs +6 -0
  34. data/sig/generated/quo/preloadable.rbs +29 -0
  35. data/sig/generated/quo/query.rbs +98 -0
  36. data/sig/generated/quo/relation_backed_query.rbs +67 -0
  37. data/sig/generated/quo/relation_backed_query_specification.rbs +94 -0
  38. data/sig/generated/quo/relation_results.rbs +38 -0
  39. data/sig/generated/quo/results.rbs +39 -0
  40. data/sig/generated/quo/testing/collection_backed_fake.rbs +13 -0
  41. data/sig/generated/quo/testing/relation_backed_fake.rbs +23 -0
  42. data/sig/generated/quo/version.rbs +5 -0
  43. data/sig/generated/quo.rbs +9 -0
  44. data/sig/literal.rbs +7 -0
  45. metadata +77 -37
  46. data/lib/quo/eager_query.rb +0 -51
  47. data/lib/quo/loaded_query.rb +0 -18
  48. data/lib/quo/merged_query.rb +0 -36
  49. data/lib/quo/query_composer.rb +0 -78
  50. data/lib/quo/railtie.rb +0 -7
  51. data/lib/quo/utilities/callstack.rb +0 -21
  52. data/lib/quo/utilities/compose.rb +0 -18
  53. data/lib/quo/utilities/sanitize.rb +0 -19
  54. data/lib/quo/utilities/wrap.rb +0 -23
  55. data/lib/quo/wrapped_query.rb +0 -18
  56. data/sig/quo/eager_query.rbs +0 -15
  57. data/sig/quo/loaded_query.rbs +0 -7
  58. data/sig/quo/merged_query.rbs +0 -19
  59. data/sig/quo/query.rbs +0 -83
  60. data/sig/quo/query_composer.rbs +0 -32
  61. data/sig/quo/results.rbs +0 -22
  62. data/sig/quo/utilities/callstack.rbs +0 -7
  63. data/sig/quo/utilities/compose.rbs +0 -8
  64. data/sig/quo/utilities/sanitize.rbs +0 -9
  65. data/sig/quo/utilities/wrap.rbs +0 -11
  66. data/sig/quo/wrapped_query.rbs +0 -11
  67. data/sig/quo.rbs +0 -41
data/lib/quo/query.rb CHANGED
@@ -1,302 +1,184 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./utilities/callstack"
4
- require_relative "./utilities/compose"
5
- require_relative "./utilities/sanitize"
6
- require_relative "./utilities/wrap"
3
+ # rbs_inline: enabled
7
4
 
8
- module Quo
9
- class Query
10
- include Quo::Utilities::Callstack
11
-
12
- extend Quo::Utilities::Compose
13
- extend Quo::Utilities::Sanitize
14
- extend Quo::Utilities::Wrap
15
-
16
- class << self
17
- def call(**options)
18
- new(**options).first
19
- end
20
-
21
- def call!(**options)
22
- new(**options).first!
23
- end
24
- end
25
-
26
- attr_reader :current_page, :page_size, :options
5
+ require "literal"
27
6
 
28
- def initialize(**options)
29
- @options = options
30
- @current_page = options[:page]&.to_i || options[:current_page]&.to_i
31
- @page_size = options[:page_size]&.to_i || Quo.configuration.default_page_size || 20
32
- end
33
-
34
- # Returns a active record query, or a Quo::Query instance
35
- def query
36
- raise NotImplementedError, "Query objects must define a 'query' method"
37
- end
7
+ module Quo
8
+ class Query < Literal::Struct
9
+ include Literal::Types
38
10
 
39
- # Combine (compose) this query object with another composeable entity, see notes for `.compose` above.
40
- # Compose is aliased as `+`. Can optionally take `joins()` parameters to perform a joins before the merge
41
- def compose(right, joins: nil)
42
- Quo::QueryComposer.new(self, right, joins).compose
11
+ def self.inspect
12
+ "#{name || "(anonymous)"}<#{superclass}>"
43
13
  end
44
14
 
45
- alias_method :+, :compose
46
-
47
- def copy(**options)
48
- self.class.new(**@options.merge(options))
15
+ def self.to_s
16
+ inspect
49
17
  end
50
18
 
51
- # Methods to prepare the query
52
- def limit(limit)
53
- copy(limit: limit)
19
+ def inspect
20
+ "#{self.class.name || "(anonymous)"}<#{self.class.superclass} #{paged? ? "" : "not "}paginated>#{super}"
54
21
  end
55
22
 
56
- def order(options)
57
- copy(order: options)
23
+ def to_s
24
+ inspect
58
25
  end
59
26
 
60
- def group(*options)
61
- copy(group: options)
27
+ # TODO: put this in a module with the composer and merge_instances methods
28
+ # Compose is aliased as `+`. Can optionally take `joins` parameters to add joins on merged relation.
29
+ # @rbs right: Quo::Query | ActiveRecord::Relation | Object & Enumerable[untyped]
30
+ # @rbs joins: Symbol | Hash[Symbol, untyped] | Array[Symbol | Hash[Symbol, untyped]]
31
+ # @rbs return: Quo::Query & Quo::ComposedQuery
32
+ def self.compose(right, joins: nil)
33
+ super_class = if self < Quo::CollectionBackedQuery || right < Quo::CollectionBackedQuery
34
+ Quo.collection_backed_query_base_class
35
+ else
36
+ Quo.relation_backed_query_base_class
37
+ end
38
+ ComposedQuery.composer(super_class, self, right, joins: joins)
62
39
  end
40
+ singleton_class.alias_method :+, :compose
63
41
 
64
- def includes(*options)
65
- copy(includes: options)
42
+ COERCE_TO_INT = ->(value) do #: (untyped value) -> Integer?
43
+ return if value == Literal::Null
44
+ value&.to_i
66
45
  end
67
46
 
68
- def preload(*options)
69
- copy(preload: options)
70
- end
47
+ # @rbs!
48
+ # attr_accessor page (): Integer?
49
+ # attr_accessor page_size (): Integer?
50
+ # @current_page: Integer?
51
+ prop :page, _Nilable(Integer), &COERCE_TO_INT
52
+ prop(:page_size, _Nilable(Integer), default: -> { Quo.default_page_size || 20 }, &COERCE_TO_INT)
71
53
 
72
- def select(*options)
73
- copy(select: options)
54
+ def next_page_query #: Quo::Query
55
+ copy(page: page + 1)
74
56
  end
75
57
 
76
- # The following methods actually execute the underlying query
77
-
78
- # Delegate SQL calculation methods to the underlying query
79
- delegate :sum, :average, :minimum, :maximum, to: :query_with_logging
80
-
81
- # Gets the count of all results ignoring the current page and page size (if set)
82
- delegate :count, to: :underlying_query
83
- alias_method :total_count, :count
84
- alias_method :size, :count
85
-
86
- # Gets the actual count of elements in the page of results (assuming paging is being used, otherwise the count of
87
- # all results)
88
- def page_count
89
- query_with_logging.count
58
+ def previous_page_query #: Quo::Query
59
+ copy(page: [page - 1, 1].max)
90
60
  end
91
61
 
92
- # Delegate methods that let us get the model class (available on AR relations)
93
- delegate :model, :klass, to: :underlying_query
94
-
95
- # Get first elements
96
- def first(limit = nil)
97
- if transform?
98
- res = query_with_logging.first(limit)
99
- if res.is_a? Array
100
- res.map.with_index { |r, i| transformer&.call(r, i) }
101
- elsif !res.nil?
102
- transformer&.call(query_with_logging.first(limit))
103
- end
104
- elsif limit
105
- query_with_logging.first(limit)
62
+ def offset #: Integer
63
+ per_page = sanitised_page_size
64
+ page_with_default = if page&.positive?
65
+ page
106
66
  else
107
- # Array#first will not take nil as a limit
108
- query_with_logging.first
67
+ 1
109
68
  end
69
+ per_page * (page_with_default - 1)
110
70
  end
111
71
 
112
- def first!(limit = nil)
113
- item = first(limit)
114
- raise ActiveRecord::RecordNotFound, "No item could be found!" unless item
115
- item
72
+ # Returns a active record query, or a Quo::Query instance
73
+ def query #: Quo::Query | ::ActiveRecord::Relation
74
+ raise NotImplementedError, "Query objects must define a 'query' method"
116
75
  end
117
76
 
118
- # Get last elements
119
- def last(limit = nil)
120
- if transform?
121
- res = query_with_logging.last(limit)
122
- if res.is_a? Array
123
- res.map.with_index { |r, i| transformer&.call(r, i) }
124
- elsif !res.nil?
125
- transformer&.call(res)
126
- end
127
- elsif limit
128
- query_with_logging.last(limit)
129
- else
130
- query_with_logging.last
77
+ # @rbs **overrides: untyped
78
+ # @rbs return: Quo::Query
79
+ def copy(**overrides)
80
+ self.class.new(**to_h.merge(overrides)).tap do |q|
81
+ q.instance_variable_set(:@__transformer, transformer)
131
82
  end
132
83
  end
133
84
 
134
- # Convert to array
135
- def to_a
136
- arr = query_with_logging.to_a
137
- transform? ? arr.map.with_index { |r, i| transformer&.call(r, i) } : arr
85
+ # Compose is aliased as `+`. Can optionally take `joins` parameters to add joins on merged relation.
86
+ # @rbs right: Quo::Query | ::ActiveRecord::Relation
87
+ # @rbs joins: untyped
88
+ # @rbs return: Quo::ComposedQuery
89
+ def merge(right, joins: nil)
90
+ ComposedQuery.merge_instances(self, right, joins: joins)
138
91
  end
92
+ alias_method :+, :merge
139
93
 
140
- def to_eager(more_opts = {})
141
- Quo::LoadedQuery.new(to_a, **options.merge(more_opts))
142
- end
143
- alias_method :load, :to_eager
144
-
145
- def results
146
- Quo::Results.new(self, transformer: transformer)
147
- end
148
-
149
- # Some convenience methods for working with results
150
- delegate :each,
151
- :find_each,
152
- :map,
153
- :flat_map,
154
- :reduce,
155
- :reject,
156
- :filter,
157
- :find,
158
- :include?,
159
- :each_with_object,
160
- to: :results
94
+ # @rbs @__transformer: nil | ^(untyped, ?Integer) -> untyped
161
95
 
162
96
  # Set a block used to transform data after query fetching
97
+ # @rbs block: ^(untyped, ?Integer) -> untyped
98
+ # @rbs return: self
163
99
  def transform(&block)
164
- @options[:__transformer] = block
100
+ @__transformer = block
165
101
  self
166
102
  end
167
103
 
168
- # Are there any results for this query?
169
- def exists?
170
- return query_with_logging.exists? if relation?
171
- query_with_logging.present?
172
- end
173
-
174
- # Are there no results for this query?
175
- def none?
176
- !exists?
177
- end
178
- alias_method :empty?, :none?
179
-
180
- # Is this query object a relation under the hood? (ie not eager loaded)
181
- def relation?
104
+ # Is this query object a ActiveRecord relation under the hood?
105
+ def relation? #: bool
182
106
  test_relation(configured_query)
183
107
  end
184
108
 
185
- # Is this query object eager loaded data under the hood? (ie not a relation)
186
- def eager?
187
- test_eager(configured_query)
109
+ # Is this query object loaded data/collection under the hood? (ie not a AR relation)
110
+ def collection? #: bool
111
+ is_collection?(configured_query)
188
112
  end
189
113
 
190
114
  # Is this query object paged? (ie is paging enabled)
191
- def paged?
192
- current_page.present?
115
+ def paged? #: bool
116
+ page.present?
193
117
  end
194
118
 
195
119
  # Is this query object transforming results?
196
- def transform?
120
+ def transform? #: bool
197
121
  transformer.present?
198
122
  end
199
123
 
200
- # Return the SQL string for this query if its a relation type query object
201
- def to_sql
202
- configured_query.to_sql if relation?
203
- end
204
-
205
124
  # Unwrap the paginated query
206
- def unwrap
125
+ def unwrap #: ActiveRecord::Relation
207
126
  configured_query
208
127
  end
209
128
 
210
129
  # Unwrap the un-paginated query
211
- def unwrap_unpaginated
130
+ def unwrap_unpaginated #: ActiveRecord::Relation
212
131
  underlying_query
213
132
  end
214
133
 
215
- delegate :distinct, to: :configured_query
216
-
217
134
  private
218
135
 
219
- def formatted_queries?
220
- !!Quo.configuration.formatted_query_log
221
- end
222
-
223
- # 'trim' a query, ie remove comments and remove newlines
224
- # This will remove dashes from inside strings too
225
- def trim_query(sql)
226
- sql.gsub(/--[^\n'"]*\n/m, " ").tr("\n", " ").strip
227
- end
228
-
229
- def format_query(sql_str)
230
- formatted_queries? ? sql_str : trim_query(sql_str)
136
+ def transformer
137
+ @__transformer
231
138
  end
232
139
 
233
- def transformer
234
- options[:__transformer]
140
+ def validated_query
141
+ raise NoMethodError, "Query objects must define a 'validated_query' method"
235
142
  end
236
143
 
237
- def offset
238
- per_page = sanitised_page_size
239
- page = if current_page && current_page&.positive?
240
- current_page
241
- else
242
- 1
243
- end
244
- per_page * (page - 1)
144
+ # The underlying query is essentially the configured query with optional extras setup
145
+ def underlying_query #: void
146
+ raise NoMethodError, "Query objects must define a 'underlying_query' method"
245
147
  end
246
148
 
247
149
  # The configured query is the underlying query with paging
248
- def configured_query
249
- q = underlying_query
250
- return q unless paged? && q.is_a?(ActiveRecord::Relation)
251
- q.offset(offset).limit(sanitised_page_size)
150
+ def configured_query #: void
151
+ raise NoMethodError, "Query objects must define a 'configured_query' method"
252
152
  end
253
153
 
254
- def sanitised_page_size
255
- if page_size && page_size.positive?
154
+ def sanitised_page_size #: Integer
155
+ if page_size&.positive?
256
156
  given_size = page_size.to_i
257
- max_page_size = Quo.configuration.max_page_size || 200
157
+ max_page_size = Quo.max_page_size || 200
258
158
  if given_size > max_page_size
259
159
  max_page_size
260
160
  else
261
161
  given_size
262
162
  end
263
163
  else
264
- Quo.configuration.default_page_size || 20
164
+ Quo.default_page_size || 20
265
165
  end
266
166
  end
267
167
 
268
- def query_with_logging
269
- debug_callstack
270
- configured_query
271
- end
272
-
273
- # The underlying query is essentially the configured query with optional extras setup
274
- def underlying_query
275
- @underlying_query ||=
276
- begin
277
- rel = unwrap_relation(query)
278
- unless test_eager(rel)
279
- rel = rel.group(@options[:group]) if @options[:group].present?
280
- rel = rel.order(@options[:order]) if @options[:order].present?
281
- rel = rel.limit(@options[:limit]) if @options[:limit].present?
282
- rel = rel.preload(@options[:preload]) if @options[:preload].present?
283
- rel = rel.includes(@options[:includes]) if @options[:includes].present?
284
- rel = rel.select(@options[:select]) if @options[:select].present?
285
- end
286
- rel
287
- end
288
- end
289
-
290
- def unwrap_relation(query)
291
- query.is_a?(Quo::Query) ? query.unwrap : query
292
- end
293
-
294
- def test_eager(rel)
295
- rel.is_a?(Quo::LoadedQuery) || (rel.is_a?(Enumerable) && !test_relation(rel))
168
+ # @rbs rel: untyped
169
+ # @rbs return: bool
170
+ def is_collection?(rel)
171
+ rel.is_a?(Quo::CollectionBackedQuery) || (rel.is_a?(Enumerable) && !test_relation(rel))
296
172
  end
297
173
 
174
+ # @rbs rel: untyped
175
+ # @rbs return: bool
298
176
  def test_relation(rel)
299
177
  rel.is_a?(ActiveRecord::Relation)
300
178
  end
179
+
180
+ def quo_unwrap_unpaginated_query(q)
181
+ q.is_a?(Quo::Query) ? q.unwrap_unpaginated : q
182
+ end
301
183
  end
302
184
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require "literal"
6
+
7
+ module Quo
8
+ class RelationBackedQuery < Query
9
+ # @rbs query: ActiveRecord::Relation | Quo::Query
10
+ # @rbs props: Hash[Symbol, untyped]
11
+ # @rbs &block: () -> ActiveRecord::Relation | Quo::Query | Object & Enumerable[untyped]
12
+ # @rbs return: Quo::RelationBackedQuery
13
+ def self.wrap(query = nil, props: {}, &block)
14
+ raise ArgumentError, "either a query or a block must be provided" unless query || block
15
+
16
+ klass = Class.new(self) do
17
+ props.each do |name, property|
18
+ if property.is_a?(Literal::Property)
19
+ prop name, property.type, property.kind, reader: property.reader, writer: property.writer, default: property.default
20
+ else
21
+ prop name, property
22
+ end
23
+ end
24
+ end
25
+ if block
26
+ klass.define_method(:query, &block)
27
+ else
28
+ klass.define_method(:query) { query }
29
+ end
30
+ klass
31
+ end
32
+
33
+ # @rbs conditions: untyped?
34
+ # @rbs return: String
35
+ def self.sanitize_sql_for_conditions(conditions)
36
+ ActiveRecord::Base.sanitize_sql_for_conditions(conditions)
37
+ end
38
+
39
+ # @rbs string: String
40
+ # @rbs return: String
41
+ def self.sanitize_sql_string(string)
42
+ sanitize_sql_for_conditions(["'%s'", string])
43
+ end
44
+
45
+ # @rbs value: untyped
46
+ # @rbs return: String
47
+ def self.sanitize_sql_parameter(value)
48
+ sanitize_sql_for_conditions(["?", value])
49
+ end
50
+
51
+ # The query specification stores all options related to building the query
52
+ # @rbs!
53
+ # @_specification: Quo::RelationBackedQuerySpecification?
54
+ prop :_specification, _Nilable(Quo::RelationBackedQuerySpecification),
55
+ default: -> { RelationBackedQuerySpecification.blank },
56
+ reader: false,
57
+ writer: false
58
+
59
+ # Apply a query specification to this query
60
+ # @rbs specification: Quo::RelationBackedQuerySpecification
61
+ # @rbs return: Quo::Query
62
+ def with_specification(specification)
63
+ copy(_specification: specification)
64
+ end
65
+
66
+ # Apply query options using the specification
67
+ # @rbs options: Hash[Symbol, untyped]
68
+ # @rbs return: Quo::Query
69
+ def with(options = {})
70
+ spec = @_specification || RelationBackedQuerySpecification.blank
71
+ with_specification(spec.merge(options))
72
+ end
73
+
74
+ # Delegate methods that let us get the model class (available on AR relations)
75
+ # @rbs def model: () -> (untyped | nil)
76
+ # @rbs def klass: () -> (untyped | nil)
77
+ delegate :model, :klass, to: :underlying_query
78
+
79
+ # @rbs return: Quo::CollectionBackedQuery
80
+ def to_collection(total_count: nil)
81
+ Quo.collection_backed_query_base_class.wrap(results.to_a).new(total_count:)
82
+ end
83
+
84
+ def results #: Quo::Results
85
+ Quo::RelationResults.new(self, transformer: transformer)
86
+ end
87
+
88
+ # Return the SQL string for this query if its a relation type query object
89
+ def to_sql #: String
90
+ configured_query.to_sql if relation?
91
+ end
92
+
93
+ # Implements a fluent API for query methods
94
+ # This allows methods to be chained like query.where(...).order(...).limit(...)
95
+ # @rbs method_name: Symbol
96
+ # @rbs *args: untyped
97
+ # @rbs **kwargs: untyped
98
+ # @rbs &block: untyped
99
+ # @rbs return: Quo::Query
100
+ def method_missing(method_name, *args, **kwargs, &block)
101
+ spec = @_specification || RelationBackedQuerySpecification.blank
102
+
103
+ # Check if the method exists in RelationBackedQuerySpecification
104
+ if spec.respond_to?(method_name)
105
+ # Call the method on the specification and return a new query with the updated specification
106
+ updated_spec = spec.method(method_name).call(*args, **kwargs, &block)
107
+ return with_specification(updated_spec)
108
+ end
109
+
110
+ # Forward to underlying query if method not found in RelationBackedQuerySpecification
111
+ super
112
+ end
113
+
114
+ # @rbs method_name: Symbol
115
+ # @rbs include_private: bool
116
+ # @rbs return: bool
117
+ def respond_to_missing?(method_name, include_private = false)
118
+ spec_instance = RelationBackedQuerySpecification.new
119
+ spec_instance.respond_to?(method_name, include_private) || super
120
+ end
121
+
122
+ private
123
+
124
+ def validated_query
125
+ query.tap do |q|
126
+ raise ArgumentError, "#query must return an ActiveRecord Relation or a Quo::Query instance" unless query.nil? || q.is_a?(::ActiveRecord::Relation) || q.is_a?(Quo::Query)
127
+ end
128
+ end
129
+
130
+ # The underlying query is essentially the configured query with optional extras setup
131
+ def underlying_query #: ActiveRecord::Relation
132
+ rel = quo_unwrap_unpaginated_query(validated_query)
133
+
134
+ # Apply specification if it exists
135
+ if @_specification
136
+ @_specification.apply_to(rel)
137
+ else
138
+ rel
139
+ end
140
+ end
141
+
142
+ # The configured query is the underlying query with paging
143
+ def configured_query #: ActiveRecord::Relation
144
+ q = underlying_query
145
+ return q unless paged?
146
+
147
+ q.offset(offset).limit(sanitised_page_size)
148
+ end
149
+ end
150
+ end