├── .circleci └── config.yml ├── .ebert.yml ├── .eslintrc ├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets └── logo.png ├── config └── config.exs ├── coveralls.json ├── lib ├── machinery.ex └── machinery │ ├── transition.ex │ └── transitions.ex ├── mix.exs ├── mix.lock ├── package-lock.json ├── package.json └── test ├── machinery └── transition_test.exs ├── machinery_test.exs ├── support ├── test_default_field_struct.exs ├── test_repo.exs ├── test_state_machine.exs ├── test_state_machine_default_field.exs ├── test_state_machine_with_extra_metadata.exs ├── test_state_machine_with_guard.exs └── test_struct.exs └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. 2 | # See: https://circleci.com/docs/2.0/configuration-reference 3 | version: 2.1 4 | 5 | 6 | jobs: 7 | run-tests: 8 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 9 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 10 | docker: 11 | - image: cimg/elixir:1.14.3 12 | # Add steps to the job 13 | # See: https://circleci.com/docs/2.0/configuration-reference/#steps 14 | steps: 15 | - checkout 16 | - run: 17 | name: "Installing Hex" 18 | command: "mix local.hex --force" 19 | - run: 20 | name: "Get Dependencies" 21 | command: "mix deps.get" 22 | - run: 23 | name: "Check Format" 24 | command: "mix format --check-formatted" 25 | - run: 26 | name: "Run Tests" 27 | command: "mix test" 28 | 29 | # Invoke jobs via workflows 30 | # See: https://circleci.com/docs/2.0/configuration-reference/#workflows 31 | workflows: 32 | run-tests-workflow: 33 | jobs: 34 | - run-tests 35 | -------------------------------------------------------------------------------- /.ebert.yml: -------------------------------------------------------------------------------- 1 | # This configuration was used Ebert to review the joaomdmoura/machinery repository 2 | # on 56a7e3e2edce2b0c0c4f8fb2746183a5bc8a3cf1. 3 | # You can make this the default configuration for future reviews by moving this 4 | # file to your repository as `.ebert.yml` and pushing it to GitHub, and tweak 5 | # it as you wish - To know more on how to change this file to better review your 6 | # repository you can go to https://docs.ebertapp.io/configuration and see the configuration 7 | # details. 8 | --- 9 | styleguide: plataformatec/linters 10 | engines: 11 | credo: 12 | enabled: true 13 | fixme: 14 | enabled: false 15 | eslint: 16 | enabled: true 17 | csslint: 18 | enabled: false 19 | remark-lint: 20 | enabled: true 21 | exclude_paths: 22 | - config 23 | - test 24 | - lib/web/static/vendor/**/* 25 | 26 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "require": true, 4 | "exports": true, 5 | "$": true 6 | } 7 | } -------------------------------------------------------------------------------- /.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 | machinery-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Misc. 29 | assets/ 30 | ignore/ 31 | .DS_store 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | ## WIP Version 4 | 5 | ## 1.1.0 6 | - Adding extra metadata support for transitions - [Pull Request](https://github.com/joaomdmoura/machinery/pull/85) 7 | 8 | ## 1.0.0 9 | - Fixing overall TYPOs 10 | [Pull Request#1](https://github.com/joaomdmoura/machinery/pull/45) 11 | [Pull Request#2](https://github.com/joaomdmoura/machinery/pull/49) 12 | [Pull Request#3](https://github.com/joaomdmoura/machinery/pull/56) 13 | [Pull Request#4](https://github.com/joaomdmoura/machinery/pull/65) 14 | [Pull Request#5](https://github.com/joaomdmoura/machinery/pull/61) 15 | - Delegate json_decoder config to phoenix - [Pull Request](https://github.com/joaomdmoura/machinery/pull/47) 16 | - Fixing flaky tests - [Pull Request](https://github.com/joaomdmoura/machinery/pull/50) 17 | - Remove Pheonix dependency (and dashboard feature) - [Pull Request](https://github.com/joaomdmoura/machinery/pull/55) 18 | - Resolve Machinery GenServer Get Timeout - [Pull Request](https://github.com/joaomdmoura/machinery/pull/58) 19 | 20 | 21 | ## 0.17.0 22 | - Allow field name customization - [Pull Request](https://github.com/joaomdmoura/machinery/pull/42) 23 | 24 | ## 0.16.1 25 | - Bumping Ecto version - [Pull Request](https://github.com/joaomdmoura/machinery/pull/38) 26 | 27 | ## 0.16.0 28 | - Support for custom error messages on guard functions - [Pull Request](https://github.com/joaomdmoura/machinery/pull/35) 29 | 30 | ## 0.15.0 31 | - Adding support for transitions logging - [Pull Request](https://github.com/joaomdmoura/machinery/pull/33) 32 | 33 | ## 0.14.0 34 | - Adding support for wildcard transitions - [Pull Request](https://github.com/joaomdmoura/machinery/pull/32) 35 | 36 | ## 0.13.0 37 | - Adding basic auth to Machinery Dashboard - [Pull Request](https://github.com/joaomdmoura/machinery/pull/30) 38 | 39 | ## 0.12.1 40 | - Better treating the JSON return for the Dashboard - [Pull Request](https://github.com/joaomdmoura/machinery/pull/27) 41 | 42 | ## 0.12.0 43 | - Adding new toogle all btn and auto closing alerts - [Pull Request](https://github.com/joaomdmoura/machinery/pull/24) 44 | - Fixing Bug to rollback state transition on the Dashboard - [Pull Request](https://github.com/joaomdmoura/machinery/pull/25) 45 | 46 | ## 0.11.0 47 | - Adding a default config desabling the Machinery Dashboard 48 | - Making the Machinery Dashboard bigger 49 | - Enabling users to overwrite the desired state on Machinery Dashboard - [Pull Request](https://github.com/joaomdmoura/machinery/pull/21) 50 | - Adding the ability to change states form Dashboard - [Pull Request](https://github.com/joaomdmoura/machinery/pull/22) 51 | 52 | ## 0.8.2 53 | - Requiring a previous version of phoenix_html to enable older applications to use Machinery. 54 | - Versioning compiled version of the assets to fix bug on dashboard interface. 55 | 56 | ## 0.8.0 57 | - Adding first version of Machinery Dashboard - [Pull Request](https://github.com/joaomdmoura/machinery/pull/14) 58 | 59 | ## 0.7.0 60 | - Improving docs 61 | - Updating DSl to Decouple Machinery from the struct itself - [Pull Request](https://github.com/joaomdmoura/machinery/pull/10) 62 | - Adding support for automatic persistence - [Pull Request](https://github.com/joaomdmoura/machinery/pull/11) 63 | - Converting states from Atoms to Strings - [Pull Request](https://github.com/joaomdmoura/machinery/pull/12) 64 | 65 | ## 0.4.1 66 | - Updating wrong docs and README - [Pull Request](https://github.com/joaomdmoura/machinery/pull/5) 67 | 68 | ## 0.4.0 69 | - New, more functional DSL, not relying on Macros so much - [Pull Request](https://github.com/joaomdmoura/machinery/pull/1) 70 | - Adding support for before and after callbacks - [Pull Request](https://github.com/joaomdmoura/machinery/pull/2) 71 | 72 | ## 0.2.0 73 | - Enabling states and transitions declarations 74 | - Adding support for guard functions -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at me@joaomdmoura.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you want to help Machinery development we recommend you to start by checking its 4 | [existing Issues](https://github.com/joaomdmoura/machinery/issues). 5 | 6 | You may also want to start with something small like updating docs or the README. 7 | 8 | ## Feature proposal 9 | 10 | If you have a feature you would like to see implemented, before jumping into the code, 11 | it's highly recommended that you open an issue describing your reasons, benefits and drawbacks. 12 | That way, we as a community can check your proposal and give you some feedback, that is extremelly 13 | helpful to achieve better ideas and also keeps you of doing code that might not be approved because 14 | of many reasons. 15 | 16 | ## Bug Report 17 | 18 | Bugs should also be reported through issues, that sould contain how to replicate the bug and some 19 | trace of the error. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Machinery 2 | 3 | [![Build Status](https://circleci.com/gh/joaomdmoura/machinery.svg?style=svg)](https://circleci.com/gh/circleci/circleci-docs) 4 | [![Module Version](https://img.shields.io/hexpm/v/machinery.svg)](https://hex.pm/packages/machinery) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/machinery/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/machinery.svg)](https://hex.pm/packages/machinery) 7 | [![License](https://img.shields.io/hexpm/l/machinery.svg)](https://github.com/joaomdmoura/machinery/blob/master/LICENSE) 8 | 9 | ![Machinery](./assets/logo.png) 10 | 11 | Machinery is a lightweight State Machine library for Elixir with built-in 12 | Phoenix integration. 13 | It provides a simple DSL for declaring states and includes support for guard 14 | clauses and callbacks. 15 | 16 | ## Table of Contents 17 | - [Installing](#installing) 18 | - [Declaring States](#declaring-states) 19 | - [Changing States](#changing-states) 20 | - [Persist State](#persist-state) 21 | - [Logging Transitions](#logging-transitions) 22 | - [Guard Functions](#guard-functions) 23 | - [Before and After Callbacks](#before-and-after-callbacks) 24 | 25 | ## Installing 26 | 27 | Add `:machinery` to your list of dependencies in `mix.exs`: 28 | 29 | ```elixir 30 | def deps do 31 | [ 32 | {:machinery, "~> 1.1.0"} 33 | ] 34 | end 35 | ``` 36 | 37 | Create a `state` field (or a custom name) for the module you want to apply a 38 | state machine to, and ensure it's declared as part of your defstruct. 39 | 40 | If using a Phoenix model, add it to the schema as a `string` and include it in 41 | the `changeset/2` function: 42 | 43 | ```elixir 44 | defmodule YourProject.User do 45 | schema "users" do 46 | # ... 47 | field :state, :string 48 | # ... 49 | end 50 | 51 | def changeset(%User{} = user, attrs) do 52 | #... 53 | |> cast(attrs, [:state]) 54 | #... 55 | end 56 | end 57 | ``` 58 | 59 | ## Declaring States 60 | 61 | Create a separate module for your State Machine logic. 62 | For example, if you want to add a state machine to your `User` model, create a 63 | `UserStateMachine` module. 64 | 65 | Then import `Machinery` in this new module and declare states as arguments. 66 | 67 | Machinery expects a `Keyword` as an argument with the keys `field`, `states` 68 | and `transitions`. 69 | 70 | - `field`: An atom representing your state field name (defaults to `state`) 71 | - `states`: A `List` of strings representing each state. 72 | - `transitions`: A Map for each state and its allowed next state(s). 73 | 74 | ### Example 75 | 76 | ```elixir 77 | defmodule YourProject.UserStateMachine do 78 | use Machinery, 79 | field: :custom_state_name, # Optional, default value is `:field` 80 | states: ["created", "partial", "completed", "canceled"], 81 | transitions: %{ 82 | "created" => ["partial", "completed"], 83 | "partial" => "completed", 84 | "*" => "canceled" 85 | } 86 | end 87 | ``` 88 | 89 | You can use wildcards `"*"` to declare a transition that can happen from any 90 | state to a specific one. 91 | 92 | ## Changing States 93 | 94 | To transition a struct to another state, call `Machinery.transition_to/3` or `Machinery.transition_to/4`. 95 | 96 | ### `Machinery.transition_to/3` or ``Machinery.transition_to/4` 97 | 98 | It takes the following arguments: 99 | 100 | - `struct`: The `struct` you want to transition to another state. 101 | - `state_machine_module`: The module that holds the state machine logic, where Machinery is imported. 102 | - `next_event`: `string` of the next state you want the struct to transition to. 103 | - *(optional)* `extra_metadata`: `map` with any extra data you might want to access on any of the sate machine functions triggered by the state change 104 | 105 | ```elixir 106 | Machinery.transition_to(your_struct, YourStateMachine, "next_state") 107 | # {:ok, updated_struct} 108 | 109 | # OR 110 | 111 | Machinery.transition_to(your_struct, YourStateMachine, "next_state", %{extra: "metadata"}) 112 | # {:ok, updated_struct} 113 | ``` 114 | 115 | ### Example 116 | 117 | ```elixir 118 | user = Accounts.get_user!(1) 119 | {:ok, updated_user} = Machinery.transition_to(user, UserStateMachine, "completed") 120 | ``` 121 | 122 | ## Persist State 123 | 124 | To persist the struct and state transition, you declare a `persist/2` or `/3` *(in case you wanna access metadata passed on `transition_to/4`)* 125 | function in the state machine module. 126 | 127 | This function will receive the unchanged `struct` as the first argument and a 128 | `string` of the next state as the second one. 129 | 130 | **your `persist/2` or `persist/3` should always return the updated struct.** 131 | 132 | ### Example 133 | 134 | ```elixir 135 | defmodule YourProject.UserStateMachine do 136 | alias YourProject.Accounts 137 | 138 | use Machinery, 139 | states: ["created", "completed"], 140 | transitions: %{"created" => "completed"} 141 | 142 | # You can add an optional third argument for the extra metadata. 143 | def persist(struct, next_state) do 144 | # Updating a user on the database with the new state. 145 | {:ok, user} = Accounts.update_user(struct, %{state: next_stated}) 146 | # `persist` should always return the updated struct 147 | user 148 | end 149 | end 150 | ``` 151 | 152 | ## Logging Transitions 153 | 154 | To log transitions, Machinery provides a `log_transition/2` or `/3` *(in case you wanna access metadata passed on `transition_to/4`)* 155 | callback that is called on every transition, after the `persist` function is executed. 156 | 157 | This function receives the unchanged `struct` as the first 158 | argument and a `string` of the next state as the second one. 159 | 160 | **`log_transition/2` or `log_transition/3` should always return the struct.** 161 | 162 | ### Example 163 | 164 | ```elixir 165 | defmodule YourProject.UserStateMachine do 166 | alias YourProject.Accounts 167 | 168 | use Machinery, 169 | states: ["created", "completed"], 170 | transitions: %{"created" => "completed"} 171 | 172 | # You can add an optional third argument for the extra metadata. 173 | def log_transition(struct, _next_state) do 174 | # Log transition here. 175 | # ... 176 | # `log_transition` should always return the struct 177 | struct 178 | end 179 | end 180 | ``` 181 | 182 | ## Guard functions 183 | 184 | Create guard conditions by adding `guard_transition/2` or `/3` *(in case you wanna access metadata passed on `transition_to/4`)* 185 | function signatures to the state machine module. 186 | This function receives two arguments: the `struct` and a `string` of the state it 187 | will transition to. 188 | 189 | Use the second argument for pattern matching the desired state you want to guard. 190 | 191 | ```elixir 192 | # The second argument is used to pattern match into the state 193 | # and guard the transition to it. 194 | # 195 | # You can add an optional third argument for the extra metadata. 196 | def guard_transition(struct, "guarded_state") do 197 | # Your guard logic here 198 | end 199 | ``` 200 | 201 | Guard conditions will allow the transition if it returns anything other than a tuple with `{:error, "cause"}`: 202 | - `{:error, "cause"}`: Transition won't be allowed. 203 | - `_` *(anything else)*: Guard clause will allow the transition. 204 | 205 | ### Example 206 | 207 | ```elixir 208 | defmodule YourProject.UserStateMachine do 209 | use Machinery, 210 | states: ["created", "completed"], 211 | transitions: %{"created" => "completed"} 212 | 213 | # Guard the transition to the "completed" state. 214 | def guard_transition(struct, "completed") do 215 | if Map.get(struct, :missing_fields) == true do 216 | {:error, "There are missing fields"} 217 | end 218 | end 219 | end 220 | ``` 221 | 222 | When trying to transition a struct that is blocked by its guard clause, 223 | you will have the following return: 224 | 225 | ```elixir 226 | blocked_struct = %TestStruct{state: "created", missing_fields: true} 227 | Machinery.transition_to(blocked_struct, TestStateMachineWithGuard, "completed") 228 | 229 | # {:error, "There are missing fields"} 230 | ``` 231 | 232 | ## Before and After callbacks 233 | 234 | You can also use before and after callbacks to handle desired side effects and 235 | reactions to a specific state transition. 236 | 237 | You can declare `before_transition/2` or `/3` *(in case you wanna access metadata passed on `transition_to/4`)* 238 | and `after_transition/2` or `/3` *(in case you wanna access metadata passed on `transition_to/4`)*, 239 | pattern matching the desired state you want to. 240 | 241 | **Before and After callbacks should return the struct.** 242 | 243 | ```elixir 244 | # Before and After callbacks should return the struct. 245 | # You can add an optional third argument for the extra metadata. 246 | def before_transition(struct, "state"), do: struct 247 | def after_transition(struct, "state"), do: struct 248 | ``` 249 | 250 | ### Example 251 | 252 | ```elixir 253 | defmodule YourProject.UserStateMachine do 254 | use Machinery, 255 | states: ["created", "partial", "completed"], 256 | transitions: %{ 257 | "created" => ["partial", "completed"], 258 | "partial" => "completed" 259 | } 260 | 261 | def before_transition(struct, "partial") do 262 | # ... overall desired side effects 263 | struct 264 | end 265 | 266 | def after_transition(struct, "completed") do 267 | # ... overall desired side effects 268 | struct 269 | end 270 | end 271 | ``` 272 | 273 | ## Copyright and License 274 | 275 | Copyright (c) 2016 João M. D. Moura 276 | 277 | Licensed under the Apache License, Version 2.0 (the "License"); 278 | you may not use this file except in compliance with the License. 279 | You may obtain a copy of the License at 280 | 281 | http://www.apache.org/licenses/LICENSE-2.0 282 | 283 | Unless required by applicable law or agreed to in writing, software 284 | distributed under the License is distributed on an "AS IS" BASIS, 285 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 286 | See the License for the specific language governing permissions and 287 | limitations under the License. 288 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaomdmoura/machinery/b9c80f986a1ee54f6ee45d90cf56ebd3a75cf69c/assets/logo.png -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | config :machinery, 6 | interface: false 7 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/web/web.ex" 4 | ] 5 | } -------------------------------------------------------------------------------- /lib/machinery.ex: -------------------------------------------------------------------------------- 1 | defmodule Machinery do 2 | @moduledoc """ 3 | This is the main Machinery module. 4 | 5 | It keeps most of the Machinery logics, it's the module that will be 6 | imported with `use` on the module responsible for the state machine. 7 | 8 | Declare the states as an argument when importing `Machinery` on the module 9 | that will control your states transitions. 10 | 11 | Machinery expects a `Keyword` as argument with two keys `states` and `transitions`. 12 | 13 | ## Parameters 14 | 15 | - `opts`: A Keyword including `states` and `transitions`. 16 | - `states`: A List of Strings representing each state. 17 | - `transitions`: A Map for each state and it allowed next state(s). 18 | 19 | ## Example 20 | ``` 21 | defmodule YourProject.UserStateMachine do 22 | use Machinery, 23 | # The first state declared will be considered 24 | # the intial state 25 | states: ["created", "partial", "complete"], 26 | transitions: %{ 27 | "created" => ["partial", "complete"], 28 | "partial" => "completed" 29 | } 30 | end 31 | ``` 32 | """ 33 | 34 | @doc """ 35 | Main macro function that will be executed upon the load of the 36 | module using it. 37 | 38 | It basically stores the states and transitions. 39 | 40 | It expects a `Keyword` as argument with two keys `states` and `transitions`. 41 | 42 | - `states`: A List of Strings representing each state. 43 | - `transitions`: A Map for each state and it allowed next state(s). 44 | 45 | P.S. The first state declared will be considered the initial state 46 | """ 47 | defmacro __using__(opts) do 48 | field = Keyword.get(opts, :field, :state) 49 | states = Keyword.get(opts, :states) 50 | transitions = Keyword.get(opts, :transitions) 51 | 52 | # Quoted response to be inserted on the abstract syntax tree (AST) of 53 | # the module that imported this using `use`. 54 | quote bind_quoted: [ 55 | field: field, 56 | states: states, 57 | transitions: transitions 58 | ] do 59 | # Functions to hold and expose internal info of the states. 60 | def _machinery_initial_state(), do: List.first(unquote(states)) 61 | def _machinery_states(), do: unquote(states) 62 | def _machinery_transitions(), do: unquote(Macro.escape(transitions)) 63 | def _field(), do: unquote(field) 64 | end 65 | end 66 | 67 | @doc """ 68 | Start function that will trigger a supervisor for the Machinery.Transitions, a 69 | GenServer that controls the state transitions. 70 | """ 71 | def start(_type, _args) do 72 | children = [{Machinery.Transitions, name: Machinery.Transitions}] 73 | opts = [strategy: :one_for_one, name: Machinery.Supervisor] 74 | Supervisor.start_link(children, opts) 75 | end 76 | 77 | @doc """ 78 | Triggers the transition of a struct to a new state, accordingly to a specific 79 | state machine module, if it passes any existing guard functions. 80 | It also runs any before or after callbacks and returns a tuple with 81 | `{:ok, struct}`, or `{:error, "reason"}`. 82 | 83 | ## Parameters 84 | 85 | - `struct`: The `struct` you want to transit to another state. 86 | - `state_machine_module`: The module that holds the state machine logic, where Machinery as imported. 87 | - `next_state`: String of the next state you want to transition to. 88 | - `extra_metadata`(optional): Map with extra data you might want to access in any of the Machinery functions (callbacks, guard, log, persist). 89 | 90 | ## Examples 91 | 92 | Machinery.transition_to(%User{state: :partial}, UserStateMachine, "completed") 93 | {:ok, %User{state: "completed"}} 94 | 95 | # Or 96 | 97 | Machinery.transition_to(%User{state: :partial}, UserStateMachine, "completed", %{verified: true}) 98 | {:ok, %User{state: "completed"}} 99 | """ 100 | @spec transition_to(struct, module, String.t(), map()) :: {:ok, struct} | {:error, String.t()} 101 | def transition_to(struct, state_machine_module, next_state, extra_metadata \\ None) do 102 | GenServer.call( 103 | Machinery.Transitions, 104 | { 105 | :run, 106 | struct, 107 | state_machine_module, 108 | next_state, 109 | extra_metadata 110 | }, 111 | :infinity 112 | ) 113 | catch 114 | :exit, error_tuple -> 115 | exception = deep_first_of_tuple(error_tuple) 116 | raise exception 117 | end 118 | 119 | defp deep_first_of_tuple(tuple) when is_tuple(tuple) do 120 | tuple 121 | |> elem(0) 122 | |> deep_first_of_tuple 123 | end 124 | 125 | defp deep_first_of_tuple(value), do: value 126 | end 127 | -------------------------------------------------------------------------------- /lib/machinery/transition.ex: -------------------------------------------------------------------------------- 1 | defmodule Machinery.Transition do 2 | @moduledoc """ 3 | Machinery module responsible for control transitions, 4 | guard functions and callbacks (before and after). 5 | This is meant to be for internal use only. 6 | """ 7 | 8 | @doc """ 9 | Function responsible for checking if the transition from a state to another 10 | was specifically declared. 11 | This is meant to be for internal use only. 12 | """ 13 | @spec declared_transition?(list, atom, atom) :: boolean 14 | def declared_transition?(transitions, current_state, next_state) do 15 | if matches_wildcard?(transitions, next_state) do 16 | true 17 | else 18 | matches_transition?(transitions, current_state, next_state) 19 | end 20 | end 21 | 22 | @doc """ 23 | Default guard transition fallback to make sure all transitions are permitted 24 | unless another existing guard condition exists. 25 | This is meant to be for internal use only. 26 | """ 27 | @spec guarded_transition?(module, struct, atom, map()) :: boolean 28 | def guarded_transition?(module, struct, state, extra_metadata) do 29 | function = 30 | if extra_metadata == None, do: &module.guard_transition/2, else: &module.guard_transition/3 31 | 32 | case run_or_fallback( 33 | function, 34 | &guard_transition_fallback/4, 35 | struct, 36 | state, 37 | module._field(), 38 | extra_metadata 39 | ) do 40 | {:error, cause} -> {:error, cause} 41 | _ -> false 42 | end 43 | end 44 | 45 | @doc """ 46 | Function responsible to run all before_transitions callbacks or 47 | fallback to a boilerplate behaviour. 48 | This is meant to be for internal use only. 49 | """ 50 | @spec before_callbacks(struct, atom, module, map()) :: struct 51 | def before_callbacks(struct, state, module, extra_metadata) do 52 | function = 53 | if extra_metadata == None, 54 | do: &module.before_transition/2, 55 | else: &module.before_transition/3 56 | 57 | run_or_fallback( 58 | function, 59 | &callbacks_fallback/4, 60 | struct, 61 | state, 62 | module._field(), 63 | extra_metadata 64 | ) 65 | end 66 | 67 | @doc """ 68 | Function responsible to run all after_transitions callbacks or 69 | fallback to a boilerplate behaviour. 70 | This is meant to be for internal use only. 71 | """ 72 | @spec after_callbacks(struct, atom, module, map()) :: struct 73 | def after_callbacks(struct, state, module, extra_metadata) do 74 | function = 75 | if extra_metadata == None, do: &module.after_transition/2, else: &module.after_transition/3 76 | 77 | run_or_fallback( 78 | function, 79 | &callbacks_fallback/4, 80 | struct, 81 | state, 82 | module._field(), 83 | extra_metadata 84 | ) 85 | end 86 | 87 | @doc """ 88 | This function will try to trigger persistence, if declared, to the struct 89 | changing state. 90 | This is meant to be for internal use only. 91 | """ 92 | @spec persist_struct(struct, atom, module, map()) :: struct 93 | def persist_struct(struct, state, module, extra_metadata) do 94 | function = if extra_metadata == None, do: &module.persist/2, else: &module.persist/3 95 | 96 | run_or_fallback( 97 | function, 98 | &persist_fallback/4, 99 | struct, 100 | state, 101 | module._field(), 102 | extra_metadata 103 | ) 104 | end 105 | 106 | @doc """ 107 | Function responsible for triggering transitions persistence. 108 | This is meant to be for internal use only. 109 | """ 110 | @spec log_transition(struct, atom, module, map()) :: struct 111 | def log_transition(struct, state, module, extra_metadata) do 112 | function = 113 | if extra_metadata == None, do: &module.log_transition/2, else: &module.log_transition/3 114 | 115 | run_or_fallback( 116 | function, 117 | &log_transition_fallback/4, 118 | struct, 119 | state, 120 | module._field(), 121 | extra_metadata 122 | ) 123 | end 124 | 125 | defp matches_wildcard?(transitions, next_state) do 126 | matches_transition?(transitions, "*", next_state) 127 | end 128 | 129 | defp matches_transition?(transitions, current_state, next_state) do 130 | case Map.fetch(transitions, current_state) do 131 | {:ok, [_ | _] = declared_states} -> Enum.member?(declared_states, next_state) 132 | {:ok, declared_state} -> declared_state == next_state 133 | :error -> false 134 | end 135 | end 136 | 137 | # This function looks at the arity of a function and calls it with 138 | # the appropriate number of parameters, passing in the struct, 139 | # state, and extra_metadata. If the function throws an error, 140 | # the fallback function is called instead. 141 | defp run_or_fallback(func, fallback, struct, state, field, extra_metadata) do 142 | case :erlang.fun_info(func)[:arity] do 143 | 2 -> func.(struct, state) 144 | 3 -> func.(struct, state, extra_metadata) 145 | _ -> raise "Invalid arity for #{inspect(func)}" 146 | end 147 | rescue 148 | error in UndefinedFunctionError -> fallback.(struct, state, error, field) 149 | error in FunctionClauseError -> fallback.(struct, state, error, field) 150 | end 151 | 152 | defp persist_fallback(struct, state, error, field) do 153 | if error.function == :persist && Enum.member?([2, 3], error.arity) do 154 | Map.put(struct, field, state) 155 | else 156 | raise error 157 | end 158 | end 159 | 160 | defp log_transition_fallback(struct, _state, error, _field) do 161 | if error.function == :log_transition && Enum.member?([2, 3], error.arity) do 162 | struct 163 | else 164 | raise error 165 | end 166 | end 167 | 168 | defp callbacks_fallback(struct, _state, error, _field) do 169 | if error.function in [:after_transition, :before_transition] && 170 | Enum.member?([2, 3], error.arity) do 171 | struct 172 | else 173 | raise error 174 | end 175 | end 176 | 177 | # If the exception passed is related to a specific signature of 178 | # guard_transition/2 it will fallback returning true and 179 | # allowing the transition, otherwise it will raise the exception. 180 | defp guard_transition_fallback(_struct, _state, error, _field) do 181 | if error.function == :guard_transition && Enum.member?([2, 3], error.arity) do 182 | true 183 | else 184 | raise error 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/machinery/transitions.ex: -------------------------------------------------------------------------------- 1 | defmodule Machinery.Transitions do 2 | @moduledoc """ 3 | This is a GenServer that controls the transitions for a struct 4 | using a set of helper functions from Machinery.Transition 5 | It's meant to be run by a supervisor. 6 | """ 7 | 8 | use GenServer 9 | alias Machinery.Transition 10 | 11 | @not_declated_error "Transition to this state isn't declared." 12 | 13 | def init(args) do 14 | {:ok, args} 15 | end 16 | 17 | @doc false 18 | def start_link(opts) do 19 | GenServer.start_link(__MODULE__, :ok, opts) 20 | end 21 | 22 | @doc false 23 | def handle_call({:run, struct, state_machine_module, next_state, extra_metadata}, _from, states) do 24 | initial_state = state_machine_module._machinery_initial_state() 25 | transitions = state_machine_module._machinery_transitions() 26 | state_field = state_machine_module._field() 27 | 28 | # Getting current state of the struct or falling back to the 29 | # first declared state on the struct model. 30 | current_state = 31 | case Map.get(struct, state_field) do 32 | nil -> initial_state 33 | current_state -> current_state 34 | end 35 | 36 | # Checking declared transitions and guard functions before 37 | # actually updating the struct and retuning the tuple. 38 | declared_transition? = Transition.declared_transition?(transitions, current_state, next_state) 39 | 40 | response = 41 | if declared_transition? do 42 | guarded_transition? = 43 | Transition.guarded_transition?(state_machine_module, struct, next_state, extra_metadata) 44 | 45 | if guarded_transition? do 46 | guarded_transition? 47 | else 48 | struct = 49 | struct 50 | |> Transition.before_callbacks(next_state, state_machine_module, extra_metadata) 51 | |> Transition.persist_struct(next_state, state_machine_module, extra_metadata) 52 | |> Transition.log_transition(next_state, state_machine_module, extra_metadata) 53 | |> Transition.after_callbacks(next_state, state_machine_module, extra_metadata) 54 | 55 | {:ok, struct} 56 | end 57 | else 58 | {:error, @not_declated_error} 59 | end 60 | 61 | {:reply, response, states} 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Machinery.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/joaomdmoura/machinery" 5 | @version "1.1.0" 6 | 7 | def project do 8 | [ 9 | app: :machinery, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | compilers: Mix.compilers(), 13 | deps: deps(), 14 | docs: docs(), 15 | package: package() 16 | ] 17 | end 18 | 19 | def application do 20 | [ 21 | mod: {Machinery, []} 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:ecto, "~> 3.0", only: :test}, 28 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 29 | ] 30 | end 31 | 32 | defp package() do 33 | [ 34 | description: 35 | "Machinery is a State Machine library for structs in general. " <> 36 | "It supports guard clauses, callbacks and integrate out of the box " <> 37 | "with Phoenix apps.", 38 | maintainers: ["João M. D. Moura"], 39 | licenses: ["Apache-2.0"], 40 | links: %{ 41 | "Changelog" => "https://hexdocs.pm/machinery/changelog.html", 42 | "GitHub" => @source_url 43 | } 44 | ] 45 | end 46 | 47 | defp docs do 48 | [ 49 | extras: [ 50 | "CHANGELOG.md": [], 51 | "CONTRIBUTING.md": [], 52 | "CODE_OF_CONDUCT.md": [title: "Code of Conduct"], 53 | LICENSE: [title: "License"], 54 | "README.md": [title: "Overview"] 55 | ], 56 | main: "readme", 57 | assets: "assets", 58 | source_url: @source_url, 59 | formatters: ["html"] 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 3 | "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm", "52694ef56e60108e5012f8af9673874c66ed58ac1c4fae9b5b7ded31786663f5"}, 4 | "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, 6 | "ecto": {:hex, :ecto, "3.2.3", "51274df79862845b388733fddcf6f107d0c8c86e27abe7131fa98f8d30761bda", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "7f3ccc2cd19a0239819df5a290fb0e61a5e92798d5fef95e49617464f8089c51"}, 7 | "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [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", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, 8 | "excoveralls": {:hex, :excoveralls, "0.12.0", "50e17a1b116fdb7facc2fe127a94db246169f38d7627b391376a0bc418413ce1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1f70c34b462719a519e9d8be0b5f81a49557d8227a2c729d252c3cf4fc84d9cd"}, 9 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, 10 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 11 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, 12 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 15 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 16 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 18 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 19 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, 20 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 21 | } 22 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "machinery", 3 | "version": "0.7.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "sortablejs": { 8 | "version": "1.7.0", 9 | "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.7.0.tgz", 10 | "integrity": "sha1-gKKyNwq9Vo4c7IwnETHvMKkE+ig=" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "machinery", 3 | "version": "1.1.0", 4 | "description": "Machinery is a State Machine library for structs in general. It supports guard clauses, callbacks and integrate out of the box with Phoenix apps.", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/joaomdmoura/machinery.git" 15 | }, 16 | "author": "@joaomdmoura", 17 | "license": "Apache License", 18 | "bugs": { 19 | "url": "https://github.com/joaomdmoura/machinery/issues" 20 | }, 21 | "homepage": "https://github.com/joaomdmoura/machinery#readme", 22 | "dependencies": { 23 | "sortablejs": "^1.7.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/machinery/transition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TransitionTest do 2 | use ExUnit.Case, async: false 3 | doctest Machinery.Transition 4 | alias Machinery.Transition 5 | 6 | test "declared_transition?/3 based on a map of transitions, current and next state" do 7 | transitions = %{ 8 | "created" => ["partial", "completed"], 9 | "partial" => "completed" 10 | } 11 | 12 | assert Transition.declared_transition?(transitions, "created", "partial") 13 | assert Transition.declared_transition?(transitions, "created", "completed") 14 | assert Transition.declared_transition?(transitions, "partial", "completed") 15 | refute Transition.declared_transition?(transitions, "partial", "created") 16 | end 17 | 18 | test "declared_transition?/3 for a declared transition that allows transition for any state" do 19 | transitions = %{ 20 | "created" => "completed", 21 | "*" => "canceled" 22 | } 23 | 24 | assert Transition.declared_transition?(transitions, "created", "completed") 25 | assert Transition.declared_transition?(transitions, "created", "canceled") 26 | assert Transition.declared_transition?(transitions, "completed", "canceled") 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/machinery_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest do 2 | use ExUnit.Case, async: false 3 | doctest Machinery 4 | 5 | alias MachineryTest.Helper 6 | alias MachineryTest.TestDefaultFieldStruct 7 | alias MachineryTest.TestStateMachine 8 | alias MachineryTest.TestStateMachineDefaultField 9 | alias MachineryTest.TestStateMachineWithGuard 10 | alias MachineryTest.TestStateMachineWithExtraMetadata 11 | alias MachineryTest.TestStruct 12 | 13 | setup do 14 | Helper.machinery_interface() 15 | end 16 | 17 | test "All internal functions should be injected into AST" do 18 | assert :erlang.function_exported(TestStateMachine, :_machinery_initial_state, 0) 19 | assert :erlang.function_exported(TestStateMachine, :_machinery_states, 0) 20 | assert :erlang.function_exported(TestStateMachine, :_machinery_transitions, 0) 21 | assert :erlang.function_exported(TestStateMachine, :_field, 0) 22 | end 23 | 24 | test "Only the declared transitions should be valid" do 25 | created_struct = %TestStruct{my_state: "created", missing_fields: false} 26 | partial_struct = %TestStruct{my_state: "partial", missing_fields: false} 27 | stateless_struct = %TestStruct{} 28 | completed_struct = %TestStruct{my_state: "completed"} 29 | 30 | assert {:ok, %TestStruct{my_state: "partial"}} = 31 | Machinery.transition_to(created_struct, TestStateMachine, "partial") 32 | 33 | assert {:ok, %TestStruct{my_state: "completed", missing_fields: false}} = 34 | Machinery.transition_to(created_struct, TestStateMachine, "completed") 35 | 36 | assert {:ok, %TestStruct{my_state: "completed", missing_fields: false}} = 37 | Machinery.transition_to(partial_struct, TestStateMachine, "completed") 38 | 39 | assert {:error, "Transition to this state isn't declared."} = 40 | Machinery.transition_to(stateless_struct, TestStateMachine, "created") 41 | 42 | assert {:error, "Transition to this state isn't declared."} = 43 | Machinery.transition_to(completed_struct, TestStateMachine, "created") 44 | end 45 | 46 | test "Wildcard transitions should be valid" do 47 | created_struct = %TestStruct{my_state: "created", missing_fields: false} 48 | partial_struct = %TestStruct{my_state: "partial", missing_fields: false} 49 | completed_struct = %TestStruct{my_state: "completed"} 50 | 51 | assert {:ok, %TestStruct{my_state: "canceled", missing_fields: false}} = 52 | Machinery.transition_to(created_struct, TestStateMachine, "canceled") 53 | 54 | assert {:ok, %TestStruct{my_state: "canceled", missing_fields: false}} = 55 | Machinery.transition_to(partial_struct, TestStateMachine, "canceled") 56 | 57 | assert {:ok, %TestStruct{my_state: "canceled"}} = 58 | Machinery.transition_to(completed_struct, TestStateMachine, "canceled") 59 | end 60 | 61 | test "Transition to should support extra metadata in the persist function" do 62 | struct = %TestStruct{my_state: "created", missing_fields: false} 63 | 64 | _expected_struct = %TestStruct{ 65 | my_state: "canceled", 66 | missing_fields: false, 67 | extra: "metadata", 68 | persist: true 69 | } 70 | 71 | {:ok, updated_struct} = 72 | Machinery.transition_to(struct, TestStateMachineWithExtraMetadata, "canceled", %{ 73 | extra: "metadata" 74 | }) 75 | 76 | assert _expected_struct = updated_struct 77 | end 78 | 79 | test "Transition to should support extra metadata in the before transition function" do 80 | struct = %TestStruct{my_state: "created"} 81 | 82 | _expected_struct = %TestStruct{ 83 | my_state: "partial", 84 | missing_fields: true, 85 | extra: "metadata", 86 | persist: true, 87 | before_transition: true 88 | } 89 | 90 | {:ok, updated_struct} = 91 | Machinery.transition_to(struct, TestStateMachineWithExtraMetadata, "partial", %{ 92 | extra: "metadata" 93 | }) 94 | 95 | assert _expected_struct = updated_struct 96 | end 97 | 98 | test "Transition to should support extra metadata in the after transition function" do 99 | struct = %TestStruct{my_state: "created"} 100 | 101 | _expected_struct = %TestStruct{ 102 | my_state: "completed", 103 | missing_fields: false, 104 | extra: "metadata", 105 | persist: true, 106 | after_transition: true, 107 | guard_tranistion: true 108 | } 109 | 110 | {:ok, updated_struct} = 111 | Machinery.transition_to(struct, TestStateMachineWithExtraMetadata, "completed", %{ 112 | extra: "metadata" 113 | }) 114 | 115 | assert _expected_struct = updated_struct 116 | end 117 | 118 | test "Transition to should support extra metadata in the log function" do 119 | struct = %TestStruct{my_state: "created"} 120 | 121 | _expected_struct = %TestStruct{ 122 | my_state: "canceled", 123 | missing_fields: false, 124 | extra: "metadata", 125 | persist: true, 126 | log: true 127 | } 128 | 129 | {:ok, updated_struct} = 130 | Machinery.transition_to(struct, TestStateMachineWithExtraMetadata, "canceled", %{ 131 | extra: "metadata" 132 | }) 133 | 134 | assert _expected_struct = updated_struct 135 | end 136 | 137 | test "Guard functions should not be executed if the transition is invalid" do 138 | struct = %TestStruct{my_state: "created", missing_fields: true, force_exception: true} 139 | 140 | assert {:error, _cause} = 141 | Machinery.transition_to(struct, TestStateMachineWithGuard, "canceled") 142 | end 143 | 144 | test "Guard functions should be executed before moving the resource to the next state" do 145 | struct = %TestStruct{my_state: "created", missing_fields: true} 146 | 147 | assert {:error, _cause} = 148 | Machinery.transition_to(struct, TestStateMachineWithGuard, "completed") 149 | end 150 | 151 | test "Guard functions should allow or block transitions" do 152 | allowed_struct = %TestStruct{my_state: "created", missing_fields: false} 153 | blocked_struct = %TestStruct{my_state: "created", missing_fields: true} 154 | 155 | assert {:ok, %TestStruct{my_state: "completed", missing_fields: false}} = 156 | Machinery.transition_to(allowed_struct, TestStateMachineWithGuard, "completed") 157 | 158 | assert {:error, _cause} = 159 | Machinery.transition_to(blocked_struct, TestStateMachineWithGuard, "completed") 160 | end 161 | 162 | test "Guard functions should return an error cause" do 163 | blocked_struct = %TestStruct{my_state: "created", missing_fields: true} 164 | 165 | assert {:error, "Guard Condition Custom Cause"} = 166 | Machinery.transition_to(blocked_struct, TestStateMachineWithGuard, "completed") 167 | end 168 | 169 | test "The first declared state should be considered the initial one" do 170 | stateless_struct = %TestStruct{} 171 | 172 | assert {:ok, %TestStruct{my_state: "partial"}} = 173 | Machinery.transition_to(stateless_struct, TestStateMachine, "partial") 174 | end 175 | 176 | test "Modules without guard conditions should allow transitions by default" do 177 | struct = %TestStruct{my_state: "created"} 178 | 179 | assert {:ok, %TestStruct{my_state: "completed"}} = 180 | Machinery.transition_to(struct, TestStateMachine, "completed") 181 | end 182 | 183 | @tag :capture_log 184 | test "Implict rescue on the guard clause internals should raise any other excepetion not strictly related to missing guard_tranistion/2 existence" do 185 | wrong_struct = %TestStruct{my_state: "created", force_exception: true} 186 | 187 | assert_raise UndefinedFunctionError, fn -> 188 | Machinery.transition_to(wrong_struct, TestStateMachineWithGuard, "completed") 189 | end 190 | end 191 | 192 | test "after_transition/2 and before_transition/2 callbacks should be automatically executed" do 193 | struct = %TestStruct{} 194 | assert struct.missing_fields == nil 195 | 196 | {:ok, partial_struct} = Machinery.transition_to(struct, TestStateMachine, "partial") 197 | assert partial_struct.missing_fields == true 198 | 199 | {:ok, completed_struct} = Machinery.transition_to(struct, TestStateMachine, "completed") 200 | assert completed_struct.missing_fields == false 201 | end 202 | 203 | @tag :capture_log 204 | test "Implict rescue on the callbacks internals should raise any other excepetion not strictly related to missing callbacks_fallback/2 existence" do 205 | wrong_struct = %TestStruct{my_state: "created", force_exception: true} 206 | 207 | assert_raise UndefinedFunctionError, fn -> 208 | Machinery.transition_to(wrong_struct, TestStateMachine, "partial") 209 | end 210 | end 211 | 212 | test "Persist function should be called after the transition" do 213 | struct = %TestStruct{my_state: "partial"} 214 | assert {:ok, _} = Machinery.transition_to(struct, TestStateMachine, "completed") 215 | end 216 | 217 | @tag :capture_log 218 | test "Persist function should still raise errors if not related to the existence of persist/1 method" do 219 | wrong_struct = %TestStruct{my_state: "created", force_exception: true} 220 | 221 | assert_raise UndefinedFunctionError, fn -> 222 | Machinery.transition_to(wrong_struct, TestStateMachine, "completed") 223 | end 224 | end 225 | 226 | @tag :capture_log 227 | test "Transition log function should still raise errors if not related to the existence of persist/1 method" do 228 | wrong_struct = %TestStruct{my_state: "created", force_exception: true} 229 | 230 | assert_raise UndefinedFunctionError, fn -> 231 | Machinery.transition_to(wrong_struct, TestStateMachineWithGuard, "partial") 232 | end 233 | end 234 | 235 | test "Transition log function should be called after the transition" do 236 | struct = %TestStruct{my_state: "created"} 237 | assert {:ok, _} = Machinery.transition_to(struct, TestStateMachineWithGuard, "partial") 238 | end 239 | 240 | @tag :capture_log 241 | test "Machinery.Transitions GenServer should be started under the Machinery.Supervisor" do 242 | transitions_pid = Process.whereis(Machinery.Transitions) 243 | assert Process.alive?(transitions_pid) 244 | end 245 | 246 | test "Should use default state name if not specified" do 247 | struct = %TestDefaultFieldStruct{state: "created"} 248 | 249 | assert {:ok, %TestDefaultFieldStruct{state: "canceled"}} = 250 | Machinery.transition_to(struct, TestStateMachineDefaultField, "canceled") 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /test/support/test_default_field_struct.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TestDefaultFieldStruct do 2 | import Ecto.Changeset 3 | use Ecto.Schema 4 | 5 | schema "test_default_field_structs" do 6 | field(:state, :string) 7 | timestamps() 8 | end 9 | 10 | @doc false 11 | def changeset(test_struct, attrs) do 12 | cast(test_struct, attrs, [:state]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/test_repo.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TestRepo do 2 | @doc """ 3 | Simulating a lame fake kind-broken pagination for test purposes. 4 | """ 5 | def all(%{offset: offset}) do 6 | [{offset_number, :integer}] = offset.params 7 | all_resources = all(nil) 8 | 9 | if offset_number > Enum.count(all_resources) do 10 | [] 11 | else 12 | all_resources 13 | end 14 | end 15 | 16 | def all(_), do: [%{id: 1}, %{id: 2}, %{id: 3}] 17 | 18 | def get!(_model, id) do 19 | %{id: id} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/support/test_state_machine.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TestStateMachine do 2 | use Machinery, 3 | field: :my_state, 4 | states: ["created", "partial", "completed", "canceled"], 5 | transitions: %{ 6 | "created" => ["partial", "completed"], 7 | "partial" => "completed", 8 | "*" => "canceled" 9 | } 10 | 11 | def before_transition(struct, "partial") do 12 | # Code to simulate and force an exception inside a 13 | # guard function. 14 | if Map.get(struct, :force_exception) do 15 | Machinery.non_existing_function_should_raise_error() 16 | end 17 | 18 | Map.put(struct, :missing_fields, true) 19 | end 20 | 21 | def after_transition(struct, "completed") do 22 | Map.put(struct, :missing_fields, false) 23 | end 24 | 25 | def persist(struct, next_state) do 26 | # Code to simulate and force an exception inside a 27 | # guard function. 28 | if Map.get(struct, :force_exception) do 29 | Machinery.non_existing_function_should_raise_error() 30 | end 31 | 32 | Map.put(struct, :my_state, next_state) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/test_state_machine_default_field.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TestStateMachineDefaultField do 2 | use Machinery, 3 | states: ["created", "canceled"], 4 | transitions: %{ 5 | "*" => "canceled" 6 | } 7 | end 8 | -------------------------------------------------------------------------------- /test/support/test_state_machine_with_extra_metadata.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TestStateMachineWithExtraMetadata do 2 | use Machinery, 3 | field: :my_state, 4 | states: ["created", "partial", "completed", "canceled"], 5 | transitions: %{ 6 | "created" => ["partial", "completed"], 7 | "partial" => "completed", 8 | "*" => "canceled" 9 | } 10 | 11 | def before_transition(struct, "partial", extra) do 12 | extra = Map.put(extra, :before_transition, true) 13 | struct = Map.merge(struct, extra) 14 | Map.put(struct, :missing_fields, true) 15 | end 16 | 17 | def after_transition(struct, "completed", extra) do 18 | extra = Map.put(extra, :after_transition, true) 19 | struct = Map.merge(struct, extra) 20 | Map.put(struct, :missing_fields, false) 21 | end 22 | 23 | def persist(struct, next_state, extra) do 24 | extra = Map.put(extra, :persist, true) 25 | struct = Map.merge(struct, extra) 26 | Map.put(struct, :my_state, next_state) 27 | end 28 | 29 | def log_transition(struct, _next_state, extra) do 30 | extra = Map.put(extra, :log, true) 31 | struct = Map.merge(struct, extra) 32 | Map.merge(struct, extra) 33 | end 34 | 35 | def guard_transition(struct, "completed", extra) do 36 | extra = Map.put(extra, :guard_transition, true) 37 | Map.merge(struct, extra) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/test_state_machine_with_guard.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TestStateMachineWithGuard do 2 | use Machinery, 3 | field: :my_state, 4 | states: ["created", "partial", "completed", "canceled"], 5 | transitions: %{ 6 | "created" => ["partial", "completed"], 7 | "partial" => "completed" 8 | } 9 | 10 | def guard_transition(struct, "completed") do 11 | # Code to simulate and force an exception inside a 12 | # guard function. 13 | if Map.get(struct, :force_exception) do 14 | Machinery.non_existing_function_should_raise_error() 15 | end 16 | 17 | no_missing_fields = Map.get(struct, :missing_fields) == false 18 | 19 | unless no_missing_fields do 20 | {:error, "Guard Condition Custom Cause"} 21 | end 22 | end 23 | 24 | def guard_transition(struct, "canceled") do 25 | # Code to simulate and force an exception inside a 26 | # guard function. 27 | if Map.get(struct, :force_exception) do 28 | Machinery.non_existing_function_should_raise_error() 29 | end 30 | 31 | no_missing_fields = Map.get(struct, :missing_fields) == false 32 | 33 | unless no_missing_fields do 34 | {:error, "Guard Condition Custom Cause"} 35 | end 36 | end 37 | 38 | def log_transition(struct, _next_state) do 39 | # Log transition here 40 | if Map.get(struct, :force_exception) do 41 | Machinery.non_existing_function_should_raise_error() 42 | end 43 | 44 | struct 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/test_struct.exs: -------------------------------------------------------------------------------- 1 | defmodule MachineryTest.TestStruct do 2 | import Ecto.Changeset 3 | use Ecto.Schema 4 | 5 | schema "test_structs" do 6 | field(:my_state, :string) 7 | field(:missing_fields, :boolean) 8 | field(:force_exception, :boolean) 9 | field(:extra, :string) 10 | field(:before_transition, :boolean) 11 | field(:after_transition, :boolean) 12 | field(:persist, :boolean) 13 | field(:log, :boolean) 14 | field(:guard_tranistion, :boolean) 15 | timestamps() 16 | end 17 | 18 | @doc false 19 | def changeset(test_struct, attrs) do 20 | cast(test_struct, attrs, [ 21 | :my_state, 22 | :missing_fields, 23 | :force_exception, 24 | :extra, 25 | :before_transition, 26 | :after_transition, 27 | :persist, 28 | :log, 29 | :guard_tranistion 30 | ]) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | # Load support modules 4 | Code.require_file("test/support/test_struct.exs") 5 | Code.require_file("test/support/test_default_field_struct.exs") 6 | Code.require_file("test/support/test_state_machine.exs") 7 | Code.require_file("test/support/test_state_machine_with_guard.exs") 8 | Code.require_file("test/support/test_state_machine_default_field.exs") 9 | Code.require_file("test/support/test_state_machine_with_extra_metadata.exs") 10 | Code.require_file("test/support/test_repo.exs") 11 | 12 | defmodule MachineryTest.Helper do 13 | import ExUnit.CaptureLog 14 | 15 | alias MachineryTest.TestRepo 16 | alias MachineryTest.TestStateMachine 17 | alias MachineryTest.TestStruct 18 | 19 | @doc false 20 | def machinery_interface(enable \\ true) do 21 | Application.put_env(:machinery, :module, TestStateMachine) 22 | Application.put_env(:machinery, :model, TestStruct) 23 | Application.put_env(:machinery, :repo, TestRepo) 24 | Application.put_env(:machinery, :interface, enable) 25 | 26 | capture_log(fn -> 27 | restart_machinery() 28 | end) 29 | 30 | :ok 31 | end 32 | 33 | @doc false 34 | def restart_machinery() do 35 | supervisor_pid = Process.whereis(Machinery.Supervisor) 36 | Process.monitor(supervisor_pid) 37 | Process.exit(supervisor_pid, :kill) 38 | 39 | receive do 40 | _ -> 41 | :timer.sleep(5) 42 | Application.start(:machinery) 43 | end 44 | end 45 | end 46 | --------------------------------------------------------------------------------