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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ class CollectionBackedQuery < Query
7
+ prop :total_count, _Nilable(Integer), reader: false
8
+
9
+ # Wrap an enumerable collection or a block that returns an enumerable collection
10
+ # @rbs data: untyped, props: Symbol => untyped, block: () -> untyped
11
+ # @rbs return: Quo::CollectionBackedQuery
12
+ def self.wrap(data = nil, props: {}, &block)
13
+ klass = Class.new(self) do
14
+ props.each do |name, property|
15
+ if property.is_a?(Literal::Property)
16
+ prop name, property.type, property.kind, reader: property.reader, writer: property.writer, default: property.default
17
+ else
18
+ prop name, property
19
+ end
20
+ end
21
+ end
22
+ if block
23
+ klass.define_method(:collection, &block)
24
+ elsif data
25
+ klass.define_method(:collection) { data }
26
+ else
27
+ raise ArgumentError, "either a query or a block must be provided"
28
+ end
29
+ # klass.set_temporary_name = "quo::Wrapper" # Ruby 3.3+
30
+ klass
31
+ end
32
+
33
+ # @rbs return: Object & Enumerable[untyped]
34
+ def collection
35
+ raise NotImplementedError, "Collection backed query objects must define a 'collection' method"
36
+ end
37
+
38
+ # The default implementation of `query` just calls `collection`, however you can also
39
+ # override this method to return an ActiveRecord::Relation or any other query-like object as usual in a Query object.
40
+ # @rbs return: Object & Enumerable[untyped]
41
+ def query
42
+ collection
43
+ end
44
+
45
+ def results
46
+ Quo::CollectionResults.new(self, transformer: transformer, total_count: @total_count)
47
+ end
48
+
49
+ # @rbs override
50
+ def relation?
51
+ false
52
+ end
53
+
54
+ # @rbs override
55
+ def collection?
56
+ true
57
+ end
58
+
59
+ # @rbs override
60
+ def to_collection
61
+ self
62
+ end
63
+
64
+ private
65
+
66
+ def validated_query
67
+ query
68
+ end
69
+
70
+ # @rbs return: Object & Enumerable[untyped]
71
+ def underlying_query
72
+ validated_query
73
+ end
74
+
75
+ # The configured query is the underlying query with paging
76
+ def configured_query #: Object & Enumerable[untyped]
77
+ q = underlying_query
78
+ return q unless paged?
79
+
80
+ if q.respond_to?(:[])
81
+ q[offset, sanitised_page_size]
82
+ else
83
+ q
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ class CollectionResults < Results
7
+ # @rbs override
8
+ def initialize(query, transformer: nil, total_count: nil)
9
+ raise ArgumentError, "Query must be a CollectionBackedQuery" unless query.is_a?(Quo::CollectionBackedQuery)
10
+ @total_count = total_count
11
+ @query = query
12
+ @configured_query = query.unwrap
13
+ @transformer = transformer
14
+ end
15
+
16
+ # Are there any results for this query?
17
+ def exists? #: bool
18
+ @configured_query.present?
19
+ end
20
+
21
+ def empty? #: bool
22
+ !exists?
23
+ end
24
+
25
+ # Gets the count of all results ignoring the current page and page size (if set).
26
+ # Optionally return the `total_count` option if it has been set.
27
+ # This is useful when the total count is known and not equal to size
28
+ # of wrapped collection.
29
+ # @rbs override
30
+ def total_count #: Integer
31
+ @total_count || @query.unwrap_unpaginated.size
32
+ end
33
+
34
+ # Gets the actual count of elements in the page of results (assuming paging is being used, otherwise the count of
35
+ # all results)
36
+ def page_count #: Integer
37
+ @configured_query.size
38
+ end
39
+
40
+ # @rbs @query: Quo::CollectionBackedQuery
41
+ # @rbs @transformer: (^(untyped, ?Integer) -> untyped)?
42
+ # @rbs @configured_query: Object & Enumerable[untyped]
43
+ end
44
+ end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ module ComposedQuery
7
+ # Combine two Query classes into a new composed query class
8
+ # Combine two query-like or composeable entities:
9
+ # These can be Quo::Query, Quo::ComposedQuery, Quo::CollectionBackedQuery and ActiveRecord::Relations.
10
+ # See the `README.md` docs for more details.
11
+ # @rbs chosen_superclass: singleton(Quo::RelationBackedQuery | Quo::CollectionBackedQuery)
12
+ # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
13
+ # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
14
+ # @rbs joins: untyped
15
+ # @rbs return: singleton(Quo::ComposedQuery)
16
+ def composer(chosen_superclass, left_query_class, right_query_class, joins: nil)
17
+ validate_query_classes(left_query_class, right_query_class)
18
+
19
+ props = collect_properties(left_query_class, right_query_class)
20
+ klass = create_composed_class(chosen_superclass, props)
21
+
22
+ assign_query_metadata(klass, left_query_class, right_query_class, joins)
23
+ klass
24
+ end
25
+ module_function :composer
26
+
27
+ # We can also merge instance of prepared queries
28
+ # @rbs left_instance: Quo::Query | ::ActiveRecord::Relation
29
+ # @rbs right_instance: Quo::Query | ::ActiveRecord::Relation
30
+ # @rbs joins: untyped
31
+ # @rbs return: Quo::ComposedQuery
32
+ def merge_instances(left_instance, right_instance, joins: nil)
33
+ validate_instances(left_instance, right_instance)
34
+
35
+ if left_instance.is_a?(Quo::Query) && right_instance.is_a?(::ActiveRecord::Relation)
36
+ return merge_query_and_relation(left_instance, right_instance, joins)
37
+ elsif right_instance.is_a?(Quo::Query) && left_instance.is_a?(::ActiveRecord::Relation)
38
+ return merge_relation_and_query(left_instance, right_instance, joins)
39
+ elsif left_instance.is_a?(Quo::Query) && right_instance.is_a?(Quo::Query)
40
+ return merge_query_instances(left_instance, right_instance, joins)
41
+ end
42
+
43
+ # Both are AR relations
44
+ composer(Quo.relation_backed_query_base_class, left_instance, right_instance, joins: joins).new
45
+ end
46
+ module_function :merge_instances
47
+
48
+ # @rbs override
49
+ def query
50
+ merge_left_and_right
51
+ end
52
+
53
+ # @rbs override
54
+ def inspect
55
+ klass_name = is_a?(Quo::RelationBackedQuery) ? Quo::RelationBackedQuery.name : Quo::CollectionBackedQuery.name
56
+ "#{klass_name}<Quo::ComposedQuery>[#{self.class.quo_operand_desc(left.class)}, #{self.class.quo_operand_desc(right.class)}](#{super})"
57
+ end
58
+
59
+ class << self
60
+ private
61
+
62
+ # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
63
+ # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
64
+ def validate_query_classes(left_query_class, right_query_class)
65
+ unless left_query_class.respond_to?(:<) && right_query_class.respond_to?(:<)
66
+ raise ArgumentError, "Cannot compose #{left_query_class} and #{right_query_class}, are they both classes? If you want to use instances use `.merge_instances`"
67
+ end
68
+ end
69
+
70
+ # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
71
+ # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
72
+ def collect_properties(left_query_class, right_query_class)
73
+ props = {}
74
+ props.merge!(left_query_class.literal_properties.properties_index) if left_query_class < Quo::Query
75
+ props.merge!(right_query_class.literal_properties.properties_index) if right_query_class < Quo::Query
76
+ props
77
+ end
78
+
79
+ def create_composed_class(chosen_superclass, props)
80
+ Class.new(chosen_superclass) do
81
+ include Quo::ComposedQuery
82
+
83
+ class << self
84
+ attr_reader :_composing_joins, :_left_query, :_right_query
85
+
86
+ def inspect
87
+ left_desc = quo_operand_desc(_left_query)
88
+ right_desc = quo_operand_desc(_right_query)
89
+ klass_name = determine_class_name
90
+ "#{klass_name}<Quo::ComposedQuery>[#{left_desc}, #{right_desc}]"
91
+ end
92
+
93
+ # @rbs operand: Quo::ComposedQuery | Quo::Query | ::ActiveRecord::Relation
94
+ # @rbs return: String
95
+ def quo_operand_desc(operand)
96
+ if operand < Quo::ComposedQuery
97
+ operand.inspect
98
+ else
99
+ operand.name || operand.superclass&.name || "(anonymous)"
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # @rbs return: String
106
+ def determine_class_name
107
+ if self < Quo::RelationBackedQuery
108
+ Quo.relation_backed_query_base_class.name
109
+ else
110
+ Quo.collection_backed_query_base_class.name
111
+ end
112
+ end
113
+ end
114
+
115
+ props.each do |name, property|
116
+ prop(
117
+ name,
118
+ property.type,
119
+ property.kind,
120
+ reader: property.reader,
121
+ writer: property.writer,
122
+ default: property.default
123
+ )
124
+ end
125
+ end
126
+ end
127
+
128
+ # @rbs klass: Class
129
+ # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
130
+ # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
131
+ # @rbs joins: untyped
132
+ def assign_query_metadata(klass, left_query_class, right_query_class, joins)
133
+ klass.instance_variable_set(:@_composing_joins, joins)
134
+ klass.instance_variable_set(:@_left_query, left_query_class)
135
+ klass.instance_variable_set(:@_right_query, right_query_class)
136
+ end
137
+
138
+ # @rbs left_instance: Quo::Query | ::ActiveRecord::Relation
139
+ # @rbs right_instance: Quo::Query | ::ActiveRecord::Relation
140
+ def validate_instances(left_instance, right_instance)
141
+ unless left_instance.is_a?(Quo::Query) || left_instance.is_a?(::ActiveRecord::Relation)
142
+ raise ArgumentError, "Cannot merge, left has incompatible type #{left_instance.class}"
143
+ end
144
+
145
+ unless right_instance.is_a?(Quo::Query) || right_instance.is_a?(::ActiveRecord::Relation)
146
+ raise ArgumentError, "Cannot merge, right has incompatible type #{right_instance.class}"
147
+ end
148
+ end
149
+
150
+ # @rbs relation: ::ActiveRecord::Relation
151
+ # @rbs query: Quo::Query
152
+ # @rbs joins: untyped
153
+ def merge_query_and_relation(query, relation, joins)
154
+ base_class = query.is_a?(Quo::RelationBackedQuery) ?
155
+ Quo.relation_backed_query_base_class :
156
+ Quo.collection_backed_query_base_class
157
+
158
+ composer(base_class, query.class, relation, joins: joins).new(**query.to_h)
159
+ end
160
+
161
+ # @rbs relation: ::ActiveRecord::Relation
162
+ # @rbs query: Quo::Query
163
+ # @rbs joins: untyped
164
+ def merge_relation_and_query(relation, query, joins)
165
+ base_class = query.is_a?(Quo::RelationBackedQuery) ?
166
+ Quo.relation_backed_query_base_class :
167
+ Quo.collection_backed_query_base_class
168
+
169
+ composer(base_class, relation, query.class, joins: joins).new(**query.to_h)
170
+ end
171
+
172
+ # @rbs left_query: Quo::Query | ::ActiveRecord::Relation
173
+ # @rbs right_query: Quo::Query | ::ActiveRecord::Relation
174
+ def merge_query_instances(left_query, right_query, joins)
175
+ props = left_query.to_h.merge(right_query.to_h.compact)
176
+
177
+ base_class = determine_base_class_for_queries(left_query, right_query)
178
+ composer(base_class, left_query.class, right_query.class, joins: joins).new(**props)
179
+ end
180
+
181
+ # @rbs left_query: Quo::Query | ::ActiveRecord::Relation
182
+ # @rbs right_query: Quo::Query | ::ActiveRecord::Relation
183
+ def determine_base_class_for_queries(left_query, right_query)
184
+ both_relation_backed = left_query.is_a?(Quo::RelationBackedQuery) &&
185
+ right_query.is_a?(Quo::RelationBackedQuery)
186
+
187
+ both_relation_backed ? Quo.relation_backed_query_base_class :
188
+ Quo.collection_backed_query_base_class
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ # @rbs return: Hash[Symbol, untyped]
195
+ def child_options(query_class)
196
+ names = property_names(query_class)
197
+ to_h.slice(*names)
198
+ end
199
+
200
+ # @rbs return: Array[Symbol]
201
+ def property_names(query_class)
202
+ query_class.literal_properties.properties_index.keys
203
+ end
204
+
205
+ # @rbs return: Quo::Query | ::ActiveRecord::Relation
206
+ def left
207
+ lq = self.class._left_query
208
+ return lq if is_relation?(lq)
209
+ lq.new(**child_options(lq))
210
+ end
211
+
212
+ # @rbs return: Quo::Query | ::ActiveRecord::Relation
213
+ def right
214
+ rq = self.class._right_query
215
+ return rq if is_relation?(rq)
216
+ rq.new(**child_options(rq))
217
+ end
218
+
219
+ # @rbs return: ActiveRecord::Relation | CollectionBackedQuery
220
+ def merge_left_and_right
221
+ left_rel = quo_unwrap_unpaginated_query(left)
222
+ right_rel = quo_unwrap_unpaginated_query(right)
223
+
224
+ if both_relations?(left_rel, right_rel)
225
+ merge_active_record_relations(left_rel, right_rel)
226
+ elsif left_relation_right_enumerable?(left_rel, right_rel)
227
+ left_rel.to_a + right_rel
228
+ elsif left_enumerable_right_relation?(left_rel, right_rel) && left_rel.respond_to?(:+)
229
+ left_rel + right_rel.to_a
230
+ elsif left_rel.respond_to?(:+)
231
+ left_rel + right_rel
232
+ else
233
+ raise ArgumentError, "Cannot merge #{left.class} with #{right.class}"
234
+ end
235
+ end
236
+
237
+ # @rbs left_rel: ActiveRecord::Relation
238
+ # @rbs right_rel: ActiveRecord::Relation
239
+ # @rbs return: ActiveRecord::Relation
240
+ def merge_active_record_relations(left_rel, right_rel)
241
+ apply_joins(left_rel).merge(right_rel)
242
+ end
243
+
244
+ # @rbs left_rel: ActiveRecord::Relation
245
+ # @rbs return: ActiveRecord::Relation
246
+ def apply_joins(left_rel)
247
+ joins = self.class._composing_joins
248
+ joins.present? ? left_rel.joins(joins) : left_rel
249
+ end
250
+
251
+ # @rbs rel: untyped
252
+ # @rbs return: bool
253
+ def is_relation?(rel)
254
+ rel.is_a?(::ActiveRecord::Relation)
255
+ end
256
+
257
+ # @rbs left: untyped
258
+ # @rbs right: untyped
259
+ # @rbs return: bool
260
+ def both_relations?(left, right)
261
+ is_relation?(left) && is_relation?(right)
262
+ end
263
+
264
+ # @rbs left: untyped
265
+ # @rbs right: untyped
266
+ # @rbs return: bool
267
+ def left_relation_right_enumerable?(left, right)
268
+ is_relation?(left) && !is_relation?(right)
269
+ end
270
+
271
+ # @rbs left: untyped
272
+ # @rbs right: untyped
273
+ # @rbs return: bool
274
+ def left_enumerable_right_relation?(left, right)
275
+ !is_relation?(left) && is_relation?(right)
276
+ end
277
+ end
278
+ end
data/lib/quo/engine.rb ADDED
@@ -0,0 +1,11 @@
1
+ # rbs_inline: enabled
2
+
3
+ module Quo
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Quo
6
+
7
+ rake_tasks do
8
+ load "tasks/quo.rake"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/mock"
4
+
5
+ require_relative "../testing/collection_backed_fake"
6
+ require_relative "../testing/relation_backed_fake"
7
+
8
+ module Quo
9
+ module Minitest
10
+ module Helpers
11
+ def fake_query(query_class, results: [], total_count: nil, page_count: nil, &block)
12
+ # make it so that results of instances of this class return a fake Result object
13
+ # of the right type which returns the results passed in
14
+ if query_class < Quo::CollectionBackedQuery
15
+ klass = Class.new(Quo::Testing::CollectionBackedFake) do
16
+ if query_class < Quo::Preloadable
17
+ include Quo::Preloadable
18
+
19
+ def query
20
+ collection
21
+ end
22
+ end
23
+ end
24
+ query_class.stub(:new, ->(**kwargs) {
25
+ klass.new(results: results, total_count: total_count, page_count: page_count)
26
+ }) do
27
+ yield
28
+ end
29
+ elsif query_class < Quo::RelationBackedQuery
30
+ query_class.stub(:new, ->(**kwargs) {
31
+ Quo::Testing::RelationBackedFake.new(results: results, total_count: total_count, page_count: page_count)
32
+ }) do
33
+ yield
34
+ end
35
+ else
36
+ raise ArgumentError, "Not a Query class: #{query_class}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ module Preloadable
7
+ def self.included(base)
8
+ base.prop :_rel_preload, base._Nilable(base._Any), reader: false, writer: false
9
+ end
10
+
11
+ # This implementation of `query` calls `collection` and preloads the includes.
12
+ # @rbs return: Object & Enumerable[untyped]
13
+ def query
14
+ records = collection
15
+ preload_includes(records) if @_rel_preload
16
+ records
17
+ end
18
+
19
+ # For use with collections of ActiveRecord models.
20
+ # Configures ActiveRecord::Associations::Preloader to load associations of models in the collection
21
+ # @rbs *options: untyped
22
+ # @rbs return: Quo::Query
23
+ def preload(*options)
24
+ copy(_rel_preload: options)
25
+ end
26
+
27
+ # Alias for `preload`
28
+ # @rbs *options: untyped
29
+ # @rbs return: Quo::Query
30
+ def includes(*options)
31
+ preload(*options)
32
+ end
33
+
34
+ private
35
+
36
+ # @rbs @_rel_preload: untyped?
37
+
38
+ # @rbs (untyped records, ?untyped? preload) -> untyped
39
+ def preload_includes(records, preload = nil)
40
+ ::ActiveRecord::Associations::Preloader.new(
41
+ records: records,
42
+ associations: preload || @_rel_preload
43
+ ).call
44
+ end
45
+ end
46
+ end