Class: Query::Composer

Inherits:
Object
  • Object
show all
Defined in:
lib/query/composer.rb,
lib/query/composer/version.rb

Overview

A class for composing queries into large, complicated reporting monstrosities that return data for trends, histograms, and all kinds of other things.

The idea is that you first create a composer object:

q = Query::Composer.new

Then, you tell the composer about a few queries:

q.use(:entities)  { User.all }
q.use(:companies) { Company.all }

These queries are independent, in that they have no dependencies. But we can add some queries now that depend on those. We declare another query, giving it one or more parameters. Those parameter names must match the identifiers of queries given to the composer. Here, we have a query that is dependent on the “entities” and “companies” queries, above.

q.use(:entities_with_extra) do |entities, companies|
  team_table = Arel::Table.new(:teams)

  Arel::SelectManager.new(ActiveRecord::Base).
    from(entities).
    project(
      entities[Arel.star],
      team_table[:name].as('team_name'),
      companies[:name].as('company_name')).
    join(team_table).
      on(team_table[:id].eq(entities[:team_id])).
    join(companies).
      on(companies[:id].eq(entities[:company_id]))
end

After you’ve defined a bunch of these queries, you should have one of them (and ONLY one of them) that nothing else depends on. This is the “root” query–the one that returns the data set you’re looking for. The composer can now do its job and accumulate and aggregate all those queries together, by calling the #build method with the identifier for the root query you want to build.

query = q.build(:some_query_identifier)

By default, this will create a query with each component represented as derived tables (nested subqueries):

SELECT "a".*,
       "b"."name" AS "company_name",
       "c"."name" AS "team_name"
  FROM (
    SELECT "users".* FROM "users"
  ) a
  INNER JOIN (
    SELECT "companies".* FROM "companies"
  ) b
  ON "b"."id" = "a"."company_id"
  INNER JOIN (
    SELECT "teams".* FROM "teams"
  ) c
  ON "c"."id" = "a"."team_id"
  WHERE ...

If you would rather use CTEs (Common Table Expressions, or “with” queries), you can pass “:use_cte => true” to generate the following:

WITH
  "a" AS (SELECT "users".* FROM "users"),
  "b" AS (SELECT "companies".* FROM "companies"),
  "c" AS (
    SELECT "a".*,
           "teams"."name" as "team_name",
           "b"."name" as "company_name"
      FROM "a"
     INNER JOIN "teams"
        ON "teams"."id" = "a"."team_id"
     INNER JOIN "b"
        ON "b".id = "a"."company_id")
  ...
SELECT ...
  FROM ...

Be aware, though, that some DBMS’s (like Postgres) do not optimize CTE’s, and so the resulting queries may be very inefficient.

If you don’t want the short, opaque identifiers to be used as aliases, you can pass “:use_aliases => false” to #build:

query = q.build(:entities_with_extra, :use_aliases => false)

That way, the query identifiers themselves will be used as the query aliases.

Defined Under Namespace

Modules: Version Classes: CircularDependency, Error, InvalidQuery, UnknownQuery

Constant Summary collapse

@@prefer_cte =
false
@@prefer_aliases =
true

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize {|_self| ... } ⇒ Composer

Create an empty query object. If a block is given, the query object will be yielded to it.

Yields:

  • (_self)

Yield Parameters:



133
134
135
136
# File 'lib/query/composer.rb', line 133

def initialize
  @parts = {}
  yield self if block_given?
end

Class Method Details

.prefer_aliases=(preference) ⇒ Object

By default, the composer generates queries that use shortened names as aliases for the full names of the components. If you’d rather use the full names instead of aliases,



126
127
128
# File 'lib/query/composer.rb', line 126

def prefer_aliases=(preference)
  @@prefer_aliases = preference
end

.prefer_aliases?Boolean

Returns:

  • (Boolean)


119
120
121
# File 'lib/query/composer.rb', line 119

def prefer_aliases?
  @@prefer_aliases
end

.prefer_cte=(preference) ⇒ Object

By default, the composer generates queries that use derived tables. If you’d rather default to CTE’s, set Query::Composer.prefer_cte to true.



115
116
117
# File 'lib/query/composer.rb', line 115

def prefer_cte=(preference)
  @@prefer_cte = preference
end

.prefer_cte?Boolean

Returns:

  • (Boolean)


108
109
110
# File 'lib/query/composer.rb', line 108

def prefer_cte?
  @@prefer_cte
end

Instance Method Details

#_alias_queries(deps, options = {}) ⇒ Object

Build a mapping of dependency names, to Arel::Table objects. The Arel::Table names will use opaque, short identifiers (“a”, “b”, etc.), unless the :use_aliases option is false, when the dependency names themselves will be used.



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/query/composer.rb', line 297

def _alias_queries(deps, options={})
  use_aliases = _use_aliases?(options)

  aliases = {}
  current_alias = "a"

  deps.each do |key|
    if use_aliases
      aliases[key] = Arel::Table.new(current_alias)
      current_alias = current_alias.succ
    else
      aliases[key] = Arel::Table.new(key)
    end
  end

  aliases
end

#_invoke(name, aliases) ⇒ Object

Invokes the named dependency, using the given aliases mapping.



223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/query/composer.rb', line 223

def _invoke(name, aliases)
  block = @parts[name]
  params = block.parameters.map { |(_, name)| aliases[name] }
  result = block.call(*params)

  if result.respond_to?(:arel)
    result.arel
  elsif result.respond_to?(:to_sql)
    result
  else
    raise InvalidQuery, "query elements must quack like #arel or #to_sql (`#{name}` returned #{result.class})"
  end
end

#_query_with_cte(root, deps, aliases) ⇒ Object

Builds an Arel object using common table expressions.



208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/query/composer.rb', line 208

def _query_with_cte(root, deps, aliases)
  query = _invoke(root, aliases)
  components = []

  deps.each do |name|
    component = _invoke(name, aliases)
    aliased = Arel::Nodes::As.new(aliases[name], component)
    components << aliased
  end

  query.with(*components) if components.any?
  query
end

#_query_with_derived_table(root, deps, aliases) ⇒ Object

Builds an Arel object using derived tables.



197
198
199
200
201
202
203
204
205
# File 'lib/query/composer.rb', line 197

def _query_with_derived_table(root, deps, aliases)
  queries = {}

  deps.each do |name|
    queries[name] = _invoke(name, queries).as(aliases[name].name)
  end

  _invoke(root, queries)
end

#_resolve(root) ⇒ Object

Resolves the tree of dependent components by traversing the graph starting at ‘root`. Returns an array of identifiers where elements later in the list depend on zero or more elements earlier in the list. The resulting list includes only the dependencies of the `root` element, but not the `root` element itself.



259
260
261
# File 'lib/query/composer.rb', line 259

def _resolve(root)
  _resolve2(root).flatten.uniq - [root]
end

#_resolve2(root, dependents = []) ⇒ Object

This is a utility function, used only by #_resolve. It recursively tranverses the tree, depth-first, and returns a “tree” (array of recursively nested arrays) representing the graph at root. The root of each subtree is at the end of the corresponding array.

[ [ [:a], [:b], :c ], [ [:d], [:e], :f ], :root ]


269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/query/composer.rb', line 269

def _resolve2(root, dependents=[])
  deps = _validate_dependencies!(root)
  return [ root ] if deps.empty?

  # Circular dependency exists if anything in the dependents
  # (that which depends on root) exists in root's own dependency
  # list

  dependents = [ root, *dependents ]
  overlap = deps & dependents
  if overlap.any?
    raise CircularDependency, "#{root} -> #{overlap.join(', ')}"
  end

  all = []

  deps.each do |dep|
    all << _resolve2(dep, dependents)
  end


  all << root
end

#_use_aliases?(options) ⇒ Boolean

Returns:

  • (Boolean)


192
193
194
# File 'lib/query/composer.rb', line 192

def _use_aliases?(options)
  options.fetch(:use_aliases, self.class.prefer_aliases?)
end

#_use_cte?(options) ⇒ Boolean

Returns:

  • (Boolean)


188
189
190
# File 'lib/query/composer.rb', line 188

def _use_cte?(options)
  options.fetch(:use_cte, self.class.prefer_cte?)
end

#_validate_dependencies!(name) ⇒ Object

Ensure that all referenced dependencies exist in the graph. Otherwise, raise Query::Composer::UnknownQuery.

Raises:



239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/query/composer.rb', line 239

def _validate_dependencies!(name)
  raise UnknownQuery, "`#{name}`" unless @parts.key?(name)
  dependencies = []

  @parts[name].parameters.each do |(_, pname)|
    unless @parts.key?(pname)
      raise UnknownQuery, "`#{pname}` referenced by `#{name}`"
    end

    dependencies << pname
  end

  dependencies
end

#alias(new_name, name) ⇒ Object

Aliases the given query component with the new name. This can be useful for redefining an existing component, where you still want to retain the old definition.

composer.use(:source) { Something.all }
composer.alias(:old_source, :source)
composer.use(:source) { |old_source| ... }


158
159
160
161
# File 'lib/query/composer.rb', line 158

def alias(new_name, name)
  @parts[new_name] = @parts[name]
  self
end

#build(root, options = {}) ⇒ Object

Return an Arel object representing the query starting at the component named ‘root`. Supported options are:

  • :use_cte (false) - the query should use common table expressions. If false, the query will use derived tables, instead.

  • :use_aliases (true) - the query will use short, opaque identifiers for aliases. If false, the query will use the full dependency names to identify the elements.



177
178
179
180
181
182
183
184
185
186
# File 'lib/query/composer.rb', line 177

def build(root, options={})
  deps = _resolve(root)
  aliases = _alias_queries(deps, options)

  if _use_cte?(options)
    _query_with_cte(root, deps, aliases)
  else
    _query_with_derived_table(root, deps, aliases)
  end
end

#delete(name) ⇒ Object

Removes the named component from the composer.



164
165
166
167
# File 'lib/query/composer.rb', line 164

def delete(name)
  @parts.delete(name)
  self
end

#use(name, &definition) ⇒ Object

Indicate that the named identifier should be defined by the given block. The names used for the parameters of the block are significant, and must exactly match the identifiers of other elements in the query.

The block should return an Arel object, for use in composing the larger reporting query. If the return value of the block responds to :arel, the result of that method will be returned instead.



146
147
148
149
# File 'lib/query/composer.rb', line 146

def use(name, &definition)
  @parts[name] = definition
  self
end