quo 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5abae18b00be2197c2d35ed322af099766deaaa13f77f14fe53f597f2ed544b2
4
+ data.tar.gz: 95e1b9e161ad9e4d6fb627194cc6b408878945aa08c43ef498bbbfa0ea5c3dd3
5
+ SHA512:
6
+ metadata.gz: 9c6ce18b7bb3de2243561ad14da26117e7053a0c04255dc94b8408d942a1382e2c4eb2c6071c22f52d1a7cef9bf9fceeeb7746788f921c0c1c6a9896fa2f4717
7
+ data.tar.gz: 063f2dd88dcd09bd1b3778b8c6bc77b09a000daae113425fc3c7152c058c2e836cb5630fa58ef060fb099179eddf6119592e25e51cb7ad0380791cd7468a1cac
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 2.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2022-11-18
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in quo.gemspec
6
+ gemspec
7
+
8
+ gem "sqlite3"
9
+
10
+ gem "rails", ">= 6"
11
+
12
+ gem "rake", "~> 13.0"
13
+
14
+ gem "minitest", "~> 5.0"
15
+
16
+ gem "standard", "~> 1.3"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Stephen Ierodiaconou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,350 @@
1
+ # Quo
2
+
3
+ Quo query objects can help you abstract ActiveRecord DB queries into reusable and composable objects with a chainable
4
+ interface.
5
+
6
+ The query object can also abstract over any array-like collection meaning that it is possible for example to cache the
7
+ data from a query and reuse it.
8
+
9
+ The core implementation provides the following functionality:
10
+
11
+ * wrap around an underlying ActiveRecord or array-like collection
12
+ * optionally provides paging behaviour to ActiveRecord based queries
13
+ * provides a number of utility methods that operate on the underlying collection (eg `exists?`)
14
+ * provides a `+` (`compose`) method which merges two query object instances (see section below for details!)
15
+ * can specify a mapping or transform method to `transform` to perform on results
16
+ * in development outputs the callstack that led to the execution of the query
17
+ * acts as a callable which executes the underlying query with `.first`
18
+ * can return an `Enumerable` of results
19
+
20
+ ## Creating a Quo query object
21
+
22
+ The query object must inherit from `Quo::Query` and provide an implementation for the `query` method.
23
+
24
+ The `query` method must return either:
25
+
26
+ - an `ActiveRecord::Relation`
27
+ - an Array (an 'eager loaded' query)
28
+ - or another `Quo::Query` instance.
29
+
30
+ Remember that the query object should be useful in composition with other query objects. Thus it should not directly
31
+ specify things that are not directly needed to fetch the right data for the given context.
32
+
33
+ For example the ordering of the results is mostly something that is specified when the query object is used, not as
34
+ part of the query itself (as then it would always enforce the ordering on other queries it was composed with).
35
+
36
+ ## Passing options to queries
37
+
38
+ If any parameters are need in `query`, these are provided when instantiating the query object using the `options` hash.
39
+
40
+ It is also possible to pass special configuration options to the constructor options hash.
41
+
42
+ Specifically when the underlying collection is a ActiveRecord relation then:
43
+
44
+ * `order`: the `order` condition for the relation (eg `:desc`)
45
+ * `includes`: the `includes` condition for the relation (eg `account: {user: :profile}`)
46
+ * `group`: the `group` condition for the relation
47
+ * `page`: the current page number to fetch
48
+ * `page_size`: the number of elements to fetch in the page
49
+
50
+ Note that the above options have no bearing on the query if it is backed by an array-like collection and that some
51
+ options can be configured using the following methods.
52
+
53
+ ## Configuring queries
54
+
55
+ Note that it is possible to configure a query using chainable methods similar to ActiveRecord:
56
+
57
+ * limit
58
+ * order
59
+ * group
60
+ * includes
61
+ * left_outer_joins
62
+ * preload
63
+ * joins
64
+
65
+ Note that these return a new Quo Query and do not mutate the original instance.
66
+
67
+ ## Composition of queries (merging or combining them)
68
+
69
+ Quo query objects are composeability. In `ActiveRecord::Relation` this is acheived using `merge`
70
+ and so under the hood `Quo::Query` uses that when composing relations. However since Queries can also abstract over
71
+ array-like collections (ie enumerable and define a `+` method) compose also handles concating them together.
72
+
73
+ Composing can be done with either
74
+
75
+ - `Quo::Query.compose(left, right)`
76
+ - or `left.compose(right)`
77
+ - or more simply with `left + right`
78
+
79
+ The composition methods also accept an optional parameter to pass to ActiveRecord relation merges for the `joins`.
80
+ This allows you to compose together Query objects which return relations which are of different models but still merge
81
+ them correctly with the appropriate joins. Note with the alias you cant neatly specify optional parameters for joins
82
+ on relations.
83
+
84
+ Note that the compose process creates a new query object instance, which is a instance of a `Quo::MergedQuery`.
85
+
86
+ Consider the following cases:
87
+
88
+ 1. compose two query objects which return `ActiveRecord::Relation`s
89
+ 2. compose two query objects, one of which returns a `ActiveRecord::Relation`, and the other an array-like
90
+ 3. compose two query objects which return array-likes
91
+
92
+ In case (1) the compose process uses `ActiveRecords::Relation`'s `merge` method to create another query object
93
+ wrapped around a new 'composed' `ActiveRecords::Relation`.
94
+
95
+ In case (2) the query object with a `ActiveRecords::Relation` inside is executed, and the result is then concatenated
96
+ to the array-like with `+`
97
+
98
+ In case (3) the values contained with each 'eager' query object are concatenated with `+`
99
+
100
+ *Note that*
101
+
102
+ with `left.compose(right)`, `left` must obviously be an instance of a `Quo::Query`, and `right` can be either a
103
+ query object or and `ActiveRecord::Relation`. However `Quo::Query.compose(left, right)` also accepts
104
+ `ActiveRecord::Relation`s for left.
105
+
106
+ ### Examples
107
+
108
+ ```ruby
109
+ class CompanyToBeApproved < Quo::Query
110
+ def query
111
+ Registration
112
+ .left_joins(:approval)
113
+ .where(approvals: {completed_at: nil})
114
+ end
115
+ end
116
+
117
+ class CompanyInUsState < Quo::Query
118
+ def query
119
+ Registration
120
+ .joins(company: :address)
121
+ .where(addresses: {state: options[:state]})
122
+ end
123
+ end
124
+
125
+ query1 = CompanyToBeApproved.new
126
+ query2 = CompanyInUsState.new(state: "California")
127
+
128
+ # Compose
129
+ composed = query1 + query2 # or Quo::Query.compose(query1, query2) or query1.compose(query2)
130
+ composed.first
131
+ ```
132
+
133
+ This effectively executes:
134
+
135
+ ```ruby
136
+ Registration
137
+ .left_joins(:approval)
138
+ .joins(company: :address)
139
+ .where(approvals: {completed_at: nil})
140
+ .where(addresses: {state: options[:state]})
141
+ ```
142
+
143
+ It is also possible to compose with an `ActiveRecord::Relation`. This can be useful in a Query object itself to help
144
+ build up the `query` relation. For example:
145
+
146
+ ```ruby
147
+ class RegistrationToBeApproved < Quo::Query
148
+ def query
149
+ done = Registration.where(step: "complete")
150
+ approved = CompanyToBeApproved.new
151
+ done + approved
152
+ end
153
+ end
154
+ ```
155
+
156
+ Also you can use joins:
157
+
158
+ ```ruby
159
+ class TagByName < Quo::Query
160
+ def query
161
+ Tag.where(name: options[:name])
162
+ end
163
+ end
164
+
165
+ class CategoryByName < Quo::Query
166
+ def query
167
+ Category.where(name: options[:name])
168
+ end
169
+ end
170
+
171
+ tags = TagByName.new(name: "Intel")
172
+ for_category = CategoryByName.new(name: "CPUs")
173
+ tags.compose(for_category, :category) # perform join on tag association `category`
174
+
175
+ # equivalent to Tag.joins(:category).where(name: "Intel").where(categories: {name: "CPUs"})
176
+ ```
177
+
178
+ Eager loaded queries can also be composed (see below sections for more details).
179
+
180
+ ### Quo::MergedQuery
181
+
182
+ The new instance of `Quo::MergedQuery` from a compose process, retains references to the original entities that were
183
+ composed. These are then used to create a more useful output from `to_s`, so that it is easier to understand what the
184
+ merged query is actually made up of:
185
+
186
+ ```ruby
187
+ q = FooQuery.new + BarQuery.new
188
+ puts q
189
+ # > "Quo::MergedQuery[FooQuery, BarQuery]"
190
+ ```
191
+
192
+ ## Query Objects & Pagination
193
+
194
+ Specify extra options to enable pagination:
195
+
196
+ * `page`: the current page number to fetch
197
+ * `page_size`: the number of elements to fetch in the page
198
+
199
+ ## 'Eager loaded' Quo::Query objects
200
+
201
+ When a query object returns an `Array` from `query` it is assumed as 'eager loaded', ie that the query has actually
202
+ already been executed and the array contains the return values. This can also be used to encapsulate data that doesn't
203
+ actually come from an ActiveRecord query.
204
+
205
+ For example, the Query below executes the query inside on the first call and memoises the resulting data. Note
206
+ however that this then means that this Query is not reusuable to `merge` with other ActiveRecord queries. If it is
207
+ `compose`d with other Query objects then it will be seen as an array-like, and concatenated to whatever it is being
208
+ joined to.
209
+
210
+ ```ruby
211
+ class CachedTags < Quo::Query
212
+ def query
213
+ @tags ||= Tag.where(active: true).to_a
214
+ end
215
+ end
216
+
217
+ q = CachedTags.new(active: false)
218
+ q.eager? # is it 'eager'? Yes it is!
219
+ q.count # array size
220
+ ```
221
+
222
+ ### `Quo::EagerQuery` objects
223
+
224
+ `Quo::EagerQuery` is a subclass of `Quo::Query` which takes a data value on instantiation and returns it on calls to `query`
225
+
226
+ ```ruby
227
+ q = Quo::EagerQuery.new(collection: [1, 2, 3])
228
+ q.eager? # is it 'eager'? Yes it is!
229
+ q.count # '3'
230
+ ```
231
+
232
+ This is useful to create eager loaded Queries without needing to create a explicit subclass of your own.
233
+
234
+ `Quo::EagerQuery` also uses `total_count` option value as the specified 'total count', useful when the data is
235
+ actually just a page of the data and not the total count.
236
+
237
+ Example of an EagerQuery used to wrap a page of enumerable data:
238
+
239
+ ```ruby
240
+ Quo::EagerQuery.new(collection: my_data, total_count: 100, page: current_page)
241
+ ```
242
+
243
+ ### Composition
244
+
245
+ Examples of composition of eager loaded queries
246
+
247
+ ```ruby
248
+ class CachedTags < Quo::Query
249
+ def query
250
+ @tags ||= Tag.where(active: true).to_a
251
+ end
252
+ end
253
+
254
+ composed = CachedTags.new(active: false) + [1, 2]
255
+ composed.last
256
+ # => 2
257
+ composed.first
258
+ # => #<Tag id: ...>
259
+
260
+ Quo::EagerQuery.new([3, 4]).compose(Quo::EagerQuery.new(collection: [1, 2])).last
261
+ # => 2
262
+ Quo::Query.compose([1, 2], [3, 4]).last
263
+ # => 4
264
+ ```
265
+
266
+ ## Transforming results
267
+
268
+ Sometimes you want to specify a block to execute on each result for any method that returns results, such as `first`,
269
+ `last` and `each`.
270
+
271
+ This can be specified using the `transform(&block)` instance method. For example:
272
+
273
+ ```ruby
274
+ TagsQuery.new(
275
+ active: [true, false],
276
+ page: 1,
277
+ page_size: 30,
278
+ ).transform { |tag| TagPresenter.new(tag) }
279
+ .first
280
+ # => #<TagPresenter ...>
281
+ ```
282
+
283
+ ## Tests & stubbing
284
+
285
+ Tests for Query objects themselves should exercise the actual underlying query. But in other code stubbing the query
286
+ maybe desirable.
287
+
288
+ The spec helper method `stub_query(query_class, {results: ..., with: ...})` can do this for you.
289
+
290
+ It stubs `.new` on the Query object and returns instances of `EagerQuery` instead with the given `results`.
291
+ The `with` option is passed to the Query object on initialisation and used when setting up the method stub on the
292
+ query class.
293
+
294
+ For example:
295
+
296
+ ```ruby
297
+ stub_query(TagQuery, with: {name: "Something"}, results: [t1, t2])
298
+ expect(TagQuery.new(name: "Something").first).to eql t1
299
+ ```
300
+
301
+ *Note that*
302
+
303
+ This returns an instance of EagerQuery, so will not work for cases were the actual type of the query instance is
304
+ important or where you are doing a composition of queries backed by relations!
305
+
306
+ If `compose` will be used then `Quo::Query.compose` needs to be stubbed. Something might be possible to make this
307
+ nicer in future.
308
+
309
+ ## Other reading
310
+
311
+ See:
312
+ * [Includes vs preload vs eager_load](http://blog.scoutapp.com/articles/2017/01/24/activerecord-includes-vs-joins-vs-preload-vs-eager_load-when-and-where)
313
+ * [Objects on Rails](http://objectsonrails.com/#sec-14)
314
+
315
+
316
+ ## Installation
317
+
318
+ Install the gem and add to the application's Gemfile by executing:
319
+
320
+ $ bundle add quo
321
+
322
+ If bundler is not being used to manage dependencies, install the gem by executing:
323
+
324
+ $ gem install quo
325
+
326
+ ## Usage
327
+
328
+ TODO: Write usage instructions here
329
+
330
+ ## Development
331
+
332
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
333
+
334
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
335
+
336
+ ## Contributing
337
+
338
+ Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/quo.
339
+
340
+ ## Inspired by `rectify`
341
+
342
+ Note this implementation is loosely based on that in the `Rectify` gem; https://github.com/andypike/rectify.
343
+
344
+ See https://github.com/andypike/rectify#query-objects for more information.
345
+
346
+ Thanks to Andy Pike for the inspiration.
347
+
348
+ ## License
349
+
350
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ require "standard/rake"
13
+
14
+ task default: %i[test standard]
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ class EagerQuery < Quo::Query
5
+ def initialize(**options)
6
+ @collection = Array.wrap(options[:collection])
7
+ super(**options.except(:collection))
8
+ end
9
+
10
+ # Optionally return the `total_count` option
11
+ def count
12
+ options[:total_count] || super
13
+ end
14
+ alias_method :total_count, :count
15
+ alias_method :size, :count
16
+
17
+ # Is this query object paged? (when no total count)
18
+ def paged?
19
+ options[:total_count].nil? && current_page.present?
20
+ end
21
+
22
+ # Return the underlying collection
23
+ def query
24
+ preload_includes(collection) if options[:includes]
25
+ collection
26
+ end
27
+
28
+ def relation?
29
+ false
30
+ end
31
+
32
+ def eager?
33
+ true
34
+ end
35
+
36
+ protected
37
+
38
+ attr_reader :collection
39
+
40
+ def preload_includes(records, preload = nil)
41
+ ::ActiveRecord::Associations::Preloader.new(
42
+ records: records,
43
+ associations: preload || options[:includes]
44
+ )
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require_relative "./utilities/callstack"
5
+
6
+ module Quo
7
+ class Enumerator
8
+ extend Forwardable
9
+ include Quo::Utilities::Callstack
10
+
11
+ def initialize(query, transformer: nil)
12
+ @query = query
13
+ @unwrapped = query.unwrap
14
+ @transformer = transformer
15
+ end
16
+
17
+ def_delegators :unwrapped,
18
+ :include?,
19
+ :member?,
20
+ :all?,
21
+ :any?,
22
+ :none?,
23
+ :one?,
24
+ :tally,
25
+ :count,
26
+ :group_by,
27
+ :partition,
28
+ :slice_before,
29
+ :slice_after,
30
+ :slice_when,
31
+ :chunk,
32
+ :chunk_while,
33
+ :sum,
34
+ :zip
35
+
36
+ # Delegate other enumerable methods to underlying collection but also transform
37
+ def method_missing(method, *args, &block)
38
+ if unwrapped.respond_to?(method)
39
+ debug_callstack
40
+ if block
41
+ unwrapped.send(method, *args) do |*block_args|
42
+ x = block_args.first
43
+ transformed = transformer.present? ? transformer.call(x) : x
44
+ block.call(transformed, *block_args[1..])
45
+ end
46
+ else
47
+ raw = unwrapped.send(method, *args)
48
+ return raw if raw.is_a?(Quo::Enumerator) || raw.is_a?(::Enumerator)
49
+ transform_results(raw)
50
+ end
51
+ else
52
+ super
53
+ end
54
+ end
55
+
56
+ def respond_to_missing?(method, include_private = false)
57
+ Enumerable.instance_methods.include?(method) || super
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :transformer, :unwrapped
63
+
64
+ def transform_results(results)
65
+ return results unless transformer.present?
66
+
67
+ if results.is_a?(Enumerable)
68
+ results.map.with_index { |item, i| transformer.call(item, i) }
69
+ else
70
+ transformer.call(results)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ class MergedQuery < Quo::Query
5
+ def initialize(options, source_queries = [])
6
+ @source_queries = source_queries
7
+ super(**options)
8
+ end
9
+
10
+ def query
11
+ @scope
12
+ end
13
+
14
+ def to_s
15
+ left = operand_desc(source_queries_left)
16
+ right = operand_desc(source_queries_right)
17
+ "Quo::MergedQuery[#{left}, #{right}]"
18
+ end
19
+
20
+ private
21
+
22
+ def source_queries_left
23
+ source_queries&.first
24
+ end
25
+
26
+ def source_queries_right
27
+ source_queries&.last
28
+ end
29
+
30
+ attr_reader :source_queries
31
+
32
+ def operand_desc(operand)
33
+ return unless operand
34
+ if operand.is_a? Quo::MergedQuery
35
+ operand.to_s
36
+ else
37
+ operand.class.name
38
+ end
39
+ end
40
+ end
41
+ end
data/lib/quo/query.rb ADDED
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./utilities/callstack"
4
+ require_relative "./utilities/compose"
5
+ require_relative "./utilities/sanitize"
6
+ require_relative "./utilities/wrap"
7
+
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
+ # Execute the query and return the first item
18
+ def call(**options)
19
+ new(**options).first
20
+ end
21
+
22
+ # Execute the query and return the first item, or raise an error if no item is found
23
+ def call!(**options)
24
+ new(**options).first!
25
+ end
26
+ end
27
+
28
+ attr_reader :current_page, :page_size, :options
29
+
30
+ def initialize(**options)
31
+ @options = options
32
+ @current_page = options[:page]&.to_i || options[:current_page]&.to_i
33
+ @page_size = options[:page_size]&.to_i || 20
34
+ @scope = unwrap_relation(options[:scope])
35
+ end
36
+
37
+ # Returns a active record query, or a Quo::Query instance
38
+ # You must provide an implementation of this of pass the 'scope' option on instantiation
39
+ def query
40
+ return @scope unless @scope.nil?
41
+ raise NotImplementedError, "Query objects must define a 'query' method"
42
+ end
43
+
44
+ # Combine (compose) this query object with another composeable entity, see notes for `.compose` above.
45
+ # Compose is aliased as `+`. Can optionally take `joins()` parameters to perform a joins before the merge
46
+ def compose(right, joins = nil)
47
+ Quo::QueryComposer.new(self, right, joins).compose
48
+ end
49
+
50
+ alias_method :+, :compose
51
+
52
+ def copy(**options)
53
+ self.class.new(**@options.merge(options))
54
+ end
55
+
56
+ # Methods to prepare the query
57
+ def limit(limit)
58
+ copy(limit: limit)
59
+ end
60
+
61
+ def order(options)
62
+ copy(order: options)
63
+ end
64
+
65
+ def group(*options)
66
+ copy(group: options)
67
+ end
68
+
69
+ def includes(*options)
70
+ copy(includes: options)
71
+ end
72
+
73
+ def preload(*options)
74
+ copy(preload: options)
75
+ end
76
+
77
+ def select(*options)
78
+ copy(select: options)
79
+ end
80
+
81
+ # The following methods actually execute the underlying query
82
+
83
+ # Delegate SQL calculation methods to the underlying query
84
+ delegate :sum, :average, :minimum, :maximum, to: :query_with_logging
85
+
86
+ # Gets the count of all results ignoring the current page and page size (if set)
87
+ delegate :count, to: :underlying_query
88
+ alias_method :total_count, :count
89
+ alias_method :size, :count
90
+
91
+ # Gets the actual count of elements in the page of results (assuming paging is being used, otherwise the count of
92
+ # all results)
93
+ def page_count
94
+ query_with_logging.count
95
+ end
96
+
97
+ # Delegate methods that let us get the model class (available on AR relations)
98
+ delegate :model, :klass, to: :underlying_query
99
+
100
+ # Get first elements
101
+ def first(*args)
102
+ if transform?
103
+ res = query_with_logging.first(*args)
104
+ if res.is_a? Array
105
+ res.map.with_index { |r, i| transformer.call(r, i) }
106
+ elsif !res.nil?
107
+ transformer.call(query_with_logging.first(*args))
108
+ end
109
+ else
110
+ query_with_logging.first(*args)
111
+ end
112
+ end
113
+
114
+ def first!(*args)
115
+ item = first(*args)
116
+ raise ActiveRecord::RecordNotFound, "No item could be found!" unless item
117
+ item
118
+ end
119
+
120
+ # Get last elements
121
+ def last(*args)
122
+ if transform?
123
+ res = query_with_logging.last(*args)
124
+ if res.is_a? Array
125
+ res.map.with_index { |r, i| transformer.call(r, i) }
126
+ elsif !res.nil?
127
+ transformer.call(query_with_logging.last(*args))
128
+ end
129
+ else
130
+ query_with_logging.last(*args)
131
+ end
132
+ end
133
+
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
138
+ end
139
+
140
+ # Convert to EagerQuery, and load all data
141
+ def to_eager(more_opts = {})
142
+ Quo::EagerQuery.new(collection: to_a, **options.merge(more_opts))
143
+ end
144
+ alias_method :load, :to_eager
145
+
146
+ # Return an enumerable
147
+ def enumerator
148
+ Quo::Enumerator.new(self, transformer: transformer)
149
+ end
150
+
151
+ # Some convenience methods for iterating over the results
152
+ delegate :each, :map, :flat_map, :reduce, :reject, :filter, to: :enumerator
153
+
154
+ # Set a block used to transform data after query fetching
155
+ def transform(&block)
156
+ @options[:__transformer] = block
157
+ self
158
+ end
159
+
160
+ # Are there any results for this query?
161
+ def exists?
162
+ return query_with_logging.exists? if relation?
163
+ query_with_logging.present?
164
+ end
165
+
166
+ # Are there no results for this query?
167
+ def none?
168
+ !exists?
169
+ end
170
+ alias_method :empty?, :none?
171
+
172
+ # Is this query object a relation under the hood? (ie not eager loaded)
173
+ def relation?
174
+ test_relation(configured_query)
175
+ end
176
+
177
+ # Is this query object eager loaded data under the hood? (ie not a relation)
178
+ def eager?
179
+ test_eager(configured_query)
180
+ end
181
+
182
+ # Is this query object paged? (ie is paging enabled)
183
+ def paged?
184
+ current_page.present?
185
+ end
186
+
187
+ # Is this query object transforming results?
188
+ def transform?
189
+ transformer.present?
190
+ end
191
+
192
+ # Return the SQL string for this query if its a relation type query object
193
+ def to_sql
194
+ configured_query.to_sql if relation?
195
+ end
196
+
197
+ # Unwrap the underlying query
198
+ def unwrap
199
+ configured_query
200
+ end
201
+
202
+ delegate :distinct, to: :configured_query
203
+
204
+ protected
205
+
206
+ def formatted_queries?
207
+ Quo.configuration&.formatted_query_log
208
+ end
209
+
210
+ # 'trim' a query, ie remove comments and remove newlines
211
+ # This will remove dashes from inside strings too
212
+ def trim_query(sql)
213
+ sql.gsub(/--[^\n'"]*\n/m, " ").tr("\n", " ").strip
214
+ end
215
+
216
+ def format_query(sql_str)
217
+ formatted_queries? ? sql_str : trim_query(sql_str)
218
+ end
219
+
220
+ def transformer
221
+ options[:__transformer]
222
+ end
223
+
224
+ def offset
225
+ per_page = sanitised_page_size
226
+ page = current_page.positive? ? current_page : 1
227
+ per_page * (page - 1)
228
+ end
229
+
230
+ # The configured query is the underlying query with paging
231
+ def configured_query
232
+ q = underlying_query
233
+ return q unless paged? && q.is_a?(ActiveRecord::Relation)
234
+ q.offset(offset).limit(sanitised_page_size)
235
+ end
236
+
237
+ def sanitised_page_size
238
+ (page_size.present? && page_size.positive?) ? [page_size.to_i, 200].min : 20
239
+ end
240
+
241
+ def query_with_logging
242
+ debug_callstack
243
+ configured_query
244
+ end
245
+
246
+ # The underlying query is essentially the configured query with optional extras setup
247
+ def underlying_query
248
+ @underlying_query ||=
249
+ begin
250
+ rel = unwrap_relation(query)
251
+ unless test_eager(rel)
252
+ rel = rel.group(@options[:group]) if @options[:group].present?
253
+ rel = rel.order(@options[:order]) if @options[:order].present?
254
+ rel = rel.limit(@options[:limit]) if @options[:limit].present?
255
+ rel = rel.preload(@options[:preload]) if @options[:preload].present?
256
+ rel = rel.includes(@options[:includes]) if @options[:includes].present?
257
+ rel = rel.joins(@options[:joins]) if @options[:joins].present?
258
+ rel = rel.select(@options[:select]) if @options[:select].present?
259
+ end
260
+ rel
261
+ end
262
+ end
263
+
264
+ def unwrap_relation(query)
265
+ query.is_a?(Quo::Query) ? query.unwrap : query
266
+ end
267
+
268
+ def test_eager(rel)
269
+ rel.is_a?(Enumerable) && !test_relation(rel)
270
+ end
271
+
272
+ def test_relation(rel)
273
+ rel.is_a?(ActiveRecord::Relation)
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ class QueryComposer
5
+ def initialize(left, right, joins = nil)
6
+ @left = left
7
+ @right = right
8
+ @joins = joins
9
+ end
10
+
11
+ def compose
12
+ combined = merge
13
+ Quo::MergedQuery.new(
14
+ merged_options.merge({scope: combined, source_queries: [left, right]})
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :left, :right, :joins
21
+
22
+ def merge
23
+ left_rel, right_rel = unwrap_relations
24
+ left_type, right_type = relation_types?
25
+ if both_relations?(left_type, right_type)
26
+ apply_joins(left_rel, joins).merge(right_rel)
27
+ elsif left_relation_right_eager?(left_type, right_type)
28
+ left_rel.to_a + right_rel
29
+ elsif left_eager_right_relation?(left_rel, left_type, right_type)
30
+ left_rel + right_rel.to_a
31
+ elsif both_eager_loaded?(left_rel, left_type, right_type)
32
+ left_rel + right_rel
33
+ else
34
+ raise_error
35
+ end
36
+ end
37
+
38
+ def merged_options
39
+ return left.options.merge(right.options) if left.is_a?(Quo::Query) && right.is_a?(Quo::Query)
40
+ return left.options if left.is_a?(Quo::Query)
41
+ return right.options if right.is_a?(Quo::Query)
42
+ {}
43
+ end
44
+
45
+ def relation_types?
46
+ [left, right].map do |query|
47
+ if query.is_a?(Quo::Query)
48
+ query.relation?
49
+ else
50
+ query.is_a?(ActiveRecord::Relation)
51
+ end
52
+ end
53
+ end
54
+
55
+ def apply_joins(left_rel, joins)
56
+ joins.present? ? left_rel.joins(joins.to_sym) : left_rel
57
+ end
58
+
59
+ def both_relations?(left_rel_type, right_rel_type)
60
+ left_rel_type && right_rel_type
61
+ end
62
+
63
+ def left_relation_right_eager?(left_rel_type, right_rel_type)
64
+ left_rel_type && !right_rel_type
65
+ end
66
+
67
+ def left_eager_right_relation?(left_rel, left_rel_type, right_rel_type)
68
+ !left_rel_type && right_rel_type && left_rel.respond_to?(:+)
69
+ end
70
+
71
+ def both_eager_loaded?(left_rel, left_rel_type, right_rel_type)
72
+ !left_rel_type && !right_rel_type && left_rel.respond_to?(:+)
73
+ end
74
+
75
+ def unwrap_relations
76
+ [left, right].map { |query| query.is_a?(Quo::Query) ? query.unwrap : query }
77
+ end
78
+
79
+ def raise_error
80
+ raise ArgumentError, "Unable to composite queries #{left.class.name} and " \
81
+ "#{right.class.name}. You cannot compose queries where #query " \
82
+ "returns an ActiveRecord::Relation in one and an Enumerable in the other."
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,7 @@
1
+ module Quo
2
+ class Railtie < ::Rails::Railtie
3
+ # rake_tasks do
4
+ # load "tasks/quo.rake"
5
+ # end
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ module Rspec
5
+ module Helpers
6
+ def stub_query(query_class, options = {})
7
+ results = options.fetch(:results, [])
8
+ with = options[:with]
9
+ unless with.nil?
10
+ return(
11
+ allow(query_class).to receive(:new)
12
+ .with(with) { ::Quo::EagerQuery.new(collection: results) }
13
+ )
14
+ end
15
+ allow(query_class).to receive(:new) { ::Quo::EagerQuery.new(collection: results) }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ module Utilities
5
+ module Callstack
6
+ def debug_callstack
7
+ return unless Quo.configuration&.query_show_callstack_size&.positive? && Rails.env.development?
8
+ max_stack = Quo.configuration.query_show_callstack_size
9
+ working_dir = Dir.pwd
10
+ exclude = %r{/(gems/|rubies/|query\.rb)}
11
+ stack = Kernel.caller.grep_v(exclude).map { |l| l.gsub(working_dir + "/", "") }
12
+ trace_message = stack[0..max_stack].join("\n &> ")
13
+ message = "\n[Query stack]: -> #{trace_message}\n"
14
+ message += " (truncated to #{max_stack} most recent)" if stack.size > max_stack
15
+ Quo.configuration.logger&.info(message)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ module Utilities
5
+ module Compose
6
+ # Combine two query-like or composeable entities:
7
+ # These can be Quo::Query, Quo::MergedQuery, Quo::EagerQuery and ActiveRecord::Relations.
8
+ # See the `README.md` docs for more details.
9
+ def compose(query1, query2, joins = nil)
10
+ Quo::QueryComposer.new(query1, query2, joins).compose
11
+ end
12
+
13
+ # Determines if the object `query` is something which can be composed with query objects
14
+ def composable_with?(query)
15
+ query.is_a?(Quo::Query) || query.is_a?(ActiveRecord::Relation)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ module Utilities
5
+ module Sanitize
6
+ # ActiveRecord::Sanitization wrappers
7
+ def sanitize_sql_for_conditions(conditions)
8
+ ActiveRecord::Base.sanitize_sql_for_conditions(conditions)
9
+ end
10
+
11
+ def sanitize_sql_string(string)
12
+ sanitize_sql_for_conditions(["'%s'", string])
13
+ end
14
+
15
+ def sanitize_sql_parameter(value)
16
+ sanitize_sql_for_conditions(["?", value])
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ module Utilities
5
+ module Wrap
6
+ # Wrap a relation in a Query. If the passed in object is already a query object then just return it
7
+ def wrap(query_rel_or_data, **options)
8
+ if query_rel_or_data.is_a?(Quo::Query) && options.present?
9
+ return query_rel_or_data.copy(**options)
10
+ end
11
+ return query_rel_or_data if query_rel_or_data.is_a? Quo::Query
12
+ if query_rel_or_data.is_a? ActiveRecord::Relation
13
+ return new(**options.merge(scope: query_rel_or_data))
14
+ end
15
+ Quo::EagerQuery.new(**options.merge(collection: query_rel_or_data))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quo
4
+ VERSION = "0.1.0"
5
+ end
data/lib/quo.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "quo/version"
4
+ require_relative "quo/query"
5
+ require_relative "quo/eager_query"
6
+ require_relative "quo/merged_query"
7
+ require_relative "quo/query_composer"
8
+ require_relative "quo/enumerator"
9
+
10
+ module Quo
11
+ class << self
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration) if block_given?
18
+ configuration
19
+ end
20
+ end
21
+
22
+ class Configuration
23
+ attr_accessor :formatted_query_log, :query_show_callstack_size, :logger
24
+
25
+ def initialize
26
+ @formatted_query_log = true
27
+ @query_show_callstack_size = 10
28
+ @logger = nil
29
+ end
30
+ end
31
+ end
data/sig/quo.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Quo
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: quo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stephen Ierodiaconou
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-11-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ description: Quo query objects are composable.
28
+ email:
29
+ - stevegeek@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".standard.yml"
35
+ - CHANGELOG.md
36
+ - Gemfile
37
+ - LICENSE.txt
38
+ - README.md
39
+ - Rakefile
40
+ - lib/quo.rb
41
+ - lib/quo/eager_query.rb
42
+ - lib/quo/enumerator.rb
43
+ - lib/quo/merged_query.rb
44
+ - lib/quo/query.rb
45
+ - lib/quo/query_composer.rb
46
+ - lib/quo/railtie.rb
47
+ - lib/quo/rspec/helpers.rb
48
+ - lib/quo/utilities/callstack.rb
49
+ - lib/quo/utilities/compose.rb
50
+ - lib/quo/utilities/sanitize.rb
51
+ - lib/quo/utilities/wrap.rb
52
+ - lib/quo/version.rb
53
+ - sig/quo.rbs
54
+ homepage: https://github.com/stevegeek/quo
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ homepage_uri: https://github.com/stevegeek/quo
59
+ source_code_uri: https://github.com/stevegeek/quo
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 2.7.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.3.7
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: Quo is a query object gem for Rails
79
+ test_files: []