Giter Club home page Giter Club logo

strukt's Introduction

Strukt

Strukt provides an extended defstruct macro which builds on top of Ecto.Schema and Ecto.Changeset to remove the boilerplate of defining type specifications, implementing validations, generating changesets from parameters, JSON serialization, and support for autogenerated fields.

This builds on top of Ecto embedded schemas, so the same familiar syntax you use today to define schema'd types in Ecto, can now be used to define structs for general purpose usage.

The functionality provided by the defstruct macro in this module is strictly a superset of the functionality provided both by Kernel.defstruct/1, as well as Ecto.Schema. If you import it in a scope where you use Kernel.defstruct/1 already, it will not interfere. Likewise, the support for defining validation rules inline with usage of field/3, embeds_one/3, etc., is strictly additive, and those additions are stripped from the AST before field/3 and friends ever see it.

Installation

def deps do
  [
    {:strukt, "~> 0.3"}
  ]
end

Example

The following is an example of using defstruct/1 to define a struct with types, autogenerated primary key, and inline validation rules.

defmodule Person do
  use Strukt

  @derives [Jason.Encoder]
  @primary_key {:uuid, Ecto.UUID, autogenerate: true}
  @timestamps_opts [autogenerate: {NaiveDateTime, :utc_now, []}]

  defstruct do
    field :name, :string, required: true
    field :email, :string, format: ~r/^.+@.+$/

    timestamps()
  end
end

And an example of how you would create and use this struct:

# Creating from params, with autogeneration of fields
iex> {:ok, person} = Person.new(name: "Paul", email: "[email protected]")
...> person
%Person{
  uuid: "d420aa8a-9294-4977-8b00-bacf3789c702",
  name: "Paul",
  email: "[email protected]",
  inserted_at: ~N[2021-06-08 22:21:23.490554],
  updated_at: ~N[2021-06-08 22:21:23.490554]
}

# Validation (Create)
iex> {:error, %Ecto.Changeset{valid?: false, errors: errors}} = Person.new(email: "[email protected]")
...> errors
[name: {"can't be blank", [validation: :required]}]

# Validation (Update)
iex> {:ok, person} = Person.new(name: "Paul", email: "[email protected]")
...> {:error, %Ecto.Changeset{valid?: false, errors: errors}} = Person.change(person, email: "foo")
...> errors
[email: {"has invalid format", [validation: :format]}]

# JSON Serialization/Deserialization
...> person == person |> Jason.encode!() |> Person.from_json()
true

Validation

There are a few different ways to express and customize validation rules for a struct.

  • Inline (as shown above, these consist of the common validations provided by Ecto.Changeset)
  • Validators (with module and function variants, as shown below)
  • Custom (by overriding the validate/1 callback)

The first two are the preferred method of expressing and controlling validation of a struct, but if for some reason you prefer a more manual approach, overriding the validate/1 callback is an option available to you and allows you to completely control validation of the struct.

NOTE: Be aware that if you override validate/1 without calling super/1 at some point in your implementation, none of the inline or module/function validators will be run. It is expected that if you are overriding the implementation, you are either intentionally disabling that functionality, or are intending to delegate to it only in certain circumstances.

Module Validators

This is the primary method of implementing reusable validation rules:

There are two callbacks, init/1 and validate/2. You can choose to omit the implementation of init/1 and a default implementation will be provided for you. The default implementation returns whatever it is given as input. Whatever is returned by init/1 is given as the second argument to validate/2. The validate/2 callback is required.

defmodule MyValidator.ValidPhoneNumber do
  use Strukt.Validator

  @pattern ~r/^(\+1 )[0-9]{3}-[0-9]{3}-[0-9]{4}$/

  @impl true
  def init(opts), do: Enum.into(opts, %{})

  @impl true
  def validate(changeset, %{fields: fields}) do
    Enum.reduce(fields, changeset, fn field, cs ->
      case fetch_change(cs, field) do
        :error ->
          cs

        {:ok, value} when value in [nil, ""] ->
          add_error(cs, field, "phone number cannot be empty")

        {:ok, value} when is_binary(value) ->
          if value =~ @pattern do
            cs
          else
            add_error(cs, field, "invalid phone number")
          end

        {:ok, _} ->
          add_error(cs, field, "expected phone number to be a string")
      end
    end)
  end
end

Function Validators

These are useful for ad-hoc validators that are specific to a single struct and aren't likely to be useful in other contexts. The function is expected to received two arguments, the first is the changeset to be validated, the second any options passed to the validation/2 macro:

defmodule File do
  use Strukt

  @allowed_content_types []

  defstruct do
    field :filename, :string, required: true
    field :content_type, :string
    field :content, :binary
  end

  validation :validate_filename_matches_content_type, @allowed_content_types

  defp validate_filename_matches_content_type(changeset, allowed) do
    # ...
  end
end

As with module validators, the function should always return an Ecto.Changeset.

Conditional Rules

You may express validation rules that apply only conditionally using guard clauses. For example, extending the example above, we could validate that the filename and content type match only when either of those fields are changed:

  # With options
  validation :validate_filename_matches_content_type, @allowed_content_types
    when is_map_key(changeset.changes, :filename) or is_map_key(changeset.changes, :content_type)

  # Without options
  validation :validate_filename_matches_content_type
    when is_map_key(changeset.changes, :filename) or is_map_key(changeset.changes, :content_type)

By default validation rules have an implicit guard of when true if one is not explicitly provided.

Custom Fields

Using the :source option allows you to express that a given field may be provided as a parameter using a different naming scheme than is used in idiomatic Elixir code (i.e. snake case):

defmodule Person do
  use Strukt

  @derives [Jason.Encoder]
  @primary_key {:uuid, Ecto.UUID, autogenerate: true}
  @timestamps_opts [autogenerate: {NaiveDateTime, :utc_now, []}]

  defstruct do
    field :name, :string, required: true, source: :NAME
    field :email, :string, format: ~r/^.+@.+$/

    timestamps()
  end
end

# in iex
iex> {:ok, person} = Person.new(%{NAME: "Ivan", email: "[email protected]"})
...> person
%Person{
  uuid: "f8736f15-bfdc-49bd-ac78-9da514208464",
  name: "Ivan",
  email: "[email protected]",
  inserted_at: ~N[2021-06-08 22:21:23.490554],
  updated_at: ~N[2021-06-08 22:21:23.490554]
}

NOTE: This does not affect serialization/deserialization via Jason.Encoder when derived.

For more, see the usage docs

strukt's People

Contributors

bitwalker avatar fishtreesugar avatar swingcloud avatar zillou avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

strukt's Issues

Incorrect Typespec Generation for Fields with Custom or Parameterized Ecto Types in Strukt

Problem Description:

When defining a field with a custom Ecto type or a parameterized type in Strukt, the generated typespec for the field defaults to any() instead of the expected specific type (e.g., Ecto.UUID.t()). This issue may lead to less precise type checks and unexpected behaviors in development environments that rely on typespecs for static analysis or documentation purposes.

Example Code:

defmodule Foo do
  use Strukt
  
  defstruct do
    field :bar, Ecto.UUID
  end
end

Expected: The typespec for :bar should be Ecto.UUID.t().
Actual: The typespec for :bar is any().

Identified Issues:

Incorrect Handling of value_type:

The value_type in the typespec.ex function is not being correctly passed as a quoted expression. This results in the type not matching the expected case for processing.

Relevant code:

type_name = type_to_type_name(meta.value_type)

defp type_to_type_name({:__aliases__, _, parts} = ast) do

Failure in defines_type? Check:

The defines_type? function check always fails. This function, intended to verify if the current module defines a given type, is not feasible in current Elixir compiler. The docs of defines_type? say

Checks if the current module defines the given type (private, opaque or not).
This function is only available for modules being compiled.

Discussion related to this issue can be found here: Getting the @type module attribute
Relevant code:

if Kernel.Typespec.defines_type?(mod, {:t, 0}) do

Unexpected error raised when validating `embeds_many` field

Description

We are using strukt for validating external data input. For example we defined a schema with embeds_many field: bar:

defmodule Foo do
  use Strukt

  defstruct do
    embeds_many :bar, Bar do
      field :baz, :string
    end
  end
end

when using new/1 to parsing external data with wrong "embedded" type, it raise an unexpected error:

iex(2)> Foo.new(%{bar: "baz"})
** (Protocol.UndefinedError) protocol Enumerable not implemented for "baz" of type BitString. This protocol is implemented for the following type(s): Date.Range, File.Stream, Function, GenEvent.Stream, HashDict, HashSet, IO.Stream, Jason.OrderedObject, List, Map, MapSet, Range, Stream
    (elixir 1.16.1) lib/enum.ex:1: Enumerable.impl_for!/1
    (elixir 1.16.1) lib/enum.ex:166: Enumerable.reduce/3
    (elixir 1.16.1) lib/enum.ex:4399: Enum.map_reduce/3
    (elixir 1.16.1) lib/enum.ex:3865: Enum.with_index/2
    (strukt 0.3.2) lib/params.ex:41: Strukt.Params.transform/4
    (strukt 0.3.2) lib/params.ex:72: Strukt.Params.map_value_to_field/4
    (elixir 1.16.1) lib/enum.ex:1700: Enum."-map/2-lists^map/1-1-"/2
    iex:2: (file)

According to the stacktrace, it happens during conforming params:

formed_params = Strukt.Params.transform(__MODULE__, params, struct)

Expected behaviour

returns a validation error:

{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{},
   errors: [bar: {"is invalid", [validation: :embed, type: {:array, :map}]}],
   data: #Foo<>,
   valid?: false
 >}

Precise typing of embeds

Currently strukt typing embeds_one :foo, Foo's type as Foo.t(), embeds_many :foos, Foo as [Foo.t()]:

strukt/lib/typespec.ex

Lines 72 to 77 in 2b6c643

|> Enum.map(fn
{name, %{type: :embeds_one, value_type: type}} ->
{name, compose_call(type, :t, [])}
{name, %{type: :embeds_many, value_type: type}} ->
{name, List.wrap(compose_call(type, :t, []))}

it couldn't handle situations like

  • :foo field could be null which type should be Foo.t() | nil,
  • :foos could be nil, but element couldn't: [Foo.t()] | nil
  • :foos could be nil, element's could be nil too: nil | [Foo.t() | nil]

any idea for handle these cases?

when passing params with nil for embed_one field, will have `FunctionClauseError`

Given a struct below

  defmodule CustomFieldsWithEmbeddedSchema do
    use Strukt

    defstruct do
      field :name, :string
      embeds_one :meta, Meta
    end
  end

  defmodule Meta do
    use Strukt

    defstruct do
      field(:name, :string)
    end
  end

in iex

iex(5)> CustomFieldsWithEmbeddedSchema.new(%{meta: nil})
** (FunctionClauseError) no function clause matching in CustomFieldsWithEmbeddedSchema.transform_params/4

    The following arguments were given to CustomFieldsWithEmbeddedSchema.transform_params/4:

        # 1
        Meta

        # 2
        nil

        # 3
        nil

        # 4
        []

    lib/strukt.ex:1: CustomFieldsWithEmbeddedSchema.transform_params/4
    lib/strukt.ex:564: CustomFieldsWithEmbeddedSchema.map_value_to_field/4
    lib/strukt.ex:532: anonymous fn/5 in CustomFieldsWithEmbeddedSchema.transform_params/3
    (elixir 1.12.2) lib/enum.ex:2385: Enum."-reduce/3-lists^foldl/2-0-"/3
    lib/strukt.ex:529: CustomFieldsWithEmbeddedSchema.transform_params/3
    lib/strukt.ex:356: CustomFieldsWithEmbeddedSchema.new/1

dialyxir warning when define a schema without embedded fields

Hello, thanks for create an amazing library.

I met a dialyxir warning when I use this library:

# foo.ex
defmodule Foo do
  use Strukt

  defstruct do
    field(:bar, :string)
    field(:baz, :string)
  end
end

and dialyxir(1.1.0) wanning at first line:

The pattern can never match the type.

Pattern:
_ = %Ecto.Changeset{:params => _}, [_ | _]

Type:

  %Ecto.Changeset{
    :action => :delete | :insert | nil | :update,
    :changes => %{atom() => _},
    :constraints => [map()],
    :data => nil | map(),
    :empty_values => _,
    :errors => [{_, _}],
    :filters => %{atom() => _},
    :params => nil | %{binary() => _},
    :prepare => [(_ -> any())],
    :repo => atom(),
    :repo_opts => [{_, _}],
    :required => [atom()],
    :types => nil | %{atom() => atom() | {_, _} | {_, _, _}},
    :valid? => boolean(),
    :validations => [{_, _}]
  },
  []

I guess this message is due to when @cast_embed_fields is [] we defined a "dead code" with type %Ecto.Changeset{:params => _}, [_ | _]

strukt/lib/strukt.ex

Lines 455 to 462 in 236c253

|> __cast_embeds__(@cast_embed_fields)
|> __validate__()
|> validate()
end
defp __cast_embeds__(changeset, []), do: changeset
defp __cast_embeds__(%Ecto.Changeset{params: params} = changeset, [field | fields]) do

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.