query-composer 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +408 -0
- data/Rakefile +17 -0
- data/examples/library.rb +200 -0
- data/lib/query/base.rb +55 -0
- data/lib/query/composer.rb +315 -0
- data/lib/query/composer/version.rb +11 -0
- data/lib/query/wrapper.rb +7 -0
- data/query-composer.gemspec +28 -0
- data/test/query/base_test.rb +62 -0
- data/test/query/composer_test.rb +102 -0
- data/test/query/wrapper_test.rb +10 -0
- data/test/test_helper.rb +46 -0
- metadata +133 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1b05724ac382544935d0309866641cb0b1e80bb6
|
4
|
+
data.tar.gz: a972fcb45a739af2874bb7fb03c8d1e9fd0eaed3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9a981449aa25be5c058063efef220f61dc5523bad4ebe0951d6ba9f65470a7c9bfd5bbc0fbd2642c1d698945e8f5501f26d8fb1f3655fa66de5651639afec228
|
7
|
+
data.tar.gz: 5e688be8b728789515a5136643bb4d1771a5f347c11d447f54413411fcecb28c171ef3a5f99e5192699fdde57cf5254f5d0ed20dd003fee4f1606e07df0721a2
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2016 Jamis Buck
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,408 @@
|
|
1
|
+
# Query::Composer
|
2
|
+
|
3
|
+
Simple SQL queries are, well, simple. But when you start needing to deal with nested subqueries, and especially when those nested subqueries themselves require nested subqueries...things start getting difficult to manage.
|
4
|
+
|
5
|
+
`Query::Composer` was extracted from a real application, where reporting queries were dynamically generated and typically exceeded 50KB of text for the query alone!
|
6
|
+
|
7
|
+
This library allows you to specify each component of query independently, as well as allowing you to indicate which other components each component depends on. The composer will then build the correct query from those components, on demand.
|
8
|
+
|
9
|
+
## Features
|
10
|
+
|
11
|
+
* Define your queries in terms of components, each of which is more easily tested and debugged
|
12
|
+
* A dependency-resolution system for determining the proper ordering of query subcomponents within a complex query
|
13
|
+
* A simple class (`Query::Base`) for more conveniently defining queries using Arel
|
14
|
+
* The ability to generate the same query using either derived tables (nested subqueries), or CTEs (Common Table Expressions)
|
15
|
+
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
First, instantiate a composer object:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
require 'query/composer'
|
23
|
+
|
24
|
+
composer = Query::Composer.new
|
25
|
+
```
|
26
|
+
|
27
|
+
Then, declare the components of your query with the `#use` method:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
composer.use(:patrons) { Patron.all }
|
31
|
+
```
|
32
|
+
|
33
|
+
Declare dependent components by providing parameters to the block that are named the same as the components that should be depended on:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
# `patrons` must exist as another component in the composer...
|
37
|
+
composer.use(:books) { |patrons| ... }
|
38
|
+
```
|
39
|
+
|
40
|
+
Component definitions must return an object that responds to either `#arel`, or `#to_sql`:
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
# ActiveRecord scopes respond to #arel
|
44
|
+
composer.use(:patrons) { Patron.all }
|
45
|
+
|
46
|
+
require 'query/base'
|
47
|
+
|
48
|
+
# Arel objects and Query::Base (a thin wrapper around
|
49
|
+
# Arel::SelectManager) respond to #to_sql
|
50
|
+
composer.use(:books_by_patron) do |patrons|
|
51
|
+
books = Book.arel_table
|
52
|
+
lendings = Lending.arel_table
|
53
|
+
|
54
|
+
Query::Base.new(books).
|
55
|
+
project(patrons[:first_name], books[:name]).
|
56
|
+
join(lendings).
|
57
|
+
on(lendings[:book_id].eq(books[:id])).
|
58
|
+
join(patrons).
|
59
|
+
on(patrons[:id].eq(lendings[:patron_id]))
|
60
|
+
end
|
61
|
+
```
|
62
|
+
|
63
|
+
Generate the query by calling `#build` on the composer, and telling it which component will be the root of the query:
|
64
|
+
|
65
|
+
```ruby
|
66
|
+
# Builds the query using the books_by_patron component as the root.
|
67
|
+
query = composer.build(:books_by_patron)
|
68
|
+
# SELECT "patrons"."first_name", "books"."name"
|
69
|
+
# FROM "books"
|
70
|
+
# INNER JOIN "lendings"
|
71
|
+
# ON "lendings"."book_id" = "books"."id"
|
72
|
+
# INNER JOIN (
|
73
|
+
# SELECT "patrons".* FROM "patrons"
|
74
|
+
# ) "patrons"
|
75
|
+
# ON "patrons"."id" = "lendings"."patron_id"
|
76
|
+
|
77
|
+
# Builds the query using the patrons component as the root
|
78
|
+
query = composer.build(:patrons)
|
79
|
+
# SELECT "patrons".* FROM "patrons"
|
80
|
+
```
|
81
|
+
|
82
|
+
Run the query by converting it to SQL and executing it:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
sql = query.to_sql
|
86
|
+
|
87
|
+
# using raw ActiveRecord connection
|
88
|
+
rows = ActiveRecord::Base.connection.execute(sql)
|
89
|
+
|
90
|
+
# using ActiveRecord models
|
91
|
+
rows = Book.find_by_sql(sql)
|
92
|
+
```
|
93
|
+
|
94
|
+
|
95
|
+
## Example
|
96
|
+
|
97
|
+
Let's use a library system as an example. (See this full example in `examples/library.rb`.) We'll imagine that there is some administrative interface where users can generate reports. One report in particular is used to show:
|
98
|
+
|
99
|
+
* All patrons from a specified set of libraries,
|
100
|
+
* Who have checked out books this month,
|
101
|
+
* From a specified set of topics,
|
102
|
+
* And compare that with the same period of the previous month.
|
103
|
+
|
104
|
+
We will assume that we have a data model consisting of libraries, topics, books, patrons, and lendings, where books belong to libraries and topics, and lendings relate patrons to books, and include the date the lending was created.
|
105
|
+
|
106
|
+
First, instantiate a composer object:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
require 'query/composer'
|
110
|
+
require 'query/base'
|
111
|
+
|
112
|
+
composer = Query::Composer.new
|
113
|
+
```
|
114
|
+
|
115
|
+
We'll assume we have some object that describes the parameters for the query, as given by the user:
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
today = Date.today
|
119
|
+
|
120
|
+
config.current_period_from = today.beginning_of_month
|
121
|
+
config.current_period_to = today
|
122
|
+
config.prior_period_from = today.last_month.beginning_of_month
|
123
|
+
config.prior_period_to = today.last_month
|
124
|
+
|
125
|
+
config.library_ids = [ ... ]
|
126
|
+
config.topic_ids = [ ... ]
|
127
|
+
```
|
128
|
+
|
129
|
+
Then, we tell the composer about the components of our query:
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
# The set of libraries specified by the user
|
133
|
+
composer.use(:libraries_set) { Library.where(id: config.library_ids) }
|
134
|
+
|
135
|
+
# The set of topics specified by the user
|
136
|
+
composer.use(:topics_set) { Topic.where(id: config.topic_ids) }
|
137
|
+
|
138
|
+
# The set of patrons to consider (all of them, here)
|
139
|
+
composer.use(:patrons_set) { Patron.all }
|
140
|
+
|
141
|
+
# The set of books to consider (all those from the given libraries
|
142
|
+
# with the given topics)
|
143
|
+
composer.use(:books_set) do |libraries_set, topics_set|
|
144
|
+
books = Book.arel_table
|
145
|
+
|
146
|
+
Query::Base.new(books).
|
147
|
+
project(books[:id]).
|
148
|
+
join(libraries_set).
|
149
|
+
on(books[:library_id].eq(libraries_set[:id])).
|
150
|
+
join(topics_set).
|
151
|
+
on(books[:topic_id].eq(topics_set[:id]))
|
152
|
+
end
|
153
|
+
```
|
154
|
+
|
155
|
+
Note the use of the parameters in the block for `books_set`. The names for the parameters are explicitly chosen here to match the names of other query components. `Query::Composer` uses these names to determine which components a component depends on--in this case, `books_set` depends on both `libraries_set` and `topics_set`.
|
156
|
+
|
157
|
+
We still need to tell the composer how to find the lendings. Because we'll need the same query with two different date spans (one for the "current" period, and one for the "prior" period), we'll create a helper method:
|
158
|
+
|
159
|
+
```ruby
|
160
|
+
# books_set -- the set of books to be considered
|
161
|
+
# from_date -- the beginning of the period to consider
|
162
|
+
# to_date -- the end of the period to consider
|
163
|
+
def lendings_set(books_set, from_date, to_date)
|
164
|
+
lendings = Lending.arel_table
|
165
|
+
|
166
|
+
patron_id = lendings[:patron_id]
|
167
|
+
count = patron_id.count.as("total")
|
168
|
+
|
169
|
+
Query::Base.new(lendings).
|
170
|
+
project(patron_id, count).
|
171
|
+
join(books_set).
|
172
|
+
on(lendings[:book_id].eq(books_set[:id])).
|
173
|
+
where(lendings[:created_at].between(from_date..to_date)).
|
174
|
+
group(patron_id)
|
175
|
+
end
|
176
|
+
```
|
177
|
+
|
178
|
+
This lendings set will be all patron ids who borrowed any of the books in the given set, between the given dates, and will include how many books were borrowed by each patron during that period.
|
179
|
+
|
180
|
+
With that, we can now finish defining our query components:
|
181
|
+
|
182
|
+
```ruby
|
183
|
+
# Books in the "current" set
|
184
|
+
composer.use(:current_set) do |books_set|
|
185
|
+
lendings_set(books_set,
|
186
|
+
config.current_period_from,
|
187
|
+
config.current_period_to)
|
188
|
+
end
|
189
|
+
|
190
|
+
composer.use(:prior_set) do |books_set|
|
191
|
+
lendings_set(books_set,
|
192
|
+
config.prior_period_from,
|
193
|
+
config.prior_period_to)
|
194
|
+
end
|
195
|
+
|
196
|
+
# Joins the current_set and prior_set to the patrons_set
|
197
|
+
composer.use(:combined_set) do |patrons_set, current_set, prior_set|
|
198
|
+
Query::Base.new(patrons_set).
|
199
|
+
project(patrons_set[Arel.star],
|
200
|
+
current_set[:total].as("current_total"),
|
201
|
+
prior_set[:total].as("prior_total")).
|
202
|
+
join(current_set).
|
203
|
+
on(current_set[:patron_id].eq(patrons_set[:id])).
|
204
|
+
join(prior_set, Arel::Nodes::OuterJoin).
|
205
|
+
on(prior_set[:patron_id].eq(patrons_set[:id]))
|
206
|
+
end
|
207
|
+
```
|
208
|
+
|
209
|
+
There--our query is defined. Now we just need to tell the composer to generate the SQL. Once we have the SQL, we can use it to query the database:
|
210
|
+
|
211
|
+
```ruby
|
212
|
+
sql = composer.build(:combined_set).to_sql
|
213
|
+
|
214
|
+
Patron.find_by_sql(sql).each do |patron|
|
215
|
+
puts "#{patron.name} :: #{patron.current_total} :: #{patron.prior_total}"
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
The generated query, assuming a current month of Feb 2016, might look like this (formatted for readability):
|
220
|
+
|
221
|
+
```sql
|
222
|
+
SELECT a.*,
|
223
|
+
e."total" AS current_total,
|
224
|
+
f."total" AS prior_total
|
225
|
+
FROM (
|
226
|
+
SELECT "patrons".*
|
227
|
+
FROM "patrons"
|
228
|
+
) a
|
229
|
+
INNER JOIN (
|
230
|
+
SELECT "lendings"."patron_id",
|
231
|
+
COUNT("lendings"."patron_id") AS total
|
232
|
+
FROM "lendings"
|
233
|
+
INNER JOIN (
|
234
|
+
SELECT "books"."id"
|
235
|
+
FROM "books"
|
236
|
+
INNER JOIN (
|
237
|
+
SELECT "libraries".*
|
238
|
+
FROM "libraries"
|
239
|
+
WHERE "libraries"."id" IN (1, 2)
|
240
|
+
) b
|
241
|
+
ON "books"."library_id" = b."id"
|
242
|
+
INNER JOIN (
|
243
|
+
SELECT "topics".*
|
244
|
+
FROM "topics"
|
245
|
+
WHERE "topics"."id" IN (1, 2, 3, 4)
|
246
|
+
) c
|
247
|
+
ON "books"."topic_id" = c."id"
|
248
|
+
) d
|
249
|
+
ON "lendings"."book_id" = d."id"
|
250
|
+
WHERE "lendings"."created_at" BETWEEN '2016-02-01' AND '2016-02-15'
|
251
|
+
GROUP BY "lendings"."patron_id"
|
252
|
+
) e
|
253
|
+
ON e."patron_id" = a."id"
|
254
|
+
LEFT OUTER JOIN (
|
255
|
+
SELECT "lendings"."patron_id",
|
256
|
+
COUNT("lendings"."patron_id") AS total
|
257
|
+
FROM "lendings"
|
258
|
+
INNER JOIN (
|
259
|
+
SELECT "books"."id"
|
260
|
+
FROM "books"
|
261
|
+
INNER JOIN (
|
262
|
+
SELECT "libraries".*
|
263
|
+
FROM "libraries"
|
264
|
+
WHERE "libraries"."id" IN (1, 2)
|
265
|
+
) b
|
266
|
+
ON "books"."library_id" = b."id"
|
267
|
+
INNER JOIN (
|
268
|
+
SELECT "topics".*
|
269
|
+
FROM "topics"
|
270
|
+
WHERE "topics"."id" IN (1, 2, 3, 4)
|
271
|
+
) c
|
272
|
+
ON "books"."topic_id" = c."id"
|
273
|
+
) d
|
274
|
+
ON "lendings"."book_id" = d."id"
|
275
|
+
WHERE "lendings"."created_at" BETWEEN '2016-01-01' AND '2016-01-15'
|
276
|
+
GROUP BY "lendings"."patron_id"
|
277
|
+
) f
|
278
|
+
ON f."patron_id" = a."id"
|
279
|
+
```
|
280
|
+
|
281
|
+
For databases that support Common Table Expressions (CTE, or "with" queries), you can pass `use_cte: true` to the `composer#build` method to have the composer generate a CTE query instead. (NOTE that CTE queries can be very inefficient in some DBMS's, like PostgreSQL!)
|
282
|
+
|
283
|
+
```ruby
|
284
|
+
sql = composer.build(:combined_set, use_cte: true)
|
285
|
+
```
|
286
|
+
|
287
|
+
The CTE query looks like this:
|
288
|
+
|
289
|
+
```sql
|
290
|
+
WITH
|
291
|
+
"a" AS (
|
292
|
+
SELECT "patrons".* FROM "patrons"),
|
293
|
+
"b" AS (
|
294
|
+
SELECT "libraries".*
|
295
|
+
FROM "libraries"
|
296
|
+
WHERE "libraries"."id" IN (1, 2)),
|
297
|
+
"c" AS (
|
298
|
+
SELECT "topics".*
|
299
|
+
FROM "topics"
|
300
|
+
WHERE "topics"."id" IN (1, 2, 3, 4)),
|
301
|
+
"d" AS (
|
302
|
+
SELECT "books"."id"
|
303
|
+
FROM "books"
|
304
|
+
INNER JOIN "b"
|
305
|
+
ON "books"."library_id" = "b"."id"
|
306
|
+
INNER JOIN "c"
|
307
|
+
ON "books"."topic_id" = "c"."id"),
|
308
|
+
"e" AS (
|
309
|
+
SELECT "lendings"."patron_id",
|
310
|
+
COUNT("lendings"."patron_id") AS total
|
311
|
+
FROM "lendings"
|
312
|
+
INNER JOIN "d"
|
313
|
+
ON "lendings"."book_id" = "d"."id"
|
314
|
+
WHERE "lendings"."created_at" BETWEEN '2016-02-01' AND '2016-02-15'
|
315
|
+
GROUP BY "lendings"."patron_id"),
|
316
|
+
"f" AS (
|
317
|
+
SELECT "lendings"."patron_id",
|
318
|
+
COUNT("lendings"."patron_id") AS total
|
319
|
+
FROM "lendings"
|
320
|
+
INNER JOIN "d" ON "lendings"."book_id" = "d"."id"
|
321
|
+
WHERE "lendings"."created_at" BETWEEN '2016-01-01' AND '2016-01-15'
|
322
|
+
GROUP BY "lendings"."patron_id")
|
323
|
+
SELECT "a".*,
|
324
|
+
"e"."total" AS current_total,
|
325
|
+
"f"."total" AS prior_total
|
326
|
+
FROM "a"
|
327
|
+
INNER JOIN "e"
|
328
|
+
ON "e"."patron_id" = "a"."id"
|
329
|
+
LEFT OUTER JOIN "f"
|
330
|
+
ON "f"."patron_id" = "a"."id"
|
331
|
+
```
|
332
|
+
|
333
|
+
Also, to make it easier to debug queries, you can also pass `use_aliases: false` to `composer#build` in order to make the composer use the full component names, instead of shorter aliases.
|
334
|
+
|
335
|
+
```ruby
|
336
|
+
sql = composer.build(:combined_set, use_aliases: false)
|
337
|
+
```
|
338
|
+
|
339
|
+
The resulting query:
|
340
|
+
|
341
|
+
```sql
|
342
|
+
SELECT patrons_set.*,
|
343
|
+
current_set."total" AS current_total,
|
344
|
+
prior_set."total" AS prior_total
|
345
|
+
FROM (
|
346
|
+
SELECT "patrons".*
|
347
|
+
FROM "patrons"
|
348
|
+
) patrons_set
|
349
|
+
INNER JOIN (
|
350
|
+
SELECT "lendings"."patron_id",
|
351
|
+
COUNT("lendings"."patron_id") AS total
|
352
|
+
FROM "lendings"
|
353
|
+
INNER JOIN (
|
354
|
+
SELECT "books"."id"
|
355
|
+
FROM "books"
|
356
|
+
INNER JOIN (
|
357
|
+
SELECT "libraries".*
|
358
|
+
FROM "libraries"
|
359
|
+
WHERE "libraries"."id" IN (1, 2)
|
360
|
+
) libraries_set
|
361
|
+
ON "books"."library_id" = libraries_set."id"
|
362
|
+
INNER JOIN (
|
363
|
+
SELECT "topics".*
|
364
|
+
FROM "topics"
|
365
|
+
WHERE "topics"."id" IN (1, 2, 3, 4)
|
366
|
+
) topics_set
|
367
|
+
ON "books"."topic_id" = topics_set."id"
|
368
|
+
) books_set
|
369
|
+
ON "lendings"."book_id" = books_set."id"
|
370
|
+
WHERE "lendings"."created_at" BETWEEN '2016-02-01' AND '2016-02-15'
|
371
|
+
GROUP BY "lendings"."patron_id"
|
372
|
+
) current_set
|
373
|
+
ON current_set."patron_id" = patrons_set."id"
|
374
|
+
LEFT OUTER JOIN (
|
375
|
+
SELECT "lendings"."patron_id",
|
376
|
+
COUNT("lendings"."patron_id") AS total
|
377
|
+
FROM "lendings"
|
378
|
+
INNER JOIN (
|
379
|
+
SELECT "books"."id"
|
380
|
+
FROM "books"
|
381
|
+
INNER JOIN (
|
382
|
+
SELECT "libraries".*
|
383
|
+
FROM "libraries"
|
384
|
+
WHERE "libraries"."id" IN (1, 2)
|
385
|
+
) libraries_set
|
386
|
+
ON "books"."library_id" = libraries_set."id"
|
387
|
+
INNER JOIN (
|
388
|
+
SELECT "topics".*
|
389
|
+
FROM "topics"
|
390
|
+
WHERE "topics"."id" IN (1, 2, 3, 4)
|
391
|
+
) topics_set
|
392
|
+
ON "books"."topic_id" = topics_set."id"
|
393
|
+
) books_set
|
394
|
+
ON "lendings"."book_id" = books_set."id"
|
395
|
+
WHERE "lendings"."created_at" BETWEEN '2016-01-01' AND '2016-01-15'
|
396
|
+
GROUP BY "lendings"."patron_id"
|
397
|
+
) prior_set
|
398
|
+
ON prior_set."patron_id" = patrons_set."id"
|
399
|
+
```
|
400
|
+
|
401
|
+
## License
|
402
|
+
|
403
|
+
`Query::Composer` is distributed under the MIT license. (See the LICENSE file for more information.)
|
404
|
+
|
405
|
+
|
406
|
+
## Author
|
407
|
+
|
408
|
+
`Query::Composer` is written and maintained by Jamis Buck <jamis@jamisbuck.org>. Many thanks to [T2 Modus](http://t2modus.com/) for permitting this code to be released as open source!
|
data/Rakefile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'rake/testtask'
|
2
|
+
require 'rubygems/tasks'
|
3
|
+
|
4
|
+
task default: :test
|
5
|
+
|
6
|
+
Rake::TestTask.new do |t|
|
7
|
+
t.libs << "test"
|
8
|
+
t.test_files = FileList['test/**/*_test.rb']
|
9
|
+
t.verbose = true
|
10
|
+
end
|
11
|
+
|
12
|
+
Gem::Tasks.new
|
13
|
+
|
14
|
+
task :clean do
|
15
|
+
FileUtils.rm_rf "pkg"
|
16
|
+
FileUtils.rm_f "test.log"
|
17
|
+
end
|