Packages

Universal foreign keys, virtual schemas, and shared data mixins

Current section

5 Versions

Jump to

Compare versions

18 files changed
+779 additions
-689 deletions
  @@ -40,9 +40,9 @@ A means of foreign keying many tables in one field. Designed for highly interlin
40 40 > A universal foreign key is actually a hard problem. Many approaches are on offer with a variety of tradeoffs. If plugging into Bonfire's Needle-based core extensions isn't a requirement for you (i.e. you don't need to put things into feeds or use boundaries for access-control) should carefully consider a variety of approaches rather than just blindly adopting the one that fitted our project's needs the best!
41 41
42 42
43 - ## Identifying objects - the ULID type
43 + ## Identifying objects - the UID type
44 44
45 - All referenceable objects in the system have a unique ID (primary key) whose type is the `Needle.ULID`. [ULIDs](https://github.com/ulid/spec) are a lot like a `UUID` in that you can generate unique ones independently of the database. It's also a little different, being made up of two parts:
45 + All referenceable objects in the system have a unique ID (primary key) whose type is the `Needle.UID`. `UUIDv7` and [ULIDs](https://github.com/ulid/spec) are a lot like standard `UUID` in that you can generate unique ones independently of the database. It's also a little different, being made up of two parts:
46 46
47 47 * The current timestamp, to millisecond precision.
48 48 * Strong random padding for uniqueness.
  @@ -51,7 +51,7 @@ This means that it naturally sorts by time to the millisecond (close enough for
51 51
52 52 If you've only worked with integer primary keys before, you are probably used to letting the database dispense an ID for you. With `ULID` (or `UUID`), IDs can be known *before* they are stored, greatly easing the process of storing a graph of data and allowing us to do more of the preparation work outside of a transaction for increased performance.
53 53
54 - In PostgreSQL, we actually store `ULID`s as `UUID` columns, thanks to both being the same size (and the lack of a `ULID` column type shipping with postgresql). You mostly will not notice this because it's handled for you, but there are a few places it can come up:
54 + In PostgreSQL, we actually store `UUIDv7` and `ULID`s as `UUID` columns, thanks to both being the same size (and the lack of specific column types shipping with postgresql). You mostly will not notice this because it's handled for you, but there are a few places it can come up:
55 55
56 56 * Ecto debug and error output may show either binary values or UUID-formatted values.
57 57 * Hand-written SQL may need to convert table IDs to the `UUID` format before use.
  @@ -61,8 +61,8 @@ In PostgreSQL, we actually store `ULID`s as `UUID` columns, thanks to both being
61 61
62 62 The `Needle` system is mostly based around a single table represented by the `Needle.Pointer` schema with the following fields:
63 63
64 - * `id` (ULID) - the database-wide unique id for the object, primary key.
65 - * `table_id` (ULID) - identifies the type of the object, references `Needle.Table`.
64 + * `id` (UID) - the database-wide unique id for the object, primary key.
65 + * `table_id` (UID) - identifies the type of the object, references `Needle.Table`.
66 66 * `deleted_at` (timestamp, default: `null`) - when the object was deleted.
67 67
68 68 Every object that is stored in the system will have a record in this table. It may also have records in other tables (handy for storing more than 3 fields about the object!).
  @@ -100,9 +100,9 @@ end
100 100
101 101 ### Picking a table id
102 102
103 - The first step to declaring a new type is picking a unique table ID in ULID format.
103 + The first step to declaring a new type is picking a unique table ID in UID format.
104 104
105 - You could just generate a random ULID, but since these IDs are special, we tend to assign a synthetic ULID that are readable as words so they stand out in debug output.
105 + You could just generate a random UID, but since these IDs are special, we tend to assign a synthetic UID that are readable as words so they stand out in debug output.
106 106
107 107 For example, the ID for the `Feed` table is: `1TFEEDS0NTHES0V1S0FM0RTA1S`, which can be read as "It feeds on the souls of mortals". Feel free to have a little fun coming up with them, it makes debug output a little more cheery! The rules are:
108 108
  @@ -110,20 +110,20 @@ For example, the ID for the `Feed` table is: `1TFEEDS0NTHES0V1S0FM0RTA1S`, which
110 110 * They must be 26 characters in length.
111 111 * The first character must be a digit in the range 0-7.
112 112
113 - To help you with this, the `Needle.ULID.synthesise!/1` method takes an alphanumeric binary and tries to return you it transliterated into a valid ULID. Example usage:
113 + To help you with this, the `Needle.UID.synthesise!/1` method takes an alphanumeric binary and tries to return you it transliterated into a valid UID. Example usage:
114 114
115 115 ```
116 - iex(1)> Needle.ULID.synthesise!("itfeedsonthesouls")
116 + iex(1)> Needle.UID.synthesise!("itfeedsonthesouls")
117 117
118 118 11:20:28.299 [error] Too short, need 9 chars.
119 119 :ok
120 - iex(2)> Needle.ULID.synthesise!("itfeedsonthesoulsofmortalsandothers")
120 + iex(2)> Needle.UID.synthesise!("itfeedsonthesoulsofmortalsandothers")
121 121
122 122 11:20:31.819 [warn] Too long, chopping off last 9 chars
123 123 "1TFEEDS0NTHES0V1S0FM0RTA1S"
124 - iex(3)> Needle.ULID.synthesise!("itfeedsonthesoulsofmortals")
124 + iex(3)> Needle.UID.synthesise!("itfeedsonthesoulsofmortals")
125 125 "1TFEEDS0NTHES0V1S0FM0RTA1S"
126 - iex(4)> Needle.ULID.synthesise!("gtfeedsonthesoulsofmortals")
126 + iex(4)> Needle.UID.synthesise!("gtfeedsonthesoulsofmortals")
127 127
128 128 11:21:03.268 [warn] First character must be a digit in the range 0-7, replacing with 7
129 129 "7TFEEDS0NTHES0V1S0FM0RTA1S"
  @@ -192,7 +192,7 @@ end
192 192 ```
193 193
194 194
195 - > As you can see, to declare a pointable schema, we start by using `Needle.Pointable`, providing the name of our otp application, the source table's name in the database and our chosen sentinel ULID.
195 + > As you can see, to declare a pointable schema, we start by using `Needle.Pointable`, providing the name of our otp application, the source table's name in the database and our chosen sentinel UID.
196 196
197 197 > We then call `pointable_schema` and define any fields we wish to put directly in the table. For the most part, `pointable_schema` is like Ecto's `schema` macro, except you do not provide the table name and let it handle the primary key.
198 198
  @@ -212,7 +212,7 @@ record or not record information for each mixin. Sample mixins include:
212 212
213 213 In this way, they are reusable across different object types. One mixin may (or may not) be used by any number of objects. This is mostly driven by the type of the object we are storing, but can also be driven by user input.
214 214
215 - Mixins are just tables too! The only requirement is they have a `ULID` primary key which references `Needle.Pointer`. The developer of the mixin is free to put whatever other fields they want in the table, so long as they have that primary-key-as-reference (which will be automatically added for you by the `mixin_schema` macro).
215 + Mixins are just tables too! The only requirement is they have a `UID` primary key which references `Needle.Pointer`. The developer of the mixin is free to put whatever other fields they want in the table, so long as they have that primary-key-as-reference (which will be automatically added for you by the `mixin_schema` macro).
216 216
217 217 Here is a sample mixin definition for a user profile:
218 218
  @@ -386,7 +386,7 @@ end
386 386 ```
387 387
388 388 > As you can see, it's pretty similar to defining a regular migration, except you use `create_pointable_table` and
389 - `drop_pointable_table`. Notice that our sentinel ULID makes an appearance again here. It's *very* important that these match what we declared in the schema.
389 + `drop_pointable_table`. Notice that our sentinel UID makes an appearance again here. It's *very* important that these match what we declared in the schema.
390 390
391 391 ### Mixins
392 392
  @@ -637,10 +637,10 @@ list) can be used to obtain the IDs for a table or tables.
637 637 All solutions to the universal primary key problem have tradeofs. Here
638 638 are what we see as the deficiencies in our approach:
639 639
640 - 1. It forces a ULID on you. This is great for us, but not
641 - everyone. ULID exposes a timestamp with millisecond precision. If
642 - the time of creation of a resource is sensitive information for
643 - your purposes, ULIDs are not going to be suitable for you.
640 + 1. It forces a UUIDv7 or ULID on you. This is great for us, but not
641 + everyone. They both expose a timestamp with millisecond precision.
642 + If the time of creation of a resource is sensitive information for
643 + your purposes, they may not going to be suitable for you.
644 644 2. Ecto has no knowledge of the specialty of `Pointer`,
645 645 e.g. `Repo.preload` does not work and you need to specify a join
646 646 condition to join through a pointer. Use our functions or add extra
  @@ -2,7 +2,7 @@
2 2 [{<<"Hexdocs">>,<<"https://hexdocs.pm/needle">>},
3 3 {<<"Repository">>,<<"https://github.com/bonfire-networks/needle">>}]}.
4 4 {<<"name">>,<<"needle">>}.
5 - {<<"version">>,<<"0.8.0">>}.
5 + {<<"version">>,<<"0.9.0">>}.
6 6 {<<"description">>,
7 7 <<"Universal foreign keys, virtual schemas, and shared data mixins">>}.
8 8 {<<"elixir">>,<<"~> 1.10">>}.
  @@ -12,17 +12,22 @@
12 12 [[{<<"name">>,<<"ecto_sql">>},
13 13 {<<"app">>,<<"ecto_sql">>},
14 14 {<<"optional">>,false},
15 - {<<"requirement">>,<<"~> 3.8">>},
15 + {<<"requirement">>,<<"~> 3.13">>},
16 + {<<"repository">>,<<"hexpm">>}],
17 + [{<<"name">>,<<"typed_ecto_schema">>},
18 + {<<"app">>,<<"typed_ecto_schema">>},
19 + {<<"optional">>,false},
20 + {<<"requirement">>,<<"~> 0.4.1">>},
16 21 {<<"repository">>,<<"hexpm">>}],
17 22 [{<<"name">>,<<"exto">>},
18 23 {<<"app">>,<<"exto">>},
19 24 {<<"optional">>,false},
20 25 {<<"requirement">>,<<"~> 0.4">>},
21 26 {<<"repository">>,<<"hexpm">>}],
22 - [{<<"name">>,<<"needle_ulid">>},
23 - {<<"app">>,<<"needle_ulid">>},
27 + [{<<"name">>,<<"needle_uid">>},
28 + {<<"app">>,<<"needle_uid">>},
24 29 {<<"optional">>,false},
25 - {<<"requirement">>,<<"~> 0.3">>},
30 + {<<"requirement">>,<<"~> 0.0.2">>},
26 31 {<<"repository">>,<<"hexpm">>}],
27 32 [{<<"name">>,<<"telemetry">>},
28 33 {<<"app">>,<<"telemetry">>},
  @@ -33,8 +38,9 @@
33 38 [<<"lib">>,<<"lib/util.ex">>,<<"lib/not_found.ex">>,<<"lib/random.ex">>,
34 39 <<"lib/unpointable.ex">>,<<"lib/needle.ex">>,<<"lib/mixin.ex">>,
35 40 <<"lib/pointers.ex">>,<<"lib/virtual.ex">>,<<"lib/pointer.ex">>,
36 - <<"lib/table.ex">>,<<"lib/migration.ex">>,<<"lib/changesets.ex">>,
37 - <<"lib/form.ex">>,<<"lib/class.ex">>,<<"lib/pointable.ex">>,
41 + <<"lib/table.ex">>,<<"lib/changesets.ex">>,<<"lib/form.ex">>,
42 + <<"lib/class.ex">>,<<"lib/pointable.ex">>,<<"lib/migration">>,
43 + <<"lib/migration/migration.ex">>,<<"lib/migration/indexable.ex">>,
38 44 <<"lib/queries.ex">>,<<"lib/tables.ex">>,<<".formatter.exs">>,<<"mix.exs">>,
39 45 <<"README.md">>,<<"LICENSE">>]}.
40 46 {<<"build_tools">>,[<<"mix">>]}.
  @@ -1,7 +1,7 @@
1 1 defmodule Needle.Changesets do
2 2 require Logger
3 3
4 - alias Needle.{ULID, Util}
4 + alias Needle.{UID, Util}
5 5 alias Ecto.Association.{BelongsTo, Has, NotLoaded}
6 6 alias Ecto.{Changeset, Schema.Metadata}
7 7
  @@ -57,26 +57,26 @@ defmodule Needle.Changesets do
57 57 else
58 58 changeset
59 59 |> Changeset.cast(params, cols)
60 - |> put_new_id()
60 + |> put_new_id(schema)
61 61 end
62 62
63 63 %schema{__meta__: %{state: :built}} ->
64 64 if Util.role(schema) in [:pointable, :virtual] do
65 65 changeset
66 66 |> Changeset.cast(params, cols)
67 - |> put_new_id()
67 + |> put_new_id(schema)
68 68 else
69 69 Changeset.cast(changeset, params, cols)
70 70 end
71 71 end
72 72 end
73 73
74 - def put_new_id(changeset) do
74 + def put_new_id(changeset, schema) do
75 75 if is_binary(get_field(changeset, :id)) do
76 76 changeset
77 77 else
78 78 changeset
79 - |> Changeset.put_change(:id, ULID.generate())
79 + |> Changeset.put_change(:id, UID.generate(schema))
80 80 end
81 81 end
82 82
  @@ -188,7 +188,8 @@ defmodule Needle.Changesets do
188 188 nil ->
189 189 if Util.role(assoc.related) && assoc.related_key == :id do
190 190 # Autogenerate the id for them and copy it back
191 - rel = Map.put(rel, assoc.related_key, ULID.generate())
191 + #  FIXME: what schema to generate for here?
192 + rel = Map.put(rel, assoc.related_key, UID.generate())
192 193
193 194 changeset
194 195 |> Changeset.put_assoc(assoc_key, rel)
  @@ -406,14 +407,6 @@ defmodule Needle.Changesets do
406 407 defp merge_child_errors({_k, %Changeset{} = cs}, acc), do: cs.errors ++ acc
407 408 defp merge_child_errors(_, acc), do: acc
408 409
409 - @doc false
410 - def default_id(changeset) do
411 - case get_field(changeset, :id) do
412 - id when is_binary(id) -> changeset
413 - _ -> Changeset.put_change(changeset, :id, ULID.generate())
414 - end
415 - end
416 -
417 410 @doc false
418 411 def replicate_map_change(changeset, source_key, target_key, xform) do
419 412 case Changeset.fetch_change(changeset, source_key) do
  @@ -4,7 +4,7 @@ defmodule Needle.Form do
4 4 """
5 5
6 6 # alias Ecto.Changeset
7 - alias Needle.{ULID, Util}
7 + alias Needle.{UID, Util}
8 8
9 9 defmacro __using__(options), do: using(__CALLER__.module, options)
10 10
  @@ -35,7 +35,7 @@ defmodule Needle.Form do
35 35 schema_check_attr(Module.get_attribute(module, __MODULE__), module, body)
36 36 end
37 37
38 - @foreign_key_type ULID
38 + @foreign_key_type UID
39 39
40 40 defp schema_check_attr(options, module, body) when is_list(options) do
41 41 otp_app = Util.get_otp_app(options)
  @@ -1,597 +0,0 @@
1 - defmodule Needle.Migration do
2 - @moduledoc "Helpers for writing Pointer-aware migrations."
3 -
4 - import Ecto.Query, only: [from: 2]
5 - import Ecto.Migration
6 - alias Needle.{Pointer, Table, ULID}
7 -
8 - defdelegate init_pointers_ulid_extra(), to: ULID.Migration
9 -
10 - @type pointer_type :: :strong | :weak | :unbreakable
11 -
12 - @doc "Creates a strong, weak or unbreakable pointer depending on `type`."
13 - @spec pointer(type :: pointer_type) :: term
14 - @spec pointer(module :: atom, type :: pointer_type) :: term
15 - def pointer(table \\ Pointer, type)
16 - def pointer(table, :strong), do: strong_pointer(table)
17 - def pointer(table, :weak), do: weak_pointer(table)
18 - def pointer(table, :unbreakable), do: unbreakable_pointer(table)
19 -
20 - @doc """
21 - A reference to a pointer for use with `add/3`. A strong pointer will
22 - be deleted when the thing it's pointing to is deleted.
23 - """
24 - def strong_pointer(table \\ Pointer) do
25 - references(table.__schema__(:source),
26 - type: :uuid,
27 - on_update: :update_all,
28 - on_delete: :delete_all
29 - )
30 - end
31 -
32 - @doc """
33 - A reference to a pointer for use with `add/3`. A weak pointer will
34 - be set null when the thing it's pointing to is deleted.
35 - """
36 - def weak_pointer(table \\ Pointer) do
37 - references(table.__schema__(:source),
38 - type: :uuid,
39 - on_update: :update_all,
40 - on_delete: :nilify_all
41 - )
42 - end
43 -
44 - @doc """
45 - A reference to a pointer for use with `add/3`. An unbreakable
46 - pointer will prevent the thing it's pointing to from being deleted.
47 - """
48 - def unbreakable_pointer(table \\ Pointer) do
49 - references(table.__schema__(:source),
50 - type: :uuid,
51 - on_update: :update_all,
52 - on_delete: :restrict
53 - )
54 - end
55 -
56 - defp table_name(name) when is_atom(name), do: Atom.to_string(name)
57 - defp table_name(name) when is_binary(name), do: name
58 -
59 - @doc false
60 - @spec add_pointer_pk() :: nil
61 - def add_pointer_pk(), do: add(:id, :uuid, primary_key: true)
62 -
63 - @doc false
64 - @spec add_pointer_ref_pk() :: nil
65 - def add_pointer_ref_pk(),
66 - do: add(:id, strong_pointer(Pointer), primary_key: true)
67 -
68 - @doc "Creates a pointable table along with its trigger."
69 - @spec create_pointable_table(schema :: atom, body :: term) :: term
70 - @spec create_pointable_table(
71 - schema :: atom,
72 - opts :: Keyword.t(),
73 - body :: term
74 - ) :: term
75 - @spec create_pointable_table(source :: binary, id :: binary, body :: term) ::
76 - term
77 - @spec create_pointable_table(
78 - source :: binary,
79 - id :: binary,
80 - opts :: Keyword.t(),
81 - body :: term
82 - ) :: term
83 -
84 - # if you're wondering why we expand these, it's so aliases are expanded and turned into atoms
85 - defmacro create_pointable_table(a, b) do
86 - {a, _} = eval_expand(a, __CALLER__)
87 - cpt(a, b)
88 - end
89 -
90 - defmacro create_pointable_table(a, b, c) do
91 - {a, _} = eval_expand(a, __CALLER__)
92 - {b, _} = eval_expand(b, __CALLER__)
93 - cpt(a, b, c)
94 - end
95 -
96 - defmacro create_pointable_table(a, b, c, d) do
97 - {a, _} = eval_expand(a, __CALLER__)
98 - {b, _} = eval_expand(b, __CALLER__)
99 - {c, _} = eval_expand(c, __CALLER__)
100 - cpt(a, b, c, d)
101 - end
102 -
103 - defp cpt(schema, body) when is_atom(schema) do
104 - cpt(schema.__schema__(:source), schema.__pointers__(:table_id), [], body)
105 - end
106 -
107 - defp cpt(schema, opts, body) when is_atom(schema) and is_list(opts) do
108 - cpt(schema.__schema__(:source), schema.__pointers__(:table_id), opts, body)
109 - end
110 -
111 - defp cpt(source, id, body) when is_binary(source) and is_binary(id) do
112 - cpt(source, id, [], body)
113 - end
114 -
115 - defp cpt(source, id, opts, body)
116 - when is_binary(source) and is_binary(id) and is_list(opts) do
117 - Needle.ULID.cast!(id)
118 - opts = [primary_key: false] ++ opts
119 -
120 - quote do
121 - Needle.Migration.insert_table_record(unquote(id), unquote(source))
122 - table = Ecto.Migration.table(unquote(source), unquote(opts))
123 -
124 - Ecto.Migration.create_if_not_exists table do
125 - Needle.Migration.add_pointer_pk()
126 - unquote(body)
127 - end
128 -
129 - Needle.Migration.create_pointable_triggers(unquote(id), unquote(source))
130 - end
131 - end
132 -
133 - def create_virtual(schema) when is_atom(schema) do
134 - create_virtual(schema.__schema__(:source), schema.__pointers__(:table_id))
135 - end
136 -
137 - def create_virtual(source, id) when is_binary(source) and is_binary(id) do
138 - {:ok, _} = Needle.ULID.dump(Needle.ULID.cast!(id))
139 - insert_table_record(id, source)
140 - create_virtual_view(source, id)
141 - create_virtual_trigger(source, id)
142 - end
143 -
144 - @doc "Drops a pointable table"
145 - @spec drop_pointable_table(schema :: atom) :: nil
146 - @spec drop_pointable_table(name :: binary, id :: binary) :: nil
147 - def drop_pointable_table(schema) when is_atom(schema) do
148 - drop_pointable_table(
149 - schema.__schema__(:source),
150 - schema.__pointers__(:table_id)
151 - )
152 - end
153 -
154 - def drop_pointable_table(name, id) when is_binary(name) and is_binary(id) do
155 - Needle.ULID.cast!(id)
156 - drop_pointable_triggers(name)
157 - drop_table(name)
158 - delete_table_record(id)
159 - end
160 -
161 - def drop_virtual(schema) when is_atom(schema) do
162 - drop_virtual(schema.__schema__(:source), schema.__pointers__(:table_id))
163 - end
164 -
165 - def drop_virtual(name, id) when is_binary(name) and is_binary(id) do
166 - Needle.ULID.cast!(id)
167 - drop_virtual_trigger(name)
168 - drop_virtual_view(name)
169 - delete_table_record(id)
170 - end
171 -
172 - def migrate_virtual(schema), do: migrate_virtual(direction(), schema)
173 - def migrate_virtual(:up, schema), do: create_virtual(schema)
174 - def migrate_virtual(:down, schema), do: drop_virtual(schema)
175 -
176 - def migrate_virtual(name, id) when is_binary(name) and is_binary(id),
177 - do: migrate_virtual(direction(), name, id)
178 -
179 - def migrate_virtual(:up, name, id), do: create_virtual(name, id)
180 - def migrate_virtual(:down, name, id), do: drop_virtual(name, id)
181 -
182 - @doc "Creates a mixin table - one with a ULID primary key and no trigger"
183 - @spec create_mixin_table(name :: atom | binary, opts :: list, body :: term) ::
184 - nil
185 - defmacro create_mixin_table(name, opts \\ [], body) do
186 - {name, _} = eval_expand(name, __CALLER__)
187 -
188 - name =
189 - cond do
190 - is_binary(name) ->
191 - name
192 -
193 - is_atom(name) ->
194 - if Code.ensure_loaded?(name),
195 - do: name.__schema__(:source),
196 - else: Atom.to_string(name)
197 - end
198 -
199 - opts = [primary_key: false] ++ List.wrap(opts)
200 -
201 - quote do
202 - name = unquote(name)
203 - table = Ecto.Migration.table(name, unquote(opts))
204 -
205 - Ecto.Migration.create_if_not_exists table do
206 - Needle.Migration.add_pointer_ref_pk()
207 -
208 - unquote(body)
209 - end
210 -
211 - # execute """
212 - # ALTER TABLE #{name} add constraint pointer_is_not_deleted check (is_not_deleted(id))
213 - # """
214 - end
215 - end
216 -
217 - @doc "Drops a mixin table."
218 - @spec drop_mixin_table(name :: atom | binary) :: nil
219 - def drop_mixin_table(name), do: drop_table(name)
220 -
221 - @doc "Creates a random table - one with a UUID v4 primary key."
222 - defmacro create_random_table(name, opts \\ [], body) do
223 - {name, _} = eval_expand(name, __CALLER__)
224 -
225 - name =
226 - cond do
227 - is_binary(name) ->
228 - name
229 -
230 - is_atom(name) ->
231 - if Code.ensure_loaded?(name),
232 - do: name.__schema__(:source),
233 - else: Atom.to_string(name)
234 - end
235 -
236 - opts = [primary_key: false] ++ List.wrap(opts)
237 -
238 - quote do
239 - table = Ecto.Migration.table(unquote(name), unquote(opts))
240 -
241 - Ecto.Migration.create_if_not_exists table do
242 - add(:id, :uuid, primary_key: true)
243 - unquote(body)
244 - end
245 - end
246 - end
247 -
248 - @doc "Drops a random table."
249 - @spec drop_random_table(name :: atom | binary) :: nil
250 - def drop_random_table(name), do: drop_table(name)
251 -
252 - @doc """
253 - When migrating up: initialises the pointers database.
254 - When migrating down: deinitialises the pointers database.
255 - """
256 - @spec init_pointers() :: nil
257 - def init_pointers(), do: init_pointers(direction())
258 -
259 - @doc """
260 - Given `:up`: initialises the pointers database.
261 - Given `:down`: deinitialises the pointers database.
262 - """
263 - @spec init_pointers(direction :: :up | :down) :: nil
264 - def init_pointers(:up) do
265 - pointer = Pointer.__schema__(:source)
266 - table = Table.__schema__(:source)
267 -
268 - create_if_not_exists table(table, primary_key: false) do
269 - add_pointer_pk()
270 - add(:table, :text)
271 - end
272 -
273 - create_if_not_exists table(pointer, primary_key: false) do
274 - add_pointer_pk()
275 -
276 - ref =
277 - references(table,
278 - on_delete: :delete_all,
279 - on_update: :update_all,
280 - type: :uuid
281 - )
282 -
283 - add(:table_id, ref, null: false)
284 - add(:deleted_at, :timestamptz, null: true)
285 - end
286 -
287 - create_if_not_exists(unique_index(table, :table))
288 - create_if_not_exists(index(pointer, :table_id))
289 - flush()
290 - add_is_not_deleted(pointer)
291 - create_pointers_trigger_function()
292 - create_pointable_trigger_function()
293 - create_virtual_trigger_function()
294 - flush()
295 - insert_table_record(Table.__pointers__(:table_id), table)
296 - end
297 -
298 - def init_pointers(:down) do
299 - pointer = Pointer.__schema__(:source)
300 - table = Table.__schema__(:source)
301 - drop_pointers_trigger_function()
302 - drop_pointable_trigger_function()
303 - drop_virtual_trigger_function()
304 - drop_if_exists(index(pointer, :table_id))
305 - drop_if_exists(index(table, :table))
306 - drop_table(pointer)
307 - drop_table(table)
308 - end
309 -
310 - def add_is_not_deleted(table) do
311 - execute("""
312 - create or replace function is_not_deleted(uuid) returns boolean as $$
313 - select exists (
314 - select 1
315 - from #{table}
316 - where id = $1
317 - and deleted_at IS NULL
318 - );
319 - $$ language sql;
320 - """)
321 - end
322 -
323 - @doc false
324 - def create_pointers_trigger_function() do
325 - fun = pointers_trigger_function()
326 -
327 - :ok =
328 - execute("""
329 - create or replace function "#{fun}"() returns trigger as $$
330 - declare query text;
331 - begin
332 - if (TG_OP = 'UPDATE') then
333 - if (OLD.deleted_at is null and NEW.deleted_at is not null) then
334 - select 'delete from "' || TG_ARGV[0] || '" where id = ''' | OLD.id | ''' :: uuid' into query;
335 - execute query;
336 - end if;
337 - return NEW;
338 - elsif (TG_OP = 'DELETE') then
339 - if (OLD.deleted_at is null) then
340 - select 'delete from "' || TG_ARGV[0] || '" where id = ''' | OLD.id | ''' :: uuid' into query;
341 - execute query;
342 - end if;
343 - return OLD;
344 - else
345 - return NEW;
346 - end if;
347 - end;
348 - $$ language plpgsql
349 - """)
350 - end
351 -
352 - @doc false
353 - def create_pointable_trigger_function() do
354 - fun = pointable_trigger_function()
355 - pointer = Pointer.__schema__(:source)
356 - table = Table.__schema__(:source)
357 -
358 - :ok =
359 - execute("""
360 - create or replace function "#{fun}"() returns trigger as $$
361 - declare table_id uuid;
362 - begin
363 - if (TG_OP = 'INSERT') then
364 - if (TG_NARGS = 1) then
365 - select TG_ARGV[0] :: uuid into table_id;
366 - else
367 - select id into table_id from "#{table}"
368 - where "table" = TG_TABLE_NAME;
369 - end if;
370 - if table_id is null then
371 - raise exception 'Table % is not pointable', TG_TABLE_NAME;
372 - end if;
373 - insert into "#{pointer}" (id, table_id) values (NEW.id, table_id)
374 - on conflict do nothing;
375 - return NEW;
376 - elsif (TG_OP = 'DELETE') then
377 - update "#{pointer}" set deleted_at = now() at time zone 'utc' where id = OLD.id;
378 - return OLD;
379 - else
380 - raise exception 'operation: %', TG_OP;
381 - end if;
382 - end;
383 - $$ language plpgsql
384 - """)
385 - end
386 -
387 - def create_virtual_trigger_function() do
388 - fun = virtual_trigger_function()
389 - pointer = Pointer.__schema__(:source)
390 -
391 - :ok =
392 - execute("""
393 - create or replace function "#{fun}"() returns trigger as $$
394 - begin
395 - if (TG_OP = 'INSERT') then
396 - insert into "#{pointer}" (id, table_id) values (NEW.id, TG_ARGV[0] :: uuid)
397 - on conflict do nothing;
398 - return NEW;
399 - elsif (TG_OP = 'DELETE') then
400 - update "#{pointer}" set deleted_at = now() at time zone 'utc' where id = OLD.id;
401 - return OLD;
402 - else
403 - raise exception 'operation: %', TG_OP;
404 - end if;
405 - end;
406 - $$ language plpgsql
407 - """)
408 - end
409 -
410 - @doc false
411 - def drop_pointers_trigger_function() do
412 - execute(~s[drop function if exists "#{pointers_trigger_function()}"() cascade])
413 - end
414 -
415 - @doc false
416 - def drop_pointable_trigger_function() do
417 - execute(~s[drop function if exists "#{pointable_trigger_function()}"() cascade])
418 - end
419 -
420 - @doc false
421 - def drop_virtual_trigger_function() do
422 - execute(~s[drop function if exists "#{virtual_trigger_function()}"() cascade])
423 - end
424 -
425 - @doc false
426 - def create_pointable_triggers(table_id, source) do
427 - {:ok, table_id} = Needle.ULID.dump(Needle.ULID.cast!(table_id))
428 - pointer = Pointer.__schema__(:source)
429 - table_id = Ecto.UUID.cast!(table_id)
430 - # because there is no create trigger if not exists
431 - drop_pointable_triggers(source)
432 -
433 - # after inserting into the pointable, a shadow record should be created in the pointable table
434 - # Note: `pg_trigger_depth()` is used to stop the triggers from issuing a single extra delete
435 - # (that would fail) when called from the other triggers installed by this function
436 - execute("""
437 - create trigger "#{source}_insert_trigger"
438 - before insert on "#{source}"
439 - for each row when (pg_trigger_depth() < 1)
440 - execute procedure "#{pointable_trigger_function()}"('#{table_id}')
441 - """)
442 -
443 - # after deleting from the pointable table, the pointer should be marked deleted
444 - execute("""
445 - create trigger "#{source}_delete_trigger"
446 - after delete on "#{source}"
447 - for each row when (pg_trigger_depth() < 1)
448 - execute procedure "#{pointable_trigger_function()}"()
449 - """)
450 -
451 - # after marking a pointer deleted, the shadow record should be deleted
452 - execute("""
453 - create trigger "#{source}_soft_delete_trigger"
454 - after update on "#{pointer}"
455 - for each row
456 - when (
457 - pg_trigger_depth() < 1
458 - and OLD.deleted_at is null
459 - and NEW.deleted_at is not null
460 - and OLD.table_id = '#{table_id}' :: uuid
461 - )
462 - execute procedure "#{pointers_trigger_function()}"('#{source}')
463 - """)
464 -
465 - # after deleting from pointers, the shadow record should be
466 - # deleted if it wasn't already marked deleted
467 - execute("""
468 - create trigger "#{source}_delete_trigger"
469 - after delete on "#{pointer}"
470 - for each row
471 - when (
472 - pg_trigger_depth() < 1
473 - and OLD.deleted_at is null
474 - and OLD.table_id = '#{table_id}' :: uuid
475 - )
476 - execute procedure "#{pointers_trigger_function()}"('#{source}')
477 - """)
478 - end
479 -
480 - @doc false
481 -
482 - def drop_pointable_triggers(table) do
483 - pointers = Pointer.__schema__(:source)
484 - table = table_name(table)
485 - execute(~s[drop trigger if exists "#{table}_insert_trigger" on "#{table}"])
486 - execute(~s[drop trigger if exists "#{table}_delete_trigger" on "#{table}"])
487 -
488 - execute(~s[drop trigger if exists "#{table}_soft_delete_trigger" on "#{pointers}"])
489 -
490 - execute(~s[drop trigger if exists "#{table}_delete_trigger" on "#{pointers}"])
491 - end
492 -
493 - @doc false
494 - def create_virtual_trigger(table, id) do
495 - {:ok, id} = Needle.ULID.dump(Needle.ULID.cast!(id))
496 - id = Ecto.UUID.cast!(id)
497 - # because there is no create trigger if not exists
498 - drop_virtual_trigger(table)
499 -
500 - execute("""
501 - create trigger "#{table}_insert_delete_trigger"
502 - instead of insert or delete on "#{table}"
503 - for each row
504 - execute procedure "#{virtual_trigger_function()}"('#{id}')
505 - """)
506 - end
507 -
508 - @doc false
509 - def drop_virtual_trigger(table) do
510 - table = table_name(table)
511 -
512 - execute(~s[drop trigger if exists "#{table}_insert_delete_trigger" on "#{table}"])
513 - end
514 -
515 - @doc false
516 - def create_virtual_view(source, id) do
517 - {:ok, id} = Needle.ULID.dump(Needle.ULID.cast!(id))
518 - id = Ecto.UUID.cast!(id)
519 - pointers = Pointer.__schema__(:source)
520 -
521 - execute("""
522 - create or replace view "#{source}" as
523 - select id as id from "#{pointers}"
524 - where table_id = ('#{id}' :: uuid) and deleted_at is null
525 - """)
526 - end
527 -
528 - @doc false
529 - def drop_virtual_view(source),
530 - do: execute(~s[drop view if exists "#{source}"])
531 -
532 - @doc false
533 - def insert_table_record(schema) do
534 - insert_table_record(
535 - schema.__schema__(:source),
536 - schema.__pointers__(:table_id)
537 - )
538 - end
539 -
540 - # Insert a Table record. Not required when using `create_pointable_table`
541 - @doc false
542 - def insert_table_record(id, name) do
543 - {:ok, table_id} = Needle.ULID.dump(Table.__pointers__(:table_id))
544 - {:ok, id} = Needle.ULID.dump(Needle.ULID.cast!(id))
545 - table = table_name(name)
546 - opts = [on_conflict: :nothing]
547 -
548 - repo().insert_all(
549 - Table.__schema__(:source),
550 - [%{id: id, table: table}],
551 - opts
552 - )
553 -
554 - repo().insert_all(
555 - Pointer.__schema__(:source),
556 - [%{id: id, table_id: table_id}],
557 - opts
558 - )
559 - end
560 -
561 - # Delete a Table record. Not required when using `drop_pointable_table`
562 - @doc false
563 - def delete_table_record(id) do
564 - {:ok, id} = Needle.ULID.dump(Needle.ULID.cast!(id))
565 - table = Table.__schema__(:source)
566 - repo().delete_all(from(t in table, where: t.id == ^id))
567 - end
568 -
569 - def drop_table(name) do
570 - name =
571 - cond do
572 - is_binary(name) -> name
573 - is_atom(name) -> name.__schema__(:source)
574 - end
575 -
576 - execute(~s[drop table if exists "#{name}" cascade])
577 - end
578 -
579 - defp eval(quoted, env), do: Code.eval_quoted(quoted, [], env)
580 -
581 - defp eval_expand(quoted, env), do: expand_alias(eval(quoted, env), env)
582 -
583 - defp expand_alias({:__aliases__, _, _} = ast, env), do: Macro.expand(ast, env)
584 - defp expand_alias(ast, _env), do: ast
585 -
586 - defp pointers_trigger_function(),
587 - do: config(:pointers_trigger_function, "pointers_pointers_trigger")
588 -
589 - defp pointable_trigger_function(),
590 - do: config(:pointable_trigger_function, "pointers_pointable_trigger")
591 -
592 - defp virtual_trigger_function(),
593 - do: config(:virtual_trigger_function, "pointers_virtual_trigger")
594 -
595 - defp config(key, default),
596 - do: Keyword.get(Application.get_env(:needle, __MODULE__, []), key, default)
597 - end
Loading more files…