diff --git a/.formatter.exs b/.formatter.exs index d2cda26..7c0a633 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,4 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test,dev}/**/*.{ex,exs}"] ] diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e10daf5..ff3302f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,20 +15,16 @@ jobs: elixir-version: - 1.11.4 - 1.12.3 - - 1.13.3 + - 1.13.4 otp-version: - 22.3 - 23.3 - - 24.0 + - 24.3 include: - - elixir-version: 1.9.4 - otp-version: 21.3 - - elixir-version: 1.9.4 - otp-version: 22.3 - elixir-version: 1.10.4 otp-version: 22.3 - elixir-version: 1.10.4 - otp-version: 24.0 + otp-version: 23.3 env: MIX_ENV: test steps: diff --git a/dev/mix/tasks/type_check.gen.ex b/dev/mix/tasks/type_check.gen.ex new file mode 100644 index 0000000..7311b3d --- /dev/null +++ b/dev/mix/tasks/type_check.gen.ex @@ -0,0 +1,18 @@ +defmodule Mix.Tasks.TypeCheck.Gen do + @moduledoc """ + Automatically generates TypeCheck override modules from a particular + dependency and puts them in the appropriate place + """ + + @shortdoc "Automatically generates TypeCheck override modules" + @requirements ["app.start"] + + use Mix.Task + + @impl Mix.Task + def run(_args) do + TypedEctoSchema.TypeCheckGen.generate() + IO.puts("Formatting...") + Mix.Task.run("format") + end +end diff --git a/dev/typed_ecto_schema/type_check_gen.ex b/dev/typed_ecto_schema/type_check_gen.ex new file mode 100644 index 0000000..4fcd339 --- /dev/null +++ b/dev/typed_ecto_schema/type_check_gen.ex @@ -0,0 +1,213 @@ +defmodule TypedEctoSchema.TypeCheckGen do + def generate(prefix \\ TypedEctoSchema.Overrides, typecheck_module \\ TypedEctoSchema.TypeCheck) do + modules_to_override = + for app <- [:ecto, :decimal], + {:ok, modules} = :application.get_key(app, :modules), + module <- modules, + Code.ensure_loaded?(module), + def_types?(module), + do: {module, Module.concat(prefix, module)} + + for {source, target} <- modules_to_override do + source + |> generate_override_module(target, typecheck_module) + |> write_file(target) + end + + modules_to_override + |> generate_typecheck_module(typecheck_module) + |> write_file(typecheck_module) + end + + def generate_typecheck_module(modules_to_override, typecheck_module) do + overrides = + modules_to_override + |> Enum.flat_map(fn {source, target} -> overrides_for(source, target) end) + |> Enum.sort() + + code = + quote do + if Code.ensure_loaded?(TypeCheck) do + defmodule unquote(typecheck_module) do + @moduledoc :REPLACE_WITH_DOCS + + @overrides unquote(Macro.escape(overrides)) + + defmacro __using__(opts) do + quote do + use TypeCheck, unquote(opts) ++ [overrides: unquote(Macro.escape(@overrides))] + end + end + + def overrides do + @overrides + end + end + end + end + |> Macro.to_string() + + new_doc = ~S[ + """ + If you use TypeCheck, you can use this module to enable our default overrides. + + You can either use this module directly + + defmodule MySchema do + use TypedEctoSchema.TypeCheck + use TypedEctoSchema + + typed_schema "source" do + field(:int, :integer) + end + end + + Or you can use `TypedEctoSchema.TypeCheck.overrides/0` instead + + defmodule MySchema do + use TypeCheck, overrides: TypedEctoSchema.TypeCheck.overrides() + use TypedEctoSchema + + typed_schema "source" do + field(:int, :integer) + end + end + + This is useful if you also have your own overrides you want to mix in. + + Consider also creating your own `MyApp.TypeCheck` to simplify using it. + """ + ] + + String.replace(code, ":REPLACE_WITH_DOCS", "#{String.trim(new_doc)}\n") + end + + defp overrides_for(source, target) do + {:ok, types} = Code.Typespec.fetch_types(source) + + for {type, {name, _, args}} <- types, type in [:type, :opaque] do + {{source, name, length(args)}, {target, name, length(args)}} + end + end + + def generate_override_module(source, target, typecheck_module) do + {:ok, types} = Code.Typespec.fetch_types(source) + + to_lazify = fetch_lazy_types(types) + + overrides = + for {type_type, {name, type_def, args}} <- types do + type_def = lazify_user_types(type_def, to_lazify) + type_code = Code.Typespec.type_to_quoted({name, type_def, args}) + + {:@, [context: Elixir], [{:"#{type_type}!", [context: Elixir], [type_code]}]} + end + + quote do + if Code.ensure_loaded?(TypeCheck) do + defmodule unquote(target) do + @moduledoc false + + use unquote(typecheck_module) + + unquote_splicing(overrides) + end + end + end + end + + defp fetch_lazy_types(types) do + graph = :digraph.new() + + ids = + for {_, {name, type, args}} <- types do + id = {name, length(args)} + :digraph.add_vertex(graph, id) + refs = [] |> find_references(type) |> Enum.uniq() + + for ref <- refs do + :digraph.add_vertex(graph, ref) + :digraph.add_edge(graph, id, ref) + end + + id + end + + # Heuristic: make the most connected nodes lazy first + ids = Enum.sort_by(ids, &{elem(&1, 1), -length(:digraph.edges(graph, &1))}) + + result = + for id <- ids, reduce: MapSet.new() do + acc -> + if :digraph.get_cycle(graph, {:t, 0}) do + :digraph.del_vertex(graph, id) + MapSet.put(acc, id) + else + acc + end + end + + :digraph.delete(graph) + + result + end + + defp def_types?(module) do + try do + match?({:ok, [_ | _]}, Code.Typespec.fetch_types(module)) + rescue + _ -> false + end + end + + def find_references(refs, {type, _line, name, args}) do + refs = + case type do + :user_type -> [{name, length(args)} | refs] + _ -> refs + end + + find_arg_references(refs, args) + end + + def find_references(refs, _), do: refs + + defp find_arg_references(refs, []), do: refs + + defp find_arg_references(refs, args) when is_list(args) do + Enum.reduce(args, refs, &find_references(&2, &1)) + end + + defp find_arg_references(refs, _args), do: refs + + defp lazify_user_types({:user_type, line, name, args}, to_lazify) do + if MapSet.member?(to_lazify, {name, length(args)}) do + {:user_type, line, :lazy, [{:user_type, line, name, lazify_args(args, to_lazify)}]} + else + {:user_type, line, name, lazify_args(args, to_lazify)} + end + end + + defp lazify_user_types({type, line, name, args}, to_lazify) do + {type, line, name, lazify_args(args, to_lazify)} + end + + defp lazify_user_types(other, _to_lazify), do: other + + defp lazify_args(args, to_lazify) when is_list(args) do + Enum.map(args, &lazify_user_types(&1, to_lazify)) + end + + defp lazify_args(args, _to_lazify), do: args + + defp write_file(code, module) when is_binary(code) do + path = Path.join([File.cwd!(), "lib", "#{Macro.underscore(module)}.ex"]) + + File.mkdir_p!(Path.dirname(path)) + File.write!(path, code) + end + + defp write_file(code, module) do + write_file(Macro.to_string(code), module) + end +end diff --git a/lib/typed_ecto_schema.ex b/lib/typed_ecto_schema.ex index 4452d52..80be03d 100644 --- a/lib/typed_ecto_schema.ex +++ b/lib/typed_ecto_schema.ex @@ -96,6 +96,32 @@ defmodule TypedEctoSchema do defines a default (`default: value`), since it makes no sense to have a default value for an enforced field. - `:opaque` - When `true` makes the generated type `t` be an opaque type. + - `:type_check` - Enable [experimental integration with + TypeCheck](#module-typecheck-integration-experimental) + + ## TypeCheck Integration (Experimental) + + We are currently experimenting with a [TypeCheck](https://hexdocs.pm/type_check/) integration, + but because it might break, we are making it opt-in. You can either specify on the + [schema options](#module-schema-options) or globally using config: + + config :typed_ecto_schema, type_check: true + + What this integration enables is that by doing + + defmodule InteropWithTypeCheck do + use TypedEctoSchema + use TypeCheck + + typed_embedded_schema type_check: true do + field(:year, :number) + end + end + + You then should be able to + + iex> InteropWithTypeCheck.t() + #TypeCheck.Type< InteropWithTypeCheck.t() :: %InteropWithTypeCheck{id: binary() | nil, year: integer() | nil} > ## Type Inference diff --git a/lib/typed_ecto_schema/overrides/decimal.ex b/lib/typed_ecto_schema/overrides/decimal.ex new file mode 100644 index 0000000..ee2a7fc --- /dev/null +++ b/lib/typed_ecto_schema/overrides/decimal.ex @@ -0,0 +1,13 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Decimal do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! decimal() :: t() | integer() | String.t() + @type! t() :: %Decimal{coef: coefficient(), exp: exponent(), sign: sign()} + @type! rounding() :: :down | :half_up | :half_even | :ceiling | :floor | :half_down | :up + @type! signal() :: :invalid_operation | :division_by_zero | :rounded | :inexact + @type! sign() :: 1 | -1 + @type! exponent() :: integer() + @type! coefficient() :: non_neg_integer() | :NaN | :inf + end +end diff --git a/lib/typed_ecto_schema/overrides/decimal/context.ex b/lib/typed_ecto_schema/overrides/decimal/context.ex new file mode 100644 index 0000000..430ae98 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/decimal/context.ex @@ -0,0 +1,13 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Decimal.Context do + @moduledoc false + use TypedEctoSchema.TypeCheck + + @type! t() :: %Decimal.Context{ + flags: [Decimal.signal()], + precision: pos_integer(), + rounding: Decimal.rounding(), + traps: [Decimal.signal()] + } + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/adapter.ex b/lib/typed_ecto_schema/overrides/ecto/adapter.ex new file mode 100644 index 0000000..9886161 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/adapter.ex @@ -0,0 +1,8 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Adapter do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! adapter_meta() :: map() + @type! t() :: module() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/adapter/queryable.ex b/lib/typed_ecto_schema/overrides/ecto/adapter/queryable.ex new file mode 100644 index 0000000..f16dd2c --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/adapter/queryable.ex @@ -0,0 +1,17 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Adapter.Queryable do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! selected() :: term() + @type! options() :: Keyword.t() + @type! cached() :: term() + @type! prepared() :: term() + @type! query_cache() :: + {:nocache, prepared()} + | {:cache, cache_function :: (cached() -> :ok), prepared()} + | {:cached, update_function :: (cached() -> :ok), + reset_function :: (prepared() -> :ok), cached()} + @type! query_meta() :: %{sources: tuple(), preloads: term(), select: map()} + @type! adapter_meta() :: Ecto.Adapter.adapter_meta() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/adapter/schema.ex b/lib/typed_ecto_schema/overrides/ecto/adapter/schema.ex new file mode 100644 index 0000000..0bdd009 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/adapter/schema.ex @@ -0,0 +1,26 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Adapter.Schema do + @moduledoc false + use TypedEctoSchema.TypeCheck + + @type! on_conflict() :: + {:raise, list(), []} + | {:nothing, list(), [atom()]} + | {[atom()], list(), [atom()]} + | {Ecto.Query.t(), list(), [atom()]} + @type! options() :: Keyword.t() + @type! placeholders() :: [term()] + @type! returning() :: [atom()] + @type! constraints() :: Keyword.t() + @type! filters() :: Keyword.t() + @type! fields() :: Keyword.t() + @type! schema_meta() :: %{ + autogenerate_id: {schema_field :: atom(), source_field :: atom(), Ecto.Type.t()}, + context: term(), + prefix: binary() | nil, + schema: atom(), + source: binary() + } + @type! adapter_meta() :: Ecto.Adapter.adapter_meta() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/adapter/transaction.ex b/lib/typed_ecto_schema/overrides/ecto/adapter/transaction.ex new file mode 100644 index 0000000..74a54d2 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/adapter/transaction.ex @@ -0,0 +1,7 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Adapter.Transaction do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! adapter_meta() :: Ecto.Adapter.adapter_meta() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/association.ex b/lib/typed_ecto_schema/overrides/ecto/association.ex new file mode 100644 index 0000000..671b9c2 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/association.ex @@ -0,0 +1,18 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Association do + @moduledoc false + use TypedEctoSchema.TypeCheck + + @type! t() :: %{ + :__struct__ => atom(), + :on_cast => nil | (... -> any()), + :cardinality => :one | :many, + :relationship => :parent | :child, + :owner => atom(), + :owner_key => atom(), + :field => atom(), + :unique => boolean(), + optional(atom()) => any() + } + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/association/not_loaded.ex b/lib/typed_ecto_schema/overrides/ecto/association/not_loaded.ex new file mode 100644 index 0000000..29a03f8 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/association/not_loaded.ex @@ -0,0 +1,12 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Association.NotLoaded do + @moduledoc false + use TypedEctoSchema.TypeCheck + + @type! t() :: %Ecto.Association.NotLoaded{ + __cardinality__: atom(), + __field__: atom(), + __owner__: any() + } + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/changeset.ex b/lib/typed_ecto_schema/overrides/ecto/changeset.ex new file mode 100644 index 0000000..411ea85 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/changeset.ex @@ -0,0 +1,37 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Changeset do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! types() :: map() + @type! data() :: map() + @type! constraint() :: %{ + type: :check | :exclusion | :foreign_key | :unique, + constraint: String.t(), + match: :exact | :suffix | :prefix, + field: atom(), + error_message: String.t(), + error_type: atom() + } + @type! action() :: nil | :insert | :update | :delete | :replace | :ignore | atom() + @type! error() :: {String.t(), Keyword.t()} + @type! t() :: t(Ecto.Schema.t() | map() | nil) + @type! t(data_type) :: %Ecto.Changeset{ + action: action(), + changes: %{optional(atom()) => term()}, + constraints: [constraint()], + data: data_type, + empty_values: term(), + errors: [{atom(), error()}], + filters: %{optional(atom()) => term()}, + params: %{optional(String.t()) => term()} | nil, + prepare: [(lazy(t()) -> lazy(t()))], + repo: atom() | nil, + repo_opts: Keyword.t(), + required: [atom()], + types: + nil | %{required(atom()) => Ecto.Type.t() | {:assoc, term()} | {:embed, term()}}, + valid?: boolean(), + validations: [{atom(), term()}] + } + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/changeset/relation.ex b/lib/typed_ecto_schema/overrides/ecto/changeset/relation.ex new file mode 100644 index 0000000..9f035f3 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/changeset/relation.ex @@ -0,0 +1,18 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Changeset.Relation do + @moduledoc false + use TypedEctoSchema.TypeCheck + + @type! t() :: %{ + :__struct__ => atom(), + :cardinality => :one | :many, + :on_replace => :raise | :mark_as_invalid | atom(), + :relationship => :parent | :child, + :ordered => boolean(), + :owner => atom(), + :related => atom(), + :field => atom(), + optional(atom()) => any() + } + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/multi.ex b/lib/typed_ecto_schema/overrides/ecto/multi.ex new file mode 100644 index 0000000..c545f20 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/multi.ex @@ -0,0 +1,25 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Multi do + @moduledoc false + use TypedEctoSchema.TypeCheck + @typep! names() :: MapSet.t() + @typep! operations() :: [{name(), lazy(operation())}] + @typep! operation() :: + {:changeset, Ecto.Changeset.t(), Keyword.t()} + | {:run, run()} + | {:put, any()} + | {:inspect, Keyword.t()} + | {:merge, merge()} + | {:update_all, Ecto.Query.t(), Keyword.t()} + | {:delete_all, Ecto.Query.t(), Keyword.t()} + | {:insert_all, schema_or_source(), [map() | Keyword.t()], Keyword.t()} + @typep! schema_or_source() :: binary() | {binary(), module()} | module() + @type! t() :: %Ecto.Multi{names: names(), operations: operations()} + @type! name() :: any() + @type! merge() :: (changes() -> t()) | {module(), atom(), [any()]} + @type! fun(result) :: (changes() -> result) + @type! run() :: + (Ecto.Repo.t(), changes() -> {:ok | :error, any()}) | {module(), atom(), [any()]} + @type! changes() :: map() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/parameterized_type.ex b/lib/typed_ecto_schema/overrides/ecto/parameterized_type.ex new file mode 100644 index 0000000..752af89 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/parameterized_type.ex @@ -0,0 +1,8 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.ParameterizedType do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! params() :: term() + @type! opts() :: keyword() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/query.ex b/lib/typed_ecto_schema/overrides/ecto/query.ex new file mode 100644 index 0000000..9ef217f --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/query.ex @@ -0,0 +1,35 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Query do + @moduledoc false + use TypedEctoSchema.TypeCheck + + @opaque! dynamic() :: %Ecto.Query.DynamicExpr{ + binding: term(), + file: term(), + fun: term(), + line: term() + } + @type! t() :: %Ecto.Query{ + aliases: term(), + assocs: term(), + combinations: term(), + distinct: term(), + from: term(), + group_bys: term(), + havings: term(), + joins: term(), + limit: term(), + lock: term(), + offset: term(), + order_bys: term(), + prefix: term(), + preloads: term(), + select: term(), + sources: term(), + updates: term(), + wheres: term(), + windows: term(), + with_ctes: term() + } + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/query/builder.ex b/lib/typed_ecto_schema/overrides/ecto/query/builder.ex new file mode 100644 index 0000000..895b7f9 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/query/builder.ex @@ -0,0 +1,7 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Query.Builder do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! quoted_type() :: Ecto.Type.primitive() | {non_neg_integer(), atom() | Macro.t()} + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/queryable.ex b/lib/typed_ecto_schema/overrides/ecto/queryable.ex new file mode 100644 index 0000000..804da62 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/queryable.ex @@ -0,0 +1,7 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Queryable do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! t() :: term() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/repo.ex b/lib/typed_ecto_schema/overrides/ecto/repo.ex new file mode 100644 index 0000000..40d9259 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/repo.ex @@ -0,0 +1,7 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Repo do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! t() :: module() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/schema.ex b/lib/typed_ecto_schema/overrides/ecto/schema.ex new file mode 100644 index 0000000..ad6fb4b --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/schema.ex @@ -0,0 +1,21 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Schema do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! embeds_many(t) :: [t] + @type! embeds_one(t) :: t + @type! many_to_many(t) :: [t] | Ecto.Association.NotLoaded.t() + @type! has_many(t) :: [t] | Ecto.Association.NotLoaded.t() + @type! has_one(t) :: t | Ecto.Association.NotLoaded.t() + @type! belongs_to(t) :: t | Ecto.Association.NotLoaded.t() + @type! t() :: schema() | embedded_schema() + @type! embedded_schema() :: %{optional(atom()) => any(), __struct__: atom()} + @type! schema() :: %{ + optional(atom()) => any(), + __struct__: atom(), + __meta__: Ecto.Schema.Metadata.t() + } + @type! prefix() :: String.t() | nil + @type! source() :: String.t() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/schema/metadata.ex b/lib/typed_ecto_schema/overrides/ecto/schema/metadata.ex new file mode 100644 index 0000000..518b4a1 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/schema/metadata.ex @@ -0,0 +1,16 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Schema.Metadata do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! t() :: t(module()) + @type! t(schema) :: %Ecto.Schema.Metadata{ + context: context(), + prefix: Ecto.Schema.prefix(), + schema: schema, + source: Ecto.Schema.source(), + state: state() + } + @type! context() :: any() + @type! state() :: :built | :loaded | :deleted + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/type.ex b/lib/typed_ecto_schema/overrides/ecto/type.ex new file mode 100644 index 0000000..b3141db --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/type.ex @@ -0,0 +1,31 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.Type do + @moduledoc false + use TypedEctoSchema.TypeCheck + + @typep! private_composite() :: + {:maybe, lazy(t())} | {:in, lazy(t())} | {:param, :any_datetime} + @type! composite() :: {:array, lazy(t())} | {:map, lazy(t())} | private_composite() + @type! base() :: + :integer + | :float + | :boolean + | :string + | :map + | :binary + | :decimal + | :id + | :binary_id + | :utc_datetime + | :naive_datetime + | :date + | :time + | :any + | :utc_datetime_usec + | :naive_datetime_usec + | :time_usec + @type! custom() :: module() | {:parameterized, module(), term()} + @type! primitive() :: base() | composite() + @type! t() :: primitive() | custom() + end +end diff --git a/lib/typed_ecto_schema/overrides/ecto/uuid.ex b/lib/typed_ecto_schema/overrides/ecto/uuid.ex new file mode 100644 index 0000000..5e77686 --- /dev/null +++ b/lib/typed_ecto_schema/overrides/ecto/uuid.ex @@ -0,0 +1,8 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.Overrides.Ecto.UUID do + @moduledoc false + use TypedEctoSchema.TypeCheck + @type! raw() :: <<_::128>> + @type! t() :: <<_::288>> + end +end diff --git a/lib/typed_ecto_schema/type_builder.ex b/lib/typed_ecto_schema/type_builder.ex index 7d2eac3..f665b85 100644 --- a/lib/typed_ecto_schema/type_builder.ex +++ b/lib/typed_ecto_schema/type_builder.ex @@ -65,14 +65,30 @@ defmodule TypedEctoSchema.TypeBuilder do end defmacro __define_type__(types, schema_opts) do - if Keyword.get(schema_opts, :opaque, false) do - quote bind_quoted: [types: types] do - @opaque t() :: %__MODULE__{unquote_splicing(types)} - end - else - quote bind_quoted: [types: types] do - @type t() :: %__MODULE__{unquote_splicing(types)} - end + typecheck_required? = TypeCheck.Macros in __CALLER__.requires + use_typecheck? = typecheck_required? and typecheck_enabled?(schema_opts) + opaque? = Keyword.get(schema_opts, :opaque, false) + + case {use_typecheck?, opaque?} do + {true, true} -> + quote bind_quoted: [types: types] do + TypeCheck.Macros.opaque!(t() :: %__MODULE__{unquote_splicing(types)}) + end + + {true, false} -> + quote bind_quoted: [types: types] do + TypeCheck.Macros.type!(t() :: %__MODULE__{unquote_splicing(types)}) + end + + {false, true} -> + quote bind_quoted: [types: types] do + @opaque t() :: %__MODULE__{unquote_splicing(types)} + end + + {false, false} -> + quote bind_quoted: [types: types] do + @type t() :: %__MODULE__{unquote_splicing(types)} + end end end @@ -169,4 +185,9 @@ defmodule TypedEctoSchema.TypeBuilder do schema_opts[:enforce] && is_nil(field_opts[:default]) ) end + + defp typecheck_enabled?(schema_opts) do + config = Application.get_env(:typed_ecto_schema, :type_check, false) + Keyword.get(schema_opts, :type_check, config) + end end diff --git a/lib/typed_ecto_schema/type_check.ex b/lib/typed_ecto_schema/type_check.ex new file mode 100644 index 0000000..b41f3a2 --- /dev/null +++ b/lib/typed_ecto_schema/type_check.ex @@ -0,0 +1,145 @@ +if Code.ensure_loaded?(TypeCheck) do + defmodule TypedEctoSchema.TypeCheck do + @moduledoc """ + If you use TypeCheck, you can use this module to enable our default overrides. + + You can either use this module directly + + defmodule MySchema do + use TypedEctoSchema.TypeCheck + use TypedEctoSchema + + typed_schema "source" do + field(:int, :integer) + end + end + + Or you can use `TypedEctoSchema.TypeCheck.overrides/0` instead + + defmodule MySchema do + use TypeCheck, overrides: TypedEctoSchema.TypeCheck.overrides() + use TypedEctoSchema + + typed_schema "source" do + field(:int, :integer) + end + end + + This is useful if you also have your own overrides you want to mix in. + + Consider also creating your own `MyApp.TypeCheck` to simplify using it. + """ + + @overrides [ + {{Decimal, :coefficient, 0}, {TypedEctoSchema.Overrides.Decimal, :coefficient, 0}}, + {{Decimal, :decimal, 0}, {TypedEctoSchema.Overrides.Decimal, :decimal, 0}}, + {{Decimal, :exponent, 0}, {TypedEctoSchema.Overrides.Decimal, :exponent, 0}}, + {{Decimal, :rounding, 0}, {TypedEctoSchema.Overrides.Decimal, :rounding, 0}}, + {{Decimal, :sign, 0}, {TypedEctoSchema.Overrides.Decimal, :sign, 0}}, + {{Decimal, :signal, 0}, {TypedEctoSchema.Overrides.Decimal, :signal, 0}}, + {{Decimal, :t, 0}, {TypedEctoSchema.Overrides.Decimal, :t, 0}}, + {{Decimal.Context, :t, 0}, {TypedEctoSchema.Overrides.Decimal.Context, :t, 0}}, + {{Ecto.Adapter, :adapter_meta, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter, :adapter_meta, 0}}, + {{Ecto.Adapter, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Adapter, :t, 0}}, + {{Ecto.Adapter.Queryable, :adapter_meta, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Queryable, :adapter_meta, 0}}, + {{Ecto.Adapter.Queryable, :cached, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Queryable, :cached, 0}}, + {{Ecto.Adapter.Queryable, :options, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Queryable, :options, 0}}, + {{Ecto.Adapter.Queryable, :prepared, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Queryable, :prepared, 0}}, + {{Ecto.Adapter.Queryable, :query_cache, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Queryable, :query_cache, 0}}, + {{Ecto.Adapter.Queryable, :query_meta, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Queryable, :query_meta, 0}}, + {{Ecto.Adapter.Queryable, :selected, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Queryable, :selected, 0}}, + {{Ecto.Adapter.Schema, :adapter_meta, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :adapter_meta, 0}}, + {{Ecto.Adapter.Schema, :constraints, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :constraints, 0}}, + {{Ecto.Adapter.Schema, :fields, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :fields, 0}}, + {{Ecto.Adapter.Schema, :filters, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :filters, 0}}, + {{Ecto.Adapter.Schema, :on_conflict, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :on_conflict, 0}}, + {{Ecto.Adapter.Schema, :options, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :options, 0}}, + {{Ecto.Adapter.Schema, :placeholders, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :placeholders, 0}}, + {{Ecto.Adapter.Schema, :returning, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :returning, 0}}, + {{Ecto.Adapter.Schema, :schema_meta, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Schema, :schema_meta, 0}}, + {{Ecto.Adapter.Transaction, :adapter_meta, 0}, + {TypedEctoSchema.Overrides.Ecto.Adapter.Transaction, :adapter_meta, 0}}, + {{Ecto.Association, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Association, :t, 0}}, + {{Ecto.Association.NotLoaded, :t, 0}, + {TypedEctoSchema.Overrides.Ecto.Association.NotLoaded, :t, 0}}, + {{Ecto.Changeset, :action, 0}, {TypedEctoSchema.Overrides.Ecto.Changeset, :action, 0}}, + {{Ecto.Changeset, :constraint, 0}, + {TypedEctoSchema.Overrides.Ecto.Changeset, :constraint, 0}}, + {{Ecto.Changeset, :data, 0}, {TypedEctoSchema.Overrides.Ecto.Changeset, :data, 0}}, + {{Ecto.Changeset, :error, 0}, {TypedEctoSchema.Overrides.Ecto.Changeset, :error, 0}}, + {{Ecto.Changeset, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Changeset, :t, 0}}, + {{Ecto.Changeset, :t, 1}, {TypedEctoSchema.Overrides.Ecto.Changeset, :t, 1}}, + {{Ecto.Changeset, :types, 0}, {TypedEctoSchema.Overrides.Ecto.Changeset, :types, 0}}, + {{Ecto.Changeset.Relation, :t, 0}, + {TypedEctoSchema.Overrides.Ecto.Changeset.Relation, :t, 0}}, + {{Ecto.Multi, :changes, 0}, {TypedEctoSchema.Overrides.Ecto.Multi, :changes, 0}}, + {{Ecto.Multi, :fun, 1}, {TypedEctoSchema.Overrides.Ecto.Multi, :fun, 1}}, + {{Ecto.Multi, :merge, 0}, {TypedEctoSchema.Overrides.Ecto.Multi, :merge, 0}}, + {{Ecto.Multi, :name, 0}, {TypedEctoSchema.Overrides.Ecto.Multi, :name, 0}}, + {{Ecto.Multi, :run, 0}, {TypedEctoSchema.Overrides.Ecto.Multi, :run, 0}}, + {{Ecto.Multi, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Multi, :t, 0}}, + {{Ecto.ParameterizedType, :opts, 0}, + {TypedEctoSchema.Overrides.Ecto.ParameterizedType, :opts, 0}}, + {{Ecto.ParameterizedType, :params, 0}, + {TypedEctoSchema.Overrides.Ecto.ParameterizedType, :params, 0}}, + {{Ecto.Query, :dynamic, 0}, {TypedEctoSchema.Overrides.Ecto.Query, :dynamic, 0}}, + {{Ecto.Query, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Query, :t, 0}}, + {{Ecto.Query.Builder, :quoted_type, 0}, + {TypedEctoSchema.Overrides.Ecto.Query.Builder, :quoted_type, 0}}, + {{Ecto.Queryable, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Queryable, :t, 0}}, + {{Ecto.Repo, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Repo, :t, 0}}, + {{Ecto.Schema, :belongs_to, 1}, {TypedEctoSchema.Overrides.Ecto.Schema, :belongs_to, 1}}, + {{Ecto.Schema, :embedded_schema, 0}, + {TypedEctoSchema.Overrides.Ecto.Schema, :embedded_schema, 0}}, + {{Ecto.Schema, :embeds_many, 1}, {TypedEctoSchema.Overrides.Ecto.Schema, :embeds_many, 1}}, + {{Ecto.Schema, :embeds_one, 1}, {TypedEctoSchema.Overrides.Ecto.Schema, :embeds_one, 1}}, + {{Ecto.Schema, :has_many, 1}, {TypedEctoSchema.Overrides.Ecto.Schema, :has_many, 1}}, + {{Ecto.Schema, :has_one, 1}, {TypedEctoSchema.Overrides.Ecto.Schema, :has_one, 1}}, + {{Ecto.Schema, :many_to_many, 1}, + {TypedEctoSchema.Overrides.Ecto.Schema, :many_to_many, 1}}, + {{Ecto.Schema, :prefix, 0}, {TypedEctoSchema.Overrides.Ecto.Schema, :prefix, 0}}, + {{Ecto.Schema, :schema, 0}, {TypedEctoSchema.Overrides.Ecto.Schema, :schema, 0}}, + {{Ecto.Schema, :source, 0}, {TypedEctoSchema.Overrides.Ecto.Schema, :source, 0}}, + {{Ecto.Schema, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Schema, :t, 0}}, + {{Ecto.Schema.Metadata, :context, 0}, + {TypedEctoSchema.Overrides.Ecto.Schema.Metadata, :context, 0}}, + {{Ecto.Schema.Metadata, :state, 0}, + {TypedEctoSchema.Overrides.Ecto.Schema.Metadata, :state, 0}}, + {{Ecto.Schema.Metadata, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Schema.Metadata, :t, 0}}, + {{Ecto.Schema.Metadata, :t, 1}, {TypedEctoSchema.Overrides.Ecto.Schema.Metadata, :t, 1}}, + {{Ecto.Type, :base, 0}, {TypedEctoSchema.Overrides.Ecto.Type, :base, 0}}, + {{Ecto.Type, :composite, 0}, {TypedEctoSchema.Overrides.Ecto.Type, :composite, 0}}, + {{Ecto.Type, :custom, 0}, {TypedEctoSchema.Overrides.Ecto.Type, :custom, 0}}, + {{Ecto.Type, :primitive, 0}, {TypedEctoSchema.Overrides.Ecto.Type, :primitive, 0}}, + {{Ecto.Type, :t, 0}, {TypedEctoSchema.Overrides.Ecto.Type, :t, 0}}, + {{Ecto.UUID, :raw, 0}, {TypedEctoSchema.Overrides.Ecto.UUID, :raw, 0}}, + {{Ecto.UUID, :t, 0}, {TypedEctoSchema.Overrides.Ecto.UUID, :t, 0}} + ] + defmacro __using__(opts) do + quote do + use TypeCheck, unquote(opts) ++ [overrides: unquote(Macro.escape(@overrides))] + end + end + + def overrides do + @overrides + end + end +end diff --git a/mix.exs b/mix.exs index 023f20e..4869fe3 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,8 @@ defmodule TypedEctoSchema.MixProject do package: package(), docs: docs(), description: - "A library to define Ecto schemas with typespecs without all the boilerplate code." + "A library to define Ecto schemas with typespecs without all the boilerplate code.", + dialyzer: [plt_add_apps: [:mix, :type_check]] ] end @@ -23,7 +24,8 @@ defmodule TypedEctoSchema.MixProject do ] end - defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(:test), do: ["lib", "dev", "test/support"] + defp elixirc_paths(:dev), do: ["lib", "dev"] defp elixirc_paths(_), do: ["lib"] # Run "mix help deps" to learn about dependencies. @@ -36,6 +38,7 @@ defmodule TypedEctoSchema.MixProject do # Project dependencies {:ecto, "~> 3.5"}, + {:type_check, "~> 0.12", optional: true}, # Documentation dependencies {:ex_doc, "~> 0.28", only: :dev, runtime: false} diff --git a/mix.lock b/mix.lock index b5ca2eb..56fd7e8 100644 --- a/mix.lock +++ b/mix.lock @@ -23,5 +23,6 @@ "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "type_check": {:hex, :type_check, "0.12.1", "793e68dfafef2ecb1d94ed6c1ab25687b251f56ffa13bd2015ab0cda958930d4", [:mix], [{:credo, "~> 1.5", [hex: :credo, repo: "hexpm", optional: true]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "196a1b625833c6f69e13e62691c07b21358d2e5a4744e84f778f2db786205b48"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/typed_ecto_schema_test.exs b/test/typed_ecto_schema_test.exs index c18304c..eb07746 100644 --- a/test/typed_ecto_schema_test.exs +++ b/test/typed_ecto_schema_test.exs @@ -74,6 +74,8 @@ defmodule TypedEctoSchemaTest do def get_types, do: Enum.reverse(@__typed_ecto_schema_types__) end + @bytecode bytecode + {:module, _name, bytecode_opaque, _exports} = defmodule OpaqueTestStruct do use TypedEctoSchema @@ -84,6 +86,8 @@ defmodule TypedEctoSchemaTest do end end + @bytecode_opaque bytecode_opaque + defmodule EnforcedTypedEctoSchema do use TypedEctoSchema @@ -158,8 +162,25 @@ defmodule TypedEctoSchemaTest do def get_types, do: Enum.reverse(@__typed_ecto_schema_types__) end - @bytecode bytecode - @bytecode_opaque bytecode_opaque + defmodule EmbeddedSchemaWithTypecheck do + use TypeCheck, overrides: TypedEctoSchema.TypeCheck.overrides() + use TypedEctoSchema + + @primary_key false + typed_embedded_schema type_check: true do + field(:int, :integer) + end + end + + defmodule SchemaWithTypecheck do + use TypeCheck, overrides: TypedEctoSchema.TypeCheck.overrides() + use TypedEctoSchema + + @primary_key false + typed_schema "source", type_check: true do + field(:int, :integer) + end + end # Standard struct name used when comparing generated types. @standard_struct_name TypedEctoSchemaTest.TestStruct @@ -727,6 +748,12 @@ defmodule TypedEctoSchemaTest do |> Ecto.Changeset.put_embed(:one, %{int: 123}) end + test "integrates with TypeCheck if and only if TypeCheck is required" do + assert %TypeCheck.Builtin.NamedType{} = EmbeddedSchemaWithTypecheck.t() + assert %TypeCheck.Builtin.NamedType{} = SchemaWithTypecheck.t() + refute function_exported?(Embedded, :t, 0) + end + ## ## Helpers ##