I’ve been working with Ecto a lot lately and wanted to spend this post writing up something I’ve learned while trying to put together a query. I’m by no means a database expert and this is one of the more complex queries I’ve had to write so far.
In the private project I’m working on, we have a database schema that consists of rentals that are marked unavailable on certain dates. These unavailable date ranges are stored in an Unavailability model and associated through a has_many / belongs_to relationship. That is, a Rental has many unavalable_date_ranges and Unavailability belongs to a Rental like this:
When trying to find a rental I want the user to be able to search (query) for rentals that are available on a given day or range of days. This means finding rentals that have no associated Unavailability records that overlap the desired days.
A while back I had written a query that turned out not to work. It only worked for very simple cases but failed when a rental had more than one associated Unavailability. I set out to fix this.
Research and Experimentation
I’m not an SQL or Ecto expert so I set out to research what I needed to learn in order to solve my problem. After some googling around I came across the blog post “All, None, and One: The SQL Left Join Trick”. This post taught me, as it promissed, how to use a left join to find rows with no matching associations. I highly recommend reading the post but I’ll summarize what I took away from it.
A left join between Rental and Unavailability will contain all of the rows from Rental even if they have no matching unavailable_date_ranges. And we can add additional criteria so that the results has only those rentals with no matching unavalable_date_ranges, that is those that are available.
I played around with this technique and was able to build up this query:
SELECT * FROM rentals
LEFT OUTER JOIN unavailabilities
ON (unavailabilities.rental_id = rentals.id)
WHERE rental_id IS NULL;This would return all the colums for rentals where there are no associated unavalable_date_ranges. The ON clause joins by the association. The WHERE clause filters the result to those rentals without any matching unavalable_date_ranges.
But this doesn’t get me exactly what I want. I need to add in a condition to check for overlap. I know how to write the condition:
(unavailabilities.start, unavailabilities.end) OVERLAPS
('2016-01-31'::date, '2016-02-05'::date)but not where to put it. After some failed experiments and more googling I came to this stack overflow answer which helped me to work out the right SQL syntax. Here’s the SQL query I ended up with:
SELECT * FROM rentals
LEFT OUTER JOIN unavailabilities
ON (unavailabilities.rental_id = rentals.id)
AND (unavailabilities.start, unavailabilities.end)
OVERLAPS ('2016-01-31'::date, '2016-02-05'::date)
WHERE rental_id IS NULL;Working within the Postgres console I was able to confirm that this works for my use cases.
Build the Ecto Query from the SQL Query
The next step was to take the SQL query I have build and turn it into an Ecto query. Of course, I could try to use the raw SQL in a fragment but it would be nice to use a plain Ecto query if possible.
While doing this work I found the post “Diving Into Ecto Part 2” very helpful. As was the Ecto documentation for join.
Just as with the raw SQL the one thing that I had a little trouble with was adding the overlaps condition in the right place. Most join examples use assoc as in this example from the docs:
from p in Post,
left_join: c in assoc(p, :comments),
select: {p, c}I wanted to combine the association with the overlap condition. But, this doesn’t work. Instead I had to write out the association condition by hand, which is similar to what we see in the SQL.
I ended up with this function to build the query:
@doc """
Query rentals available on specific date range.
* `query` - Initial query to start with. Only, rentals included in this
query will be considered.
* `s` - starting date to check for availablity
* `e` - ending date to check for availablity
"""
def available_between(query \\ Rental, s, e) do
s = parse_date(s)
e = parse_date(e)
from rental in query,
left_join: range in Unavailability,
on: (range.rental_id == rental.id) and overlaps(range, ^s, ^e),
where: is_nil(range.rental_id)
end
defmacro overlaps(range, s, e) do
# Uses the Postgres specific OVERLAPS function
quote do
fragment("(?, ?) OVERLAPS (?, ?)",
unquote(range).start, unquote(range).end,
type(unquote(s), Ecto.Date), type(unquote(e), Ecto.Date))
end
endThe condition (range.rental_id == rental.id) plays the same role as assoc(rental, :unavailable_date_ranges) and then I combined that with overlaps(range, ^s, ^e) to setup the left join. I think this resembles the SQL quite closely.
Here’s the SQL generated by Ecto as shown in the logs:
SELECT r0."id", r0."title", r0."description", r0."location", r0."owner_id",
r0."inserted_at", r0."updated_at" FROM "rentals" AS r0
LEFT OUTER JOIN "unavailabilities" AS u1
ON (u1."rental_id" = r0."id")
AND (u1."start", u1."end") OVERLAPS ($1::date, $2::date)
WHERE (u1."rental_id" IS NULL) [{2016, 1, 31}, {2016, 2, 5}]Conclusion
In this post we explored a bit of SQL and I learned a bit about left joins and how to use the effectively. We also looked at building up Ecto queries from SQL. I learned a lot in doing this work and I hope you were able to learn from this post.