porta

Utilities for Phoenix and Ecto


License
MPL-2.0

Documentation

Porta

Porta is an extension of Ecto (and, optionally, Phoenix) which provides an alternative, more SQL-oriented way of doing database stuff. Its most notable feature is Composable SQL. An example:

priv/queries/banned_users_by_occupation.sql

select u.* from users u
where u.banned = true and u.occupation = _occupation_
order by u.age

priv/queries/users_bought_product_after.sql

select u.id from users u
order by u.name
left join orders o on u.id = o.user_id
where o.inserted_at > _after_
join products p on p.id = o.product_id
where p.name = _product_

lib/app/repo.ex

defmodule App.Repo do
  use Ecto.Repo
  use Porta.Repo
end

banned_cheerleaders_who_bought_salmon_after_article_50_was_invoked =
  App.Repo.run! [
    banned_users_by_occupation: %{occupation: "cheerleader"},
    users_bought_product_after: %{product: "salmon", after: ~N"2017-03-29 12:30:00"}
  ]

This merges the two queries and runs something equivalent to:

select u.* from users u
left join orders o on u.id = o.user_id
join products p on p.id = o.product_id
where u.banned = true and u.occupation = $1
  and o.inserted_at > $3 and p.name = $2
order by u.age, u.name

With the positional arguments ["cheerleader", "salmon", ~N"2017-03-29 12:30:00"].

Caveats

The merger above is less intelligent than it seems.

It first splits each query wherever a line starts with a clause keyword such as left join, where and order by. Because of this, the newlines in the examples above are required. After that, it replaces all named parameters with positional parameters (or subqueries, or raw SQL). Then it groups and sorts all clauses on their type, maintaining the order of clauses of the same type. Finally, clauses of the same type are merged together if necessary.

While this approach supports all SQL dialects and allows for more expressive queries than most typical ORMs, there are a few problems:

  • Each table has to be aliased the same way across all merged queries. I think this can always be achieved through subquerying, but I may be wrong.
  • Some clause types do not have a simple, natural merge strategy. In the case of select clauses, this means we currently discard everything but the first one.

Subqueries

Whenever a keyword list is used in place of an argument, it is assumed to be a subquery:

users_who_bought_user_9s_favorite_product_after_carnaval_in_the_year_1979 =
  Repo.run! users_bought_product_after: %{
    product: [favorite_product: %{user_id: 9}],
    after: [carnaval: %{year: 1979}]
  }

priv/queries/favorite_product.sql

select p.name from products p
join orders o on p.id = o.product_id
join users u on u.id = o.user_id
where u.id = _user_id_
group by p.id
order by count(o.*)
limit 1

priv/queries/carnaval.sql

select (_year_::int || '-11-11)::date

Raw SQL

(don't put user input in raw parameters or you will get hacked and robbed and lynched)

To insert raw SQL in the place of a named parameter, use a {:raw, param} tuple. This can be used to dynamically specify columns and table names:

priv/queries/select_user_column.sql

select _column_ from users
Repo.run! select_user_column: %{
  column: {:raw, "name"}
}

Ad-Hoc Queries

A few SQL queries are included in Porta by default. An example is the select query, which works as if you put select _columns_ from _from_ _alias_ in priv/queries/select.sql. These queries can be composed as well:

Repo.run! [
  union_all: %{
    left: [
      select: %{
        columns: {:raw, "name"},
        from: [banned_users_by_occupation: %{occupation: "guru"}],
        alias: {:raw, "gurus"}
      }
    ],
    right: [
      select: %{
        columns: {:raw, "name"},
        from: {:raw, "users"},
        alias: {:raw, "u"}
      },
      where: %{clause: {:raw, "u.age < 19"}}
    ]
  },
  order: %{by: {:raw, "name"}}
]

While the union and union_all default queries are very useful, it usually makes more sense to put select subqueries in their own file under priv/queries.

A feature that should see even rarer use is the ability to replace the atoms in the query list with strings:

Repo.run! [{"select _i_ - 5", %{i: 6}}]

This is equivalent to putting select _i_ - 5 in select_i_minus_5.sql and running:

Repo.run! select_i_minus_5: %{i: 6}

SQL Files as Migrations

I'll write up documentation for this and the other Mix tasks someday.

Installation

Most of Porta's features require a somewhat recent version of Ecto. Some require Phoenix. Other than that:

def deps do
  [{:porta, "~> 0.2.0"}]
end