├── .formatter.exs ├── .gitignore ├── README.md ├── lib ├── ecto_entity.ex └── ecto_entity │ ├── entity.ex │ ├── store.ex │ ├── store │ ├── json.ex │ └── storage.ex │ └── type.ex ├── mix.exs ├── mix.lock ├── run_db_tests └── test ├── ecto_entity ├── entity_test.exs ├── mysql_test.exs ├── postgres_test.exs ├── sqlite_test.exs ├── store_test.exs └── type_test.exs ├── support └── test_repo.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ecto_entity-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoEntity 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `ecto_entity` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:ecto_entity, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at [https://hexdocs.pm/ecto_entity](https://hexdocs.pm/ecto_entity). 21 | 22 | -------------------------------------------------------------------------------- /lib/ecto_entity.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity do 2 | @moduledoc """ 3 | 4 | """ 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_entity/entity.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.Entity do 2 | alias EctoEntity.Type 3 | import Ecto.Query, only: [from: 1] 4 | 5 | defimpl Ecto.Queryable, for: Type do 6 | def to_query(%Type{source: source}) do 7 | from(source) 8 | end 9 | end 10 | 11 | @doc """ 12 | Turn a raw map from the database into a map typed according to the definition 13 | in the way that Ecto usually does it with schemas. 14 | """ 15 | def load(%Type{ephemeral: %{store: store}} = definition, data) do 16 | # Based off of Ecto.Repo.Schema.load/3 17 | %{config: %{repo: %{module: repo}}} = store 18 | loader = &Ecto.Type.adapter_load(repo.__adapter__, &1, &2) 19 | data = Enum.into(data, %{}) 20 | 21 | Enum.reduce(definition.fields, %{}, fn {field, field_opts}, acc -> 22 | %{field_type: type} = field_opts 23 | 24 | case Map.fetch(data, field) do 25 | {:ok, value} -> Map.put(acc, field, load!(definition, field, type, value, loader)) 26 | :error -> acc 27 | end 28 | end) 29 | end 30 | 31 | defp load!(definition, field, type, value, loader) do 32 | # Types are known atom names from ecto 33 | type = String.to_existing_atom(type) 34 | 35 | case loader.(type, value) do 36 | {:ok, value} -> 37 | value 38 | 39 | :error -> 40 | raise ArgumentError, 41 | "cannot load `#{inspect(value)}` as type #{inspect(type)} " <> 42 | "for field `#{field}`#{definition.source}" 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ecto_entity/store.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.Store do 2 | alias EctoEntity.Type 3 | 4 | defstruct config: nil 5 | 6 | defmodule Error do 7 | defexception [:message] 8 | end 9 | 10 | @type config :: %{ 11 | type_storage: %{ 12 | module: atom(), 13 | settings: any() 14 | }, 15 | repo: %{ 16 | module: atom(), 17 | dynamic: pid() | nil 18 | } 19 | } 20 | 21 | @type t :: %__MODULE__{ 22 | config: config() 23 | } 24 | 25 | alias EctoEntity.Store 26 | 27 | @spec init(config :: config) :: t() 28 | def init(config) when is_map(config) do 29 | config = 30 | config 31 | |> validate_config!() 32 | |> config_defaults() 33 | 34 | %__MODULE__{config: config} 35 | end 36 | 37 | @spec init(config :: keyword) :: t() 38 | def init(config) when is_list(config) do 39 | if not Keyword.keyword?(config) do 40 | raise Error, message: "Expected map or keyword, got list." 41 | end 42 | 43 | config 44 | |> Map.new() 45 | |> init() 46 | end 47 | 48 | def list(%Type{ephemeral: %{store: store}} = definition) when not is_nil(store) do 49 | repo = set_dynamic(store) 50 | 51 | source = cleanse_source(definition) 52 | 53 | case Ecto.Adapters.SQL.query(repo, "select * from #{source}", []) do 54 | {:ok, result} -> 55 | result_to_items(definition, result) 56 | 57 | {:error, _} -> 58 | # Would be nice to add query info and stuff to this error 59 | raise Ecto.QueryError 60 | end 61 | end 62 | 63 | def list(%Store{} = store, %Type{} = definition) do 64 | definition 65 | |> set_type_store(store) 66 | |> list() 67 | end 68 | 69 | 70 | def insert(%Type{ephemeral: %{store: store}} = definition, entity) when not is_nil(store) do 71 | repo = set_dynamic(store) 72 | new_id = new_id_for_type(definition) 73 | 74 | source = cleanse_source(definition) 75 | 76 | columns = 77 | entity 78 | |> Enum.sort() 79 | |> Enum.map(fn {key, _value} -> 80 | cleanse_field_name(key) 81 | end) 82 | 83 | columns = 84 | if new_id do 85 | ["id" | columns] 86 | else 87 | columns 88 | end 89 | 90 | values = 91 | entity 92 | |> Enum.sort() 93 | |> Enum.map(fn {_key, value} -> 94 | value 95 | end) 96 | 97 | values = 98 | if new_id do 99 | [new_id | values] 100 | else 101 | values 102 | end 103 | 104 | connection_module = get_connection(store) 105 | 106 | prefix = nil 107 | sql = connection_module.insert(prefix, source, columns, [values], {:raise, nil, []}, [], []) 108 | 109 | case Ecto.Adapters.SQL.query( 110 | repo, 111 | sql, 112 | values 113 | ) do 114 | {:ok, _result} -> 115 | # This could be done with RETURNING *, but that doesn't work in MySQL 116 | #[item] = result_to_items(definition, result) 117 | {:ok, new_id} 118 | 119 | {:error, _} = err -> 120 | err 121 | end 122 | end 123 | 124 | def update(%Type{ephemeral: %{store: store}} = definition, %{"id" => id} = _entity, updates_kv) 125 | when not is_nil(store) do 126 | updates = Enum.into(updates_kv, %{}) 127 | fields = Map.keys(updates) 128 | values = Map.values(updates) 129 | 130 | repo = set_dynamic(store) 131 | 132 | source = cleanse_source(definition) 133 | 134 | connection_module = get_connection(store) 135 | 136 | prefix = nil 137 | sql = connection_module.update(prefix, source, fields, [id: id], []) 138 | 139 | case Ecto.Adapters.SQL.query( 140 | repo, 141 | sql, 142 | values ++ [id] 143 | ) do 144 | {:ok, _new_id} -> 145 | # This could be done with RETURNING *, but that doesn't work in MySQL 146 | #[item] = result_to_items(definition, result) 147 | {:ok, nil} 148 | 149 | {:error, _} = err -> 150 | err 151 | end 152 | end 153 | 154 | def delete(%Type{ephemeral: %{store: store}} = definition, %{"id" => id} = _entity) 155 | when not is_nil(store) do 156 | repo = set_dynamic(store) 157 | source = cleanse_source(definition) 158 | 159 | connection_module = get_connection(store) 160 | 161 | prefix = nil 162 | sql = connection_module.delete(prefix, source, [id: id], []) 163 | 164 | case Ecto.Adapters.SQL.query( 165 | repo, 166 | sql, 167 | [id] 168 | ) do 169 | {:ok, _result} -> 170 | {:ok, nil} 171 | 172 | {:error, _} = err -> 173 | err 174 | end 175 | end 176 | 177 | def get_type(store, source) do 178 | {module, settings} = get_storage(store) 179 | 180 | case apply(module, :get_type, [settings, source]) do 181 | {:ok, definition} -> 182 | definition = set_type_store(definition, store) 183 | {:ok, definition} 184 | 185 | {:error, _} = err -> 186 | err 187 | end 188 | end 189 | 190 | def put_type(store, %{source: source} = definition) do 191 | {module, settings} = get_storage(store) 192 | 193 | case apply(module, :put_type, [settings, definition]) do 194 | :ok -> 195 | get_type(store, source) 196 | 197 | err -> 198 | err 199 | end 200 | end 201 | 202 | def remove_type(store, source) do 203 | {module, settings} = get_storage(store) 204 | apply(module, :remove_type, [settings, source]) 205 | end 206 | 207 | def set_type_store(%{ephemeral: ephemeral} = definition, store) do 208 | %{definition | ephemeral: Map.put(ephemeral, :store, store)} 209 | end 210 | 211 | def list_types(store) do 212 | {module, settings} = get_storage(store) 213 | apply(module, :list_types, [settings]) 214 | end 215 | 216 | def migration_status(_store, _definition) do 217 | # TODO: Lots of questions-marks 218 | raise "Not implemented" 219 | end 220 | 221 | def migrate(_store, _definition) do 222 | # TODO: Implement 223 | raise "Not implemented" 224 | end 225 | 226 | def remove_all_data(%Type{ephemeral: %{store: store}} = definition) when not is_nil(store) do 227 | repo = set_dynamic(store) 228 | source = cleanse_source(definition) 229 | 230 | case Ecto.Adapters.SQL.query( 231 | repo, 232 | "delete from #{source}", 233 | [] 234 | ) do 235 | {:ok, _} -> 236 | {:ok, nil} 237 | 238 | {:error, _} = err -> 239 | err 240 | end 241 | end 242 | 243 | def drop_table(%Type{ephemeral: %{store: store}} = definition) when not is_nil(store) do 244 | repo = set_dynamic(store) 245 | source = cleanse_source(definition) 246 | 247 | case Ecto.Adapters.SQL.query( 248 | repo, 249 | "drop table #{source}", 250 | [] 251 | ) do 252 | {:ok, _} -> 253 | :ok 254 | 255 | {:error, _} = err -> 256 | err 257 | end 258 | end 259 | 260 | defp get_storage(store) do 261 | %{config: %{type_storage: %{module: module, settings: settings}}} = store 262 | {module, settings} 263 | end 264 | 265 | defp validate_config!(config) do 266 | %{ 267 | type_storage: %{ 268 | module: _, 269 | settings: _ 270 | }, 271 | repo: %{ 272 | module: _ 273 | } 274 | } = config 275 | 276 | config 277 | end 278 | 279 | @config_defaults %{ 280 | [:repo, :dynamic] => false 281 | } 282 | defp config_defaults(config) do 283 | Enum.reduce(@config_defaults, config, fn {path, default_value}, config -> 284 | case get_in(config, path) do 285 | nil -> put_in(config, path, default_value) 286 | _ -> config 287 | end 288 | end) 289 | end 290 | 291 | defp result_to_items(definition, result) do 292 | %{columns: columns, rows: rows} = result 293 | 294 | Enum.map(rows, fn row -> 295 | item = 296 | columns 297 | |> Enum.zip(row) 298 | 299 | EctoEntity.Entity.load(definition, item) 300 | end) 301 | end 302 | 303 | defp set_dynamic(store) do 304 | %{config: %{repo: %{module: repo_module, dynamic: dynamic}}} = store 305 | 306 | if not is_nil(dynamic) and dynamic do 307 | repo_module.put_dynamic_repo(dynamic) 308 | dynamic 309 | else 310 | repo_module 311 | end 312 | end 313 | 314 | defp cleanse_source(%{source: source}) do 315 | # Strip out all expect A-Z a-z 0-9 - _ 316 | Regex.replace(~r/[^A-Za-z0-9-_]/, source, "") 317 | end 318 | 319 | defp cleanse_field_name(field) do 320 | # Strip out all expect A-Z a-z 0-9 - _ 321 | # SQL does allow pretty much anything, we don't. 322 | Regex.replace(~r/[^A-Za-z0-9-_]/, field, "") 323 | end 324 | 325 | defp new_id_for_type(definition) do 326 | # TODO: Currently assumes single primary key 327 | {_field_name, field_options} = 328 | Enum.find(definition.fields, fn {_key, field_opts} -> 329 | get_in(field_opts, [:persistence_options, :primary_key]) || false 330 | end) 331 | 332 | case field_options.storage_type do 333 | "id" -> nil 334 | "string" -> UUID.uuid1() 335 | end 336 | end 337 | 338 | defp get_connection(store) do 339 | Module.concat(store.config.repo.module.__adapter__(), Connection) 340 | end 341 | end 342 | -------------------------------------------------------------------------------- /lib/ecto_entity/store/json.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.Store.SimpleJson do 2 | @behaviour EctoEntity.Store.Storage 3 | import EctoEntity.Store.Storage, only: [error: 2] 4 | require Logger 5 | 6 | alias EctoEntity.Type 7 | 8 | @assorted_file_errors [ 9 | :eacces, 10 | :eisdir, 11 | :enotdir, 12 | :enomem 13 | ] 14 | 15 | @impl true 16 | def get_type(%{directory_path: path}, source) do 17 | File.mkdir_p!(path) 18 | 19 | filepath = Path.join(path, "#{source}.json") 20 | 21 | with {:ok, json} <- File.read(filepath), 22 | {:ok, decoded} <- Jason.decode(json), 23 | {:ok, type} <- Type.from_persistable(decoded) do 24 | {:ok, type} 25 | else 26 | {:error, :enoent} -> 27 | {:error, error(:not_found, "File not found")} 28 | 29 | {:error, err} when err in @assorted_file_errors -> 30 | {:error, ferror(err)} 31 | 32 | {:error, %Jason.DecodeError{} = err} -> 33 | Logger.error("JSON Decoding error: #{inspect(err)}", error: err) 34 | {:error, error(:decoding_failed, "JSON decoding failed.")} 35 | 36 | {:error, :bad_type} -> 37 | {:error, error(:normalize_definition_failed, "Failed to normalize definition.")} 38 | 39 | {:error, _} -> 40 | {:error, error(:unknown, "Unknown error")} 41 | end 42 | end 43 | 44 | @impl true 45 | def put_type(%{directory_path: path}, definition) do 46 | try do 47 | File.mkdir_p!(path) 48 | 49 | data = 50 | definition 51 | |> Type.to_persistable() 52 | |> Jason.encode!() 53 | 54 | path 55 | |> Path.join("#{definition.source}.json") 56 | |> File.write!(data) 57 | catch 58 | _ -> {:error, :unknown} 59 | end 60 | 61 | :ok 62 | end 63 | 64 | @impl true 65 | def remove_type(%{directory_path: path}, source) do 66 | File.mkdir_p!(path) 67 | 68 | filepath = Path.join(path, "#{source}.json") 69 | 70 | case File.rm(filepath) do 71 | :ok -> 72 | :ok 73 | 74 | {:error, :enoent} -> 75 | {:error, error(:not_found, "File not found")} 76 | 77 | {:error, err} when err in @assorted_file_errors -> 78 | {:error, ferror(err)} 79 | 80 | {:error, _} -> 81 | {:error, error(:unknown, "Unknown error")} 82 | end 83 | end 84 | 85 | @impl true 86 | def list_types(%{directory_path: path}) do 87 | File.mkdir_p!(path) 88 | 89 | File.ls!(path) 90 | |> Enum.filter(fn filename -> 91 | String.ends_with?(filename, ".json") 92 | end) 93 | |> Enum.map(fn filename -> 94 | Path.basename(filename, ".json") 95 | end) 96 | end 97 | 98 | defp ferror(err) do 99 | error(:reading_failed, "File reading error: #{inspect(:file.format_error(err))}") 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/ecto_entity/store/storage.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.Store.Storage do 2 | defmodule Error do 3 | defexception [:type, :message] 4 | 5 | @type t :: %__MODULE__{ 6 | type: 7 | :not_found 8 | | :reading_failed 9 | | :decoding_failed 10 | | :normalize_definition_failed 11 | | :unknown, 12 | message: binary() 13 | } 14 | end 15 | 16 | @spec error( 17 | type :: 18 | :not_found 19 | | :reading_failed 20 | | :decoding_failed 21 | | :normalize_definition_failed 22 | | :unknown, 23 | message :: binary() 24 | ) :: Error.t() 25 | def error(type, message) do 26 | %Error{type: type, message: message} 27 | end 28 | 29 | @callback get_type(settings :: map(), source :: binary()) :: EctoEntity.Type.t() | nil 30 | @callback put_type(settings :: map(), EctoEntity.Type.t()) :: :ok | {:error, any()} 31 | @callback remove_type(settings :: map(), source :: binary()) :: :ok | {:error, any()} 32 | @callback list_types(settings :: map()) :: list(EctoEntity.Type.t()) 33 | end 34 | -------------------------------------------------------------------------------- /lib/ecto_entity/type.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.Type do 2 | @moduledoc """ 3 | 4 | ## Examples 5 | 6 | Create a type, add defaults and a single field title field. 7 | 8 | iex> alias EctoEntity.Type 9 | ...> t = "posts" 10 | ...> |> Type.new("Post", "post", "posts") 11 | ...> |> Type.migration_defaults!(fn set -> 12 | ...> set 13 | ...> |> Type.add_field!("title", "string", "string", nullable: false) 14 | ...> end) 15 | ...> %{source: "posts", label: "Post", singular: "post", plural: "posts", 16 | ...> fields: %{ 17 | ...> "id" => _, 18 | ...> "title" => _, 19 | ...> "inserted_at" => _, 20 | ...> "updated_at" => _ 21 | ...> }} 22 | ...> |> match?(t) 23 | true 24 | 25 | """ 26 | 27 | require Logger 28 | 29 | defmodule Error do 30 | defexception [:message] 31 | end 32 | 33 | defstruct label: nil, 34 | source: nil, 35 | singular: nil, 36 | plural: nil, 37 | fields: %{}, 38 | changesets: %{}, 39 | migrations: %{}, 40 | ephemeral: %{} 41 | 42 | alias EctoEntity.Type 43 | 44 | @atom_keys [ 45 | # Base type 46 | "label", 47 | "source", 48 | "singular", 49 | "plural", 50 | "fields", 51 | "changesets", 52 | "migrations", 53 | "ephemeral", 54 | # field options 55 | "field_type", 56 | "storage_type", 57 | "persistence_options", 58 | "validation_options", 59 | "filters", 60 | "meta", 61 | # persistence options 62 | "primary_key", 63 | "nullable", 64 | "indexed", 65 | "unique", 66 | "default", 67 | # persistence changes 68 | "make_nullable", 69 | "add_index", 70 | "drop_index", 71 | "remove_uniqueness", 72 | "set_default", 73 | # validation options 74 | "required", 75 | "format", 76 | "number", 77 | "excluding", 78 | "including", 79 | "length", 80 | # filters 81 | "type", 82 | "args", 83 | # migration 84 | "id", 85 | "created_at", 86 | "last_migration_count", 87 | "set", 88 | # migration set items 89 | "identifier", 90 | "primary_type" 91 | ] 92 | 93 | @atomize_maps [ 94 | :changesets, 95 | :migrations, 96 | :persistence_options, 97 | :persistence_changes, 98 | :validation_options, 99 | :set 100 | ] 101 | 102 | @stringify_deep [ 103 | :meta 104 | ] 105 | 106 | @type field_name :: binary() 107 | @type field_type :: binary() | atom() 108 | 109 | # Could restrict to :create|:update|:import 110 | @type changeset_name :: atom() 111 | 112 | # Typically "cast", "validate_required" and friends 113 | @type changeset_op :: binary() 114 | 115 | @type migration_set_id :: binary() 116 | @type iso8601 :: binary() 117 | 118 | @type field_options :: 119 | %{ 120 | field_type: field_type(), 121 | storage_type: binary(), 122 | # Options enforced at the persistence layer, typically DB options 123 | persistence_options: %{ 124 | optional(:primary_key) => bool(), 125 | required(:nullable) => bool(), 126 | required(:indexed) => bool(), 127 | optional(:unique) => bool(), 128 | optional(:default) => any() 129 | }, 130 | # Options enforced at the validation step, equivalently to Ecto.Changeset 131 | validation_options: %{ 132 | required(:required) => bool(), 133 | optional(:format) => binary(), 134 | optional(:number) => %{optional(binary()) => number()}, 135 | optional(:excluding) => any(), 136 | optional(:including) => any(), 137 | optional(:length) => %{optional(binary()) => number()} 138 | }, 139 | # Filters, such as slugify and other potential transformation for the incoming data 140 | filters: [ 141 | %{ 142 | type: binary(), 143 | args: any() 144 | }, 145 | ... 146 | ], 147 | # For presentation layer metadata and such 148 | meta: %{ 149 | optional(binary()) => any() 150 | } 151 | } 152 | 153 | @type migration_set_item :: 154 | %{ 155 | # add_primary_key 156 | type: binary(), 157 | primary_type: binary() 158 | } 159 | | %{ 160 | # add_timestamps 161 | type: binary() 162 | } 163 | | %{ 164 | # add_field 165 | type: binary(), 166 | identifier: binary(), 167 | field_type: field_type(), 168 | storage_type: binary(), 169 | # Options enforced at the persistence layer, typically DB options 170 | persistence_options: %{ 171 | required(:nullable) => bool(), 172 | required(:indexed) => bool(), 173 | optional(:unique) => bool(), 174 | optional(:default) => any() 175 | }, 176 | # Options enforced at the validation step, equivalently to Ecto.Changeset 177 | validation_options: %{ 178 | required(:required) => bool(), 179 | optional(:format) => binary(), 180 | optional(:number) => %{optional(binary()) => number()}, 181 | optional(:excluding) => any(), 182 | optional(:including) => any(), 183 | optional(:length) => %{optional(binary()) => number()} 184 | }, 185 | # Filters, such as slugify and other potential transformation for the incoming data 186 | filters: [ 187 | %{ 188 | type: binary(), 189 | args: any() 190 | }, 191 | ... 192 | ], 193 | # For presentation layer metadata and such 194 | meta: %{ 195 | optional(binary()) => any() 196 | } 197 | } 198 | | %{ 199 | # alter_field 200 | required(:type) => binary(), 201 | required(:identifier) => binary(), 202 | # Options enforced at the persistence layer, typically DB options 203 | optional(:persistence_changes) => %{ 204 | optional(:make_nullable) => true, 205 | optional(:add_index) => true, 206 | optional(:drop_index) => true, 207 | optional(:remove_uniqueness) => true, 208 | optional(:set_default) => any() 209 | }, 210 | # Options enforced at the validation step, equivalently to Ecto.Changeset 211 | optional(:validation_options) => %{ 212 | optional(:required) => bool(), 213 | optional(:format) => binary(), 214 | optional(:number) => %{optional(binary()) => number()}, 215 | optional(:excluding) => any(), 216 | optional(:including) => any(), 217 | optional(:length) => %{optional(binary()) => number()} 218 | }, 219 | # Filters, such as slugify and other potential transformation for the incoming data 220 | optional(:filters) => [ 221 | %{ 222 | type: binary(), 223 | args: any() 224 | }, 225 | ... 226 | ], 227 | # For presentation layer metadata and such 228 | optional(:meta) => %{ 229 | optional(binary()) => any() 230 | } 231 | } 232 | 233 | @type changeset :: %{ 234 | operation: binary(), 235 | args: any() 236 | } 237 | 238 | @type migration :: %{ 239 | id: migration_set_id(), 240 | created_at: iso8601(), 241 | # Essentially a vector clock allowing migration code to detect conflicts 242 | last_migration_count: integer(), 243 | set: [migration_set_item(), ...] 244 | } 245 | 246 | @type t :: %Type{ 247 | # pretty name 248 | label: binary(), 249 | # slug, often used for table-name 250 | source: binary(), 251 | singular: binary(), 252 | plural: binary(), 253 | fields: %{ 254 | optional(field_name()) => field_options() 255 | }, 256 | changesets: %{ 257 | optional(changeset_name()) => [changeset()] 258 | }, 259 | migrations: [migration()] 260 | } 261 | 262 | @type map_t :: %{ 263 | # pretty name 264 | label: binary(), 265 | # slug, often used for table-name 266 | source: binary(), 267 | singular: binary(), 268 | plural: binary(), 269 | fields: %{ 270 | optional(field_name()) => field_options() 271 | }, 272 | changesets: %{ 273 | optional(changeset_name()) => [changeset()] 274 | }, 275 | migrations: [migration()], 276 | ephemeral: map() 277 | } 278 | 279 | @spec new( 280 | source :: binary, 281 | label :: binary, 282 | singular :: binary, 283 | plural :: binary 284 | ) :: t 285 | def new(source, label, singular, plural) 286 | when is_binary(source) and is_binary(label) and is_binary(singular) and is_binary(plural) do 287 | %Type{ 288 | label: label, 289 | source: source, 290 | singular: singular, 291 | plural: plural, 292 | fields: %{}, 293 | changesets: %{}, 294 | migrations: [], 295 | ephemeral: %{} 296 | } 297 | end 298 | 299 | @spec from_map!(type :: map_t) :: t 300 | def from_map!(type) do 301 | struct!(Type, type) 302 | end 303 | 304 | @spec to_persistable(type :: t() | map_t()) :: map() 305 | def to_persistable(type) do 306 | type 307 | |> Map.drop([:__struct__, :ephemeral]) 308 | |> stringify_map() 309 | end 310 | 311 | @spec from_persistable(stringly_type :: map) :: {:ok, t()} | {:error, term()} 312 | def from_persistable(stringly_type) do 313 | try do 314 | typable = atomize!(stringly_type) 315 | {:ok, from_map!(typable)} 316 | catch 317 | _ -> {:error, :bad_type} 318 | end 319 | end 320 | 321 | @spec migration_defaults!(type :: t, callback :: fun()) :: t 322 | def migration_defaults!(%{migrations: migrations} = type, callback) do 323 | if migrations == [] do 324 | type 325 | |> migration_set(fn set -> 326 | set 327 | |> add_primary_key() 328 | |> add_timestamps() 329 | |> callback.() 330 | end) 331 | else 332 | raise "Cannot set migration defaults on a type with pre-existing migrations." 333 | end 334 | end 335 | 336 | @spec migration_set(type :: t, callback :: fun()) :: t 337 | def migration_set(%{migrations: migrations} = type, callback) do 338 | migration = %{ 339 | id: new_uuid(), 340 | created_at: DateTime.utc_now() |> DateTime.to_iso8601(), 341 | last_migration_count: Enum.count(migrations), 342 | set: callback.([]) |> Enum.reverse() 343 | } 344 | 345 | %{type | migrations: migrations ++ [migration]} 346 | |> migrations_to_fields() 347 | |> migrations_to_changesets() 348 | end 349 | 350 | def add_primary_key(type, use_integer \\ false) 351 | 352 | def add_primary_key(type, use_integer) when is_map(type) do 353 | migration_set(type, fn set -> 354 | add_primary_key(set, use_integer) 355 | end) 356 | end 357 | 358 | def add_primary_key(migration_set, use_integer) when is_list(migration_set) do 359 | primary_type = 360 | case use_integer do 361 | true -> "integer" 362 | false -> "uuid" 363 | end 364 | 365 | migration_set_item = %{ 366 | type: "add_primary_key", 367 | primary_type: primary_type 368 | } 369 | 370 | [migration_set_item | migration_set] 371 | end 372 | 373 | @persistence_options [ 374 | :nullable, 375 | :indexed, 376 | :unique, 377 | :default 378 | ] 379 | 380 | @validation_options [ 381 | :required, 382 | :format, 383 | :number, 384 | :excluding, 385 | :including, 386 | :length 387 | ] 388 | def add_field!(type, identifier, field_type, storage_type, options) when is_map(type) do 389 | migration_set(type, fn set -> 390 | add_field!(set, identifier, field_type, storage_type, options) 391 | end) 392 | end 393 | 394 | def add_field!(migration_set, identifier, field_type, storage_type, options) 395 | when is_list(migration_set) do 396 | valid_keys = List.flatten([@persistence_options, @validation_options, [:meta, :filters]]) 397 | check_options!(options, valid_keys) 398 | 399 | persistence_options = 400 | options 401 | |> Enum.filter(fn {key, _} -> 402 | key in @persistence_options 403 | end) 404 | |> Enum.into(%{}) 405 | |> Map.put_new(:nullable, true) 406 | |> Map.put_new(:indexed, false) 407 | 408 | validation_options = 409 | options 410 | |> Enum.filter(fn {key, _} -> 411 | key in @validation_options 412 | end) 413 | |> Enum.into(%{}) 414 | |> Map.put_new(:required, false) 415 | 416 | migration_set_item = %{ 417 | type: "add_field", 418 | identifier: identifier, 419 | field_type: field_type, 420 | storage_type: storage_type, 421 | persistence_options: persistence_options, 422 | validation_options: validation_options, 423 | filters: Keyword.get(options, :filters, []), 424 | meta: Keyword.get(options, :meta, %{}) 425 | } 426 | 427 | [migration_set_item | migration_set] 428 | end 429 | 430 | def add_timestamps(type) when is_map(type) do 431 | migration_set(type, fn set -> 432 | add_timestamps(set) 433 | end) 434 | end 435 | 436 | def add_timestamps(migration_set) when is_list(migration_set) do 437 | migration_set_item = %{ 438 | type: "add_timestamps" 439 | } 440 | 441 | [migration_set_item | migration_set] 442 | end 443 | 444 | @persistence_options [ 445 | :make_nullable, 446 | :add_index, 447 | :drop_index, 448 | :remove_uniqueness, 449 | :set_default 450 | ] 451 | 452 | @validation_options [ 453 | :required, 454 | :format, 455 | :number, 456 | :excluding, 457 | :including, 458 | :length 459 | ] 460 | def alter_field!(type, identifier, options) when is_map(type) do 461 | migration_set(type, fn set -> 462 | alter_field!(set, type, identifier, options) 463 | end) 464 | end 465 | 466 | def alter_field!(migration_set, type, identifier, options) when is_list(migration_set) do 467 | valid_keys = List.flatten([@persistence_options, @validation_options, [:meta, :filters]]) 468 | check_options!(options, valid_keys) 469 | 470 | if not migration_field_exists?(type, migration_set, identifier) do 471 | raise "Cannot alter a field that is not defined previously in type fields or the current migration set." 472 | end 473 | 474 | persistence_options = 475 | options 476 | |> Enum.filter(fn {key, _} -> 477 | key in @persistence_options 478 | end) 479 | |> Enum.into(%{}) 480 | 481 | validation_options = 482 | options 483 | |> Enum.filter(fn {key, _} -> 484 | key in @validation_options 485 | end) 486 | |> Enum.into(%{}) 487 | 488 | migration_set_item = %{ 489 | type: "alter_field", 490 | identifier: identifier, 491 | persistence_options: persistence_options, 492 | validation_options: validation_options, 493 | filters: Keyword.get(options, :filters, []), 494 | meta: Keyword.get(options, :meta, %{}) 495 | } 496 | 497 | [migration_set_item | migration_set] 498 | end 499 | 500 | defp migration_field_exists?(type, set, identifier) do 501 | Map.has_key?(type.fields, identifier) or 502 | Enum.any?(set, fn msi -> 503 | match?(%{type: "add_field", identifier: ^identifier}, msi) 504 | end) 505 | end 506 | 507 | defp migrations_to_fields(type) do 508 | fields = 509 | Enum.reduce(type.migrations, %{}, fn migration, fields -> 510 | # msi - migration_set_item 511 | Enum.reduce(migration.set, fields, &migration_set_item_to_field/2) 512 | end) 513 | 514 | %{type | fields: fields} 515 | end 516 | 517 | defp migration_set_item_to_field(%{type: "add_field"} = item, fields) do 518 | Map.put(fields, item.identifier, Map.drop(item, [:type, :identifier])) 519 | end 520 | 521 | defp migration_set_item_to_field(%{type: "add_timestamps"}, fields) do 522 | field = %{ 523 | field_type: "naive_datetime", 524 | storage_type: "naive_datetime", 525 | persistence_options: %{ 526 | nullable: false, 527 | indexed: true, 528 | unique: false 529 | }, 530 | validation_options: %{}, 531 | filters: [], 532 | meta: %{ 533 | "ecto-entity" => %{"source" => "add_timestamps"} 534 | } 535 | } 536 | 537 | fields 538 | |> Map.put("inserted_at", field) 539 | |> Map.put("updated_at", field) 540 | end 541 | 542 | defp migration_set_item_to_field(%{type: "add_primary_key"} = item, fields) do 543 | ecto_type = 544 | case item.primary_type do 545 | "integer" -> "id" 546 | "uuid" -> "string" 547 | end 548 | 549 | field = %{ 550 | field_type: ecto_type, 551 | storage_type: ecto_type, 552 | persistence_options: %{ 553 | # Implies unique, indexed 554 | primary_key: true, 555 | nullable: false 556 | }, 557 | validation_options: %{}, 558 | filters: [], 559 | meta: %{} 560 | } 561 | 562 | Map.put(fields, "id", field) 563 | end 564 | 565 | defp migration_set_item_to_field(%{type: "alter_field"} = msi, fields) do 566 | field = Map.get(fields, msi.identifier) 567 | 568 | persistence_options = 569 | Enum.reduce(msi.persistence_options, field.persistence_options, fn {key, value}, opts -> 570 | case key do 571 | :make_nullable -> msi_bool(opts, value, :nullable, true) 572 | :add_index -> msi_bool(opts, value, :indexed, true) 573 | :drop_index -> msi_bool(opts, value, :indexed, false) 574 | :remove_uniqueness -> msi_bool(opts, value, :unique, false) 575 | :set_default -> Map.put(opts, :default, value) 576 | end 577 | end) 578 | 579 | validation_options = Map.merge(field.validation_options, msi.validation_options) 580 | 581 | field = %{ 582 | field 583 | | persistence_options: persistence_options, 584 | validation_options: validation_options 585 | } 586 | 587 | Map.put(fields, msi.identifier, field) 588 | end 589 | 590 | defp msi_bool(fields, apply?, key, value) do 591 | if apply? do 592 | Map.put(fields, key, value) 593 | else 594 | fields 595 | end 596 | end 597 | 598 | # TODO: implement 599 | defp migrations_to_changesets(type) do 600 | type 601 | end 602 | 603 | defp new_uuid do 604 | UUID.uuid1() 605 | end 606 | 607 | defp check_options!(opts, valid_keys) do 608 | Enum.each(opts, fn {key, _} -> 609 | if key not in valid_keys do 610 | raise Error, message: "Invalid option: #{key}" 611 | end 612 | end) 613 | end 614 | 615 | defp atomize!(stringly_type) do 616 | do_atomize!(stringly_type) 617 | end 618 | 619 | defp do_atomize!(s) when is_map(s) do 620 | s 621 | |> Enum.map(fn {key, value} -> 622 | if key in @atom_keys do 623 | key = String.to_existing_atom(key) 624 | 625 | value = 626 | if is_map(value) or is_list(value) do 627 | if key in @atomize_maps do 628 | do_atomize!(value) 629 | else 630 | if key in @stringify_deep do 631 | do_keep_strings!(value, :deep) 632 | else 633 | do_keep_strings!(value) 634 | end 635 | end 636 | else 637 | # Simple value, no change 638 | value 639 | end 640 | 641 | {key, value} 642 | else 643 | {key, value} 644 | end 645 | end) 646 | |> Map.new() 647 | end 648 | 649 | defp do_atomize!([]) do 650 | [] 651 | end 652 | 653 | defp do_atomize!([_ | _] = s) when is_list(s) do 654 | Enum.map(s, fn item -> 655 | do_atomize!(item) 656 | end) 657 | end 658 | 659 | defp do_keep_strings!(s, style \\ :shallow) 660 | 661 | defp do_keep_strings!([], _) do 662 | [] 663 | end 664 | 665 | defp do_keep_strings!([_ | _] = s, style) when is_list(s) do 666 | Enum.map(s, fn item -> 667 | do_keep_strings!(item, style) 668 | end) 669 | end 670 | 671 | defp do_keep_strings!(%{} = s, style) do 672 | s 673 | |> Enum.map(fn {key, value} -> 674 | value = 675 | if is_map(value) or is_list(value) do 676 | case style do 677 | :deep -> do_keep_strings!(value, :deep) 678 | _ -> do_atomize!(value) 679 | end 680 | else 681 | # Simple value, no change 682 | value 683 | end 684 | 685 | {key, value} 686 | end) 687 | |> Map.new() 688 | end 689 | 690 | defp stringify_map(source) when is_struct(source) do 691 | source 692 | |> Map.delete(:__struct__) 693 | |> stringify_map() 694 | end 695 | 696 | defp stringify_map(source) when is_map(source) do 697 | source 698 | |> Enum.map(&stringify_kv/1) 699 | |> Map.new() 700 | end 701 | 702 | defp stringify_map(source) when is_list(source) do 703 | cond do 704 | source == [] -> 705 | source 706 | |> Enum.map(&stringify_value/1) 707 | 708 | Keyword.keyword?(source) -> 709 | source 710 | |> Enum.map(&stringify_kv/1) 711 | |> Map.new() 712 | 713 | true -> 714 | source 715 | |> Enum.map(&stringify_value/1) 716 | end 717 | end 718 | 719 | defp stringify_kv({key, value}) do 720 | {stringify_key(key), stringify_value(value)} 721 | end 722 | 723 | defp stringify_key(key) when is_atom(key), do: Atom.to_string(key) 724 | 725 | defp stringify_key(key) when is_binary(key), do: key 726 | 727 | defp stringify_value(value) when is_map(value) or is_list(value) do 728 | stringify_map(value) 729 | end 730 | 731 | defp stringify_value(value), do: value 732 | end 733 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_entity, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | {:elixir_uuid, "~> 1.2"}, 25 | {:ecto, "~> 3.6"}, 26 | {:jason, "~> 1.2", optional: true}, 27 | {:ecto_sql, "~> 3.6", optional: true}, 28 | {:ecto_sqlite3, "~> 0.5.6", only: :test}, 29 | {:postgrex, "~> 0.15.10", only: :test}, 30 | {:myxql, "~> 0.5.1", only: :test} 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 3 | "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, 4 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 5 | "ecto": {:hex, :ecto, "3.6.1", "7bb317e3fd0179ad725069fd0fe8a28ebe48fec6282e964ea502e4deccb0bd0f", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbb3294a990447b19f0725488a749f8cf806374e0d9d0dffc45d61e7aeaf6553"}, 6 | "ecto_sql": {:hex, :ecto_sql, "3.6.1", "8774dc3fc0ff7b6be510858b99883640f990c0736b8ab54588f9a0c91807f909", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "66f35c3f2d5978b6bffebd1e6351ab8c9d6b68650d62abd1ab8d149de40e0779"}, 7 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.5.7", "335ec10420d6910255c8ee7769acd43c7381d7377183376d75a85ab228400460", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.5", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "ee5684818c14e7ba573f9f6e33c7e909b7b97b972ce90afc0705da6828f2f22f"}, 8 | "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, 9 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, 10 | "exqlite": {:hex, :exqlite, "0.6.1", "c75ff47b792e06f285bd661a5ee0af217cb322e3d1f3a7128d3af5703ce7c2f6", [:make, :mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "53a363dcf178ff6936bda9015edcb8367e3ab6eaf0f5890a4d9c1752d11cac14"}, 11 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 12 | "myxql": {:hex, :myxql, "0.5.1", "42cc502f9f373eeebfe6753266c0b601c01a6a96e4d861d429a4952ffb396689", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.3", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "73c6b684ae119ef9707a755f185f1410ec611ee748e54b9b1b1ff4aab4bc48d7"}, 13 | "norm": {:hex, :norm, "0.12.0", "b27a629fea9aaf7757604e9d4ed06cd815c2c5fb335f38b33c1bf17db9b217b0", [:mix], [], "hexpm", "aecd53df72219966d9493e3426a32d83490c822ef618ef442721b1ae68cb6f0c"}, 14 | "postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"}, 15 | "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, 16 | } 17 | -------------------------------------------------------------------------------- /run_db_tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | # Clean up any existing test-DB 6 | docker stop ecto-entity-postgres || true 7 | docker rm ecto-entity-postgres || true 8 | 9 | # Create a new one 10 | docker run -p 127.0.0.1:5432:5432 --name ecto-entity-postgres -e POSTGRES_PASSWORD=testdbpass -d postgres:13 11 | 12 | # Wait for it to be accessible 13 | echo "Waiting for postgres..." 14 | while ! nc -z 127.0.0.1 5432; do sleep 1; done; 15 | echo "Ready" 16 | 17 | echo "Running tests..." 18 | # Run the postgres tests 19 | mix test test/ecto_entity/postgres_test.exs --include postgres 20 | 21 | # Clean up test-DB 22 | docker stop ecto-entity-postgres || true 23 | docker rm ecto-entity-postgres || true 24 | 25 | # Clean up any existing test-DB 26 | docker stop ecto-entity-mariadb || true 27 | docker rm ecto-entity-mariadb || true 28 | 29 | # Create a new one 30 | docker run -p 127.0.0.1:3306:3306 --name ecto-entity-mariadb -e MARIADB_ROOT_PASSWORD=testdbpass -d mariadb:10.3 31 | 32 | # Wait for it to be accessible 33 | echo "Waiting for MariaDB..." 34 | #while ! nc -z 127.0.0.1 3306; do sleep 1; done; 35 | #docker run --health-cmd='mysqladmin ping --silent' -d mariadb:10 36 | sleep 20 37 | #echo "Ready" 38 | 39 | echo "Running tests..." 40 | # Run the postgres tests 41 | mix test test/ecto_entity/mysql_test.exs --include mysql 42 | 43 | echo "Cleaning up..." 44 | # Clean up test-DB 45 | docker stop ecto-entity-mariadb || true 46 | docker rm ecto-entity-mariadb || true 47 | -------------------------------------------------------------------------------- /test/ecto_entity/entity_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.EntityTest do 2 | use ExUnit.Case, async: true 3 | 4 | # doctest EctoEntity.Entity 5 | 6 | alias EctoEntity.Type 7 | import Ecto.Query, only: [from: 1] 8 | 9 | @label "Post" 10 | @source "posts" 11 | @singular "post" 12 | @plural "posts" 13 | 14 | test "query from type via protocol" do 15 | type = Type.new(@source, @label, @singular, @plural) 16 | assert %Ecto.Query{} = from(type) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/ecto_entity/mysql_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.MySQLTest do 2 | @moduledoc """ 3 | Slightly rough and tumble test until we get migrations in order. 4 | This allows us to test actual SQL, end to end. 5 | """ 6 | use ExUnit.Case, async: true 7 | 8 | alias EctoEntity.Store 9 | alias EctoEntity.Type 10 | require Logger 11 | 12 | defmodule Repo do 13 | use Ecto.Repo, otp_app: :ecto_entity, adapter: Ecto.Adapters.MyXQL 14 | end 15 | 16 | def activate_repo do 17 | id = System.unique_integer([:positive, :monotonic]) 18 | dbname = "test-db-#{id}" 19 | options = [ 20 | name: nil, 21 | database: dbname, 22 | host: "localhost", 23 | username: "root", 24 | password: "testdbpass", 25 | port: 3306, 26 | log: false 27 | ] 28 | Repo.__adapter__().storage_up(options) 29 | {:ok, repo} = Repo.start_link(options) 30 | Repo.put_dynamic_repo(repo) 31 | repo 32 | end 33 | 34 | @label "Post" 35 | @source "posts" 36 | @singular "post" 37 | @plural "posts" 38 | 39 | def create_table(repo) do 40 | {:ok, _result} = 41 | Ecto.Adapters.SQL.query( 42 | repo, 43 | "create table #{@source} (id text, title text, body text)", 44 | [] 45 | ) 46 | end 47 | 48 | defp get_config(tmp_dir, repo) do 49 | %{ 50 | type_storage: %{ 51 | module: EctoEntity.Store.SimpleJson, 52 | settings: %{directory_path: Path.join(tmp_dir, "store")} 53 | }, 54 | repo: %{module: Repo, dynamic: repo} 55 | } 56 | end 57 | 58 | defp new_type do 59 | Type.new(@source, @label, @singular, @plural) 60 | |> Type.migration_defaults!(fn set -> 61 | set 62 | |> Type.add_field!("title", "string", "text", required: true, nullable: false) 63 | |> Type.add_field!("body", "string", "text", required: false, nullable: true) 64 | end) 65 | end 66 | 67 | def bootstrap(dir) do 68 | repo = activate_repo() 69 | create_table(repo) 70 | config = get_config(dir, repo) 71 | type = new_type() 72 | store = Store.init(config) 73 | {:ok, type} = Store.put_type(store, type) 74 | # Now we have a type set up with a database created by cheating 75 | # We've also enriched it with ephemerals from the store 76 | # It is now fully convenient 77 | type 78 | end 79 | 80 | @tag :mysql 81 | @tag :tmp_dir 82 | test "create", %{tmp_dir: dir} do 83 | type = bootstrap(dir) 84 | assert {:ok, entity_id} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 85 | assert [%{"id" => ^entity_id, "title" => "foo", "body" => "bar"}] = Store.list(type) 86 | end 87 | 88 | @tag :mysql 89 | @tag :tmp_dir 90 | test "update", %{tmp_dir: dir} do 91 | type = bootstrap(dir) 92 | assert {:ok, entity_id} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 93 | assert [%{"id" => ^entity_id, "title" => "foo", "body" => "bar"}] = Store.list(type) 94 | assert {:ok, _} = Store.update(type, %{"id" => entity_id}, title: "baz") 95 | assert [%{"id" => ^entity_id, "title" => "baz", "body" => "bar"}] = Store.list(type) 96 | end 97 | 98 | @tag :mysql 99 | @tag :tmp_dir 100 | test "delete", %{tmp_dir: dir} do 101 | type = bootstrap(dir) 102 | assert {:ok, entity_id} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 103 | assert [%{"id" => ^entity_id, "title" => "foo", "body" => "bar"}] = Store.list(type) 104 | assert {:ok, _} = Store.delete(type, %{"id" => entity_id}) 105 | assert [] = Store.list(type) 106 | end 107 | 108 | @tag :mysql 109 | @tag :tmp_dir 110 | test "remove all data", %{tmp_dir: dir} do 111 | type = bootstrap(dir) 112 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 113 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 114 | assert [%{}, %{}] = Store.list(type) 115 | assert {:ok, _} = Store.remove_all_data(type) 116 | assert [] = Store.list(type) 117 | end 118 | 119 | @tag :mysql 120 | @tag :tmp_dir 121 | test "drop table", %{tmp_dir: dir} do 122 | type = bootstrap(dir) 123 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 124 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 125 | assert [%{}, %{}] = Store.list(type) 126 | assert :ok = Store.drop_table(type) 127 | assert {:error, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/ecto_entity/postgres_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.PostgresTest do 2 | @moduledoc """ 3 | Slightly rough and tumble test until we get migrations in order. 4 | This allows us to test actual SQL, end to end. 5 | """ 6 | use ExUnit.Case, async: true 7 | 8 | alias EctoEntity.Store 9 | alias EctoEntity.Type 10 | require Logger 11 | 12 | defmodule Repo do 13 | use Ecto.Repo, otp_app: :ecto_entity, adapter: Ecto.Adapters.Postgres 14 | end 15 | 16 | def activate_repo do 17 | id = System.unique_integer([:positive, :monotonic]) 18 | dbname = "test-db-#{id}" 19 | options = [ 20 | name: nil, 21 | database: dbname, 22 | host: "localhost", 23 | username: "postgres", 24 | password: "testdbpass", 25 | port: 5432, 26 | log: false 27 | ] 28 | Repo.__adapter__().storage_up(options) 29 | {:ok, repo} = Repo.start_link(options) 30 | Repo.put_dynamic_repo(repo) 31 | repo 32 | end 33 | 34 | @label "Post" 35 | @source "posts" 36 | @singular "post" 37 | @plural "posts" 38 | 39 | def create_table(repo) do 40 | {:ok, _result} = 41 | Ecto.Adapters.SQL.query( 42 | repo, 43 | "create table #{@source} (id text, title text, body text)", 44 | [] 45 | ) 46 | end 47 | 48 | defp get_config(tmp_dir, repo) do 49 | %{ 50 | type_storage: %{ 51 | module: EctoEntity.Store.SimpleJson, 52 | settings: %{directory_path: Path.join(tmp_dir, "store")} 53 | }, 54 | repo: %{module: Repo, dynamic: repo} 55 | } 56 | end 57 | 58 | defp new_type do 59 | Type.new(@source, @label, @singular, @plural) 60 | |> Type.migration_defaults!(fn set -> 61 | set 62 | |> Type.add_field!("title", "string", "text", required: true, nullable: false) 63 | |> Type.add_field!("body", "string", "text", required: false, nullable: true) 64 | end) 65 | end 66 | 67 | def bootstrap(dir) do 68 | repo = activate_repo() 69 | create_table(repo) 70 | config = get_config(dir, repo) 71 | type = new_type() 72 | store = Store.init(config) 73 | {:ok, type} = Store.put_type(store, type) 74 | # Now we have a type set up with a database created by cheating 75 | # We've also enriched it with ephemerals from the store 76 | # It is now fully convenient 77 | type 78 | end 79 | 80 | @tag :postgres 81 | @tag :tmp_dir 82 | test "create", %{tmp_dir: dir} do 83 | type = bootstrap(dir) 84 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 85 | #assert %{"id" => _entity_id, "title" => "foo", "body" => "bar"} = entity 86 | assert [%{"title" => "foo", "body" => "bar"}] = Store.list(type) 87 | end 88 | 89 | @tag :postgres 90 | @tag :tmp_dir 91 | test "update", %{tmp_dir: dir} do 92 | type = bootstrap(dir) 93 | assert {:ok, new_id} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 94 | assert [%{"id" => _entity_id, "title" => "foo", "body" => "bar"}] = Store.list(type) 95 | assert {:ok, _} = Store.update(type, %{"id" => new_id}, title: "baz") 96 | assert [%{"id" => _entity_id, "title" => "baz", "body" => "bar"}] = Store.list(type) 97 | end 98 | 99 | @tag :postgres 100 | @tag :tmp_dir 101 | test "delete", %{tmp_dir: dir} do 102 | type = bootstrap(dir) 103 | assert {:ok, entity_id} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 104 | assert [%{"id" => ^entity_id, "title" => "foo", "body" => "bar"}] = Store.list(type) 105 | assert {:ok, _} = Store.delete(type, %{"id" => entity_id}) 106 | assert [] = Store.list(type) 107 | end 108 | 109 | @tag :postgres 110 | @tag :tmp_dir 111 | test "remove all data", %{tmp_dir: dir} do 112 | type = bootstrap(dir) 113 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 114 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 115 | assert [%{}, %{}] = Store.list(type) 116 | assert {:ok, _} = Store.remove_all_data(type) 117 | assert [] = Store.list(type) 118 | end 119 | 120 | @tag :postgres 121 | @tag :tmp_dir 122 | test "drop table", %{tmp_dir: dir} do 123 | type = bootstrap(dir) 124 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 125 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 126 | assert [%{}, %{}] = Store.list(type) 127 | assert :ok = Store.drop_table(type) 128 | assert {:error, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/ecto_entity/sqlite_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.SqliteTest do 2 | @moduledoc """ 3 | Slightly rough and tumble test until we get migrations in order. 4 | This allows us to test actual SQL, end to end. 5 | """ 6 | use ExUnit.Case, async: true 7 | 8 | alias EctoEntity.Store 9 | alias EctoEntity.Type 10 | require Logger 11 | 12 | defmodule Repo do 13 | use Ecto.Repo, otp_app: :ecto_entity, adapter: Ecto.Adapters.SQLite3 14 | end 15 | 16 | def activate_repo(dir) do 17 | options = [name: nil, database: Path.join(dir, "database.db"), log: false] 18 | Repo.__adapter__().storage_up(options) 19 | {:ok, repo} = Repo.start_link(options) 20 | Repo.put_dynamic_repo(repo) 21 | repo 22 | end 23 | 24 | @label "Post" 25 | @source "posts" 26 | @singular "post" 27 | @plural "posts" 28 | 29 | def create_table(repo) do 30 | {:ok, _result} = 31 | Ecto.Adapters.SQL.query( 32 | repo, 33 | "create table #{@source} (id uuid, title text, body text)", 34 | [] 35 | ) 36 | end 37 | 38 | defp get_config(tmp_dir, repo) do 39 | %{ 40 | type_storage: %{ 41 | module: EctoEntity.Store.SimpleJson, 42 | settings: %{directory_path: Path.join(tmp_dir, "store")} 43 | }, 44 | repo: %{module: Repo, dynamic: repo} 45 | } 46 | end 47 | 48 | defp new_type do 49 | Type.new(@source, @label, @singular, @plural) 50 | |> Type.migration_defaults!(fn set -> 51 | set 52 | |> Type.add_field!("title", "string", "text", required: true, nullable: false) 53 | |> Type.add_field!("body", "string", "text", required: false, nullable: true) 54 | end) 55 | end 56 | 57 | def bootstrap(dir) do 58 | repo = activate_repo(dir) 59 | create_table(repo) 60 | config = get_config(dir, repo) 61 | type = new_type() 62 | store = Store.init(config) 63 | {:ok, type} = Store.put_type(store, type) 64 | # Now we have a type set up with a database created by cheating 65 | # We've also enriched it with ephemerals from the store 66 | # It is now fully convenient 67 | type 68 | end 69 | 70 | @tag :tmp_dir 71 | test "create", %{tmp_dir: dir} do 72 | type = bootstrap(dir) 73 | assert {:ok, _entity} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 74 | #assert %{"id" => _entity_id, "title" => "foo", "body" => "bar"} = entity 75 | assert [%{"title" => "foo", "body" => "bar"}] = Store.list(type) 76 | end 77 | 78 | @tag :tmp_dir 79 | test "update", %{tmp_dir: dir} do 80 | type = bootstrap(dir) 81 | assert {:ok, _entity} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 82 | #assert %{"id" => entity_id, "title" => "foo", "body" => "bar"} = entity 83 | assert [%{"id" => entity_id, "title" => "foo", "body" => "bar"} = entity] = Store.list(type) 84 | assert {:ok, _} = Store.update(type, entity, title: "baz") 85 | assert [%{"id" => ^entity_id, "title" => "baz", "body" => "bar"}] = Store.list(type) 86 | end 87 | 88 | @tag :tmp_dir 89 | test "delete", %{tmp_dir: dir} do 90 | type = bootstrap(dir) 91 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 92 | assert [%{"id" => _entity_id, "title" => "foo", "body" => "bar"} = entity] = Store.list(type) 93 | assert {:ok, _} = Store.delete(type, entity) 94 | assert [] = Store.list(type) 95 | end 96 | 97 | @tag :tmp_dir 98 | test "remove all data", %{tmp_dir: dir} do 99 | type = bootstrap(dir) 100 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 101 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 102 | assert [%{}, %{}] = Store.list(type) 103 | assert {:ok, _} = Store.remove_all_data(type) 104 | assert [] = Store.list(type) 105 | end 106 | 107 | @tag :tmp_dir 108 | test "drop table", %{tmp_dir: dir} do 109 | type = bootstrap(dir) 110 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 111 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 112 | assert [%{}, %{}] = Store.list(type) 113 | assert :ok = Store.drop_table(type) 114 | assert {:error, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/ecto_entity/store_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.StoreTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias EctoEntity.Store 5 | alias EctoEntity.Store.Storage.Error 6 | import EctoEntity.Store.Storage, only: [error: 2] 7 | alias EctoEntity.Type 8 | 9 | @settings %{foo: 1, bar: 2} 10 | 11 | defmodule StorageTest do 12 | @behaviour EctoEntity.Store.Storage 13 | @settings %{foo: 1, bar: 2} 14 | 15 | @impl true 16 | def get_type(settings, source) do 17 | assert @settings == settings 18 | 19 | case Process.get(source, nil) do 20 | nil -> {:error, error(:not_found, "Not found")} 21 | type -> {:ok, type} 22 | end 23 | end 24 | 25 | @impl true 26 | def put_type(settings, definition) do 27 | assert @settings == settings 28 | Process.put(definition.source, definition) 29 | :ok 30 | end 31 | 32 | @impl true 33 | def remove_type(settings, source) do 34 | assert @settings == settings 35 | 36 | with {:ok, _type} <- get_type(settings, source) do 37 | Process.delete(source) 38 | :ok 39 | end 40 | end 41 | 42 | @impl true 43 | def list_types(settings) do 44 | assert @settings == settings 45 | Process.get_keys() 46 | end 47 | end 48 | 49 | @config %{ 50 | type_storage: %{module: StorageTest, settings: @settings}, 51 | repo: %{module: EctoSQL.TestRepo, dynamic: false} 52 | } 53 | @label "Post" 54 | @source "posts" 55 | @singular "post" 56 | @plural "posts" 57 | 58 | defp new_type do 59 | Type.new(@source, @label, @singular, @plural) 60 | |> Type.migration_defaults!(fn set -> 61 | set 62 | |> Type.add_field!("title", "string", "text", required: true, nullable: false) 63 | |> Type.add_field!("body", "string", "text", required: false, nullable: true) 64 | end) 65 | end 66 | 67 | defp strip_ephemeral(type) do 68 | case type do 69 | {:ok, type} -> {:ok, Map.put(type, :ephemeral, %{})} 70 | type -> Map.put(type, :ephemeral, %{}) 71 | end 72 | end 73 | 74 | describe "test store" do 75 | test "init" do 76 | store = Store.init(@config) 77 | assert %{config: @config} = store 78 | end 79 | 80 | test "get type, missing" do 81 | {:error, err} = 82 | @config 83 | |> Store.init() 84 | |> Store.get_type(@source) 85 | 86 | assert %Error{type: :not_found} = err 87 | end 88 | 89 | test "put type, get type success" do 90 | store = Store.init(@config) 91 | type = new_type() 92 | assert {:ok, _type} = Store.put_type(store, type) 93 | 94 | assert {:ok, type} == Store.get_type(store, @source) |> strip_ephemeral() 95 | end 96 | 97 | test "remove type, missing" do 98 | {:error, err} = 99 | @config 100 | |> Store.init() 101 | |> Store.remove_type(@source) 102 | 103 | assert %Error{type: :not_found} = err 104 | end 105 | 106 | test "remove type success" do 107 | store = Store.init(@config) 108 | type = new_type() 109 | assert {:ok, _type} = Store.put_type(store, type) 110 | 111 | assert :ok == Store.remove_type(store, @source) 112 | end 113 | end 114 | 115 | describe "json store" do 116 | defp config_json(tmp_dir) do 117 | %{ 118 | type_storage: %{ 119 | module: EctoEntity.Store.SimpleJson, 120 | settings: %{directory_path: tmp_dir} 121 | }, 122 | repo: %{module: TestRepo} 123 | } 124 | end 125 | 126 | @tag :tmp_dir 127 | test "init", %{tmp_dir: tmp_dir} do 128 | config = config_json(tmp_dir) 129 | 130 | assert %Store{ 131 | config: %{ 132 | type_storage: %{ 133 | module: EctoEntity.Store.SimpleJson, 134 | settings: %{directory_path: ^tmp_dir} 135 | } 136 | } 137 | } = Store.init(config) 138 | end 139 | 140 | @tag :tmp_dir 141 | test "get type missing", %{tmp_dir: tmp_dir} do 142 | config = config_json(tmp_dir) 143 | 144 | {:error, err} = 145 | config 146 | |> Store.init() 147 | |> Store.get_type(@source) 148 | 149 | assert %Error{type: :not_found} = err 150 | end 151 | 152 | @tag :tmp_dir 153 | test "put type, get type success", %{tmp_dir: tmp_dir} do 154 | config = config_json(tmp_dir) 155 | store = Store.init(config) 156 | type = new_type() 157 | assert {:ok, _type} = Store.put_type(store, type) 158 | 159 | assert {:ok, type} == Store.get_type(store, @source) |> strip_ephemeral() 160 | end 161 | 162 | @tag :tmp_dir 163 | test "remove type, missing", %{tmp_dir: tmp_dir} do 164 | config = config_json(tmp_dir) 165 | 166 | {:error, err} = 167 | config 168 | |> Store.init() 169 | |> Store.remove_type(@source) 170 | 171 | assert %Error{type: :not_found} = err 172 | end 173 | 174 | @tag :tmp_dir 175 | test "remove type success", %{tmp_dir: tmp_dir} do 176 | config = config_json(tmp_dir) 177 | store = Store.init(config) 178 | type = new_type() 179 | assert {:ok, _type} = Store.put_type(store, type) 180 | 181 | assert :ok == Store.remove_type(store, @source) 182 | end 183 | end 184 | 185 | describe "loading" do 186 | test "load/cast data for definition using repo" do 187 | store = Store.init(@config) 188 | type = new_type() 189 | assert {:ok, type} = Store.put_type(store, type) 190 | # We now have a type with store ephemerals 191 | assert %{"title" => "foo"} = EctoEntity.Entity.load(type, %{"title" => "foo"}) 192 | 193 | assert_raise ArgumentError, fn -> 194 | EctoEntity.Entity.load(type, %{"title" => 5}) 195 | end 196 | end 197 | end 198 | 199 | describe "queries" do 200 | setup do 201 | store = Store.init(@config) 202 | type = new_type() 203 | {:ok, type} = Store.put_type(store, type) 204 | # We now have a type with store ephemerals 205 | {:ok, type: type} 206 | end 207 | 208 | test "create", %{type: type} do 209 | assert {:ok, _} = Store.insert(type, %{"title" => "foo", "body" => "bar"}) 210 | assert_receive {:insert, nil, "posts", 211 | ["id", "body", "title"], 212 | [[_id, "bar", "foo"]], 213 | {:raise, nil, []}, 214 | [], 215 | [] 216 | } 217 | assert_receive {:query, _, "insert", params, _} 218 | assert [_, "bar", "foo"] = params 219 | end 220 | 221 | test "create with bad source", %{type: type} do 222 | assert {:ok, _} = 223 | Store.insert(%{type | source: "posts;!=#"}, %{"title" => "foo"}) 224 | 225 | assert_receive {:insert, nil, "posts", 226 | ["id", "title"], 227 | [[_id, "foo"]], 228 | {:raise, nil, []}, 229 | [], 230 | [] 231 | } 232 | assert_receive {:query, _, "insert", params, _} 233 | assert [_, "foo"] = params 234 | end 235 | 236 | test "create with bad field", %{type: type} do 237 | assert {:ok, _} = Store.insert(type, %{"title';''='" => "foo"}) 238 | assert_receive {:insert, nil, "posts", 239 | ["id", "title"], 240 | [[_id, "foo"]], 241 | {:raise, nil, []}, 242 | [], 243 | [] 244 | } 245 | assert_receive {:query, _, "insert", params, _} 246 | assert [_, "foo"] = params 247 | end 248 | 249 | test "list", %{type: type} do 250 | assert [] = Store.list(type) 251 | assert_receive {:query, _, query, params, _} 252 | assert "select * from posts" = String.downcase(query) 253 | assert [] = params 254 | end 255 | 256 | test "list with bad source", %{type: type} do 257 | assert [] = Store.list(%{type | source: "posts;!=#"}) 258 | assert_receive {:query, _, query, params, _} 259 | assert "select * from posts" = String.downcase(query) 260 | assert [] = params 261 | end 262 | 263 | test "update", %{type: type} do 264 | assert {:ok, _} = Store.update(type, %{"id" => 5}, title: "foo", body: "bar") 265 | assert_receive {:query, _, query, _params, _} 266 | assert "update" = String.downcase(query) 267 | end 268 | 269 | test "update with bad source", %{type: type} do 270 | assert {:ok, _} = 271 | Store.update(%{type | source: "posts;!=#"}, %{"id" => 5}, title: "foo") 272 | 273 | assert_receive {:query, _, query, _params, _} 274 | assert "update" = String.downcase(query) 275 | end 276 | 277 | test "update with bad field", %{type: type} do 278 | assert {:ok, _} = Store.update(type, %{"id" => 5}, %{"title';''='" => "foo"}) 279 | assert_receive {:query, _, query, _params, _} 280 | assert "update" = String.downcase(query) 281 | end 282 | 283 | test "delete", %{type: type} do 284 | assert {:ok, _} = Store.delete(type, %{"id" => 5}) 285 | assert_receive {:query, _, query, params, _} 286 | assert "delete" = String.downcase(query) 287 | assert [5] = params 288 | end 289 | 290 | test "delete with bad source", %{type: type} do 291 | assert {:ok, _} = Store.delete(%{type | source: "posts;!=#"}, %{"id" => 5}) 292 | assert_receive {:query, _, query, params, _} 293 | assert "delete" = String.downcase(query) 294 | assert [5] = params 295 | end 296 | 297 | test "remove all data", %{type: type} do 298 | assert {:ok, _} = Store.remove_all_data(type) 299 | assert_receive {:query, _, query, params, _} 300 | assert "delete from posts" = String.downcase(query) 301 | assert [] = params 302 | end 303 | 304 | test "drop table", %{type: type} do 305 | assert :ok = Store.drop_table(type) 306 | assert_receive {:query, _, query, params, _} 307 | assert "drop table posts" = String.downcase(query) 308 | assert [] = params 309 | end 310 | end 311 | end 312 | -------------------------------------------------------------------------------- /test/ecto_entity/type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoEntity.TypeTest do 2 | use ExUnit.Case, async: true 3 | doctest EctoEntity.Type 4 | 5 | alias EctoEntity.Type 6 | 7 | @label "Post" 8 | @source "posts" 9 | @singular "post" 10 | @plural "posts" 11 | 12 | @full_type %{ 13 | label: @label, 14 | source: @source, 15 | singular: @singular, 16 | plural: @plural, 17 | fields: %{ 18 | "id" => %{}, 19 | "updated_at" => %{}, 20 | "inserted_at" => %{}, 21 | "title" => %{ 22 | field_type: "string", 23 | storage_type: "text", 24 | persistence_options: %{ 25 | nullable: false, 26 | indexed: true, 27 | unique: false, 28 | default: "foo" 29 | }, 30 | validation_options: %{ 31 | required: true, 32 | length: %{"min" => 4} 33 | }, 34 | filters: [], 35 | meta: %{ 36 | "ui" => %{ 37 | "anykey" => "anyvalue" 38 | } 39 | } 40 | } 41 | }, 42 | changesets: %{}, 43 | migrations: [ 44 | %{ 45 | id: UUID.uuid4(), 46 | created_at: DateTime.utc_now() |> DateTime.to_iso8601(), 47 | last_migration_count: 0, 48 | set: [ 49 | %{ 50 | type: "add_primary_key", 51 | primary_type: "uuid" 52 | }, 53 | %{ 54 | type: "add_timestamps" 55 | }, 56 | %{ 57 | type: "add_field", 58 | identifier: "title", 59 | field_type: "string", 60 | storage_type: "text", 61 | persistence_options: %{ 62 | nullable: false, 63 | indexed: true, 64 | unique: false, 65 | default: "foo" 66 | }, 67 | validation_options: %{ 68 | required: true, 69 | length: %{"min" => 4} 70 | }, 71 | filters: [], 72 | meta: %{ 73 | "ui" => %{ 74 | "anykey" => "anyvalue" 75 | } 76 | } 77 | } 78 | ] 79 | } 80 | ] 81 | } 82 | 83 | test "create type" do 84 | type = Type.new(@source, @label, @singular, @plural) 85 | 86 | assert %Type{ 87 | source: @source, 88 | label: @label, 89 | singular: @singular, 90 | plural: @plural, 91 | fields: %{}, 92 | changesets: %{}, 93 | migrations: [] 94 | } = type 95 | end 96 | 97 | test "type from compatible map" do 98 | type = Type.new(@source, @label, @singular, @plural) 99 | 100 | map = Map.delete(type, :__struct__) 101 | 102 | assert %Type{ 103 | source: @source, 104 | label: @label, 105 | singular: @singular, 106 | plural: @plural, 107 | fields: %{}, 108 | changesets: %{}, 109 | migrations: [], 110 | ephemeral: %{} 111 | } = Type.from_map!(map) 112 | end 113 | 114 | test "add field" do 115 | type = Type.new(@source, @label, @singular, @plural) 116 | 117 | %{ 118 | fields: fields, 119 | changesets: _changesets, 120 | migrations: [migration] 121 | } = 122 | Type.add_field!(type, "title", "string", "string", 123 | nullable: false, 124 | indexed: true, 125 | unique: false, 126 | required: true, 127 | length: %{max: 200} 128 | ) 129 | 130 | assert %{ 131 | id: _, 132 | created_at: _, 133 | set: [ 134 | %{ 135 | type: "add_field", 136 | identifier: "title", 137 | field_type: "string", 138 | storage_type: "string", 139 | persistence_options: %{ 140 | nullable: false, 141 | indexed: true, 142 | unique: false 143 | }, 144 | validation_options: %{ 145 | required: true, 146 | length: %{max: 200} 147 | } 148 | } 149 | ] 150 | } = migration 151 | 152 | assert %{ 153 | "title" => %{ 154 | field_type: "string", 155 | storage_type: "string", 156 | persistence_options: %{ 157 | nullable: false, 158 | indexed: true, 159 | unique: false 160 | }, 161 | validation_options: %{ 162 | required: true, 163 | length: %{max: 200} 164 | }, 165 | filters: [], 166 | meta: %{} 167 | } 168 | } == fields 169 | end 170 | 171 | test "add field - invalid option" do 172 | type = Type.new(@source, @label, @singular, @plural) 173 | 174 | assert_raise(Type.Error, fn -> 175 | Type.add_field!(type, "title", "string", "string", 176 | fullable: false, 177 | indexed: true, 178 | unique: false, 179 | required: true, 180 | length: %{max: 200} 181 | ) 182 | end) 183 | end 184 | 185 | test "add timestamps" do 186 | type = Type.new(@source, @label, @singular, @plural) 187 | 188 | %{ 189 | fields: fields, 190 | changesets: _changesets, 191 | migrations: [migration] 192 | } = Type.add_timestamps(type) 193 | 194 | assert %{ 195 | id: _, 196 | created_at: _, 197 | set: [ 198 | %{ 199 | type: "add_timestamps" 200 | } 201 | ] 202 | } = migration 203 | 204 | assert %{ 205 | "inserted_at" => %{ 206 | field_type: "naive_datetime", 207 | filters: [], 208 | meta: %{"ecto-entity" => %{"source" => "add_timestamps"}}, 209 | persistence_options: %{indexed: true, nullable: false, unique: false}, 210 | storage_type: "naive_datetime", 211 | validation_options: %{} 212 | }, 213 | "updated_at" => %{ 214 | field_type: "naive_datetime", 215 | filters: [], 216 | meta: %{"ecto-entity" => %{"source" => "add_timestamps"}}, 217 | persistence_options: %{indexed: true, nullable: false, unique: false}, 218 | storage_type: "naive_datetime", 219 | validation_options: %{} 220 | } 221 | } == fields 222 | end 223 | 224 | test "add primary key, default uuid" do 225 | type = Type.new(@source, @label, @singular, @plural) 226 | 227 | %{ 228 | fields: fields, 229 | changesets: _changesets, 230 | migrations: [migration] 231 | } = Type.add_primary_key(type) 232 | 233 | assert %{ 234 | id: _, 235 | created_at: _, 236 | set: [ 237 | %{ 238 | type: "add_primary_key", 239 | primary_type: "uuid" 240 | } 241 | ] 242 | } = migration 243 | 244 | assert %{ 245 | "id" => %{ 246 | field_type: "string", 247 | storage_type: "string", 248 | persistence_options: %{nullable: false, primary_key: true}, 249 | validation_options: %{}, 250 | filters: [], 251 | meta: %{} 252 | } 253 | } == fields 254 | end 255 | 256 | test "add primary key, force integer" do 257 | type = Type.new(@source, @label, @singular, @plural) 258 | 259 | %{ 260 | fields: fields, 261 | changesets: _changesets, 262 | migrations: [migration] 263 | } = Type.add_primary_key(type, true) 264 | 265 | assert %{ 266 | id: _, 267 | created_at: _, 268 | set: [ 269 | %{ 270 | type: "add_primary_key", 271 | primary_type: "integer" 272 | } 273 | ] 274 | } = migration 275 | 276 | assert %{ 277 | "id" => %{ 278 | field_type: "id", 279 | storage_type: "id", 280 | persistence_options: %{nullable: false, primary_key: true}, 281 | validation_options: %{}, 282 | filters: [], 283 | meta: %{} 284 | } 285 | } == fields 286 | end 287 | 288 | test "migration defaults" do 289 | type = Type.new(@source, @label, @singular, @plural) 290 | 291 | %{ 292 | fields: fields, 293 | changesets: _changesets, 294 | migrations: [migration] 295 | } = 296 | Type.migration_defaults!(type, fn set -> 297 | set 298 | end) 299 | 300 | assert %{ 301 | id: _, 302 | created_at: _, 303 | set: [ 304 | %{ 305 | type: "add_primary_key", 306 | primary_type: "uuid" 307 | }, 308 | %{ 309 | type: "add_timestamps" 310 | } 311 | ] 312 | } = migration 313 | 314 | assert %{ 315 | "id" => %{ 316 | field_type: "string", 317 | storage_type: "string", 318 | persistence_options: %{nullable: false, primary_key: true}, 319 | validation_options: %{}, 320 | filters: [], 321 | meta: %{} 322 | }, 323 | "inserted_at" => %{ 324 | field_type: "naive_datetime", 325 | filters: [], 326 | meta: %{"ecto-entity" => %{"source" => "add_timestamps"}}, 327 | persistence_options: %{indexed: true, nullable: false, unique: false}, 328 | storage_type: "naive_datetime", 329 | validation_options: %{} 330 | }, 331 | "updated_at" => %{ 332 | field_type: "naive_datetime", 333 | filters: [], 334 | meta: %{"ecto-entity" => %{"source" => "add_timestamps"}}, 335 | persistence_options: %{indexed: true, nullable: false, unique: false}, 336 | storage_type: "naive_datetime", 337 | validation_options: %{} 338 | } 339 | } == fields 340 | end 341 | 342 | test "alter field, separate migration sets" do 343 | type = Type.new(@source, @label, @singular, @plural) 344 | 345 | type = 346 | type 347 | |> Type.add_field!("title", "string", "string", 348 | nullable: false, 349 | indexed: true, 350 | unique: true, 351 | required: true, 352 | length: %{max: 200} 353 | ) 354 | |> Type.alter_field!("title", 355 | make_nullable: true, 356 | drop_index: true, 357 | remove_uniqueness: true, 358 | set_default: "foo", 359 | required: false 360 | ) 361 | 362 | %{migrations: [_, %{set: [migration]}], fields: %{"title" => field_options}} = type 363 | 364 | assert %{ 365 | type: "alter_field", 366 | identifier: "title", 367 | persistence_options: %{ 368 | make_nullable: true, 369 | drop_index: true, 370 | remove_uniqueness: true, 371 | set_default: "foo" 372 | }, 373 | validation_options: %{ 374 | required: false 375 | } 376 | } = migration 377 | 378 | assert %{ 379 | field_type: "string", 380 | storage_type: "string", 381 | persistence_options: %{ 382 | nullable: true, 383 | indexed: false, 384 | unique: false, 385 | default: "foo" 386 | }, 387 | validation_options: %{ 388 | required: false, 389 | length: %{max: 200} 390 | }, 391 | filters: [], 392 | meta: %{} 393 | } = field_options 394 | end 395 | 396 | test "alter field, same migration set" do 397 | type = Type.new(@source, @label, @singular, @plural) 398 | 399 | type = 400 | type 401 | |> Type.migration_set(fn set -> 402 | set 403 | |> Type.add_field!("title", "string", "string", 404 | nullable: false, 405 | indexed: true, 406 | unique: true, 407 | required: true, 408 | length: %{max: 200} 409 | ) 410 | |> Type.alter_field!(type, "title", 411 | make_nullable: true, 412 | drop_index: true, 413 | remove_uniqueness: true, 414 | set_default: "foo", 415 | required: false 416 | ) 417 | end) 418 | 419 | %{migrations: [%{set: [_, migration]}], fields: %{"title" => field_options}} = type 420 | 421 | assert %{ 422 | type: "alter_field", 423 | identifier: "title", 424 | persistence_options: %{ 425 | make_nullable: true, 426 | drop_index: true, 427 | remove_uniqueness: true, 428 | set_default: "foo" 429 | }, 430 | validation_options: %{ 431 | required: false 432 | } 433 | } = migration 434 | 435 | assert %{ 436 | field_type: "string", 437 | storage_type: "string", 438 | persistence_options: %{ 439 | nullable: true, 440 | indexed: false, 441 | unique: false, 442 | default: "foo" 443 | }, 444 | validation_options: %{ 445 | required: false, 446 | length: %{max: 200} 447 | }, 448 | filters: [], 449 | meta: %{} 450 | } = field_options 451 | end 452 | 453 | test "alter field, invalid, field doesn't exist" do 454 | type = Type.new(@source, @label, @singular, @plural) 455 | 456 | assert_raise RuntimeError, fn -> 457 | Type.alter_field!(type, "title", 458 | make_nullable: true, 459 | drop_index: true, 460 | remove_uniqueness: true, 461 | set_default: "foo", 462 | required: false 463 | ) 464 | end 465 | end 466 | 467 | test "make type persistable, stringify" do 468 | persistable = Type.to_persistable(@full_type) 469 | assert {:ok, Type.from_map!(@full_type)} == Type.from_persistable(persistable) 470 | end 471 | end 472 | -------------------------------------------------------------------------------- /test/support/test_repo.exs: -------------------------------------------------------------------------------- 1 | defmodule MigrationsAgent do 2 | use Agent 3 | 4 | def start_link(versions) do 5 | Agent.start_link(fn -> versions end, name: __MODULE__) 6 | end 7 | 8 | def get do 9 | Agent.get(__MODULE__, & &1) 10 | end 11 | 12 | def up(version, opts) do 13 | Agent.update(__MODULE__, &[{version, opts[:prefix]} | &1]) 14 | end 15 | 16 | def down(version, opts) do 17 | Agent.update(__MODULE__, &List.delete(&1, {version, opts[:prefix]})) 18 | end 19 | end 20 | 21 | defmodule EctoSQL.TestServer do 22 | use GenServer 23 | 24 | def start_link(opts) do 25 | GenServer.start_link(__MODULE__, opts, opts) 26 | end 27 | 28 | @impl true 29 | def init(opts) do 30 | {:ok, opts} 31 | end 32 | end 33 | 34 | defmodule EctoSQL.TestAdapter do 35 | defmodule Connection do 36 | @behaviour Ecto.Adapters.SQL.Connection 37 | 38 | ## Module and Options 39 | 40 | @impl true 41 | def child_spec(opts) do 42 | EctoSQL.TestServer.child_spec(opts) 43 | end 44 | 45 | @impl true 46 | def to_constraints(err, opts) do 47 | send(self(), {:to_constraints, err, opts}) 48 | [] 49 | end 50 | 51 | ## Query 52 | 53 | @impl true 54 | def prepare_execute(conn, name, sql, params, opts) do 55 | send(self(), {:prepare_execute, conn, name, sql, params, opts}) 56 | {:ok, sql, :fine} 57 | end 58 | 59 | @impl true 60 | def query(conn, sql, params, opts) do 61 | send(self(), {:query, conn, sql, params, opts}) 62 | 63 | case String.downcase(sql) do 64 | "insert " <> _ -> {:ok, %{num_rows: 1, columns: ["id"], rows: [["something"]]}} 65 | "update" <> _ -> {:ok, %{num_rows: 1, columns: ["id"], rows: [["something"]]}} 66 | "delete" <> _ -> {:ok, %{num_rows: 1}} 67 | _ -> {:ok, %{columns: [], rows: []}} 68 | end 69 | end 70 | 71 | @impl true 72 | def execute(conn, query, params, opts) do 73 | send(self(), {:execute, conn, query, params, opts}) 74 | {:ok, %{columns: [], rows: []}} 75 | end 76 | 77 | @impl true 78 | def stream(conn, sql, params, opts) do 79 | send(self(), {:stream, conn, sql, params, opts}) 80 | Stream.map([1, 2, 3], fn i -> i end) 81 | end 82 | 83 | @impl true 84 | def all(query, as_prefix \\ []) do 85 | send(self(), {:all, query, as_prefix}) 86 | query 87 | end 88 | 89 | @impl true 90 | def update_all(query, prefix \\ nil) do 91 | send(self(), {:update_all, query, prefix}) 92 | query 93 | end 94 | 95 | @impl true 96 | def delete_all(query) do 97 | send(self(), {:delete_all, query}) 98 | query 99 | end 100 | 101 | @impl true 102 | def insert(prefix, table, header, rows, on_conflict, returning, placeholders) do 103 | send(self(), {:insert, prefix, table, header, rows, on_conflict, returning, placeholders}) 104 | "insert" 105 | end 106 | 107 | @impl true 108 | def update(prefix, table, fields, filters, returning) do 109 | send(self(), {:update, prefix, table, fields, filters, returning}) 110 | "update" 111 | end 112 | 113 | @impl true 114 | def delete(prefix, table, filters, returning) do 115 | send(self(), {:delete, prefix, table, filters, returning}) 116 | "delete" 117 | end 118 | 119 | @impl true 120 | def ddl_logs(result) do 121 | send(self(), {:ddl_logs, result}) 122 | raise "not implemented" 123 | end 124 | 125 | @impl true 126 | def execute_ddl(command) do 127 | send(self(), {:execute_ddl, command}) 128 | raise "not implemented" 129 | end 130 | 131 | @impl true 132 | def explain_query(connection, query, params, opts) do 133 | send(self(), {:explain_query, connection, query, params, opts}) 134 | raise "not implemented" 135 | end 136 | 137 | @impl true 138 | def table_exists_query(table) do 139 | send(self(), {:table_exists_query, table}) 140 | raise "not implemented" 141 | end 142 | end 143 | 144 | use Ecto.Adapters.SQL, driver: :test 145 | # @behaviour Ecto.Adapter 146 | # @behaviour Ecto.Adapter.Queryable 147 | # @behaviour Ecto.Adapter.Schema 148 | # @behaviour Ecto.Adapter.Transaction 149 | # @behaviour Ecto.Adapter.Migration 150 | 151 | defmacro __before_compile__(_opts), do: :ok 152 | def ensure_all_started(_, _), do: {:ok, []} 153 | 154 | def checked_out?(_), do: raise("not implemented") 155 | 156 | ## Types 157 | 158 | def loaders(_primitive, type), do: [type] 159 | def dumpers(_primitive, type), do: [type] 160 | def autogenerate(_), do: nil 161 | 162 | ## Queryable 163 | 164 | def prepare(operation, query), do: {:nocache, {operation, query}} 165 | 166 | # Migration emulation 167 | 168 | def execute(_, _, {:nocache, {:all, %{from: %{source: {"schema_migrations", _}}}}}, _, opts) do 169 | true = opts[:schema_migration] 170 | versions = MigrationsAgent.get() 171 | {length(versions), Enum.map(versions, &[elem(&1, 0)])} 172 | end 173 | 174 | def execute( 175 | _, 176 | _meta, 177 | {:nocache, {:delete_all, %{from: %{source: {"schema_migrations", _}}}}}, 178 | [version], 179 | opts 180 | ) do 181 | true = opts[:schema_migration] 182 | MigrationsAgent.down(version, opts) 183 | {1, nil} 184 | end 185 | 186 | def insert(_, %{source: "schema_migrations"}, val, _, _, opts) do 187 | true = opts[:schema_migration] 188 | version = Keyword.fetch!(val, :version) 189 | MigrationsAgent.up(version, opts) 190 | {:ok, []} 191 | end 192 | 193 | ## Migrations 194 | 195 | def lock_for_migrations(mod, opts, fun) do 196 | send(test_process(), {:lock_for_migrations, mod, fun, opts}) 197 | fun.() 198 | end 199 | 200 | def execute_ddl(_, command, _) do 201 | Process.put(:last_command, command) 202 | {:ok, []} 203 | end 204 | 205 | def supports_ddl_transaction? do 206 | get_config(:supports_ddl_transaction?, false) 207 | end 208 | 209 | defp test_process do 210 | get_config(:test_process, self()) 211 | end 212 | 213 | defp get_config(name, default) do 214 | :ecto_sql 215 | |> Application.get_env(__MODULE__, []) 216 | |> Keyword.get(name, default) 217 | end 218 | end 219 | 220 | defmodule EctoSQL.TestRepo do 221 | use Ecto.Repo, otp_app: :ecto_sql, adapter: EctoSQL.TestAdapter 222 | 223 | def default_options(_operation) do 224 | Process.get(:repo_default_options, []) 225 | end 226 | end 227 | 228 | defmodule EctoSQL.MigrationTestRepo do 229 | use Ecto.Repo, otp_app: :ecto_sql, adapter: EctoSQL.TestAdapter 230 | end 231 | 232 | EctoSQL.TestRepo.start_link() 233 | EctoSQL.TestRepo.start_link(name: :tenant_db) 234 | EctoSQL.MigrationTestRepo.start_link() 235 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("support/test_repo.exs", __DIR__) 2 | ExUnit.configure(exclude: [:postgres, :mysql]) 3 | ExUnit.start() 4 | --------------------------------------------------------------------------------