• Stars
    star
    166
  • Rank 227,748 (Top 5 %)
  • Language
    Ruby
  • License
    MIT License
  • Created over 8 years ago
  • Updated about 7 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

A library for composing complex SQL queries by defining their subcomponents and the dependencies between them.

Query::Composer

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.

Query::Composer was extracted from a real application, where reporting queries were dynamically generated and typically exceeded 50KB of text for the query alone!

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.

Features

  • Define your queries in terms of components, each of which is more easily tested and debugged
  • A dependency-resolution system for determining the proper ordering of query subcomponents within a complex query
  • A simple class (Query::Base) for more conveniently defining queries using Arel
  • The ability to generate the same query using either derived tables (nested subqueries), or CTEs (Common Table Expressions)

Usage

First, instantiate a composer object:

require 'query/composer'

composer = Query::Composer.new

Then, declare the components of your query with the #use method:

composer.use(:patrons) { Patron.all }

Declare dependent components by providing parameters to the block that are named the same as the components that should be depended on:

# `patrons` must exist as another component in the composer...
composer.use(:books) { |patrons| ... }

Component definitions must return an object that responds to either #arel, or #to_sql:

# ActiveRecord scopes respond to #arel
composer.use(:patrons) { Patron.all }

require 'query/base'

# Arel objects and Query::Base (a thin wrapper around
# Arel::SelectManager) respond to #to_sql
composer.use(:books_by_patron) do |patrons|
  books = Book.arel_table
  lendings = Lending.arel_table

  Query::Base.new(books).
    project(patrons[:first_name], books[:name]).
    join(lendings).
      on(lendings[:book_id].eq(books[:id])).
    join(patrons).
      on(patrons[:id].eq(lendings[:patron_id]))
end

Generate the query by calling #build on the composer, and telling it which component will be the root of the query:

# Builds the query using the books_by_patron component as the root.
query = composer.build(:books_by_patron)
# SELECT "patrons"."first_name", "books"."name"
# FROM "books"
# INNER JOIN "lendings"
# ON "lendings"."book_id" = "books"."id"
# INNER JOIN (
#   SELECT "patrons".* FROM "patrons"
# ) "patrons"
# ON "patrons"."id" = "lendings"."patron_id"

# Builds the query using the patrons component as the root
query = composer.build(:patrons)
# SELECT "patrons".* FROM "patrons"

Run the query by converting it to SQL and executing it:

sql = query.to_sql

# using raw ActiveRecord connection
rows = ActiveRecord::Base.connection.execute(sql)

# using ActiveRecord models
rows = Book.find_by_sql(sql)

Example

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:

  • All patrons from a specified set of libraries,
  • Who have checked out books this month,
  • From a specified set of topics,
  • And compare that with the same period of the previous month.

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.

First, instantiate a composer object:

require 'query/composer'
require 'query/base'

composer = Query::Composer.new

We'll assume we have some object that describes the parameters for the query, as given by the user:

today = Date.today

config.current_period_from = today.beginning_of_month
config.current_period_to   = today
config.prior_period_from   = today.last_month.beginning_of_month
config.prior_period_to     = today.last_month

config.library_ids         = [ ... ]
config.topic_ids           = [ ... ]

Then, we tell the composer about the components of our query:

# The set of libraries specified by the user
composer.use(:libraries_set) { Library.where(id: config.library_ids) }

# The set of topics specified by the user
composer.use(:topics_set) { Topic.where(id: config.topic_ids) }

# The set of patrons to consider (all of them, here)
composer.use(:patrons_set) { Patron.all }

# The set of books to consider (all those from the given libraries
# with the given topics)
composer.use(:books_set) do |libraries_set, topics_set|
  books = Book.arel_table

  Query::Base.new(books).
    project(books[:id]).
    join(libraries_set).
      on(books[:library_id].eq(libraries_set[:id])).
    join(topics_set).
      on(books[:topic_id].eq(topics_set[:id]))
end

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.

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:

# books_set -- the set of books to be considered
# from_date -- the beginning of the period to consider
# to_date -- the end of the period to consider
def lendings_set(books_set, from_date, to_date)
  lendings = Lending.arel_table

  patron_id = lendings[:patron_id]
  count = patron_id.count.as("total")

  Query::Base.new(lendings).
    project(patron_id, count).
    join(books_set).
      on(lendings[:book_id].eq(books_set[:id])).
    where(lendings[:created_at].between(from_date..to_date)).
    group(patron_id)
end

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.

With that, we can now finish defining our query components:

# Books in the "current" set
composer.use(:current_set) do |books_set|
  lendings_set(books_set,
    config.current_period_from,
    config.current_period_to)
end

composer.use(:prior_set) do |books_set|
  lendings_set(books_set,
    config.prior_period_from,
    config.prior_period_to)
end

# Joins the current_set and prior_set to the patrons_set
composer.use(:combined_set) do |patrons_set, current_set, prior_set|
  Query::Base.new(patrons_set).
    project(patrons_set[Arel.star],
            current_set[:total].as("current_total"),
            prior_set[:total].as("prior_total")).
    join(current_set).
      on(current_set[:patron_id].eq(patrons_set[:id])).
    join(prior_set, Arel::Nodes::OuterJoin).
      on(prior_set[:patron_id].eq(patrons_set[:id]))
end

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:

sql = composer.build(:combined_set).to_sql

Patron.find_by_sql(sql).each do |patron|
  puts "#{patron.name} :: #{patron.current_total} :: #{patron.prior_total}"
end

The generated query, assuming a current month of Feb 2016, might look like this (formatted for readability):

SELECT a.*,
       e."total" AS current_total,
       f."total" AS prior_total
FROM (
  SELECT "patrons".*
  FROM "patrons"
) a
INNER JOIN (
  SELECT "lendings"."patron_id",
         COUNT("lendings"."patron_id") AS total
  FROM "lendings"
  INNER JOIN (
    SELECT "books"."id"
    FROM "books"
    INNER JOIN (
      SELECT "libraries".*
      FROM "libraries"
      WHERE "libraries"."id" IN (1, 2)
    ) b
    ON "books"."library_id" = b."id"
    INNER JOIN (
      SELECT "topics".*
      FROM "topics"
      WHERE "topics"."id" IN (1, 2, 3, 4)
    ) c
    ON "books"."topic_id" = c."id"
  ) d
  ON "lendings"."book_id" = d."id"
  WHERE "lendings"."created_at" BETWEEN '2016-02-01' AND '2016-02-15'
  GROUP BY "lendings"."patron_id"
) e
ON e."patron_id" = a."id"
LEFT OUTER JOIN (
  SELECT "lendings"."patron_id",
         COUNT("lendings"."patron_id") AS total
  FROM "lendings"
  INNER JOIN (
    SELECT "books"."id"
    FROM "books"
    INNER JOIN (
      SELECT "libraries".*
      FROM "libraries"
      WHERE "libraries"."id" IN (1, 2)
    ) b
    ON "books"."library_id" = b."id"
    INNER JOIN (
      SELECT "topics".*
      FROM "topics"
      WHERE "topics"."id" IN (1, 2, 3, 4)
    ) c
    ON "books"."topic_id" = c."id"
  ) d
  ON "lendings"."book_id" = d."id"
  WHERE "lendings"."created_at" BETWEEN '2016-01-01' AND '2016-01-15'
  GROUP BY "lendings"."patron_id"
) f
ON f."patron_id" = a."id"

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!)

sql = composer.build(:combined_set, use_cte: true)

The CTE query looks like this:

WITH
  "a" AS (
    SELECT "patrons".* FROM "patrons"),
  "b" AS (
    SELECT "libraries".*
    FROM "libraries"
    WHERE "libraries"."id" IN (1, 2)),
  "c" AS (
    SELECT "topics".*
    FROM "topics"
    WHERE "topics"."id" IN (1, 2, 3, 4)),
  "d" AS (
    SELECT "books"."id"
    FROM "books"
    INNER JOIN "b"
      ON "books"."library_id" = "b"."id"
    INNER JOIN "c"
      ON "books"."topic_id" = "c"."id"),
  "e" AS (
    SELECT "lendings"."patron_id",
           COUNT("lendings"."patron_id") AS total
    FROM "lendings"
    INNER JOIN "d"
      ON "lendings"."book_id" = "d"."id"
    WHERE "lendings"."created_at" BETWEEN '2016-02-01' AND '2016-02-15'
    GROUP BY "lendings"."patron_id"),
  "f" AS (
    SELECT "lendings"."patron_id",
           COUNT("lendings"."patron_id") AS total
    FROM "lendings"
    INNER JOIN "d" ON "lendings"."book_id" = "d"."id"
    WHERE "lendings"."created_at" BETWEEN '2016-01-01' AND '2016-01-15'
    GROUP BY "lendings"."patron_id")
SELECT "a".*,
       "e"."total" AS current_total,
       "f"."total" AS prior_total
FROM "a"
INNER JOIN "e"
ON "e"."patron_id" = "a"."id"
LEFT OUTER JOIN "f"
ON "f"."patron_id" = "a"."id"

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.

sql = composer.build(:combined_set, use_aliases: false)

The resulting query:

SELECT patrons_set.*,
       current_set."total" AS current_total,
       prior_set."total" AS prior_total
FROM (
  SELECT "patrons".*
  FROM "patrons"
) patrons_set
INNER JOIN (
  SELECT "lendings"."patron_id",
         COUNT("lendings"."patron_id") AS total
  FROM "lendings"
  INNER JOIN (
    SELECT "books"."id"
    FROM "books"
    INNER JOIN (
      SELECT "libraries".*
      FROM "libraries"
      WHERE "libraries"."id" IN (1, 2)
    ) libraries_set
    ON "books"."library_id" = libraries_set."id"
    INNER JOIN (
      SELECT "topics".*
      FROM "topics"
      WHERE "topics"."id" IN (1, 2, 3, 4)
    ) topics_set
    ON "books"."topic_id" = topics_set."id"
  ) books_set
  ON "lendings"."book_id" = books_set."id"
  WHERE "lendings"."created_at" BETWEEN '2016-02-01' AND '2016-02-15'
  GROUP BY "lendings"."patron_id"
) current_set
ON current_set."patron_id" = patrons_set."id"
LEFT OUTER JOIN (
  SELECT "lendings"."patron_id",
         COUNT("lendings"."patron_id") AS total
  FROM "lendings"
  INNER JOIN (
    SELECT "books"."id"
    FROM "books"
    INNER JOIN (
      SELECT "libraries".*
      FROM "libraries"
      WHERE "libraries"."id" IN (1, 2)
    ) libraries_set
    ON "books"."library_id" = libraries_set."id"
    INNER JOIN (
      SELECT "topics".*
      FROM "topics"
      WHERE "topics"."id" IN (1, 2, 3, 4)
    ) topics_set
    ON "books"."topic_id" = topics_set."id"
  ) books_set
  ON "lendings"."book_id" = books_set."id"
  WHERE "lendings"."created_at" BETWEEN '2016-01-01' AND '2016-01-15'
  GROUP BY "lendings"."patron_id"
) prior_set
ON prior_set."patron_id" = patrons_set."id"

License

Query::Composer is distributed under the MIT license. (See the LICENSE file for more information.)

Author

Query::Composer is written and maintained by Jamis Buck [email protected]. Many thanks to T2 Modus for permitting this code to be released as open source!

More Repositories

1

bulk_insert

Efficient bulk inserts with ActiveRecord
Ruby
818
star
2

bucketwise

ATTENTION: This project is no longer being updated. If you're still interested, feel free to read on... "A web-based personal finance manager with a focus on non-OCD budgeting and avoiding credit card debt"
Ruby
457
star
3

csmazes

Maze algorithms implemented in CoffeeScript, with an eye toward demonstrating how the algorithms work by animating them.
CoffeeScript
391
star
4

castaway

System for building screencasts and video presentations
Ruby
306
star
5

fuzzyfinder_textmate

A vim script that extends the fuzzyfinder plugin to support TextMate style file searches (e.g. cmd-T) (Unmaintained now, see http://weblog.jamisbuck.org/2009/1/28/the-future-of-fuzzyfinder-textmate)
Vim Script
216
star
6

theseus

A very flexible random maze generator, solver, and renderer for Ruby
Ruby
178
star
7

fuzzy_file_finder

A (slightly enhanced) implementation of TextMate's cmd-T lookup functionality, in Ruby, for embedding in other projects
Ruby
143
star
8

wordsearch

A word-search puzzle generator
Ruby
76
star
9

safe_mass_assignment

ActiveRecord plugin for allowing (careful) mass assignment of protected attributes, separate from values provided via users of your application.
Ruby
55
star
10

net-ssh-multi

SSH connection multiplexing: execute commands simultaneously on multiple hosts via SSH
Ruby
44
star
11

sqlpp

A simplistic SQL parser and pretty-printer
Ruby
42
star
12

impose

A utility and library for imposition -- arranging pages on a sheet of paper for optimal printing
Ruby
37
star
13

dnd-dungeon

A random maze generator in C, with a CGI front-end for generating random dungeons for D&D, 3rd edition
35
star
14

kaleidoscope

Generate uniform tilings (tesselations) of a plane using Wythoff constructions. Not as hard (or scary) as it sounds!
Ruby
32
star
15

net-ssh-shell

NOTE: this repository is no longer actively maintained. Please go to the actively maintained repository, here: https://github.com/mitchellh/net-ssh-shell. Net::SSH::Shell is a net-ssh extension library that provides an API for programmatically interacting with a login shell
Ruby
26
star
16

ifrb

Interactive Fiction for Interactive Ruby
Ruby
23
star
17

mod_reproxy

A module for Apache 2 that implements support for the X-Reproxy-Url response header
C
23
star
18

net-ssh-gateway

THIS REPOSITORY IS NO LONGER MAINTAINED. Please see https://github.com/net-ssh/net-ssh-gateway for the currently maintained version. Thanks! -- A gateway class for tunneling connections via SSH over a forwarded port.
Ruby
23
star
19

amazing-desktops

A simple utility for generating random abstract images (using mazes) for use as desktop wallpaper.
C
21
star
20

MazeMaker

An implementation of grid layouts and maze algorithms, in Swift
Swift
20
star
21

logic-engine

Prolog-inspired logic engine in Ruby, with backtracking
16
star
22

rtc-ocaml

"The Ray Tracer Challenge" (http://www.raytracerchallenge.com) implemented in OCaml
OCaml
15
star
23

code_slide

Generate PDF/PNG slides from source code
Ruby
12
star
24

curves

A library for interpolating various curves (bezier, cubic hermite, etc.)
Ruby
12
star
25

mazoo

An HTML5 game to test your maze navigation skills!
JavaScript
11
star
26

zing

Framework for playful maze generation (from "Twisty Little Passages" presentation at MWRC 2015)
Ruby
11
star
27

process_roulette

A silly little game that could mess up your machine pretty badly (please use a VM!)
Ruby
9
star
28

chaussettes

A thin wrapper around the sox audio manipulation utility
Ruby
9
star
29

dnd-npc

A random NPC generator for D&D 3rd edition, written in C. Includes CGI and console interfaces.
C
8
star
30

truth

A utility for displaying a truth table for an expression
Ruby
8
star
31

celtic_knot

A library for generating Celtic Knotwork designs from graphs
Ruby
8
star
32

lindy

An L-system parser and interpreter
Ruby
7
star
33

dnd-util

An encapsulation (in C) of the core logic and data of D&D, 3rd edition.
6
star
34

KSP-RealSolarSystem-Bundler

Program for bundling all needed dependencies for the "Real Solar System" mod, for Kerbal Space Program.
Ruby
6
star
35

ekawada-web

A rails application for recording, comparing, and researching string figures
Ruby
6
star
36

weekly-challenges

My submissions for the weekly programming challenges (https://medium.com/@jamis/weekly-programming-challenge-1-55b63b9d2a1)
Ruby
6
star
37

scruffy-labrador

A flexible JavaScript implementation of a grid/graph, and some maze generation algorithms
JavaScript
6
star
38

artifex

A D&D4e NPC generator
CoffeeScript
5
star
39

jamis.github.io

Basil & Fabian - A Wizard & His Man
CoffeeScript
5
star
40

hercule

A logic puzzle for PalmOS (historical interest only, mostly, unless you have a really old device)
C
5
star
41

dnd-templates

A templating system written in C. (Deprecated, obsolete, etc!)
4
star
42

sqlite-ruby

bindings for the SQLite 2.x embedded database
Ruby
4
star
43

korean-proverbs

Translations of Korean proverbs
4
star
44

runeo

An ActiveRecord-inspired wrapper for the Neo4j REST API
Ruby
4
star
45

dnd-writetem

A templating system written in C, with a stream wrapper system. (Deprecated, obsolete, etc!)
3
star
46

hangul-tools

Romanize Korean text
Ruby
3
star
47

piece-by-piece

An evidence-oriented genealogical research database, inspired by the GENTECH data model
CoffeeScript
3
star
48

tinker

CYOA-style game system inspired by Sarah Allen's "pie" project.
Ruby
3
star
49

strings2go

A string figure collection for the iPhone
Ruby
2
star
50

kincaid

A DSL for creating dungeon maps for tabletop RPG's
Ruby
2
star
51

test_session_manager

Allow tests for Rails applications to inject session data (including flash) into test requests
Ruby
1
star
52

buckblog

The Buckblog -- assorted ramblings by Jamis Buck -- http://weblog.jamisbuck.org
HTML
1
star
53

derring-do

The over-eager progress monitor for Ruby.
Ruby
1
star
54

taleswapper

A home for creating and sharing stories, with an emphasis on role-playing games
Ruby
1
star