query-composer 1.0.0

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