quo 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []