/ruby-qx

SQL expression builder for Ruby

Primary LanguageRuby

Further maintenance of this library will happen in Houdini

This library is deprecated. You can use Arel instead

Qx

A ruby SQL expression builder (and executor) focused on Postgresql. It allows you to directly and safely write efficient SQL expressions in Ruby, using data from Ruby-land. These expressions can be passed around, reused, and modified. This lib is for those of us who want to use SQL directly within ruby, and do not want an ORM.

This library uses ActiveRecord for executing SQL, taking advantage of its connection pooling features. If you'd like to see support for Sequel, etc, please make a PR.

This implements a subset of the SQL language that we find most useful so far. Add new SQL clauses with a PR if you'd like to see more in here.

Example

# A fairly complex query with subqueries inside some joins:
# Create a select query on the payments table
payments_subquery = Qx.select( "supporter_id", "SUM(gross_amount)", "MAX(date) AS max_date", "MIN(date) AS min_date", "COUNT(*) AS count")
  .from(:payments)
  .group_by(:supporter_id)
  .as(:payments)

# Another subquery
tags_subquery = Qx.select("tag_joins.supporter_id", "ARRAY_AGG(tag_masters.id) AS ids", "ARRAY_AGG(tag_masters.name::text) AS names")
  .from(:tag_joins)
  .join(:tag_masters, "tag_masters.id=tag_joins.tag_master_id")
  .group_by("tag_joins.supporter_id")
  .as(:tags)

# Combine the above subqueries into a select on the supporters table
expr = Qx.select('supporters.id').from(:supporters)
  .left_join(tags_subquery, "tags.supporter_id=supporters.id")
  .add_left_join(payments_subquery, "payments.supporter_id=supporters.id")
  .where("supporters.nonprofit_id=$id", id: np_id.to_i)
  .and_where("coalesce(supporters.deleted, FALSE) = FALSE")
  .order_by('payments.max_date DESC NULLS LAST')
  .execute(format: 'csv')
  
# Easy bulk insert
Qx.insert_into(:table_name).values([{x: 1}, {x: 2}, {x: 3}]).execute
# Qx also supports insert from selects, like `INSERT INTO x (y) SELECT y FROM x`
Qx.insert_into(:table_name, ['x']).select("x").from("table2").execute

Qx.config(options)

Qx.config takes a hash of options. For now, there is only one option: :type_map, which allows you to specify a PG typemap

Qx.config(type_map: PG::BasicTypeMapForResults.new(ActiveRecord::Base.connection.raw_connection))

.config is best called in an initializer when your app is starting up. Be sure to initialize ActiveRecord before using Qx. In Rails, you don't need to do anything extra for that.

API

Please refer to this test file to see all the SQL constructor methods: /test/qx_test.rb

For each test, see the assert_equal line to find the resulting SQL expression. Above that, in the parsed = ... line, you can see how the expression was created using Qx.

expression.execute

When you have an expression, call .execute to actually execute it.

Qx.select("id", "name").from(:users).where(email: "user@example.com").execute

.ex is a shortcut alias

Qx.execute_file(path, interpolation_data, options)

This function will open a sql file and execute its contents. You can interpolate data into the file with $variable_name.

Given a sql file like:

SELECT * FROM users WHERE account_id=$account_id LIMIT 1

You can execute the file with:

Qx.execute_file('./get_user.sql', account_id: 123)
# -> [{email: 'uzr@example.com', ...}]

.parse

Instead of executing the expression, you can parse the expression into a string:

Qx.select("id", "name").from(:users).where(email: "user@example.com").parse
# -> "SELECT id, name FROM users WHERE email = 'user@example.com'"

Note you can also use .pp to pretty-print the expression for debugging (for now it just colorizes, but in the near future it will indent the expression for you)

shortcut / helper functions

Some convenience functions are provided that compose SQL expressions for you.

Qx.fetch(table_name, ids_or_data)

This is a quick way to fetch some full rows of data by id or another column. You can either pass in an array of ids, a single id, or a hash that matches on columns.

Qx.fetch(:table_name, [12, 34, 56])
# SELECT * FROM table_name WHERE ids IN (12, 34, 56)

donation = Qx.fetch(:donations, 23)
# SELECT * FROM donations WHERE ID IN (23)
donor = Qx.fetch(:donors, donation['supporter_id'])
# SELECT * FROM donors WHERE ID IN (33)

# Select by a different column besides "id" -- in this case, we use "status"
donation = Qx.fetch(:donations, {status: 'active'})
# SELECT * FROM donations WHERE status IN ('active')

expr.common_values(hash)

If you're bulk inserting but want some common values in all your rows, you don't have to have that common data in every single row. Instead, you can use .common_values:

expr = Qx.insert_into(:table_name)
  .values([{x: 1}, {x: 2}])
  .common_values({y: 'common'})
# INSERT INTO "table_name" ("x", "y")
# VALUES (1, 'common'), (2, 'common')

expr.timestamps

Inside an INSERT INTO expression, if you add the .timestamps method, it will add both created_at and updated_at columns, set to the current utc time.

Qx.insert_into(:table_name).values(x: 1).timestamps.execute
# INSERT INTO table_name (x, created_at, updated_at) VALUES (1, '2020-01-01 00:00:00 UTC', '2020-01-01 00:00:00 UTC')
# (pretending that the above dates is Time.now)

Inside an UPDATE expression, .timestamps will set the updated_at column to the current time for you:

Qx.update(:table_name).set(x: 1).timestamps.where(id: 1).execute
# UPDATE table_name SET x=1, updated_at='2020-01-01 00:00:00 UTC' WHERE id=1

.ts is a shortcut alias for this method

expr.pp (pretty-printing)

This gives a nicely formatted and colorized output of your expression for debugging.

Qx.select(:id)
  .from(:table)
  .where("id IN ($ids)", ids: [1,2,3,4]) 
  .pp

For now it just adds some syntax highlighting; in the near future it will also add indentation.

expr.paginate(current_page, page_length)

This is a convenience method for more easily paginating a SELECT query using the combination of OFFSET and LIMIT

Simply pass in the page length and the current page to get the paginated results.

Qx.select(:id).from(:table).paginate(2, 30)
# SELECT id FROM table OFFSET 30 LIMIT 30

JSON helpers

A helper method, called .to_json(alias) allows you to wrap your results in a json blob. Under the hood it composes the postgres functions array_to_json(array_agg(row_to_json(t))) to conveniently convert a collection of sql data into a single json blob.

Notice in this example that there is one result with one key called "data", mapped to a single JSON string:

Qx.select(:id, :email).from(:users).to_json(:data).execute
# SELECT row_to_json(data) AS data FROM (SELECT id, email FROM users) data
# returns [
#   { "data" => "[{\"id\": 1, \"email\": \"bob@example.com\"}, {\"id\": 1, \"email\": \"bob@example.com\"}]" }
# ]

In doing highly nested json, you can call .to_json(alias) within different subqueries. This example borrows from a JSON API (jsonapi.org) example:

definitions = Qx.select(:part_of_speech, :body)
  .from("definitions")
  .where("word_id=words.id")
  .order_by("position ASC")
  .to_json("ds")

expr = Qx.select(:text, :pronunciation, definitions.as("definitions"))
  .from("words")
  .where("text='autumn'")
  .to_json("data")

The above nested expression parses into the following sql:

SELECT array_to_json(array_agg(row_to_json(data))) AS data
FROM (
  SELECT 
    text
  , pronunciation
  , (
      SELECT array_to_json(array_agg(row_to_json(d)))
      FROM (
        SELECT part_of_speech, body
        FROM definitions
        WHERE word_id=words.id
        ORDER BY position asc
      ) d
    ) AS definitions
  FROM words
  WHERE text = 'autumn'
) data

And, when executed, will give the following json string:

{
  "text": "autumn",
  "pronunciation": "autumn",
  "definitions": [
    {
        "part_of_speech": "noun",
        "body": "skilder wearifully uninfolded..."
    },
    {
        "part_of_speech": "verb",
        "body": "intrafissural fernbird kittly..."
    },
    {
        "part_of_speech": "adverb",
        "body": "infrugal lansquenet impolarizable..."
    }
  ]
}

Performance Optimization Tools

Since this lib is built with Postgresql, it takes advantage of its performance optimization tools such as EXPLAIN, ANALYZE, and statistics queries.

expr.explain

For performance optimization, you can use an EXPLAIN command on a Qx select expression object (http://www.postgresql.org/docs/9.5/static/using-explain.html).

Qx.select(:id)
  .from(:table)
  .where("id IN ($ids)", ids: [1,2,3,4]) 
  .explain
  .execute

Development and testing

Testing

Set up Postgres on your machine and create a database called qx_test. Grant all privileges to a user called admin with password password

Run tests with ruby test/qx_test.rb