Packages

Ecto helpers for creating database views and functions

Current section

Files

Jump to
familiar lib familiar.ex
Raw

lib/familiar.ex

defmodule Familiar do
@moduledoc """
Helper functions for creating database views and functions - your database's
familiars.
View and function definitions are stored as SQL files in `priv/repo/views` and
`priv/repo/functions` respectively.
Each definition file has the following file name format:
NAME_vNUMBER.sql
The NAME will be the name of the created database object and will be what is
used to refer to it in the functions defined in this module. The NUMBER is the
version number and should be incremented whenever a view or function is
revised. Old versions should be kept and shouldn't be modified once deployed.
A view definition file can be generated using the following mix task:
$ mix familiar.gen.view my_view
## Example
Given the following view definition in `priv/repo/views/active_users_v1.sql`:
SELECT * FROM users
WHERE users.active = TRUE;
The view can be created like so:
defmodule MyRepo.Migrations.CreateActiveUsers do
use Ecto.Migration
use Familiar
def change do
create_view :active_users
end
end
### Updating the view
If we want to update the `active_users` view created above, we can first generate a new version of the view by running:
$ mix familiar.gen.view active_users
And then editing the generated `active_users_v2.sql` as needed. Then the view can be updated in a migration:
defmodule MyRepo.Migrations.UpdateActiveUsers do
use Ecto.Migration
use Familiar
def change do
update_view :active_users, revert: 1
end
end
The `:revert` option is optional however if it is omitted the migration will not be reversible.
The new version number can be specified explicitly if desired:
$ mix familiar.gen.view my_view --version 3
### Non default schema
Definition to be created in the non default schema can be placed in
a subdirectory with the name of the schema. For example
`priv/repo/views/bi/analytics_v1.sql` will create a the `analytics`
view in the `bi` schema.
The `:schema` option then needs to be added to each function call.
The `--schema` option can be also be passed to `familiar.gen.view`.
"""
defmacro __using__(_opts) do
quote do
import Familiar
end
end
@doc """
Creates a new database view from a view definition file.
## Options:
* `:version` - the version of the view to create
* `:materialized` - whether the view is materialized or not. Defaults to `false`.
* `:schema` - the schema to create the view in. Creates in default schema if not specified
"""
def create_view(view_name, opts) do
view_name = normalise_name(view_name)
version = Keyword.fetch!(opts, :version)
materialized = Keyword.get(opts, :materialized, false)
schema = Keyword.get(opts, :schema)
new_sql = read_file(:views, view_name, version, schema)
execute(
create_view_sql(view_name, new_sql, materialized, schema),
drop_view_sql(view_name, materialized, schema)
)
end
@doc """
Updates a database view from a view definition file.
This function will drop the existing view and then create the new version of
the view so dependant views, functions, triggers etc will need to be dropped
first.
## Options:
* `:version` - the version of the updated view
* `:materialized` - whether the view is materialized or not. Defaults to `false`.
* `:revert` - the version to revert to if the migration is rolled back
* `:schema` - the schema the view lives in. Uses default schema if not specified
"""
def update_view(view_name, opts) do
view_name = normalise_name(view_name)
version = Keyword.fetch!(opts, :version)
materialized = Keyword.get(opts, :materialized, false)
schema = Keyword.get(opts, :schema)
revert = Keyword.get(opts, :revert)
new_sql = read_file(:views, view_name, version, schema)
if revert do
old_sql = read_file(:views, view_name, revert, schema)
execute(
drop_and_create_view(view_name, new_sql, materialized, schema),
drop_and_create_view(view_name, old_sql, materialized, schema)
)
else
execute(drop_and_create_view(view_name, new_sql, materialized, schema))
end
end
@doc """
Replaces a database view from a view definition file.
This function will use `CREATE OR REPLACE VIEW` so can only be used if the new
version has the same columns as the old version.
## Options:
* `:version` - the version of the updated view
* `:materialized` - whether the view is materialized or not. Defaults to `false`.
* `:revert` - the version to revert to if the migration is rolled back
* `:schema` - the schema the view lives in. Uses default schema if not specified
"""
def replace_view(view_name, opts) do
view_name = normalise_name(view_name)
version = Keyword.fetch!(opts, :version)
schema = Keyword.get(opts, :schema)
revert = Keyword.get(opts, :revert)
new_sql = read_file(:views, view_name, version, schema)
if revert do
old_sql = read_file(:views, view_name, revert, schema)
execute(
replace_view_sql(view_name, new_sql, schema),
replace_view_sql(view_name, old_sql, schema)
)
else
execute(replace_view_sql(view_name, new_sql, schema))
end
end
@doc """
Drops a database view.
## Options:
* `:materialized` - whether the view is materialized or not. Defaults to `false`.
* `:revert` - the version to create if the migration is rolled back
* `:schema` - the schema the view lives in. Uses default schema if not specified
"""
def drop_view(view_name, opts \\ []) do
revert = Keyword.get(opts, :revert)
view_name = normalise_name(view_name)
schema = Keyword.get(opts, :schema)
materialized = Keyword.get(opts, :materialized, false)
if revert do
sql = read_file(:views, view_name, revert, schema)
execute(
drop_view_sql(view_name, materialized, schema),
create_view_sql(view_name, sql, materialized, schema)
)
else
execute(drop_view_sql(view_name, materialized, schema))
end
end
@doc """
Creates a new database function from a function definition file.
## Options:
* `:version` - the version of the function to create
* `:schema` - the schema to create the function in. Uses default schema if not specified
"""
def create_function(function_name, opts) do
function_name = normalise_name(function_name)
version = Keyword.fetch!(opts, :version)
schema = Keyword.get(opts, :schema)
new_sql = read_file(:functions, function_name, version, schema)
execute(
create_function_sql(function_name, new_sql, schema),
drop_function_sql(function_name, schema)
)
end
@doc """
Updates a new database function from a function definition file.
This function will drop the existing function before creating a new function
so dependant views, functions, triggers etc will need to be dropped first.
## Options:
* `:version` - the version of the updated function
* `:revert` - the version to revert to if the migration is rolled back
* `:schema` - the schema the function lives in. Uses default schema if not specified
"""
def update_function(function_name, opts) do
function_name = normalise_name(function_name)
version = Keyword.fetch!(opts, :version)
schema = Keyword.get(opts, :schema)
revert = Keyword.get(opts, :revert)
new_sql = read_file(:functions, function_name, version, schema)
if revert do
old_sql = read_file(:functions, function_name, revert, schema)
execute(
drop_and_create_function(function_name, new_sql, schema),
drop_and_create_function(function_name, old_sql, schema)
)
else
execute(drop_and_create_function(function_name, new_sql, schema))
end
end
@doc """
Replaces a new database function from a function definition file.
This function will use `CREATE OR REPLACE FUNCTION` so can only be used if the new
version has the same arguments and return type as the old version.
## Options:
* `:version` - the version of the updated function
* `:revert` - the version to revert to if the migration is rolled back
* `:schema` - the schema the function lives in. Uses default schema if not specified
"""
def replace_function(function_name, opts) do
function_name = normalise_name(function_name)
version = Keyword.fetch!(opts, :version)
schema = Keyword.get(opts, :schema)
revert = Keyword.get(opts, :revert)
new_sql = read_file(:functions, function_name, version, schema)
if revert do
old_sql = read_file(:functions, function_name, revert, schema)
execute(
replace_function_sql(function_name, new_sql, schema),
replace_function_sql(function_name, old_sql, schema)
)
else
execute(replace_function_sql(function_name, new_sql, schema))
end
end
@doc """
Drops a database function.
## Options:
* `:revert` - the version to create if the migration is rolled back
* `:schema` - the schema the function lives in. Uses default schema if not specified
"""
def drop_function(function_name, opts \\ []) do
function_name = normalise_name(function_name)
schema = Keyword.get(opts, :schema)
revert = Keyword.get(opts, :revert)
if revert do
old_sql = read_file(:functions, function_name, revert, schema)
execute(
drop_function_sql(function_name, schema),
create_function_sql(function_name, old_sql, schema)
)
else
execute(drop_function_sql(function_name, schema))
end
end
defp normalise_name(view_name) do
"#{view_name}"
end
defp execute(sql) do
Ecto.Migration.execute(wrap(sql))
end
defp execute(up, down) do
Ecto.Migration.execute(wrap(up), wrap(down))
end
defp wrap(statements) when is_list(statements) do
fn ->
for sql <- statements do
Ecto.Migration.repo().query!(sql)
end
end
end
defp wrap(sql) do
fn -> Ecto.Migration.repo().query!(sql) end
end
defp create_view_sql(view_name, sql, materialized, schema) do
m = if materialized, do: "MATERIALIZED", else: ""
"CREATE #{m} VIEW #{wrap_name(view_name, schema)} AS #{sql};"
end
defp replace_view_sql(view_name, sql, schema) do
"CREATE OR REPLACE VIEW #{wrap_name(view_name, schema)} AS #{sql};"
end
defp drop_view_sql(view_name, materialized, schema) do
m = if materialized, do: "MATERIALIZED", else: ""
"DROP #{m} VIEW #{wrap_name(view_name, schema)};"
end
defp drop_and_create_view(view_name, sql, materialized, schema) do
[
drop_view_sql(view_name, materialized, schema),
create_view_sql(view_name, sql, materialized, schema)
]
end
defp create_function_sql(function_name, sql, schema) do
"CREATE FUNCTION #{wrap_name(function_name, schema)} #{sql};"
end
defp replace_function_sql(function_name, sql, schema) do
"CREATE OR REPLACE FUNCTION #{wrap_name(function_name, schema)} #{sql};"
end
defp drop_function_sql(function_name, schema) do
"DROP FUNCTION #{wrap_name(function_name, schema)}"
end
defp wrap_name(name, schema) when not is_nil(schema) do
~s|"#{schema}"."#{name}"|
end
defp wrap_name(name, nil) do
~s|"#{name}"|
end
defp drop_and_create_function(function_name, sql, schema) do
[drop_function_sql(function_name, schema), create_function_sql(function_name, sql, schema)]
end
defp read_file(type, view_name, version, schema) do
schema_str = if schema, do: "#{schema}/", else: ""
File.read!(make_dir(type) <> "/#{schema_str}#{view_name}_v#{version}.sql")
end
defp make_dir(type) do
otp_app = Ecto.Migration.repo().config()[:otp_app]
Application.app_dir(otp_app, "priv/repo/#{type}/")
end
end