├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── assets └── patterns.png ├── guides └── overview.md ├── lib ├── mix │ └── tasks │ │ ├── phx.gen.solid.service.ex │ │ └── phx.gen.solid.value.ex └── phx_gen_solid │ └── generator.ex ├── mix.exs ├── mix.lock ├── priv └── templates │ └── phx.gen.solid │ ├── service_create.ex │ ├── service_delete.ex │ ├── service_update.ex │ ├── value.ex │ └── value_context.ex └── test ├── mix └── tasks │ ├── phx.gen.solid.service_test.exs │ └── phx.gen.solid.value_test.exs ├── mix_helper.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 | phx_gen_solid-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | .gitconfig 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Remote 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phx.gen.solid 2 | 3 | A Phoenix generator for Handlers, Services, Finders, and Values. 4 | 5 | **Still early in development, some features may be missing. Expect bugs.** 6 | 7 | ## Overview 8 | 9 | `mix phx.gen.solid` exists to generate the boilerplate usually required when 10 | utilizing the SOLID principles, outlined below. By default it provides fairly 11 | general templates for each of the handlers(WIP), services(WIP), finders(WIP), 12 | and values. However, all of the templates are completely overrideable. 13 | 14 | - Marcelo Lebre's talk - [Four patterns to save your codebase and your sanity](https://www.youtube.com/watch?v=xWqOR-cdIUQ) 15 | 16 | ### Installation 17 | 18 | After running `mix phx.new`, `cd` into your application's directory (ex. `my_app`). 19 | 20 | #### Basic Installation 21 | 22 | 1. Add `phx_gen_solid` to your list of dependencies in `mix.exs` 23 | 24 | ```elixir 25 | def deps do 26 | [ 27 | {:phx_gen_solid, "~> 0.3", only: [:dev], runtime: false} 28 | ... 29 | ] 30 | end 31 | ``` 32 | 33 | 2. Install and compile dependencies 34 | 35 | ``` 36 | $ mix do deps.get, deps.compile 37 | ``` 38 | 39 | ### Running the generators 40 | 41 | From the root of your phoenix app, you can run the following generators 42 | 43 | #### mix phx.gen.solid.value 44 | 45 | This generator will build you a simple Value 46 | 47 | $ mix phx.gen.solid.value Accounts User users id slug name 48 | 49 | This creates a Value in `MyApp.Accounts.Value.User`. By default the allowed 50 | fields for this value will be the arguments you passed into the generator, 51 | in this case, `@valid_fields [:id, :slug, :name]`. 52 | 53 | To generate the helpers along with the value: 54 | 55 | $ mix phx.gen.solid.value Accounts User users id slug name --helpers 56 | 57 | In addition to what gets created above, this will also generate the helpers 58 | context with a default name of `MyApp.Value`. 59 | 60 | To override the name of the module where the helpers exist: 61 | 62 | $ mix phx.gen.solid.value Accounts User users id slug name --value-context MyApp.Helpers.Value 63 | 64 | This will override the name used in the generated value to alias the module 65 | given. 66 | 67 | #### mix phx.gen.solid.service 68 | 69 | Generates C~~R~~UD Services for a resource. 70 | 71 | $ mix phx.gen.solid.service Accounts User users id slug name 72 | 73 | The first argument is the context module followed by the schema module and its 74 | plural name. 75 | 76 | This creates the following services: 77 | - `MyApp.Accounts.Service.CreateUser` 78 | - `MyApp.Accounts.Service.UpdateUser` 79 | - `MyApp.Accounts.Service.DeleteUser` 80 | 81 | #### mix phx.gen.solid.handler 82 | 83 | TODO 84 | 85 | #### mix phx.gen.solid.finder 86 | 87 | TODO 88 | -------------------------------------------------------------------------------- /assets/patterns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remoteoss/phx_gen_solid/334e227b5925019ba21bcc38c286d9df14c44e02/assets/patterns.png -------------------------------------------------------------------------------- /guides/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | > Still early in development & subject to change 4 | 5 | `mix phx.gen.solid` exists to generate the boilerplate usually required when 6 | utilizing the SOLID principles, outlined below, in a larger phoenix project. 7 | By default it provides fairly general templates for each of the handlers, 8 | services, finders, and values. However, all of the templates are completely 9 | overrideable. 10 | 11 | ## Currently Supported Generators 12 | 13 | - `Mix.Tasks.Phx.Gen.Solid.Value` - used to generate a value 14 | - `Mix.Tasks.Phx.Gen.Solid.Handler` - TODO 15 | - `Mix.Tasks.Phx.Gen.Solid.Service` - used to generate C~~R~~UD services 16 | - `Mix.Tasks.Phx.Gen.Solid.Finder` - TODO 17 | 18 | ## SOLID Principles 19 | 20 | The best way to contain cyclomatic complexity is by employing SOLID principles whenever applicable: 21 | 22 | > Single-responsibility principle - A class/module should only have a single responsibility 23 | 24 | > Open-closed principle - Software entities should be open to extension but closed to modification 25 | 26 | > Liskov Substitution principle - Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program. 27 | 28 | > Interface Segregation principle - Many client-specific interfaces are better than one general-purpose interface. 29 | 30 | > Dependency inversion principle - Abstractions over concretions 31 | 32 | ## 4 Patterns 33 | 34 | A way to enforce the SOLID principles is by implementing a combination of 4 35 | design patterns and their interactions to guide codebase scalability. 36 | 37 | - Handlers 38 | - Services 39 | - Finders 40 | - Values 41 | 42 | ![Pattern Interaction Map](assets/patterns.png) 43 | 44 | ### Handlers 45 | 46 | Handlers are orchestators. They exist only to dispatch and compose. It orders 47 | execution of tasks and/or fetches data to put a response back together. 48 | 49 | **Do** 50 | 51 | - Organize by business logic, domain, or sub-domain 52 | - Orchestrate high level operations 53 | - Command services, finders, values or other handlers 54 | - Multiple public functions 55 | - Keep controllers thin 56 | - Make it easy to read 57 | - Flow control (if, case, pattern match, etc.) 58 | 59 | **Don't** 60 | 61 | - Directly create/modify data structures 62 | - Execute any read/write operations 63 | 64 | Below is an example of a handler that creates a user, sends a notification, and 65 | fetches some data. 66 | 67 | ```elixir 68 | defmodule Remoteoss.Handler.Registration do 69 | alias Remoteoss.Accounts.Service.{CreateUser, SendNotification} 70 | alias Remoteoss.Accounts.Finder.SuperHeroName 71 | 72 | def setup_user(name) do 73 | with {:ok, user} <- CreateUser.call(name), 74 | :ok <- SendNotification.call(user), 75 | super_hero_details <- SuperHeroName.find(name) do 76 | {user, super_hero_details} 77 | else 78 | error -> 79 | error 80 | end 81 | end 82 | end 83 | ``` 84 | 85 | ### Services 86 | 87 | Services are the execution arm. Services execute actions, write data, invoke 88 | third party services, etc. 89 | 90 | **Do** 91 | 92 | - Organize by Application Logic 93 | - Reusable across Handlers and other Services 94 | - Commands services, finders and values 95 | - Focuses on achieving one single goal 96 | - Exposes a single public function: `call` 97 | - Create/modify data structures 98 | - Execute and take actions 99 | 100 | **Don't** 101 | 102 | - Use a service to achieve multiple goals 103 | - Call Handlers 104 | - If too big you need to break it into smaller services or your service is 105 | actually a handler 106 | 107 | Below is an example of a service that creates a user. 108 | 109 | ```elixir 110 | defmodule Remoteoss.Accounts.Service.CreateUser do 111 | alias Remoteoss.Accounts 112 | alias Remoteoss.Service.ActivityLog 113 | require Logger 114 | 115 | def call(name) do 116 | with {:ok, user} <- Accounts.create_user(%{name: name}), 117 | :ok <- ActivityLog.call(:create_user) do 118 | {:ok, user} 119 | else 120 | {:error, %Ecto.Changeset{} = changeset} -> 121 | {:error, {:invalid_params, changeset.errors}} 122 | 123 | error -> 124 | error 125 | end 126 | end 127 | end 128 | ``` 129 | 130 | ### Finders 131 | 132 | Finders fetch data, they don't mutate nor write, only read and present. 133 | 134 | Non-complex database queries may also exist in Phoenix Contexts. A query can be 135 | considered complex when their are several conditions for filtering, ordering, 136 | and/or pagination. Rule of thumb is when passing a params or opts Map variable 137 | to the function, a Finder is more appropriate. 138 | 139 | **Do** 140 | 141 | - Organized by Application Logic 142 | - Reusable across Handlers and Services 143 | - Focuses on achieving one single goal 144 | - Exposes a single public function: `find` 145 | - Read data structure 146 | - Uses Values to return complex data 147 | - Finders only read and look up data 148 | 149 | **Don't** 150 | 151 | - Call any services 152 | - Create/modify data structures 153 | 154 | Below is an example of a finder that finds a user. 155 | 156 | ```elixir 157 | defmodule Remoteoss.Accounts.Finder.UserWithName do 158 | alias Remoteoss.Accounts 159 | 160 | def find(name) when is_binary(name) do 161 | case Accounts.get_user_by_name(name) do 162 | nil -> {:error, :not_found} 163 | user -> {:ok, user} 164 | end 165 | end 166 | 167 | def find(_), do: {:error, :invalid_name} 168 | end 169 | ``` 170 | 171 | ### Values 172 | 173 | Values allow us to compose data structures such as responses, 174 | intermediate objects, etc. 175 | 176 | **Do** 177 | 178 | - Organize by Application Logic 179 | - Reusable across Handlers, Services, and Finders 180 | - Focuses on composing a data structure 181 | - Exposes a single public function: `build` 182 | - Use composition to build through simple logic 183 | - Only returns a `List` or a `Map` 184 | 185 | **Don't** 186 | 187 | - Call any Services, Handlers or Finders 188 | 189 | Below is an example of a value that builds a user object to be used in a JSON 190 | response. Note this utilizes the helper functions generated with 191 | `Mix.Tasks.Phx.Gen.Solid.Value`. 192 | 193 | ```elixir 194 | defmodule Remoteoss.Accounts.Value.User do 195 | alias Remoteoss.Value 196 | 197 | @valid_fields [:id, :name] 198 | 199 | def build(user, valid_fields \\ @valid_fields) 200 | 201 | def build(nil, _), do: nil 202 | 203 | def build(user, valid_fields) do 204 | user 205 | |> Value.init() 206 | |> Value.only(valid_fields) 207 | end 208 | end 209 | ``` 210 | -------------------------------------------------------------------------------- /lib/mix/tasks/phx.gen.solid.service.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Phx.Gen.Solid.Service do 2 | @shortdoc "Generates C~~R~~UD services for a resource" 3 | 4 | @moduledoc """ 5 | Generates C~~R~~UD Services for a resource. 6 | 7 | mix phx.gen.solid.service Accounts User users 8 | 9 | The first argument is the context module followed by the schema module and its 10 | plural name. 11 | 12 | This creates the following services: 13 | - `MyApp.Accounts.Service.CreateUser` 14 | - `MyApp.Accounts.Service.UpdateUser` 15 | - `MyApp.Accounts.Service.DeleteUser` 16 | 17 | For more information about the generated Services, see the [Overview](overview.html). 18 | """ 19 | 20 | use Mix.Task 21 | 22 | alias Mix.Phoenix.Context 23 | alias Mix.Tasks.Phx.Gen 24 | alias PhxGenSolid.Generator 25 | 26 | @switches [] 27 | 28 | @impl true 29 | def run(args) do 30 | if Mix.Project.umbrella?() do 31 | Mix.raise("mix phx.gen.solid can only be run inside an application directory") 32 | end 33 | 34 | {opts, parsed} = OptionParser.parse!(args, strict: @switches) 35 | 36 | # Don't pass along the opts 37 | {context, schema} = Gen.Context.build(parsed, __MODULE__) 38 | 39 | binding = [ 40 | context: context, 41 | opts: opts, 42 | schema: schema, 43 | web_app_name: Generator.web_app_name(context), 44 | service_create_module: build_cud_module_name(context, schema, "Create"), 45 | service_update_module: build_cud_module_name(context, schema, "Update"), 46 | service_delete_module: build_cud_module_name(context, schema, "Delete") 47 | ] 48 | 49 | paths = Generator.paths() 50 | Generator.prompt_for_conflicts(context, &files_to_be_generated/1) 51 | Generator.copy_new_files(context, binding, paths, &files_to_be_generated/1) 52 | end 53 | 54 | def raise_with_help(msg), do: Generator.raise_with_help(msg) 55 | 56 | defp files_to_be_generated(%Context{schema: schema} = context) do 57 | [ 58 | {:eex, "service_create.ex", 59 | Path.join([context.dir, "services", "create_#{schema.singular}.ex"])}, 60 | {:eex, "service_update.ex", 61 | Path.join([context.dir, "services", "update_#{schema.singular}.ex"])}, 62 | {:eex, "service_delete.ex", 63 | Path.join([context.dir, "services", "delete_#{schema.singular}.ex"])} 64 | ] 65 | end 66 | 67 | defp build_cud_module_name(context, schema, action) do 68 | Module.concat([ 69 | context.base_module, 70 | "#{inspect(context.alias)}", 71 | "Service", 72 | "#{action}#{inspect(schema.alias)}" 73 | ]) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/mix/tasks/phx.gen.solid.value.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Phx.Gen.Solid.Value do 2 | @shortdoc "Generates Value logic for a resource" 3 | 4 | @moduledoc """ 5 | Generates Value logic for a resource. 6 | 7 | mix phx.gen.solid.value Accounts User users id name age 8 | 9 | The first argument is the context module followed by the schema module and its 10 | plural name. 11 | 12 | This creates a new Value in `MyApp.Accounts.Value.User`. By default the 13 | allowed fields for this value will be the arguments you passed into the 14 | generator, in this case, `@valid_fields [:id, :slug, :name]`. 15 | 16 | **Options** 17 | - `--helpers` - This will generate the Value helpers context in `MyApp.Value`. 18 | Module name can be overridden by `--value-context`. 19 | - `--value-context MODULE` - This will be the name used for the helpers alias 20 | and/or helper modue name when generated. Defaults to `MyApp.Value`. 21 | 22 | The generated Value relies on a few helper functions also generated by this 23 | task. By default it will be placed in your projects context folder. 24 | 25 | For more information about the generated Value, see the [Overview](overview.html). 26 | """ 27 | 28 | use Mix.Task 29 | 30 | alias Mix.Phoenix.Context 31 | alias Mix.Tasks.Phx.Gen 32 | alias PhxGenSolid.Generator 33 | 34 | @switches [helpers: :boolean, value_context: :string] 35 | 36 | @impl true 37 | def run(args) do 38 | if Mix.Project.umbrella?() do 39 | Mix.raise("mix phx.gen.solid can only be run inside an application directory") 40 | end 41 | 42 | {opts, parsed} = OptionParser.parse!(args, strict: @switches) 43 | 44 | # Don't pass along the opts 45 | {context, schema} = Gen.Context.build(parsed, __MODULE__) 46 | 47 | binding = [ 48 | context: context, 49 | opts: opts, 50 | schema: schema, 51 | web_app_name: Generator.web_app_name(context), 52 | value_context: build_value_context(context, opts), 53 | value_fields: build_value_fields(schema.attrs), 54 | value_module: 55 | Module.concat([ 56 | context.base_module, 57 | "#{inspect(context.alias)}", 58 | "Value", 59 | "#{inspect(schema.alias)}" 60 | ]) 61 | ] 62 | 63 | paths = Generator.paths() 64 | Generator.prompt_for_conflicts(context, &files_to_be_generated/2, opts) 65 | Generator.copy_new_files(context, binding, paths, &files_to_be_generated/2, opts) 66 | end 67 | 68 | def raise_with_help(msg), do: Generator.raise_with_help(msg) 69 | 70 | defp build_value_context(context, opts) do 71 | default_context = Module.concat([context.base_module, "Value"]) 72 | 73 | case Keyword.has_key?(opts, :value_context) do 74 | true -> 75 | opts 76 | |> Keyword.get(:value_context) 77 | |> String.split(".") 78 | |> Module.concat() 79 | 80 | false -> 81 | default_context 82 | end 83 | end 84 | 85 | defp files_to_be_generated(%Context{schema: schema} = context, opts) do 86 | should_gen_helpers = Keyword.get(opts, :helpers, false) 87 | 88 | base_files = [ 89 | {:eex, "value.ex", Path.join([context.dir, "values", "#{schema.singular}.ex"])} 90 | ] 91 | 92 | helpers = helper_files_to_be_generated(context) 93 | 94 | maybe_gen_helpers(base_files, helpers, should_gen_helpers) 95 | end 96 | 97 | defp helper_files_to_be_generated(%Context{context_app: context_app}) do 98 | app_path = Mix.Phoenix.context_lib_path(context_app, "") 99 | 100 | [ 101 | {:eex, "value_context.ex", Path.join([app_path, "value.ex"])} 102 | ] 103 | end 104 | 105 | defp maybe_gen_helpers(base_files, helpers, true), do: Enum.concat(base_files, helpers) 106 | defp maybe_gen_helpers(base_files, _helpers, false), do: base_files 107 | 108 | defp build_value_fields(attrs) do 109 | Keyword.keys(attrs) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/phx_gen_solid/generator.ex: -------------------------------------------------------------------------------- 1 | defmodule PhxGenSolid.Generator do 2 | alias Mix.Phoenix.Context 3 | 4 | # The paths to look for template files for generators. 5 | # 6 | # Defaults to checking the current app's `priv` directory and falls back to 7 | # phx_gen_solid's `priv` directory. 8 | def paths do 9 | [".", :phx_gen_solid, :phoenix] 10 | end 11 | 12 | def web_app_name(%Context{} = context) do 13 | context.web_module 14 | |> inspect() 15 | |> Phoenix.Naming.underscore() 16 | end 17 | 18 | def copy_new_files(%Context{} = context, binding, paths, files_fn) do 19 | files = files_fn.(context) 20 | Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.solid", binding, files) 21 | 22 | context 23 | end 24 | 25 | def copy_new_files(%Context{} = context, binding, paths, files_fn, opts) do 26 | files = files_fn.(context, opts) 27 | Mix.Phoenix.copy_from(paths, "priv/templates/phx.gen.solid", binding, files) 28 | 29 | context 30 | end 31 | 32 | def prompt_for_conflicts(context, files_fn) do 33 | context 34 | |> files_fn.() 35 | |> Mix.Phoenix.prompt_for_conflicts() 36 | end 37 | 38 | def prompt_for_conflicts(context, files_fn, opts) do 39 | context 40 | |> files_fn.(opts) 41 | |> Mix.Phoenix.prompt_for_conflicts() 42 | end 43 | 44 | def raise_with_help(msg) do 45 | Mix.raise(""" 46 | #{msg} 47 | 48 | mix phx.gen.solid.service and phx.gen.solid.value expect a context module 49 | name, followed by a singular and plural name of the generated resource, 50 | ending with any number of attributes. 51 | For example: 52 | 53 | mix phx.gen.solid.service Accounts User users name:string 54 | mix phx.gen.solid.value Accounts User users name:string 55 | 56 | The context serves as the API boundary for the given resource. Multiple 57 | resources may belong to a context and a resource may be split over distinct 58 | contexts (such as Accounts.User and Payments.User). 59 | """) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhxGenSolid.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.0" 5 | 6 | def project do 7 | [ 8 | app: :phx_gen_solid, 9 | version: @version, 10 | description: "A SOLID generator for Phoenix 1.7 applications", 11 | elixir: "~> 1.13", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | docs: docs(), 15 | package: package() 16 | ] 17 | end 18 | 19 | # Run "mix help compile.app" to learn about applications. 20 | def application do 21 | [ 22 | extra_applications: [:logger] 23 | ] 24 | end 25 | 26 | # Run "mix help deps" to learn about dependencies. 27 | defp deps do 28 | [ 29 | {:phoenix, "~> 1.7.6"}, 30 | {:phx_new, "~> 1.7.6", only: [:dev, :test]}, 31 | # Docs 32 | {:ex_doc, "~> 0.29.4", only: :dev, runtime: false} 33 | ] 34 | end 35 | 36 | defp docs do 37 | [ 38 | main: "overview", 39 | source_ref: "v#{@version}", 40 | source_url: "https://github.com/remoteoss/phx_gen_solid", 41 | assets: "assets", 42 | extras: extras() 43 | ] 44 | end 45 | 46 | defp extras do 47 | ["guides/overview.md"] 48 | end 49 | 50 | defp package do 51 | [ 52 | maintainers: ["Kramer Hampton"], 53 | licenses: ["MIT"], 54 | links: %{"GitHub" => "https://github.com/remoteoss/phx_gen_solid"} 55 | ] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, 4 | "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, 5 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 8 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 10 | "phoenix": {:hex, :phoenix, "1.7.6", "61f0625af7c1d1923d582470446de29b008c0e07ae33d7a3859ede247ddaf59a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f6b4be7780402bb060cbc6e83f1b6d3f5673b674ba73cc4a7dd47db0322dfb88"}, 11 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 12 | "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, 13 | "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, 14 | "phx_new": {:hex, :phx_new, "1.7.6", "b91ebcfa77976a18c0d0f3b9091ceab89d462f7958bea1f8a6764b516adba586", [:mix], [], "hexpm", "07aa3648f65201aa27cc8db14dee71c9e57addd94f3585af4f57d9d15d6cd2df"}, 15 | "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, 16 | "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, 17 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 18 | "websock": {:hex, :websock, "0.5.2", "b3c08511d8d79ed2c2f589ff430bd1fe799bb389686dafce86d28801783d8351", [:mix], [], "hexpm", "925f5de22fca6813dfa980fb62fd542ec43a2d1a1f83d2caec907483fe66ff05"}, 19 | "websock_adapter": {:hex, :websock_adapter, "0.5.3", "4908718e42e4a548fc20e00e70848620a92f11f7a6add8cf0886c4232267498d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "cbe5b814c1f86b6ea002b52dd99f345aeecf1a1a6964e209d208fb404d930d3d"}, 20 | } 21 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.solid/service_create.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect service_create_module %> do 2 | @moduledoc """ 3 | Creates a <%= schema.singular %>. 4 | """ 5 | 6 | alias <%= inspect context.module %> 7 | 8 | def call(params) do 9 | params 10 | |> <%= inspect context.alias %>.create_<%= schema.singular %>(params) 11 | |> handle_result() 12 | end 13 | 14 | defp handle_result(result), do: result 15 | end 16 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.solid/service_delete.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect service_delete_module %> do 2 | @moduledoc """ 3 | Deletes a <%= schema.singular %>. 4 | """ 5 | 6 | alias <%= inspect context.module %> 7 | 8 | def call(params) do 9 | params 10 | |> <%= inspect context.alias %>.delete_<%= schema.singular %>(params) 11 | |> handle_result() 12 | end 13 | 14 | defp handle_result(result), do: result 15 | end 16 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.solid/service_update.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect service_update_module %> do 2 | @moduledoc """ 3 | Updates a <%= schema.singular %>. 4 | """ 5 | 6 | alias <%= inspect context.module %> 7 | 8 | def call(params) do 9 | params 10 | |> <%= inspect context.alias %>.update_<%= schema.singular %>(params) 11 | |> handle_result() 12 | end 13 | 14 | defp handle_result(result), do: result 15 | end 16 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.solid/value.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect value_module %> do 2 | @moduledoc """ 3 | The <%= schema.singular %> value. 4 | """ 5 | alias <%= inspect value_context %> 6 | 7 | @<%= schema.singular %>_fields <%= inspect value_fields %> 8 | 9 | def build(<%= schema.singular %>, <%= schema.singular %>_fields \\ @<%= schema.singular %>_fields) 10 | 11 | def build(nil, _), do: nil 12 | 13 | def build(<%= schema.singular %>, <%= schema.singular %>_fields) do 14 | <%= schema.singular %> 15 | |> Value.init() 16 | |> Value.only(<%= schema.singular %>_fields) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.solid/value_context.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect value_context %> do 2 | @moduledoc """ 3 | Module to allow better Value composition. With this module we're able to 4 | compose complex structures faster and simpler. 5 | 6 | A Value's base format can only be a List or a Map. 7 | """ 8 | 9 | def build(module, data, opts \\ []) do 10 | data 11 | |> module.build() 12 | |> filter_fields(opts) 13 | |> remove_fields(opts) 14 | end 15 | 16 | defp filter_fields(value, opts) do 17 | case Keyword.get(opts, :only) do 18 | nil -> value 19 | fields -> __MODULE__.only(value, fields) 20 | end 21 | end 22 | 23 | defp remove_fields(value, opts) do 24 | case Keyword.get(opts, :except) do 25 | nil -> value 26 | fields -> __MODULE__.except(value, fields) 27 | end 28 | end 29 | 30 | @doc """ 31 | Initiate a Value base format as a List 32 | 33 | ## Examples 34 | iex> init_with_list() 35 | [] 36 | """ 37 | def init_with_list, do: [] 38 | 39 | @doc """ 40 | Initiate a Value base format as a Map 41 | 42 | ## Examples 43 | iex> init_with_map() 44 | %{} 45 | """ 46 | def init_with_map, do: %{} 47 | 48 | @doc """ 49 | Initiate a Value based on a pre-existing Struct. 50 | 51 | ## Examples 52 | iex> country = %Country{name: "Portugal", region: "Europe", slug: "slug", code: "code"} 53 | %Country{name: "Portugal", region: "Europe", slug: "slug", code: "code"} 54 | iex> init(country) 55 | %{name: "Portugal", region: "Europe", slug: "slug", code: "code"} 56 | 57 | iex> init(%{a: 1}) 58 | %{a: 1} 59 | iex> init([1, 2, 3]) 60 | [1, 2, 3] 61 | """ 62 | def init(%{__struct__: _} = value) do 63 | value 64 | |> Map.from_struct() 65 | |> Map.drop([:__meta__, :__struct__]) 66 | end 67 | 68 | # Initiate a Value based on a pre-existing Map or List. 69 | def init(value) do 70 | value 71 | end 72 | 73 | @doc """ 74 | Remove specified keys from a Value. 75 | 76 | ## Examples 77 | iex> response = init(%{a: 1, b: 2}) 78 | %{a: 1, b: 2} 79 | iex> except(response, [:a]) 80 | %{b: 2} 81 | """ 82 | def except(value, keys) when is_map(value), do: Map.drop(value, keys) 83 | 84 | @doc """ 85 | Return only specified keys from a Value. 86 | 87 | ## Examples 88 | iex> response = init(%{a: 1, b: 2}) 89 | %{a: 1, b: 2} 90 | iex> only(response, [:a]) 91 | %{a: 1} 92 | """ 93 | def only(value, keys) when is_map(value), do: Map.take(value, keys) 94 | 95 | @doc """ 96 | Add an item to a Value list. 97 | 98 | ## Examples 99 | iex> response = init([1, 2, 3]) 100 | [1, 2, 3] 101 | iex> add(response, 4) 102 | [4, 1, 2, 3] 103 | 104 | iex> response = init(%{a: 1, b: 2}) 105 | %{a: 1, b: 2} 106 | iex> add(response, %{c: 3}) 107 | %{a: 1, b: 2, c: 3} 108 | iex> add(response, c: 3) 109 | %{a: 1, b: 2, c: 3} 110 | """ 111 | def add(value, entry) when is_list(value), do: [entry | value] 112 | 113 | # Add an item to a value map. Accepts a Map or a simple keyword list. 114 | def add(value, entry) when is_map(value) do 115 | Enum.reduce(entry, value, fn {key, key_value}, acc -> 116 | Map.put(acc, key, key_value) 117 | end) 118 | end 119 | 120 | @doc """ 121 | Removes keys with `nil` values from the map 122 | """ 123 | def compact(map) do 124 | map 125 | |> Enum.reject(fn {_, value} -> is_nil(value) end) 126 | |> Map.new() 127 | end 128 | 129 | @doc """ 130 | Modifies provided key by applying provided function. 131 | If key is not present it won't be updated, no exception be raised. 132 | 133 | ## Examples 134 | iex> response = init(%{a: 1, b: 2}) 135 | %{a: 1, b: 2} 136 | iex> modify(response, :b, fn val -> val * 2 end) 137 | %{a: 1, b: 4} 138 | iex> modify(response, :c, fn val -> val * 2 end) 139 | %{a: 1, b: 2} 140 | """ 141 | def modify(data, key, fun) when is_map(data) and is_function(fun) do 142 | data 143 | |> Map.update(key, nil, fun) 144 | |> compact() 145 | end 146 | 147 | @doc """ 148 | build associations with their own 'Value' modules when their are present, 149 | avoiding `nil` or unloaded structs 150 | """ 151 | def build_assoc(value_module, assoc, fields \\ nil) 152 | def build_assoc(_value_module, nil, _), do: nil 153 | def build_assoc(_value_module, %Ecto.Association.NotLoaded{}, _), do: nil 154 | def build_assoc(value_module, assoc, nil), do: value_module.build(assoc) 155 | def build_assoc(value_module, assoc, fields), do: value_module.build(assoc, fields) 156 | end 157 | -------------------------------------------------------------------------------- /test/mix/tasks/phx.gen.solid.service_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../mix_helper.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Phx.Gen.Solid.ServiceTest do 4 | use ExUnit.Case 5 | import MixHelper 6 | alias Mix.Tasks.Phx.Gen.Solid 7 | 8 | setup do 9 | Mix.Task.clear() 10 | :ok 11 | end 12 | 13 | test "invalid mix args", config do 14 | in_tmp_project(config.test, fn -> 15 | assert_raise Mix.Error, 16 | ~r/Expected the context, "accounts", to be a valid module name/, 17 | fn -> 18 | Solid.Service.run(~w(accounts User users name:string)) 19 | end 20 | 21 | assert_raise Mix.Error, ~r/Expected the schema, "users", to be a valid module name/, fn -> 22 | Solid.Service.run(~w(User users name:string)) 23 | end 24 | 25 | assert_raise Mix.Error, ~r/The context and schema should have different names/, fn -> 26 | Solid.Service.run(~w(Accounts Accounts users)) 27 | end 28 | 29 | assert_raise Mix.Error, ~r/Invalid arguments/, fn -> 30 | Solid.Service.run(~w(Accounts.User users)) 31 | end 32 | 33 | assert_raise Mix.Error, ~r/Invalid arguments/, fn -> 34 | Solid.Service.run(~w(Accounts User)) 35 | end 36 | end) 37 | end 38 | 39 | test "generates create, update, delete services", config do 40 | in_tmp_project(config.test, fn -> 41 | Solid.Service.run(~w(Accounts User users name:string)) 42 | 43 | assert_file("lib/phx_gen_solid/accounts/services/create_user.ex", fn file -> 44 | assert file =~ "defmodule PhxGenSolid.Accounts.Service.CreateUser" 45 | assert file =~ "alias PhxGenSolid.Accounts" 46 | 47 | assert file =~ """ 48 | def call(params) do 49 | params 50 | |> Accounts.create_user(params) 51 | |> handle_result() 52 | end 53 | """ 54 | 55 | assert file =~ "defp handle_result(result), do: result" 56 | end) 57 | 58 | assert_file("lib/phx_gen_solid/accounts/services/update_user.ex", fn file -> 59 | assert file =~ "defmodule PhxGenSolid.Accounts.Service.UpdateUser" 60 | assert file =~ "alias PhxGenSolid.Accounts" 61 | 62 | assert file =~ """ 63 | def call(params) do 64 | params 65 | |> Accounts.update_user(params) 66 | |> handle_result() 67 | end 68 | """ 69 | 70 | assert file =~ "defp handle_result(result), do: result" 71 | end) 72 | 73 | assert_file("lib/phx_gen_solid/accounts/services/delete_user.ex", fn file -> 74 | assert file =~ "defmodule PhxGenSolid.Accounts.Service.DeleteUser" 75 | assert file =~ "alias PhxGenSolid.Accounts" 76 | 77 | assert file =~ """ 78 | def call(params) do 79 | params 80 | |> Accounts.delete_user(params) 81 | |> handle_result() 82 | end 83 | """ 84 | 85 | assert file =~ "defp handle_result(result), do: result" 86 | end) 87 | end) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/mix/tasks/phx.gen.solid.value_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../../mix_helper.exs", __DIR__) 2 | 3 | defmodule Mix.Tasks.Phx.Gen.Solid.ValueTest do 4 | use ExUnit.Case 5 | import MixHelper 6 | alias Mix.Tasks.Phx.Gen.Solid 7 | 8 | setup do 9 | Mix.Task.clear() 10 | :ok 11 | end 12 | 13 | test "invalid mix args", config do 14 | in_tmp_project(config.test, fn -> 15 | assert_raise Mix.Error, 16 | ~r/Expected the context, "accounts", to be a valid module name/, 17 | fn -> 18 | Solid.Value.run(~w(accounts User users name:string)) 19 | end 20 | 21 | assert_raise Mix.Error, ~r/Expected the schema, "users", to be a valid module name/, fn -> 22 | Solid.Value.run(~w(User users name:string)) 23 | end 24 | 25 | assert_raise Mix.Error, ~r/The context and schema should have different names/, fn -> 26 | Solid.Value.run(~w(Accounts Accounts users)) 27 | end 28 | 29 | assert_raise Mix.Error, ~r/Invalid arguments/, fn -> 30 | Solid.Value.run(~w(Accounts.User users)) 31 | end 32 | 33 | assert_raise Mix.Error, ~r/Invalid arguments/, fn -> 34 | Solid.Value.run(~w(Accounts User)) 35 | end 36 | end) 37 | end 38 | 39 | test "generates value", config do 40 | in_tmp_project(config.test, fn -> 41 | Solid.Value.run(~w(Accounts User users name:string)) 42 | 43 | assert_file("lib/phx_gen_solid/accounts/values/user.ex", fn file -> 44 | assert file =~ "defmodule PhxGenSolid.Accounts.Value.User" 45 | assert file =~ "alias PhxGenSolid.Value" 46 | assert file =~ "@user_fields [:name]" 47 | 48 | assert file =~ "def build(user, user_fields \\\\ @user_fields)" 49 | assert file =~ "def build(nil, _), do: nil" 50 | 51 | assert file =~ """ 52 | def build(user, user_fields) do 53 | user 54 | |> Value.init() 55 | |> Value.only(user_fields) 56 | end 57 | """ 58 | end) 59 | 60 | # Because we didn't pass --helpers make sure we didn't generate it 61 | refute_file("lib/phx_gen_solid/value.ex") 62 | end) 63 | end 64 | 65 | test "creates helpers when option is passed", config do 66 | in_tmp_project(config.test, fn -> 67 | Solid.Value.run(~w(Accounts User users name:string --helpers)) 68 | 69 | assert_file("lib/phx_gen_solid/value.ex", fn file -> 70 | assert file =~ "defmodule PhxGenSolid.Value" 71 | end) 72 | end) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/mix_helper.exs: -------------------------------------------------------------------------------- 1 | # Silence mix generator output during tests 2 | Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule MixHelper do 5 | import ExUnit.Assertions 6 | 7 | def tmp_path do 8 | Path.expand("../tmp", __DIR__) 9 | end 10 | 11 | defp random_string(len) do 12 | len |> :crypto.strong_rand_bytes() |> Base.encode64() |> binary_part(0, len) 13 | end 14 | 15 | def in_tmp_project(which, function) do 16 | path_root_folder = Path.join([tmp_path(), random_string(10)]) 17 | path = Path.join([path_root_folder, to_string(which)]) 18 | 19 | try do 20 | File.rm_rf!(path) 21 | File.mkdir_p!(path) 22 | 23 | File.cd!(path, fn -> 24 | File.touch!("mix.exs") 25 | 26 | File.write!(".formatter.exs", """ 27 | [ 28 | import_deps: [:phx_gen_solid], 29 | inputs: ["*.exs"] 30 | ] 31 | """) 32 | 33 | function.() 34 | end) 35 | after 36 | File.rm_rf!(path_root_folder) 37 | end 38 | end 39 | 40 | def assert_file(file) do 41 | assert File.regular?(file), "Expected #{file} to exist, but does not" 42 | end 43 | 44 | def refute_file(file) do 45 | refute File.regular?(file), "Expected #{file} to not exist, but it does" 46 | end 47 | 48 | def assert_file(file, match) do 49 | cond do 50 | is_list(match) -> 51 | assert_file(file, &Enum.each(match, fn m -> assert &1 =~ m end)) 52 | 53 | is_binary(match) or is_struct(match, Regex) -> 54 | assert_file(file, &assert(&1 =~ match)) 55 | 56 | is_function(match, 1) -> 57 | assert_file(file) 58 | match.(File.read!(file)) 59 | 60 | true -> 61 | raise inspect({file, match}) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------