├── test ├── test_helper.exs └── ecto_fields_test.exs ├── lib ├── ecto_fields.ex └── fields │ ├── positive_integer.ex │ ├── ip4.ex │ ├── ip.ex │ ├── ip6.ex │ ├── slug.ex │ ├── atom.ex │ ├── static.ex │ ├── url.ex │ └── email.ex ├── .gitignore ├── .formatter.exs ├── README.md ├── LICENSE ├── mix.exs ├── config └── config.exs └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/ecto_fields.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields do 2 | end 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | erl_crash.dump 6 | *.ez 7 | /.elixir_ls 8 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: [".formatter.exs", "mix.exs", "{config,lib,priv,test}/**/*.{ex,exs}"], 4 | line_length: 132 5 | ] 6 | -------------------------------------------------------------------------------- /lib/fields/positive_integer.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.PositiveInteger do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :integer 5 | 6 | @doc """ 7 | Validate that the given value is a positive integer. 8 | 9 | ## Examples 10 | 11 | iex> EctoFields.PositiveInteger.cast(1) 12 | {:ok, 1} 13 | 14 | iex> EctoFields.PositiveInteger.cast(0) 15 | :error 16 | 17 | iex> EctoFields.PositiveInteger.cast(-10) 18 | :error 19 | """ 20 | def cast(int) when is_integer(int) and int > 0 do 21 | {:ok, int} 22 | end 23 | 24 | def cast(nil), do: {:ok, nil} 25 | 26 | def cast(_), do: :error 27 | 28 | # converts a value to our ecto type 29 | def load(int), do: {:ok, int} 30 | 31 | # converts our ecto type to a value 32 | def dump(int), do: {:ok, int} 33 | 34 | def embed_as(_), do: :self 35 | 36 | def equal?(a, b), do: a == b 37 | end 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoFields 2 | 3 | Provides commonly used fields for Ecto projects. 4 | 5 | ## Installation 6 | 7 | To install EctoFields: 8 | 9 | 1. Add ecto_fields to your list of dependencies in `mix.exs` : 10 | ```elixir 11 | def deps do 12 | [{:ecto_fields, "~> 1.3.0"}] 13 | end 14 | ``` 15 | 2. Use the fields in your Ecto schema: 16 | ```elixir 17 | schema "user" do 18 | field :name, :string 19 | field :email, EctoFields.Email 20 | field :website, EctoFields.URL 21 | field :ip_address, EctoFields.IP 22 | end 23 | ``` 24 | ## Current fields 25 | 26 | * EctoFields.Atom 27 | * EctoFields.Email 28 | * EctoFields.IP (accepts both ipv4 and ipv6) 29 | * EctoFields.IPv4 30 | * EctoFields.IPv6 31 | * EctoFields.PositiveInteger 32 | * EctoFields.Slug 33 | * EctoFields.Static 34 | * EctoFields.URL 35 | 36 | ## Roadmap 37 | 38 | ### Likely: 39 | 40 | * EctoFields.Duration 41 | 42 | ### Maybe: 43 | 44 | * EctoFields.File 45 | * EctoFields.Image 46 | 47 | -------------------------------------------------------------------------------- /lib/fields/ip4.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.IPv4 do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :string 5 | 6 | @doc """ 7 | Validate that the given value is a valid ipv4 8 | 9 | ## Examples 10 | 11 | iex> EctoFields.IPv4.cast("192.168.10.1") 12 | {:ok, "192.168.10.1"} 13 | 14 | iex> EctoFields.IPv4.cast("2001:1620:28:1:b6f:8bca:93:a116") 15 | :error 16 | 17 | iex> EctoFields.IPv4.cast("http://example.com") 18 | :error 19 | """ 20 | def cast(ip) when is_binary(ip) and byte_size(ip) > 0 do 21 | case ip |> String.to_charlist() |> :inet_parse.ipv4strict_address() do 22 | {:ok, _} -> {:ok, ip} 23 | {:error, _} -> :error 24 | end 25 | end 26 | 27 | def cast(nil), do: {:ok, nil} 28 | 29 | def cast(_), do: :error 30 | 31 | # converts a string to our ecto type 32 | def load(ip), do: {:ok, ip} 33 | 34 | # converts our ecto type to a string 35 | def dump(ip), do: {:ok, ip} 36 | 37 | def embed_as(_), do: :self 38 | 39 | def equal?(a, b), do: a == b 40 | end 41 | -------------------------------------------------------------------------------- /lib/fields/ip.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.IP do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :string 5 | 6 | @doc """ 7 | Validate that the given value is a valid ip 8 | 9 | ## Examples 10 | 11 | iex> EctoFields.IP.cast("192.168.10.1") 12 | {:ok, "192.168.10.1"} 13 | 14 | iex> EctoFields.IP.cast("2001:1620:28:1:b6f:8bca:93:a116") 15 | {:ok, "2001:1620:28:1:b6f:8bca:93:a116"} 16 | 17 | iex> EctoFields.IP.cast("http://example.com") 18 | :error 19 | """ 20 | def cast(ip) when is_binary(ip) and byte_size(ip) > 0 do 21 | case ip |> String.to_charlist() |> :inet.parse_strict_address() do 22 | {:ok, _} -> {:ok, ip} 23 | {:error, _} -> :error 24 | end 25 | end 26 | 27 | def cast(nil), do: {:ok, nil} 28 | 29 | def cast(_), do: :error 30 | 31 | # converts a string to our ecto type 32 | def load(ip), do: {:ok, ip} 33 | 34 | # converts our ecto type to a string 35 | def dump(ip), do: {:ok, ip} 36 | 37 | def embed_as(_), do: :self 38 | 39 | def equal?(a, b), do: a == b 40 | end 41 | -------------------------------------------------------------------------------- /lib/fields/ip6.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.IPv6 do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :string 5 | 6 | @doc """ 7 | Validate that the given value is a valid ipv6 8 | 9 | ## Examples 10 | 11 | iex> EctoFields.IPv6.cast("2001:1620:28:1:b6f:8bca:93:a116") 12 | {:ok, "2001:1620:28:1:b6f:8bca:93:a116"} 13 | 14 | iex> EctoFields.IPv6.cast("192.168.10.1") 15 | :error 16 | 17 | iex> EctoFields.IPv6.cast("http://example.com") 18 | :error 19 | """ 20 | def cast(ip) when is_binary(ip) and byte_size(ip) > 0 do 21 | case ip |> String.to_charlist() |> :inet_parse.ipv6strict_address() do 22 | {:ok, _} -> {:ok, ip} 23 | {:error, _} -> :error 24 | end 25 | end 26 | 27 | def cast(nil), do: {:ok, nil} 28 | 29 | def cast(_), do: :error 30 | 31 | # converts a string to our ecto type 32 | def load(ip), do: {:ok, ip} 33 | 34 | # converts our ecto type to a string 35 | def dump(ip), do: {:ok, ip} 36 | 37 | def embed_as(_), do: :self 38 | 39 | def equal?(a, b), do: a == b 40 | end 41 | -------------------------------------------------------------------------------- /lib/fields/slug.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.Slug do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :string 5 | 6 | @doc """ 7 | Coerce a regular string into a slug 8 | 9 | ## Examples 10 | 11 | iex> EctoFields.Slug.cast(" My latest blog post-") 12 | {:ok, "my-latest-blog-post"} 13 | 14 | iex> EctoFields.Slug.cast("From the ЉЊАБЖЗ Naughty ЁЂЃЄ Strings цчшщъыьэюя list") 15 | {:ok, "from-the-naughty-strings-list"} 16 | """ 17 | def cast(title) when is_binary(title) and byte_size(title) > 0 do 18 | slug = title 19 | |> String.normalize(:nfd) 20 | |> String.downcase 21 | |> String.replace(~r/[^a-z\s]/u, "") 22 | |> String.replace(~r/\s+/, "-") 23 | |> String.replace(~r/^\-*(.*?)\-*$/, "\\1") 24 | 25 | {:ok, slug} 26 | end 27 | 28 | def cast(nil), do: {:ok, nil} 29 | 30 | def cast(_), do: :error 31 | 32 | # converts a string to our ecto type 33 | def load(slug), do: {:ok, slug} 34 | 35 | # converts our ecto type to a string 36 | def dump(slug), do: {:ok, slug} 37 | 38 | def embed_as(_), do: :self 39 | 40 | def equal?(a, b), do: a == b 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jerel Unruh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.Mixfile do 2 | use Mix.Project 3 | 4 | def project() do 5 | [ 6 | app: :ecto_fields, 7 | version: "1.3.0", 8 | elixir: "~> 1.4", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | package: package(), 13 | description: description() 14 | ] 15 | end 16 | 17 | def package() do 18 | [ 19 | licenses: ["MIT"], 20 | maintainers: ["jerel"], 21 | links: %{"GitHub" => "https://github.com/jerel/ecto_fields"} 22 | ] 23 | end 24 | 25 | def description() do 26 | """ 27 | Provides commonly used fields for Ecto projects. 28 | """ 29 | end 30 | 31 | # Dependencies can be Hex packages: 32 | # 33 | # {:mydep, "~> 0.3.0"} 34 | # 35 | # Or git/path repositories: 36 | # 37 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 38 | # 39 | # Type "mix help deps" for more examples and options 40 | defp deps() do 41 | [ 42 | {:ecto, ">= 2.2.0"}, 43 | {:ex_doc, ">= 0.0.0", only: :dev}, 44 | {:mix_test_watch, "~> 0.2", only: :dev}, 45 | {:propcheck, "~> 1.0.5", only: :test} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :ecto_fields, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:ecto_fields, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/fields/atom.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.Atom do 2 | @moduledoc """ 3 | Coerce a regular string into an atom 4 | 5 | ## Examples 6 | 7 | Note: only use this field when you have a fixed number of possible values (atoms are not garbage collected) 8 | 9 | iex> EctoFields.Atom.cast("started") 10 | {:ok, :started} 11 | 12 | iex> EctoFields.Atom.cast(:started) 13 | {:ok, :started} 14 | 15 | iex> EctoFields.Atom.cast(nil) 16 | {:ok, nil} 17 | """ 18 | @behaviour Ecto.Type 19 | 20 | @max_atom_length 0xFF 21 | 22 | def type(), do: :string 23 | 24 | def cast(nil), do: {:ok, nil} 25 | def cast(atom) when is_atom(atom), do: {:ok, atom} 26 | def cast(binary) when is_binary(binary) and byte_size(binary) <= @max_atom_length, do: {:ok, String.to_atom(binary)} 27 | def cast(_), do: :error 28 | 29 | # when loading from the database convert to an atom 30 | def load(term), do: cast(term) 31 | 32 | # save to the database 33 | def dump(nil), do: {:ok, nil} 34 | def dump(atom) when is_atom(atom), do: {:ok, Atom.to_string(atom)} 35 | def dump(binary) when is_binary(binary) and byte_size(binary) <= @max_atom_length, do: {:ok, binary} 36 | def dump(_), do: :error 37 | 38 | def embed_as(_), do: :self 39 | 40 | def equal?(a, b), do: a == b 41 | end 42 | -------------------------------------------------------------------------------- /test/ecto_fields_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoFieldsTest do 2 | use ExUnit.Case 3 | use PropCheck 4 | 5 | doctest(EctoFields) 6 | 7 | doctest(EctoFields.Email) 8 | doctest(EctoFields.IP) 9 | doctest(EctoFields.IPv4) 10 | doctest(EctoFields.IPv6) 11 | doctest(EctoFields.PositiveInteger) 12 | doctest(EctoFields.Slug) 13 | doctest(EctoFields.URL) 14 | doctest(EctoFields.Atom) 15 | doctest(EctoFields.Static) 16 | 17 | property("EctoFields.Atom") do 18 | forall(atom in oneof([atom(), atom_utf8()])) do 19 | string = Atom.to_string(atom) 20 | 21 | {:ok, atom} == EctoFields.Atom.cast(string) and {:ok, atom} == EctoFields.Atom.load(string) and 22 | {:ok, string} == EctoFields.Atom.dump(atom) 23 | end 24 | end 25 | 26 | @doc false 27 | defp atom_utf8() do 28 | :proper_types.new_type( 29 | [ 30 | {:generator, &atom_utf8_gen/1}, 31 | {:reverse_gen, &atom_utf8_rev/1}, 32 | {:size_transform, fn size -> :erlang.min(size, 255) end}, 33 | {:is_instance, &atom_utf8_is_instance/1} 34 | ], 35 | :wrapper 36 | ) 37 | end 38 | 39 | @doc false 40 | defp atom_utf8_gen(size) when is_integer(size) and size >= 0 do 41 | let(string <- such_that(x <- :proper_unicode.utf8(size), when: x === <<>> or :binary.first(x) !== ?$)) do 42 | :erlang.binary_to_atom(string, :utf8) 43 | end 44 | end 45 | 46 | @doc false 47 | defp atom_utf8_rev(atom) when is_atom(atom) do 48 | {:"$used", :erlang.atom_to_list(atom), atom} 49 | end 50 | 51 | @doc false 52 | defp atom_utf8_is_instance(x) do 53 | is_atom(x) and (x === :"" or :erlang.hd(:erlang.atom_to_list(x)) !== ?$) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/fields/static.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.Static do 2 | @moduledoc """ 3 | Enforce a static value for a database column, useful when multiple schemas store data in one table 4 | 5 | ## Examples 6 | 7 | iex> defmodule Test do 8 | ...> import EctoFields.Static 9 | ...> static_field(UserType, "admin") 10 | ...> end 11 | ...> Test.UserType.cast(nil) 12 | {:ok, "admin"} 13 | iex> Test.UserType.cast("superadmin") 14 | :error 15 | iex> Test.UserType.cast("admin") 16 | {:ok, "admin"} 17 | 18 | Typical usage in Ecto schemas looks like this: 19 | 20 | iex> defmodule Truck do 21 | ...> use Ecto.Schema 22 | ...> import EctoFields.Static 23 | ...> 24 | ...> static_field(Type, "truck") 25 | ...> static_field(Second, "anything") 26 | ...> 27 | ...> schema "vehicles" do 28 | ...> field :type, Type 29 | ...> field :second, Second 30 | ...> field :license_plate, :string 31 | ...> field :make, :string 32 | ...> end 33 | ...> end 34 | ...> Truck.Type.cast(nil) 35 | {:ok, "truck"} 36 | iex> Truck.Second.cast(nil) 37 | {:ok, "anything"} 38 | """ 39 | 40 | defmacro static_field(module, value) do 41 | quote do 42 | defmodule unquote(module) do 43 | @behaviour Ecto.Type 44 | 45 | def type, do: :string 46 | 47 | # cast for usage in queries and changesets 48 | def cast(nil), do: {:ok, unquote(value)} 49 | def cast(val) when val == unquote(value), do: {:ok, unquote(value)} 50 | def cast(_), do: :error 51 | 52 | # load from the database 53 | def load(nil), do: {:ok, unquote(value)} 54 | def load(val) when val == unquote(value), do: {:ok, val} 55 | # if it's another other than nil or the static value we error out (someone inserted garbage into the database) 56 | def load(_val), do: :error 57 | 58 | # write to the database 59 | def dump(_), do: {:ok, unquote(value)} 60 | 61 | def embed_as(_), do: :self 62 | 63 | def equal?(a, b), do: a == b 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/fields/url.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.URL do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :string 5 | 6 | @doc """ 7 | Validate that the given value is a valid fully qualified url 8 | 9 | ## Examples 10 | 11 | iex> EctoFields.URL.cast("http://1.1.1.1") 12 | {:ok, "http://1.1.1.1"} 13 | 14 | iex> EctoFields.URL.cast("http://example.com") 15 | {:ok, "http://example.com"} 16 | 17 | iex> EctoFields.URL.cast("https://example.com") 18 | {:ok, "https://example.com"} 19 | 20 | iex> EctoFields.URL.cast("http://example.com/test/foo.html?search=1&page=two#header") 21 | {:ok, "http://example.com/test/foo.html?search=1&page=two#header"} 22 | 23 | iex> EctoFields.URL.cast("http://example.com:8080/") 24 | {:ok, "http://example.com:8080/"} 25 | 26 | iex> EctoFields.URL.cast("myblog.html") 27 | :error 28 | 29 | iex> EctoFields.URL.cast("http://example.com\blog\first") 30 | :error 31 | """ 32 | def cast(url) when is_binary(url) and byte_size(url) > 0 do 33 | url 34 | |> validate_protocol 35 | |> validate_host 36 | |> validate_uri 37 | end 38 | 39 | def cast(nil), do: {:ok, nil} 40 | 41 | def cast(_), do: :error 42 | 43 | # converts a string to our ecto type 44 | def load(url), do: {:ok, url} 45 | 46 | # converts our ecto type to a string 47 | def dump(url), do: {:ok, url} 48 | 49 | def embed_as(_), do: :self 50 | 51 | def equal?(a, b), do: a == b 52 | 53 | defp validate_protocol("http://" <> rest = url) do 54 | {url, rest} 55 | end 56 | 57 | defp validate_protocol("https://" <> rest = url) do 58 | {url, rest} 59 | end 60 | 61 | defp validate_protocol(_), do: :error 62 | 63 | defp validate_host(:error), do: :error 64 | 65 | defp validate_host({url, rest}) do 66 | [domain | uri] = String.split(rest, "/") 67 | 68 | domain = 69 | case String.split(domain, ":") do 70 | # ipv6 71 | [_, _, _, _, _, _, _, _] -> domain 72 | [domain, _port] -> domain 73 | _ -> domain 74 | end 75 | 76 | erl_host = String.to_charlist(domain) 77 | 78 | if :inet_parse.domain(erl_host) or 79 | match?({:ok, _}, :inet_parse.ipv4strict_address(erl_host)) or 80 | match?({:ok, _}, :inet_parse.ipv6strict_address(erl_host)) do 81 | {url, Enum.join(uri, "/")} 82 | else 83 | :error 84 | end 85 | end 86 | 87 | defp validate_uri(:error), do: :error 88 | 89 | defp validate_uri({url, uri}) do 90 | if uri == URI.encode(uri) |> URI.decode() do 91 | {:ok, url} 92 | else 93 | :error 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/fields/email.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFields.Email do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :string 5 | 6 | @doc """ 7 | Validate that the given value is a valid email 8 | 9 | ## Examples 10 | 11 | iex> EctoFields.Email.cast("foo.bar@example.com ") 12 | {:ok, "foo.bar@example.com"} 13 | 14 | iex> EctoFields.Email.cast("foo.bar+baz/@long.example.photography.uk") 15 | {:ok, "foo.bar+baz/@long.example.photography.uk"} 16 | 17 | iex> EctoFields.Email.cast("test@localhost") 18 | {:ok, "test@localhost"} 19 | 20 | iex> EctoFields.Email.cast("test@192.168.10.1") 21 | {:ok, "test@192.168.10.1"} 22 | 23 | iex> EctoFields.Email.cast("test@2001:1620:28:1:b6f:8bca:93:a116") 24 | {:ok, "test@2001:1620:28:1:b6f:8bca:93:a116"} 25 | 26 | iex> EctoFields.Email.cast("foo.bar@example.com/") 27 | :error 28 | 29 | iex> EctoFields.Email.cast("foo.bar@example.com.") 30 | :error 31 | 32 | iex> EctoFields.Email.cast("bad email") 33 | :error 34 | 35 | iex> EctoFields.Email.cast("test@example.com