Skip to content

Commit 55842e6

Browse files
authored
Oban like migration system (#1)
This commit introduces an Oban like migration system for the ErrorTracker. Users can generate their own migrations that call the ErrorTracker migrations and possibly set an alternative database prefix in case that they want to store the errors in a separate schema than the rest of the application. This commit also updates the dev script to showcase how to use the new migration system on an alternative prefix.
1 parent 37d51b6 commit 55842e6

7 files changed

Lines changed: 259 additions & 28 deletions

File tree

dev.exs

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Application.put_env(:error_tracker, ErrorTrackerDevWeb.Endpoint,
4444
# Setup up the ErrorTracker configuration
4545
Application.put_env(:error_tracker, :repo, ErrorTrackerDev.Repo)
4646
Application.put_env(:error_tracker, :application, :error_tracker_dev)
47+
Application.put_env(:error_tracker, :prefix, "private")
4748

4849
defmodule ErrorTrackerDevWeb.PageController do
4950
import Plug.Conn
@@ -112,30 +113,8 @@ end
112113
defmodule Migration0 do
113114
use Ecto.Migration
114115

115-
def change do
116-
create table(:error_tracker_errors) do
117-
add :kind, :string, null: false
118-
add :reason, :text, null: false
119-
add :source_line, :text, null: false
120-
add :source_function, :text, null: false
121-
add :status, :string, null: false
122-
add :fingerprint, :string, null: false
123-
124-
timestamps()
125-
end
126-
127-
create unique_index(:error_tracker_errors, :fingerprint)
128-
129-
create table(:error_tracker_occurrences) do
130-
add :context, :map, null: false
131-
add :stacktrace, :map, null: false
132-
add :error_id, references(:error_tracker_errors, on_delete: :delete_all), null: false
133-
134-
timestamps(updated_at: false)
135-
end
136-
137-
create index(:error_tracker_occurrences, :error_id)
138-
end
116+
def up, do: ErrorTracker.Migrations.up(prefix: "private")
117+
def down, do: ErrorTracker.Migrations.down(prefix: "private")
139118
end
140119

141120
Application.put_env(:phoenix, :serve_endpoints, true)

lib/error_tracker.ex

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,20 @@ defmodule ErrorTracker do
66
error =
77
repo().insert!(error,
88
on_conflict: [set: [status: :unresolved]],
9-
conflict_target: :fingerprint
9+
conflict_target: :fingerprint,
10+
prefix: prefix()
1011
)
1112

1213
error
1314
|> Ecto.build_assoc(:occurrences, stacktrace: stacktrace, context: context)
14-
|> repo().insert!()
15+
|> repo().insert!(prefix: prefix())
1516
end
1617

1718
def repo do
1819
Application.fetch_env!(:error_tracker, :repo)
1920
end
21+
22+
def prefix do
23+
Application.get_env(:error_tracker, :prefix, "public")
24+
end
2025
end

lib/error_tracker/migrations.ex

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
defmodule ErrorTracker.Migrations do
2+
@moduledoc """
3+
Create and modify the database tables for the ErrorTracker.
4+
5+
## Usage
6+
7+
To use the ErrorTracker migrations in your application you will need to generate
8+
a regular `Ecto.Migration` that performs the relevant calls to `ErrorTracker.Migration`.
9+
10+
```bash
11+
mix ecto.gen.migration add_error_tracker
12+
```
13+
14+
Open the generated migration file and call the `up` and `down` functions on
15+
`ErrorTracker.Migration`.
16+
17+
```elixir
18+
defmodule MyApp.Repo.Migrations.AddErrorTracker do
19+
use Ecto.Migration
20+
21+
def up, do: ErrorTracker.Migrations.up()
22+
def down, do: ErrorTracker.Migrations.down()
23+
end
24+
```
25+
26+
This will run every ErrorTracker migration for your database. You can now run the migration
27+
and perform the database changes:
28+
29+
```bash
30+
mix ecto.migrate
31+
```
32+
33+
As new versions of the ErrorTracker are released you may need to run additional migrations.
34+
To do this you can follow the previous process and create a new migration:
35+
36+
```bash
37+
mix ecto.gen.migration update_error_tracker_to_vN
38+
```
39+
40+
Open the generated migration file and call the `up` and `down` functions on the
41+
`ErrorTracker.Migration` passing the desired `version`.
42+
43+
```elixir
44+
defmodule MyApp.Repo.Migrations.UpdateErrorTrackerToVN do
45+
use Ecto.Migration
46+
47+
def up, do: ErrorTracker.Migrations.up(version: N)
48+
def down, do: ErrorTracker.Migrations.down(version: N)
49+
end
50+
```
51+
52+
Then run the migrations to perform the database changes:
53+
54+
```bash
55+
mix ecto.migrate
56+
```
57+
58+
## Custom prefix
59+
60+
ErrorTracker supports namespacing its own tables using PostgreSQL schemas, also known
61+
as "prefixes" in Ecto. With prefixes your error tables can reside outside of your primary
62+
schema (which is usually named "public").
63+
64+
To use a prefix you need to specify it in your migrations:
65+
66+
```elixir
67+
defmodule MyApp.Repo.Migrations.AddErrorTracker do
68+
use Ecto.Migration
69+
70+
def up, do: ErrorTracker.Migrations.up(prefix: "custom_schema")
71+
def down, do: ErrorTracker.Migrations.down(prefix: "custom_schema")
72+
end
73+
```
74+
75+
This will automatically create the database schema for you. If the schema does already exist
76+
the migration may fail when trying to recreate it. In such cases you can instruct the ErrorTracker
77+
not to create the schema again:
78+
79+
```elixir
80+
defmodule MyApp.Repo.Migrations.AddErrorTracker do
81+
use Ecto.Migration
82+
83+
def up, do: ErrorTracker.Migrations.up(prefix: "custom_schema", create_schema: false)
84+
def down, do: ErrorTracker.Migrations.down(prefix: "custom_schema")
85+
end
86+
```
87+
88+
If you are using a custom schema other than the default "public" you need to configure the
89+
ErrorTracker to use that schema:
90+
91+
```elixir
92+
config :error_tracker, :prefix, "custom_schema"
93+
```
94+
"""
95+
defdelegate up(opts \\ []), to: ErrorTracker.Migration
96+
defdelegate down(opts \\ []), to: ErrorTracker.Migration
97+
end
98+
99+
defmodule ErrorTracker.Migration do
100+
@moduledoc false
101+
102+
@callback up(Keyword.t()) :: :ok
103+
@callback down(Keyword.t()) :: :ok
104+
@callback current_version(Keyword.t()) :: non_neg_integer()
105+
106+
def up(opts \\ []) when is_list(opts) do
107+
migrator().up(opts)
108+
end
109+
110+
def down(opts \\ []) when is_list(opts) do
111+
migrator().down(opts)
112+
end
113+
114+
def migrated_version(opts \\ []) when is_list(opts) do
115+
migrator().migrated_version(opts)
116+
end
117+
118+
defp migrator do
119+
case ErrorTracker.repo().__adapter__() do
120+
Ecto.Adapters.Postgres -> ErrorTracker.Migrations.Postgres
121+
adapter -> raise "ErrorTracker does not support #{adapter}"
122+
end
123+
end
124+
end
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
defmodule ErrorTracker.Migrations.Postgres do
2+
@behaviour ErrorTracker.Migration
3+
4+
use Ecto.Migration
5+
6+
import Ecto.Query
7+
8+
@initial_version 1
9+
@current_version 1
10+
@default_prefix "public"
11+
12+
@impl ErrorTracker.Migration
13+
def up(opts) do
14+
opts = with_defaults(opts, @current_version)
15+
initial = current_version(opts)
16+
17+
cond do
18+
initial == 0 ->
19+
change(@initial_version..opts.version, :up, opts)
20+
21+
initial < opts.version ->
22+
change((initial + 1)..opts.version, :up, opts)
23+
24+
true ->
25+
:ok
26+
end
27+
end
28+
29+
@impl ErrorTracker.Migration
30+
def down(opts) do
31+
opts = with_defaults(opts, @initial_version)
32+
initial = max(current_version(opts), @initial_version)
33+
34+
if initial >= opts.version do
35+
change(initial..opts.version, :down, opts)
36+
end
37+
end
38+
39+
@impl ErrorTracker.Migration
40+
def current_version(opts) do
41+
opts = with_defaults(opts, @initial_version)
42+
repo = Map.get_lazy(opts, :repo, fn -> repo() end)
43+
44+
query =
45+
from pg_class in "pg_class",
46+
left_join: pg_description in "pg_description",
47+
on: pg_description.objoid == pg_class.oid,
48+
left_join: pg_namespace in "pg_namespace",
49+
on: pg_namespace.oid == pg_class.relnamespace,
50+
where: pg_class.relname == "error_tracker_errors",
51+
where: pg_namespace.nspname == ^opts.escaped_prefix,
52+
select: pg_description.description
53+
54+
case repo.one(query, log: false) do
55+
version when is_binary(version) -> String.to_integer(version)
56+
_other -> 0
57+
end
58+
end
59+
60+
defp change(versions_range, direction, opts) do
61+
for version <- versions_range do
62+
padded_version = String.pad_leading(to_string(version), 2, "0")
63+
64+
migration_module = Module.concat(__MODULE__, "V#{padded_version}")
65+
apply(migration_module, direction, [opts])
66+
end
67+
68+
case direction do
69+
:up -> record_version(opts, Enum.max(versions_range))
70+
:down -> record_version(opts, Enum.min(versions_range) - 1)
71+
end
72+
end
73+
74+
defp record_version(%{prefix: prefix}, version) do
75+
case version do
76+
0 -> :ok
77+
_other -> execute "COMMENT ON TABLE #{inspect(prefix)}.error_tracker_errors IS '#{version}'"
78+
end
79+
end
80+
81+
defp with_defaults(opts, version) do
82+
opts = Enum.into(opts, %{prefix: @default_prefix, version: version})
83+
84+
opts
85+
|> Map.put_new(:create_schema, opts.prefix != @default_prefix)
86+
|> Map.put_new(:escaped_prefix, String.replace(opts.prefix, "'", "\\'"))
87+
end
88+
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule ErrorTracker.Migrations.Postgres.V01 do
2+
use Ecto.Migration
3+
4+
def up(%{create_schema: create_schema, prefix: prefix}) do
5+
if create_schema, do: execute("CREATE SCHEMA IF NOT EXISTS #{prefix}")
6+
7+
create table(:error_tracker_errors, prefix: prefix) do
8+
add :kind, :string, null: false
9+
add :reason, :text, null: false
10+
add :source_line, :text, null: false
11+
add :source_function, :text, null: false
12+
add :status, :string, null: false
13+
add :fingerprint, :string, null: false
14+
15+
timestamps(type: :utc_datetime_usec)
16+
end
17+
18+
create unique_index(:error_tracker_errors, [:fingerprint], prefix: prefix)
19+
20+
create table(:error_tracker_occurrences, prefix: prefix) do
21+
add :context, :map, null: false
22+
add :stacktrace, :map, null: false
23+
add :error_id, references(:error_tracker_errors, on_delete: :delete_all), null: false
24+
25+
timestamps(type: :utc_datetime_usec, updated_at: false)
26+
end
27+
28+
create index(:error_tracker_occurrences, [:error_id], prefix: prefix)
29+
end
30+
31+
def down(%{prefix: prefix}) do
32+
drop table(:error_tracker_occurrences, prefix: prefix)
33+
drop table(:error_tracker_errors, prefix: prefix)
34+
end
35+
end

lib/error_tracker/models/error.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule ErrorTracker.Error do
1111

1212
has_many :occurrences, ErrorTracker.Occurrence
1313

14-
timestamps()
14+
timestamps(type: :utc_datetime_usec)
1515
end
1616

1717
def new(exception, stacktrace = %ErrorTracker.Stacktrace{}) do

lib/error_tracker/models/occurrence.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ defmodule ErrorTracker.Occurrence do
77
embeds_one :stacktrace, ErrorTracker.Stacktrace
88
belongs_to :error, ErrorTracker.Error
99

10-
timestamps(updated_at: false)
10+
timestamps(type: :utc_datetime_usec, updated_at: false)
1111
end
1212
end

0 commit comments

Comments
 (0)