diff --git a/.gitignore b/.gitignore index 85bcb1f..75c3b7f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /deps erl_crash.dump *.ez +.elixir_ls \ No newline at end of file diff --git a/README.md b/README.md index 9d7643d..2704f74 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,39 @@ To have it clear: * SqlDust will return a tuple of **two** elements when NOT having passed variables * SqlDust will return a tuple of **three** elements when having passed variables +### Lateral Joins + +Postgres 9.3 introduced lateral joins; a very convenient way to compare and filter your query based on other information in the database. +A lateral subquery gets executed once and it's result is available for the outer query to compare to. + +[Read more here](https://www.postgresql.org/docs/9.4/static/queries-table-expressions.html) + +```elixir +sub_query = +City + |> select("date_started") + |> where("id = 5") + +City + |> select("id, name") + |> join_lateral("first_city", sub_query) + |> where("date_started > first_city.date_started") + |> to_sql + |> elem(0) + |> IO.puts + +""" +SELECT `c`.`id`, `c`.`name`, `c`.`date_started`, +FROM `cities` `c` +LEFT JOIN LATERAL ( + SELECT `c`.`date_started` + FROM `cities` `c` + WHERE (`c`.`id` = 1) +) AS first_city ON TRUE +WHERE (`c`.`date_started` > first_city."date_started") +""" +``` + ## Testing Run the following command for testing: diff --git a/lib/sql_dust.ex b/lib/sql_dust.ex index f4eca6e..c446147 100644 --- a/lib/sql_dust.ex +++ b/lib/sql_dust.ex @@ -5,7 +5,7 @@ defmodule SqlDust do import SqlDust.JoinUtils alias SqlDust.MapUtils - defstruct [:select, :from, :join_on, :where, :group_by, :order_by, :limit, :offset, :unique, :schema, :variables, :adapter] + defstruct [:select, :from, :join_on, :join_lateral, :where, :group_by, :order_by, :limit, :offset, :unique, :schema, :variables, :adapter] @moduledoc """ SqlDust is a module that generates SQL queries as intuitively as possible. @@ -75,6 +75,8 @@ defmodule SqlDust do |> Enum.map(fn(path) -> derive_joins(path, options) end) |> List.flatten + joins = joins ++ derive_lateral_joins(options) + Map.put options, :joins, joins end @@ -98,7 +100,7 @@ defmodule SqlDust do {having, where} = where - |> Enum.partition(fn([sql | _]) -> + |> Enum.split_with(fn([sql | _]) -> sql = sanitize_sql(sql) Enum.any?(options.aliases, fn(sql_alias) -> String.match?(sql, ~r/(^|[^\.\w])#{sql_alias}([^\.\w]|$)/) diff --git a/lib/sql_dust/utils/compose_utils.ex b/lib/sql_dust/utils/compose_utils.ex index 92d5967..0b8b71a 100644 --- a/lib/sql_dust/utils/compose_utils.ex +++ b/lib/sql_dust/utils/compose_utils.ex @@ -13,6 +13,9 @@ defmodule SqlDust.ComposeUtils do def join_on(statements) do join_on(options(), statements) end def join_on(options, statements) do append(options, :join_on, statements, false) end + def join_lateral(name, sub_dust) do join_lateral(options(), name, sub_dust) end + def join_lateral(options, name, sub_dust) do append(options, :join_lateral, {name, sub_dust}, false) end + def where(statements) do where(options(), statements) end def where(options, statements) do append(options, :where, statements, false) end @@ -65,9 +68,21 @@ defmodule SqlDust.ComposeUtils do from = options.from schema = options.schema - options = Map.take(options, [:select, :join_on, :where, :group_by, :order_by, :limit, :offset, :unique, :variables, :adapter]) + options = Map.take(options, [:select, :join_on, :join_lateral, :where, :group_by, :order_by, :limit, :offset, :unique, :variables, :adapter]) |> Enum.reduce(%{from: options.from}, fn ({_key, nil}, map) -> map + ({:join_lateral, joins}, map) -> + Map.put(map, :join_lateral, + Enum.map(joins, fn + + {name, %SqlDust{} = sub_dust} -> + {name, to_sql(sub_dust) |> elem(0)} + + {name, sub_query} -> + {name, sub_query} + + end + )) ({key, value}, map) -> Map.put(map, key, value) end) diff --git a/lib/sql_dust/utils/join_utils.ex b/lib/sql_dust/utils/join_utils.ex index e9d93a1..238c619 100644 --- a/lib/sql_dust/utils/join_utils.ex +++ b/lib/sql_dust/utils/join_utils.ex @@ -120,6 +120,17 @@ defmodule SqlDust.JoinUtils do end) end + def derive_lateral_joins(%{join_lateral: joins}) when not is_nil(joins) do + joins + |> Enum.map(fn({name, sub_query}) -> + {:has_one, ["LEFT JOIN LATERAL (", sub_query, ") AS", name, "ON TRUE"] |> Enum.join(" ")} + end) + end + + def derive_lateral_joins(_) do + [] + end + defp additional_join_conditions(path, %{join_on: join_on} = options) when is_bitstring(join_on) do additional_join_conditions(path, %{options | join_on: [join_on]}) end diff --git a/lib/sql_dust/utils/path_utils.ex b/lib/sql_dust/utils/path_utils.ex index 64f34cf..9f8af2c 100644 --- a/lib/sql_dust/utils/path_utils.ex +++ b/lib/sql_dust/utils/path_utils.ex @@ -55,7 +55,27 @@ defmodule SqlDust.PathUtils do excluded = format_excluded_aliases(excluded, aliases, options) - {sql, aliases, excluded} + {sql, aliases, excluded ++ scan_and_format_lateral_fields(sql, options)} + end + + defp scan_and_format_lateral_fields(sql, %{join_lateral: laterals}) when is_binary(sql) do + laterals + |> Enum.reduce([], fn {lateral_alias, _subquery}, acc -> + acc ++ + ( + "\\b#{lateral_alias}\\.(\\w+)" + |> Regex.compile() + |> elem(1) + |> Regex.scan(sql) + |> Enum.map(fn [full_path, field] -> + [Regex.compile(full_path) |> elem(1), "#{lateral_alias}.\"#{field}\""] + end) + ) + end) + end + + defp scan_and_format_lateral_fields(_, _) do + [] end defp format_excluded_aliases(excluded, aliases, options) do @@ -149,7 +169,6 @@ defmodule SqlDust.PathUtils do end defp do_dissect_path(path, options) do - quotation_symbol = quotation_mark(options) split_on_dot_outside_quotation_mark = ~r/\.(?=(?:[^#{quotation_symbol}]*#{quotation_symbol}[^#{quotation_symbol}]*#{quotation_symbol})*[^#{quotation_symbol}]*$)/ segments = String.split(path, split_on_dot_outside_quotation_mark) diff --git a/test/sql_dust/lateral_join_test.exs b/test/sql_dust/lateral_join_test.exs new file mode 100644 index 0000000..ed2b842 --- /dev/null +++ b/test/sql_dust/lateral_join_test.exs @@ -0,0 +1,20 @@ +defmodule SqlDust.LateralJoinTest do + use ExUnit.Case + + import SqlDust.Query + + test "laterl join returns join" do + sub_dust = select("id") |> from("account") |> where("id = 1") + query_dust = select("id") |> from("users") |> join_lateral("sub_table", sub_dust) + + assert query_dust |> to_sql |> elem(0) == "SELECT `u`.`id`\nFROM `users` `u`\nLEFT JOIN LATERAL ( SELECT `a`.`id`\nFROM `account` `a`\nWHERE (`a`.`id` = 1)\n ) AS sub_table ON TRUE\n" + end + + + test "laterl join alias doesn't get ignored" do + sub_dust = select("id", "user_id") |> from("account") |> where("id = 1") + query_dust = select("id") |> from("users") |> join_lateral("sub_table", sub_dust) |> where("sub_table.user_id = id") + + assert query_dust |> to_sql |> elem(0) == "SELECT `u`.`id`\nFROM `users` `u`\nLEFT JOIN LATERAL ( SELECT `a`.`user_id`\nFROM `account` `a`\nWHERE (`a`.`id` = 1)\n ) AS sub_table ON TRUE\nWHERE (sub_table.\"user_id\" = `u`.`id`)\n" + end +end