├── .credo.exs ├── .formatter.exs ├── .github ├── CODEOWNERS ├── dependabot.yml ├── release-please-config.json ├── release-please-manifest.json └── workflows │ ├── ci.yaml │ ├── common-config.yaml │ ├── pr.yaml │ ├── production.yaml │ ├── publish-docs.yaml │ ├── release.yaml │ └── stale.yaml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── LICENSE.md ├── README.md ├── bin └── release ├── config ├── config.exs └── test.exs ├── lib ├── ex_machina.ex └── ex_machina │ ├── ecto.ex │ ├── ecto_strategy.ex │ ├── sequence.ex │ ├── strategy.ex │ └── undefined_factory_error.ex ├── mix.exs ├── mix.lock ├── priv └── test_repo │ └── migrations │ └── 1_migrate_all.exs └── test ├── ex_machina ├── ecto_strategy_test.exs ├── ecto_test.exs ├── sequence_test.exs └── strategy_test.exs ├── ex_machina_test.exs ├── support ├── ecto_case.ex ├── models │ ├── article.ex │ ├── comment.ex │ ├── custom.ex │ ├── invalid_cast.ex │ ├── invalid_type.ex │ ├── money.ex │ ├── publisher.ex │ └── user.ex ├── test_factory.ex └── test_repo.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | # This file contains the configuration for Credo and you are probably reading 4 | # this after creating it with `mix credo.gen.config`. 5 | # 6 | # If you find anything wrong or unclear in this file, please report an 7 | # issue on GitHub: https://github.com/rrrene/credo/issues 8 | # 9 | %{ 10 | # 11 | # You can have as many configs as you like in the `configs:` field. 12 | configs: [ 13 | %{ 14 | # 15 | # Run any config using `mix credo -C `. If no config name is given 16 | # "default" is used. 17 | # 18 | name: "default", 19 | # 20 | # These are the files included in the analysis: 21 | files: %{ 22 | # 23 | # You can give explicit globs or simply directories. 24 | # In the latter case `**/*.{ex,exs}` will be used. 25 | # 26 | included: ["config/", "lib/", "priv/", "test/"], 27 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 28 | }, 29 | # 30 | # Load and configure plugins here: 31 | # 32 | plugins: [], 33 | # 34 | # If you create your own checks, you must specify the source files for 35 | # them here, so they can be loaded by Credo before running the analysis. 36 | # 37 | requires: [], 38 | # 39 | # If you want to enforce a style guide and need a more traditional linting 40 | # experience, you can change `strict` to `true` below: 41 | # 42 | strict: true, 43 | # 44 | # To modify the timeout for parsing files, change this value: 45 | # 46 | parse_timeout: 5000, 47 | # 48 | # If you want to use uncolored output by default, you can change `color` 49 | # to `false` below: 50 | # 51 | color: true, 52 | # 53 | # You can customize the parameters of any check by adding a second element 54 | # to the tuple. 55 | # 56 | # To disable a check put `false` as second element: 57 | # 58 | # {Credo.Check.Design.DuplicatedCode, false} 59 | # 60 | checks: [ 61 | # 62 | ## Consistency Checks 63 | # 64 | {Credo.Check.Consistency.ExceptionNames, []}, 65 | {Credo.Check.Consistency.LineEndings, []}, 66 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 67 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 68 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 69 | {Credo.Check.Consistency.SpaceInParentheses, []}, 70 | {Credo.Check.Consistency.TabsOrSpaces, []}, 71 | {Credo.Check.Consistency.UnusedVariableNames, false}, 72 | 73 | # 74 | ## Design Checks 75 | # 76 | # You can customize the priority of any check 77 | # Priority values are: `low, normal, high, higher` 78 | # 79 | {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 2]}, 80 | {Credo.Check.Design.DuplicatedCode, false}, 81 | # You can also customize the exit_status of each check. 82 | # If you don't want TODO comments to cause `mix credo` to fail, just 83 | # set this value to 0 (zero). 84 | # 85 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 86 | {Credo.Check.Design.TagFIXME, []}, 87 | 88 | # 89 | ## Readability Checks 90 | # 91 | {Credo.Check.Readability.AliasAs, false}, 92 | {Credo.Check.Readability.AliasOrder, []}, 93 | {Credo.Check.Readability.BlockPipe, []}, 94 | {Credo.Check.Readability.FunctionNames, []}, 95 | {Credo.Check.Readability.ImplTrue, []}, 96 | {Credo.Check.Readability.LargeNumbers, [trailing_digits: 2]}, 97 | {Credo.Check.Readability.MaxLineLength, false}, 98 | {Credo.Check.Readability.ModuleAttributeNames, []}, 99 | {Credo.Check.Readability.ModuleDoc, false}, 100 | {Credo.Check.Readability.ModuleNames, []}, 101 | {Credo.Check.Readability.MultiAlias, false}, 102 | {Credo.Check.Readability.NestedFunctionCalls, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 105 | {Credo.Check.Readability.PredicateFunctionNames, []}, 106 | {Credo.Check.Readability.PreferImplicitTry, []}, 107 | {Credo.Check.Readability.RedundantBlankLines, []}, 108 | {Credo.Check.Readability.Semicolons, []}, 109 | {Credo.Check.Readability.SeparateAliasRequire, []}, 110 | {Credo.Check.Readability.SinglePipe, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.Specs, false}, 113 | {Credo.Check.Readability.StrictModuleLayout, 114 | [ 115 | order: 116 | ~w(moduledoc behaviour use import require alias module_attribute defstruct callback macrocallback optional_callback)a, 117 | ignore: [:type], 118 | ignore_module_attributes: [:tag, :trace] 119 | ]}, 120 | {Credo.Check.Readability.StringSigils, []}, 121 | {Credo.Check.Readability.TrailingBlankLine, []}, 122 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 123 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 124 | {Credo.Check.Readability.VariableNames, []}, 125 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 126 | 127 | # 128 | ## Refactoring Opportunities 129 | # 130 | {Credo.Check.Refactor.ABCSize, false}, 131 | {Credo.Check.Refactor.AppendSingleItem, []}, 132 | {Credo.Check.Refactor.CondStatements, []}, 133 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 134 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 135 | {Credo.Check.Refactor.FunctionArity, []}, 136 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 137 | # {Credo.Check.Refactor.MapInto, []}, 138 | {Credo.Check.Refactor.MatchInCondition, []}, 139 | {Credo.Check.Refactor.ModuleDependencies, false}, 140 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 141 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 142 | {Credo.Check.Refactor.NegatedIsNil, []}, 143 | {Credo.Check.Refactor.Nesting, []}, 144 | {Credo.Check.Refactor.PipeChainStart, []}, 145 | {Credo.Check.Refactor.UnlessWithElse, []}, 146 | {Credo.Check.Refactor.VariableRebinding, false}, 147 | {Credo.Check.Refactor.WithClauses, []}, 148 | 149 | # 150 | ## Warnings 151 | # 152 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 153 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 154 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 155 | {Credo.Check.Warning.IExPry, []}, 156 | {Credo.Check.Warning.IoInspect, []}, 157 | {Credo.Check.Warning.LeakyEnvironment, []}, 158 | # {Credo.Check.Warning.LazyLogging, []}, 159 | {Credo.Check.Warning.MapGetUnsafePass, []}, 160 | # disabling this check by default, as if not included, it will be 161 | # run on version 1.7.0 and above 162 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 163 | {Credo.Check.Warning.MixEnv, []}, 164 | {Credo.Check.Warning.OperationOnSameValues, []}, 165 | {Credo.Check.Warning.OperationWithConstantResult, []}, 166 | {Credo.Check.Warning.RaiseInsideRescue, []}, 167 | {Credo.Check.Warning.UnsafeExec, []}, 168 | {Credo.Check.Warning.UnsafeToAtom, []}, 169 | {Credo.Check.Warning.UnusedEnumOperation, []}, 170 | {Credo.Check.Warning.UnusedFileOperation, []}, 171 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 172 | {Credo.Check.Warning.UnusedListOperation, []}, 173 | {Credo.Check.Warning.UnusedPathOperation, []}, 174 | {Credo.Check.Warning.UnusedRegexOperation, []}, 175 | {Credo.Check.Warning.UnusedStringOperation, []}, 176 | {Credo.Check.Warning.UnusedTupleOperation, []} 177 | ] 178 | } 179 | ] 180 | } 181 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | [ 4 | import_deps: [:ecto, :ecto_sql], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,priv,test}/**/*.{heex,ex,exs}"], 6 | line_length: 120, 7 | plugins: [] 8 | ] 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @beam-community/team 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | commit-message: 8 | prefix: "chore(deps)" 9 | 10 | - package-ecosystem: mix 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | commit-message: 15 | prefix: "chore(deps)" 16 | groups: 17 | prod: 18 | dependency-type: production 19 | dev: 20 | dependency-type: development 21 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$comment": "This file is synced with beam-community/common-config. Any changes will be overwritten.", 3 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 4 | "changelog-sections": [ 5 | { 6 | "type": "feat", 7 | "section": "Features", 8 | "hidden": false 9 | }, 10 | { 11 | "type": "fix", 12 | "section": "Bug Fixes", 13 | "hidden": false 14 | }, 15 | { 16 | "type": "refactor", 17 | "section": "Miscellaneous", 18 | "hidden": false 19 | } 20 | ], 21 | "draft": false, 22 | "draft-pull-request": false, 23 | "packages": { 24 | ".": { 25 | "extra-files": [ 26 | "README.md" 27 | ], 28 | "release-type": "elixir" 29 | } 30 | }, 31 | "plugins": [ 32 | { 33 | "type": "sentence-case" 34 | } 35 | ], 36 | "prerelease": false, 37 | "pull-request-header": "An automated release has been created for you.", 38 | "separate-pull-requests": true 39 | } 40 | -------------------------------------------------------------------------------- /.github/release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "2.8.0" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: CI 4 | 5 | on: 6 | merge_group: 7 | pull_request: 8 | types: 9 | - opened 10 | - reopened 11 | - synchronize 12 | push: 13 | branches: 14 | - main 15 | workflow_call: 16 | secrets: 17 | GH_PERSONAL_ACCESS_TOKEN: 18 | required: true 19 | workflow_dispatch: 20 | 21 | concurrency: 22 | group: CI ${{ github.head_ref || github.run_id }} 23 | cancel-in-progress: true 24 | 25 | jobs: 26 | Credo: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup Elixir 34 | uses: stordco/actions-elixir/setup@v1 35 | with: 36 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 37 | 38 | - name: Credo 39 | run: mix credo --strict 40 | 41 | Dependencies: 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | 48 | - name: Setup Elixir 49 | uses: stordco/actions-elixir/setup@v1 50 | with: 51 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 52 | 53 | - name: Unused 54 | run: mix deps.unlock --check-unused 55 | 56 | Dialyzer: 57 | runs-on: ubuntu-latest 58 | 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v4 62 | 63 | - name: Setup Elixir 64 | uses: stordco/actions-elixir/setup@v1 65 | with: 66 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 67 | 68 | - name: Dialyzer 69 | run: mix dialyzer --format github 70 | 71 | Format: 72 | runs-on: ubuntu-latest 73 | 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v4 77 | 78 | - name: Setup Elixir 79 | uses: stordco/actions-elixir/setup@v1 80 | with: 81 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 82 | 83 | - name: Format 84 | run: mix format --check-formatted 85 | 86 | Test: 87 | name: Test (Elixir ${{ matrix.versions.elixir }} OTP ${{ matrix.versions.otp }}) 88 | 89 | runs-on: ubuntu-latest 90 | 91 | env: 92 | MIX_ENV: test 93 | 94 | services: 95 | postgres: 96 | image: postgres:16-alpine 97 | ports: 98 | - 5432:5432 99 | env: 100 | POSTGRES_USER: postgres 101 | POSTGRES_PASSWORD: postgres 102 | POSTGRES_INITDB_ARGS: "--nosync" 103 | 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v4 107 | 108 | - name: Setup Elixir 109 | uses: stordco/actions-elixir/setup@v1 110 | with: 111 | elixir-version: ${{ matrix.versions.elixir }} 112 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 113 | otp-version: ${{ matrix.versions.otp }} 114 | 115 | - name: Compile 116 | run: mix compile --warnings-as-errors 117 | 118 | - name: Test 119 | run: mix test 120 | 121 | strategy: 122 | fail-fast: false 123 | matrix: 124 | versions: 125 | - elixir: 1.16 126 | otp: 26 127 | - elixir: 1.17 128 | otp: 27 129 | - elixir: 1.18 130 | otp: 27 131 | 132 | -------------------------------------------------------------------------------- /.github/workflows/common-config.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Common Config 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/workflows/common-config.yaml 11 | repository_dispatch: 12 | types: 13 | - common-config 14 | schedule: 15 | - cron: "8 12 8 * *" 16 | workflow_dispatch: {} 17 | 18 | concurrency: 19 | group: Common Config 20 | 21 | jobs: 22 | Sync: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 30 | persist-credentials: true 31 | 32 | - name: Setup Node 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: 20 36 | 37 | - name: Setup Elixir 38 | uses: stordco/actions-elixir/setup@v1 39 | with: 40 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 41 | elixir-version: "1.16" 42 | otp-version: "26.0" 43 | 44 | - name: Sync 45 | uses: stordco/actions-sync@v1 46 | with: 47 | commit-message: "chore: sync files with beam-community/common-config" 48 | pr-enabled: true 49 | pr-labels: common-config 50 | pr-title: "chore: sync files with beam-community/common-config" 51 | pr-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 52 | sync-auth: doomspork:${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 53 | sync-branch: latest 54 | sync-repository: github.com/beam-community/common-config.git 55 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: PR 4 | 5 | on: 6 | merge_group: 7 | pull_request: 8 | types: 9 | - edited 10 | - opened 11 | - reopened 12 | - synchronize 13 | 14 | jobs: 15 | Title: 16 | permissions: 17 | pull-requests: read 18 | 19 | if: ${{ github.event_name == 'pull_request' }} 20 | name: Check Title 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Check 25 | uses: stordco/actions-pr-title@v1.0.0 26 | with: 27 | regex: '^(refactor!|feat!|fix!|refactor|fix|feat|chore)(\(\w+\))?:\s(\[#\d{1,5}\])?.*$' 28 | hint: | 29 | Your PR title does not match the Conventional Commits convention. Please rename your PR to match one of the following formats: 30 | 31 | fix: [#123] some title of the PR 32 | fix(scope): [#123] some title of the PR 33 | feat: [#1234] some title of the PR 34 | chore: update some action 35 | 36 | Note: Adding ! (i.e. `feat!:`) represents a breaking change and will result in a SemVer major release. 37 | 38 | Please use one of the following types: 39 | 40 | - **feat:** A new feature, resulting in a MINOR version bump. 41 | - **fix:** A bug fix, resulting in a PATCH version bump. 42 | - **refactor:** A code change that neither fixes a bug nor adds a feature. 43 | - **chore:** Changes unrelated to the release code, resulting in no version bump. 44 | - **revert:** Reverts a previous commit. 45 | 46 | See https://www.conventionalcommits.org/en/v1.0.0/ for more information. 47 | -------------------------------------------------------------------------------- /.github/workflows/production.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Production 4 | 5 | on: 6 | release: 7 | types: 8 | - released 9 | - prereleased 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: Production 14 | 15 | jobs: 16 | Hex: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Elixir 24 | uses: stordco/actions-elixir/setup@v1 25 | with: 26 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 27 | 28 | - name: Compile 29 | run: mix compile --docs 30 | 31 | - name: Publish 32 | run: mix hex.publish --yes 33 | env: 34 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 35 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Publish Docs 4 | 5 | on: 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: hex-publish-docs 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | Hex: 14 | runs-on: ubuntu-latest 15 | if: github.ref == 'refs/heads/main' 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Elixir 21 | uses: stordco/actions-elixir/setup@v1 22 | with: 23 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 24 | 25 | - name: Publish Docs 26 | run: mix hex.publish docs --yes 27 | env: 28 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Release 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | Please: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - id: release 16 | name: Release 17 | uses: googleapis/release-please-action@v4 18 | with: 19 | config-file: .github/release-please-config.json 20 | manifest-file: .github/release-please-manifest.json 21 | release-type: elixir 22 | token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues and PRs" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "30 1 * * *" 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | stale: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/stale@v9 18 | with: 19 | days-before-issue-stale: 30 20 | days-before-issue-close: 15 21 | days-before-pr-stale: 60 22 | days-before-pr-close: 60 23 | 24 | stale-issue-label: "stale:discard" 25 | exempt-issue-labels: "stale:keep" 26 | stale-issue-message: > 27 | This issue has been automatically marked as "stale:discard". We are sorry that we haven't been able to 28 | prioritize it yet. 29 | 30 | If this issue still relevant, please leave any comment if you have any new additional information that 31 | helps to solve this issue. We encourage you to create a pull request, if you can. We are happy to help you 32 | with that. 33 | 34 | close-issue-message: > 35 | Closing this issue after a prolonged period of inactivity. If this issue is still relevant, feel free to 36 | re-open the issue. Thank you! 37 | 38 | stale-pr-label: "stale:discard" 39 | exempt-pr-labels: "stale:keep" 40 | stale-pr-message: > 41 | This pull request has been automatically marked as "stale:discard". **If this pull request is still 42 | relevant, please leave any comment** (for example, "bump"), and we'll keep it open. We are sorry that we 43 | haven't been able to prioritize reviewing it yet. 44 | Your contribution is very much appreciated!. 45 | close-pr-message: > 46 | Closing this pull request after a prolonged period of inactivity. If this issue is still relevant, please 47 | ask for this pull request to be reopened. Thank you! 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /doc 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18 2 | erlang 27.2 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The noteworthy changes for each ExMachina version are included here. For a 4 | complete changelog, see the git history for each version via the version links. 5 | 6 | **To see the dates a version was published see the [hex package page].** 7 | 8 | [hex package page]: https://hex.pm/packages/ex_machina 9 | 10 | ## [2.8.0](https://github.com/beam-community/ex_machina/compare/v2.7.0...v2.8.0) (2024-06-24) 11 | 12 | 13 | ### Features 14 | 15 | * ExMachina.start/2: return a supervisor from Application callback ([#434](https://github.com/beam-community/ex_machina/issues/434)) ([c9ebb47](https://github.com/beam-community/ex_machina/commit/c9ebb47d7ffecdae3acf22f71c257de8bcdcffdc)) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * Revert code changes breaking ecto record loading ([#447](https://github.com/beam-community/ex_machina/issues/447)) ([b796311](https://github.com/beam-community/ex_machina/commit/b796311761413086dac24dd3451ded8c0c349870)) 21 | 22 | 23 | ### Miscellaneous 24 | 25 | * Add release please manifest file ([ae31578](https://github.com/beam-community/ex_machina/commit/ae31578057b682b6bf7d51d3bb29229e4cbb16c3)) 26 | * Clear up log level warning ([821a61a](https://github.com/beam-community/ex_machina/commit/821a61a351676c2fee717451d8881059dc04730d)) 27 | * Fix missing , in configuration file ([cf74a91](https://github.com/beam-community/ex_machina/commit/cf74a91a4f0b913b6b96a233692419a030be4cff)) 28 | * README updates ([#444](https://github.com/beam-community/ex_machina/issues/444)) ([a4352dd](https://github.com/beam-community/ex_machina/commit/a4352dd59c3ce90d95fe85af65dda9ad332367d1)) 29 | * Remove circleci, update tools-version ([#438](https://github.com/beam-community/ex_machina/issues/438)) ([b06f4b6](https://github.com/beam-community/ex_machina/commit/b06f4b6ebe323b8c0b4edcc961fbf4abb5a68bcd)) 30 | * Remove extra character from test configuration file ([e8edf47](https://github.com/beam-community/ex_machina/commit/e8edf473c84ba414c5c07c267abac773cb64be6d)) 31 | * Resolve config warning ([#440](https://github.com/beam-community/ex_machina/issues/440)) ([a327830](https://github.com/beam-community/ex_machina/commit/a32783031b21cd91bced561d4a30e9031f8a77e9)) 32 | * Satisfy Credo consistency check ([5aa4f01](https://github.com/beam-community/ex_machina/commit/5aa4f01c8234a76cb3a41fa43e6da7cbd668ba03)) 33 | * Support common-config ([#436](https://github.com/beam-community/ex_machina/issues/436)) ([2c2a309](https://github.com/beam-community/ex_machina/commit/2c2a309532f418083dac0df7bc74bf675056ad28)) 34 | * Sync files with beam-community/common-config ([#437](https://github.com/beam-community/ex_machina/issues/437)) ([72e4038](https://github.com/beam-community/ex_machina/commit/72e40389a273a38be5f8dd96aef02976258212aa)) 35 | * Sync files with beam-community/common-config ([#441](https://github.com/beam-community/ex_machina/issues/441)) ([c809bce](https://github.com/beam-community/ex_machina/commit/c809bce0db914604b431be8d47d922aae9fe8e3e)) 36 | * Sync files with beam-community/common-config ([#448](https://github.com/beam-community/ex_machina/issues/448)) ([cca2acf](https://github.com/beam-community/ex_machina/commit/cca2acfecd66b1002eff2470a42e9302c76f5495)) 37 | * Sync files with beam-community/common-config ([#450](https://github.com/beam-community/ex_machina/issues/450)) ([69612ae](https://github.com/beam-community/ex_machina/commit/69612ae19903a9410cc1fbaf9680d070c0b72370)) 38 | * Update and run formatter ([#439](https://github.com/beam-community/ex_machina/issues/439)) ([8bb6057](https://github.com/beam-community/ex_machina/commit/8bb605725658a9dc36bd6e1f1579736f4b6514f4)) 39 | * Update mix.exs and deps ([c6c76f0](https://github.com/beam-community/ex_machina/commit/c6c76f044d4fe8f57d82daed50800f8d43bd15b2)) 40 | * Update test postgres configuration ([6aab2c8](https://github.com/beam-community/ex_machina/commit/6aab2c80cf17a66a0f087e6402b74e5477510884)) 41 | 42 | ## [2.7.0] 43 | 44 | [2.7.0]: https://github.com/thoughtbot/ex_machina/compare/v2.6.0...v2.7.0 45 | 46 | ### Added 47 | 48 | - Allow setting sequence starting point (#414) 49 | 50 | [#414]: https://github.com/thoughtbot/ex_machina/pull/414 51 | 52 | ## [2.6.0] 53 | 54 | [2.6.0]: https://github.com/thoughtbot/ex_machina/compare/v2.5.0...v2.6.0 55 | 56 | ### Added 57 | 58 | - Pass opts to Repo.insert! (add function-level opts to strategies) ([#411]) 59 | 60 | ### Fixes/Improvements 61 | 62 | - Import evaluate_lazy_attributes for ExMachina ([#410]) 63 | 64 | ### Docs 65 | 66 | - Use HTTPS for links in README ([#413]) 67 | - Remove "web" dir from README.md ([#412]) 68 | 69 | [#413]: https://github.com/thoughtbot/ex_machina/pull/413 70 | [#412]: https://github.com/thoughtbot/ex_machina/pull/412 71 | [#411]: https://github.com/thoughtbot/ex_machina/pull/411 72 | [#410]: https://github.com/thoughtbot/ex_machina/pull/410 73 | 74 | ## [2.5.0] 75 | 76 | [2.5.0]: https://github.com/thoughtbot/ex_machina/compare/v2.4.0...v2.5.0 77 | 78 | ### Added 79 | 80 | - Allow delayed evaluation of attributes ([#408]) 81 | 82 | ### Fixes 83 | 84 | - Fix Elixir 1.11 compiler warnings ([#399]) 85 | - Fix Elixir 1.11 warning by using extra_applications ([#400]) 86 | 87 | ### Docs 88 | 89 | - Update references to prior art ([#384]) 90 | - Bump version number in Readme ([#376]) 91 | 92 | [#376]: https://github.com/thoughtbot/ex_machina/pull/376 93 | [#384]: https://github.com/thoughtbot/ex_machina/pull/384 94 | [#399]: https://github.com/thoughtbot/ex_machina/pull/399 95 | [#400]: https://github.com/thoughtbot/ex_machina/pull/400 96 | [#408]: https://github.com/thoughtbot/ex_machina/pull/408 97 | 98 | ## [2.4.0] 99 | 100 | ### Added 101 | 102 | - Allow ExMachina.Ecto to be used without :repo option ([#370]) 103 | 104 | [2.4.0]: https://github.com/thoughtbot/ex_machina/compare/v2.3.0...v2.4.0 105 | [#370]: https://github.com/thoughtbot/ex_machina/pull/370 106 | 107 | ## [2.3.0] 108 | 109 | ### Added 110 | 111 | - Allows more control over factory definitions ([#333]) 112 | - Adds ability to reset specific sequences ([#331]) 113 | 114 | ### Docs 115 | 116 | - Adds additional callbacks for functions with default params ([#319]) 117 | 118 | ## Updated dependencies 119 | 120 | - Bump ex_doc from 0.19.1 to 0.19.3 121 | - Bump ecto_sql from 3.0.0 to 3.0.5 122 | - Bump ecto from 3.0.0 to 3.0.5 123 | 124 | [2.3.0]: https://github.com/thoughtbot/ex_machina/compare/v2.2.2...v2.3.0 125 | [#333]: https://github.com/thoughtbot/ex_machina/pull/333 126 | [#331]: https://github.com/thoughtbot/ex_machina/pull/331 127 | [#319]: https://github.com/thoughtbot/ex_machina/pull/319 128 | 129 | ## [2.2.2] 130 | 131 | - Adds support for Ecto 3.0 ([#301]) 132 | 133 | [2.2.2]: https://github.com/thoughtbot/ex_machina/compare/v2.2.1...v2.2.2 134 | [#301]: https://github.com/thoughtbot/ex_machina/pull/301 135 | 136 | ## [2.2.1] 137 | 138 | ### Fixed 139 | 140 | - Fixes sequence typespec ([#278]) 141 | 142 | ### Removed 143 | 144 | - Removed `fields_for/2` function that would raise an error since 1.0.0 ([#287]) 145 | 146 | ### Docs 147 | 148 | - Adds example for derived attribute ([#264]) 149 | - Adds example for dependent factory ([#239]) 150 | 151 | 152 | [2.2.1]: https://github.com/thoughtbot/ex_machina/compare/v2.2.0...v2.2.1 153 | [#239]: https://github.com/thoughtbot/ex_machina/pull/239 154 | [#264]: https://github.com/thoughtbot/ex_machina/pull/264 155 | [#278]: https://github.com/thoughtbot/ex_machina/pull/278 156 | [#287]: https://github.com/thoughtbot/ex_machina/pull/287 157 | 158 | ## [2.2.0] 159 | 160 | ### Added 161 | 162 | - Adds support for using lists in sequences ([#227]). 163 | 164 | ### Fixed 165 | 166 | - Elixir 1.6.x changed the behavior of `Regex.split/3` which caused factory 167 | names to break. Added a fix in ([#275]). 168 | 169 | [2.2.0]: https://github.com/thoughtbot/ex_machina/compare/v2.1.0...v2.2.0 170 | [#227]: https://github.com/thoughtbot/ex_machina/pull/227 171 | [#275]: https://github.com/thoughtbot/ex_machina/pull/275 172 | 173 | ## [2.1.0] 174 | 175 | ### Added 176 | 177 | - Support bare maps in embeds https://github.com/thoughtbot/ex_machina/commit/efd4e7c6125843d20b8dd07d91ded6240ecaf5ef 178 | - Handle nested structures in `string_params_for/2` https://github.com/thoughtbot/ex_machina/pull/224 179 | 180 | ### Fixed 181 | 182 | - Handle the number `0` in `*_list` functions https://github.com/thoughtbot/ex_machina/commit/012e957e7ab1e22eca18b62e8f3fcc2a98a7f286 183 | 184 | ### Improved 185 | 186 | - Miscellaneous documentation improvements. 187 | 188 | [2.1.0]: https://github.com/thoughtbot/ex_machina/compare/v2.0.0...v2.1.0 189 | 190 | ## [2.0.0] 191 | 192 | ### Added 193 | 194 | - Cast all values before insert ([#149]) 195 | 196 | For example, this means that if you have `field :equity, :decimal` in your 197 | schema, you can set the value to `0` in your factory and it will automatically 198 | cast the value to a Decimal. 199 | 200 | - Add `string_params_for`, which is useful for controller specs. ([#168]) 201 | - Add `Sequence.reset/0` for resetting sequences between tests. ([#151]) 202 | 203 | ### Changed 204 | 205 | - `params_*` functions now drop fields with `nil` values ([#148]) 206 | - Don't delete `has_many`s from `params_*` functions ([#174]) 207 | 208 | ### Fixed 209 | 210 | - Fix an issue where values on embedded associations would not be cast ([#200]) 211 | - Only drop autogenerated ids ([#147]) 212 | - Fix an issue where setting an association to `nil` would break `insert` ([#193]) 213 | - Fix an issue where unbuild has_many through associations were not removed in 214 | `params_*` functions ([#192]) 215 | 216 | [2.0.0]: https://github.com/thoughtbot/ex_machina/compare/v1.0.2...v2.0.0 217 | [#200]: https://github.com/thoughtbot/ex_machina/pull/200 218 | [#149]: https://github.com/thoughtbot/ex_machina/pull/149 219 | [#151]: https://github.com/thoughtbot/ex_machina/pull/151 220 | [#148]: https://github.com/thoughtbot/ex_machina/pull/148 221 | [#147]: https://github.com/thoughtbot/ex_machina/pull/147 222 | [#168]: https://github.com/thoughtbot/ex_machina/pull/168 223 | [#174]: https://github.com/thoughtbot/ex_machina/pull/174 224 | [#193]: https://github.com/thoughtbot/ex_machina/pull/193 225 | [#192]: https://github.com/thoughtbot/ex_machina/pull/192 226 | 227 | ## [1.0.2] 228 | 229 | Minor documentation fixes 230 | 231 | [1.0.2]: https://github.com/thoughtbot/ex_machina/compare/v1.0.1...v1.0.2 232 | 233 | ## [1.0.1] 234 | 235 | Small change to the error generated when a factory definition is not found ([#142]) 236 | 237 | [1.0.1]: https://github.com/thoughtbot/ex_machina/compare/v1.0.0...v1.0.1 238 | [#142]: https://github.com/thoughtbot/ex_machina/pull/142 239 | 240 | ## [1.0.0] 241 | 242 | A lot has changed but we tried to make upgrading as simple as possible. 243 | 244 | **To upgrade:** In `mix.exs` change the version to `"~> 1.0"` and run `mix 245 | deps.get`. Once you've updated, run `mix test` and ExMachina will raise errors 246 | that show you what needs to change to work with 1.0.0. 247 | 248 | ### Fixed 249 | 250 | - Fix compilation issues under OTP 19 ([#138]) 251 | - Raise helpful error when trying to insert twice ([#128]) 252 | 253 | ### Added 254 | 255 | - Add `Sequence.next/1` for quickly creating sequences. Example: 256 | `sequence("username")` will generate `"username1"`, then `"username2"` ([#84]) 257 | - Raise if passing invalid keys to structs ([#99]) 258 | - Add `params_with_assocs` ([#124]) 259 | 260 | ### Changed 261 | 262 | - Rename `fields_for` to `params_for` ([#98]) 263 | - If using ExMachina with Ecto, use `insert`, `insert_list` and `insert_pair` 264 | instead of `create_*` 265 | - Instead of defining a custom `save_record`, you can now implement an 266 | `ExMachina.Strategy`. See the documentation on hex.pm for more info ([#102]) 267 | - Define factory as `user_factory` instead of `factory(:user)` ([#110]). See PR 268 | and related issue for details on why this was changed. 269 | - `params_for` no longer returns the primary key ([#123]) 270 | 271 | [1.0.0]: https://github.com/thoughtbot/ex_machina/compare/v0.6.1...v1.0.0 272 | [#138]: https://github.com/thoughtbot/ex_machina/pull/138 273 | [#128]: https://github.com/thoughtbot/ex_machina/pull/128 274 | [#84]: https://github.com/thoughtbot/ex_machina/pull/84 275 | [#99]: https://github.com/thoughtbot/ex_machina/pull/99 276 | [#124]: https://github.com/thoughtbot/ex_machina/pull/124 277 | [#98]: https://github.com/thoughtbot/ex_machina/pull/98 278 | [#102]: https://github.com/thoughtbot/ex_machina/pull/102 279 | [#110]: https://github.com/thoughtbot/ex_machina/pull/110 280 | [#123]: https://github.com/thoughtbot/ex_machina/pull/123 281 | 282 | ## [0.6.1] 283 | 284 | Removes warnings as reported by 285 | https://github.com/thoughtbot/ex_machina/issues/70. We recommend updating if you 286 | are using Ecto 1.1. There are no backward incompatible changes and no new 287 | features. 288 | 289 | [0.6.1]: https://github.com/thoughtbot/ex_machina/compare/v0.6.0...v0.6.1 290 | 291 | ## [0.6.0] 292 | 293 | You can continue using ExMachina 0.5.0 if you are not ready for Ecto 1.1 yet. 294 | There are no additional new features in this release. 295 | 296 | - Updated to use Ecto 1.1 297 | - Require Ecto 1.1 298 | 299 | There are still some warnings that we need to fix for Ecto 1.1, but this release 300 | at least fixes the error that was caused when upgrading to Ecto 1.1. 301 | 302 | [0.6.0]: https://github.com/thoughtbot/ex_machina/compare/v0.5.0...v0.6.0 303 | 304 | ## [0.5.0] 305 | 306 | ### Changed 307 | 308 | - Factories were simplified so that `attrs` is no longer required. See [70a0481] and [issue #56] 309 | - ExMachina.Ecto.assoc/3 was removed. You can now use build(:factory) instead. See discussion in [issue #56] 310 | 311 | ### Fixed 312 | - Use association id as defined on the schema [7c67047] 313 | 314 | [issue #56]:https://github.com/thoughtbot/ex_machina/issues/56 315 | [70a0481]: https://github.com/thoughtbot/ex_machina/commit/70a04814aacc33b3c727e133f4bd6b03a8217731 316 | [7c67047]:https://github.com/thoughtbot/ex_machina/commit/7c6704706cffa7285a608049a1b1f10784790fdd 317 | [0.5.0]: https://github.com/thoughtbot/ex_machina/compare/v0.4.0...v0.5.0 318 | 319 | ## [0.4.0] 320 | 321 | ### Added 322 | 323 | - Add support for `has_many` and `has_one` Ecto associations. See [1ff4198]. 324 | 325 | ### Changed 326 | 327 | - Factories must now be defined with functions. See [59b7d23] 328 | 329 | [1ff4198]: https://github.com/thoughtbot/ex_machina/commit/1ff4198488caa8225563ec2d4262a6f42d7d29be 330 | [59b7d23]: https://github.com/thoughtbot/ex_machina/commit/59b7d23522d8ef4a3ae209f856b4d3c159de376e 331 | [0.4.0]: https://github.com/thoughtbot/ex_machina/compare/v0.3.0...v0.4.0 332 | 333 | ## [0.3.0] 334 | 335 | ### Added 336 | 337 | - Add `build_list` and `build_pair`. See [8f332ce]. 338 | - Add a `create` method that takes a map. This allows you to chain functions 339 | like: `build(:foo) |> make_admin |> create`. See [59cbef5]. 340 | 341 | [8f332ce]: https://github.com/thoughtbot/ex_machina/commit/8f332ce0499f4e81f9dbb653fef3a6bc1e697cb6 342 | [59cbef5]: https://github.com/thoughtbot/ex_machina/commit/59cbef569d7740d2958653fe177790b0cb506ff6 343 | 344 | ### Changed 345 | 346 | - Factories must now be defined with a macro. See [03c41f6] 347 | - `belongs_to` associations are now built instead of created. See [b518285]. 348 | 349 | [b518285]: https://github.com/thoughtbot/ex_machina/commit/b518285fa144459c36848bda5e72498914c19cdd 350 | [03c41f6]: https://github.com/thoughtbot/ex_machina/commit/03c41f64470423a168f91d40edcd91eb242c3c61 351 | [0.3.0]: https://github.com/thoughtbot/ex_machina/compare/v0.2.0...v0.3.0 352 | 353 | ## [0.2.0] 354 | 355 | ### Changed 356 | 357 | - Ecto functionality was extracted to `ExMachina.Ecto`. See [270c19b]. 358 | 359 | [270c19b]: https://github.com/thoughtbot/ex_machina/commit/270c19bbb805b7c62365612419410990f28c8baf 360 | [0.2.0]: https://github.com/thoughtbot/ex_machina/compare/v0.1.0...v0.2.0 361 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 BEAM Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 thoughtbot, inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExMachina 2 | 3 | [![Continuous Integration](https://github.com/beam-community/ex_machina/actions/workflows/ci.yaml/badge.svg)](https://github.com/beam-community/ex_machina/actions/workflows/ci.yaml) 4 | [![Module Version](https://img.shields.io/hexpm/v/ex_machina.svg)](https://hex.pm/packages/ex_machina) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/ex_machina/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/ex_machina.svg)](https://hex.pm/packages/ex_machina) 7 | [![License](https://img.shields.io/hexpm/l/ex_machina.svg)](https://github.com/beam-community/ex_machina/blob/master/LICENSE) 8 | [![Last Updated](https://img.shields.io/github/last-commit/beam-community/ex_machina.svg)](https://github.com/beam-community/ex_machina/commits/master) 9 | 10 | ExMachina makes it easy to create test data and associations. It works great 11 | with Ecto, but is configurable to work with any persistence library. 12 | 13 | > **This README follows the main branch, which may not be the currently published version**. Here are the 14 | [docs for the latest published version of ExMachina](https://hexdocs.pm/ex_machina/readme.html). 15 | 16 | ## Installation 17 | 18 | In `mix.exs`, add the ExMachina dependency: 19 | 20 | 21 | ```elixir 22 | def deps do 23 | [ 24 | {:ex_machina, "~> 2.8.0", only: :test}, 25 | ] 26 | end 27 | ``` 28 | 29 | 30 | Add your factory module inside `test/support` so that it is only compiled in the 31 | test environment. 32 | 33 | Next, be sure to start the application in your `test/test_helper.exs` before 34 | ExUnit.start: 35 | 36 | ```elixir 37 | {:ok, _} = Application.ensure_all_started(:ex_machina) 38 | ``` 39 | 40 | #### Install in just the test environment for non-Phoenix projects 41 | 42 | You will follow the same instructions as above, but you will also need to add 43 | `test/support` to your compilation paths (elixirc_paths) if you have not done 44 | so already. 45 | 46 | In `mix.exs`, add test/support to your elixirc_paths for just the test env. 47 | 48 | ```elixir 49 | def project do 50 | [app: ..., 51 | # Add this if it's not already in your project definition. 52 | elixirc_paths: elixirc_paths(Mix.env)] 53 | end 54 | 55 | # This makes sure your factory and any other modules in test/support are compiled 56 | # when in the test environment. 57 | defp elixirc_paths(:test), do: ["lib", "test/support"] 58 | defp elixirc_paths(_), do: ["lib"] 59 | ``` 60 | 61 | ## Overview 62 | 63 | [Check out the docs](https://hexdocs.pm/ex_machina/ExMachina.html) for more details. 64 | 65 | Define factories: 66 | 67 | ```elixir 68 | defmodule MyApp.Factory do 69 | # with Ecto 70 | use ExMachina.Ecto, repo: MyApp.Repo 71 | 72 | # without Ecto 73 | use ExMachina 74 | 75 | def user_factory do 76 | %MyApp.User{ 77 | name: "Jane Smith", 78 | email: sequence(:email, &"email-#{&1}@example.com"), 79 | role: sequence(:role, ["admin", "user", "other"]), 80 | } 81 | end 82 | 83 | def article_factory do 84 | title = sequence(:title, &"Use ExMachina! (Part #{&1})") 85 | # derived attribute 86 | slug = MyApp.Article.title_to_slug(title) 87 | %MyApp.Article{ 88 | title: title, 89 | slug: slug, 90 | # another way to build derived attributes 91 | tags: fn article -> 92 | if String.contains?(article.title, "Silly") do 93 | ["silly"] 94 | else 95 | [] 96 | end 97 | end, 98 | # associations are inserted when you call `insert` 99 | author: build(:user) 100 | } 101 | end 102 | 103 | # derived factory 104 | def featured_article_factory do 105 | struct!( 106 | article_factory(), 107 | %{ 108 | featured: true, 109 | } 110 | ) 111 | end 112 | 113 | def comment_factory do 114 | %MyApp.Comment{ 115 | text: "It's great!", 116 | article: build(:article), 117 | } 118 | end 119 | end 120 | ``` 121 | 122 | Using factories ([check out the docs](https://hexdocs.pm/ex_machina/ExMachina.html) for more details): 123 | 124 | ```elixir 125 | # `attrs` are automatically merged in for all build/insert functions. 126 | 127 | # `build*` returns an unsaved comment. 128 | # Associated records defined on the factory are built. 129 | attrs = %{body: "A comment!"} # attrs is optional. Also accepts a keyword list. 130 | build(:comment, attrs) 131 | build_pair(:comment, attrs) 132 | build_list(3, :comment, attrs) 133 | 134 | # `insert*` returns an inserted comment. Only works with ExMachina.Ecto 135 | # Associated records defined on the factory are inserted as well. 136 | insert(:comment, attrs) 137 | insert_pair(:comment, attrs) 138 | insert_list(3, :comment, attrs) 139 | 140 | # `params_for` returns a plain map without any Ecto specific attributes. 141 | # This is only available when using `ExMachina.Ecto`. 142 | params_for(:comment, attrs) 143 | 144 | # `params_with_assocs` is the same as `params_for` but inserts all belongs_to 145 | # associations and sets the foreign keys. 146 | # This is only available when using `ExMachina.Ecto`. 147 | params_with_assocs(:comment, attrs) 148 | 149 | # Use `string_params_for` to generate maps with string keys. This can be useful 150 | # for Phoenix controller tests. 151 | string_params_for(:comment, attrs) 152 | string_params_with_assocs(:comment, attrs) 153 | ``` 154 | 155 | ## Delayed evaluation of attributes 156 | 157 | `build/2` is a function call. As such, it gets evaluated immediately. So this 158 | code: 159 | 160 | ```elixir 161 | insert_pair(:account, user: build(:user)) 162 | ``` 163 | 164 | Is equivalent to this: 165 | 166 | ```elixir 167 | user = build(:user) 168 | insert_pair(:account, user: user) # same user for both accounts 169 | ``` 170 | 171 | Sometimes that presents a problem. Consider the following factory: 172 | 173 | ```elixir 174 | def user_factory do 175 | %{name: "Gandalf", email: sequence(:email, &"gandalf#{&1}@istari.com")} 176 | end 177 | ``` 178 | 179 | If you want to build a separate `user` per `account`, then calling 180 | `insert_pair(:account, user: build(:user))` will not give you the desired 181 | result. 182 | 183 | In those cases, you can delay the execution of the factory by passing it as an 184 | anonymous function: 185 | 186 | ```elixir 187 | insert_pair(:account, user: fn -> build(:user) end) 188 | ``` 189 | 190 | You can also do that in a factory definition: 191 | 192 | ```elixir 193 | def account_factory do 194 | %{user: fn -> build(:user) end} 195 | end 196 | ``` 197 | 198 | You can even accept the parent record as an argument to the function: 199 | 200 | ```elixir 201 | def account_factory do 202 | %{user: fn account -> build(:user, vip: account.premium) end} 203 | end 204 | ``` 205 | 206 | Note that the `account` passed to the anonymous function is only the struct 207 | after it's built. It's not an inserted record. Thus, it does not have data that 208 | is only accessible after being inserted into the database (e.g. `id`). 209 | 210 | ## Full control of factory 211 | 212 | By default, ExMachina will merge the attributes you pass into build/insert into 213 | your factory. But if you want full control of your attributes, you can define 214 | your factory as accepting one argument, the attributes being passed into your 215 | factory. 216 | 217 | ```elixir 218 | def custom_article_factory(attrs) do 219 | title = Map.get(attrs, :title, "default title") 220 | 221 | article = %Article{ 222 | author: "John Doe", 223 | title: title 224 | } 225 | 226 | # merge attributes and evaluate lazy attributes at the end to emulate 227 | # ExMachina's default behavior 228 | article 229 | |> merge_attributes(attrs) 230 | |> evaluate_lazy_attributes() 231 | end 232 | ``` 233 | 234 | **NOTE** that in this case ExMachina will _not_ merge the attributes into your 235 | factory, and it will not evaluate lazy attributes. You will have to do this on 236 | your own if desired. 237 | 238 | ### Non-map factories 239 | 240 | Because you have full control of the factory when defining it with one argument, 241 | you can build factories that are neither maps nor structs. 242 | 243 | ```elixir 244 | # factory definition 245 | def room_number_factory(attrs) do 246 | %{floor: floor_number} = attrs 247 | sequence(:room_number, &"#{floor_number}0#{&1}") 248 | end 249 | 250 | # example usage 251 | build(:room_number, floor: 5) 252 | # => "500" 253 | 254 | build(:room_number, floor: 5) 255 | # => "501" 256 | ``` 257 | 258 | **NOTE** that you cannot use non-map factories with Ecto. So you cannot 259 | `insert(:room_number)`. 260 | 261 | ## Usage in a test 262 | 263 | ```elixir 264 | # Example of use in Phoenix with a factory that uses ExMachina.Ecto 265 | defmodule MyApp.MyModuleTest do 266 | use MyApp.ConnCase 267 | # If using Phoenix, import this inside the using block in MyApp.ConnCase 268 | import MyApp.Factory 269 | 270 | test "shows comments for an article" do 271 | conn = conn() 272 | article = insert(:article) 273 | comment = insert(:comment, article: article) 274 | 275 | conn = get conn, article_path(conn, :show, article.id) 276 | 277 | assert html_response(conn, 200) =~ article.title 278 | assert html_response(conn, 200) =~ comment.body 279 | end 280 | end 281 | ``` 282 | 283 | ## Where to put your factories 284 | 285 | If you are using ExMachina in all environments: 286 | 287 | > Start by creating one factory module (such as `MyApp.Factory`) in 288 | `lib/my_app/factory.ex` and putting all factory definitions in that module. 289 | 290 | If you are using ExMachina in only the test environment: 291 | 292 | > Start by creating one factory module (such as `MyApp.Factory`) in 293 | `test/support/factory.ex` and putting all factory definitions in that module. 294 | 295 | Later on you can easily create different factories by creating a new module in 296 | the same directory. This can be helpful if you need to create factories that are 297 | used for different repos, your factory module is getting too big, or if you have 298 | different ways of saving the record for different types of factories. 299 | 300 | ### Splitting factories into separate files 301 | 302 | This example shows how to set up factories for the testing environment. For setting them in all environments, please see the _To install in all environments_ section 303 | 304 | > Start by creating main factory module in `test/support/factory.ex` and name it `MyApp.Factory`. The purpose of the main factory is to allow you to include only a single module in all tests. 305 | 306 | ```elixir 307 | # test/support/factory.ex 308 | defmodule MyApp.Factory do 309 | use ExMachina.Ecto, repo: MyApp.Repo 310 | use MyApp.ArticleFactory 311 | end 312 | ``` 313 | 314 | The main factory includes `MyApp.ArticleFactory`, so let's create it next. It might be useful to create a separate directory for factories, like `test/factories`. Here is how to create a factory: 315 | 316 | ```elixir 317 | # test/factories/article_factory.ex 318 | defmodule MyApp.ArticleFactory do 319 | defmacro __using__(_opts) do 320 | quote do 321 | def article_factory do 322 | %MyApp.Article{ 323 | title: "My awesome article!", 324 | body: "Still working on it!" 325 | } 326 | end 327 | end 328 | end 329 | end 330 | ``` 331 | 332 | This way you can split your giant factory file into many small files. But what about name conflicts? Use pattern matching to avoid them! 333 | 334 | ```elixir 335 | # test/factories/post_factory.ex 336 | defmodule MyApp.PostFactory do 337 | defmacro __using__(_opts) do 338 | quote do 339 | def post_factory do 340 | %MyApp.Post{ 341 | body: "Example body" 342 | } 343 | end 344 | 345 | def with_comments(%MyApp.Post{} = post) do 346 | insert_pair(:comment, post: post) 347 | post 348 | end 349 | end 350 | end 351 | end 352 | 353 | # test/factories/video_factory.ex 354 | defmodule MyApp.VideoFactory do 355 | defmacro __using__(_opts) do 356 | quote do 357 | def video_factory do 358 | %MyApp.Video{ 359 | url: "example_url" 360 | } 361 | end 362 | 363 | def with_comments(%MyApp.Video{} = video) do 364 | insert_pair(:comment, video: video) 365 | video 366 | end 367 | end 368 | end 369 | end 370 | ``` 371 | 372 | If you place your factories outside of `test/support` make sure they will compile by adding that directory to the compilation paths in your `mix.exs` file. For example for the `test/factories` files above you would modify your file like so: 373 | 374 | ```elixir 375 | # ./mix.exs 376 | ... 377 | defp elixirc_paths(:test), do: ["lib", "test/factories", "test/support"] 378 | ... 379 | ``` 380 | 381 | ## Ecto 382 | 383 | ### Ecto Associations 384 | 385 | ExMachina will automatically save any associations when you call any of the 386 | `insert` functions. This includes `belongs_to` and anything that is 387 | inserted by Ecto when using `Repo.insert!`, such as `has_many`, `has_one`, 388 | and embeds. Since we automatically save these records for you, we advise that 389 | factory definitions only use `build/2` when declaring associations, like so: 390 | 391 | ```elixir 392 | def article_factory do 393 | %Article{ 394 | title: "Use ExMachina!", 395 | # associations are inserted when you call `insert` 396 | comments: [build(:comment)], 397 | author: build(:user), 398 | } 399 | end 400 | ``` 401 | 402 | Using `insert/2` in factory definitions may lead to performance issues and bugs, 403 | as records will be saved unnecessarily. 404 | 405 | ### Passing options to Repo.insert!/2 406 | 407 | `ExMachina.Ecto` uses 408 | [`Repo.insert!/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert!/2) to 409 | insert records into the database. Sometimes you may want to pass options to deal 410 | with multi-tenancy or return some values generated by the database. In those 411 | cases, you can use `c:ExMachina.Ecto.insert/3`: 412 | 413 | For example, 414 | 415 | ```elixir 416 | # return values from the database 417 | insert(:user, [name: "Jane"], returning: true) 418 | 419 | # use a different prefix 420 | insert(:user, [name: "Jane"], prefix: "other_tenant") 421 | ``` 422 | 423 | ## Flexible Factories with Pipes 424 | 425 | ```elixir 426 | def make_admin(user) do 427 | %{user | admin: true} 428 | end 429 | 430 | def with_article(user) do 431 | insert(:article, user: user) 432 | user 433 | end 434 | 435 | build(:user) |> make_admin |> insert |> with_article 436 | ``` 437 | 438 | ## Using with Phoenix 439 | 440 | If you want to keep the factories somewhere other than `test/support`, 441 | change this line in `mix.exs`: 442 | 443 | ```elixir 444 | # Add the folder to the end of the list. In this case we're adding `test/factories`. 445 | defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"] 446 | ``` 447 | 448 | ## Custom Strategies 449 | 450 | You can use ExMachina without Ecto, by using just the `build` functions, or you 451 | can define one or more custom strategies to use in your factory. You can also 452 | use custom strategies with Ecto. Here's an example of a strategy for json 453 | encoding your factories. See the docs on [ExMachina.Strategy] for more info. 454 | 455 | [ExMachina.Strategy]: https://hexdocs.pm/ex_machina/ExMachina.Strategy.html 456 | 457 | ```elixir 458 | defmodule MyApp.JsonEncodeStrategy do 459 | use ExMachina.Strategy, function_name: :json_encode 460 | 461 | def handle_json_encode(record, _opts) do 462 | Poison.encode!(record) 463 | end 464 | end 465 | 466 | defmodule MyApp.Factory do 467 | use ExMachina 468 | # Using this will add json_encode/2, json_encode_pair/2 and json_encode_list/3 469 | use MyApp.JsonEncodeStrategy 470 | 471 | def user_factory do 472 | %User{name: "John"} 473 | end 474 | end 475 | 476 | # Will build and then return a JSON encoded version of the user. 477 | MyApp.Factory.json_encode(:user) 478 | ``` 479 | 480 | ## Contributing 481 | 482 | Before opening a pull request, please open an issue first. 483 | 484 | git clone https://github.com/thoughtbot/ex_machina.git 485 | cd ex_machina 486 | mix deps.get 487 | mix test 488 | 489 | Once you've made your additions and `mix test` passes, go ahead and open a PR! 490 | 491 | ## License 492 | 493 | ExMachina is Copyright © 2015 thoughtbot. It is free software, and may be 494 | redistributed under the terms specified in the [LICENSE](/LICENSE) file. 495 | 496 | ## About thoughtbot 497 | 498 | ![thoughtbot](https://thoughtbot.com/logo.png) 499 | 500 | ExMachina is maintained and funded by thoughtbot, inc. 501 | The names and logos for thoughtbot are trademarks of thoughtbot, inc. 502 | 503 | We love open source software, Elixir, and Phoenix. See [our other Elixir 504 | projects][elixir-phoenix], or [hire our Elixir Phoenix development team][hire] 505 | to design, develop, and grow your product. 506 | 507 | [elixir-phoenix]: https://thoughtbot.com/services/elixir-phoenix?utm_source=github 508 | [hire]: https://thoughtbot.com?utm_source=github 509 | 510 | ## Inspiration 511 | 512 | * [Fixtures for Ecto](https://blog.danielberkompas.com/elixir/2015/07/16/fixtures-for-ecto.html) 513 | * [Factory Bot](https://github.com/thoughtbot/factory_bot) 514 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | previous_version="${1}" 6 | release_version="${2}" 7 | 8 | sed -i '' "s/$previous_version/$release_version/" README.md 9 | sed -i '' "s/$previous_version/$release_version/" mix.exs 10 | 11 | git add . 12 | git commit -m "Release version $release_version" 13 | git tag "v$release_version" 14 | git push origin "v$release_version" 15 | mix hex.publish 16 | -------------------------------------------------------------------------------- /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 | # By default, ExMachina will convert datetime values to a map with string 6 | # keys such as 7 | # %{ "calendar" => Calendar.ISO, "day" => 4, "hour" => 17, "microsecond" => {657863, 6}, "minute" => 25, "month" => 8, "second" => 42, "std_offset" => 0, "time_zone" => "Etc/UTC", "utc_offset" => 0, "year" => 2019, "zone_abbr" => "UTC" } 8 | # If you would like the date values to be preserved, you can set the following 9 | # config value. This may be made the default in the future but is a breaking change. 10 | # 11 | # config :ex_machina, preserve_dates: true 12 | # 13 | # 14 | # This configuration is loaded before any dependency and is restricted 15 | # to this project. If another project depends on this project, this 16 | # file won't be loaded nor affect the parent project. For this reason, 17 | # if you want to provide default values for your application for third- 18 | # party users, it should be done in your mix.exs file. 19 | 20 | # Sample configuration: 21 | # 22 | # config :logger, :console, 23 | # level: :info, 24 | # format: "$date $time [$level] $metadata$message\n", 25 | # metadata: [:user_id] 26 | 27 | # It is also possible to import configuration files, relative to this 28 | # directory. For example, you can emulate configuration per environment 29 | # by uncommenting the line below and defining dev.exs, test.exs and such. 30 | # Configuration from the imported file will override the ones defined 31 | # here (which is why it is important to import them last). 32 | if Mix.env() == :test do 33 | import_config "test.exs" 34 | end 35 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ex_machina, preserve_dates: true 4 | 5 | config :ex_machina, ExMachina.TestRepo, 6 | pool: Ecto.Adapters.SQL.Sandbox, 7 | hostname: "localhost", 8 | port: "5432", 9 | username: "postgres", 10 | password: "postgres", 11 | database: "ex_machina_test" 12 | 13 | config :logger, level: :warning 14 | -------------------------------------------------------------------------------- /lib/ex_machina.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina do 2 | @moduledoc """ 3 | Defines functions for generating data 4 | 5 | In depth examples are in the [README](readme.html) 6 | """ 7 | use Application 8 | 9 | alias ExMachina.UndefinedFactoryError 10 | 11 | @callback build(factory_name :: atom) :: any 12 | @callback build(factory_name :: atom, attrs :: keyword | map) :: any 13 | @callback build_list(number_of_records :: integer, factory_name :: atom) :: list 14 | @callback build_list(number_of_records :: integer, factory_name :: atom, attrs :: keyword | map) :: list 15 | @callback build_pair(factory_name :: atom) :: list 16 | @callback build_pair(factory_name :: atom, attrs :: keyword | map) :: list 17 | 18 | @doc false 19 | def start(_type, _args) do 20 | Supervisor.start_link([ExMachina.Sequence], 21 | strategy: :one_for_one, 22 | name: __MODULE__.Supervisor 23 | ) 24 | end 25 | 26 | defmacro __using__(_opts) do 27 | quote do 28 | @before_compile unquote(__MODULE__) 29 | 30 | import ExMachina, 31 | only: [ 32 | sequence: 1, 33 | sequence: 2, 34 | sequence: 3, 35 | merge_attributes: 2, 36 | evaluate_lazy_attributes: 1 37 | ] 38 | 39 | alias ExMachina.UndefinedFactoryError 40 | 41 | def build(factory_name, attrs \\ %{}) do 42 | ExMachina.build(__MODULE__, factory_name, attrs) 43 | end 44 | 45 | def build_pair(factory_name, attrs \\ %{}) do 46 | ExMachina.build_pair(__MODULE__, factory_name, attrs) 47 | end 48 | 49 | def build_list(number_of_records, factory_name, attrs \\ %{}) do 50 | ExMachina.build_list(__MODULE__, number_of_records, factory_name, attrs) 51 | end 52 | 53 | @spec create(any) :: no_return 54 | def create(_) do 55 | raise_function_replaced_error("create/1", "insert/1") 56 | end 57 | 58 | @spec create(any, any) :: no_return 59 | def create(_, _) do 60 | raise_function_replaced_error("create/2", "insert/2") 61 | end 62 | 63 | @spec create_pair(any, any) :: no_return 64 | def create_pair(_, _) do 65 | raise_function_replaced_error("create_pair/2", "insert_pair/2") 66 | end 67 | 68 | @spec create_list(any, any, any) :: no_return 69 | def create_list(_, _, _) do 70 | raise_function_replaced_error("create_list/3", "insert_list/3") 71 | end 72 | 73 | @spec raise_function_replaced_error(String.t(), String.t()) :: no_return 74 | defp raise_function_replaced_error(old_function, new_function) do 75 | raise """ 76 | #{old_function} has been removed. 77 | 78 | If you are using ExMachina.Ecto, use #{new_function} instead. 79 | 80 | If you are using ExMachina with a custom `save_record/2`, you now must use ExMachina.Strategy. 81 | See the ExMachina.Strategy documentation for examples. 82 | """ 83 | end 84 | 85 | defoverridable create: 1, create: 2, create_pair: 2, create_list: 3 86 | end 87 | end 88 | 89 | @doc """ 90 | Shortcut for creating unique string values. 91 | 92 | This is automatically imported into a model factory when you `use ExMachina`. 93 | 94 | This is equivalent to `sequence(name, &"\#{name}\#{&1}")`. If you need to 95 | customize the returned string, see `sequence/2`. 96 | 97 | Note that sequences keep growing and are *not* reset by ExMachina. Most of the 98 | time you won't need to reset the sequence, but when you do need to reset them, 99 | you can use `ExMachina.Sequence.reset/0`. 100 | 101 | ## Examples 102 | 103 | def user_factory do 104 | %User{ 105 | # Will generate "username0" then "username1", etc. 106 | username: sequence("username") 107 | } 108 | end 109 | 110 | def article_factory do 111 | %Article{ 112 | # Will generate "Article Title0" then "Article Title1", etc. 113 | title: sequence("Article Title") 114 | } 115 | end 116 | """ 117 | @spec sequence(String.t()) :: String.t() 118 | 119 | def sequence(name), do: ExMachina.Sequence.next(name) 120 | 121 | @doc """ 122 | Create sequences for generating unique values. 123 | 124 | This is automatically imported into a model factory when you `use ExMachina`. 125 | 126 | The `name` can be any term, although it is typically an atom describing the 127 | sequence. Each time a sequence is called with the same `name`, its number is 128 | incremented by one. 129 | 130 | The `formatter` function takes the sequence number, and returns a sequential 131 | representation of that number – typically a formatted string. 132 | 133 | ## Examples 134 | 135 | def user_factory do 136 | %{ 137 | # Will generate "me-0@foo.com" then "me-1@foo.com", etc. 138 | email: sequence(:email, &"me-\#{&1}@foo.com"), 139 | # Will generate "admin" then "user", "other", "admin" etc. 140 | role: sequence(:role, ["admin", "user", "other"]) 141 | } 142 | end 143 | """ 144 | @spec sequence(any, (integer -> any) | nonempty_list) :: any 145 | def sequence(name, formatter), do: ExMachina.Sequence.next(name, formatter) 146 | 147 | @doc """ 148 | Similar to `sequence/2` but it allows for passing a `start_at` option 149 | to the sequence generation. 150 | 151 | ## Examples 152 | 153 | def user_factory do 154 | %{ 155 | # Will generate "me-100@foo.com" then "me-101@foo.com", etc. 156 | email: sequence(:email, &"me-\#{&1}@foo.com", start_at: 100), 157 | } 158 | end 159 | """ 160 | @spec sequence(any, (integer -> any) | nonempty_list, start_at: non_neg_integer) :: any 161 | def sequence(name, formatter, opts), do: ExMachina.Sequence.next(name, formatter, opts) 162 | 163 | @doc """ 164 | Builds a single factory. 165 | 166 | This will defer to the `[factory_name]_factory/0` callback defined in the 167 | factory module in which it is `use`d. 168 | 169 | ### Example 170 | 171 | def user_factory do 172 | %{name: "John Doe", admin: false} 173 | end 174 | 175 | # Returns %{name: "John Doe", admin: false} 176 | build(:user) 177 | 178 | # Returns %{name: "John Doe", admin: true} 179 | build(:user, admin: true) 180 | 181 | ## Full control of a factory's attributes 182 | 183 | If you want full control over the factory attributes, you can define the 184 | factory with `[factory_name]_factory/1`, taking in the attributes as the first 185 | argument. 186 | 187 | Caveats: 188 | 189 | - ExMachina will no longer merge the attributes for your factory. If you want 190 | to do that, you can merge the attributes with the `merge_attributes/2` helper. 191 | 192 | - ExMachina will no longer evaluate lazy attributes. If you want to do that, 193 | you can evaluate the lazy attributes with the `evaluate_lazy_attributes/1` 194 | helper. 195 | 196 | ### Example 197 | 198 | def article_factory(attrs) do 199 | title = Map.get(attrs, :title, "default title") 200 | slug = Article.title_to_slug(title) 201 | 202 | article = %Article{title: title, slug: slug} 203 | 204 | article 205 | # merge attributes on your own 206 | |> merge_attributes(attrs) 207 | # evaluate any lazy attributes 208 | |> evaluate_lazy_attributes() 209 | end 210 | 211 | # Returns %Article{title: "default title", slug: "default-title"} 212 | build(:article) 213 | 214 | # Returns %Article{title: "hello world", slug: "hello-world"} 215 | build(:article, title: "hello world") 216 | """ 217 | def build(module, factory_name, attrs \\ %{}) do 218 | attrs = Enum.into(attrs, %{}) 219 | 220 | function_name = build_function_name(factory_name) 221 | 222 | cond do 223 | factory_accepting_attributes_defined?(module, function_name) -> 224 | apply(module, function_name, [attrs]) 225 | 226 | factory_without_attributes_defined?(module, function_name) -> 227 | module 228 | |> apply(function_name, []) 229 | |> merge_attributes(attrs) 230 | |> evaluate_lazy_attributes() 231 | 232 | true -> 233 | raise UndefinedFactoryError, factory_name 234 | end 235 | end 236 | 237 | defp build_function_name(factory_name) do 238 | factory_name 239 | |> Atom.to_string() 240 | |> Kernel.<>("_factory") 241 | # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom 242 | |> String.to_atom() 243 | end 244 | 245 | defp factory_accepting_attributes_defined?(module, function_name) do 246 | Code.ensure_loaded?(module) && function_exported?(module, function_name, 1) 247 | end 248 | 249 | defp factory_without_attributes_defined?(module, function_name) do 250 | Code.ensure_loaded?(module) && function_exported?(module, function_name, 0) 251 | end 252 | 253 | @doc """ 254 | Helper function to merge attributes into a factory that could be either a map 255 | or a struct. 256 | 257 | ## Example 258 | 259 | # custom factory 260 | def article_factory(attrs) do 261 | title = Map.get(attrs, :title, "default title") 262 | 263 | article = %Article{ 264 | title: title 265 | } 266 | 267 | merge_attributes(article, attrs) 268 | end 269 | 270 | Note that when trying to merge attributes into a struct, this function will 271 | raise if one of the attributes is not defined in the struct. 272 | """ 273 | @spec merge_attributes(struct | map, map) :: struct | map | no_return 274 | def merge_attributes(%{__struct__: _} = record, attrs), do: struct!(record, attrs) 275 | def merge_attributes(record, attrs), do: Map.merge(record, attrs) 276 | 277 | @doc """ 278 | Helper function to evaluate lazy attributes that are passed into a factory. 279 | 280 | ## Example 281 | 282 | # custom factory 283 | def article_factory(attrs) do 284 | %{title: "title"} 285 | |> merge_attributes(attrs) 286 | |> evaluate_lazy_attributes() 287 | end 288 | 289 | def author_factory do 290 | %{name: sequence("gandalf")} 291 | end 292 | 293 | # => returns [ 294 | # %{title: "title", author: %{name: "gandalf0"}, 295 | # %{title: "title", author: %{name: "gandalf0"} 296 | # ] 297 | build_pair(:article, author: build(:author)) 298 | 299 | # => returns [ 300 | # %{title: "title", author: %{name: "gandalf0"}, 301 | # %{title: "title", author: %{name: "gandalf1"} 302 | # ] 303 | build_pair(:article, author: fn -> build(:author) end) 304 | """ 305 | @spec evaluate_lazy_attributes(struct | map) :: struct | map 306 | def evaluate_lazy_attributes(%{__struct__: record} = factory) do 307 | struct!( 308 | record, 309 | factory |> Map.from_struct() |> do_evaluate_lazy_attributes(factory) 310 | ) 311 | end 312 | 313 | def evaluate_lazy_attributes(attrs) when is_map(attrs) do 314 | do_evaluate_lazy_attributes(attrs, attrs) 315 | end 316 | 317 | defp do_evaluate_lazy_attributes(attrs, parent_factory) do 318 | attrs 319 | |> Enum.map(fn 320 | {k, v} when is_function(v, 1) -> {k, v.(parent_factory)} 321 | {k, v} when is_function(v) -> {k, v.()} 322 | {_, _} = tuple -> tuple 323 | end) 324 | |> Enum.into(%{}) 325 | end 326 | 327 | @doc """ 328 | Builds two factories. 329 | 330 | This is just an alias for `build_list(2, factory_name, attrs)`. 331 | 332 | ## Example 333 | 334 | # Returns a list of 2 users 335 | build_pair(:user) 336 | """ 337 | def build_pair(module, factory_name, attrs \\ %{}) do 338 | ExMachina.build_list(module, 2, factory_name, attrs) 339 | end 340 | 341 | @doc """ 342 | Builds any number of factories. 343 | 344 | ## Example 345 | 346 | # Returns a list of 3 users 347 | build_list(3, :user) 348 | """ 349 | def build_list(module, number_of_records, factory_name, attrs \\ %{}) do 350 | stream = 351 | Stream.repeatedly(fn -> 352 | ExMachina.build(module, factory_name, attrs) 353 | end) 354 | 355 | Enum.take(stream, number_of_records) 356 | end 357 | 358 | defmacro __before_compile__(_env) do 359 | quote do 360 | @doc "Raises a helpful error if no factory is defined." 361 | @spec factory(any) :: no_return 362 | def factory(factory_name), do: raise(UndefinedFactoryError, factory_name) 363 | end 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /lib/ex_machina/ecto.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.Ecto do 2 | @moduledoc """ 3 | Module for building and inserting factories with Ecto 4 | 5 | This module works much like the regular `ExMachina` module, but adds a few 6 | nice things that make working with Ecto easier. 7 | 8 | * It uses `ExMachina.EctoStrategy`, which adds `insert/1`, `insert/2`, 9 | `insert/3` `insert_pair/2`, `insert_list/3`. 10 | * Adds a `params_for` function that is useful for working with changesets or 11 | sending params to API endpoints. 12 | 13 | More in-depth examples are in the [README](readme.html). 14 | """ 15 | 16 | @callback insert(factory_name :: atom) :: any 17 | @callback insert(factory_name :: atom, attrs :: keyword | map) :: any 18 | 19 | @doc """ 20 | Builds a factory and inserts it into the database. 21 | 22 | The first two arguments are the same as `c:ExMachina.build/2`. The last 23 | argument is a set of options that will be passed to Ecto's 24 | [`Repo.insert!/2`](https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert!/2). 25 | 26 | ## Examples 27 | 28 | # return all values from the database 29 | insert(:user, [name: "Jane"], returning: true) 30 | build(:user, name: "Jane") |> insert(returning: true) 31 | 32 | # use a different prefix 33 | insert(:user, [name: "Jane"], prefix: "other_tenant") 34 | build(:user, name: "Jane") |> insert(prefix: "other_tenant") 35 | """ 36 | @callback insert(factory_name :: atom, attrs :: keyword | map, opts :: keyword | map) :: any 37 | 38 | @doc """ 39 | Builds two factories and inserts them into the database. 40 | 41 | The arguments are the same as `c:ExMachina.build_pair/2`. 42 | """ 43 | @callback insert_pair(factory_name :: atom) :: list 44 | @callback insert_pair(factory_name :: atom, attrs :: keyword | map) :: list 45 | 46 | @doc """ 47 | Builds many factories and inserts them into the database. 48 | 49 | The arguments are the same as `c:ExMachina.build_list/3`. 50 | """ 51 | @callback insert_list(number_of_records :: integer, factory_name :: atom) :: list 52 | @callback insert_list( 53 | number_of_records :: integer, 54 | factory_name :: atom, 55 | attrs :: keyword | map 56 | ) :: list 57 | 58 | @doc """ 59 | Builds a factory and returns only its fields. 60 | 61 | This is only for use with Ecto models. 62 | 63 | Will return a map with the fields and virtual fields, but without the Ecto 64 | metadata, the primary key, or any `belongs_to` associations. This will 65 | recursively act on `has_one` associations and Ecto structs found in 66 | `has_many` associations. 67 | 68 | If you want `belongs_to` associations to be inserted, use 69 | `c:params_with_assocs/2`. 70 | 71 | If you want params with string keys use `c:string_params_for/2`. 72 | 73 | ## Example 74 | 75 | def user_factory do 76 | %MyApp.User{name: "John Doe", admin: false} 77 | end 78 | 79 | # Returns %{name: "John Doe", admin: true} 80 | params_for(:user, admin: true) 81 | 82 | # Returns %{name: "John Doe", admin: false} 83 | params_for(:user) 84 | """ 85 | @callback params_for(factory_name :: atom) :: %{optional(atom) => any} 86 | @callback params_for(factory_name :: atom, attrs :: keyword | map) :: %{optional(atom) => any} 87 | 88 | @doc """ 89 | Similar to `c:params_for/2` but converts atom keys to strings in returned map. 90 | 91 | The result of this function can be safely used in controller tests for Phoenix 92 | web applications. 93 | 94 | ## Example 95 | 96 | def user_factory do 97 | %MyApp.User{name: "John Doe", admin: false} 98 | end 99 | 100 | # Returns %{"name" => "John Doe", "admin" => true} 101 | string_params_for(:user, admin: true) 102 | """ 103 | @callback string_params_for(factory_name :: atom) :: %{optional(String.t()) => any} 104 | @callback string_params_for(factory_name :: atom, attrs :: keyword | map) :: %{ 105 | optional(String.t()) => any 106 | } 107 | 108 | @doc """ 109 | Similar to `c:params_for/2` but inserts all `belongs_to` associations and 110 | sets the foreign keys. 111 | 112 | If you want params with string keys use `c:string_params_with_assocs/2`. 113 | 114 | ## Example 115 | 116 | def article_factory do 117 | %MyApp.Article{title: "An Awesome Article", author: build(:author)} 118 | end 119 | 120 | # Inserts an author and returns %{title: "An Awesome Article", author_id: 12} 121 | params_with_assocs(:article) 122 | """ 123 | @callback params_with_assocs(factory_name :: atom) :: %{optional(atom) => any} 124 | @callback params_with_assocs(factory_name :: atom, attrs :: keyword | map) :: %{ 125 | optional(atom) => any 126 | } 127 | @doc """ 128 | Similar to `c:params_with_assocs/2` but converts atom keys to strings in 129 | returned map. 130 | 131 | The result of this function can be safely used in controller tests for Phoenix 132 | web applications. 133 | 134 | ## Example 135 | 136 | def article_factory do 137 | %MyApp.Article{title: "An Awesome Article", author: build(:author)} 138 | end 139 | 140 | # Inserts an author and returns %{"title" => "An Awesome Article", "author_id" => 12} 141 | string_params_with_assocs(:article) 142 | """ 143 | @callback string_params_with_assocs(factory_name :: atom) :: %{optional(String.t()) => any} 144 | @callback string_params_with_assocs(factory_name :: atom, attrs :: keyword | map) :: %{ 145 | optional(String.t()) => any 146 | } 147 | 148 | defmacro __using__(opts) do 149 | verify_ecto_dep() 150 | 151 | quote do 152 | use ExMachina 153 | use ExMachina.EctoStrategy, repo: unquote(Keyword.get(opts, :repo)) 154 | 155 | def params_for(factory_name, attrs \\ %{}) do 156 | ExMachina.Ecto.params_for(__MODULE__, factory_name, attrs) 157 | end 158 | 159 | def string_params_for(factory_name, attrs \\ %{}) do 160 | ExMachina.Ecto.string_params_for(__MODULE__, factory_name, attrs) 161 | end 162 | 163 | def params_with_assocs(factory_name, attrs \\ %{}) do 164 | ExMachina.Ecto.params_with_assocs(__MODULE__, factory_name, attrs) 165 | end 166 | 167 | def string_params_with_assocs(factory_name, attrs \\ %{}) do 168 | ExMachina.Ecto.string_params_with_assocs(__MODULE__, factory_name, attrs) 169 | end 170 | end 171 | end 172 | 173 | @doc false 174 | def params_for(module, factory_name, attrs \\ %{}) do 175 | factory_name 176 | |> module.build(attrs) 177 | |> recursively_strip 178 | end 179 | 180 | @doc false 181 | def string_params_for(module, factory_name, attrs \\ %{}) do 182 | module 183 | |> params_for(factory_name, attrs) 184 | |> convert_atom_keys_to_strings 185 | end 186 | 187 | @doc false 188 | def params_with_assocs(module, factory_name, attrs \\ %{}) do 189 | factory_name 190 | |> module.build(attrs) 191 | |> insert_belongs_to_assocs(module) 192 | |> recursively_strip 193 | end 194 | 195 | @doc false 196 | def string_params_with_assocs(module, factory_name, attrs \\ %{}) do 197 | module 198 | |> params_with_assocs(factory_name, attrs) 199 | |> convert_atom_keys_to_strings 200 | end 201 | 202 | defp recursively_strip(%{__struct__: _} = record) do 203 | record 204 | |> set_persisted_belongs_to_ids 205 | |> handle_assocs 206 | |> handle_embeds 207 | |> drop_ecto_fields 208 | |> drop_fields_with_nil_values 209 | end 210 | 211 | defp recursively_strip(record), do: record 212 | 213 | defp handle_assocs(%{__struct__: struct} = record) do 214 | associations = struct.__schema__(:associations) 215 | 216 | Enum.reduce(associations, record, fn association_name, record -> 217 | case struct.__schema__(:association, association_name) do 218 | %{__struct__: Ecto.Association.BelongsTo} -> 219 | Map.delete(record, association_name) 220 | 221 | _ -> 222 | record 223 | |> Map.get(association_name) 224 | |> handle_assoc(record, association_name) 225 | end 226 | end) 227 | end 228 | 229 | defp handle_assoc(original_assoc, record, association_name) do 230 | case original_assoc do 231 | %{__meta__: %{__struct__: Ecto.Schema.Metadata, state: :built}} -> 232 | assoc = recursively_strip(original_assoc) 233 | Map.put(record, association_name, assoc) 234 | 235 | nil -> 236 | Map.put(record, association_name, nil) 237 | 238 | list when is_list(list) -> 239 | has_many_assoc = Enum.map(original_assoc, &recursively_strip/1) 240 | Map.put(record, association_name, has_many_assoc) 241 | 242 | %{__struct__: Ecto.Association.NotLoaded} -> 243 | Map.delete(record, association_name) 244 | end 245 | end 246 | 247 | defp handle_embeds(%{__struct__: struct} = record) do 248 | embeds = struct.__schema__(:embeds) 249 | 250 | Enum.reduce(embeds, record, fn embed_name, record -> 251 | record 252 | |> Map.get(embed_name) 253 | |> handle_embed(record, embed_name) 254 | end) 255 | end 256 | 257 | defp handle_embed(original_embed, record, embed_name) do 258 | case original_embed do 259 | %{} -> 260 | embed = recursively_strip(original_embed) 261 | Map.put(record, embed_name, embed) 262 | 263 | list when is_list(list) -> 264 | embeds_many = Enum.map(original_embed, &recursively_strip/1) 265 | Map.put(record, embed_name, embeds_many) 266 | 267 | nil -> 268 | Map.delete(record, embed_name) 269 | end 270 | end 271 | 272 | defp set_persisted_belongs_to_ids(%{__struct__: struct} = record) do 273 | associations = struct.__schema__(:associations) 274 | 275 | Enum.reduce(associations, record, fn association_name, record -> 276 | association = struct.__schema__(:association, association_name) 277 | 278 | with %{__struct__: Ecto.Association.BelongsTo} <- association, 279 | belongs_to <- Map.get(record, association_name), 280 | %{__meta__: %{__struct__: Ecto.Schema.Metadata, state: :loaded}} <- belongs_to do 281 | set_belongs_to_primary_key(record, belongs_to, association) 282 | else 283 | _ -> record 284 | end 285 | end) 286 | end 287 | 288 | defp set_belongs_to_primary_key(record, belongs_to, association) do 289 | primary_key = Map.get(belongs_to, association.related_key) 290 | Map.put(record, association.owner_key, primary_key) 291 | end 292 | 293 | defp insert_belongs_to_assocs(%{__struct__: struct} = record, module) do 294 | associations = struct.__schema__(:associations) 295 | 296 | Enum.reduce(associations, record, fn association_name, record -> 297 | case struct.__schema__(:association, association_name) do 298 | association = %{__struct__: Ecto.Association.BelongsTo} -> 299 | insert_built_belongs_to_assoc(module, association, record) 300 | 301 | _ -> 302 | record 303 | end 304 | end) 305 | end 306 | 307 | defp insert_built_belongs_to_assoc(module, association, record) do 308 | case Map.get(record, association.field) do 309 | built_relation = %{__meta__: %{state: :built}} -> 310 | relation = module.insert(built_relation) 311 | set_belongs_to_primary_key(record, relation, association) 312 | 313 | _ -> 314 | Map.delete(record, association.owner_key) 315 | end 316 | end 317 | 318 | @doc false 319 | def drop_ecto_fields(%{__struct__: struct} = record) do 320 | record 321 | |> Map.from_struct() 322 | |> Map.delete(:__meta__) 323 | |> drop_autogenerated_ids(struct) 324 | end 325 | 326 | def drop_ecto_fields(embedded_record), do: embedded_record 327 | 328 | defp drop_autogenerated_ids(map, struct) do 329 | case struct.__schema__(:autogenerate_id) do 330 | {name, _source, _type} -> Map.delete(map, name) 331 | {name, _type} -> Map.delete(map, name) 332 | nil -> map 333 | end 334 | end 335 | 336 | defp drop_fields_with_nil_values(map) do 337 | map 338 | |> Enum.reject(fn {_, value} -> value == nil end) 339 | |> Enum.into(%{}) 340 | end 341 | 342 | defp convert_atom_keys_to_strings(values) when is_list(values) do 343 | Enum.map(values, &convert_atom_keys_to_strings/1) 344 | end 345 | 346 | defp convert_atom_keys_to_strings(%NaiveDateTime{} = value) do 347 | if Application.get_env(:ex_machina, :preserve_dates, false) do 348 | value 349 | else 350 | value |> Map.from_struct() |> convert_atom_keys_to_strings() 351 | end 352 | end 353 | 354 | defp convert_atom_keys_to_strings(%DateTime{} = value) do 355 | if Application.get_env(:ex_machina, :preserve_dates, false) do 356 | value 357 | else 358 | value |> Map.from_struct() |> convert_atom_keys_to_strings() 359 | end 360 | end 361 | 362 | defp convert_atom_keys_to_strings(%{__struct__: _} = record) when is_map(record) do 363 | record |> Map.from_struct() |> convert_atom_keys_to_strings() 364 | end 365 | 366 | defp convert_atom_keys_to_strings(record) when is_map(record) do 367 | Enum.reduce(record, Map.new(), fn {key, value}, acc -> 368 | Map.put(acc, to_string(key), convert_atom_keys_to_strings(value)) 369 | end) 370 | end 371 | 372 | defp convert_atom_keys_to_strings(value), do: value 373 | 374 | defp verify_ecto_dep do 375 | unless Code.ensure_loaded?(Ecto) do 376 | raise "You tried to use ExMachina.Ecto, but the Ecto module is not loaded. " <> 377 | "Please add ecto to your dependencies." 378 | end 379 | end 380 | end 381 | -------------------------------------------------------------------------------- /lib/ex_machina/ecto_strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.EctoStrategy do 2 | @moduledoc false 3 | 4 | use ExMachina.Strategy, function_name: :insert 5 | 6 | def handle_insert(%{__meta__: %{state: :loaded}} = record, _) do 7 | raise "You called `insert` on a record that has already been inserted. 8 | Make sure that you have not accidentally called insert twice. 9 | 10 | The record you attempted to insert: 11 | 12 | #{inspect(record, limit: :infinity)}" 13 | end 14 | 15 | def handle_insert(_, %{repo: nil}) do 16 | raise """ 17 | insert/1 is not available unless you provide the :repo option. Example: 18 | 19 | use ExMachina.Ecto, repo: MyApp.Repo 20 | """ 21 | end 22 | 23 | def handle_insert(%{__meta__: %{__struct__: Ecto.Schema.Metadata}} = record, %{repo: repo}) do 24 | record 25 | |> cast() 26 | |> repo.insert!() 27 | end 28 | 29 | def handle_insert(record, %{repo: _repo}) do 30 | raise ArgumentError, "#{inspect(record)} is not an Ecto model. Use `build` instead" 31 | end 32 | 33 | def handle_insert(_record, _opts) do 34 | raise "expected :repo to be given to ExMachina.EctoStrategy" 35 | end 36 | 37 | def handle_insert( 38 | %{__meta__: %{__struct__: Ecto.Schema.Metadata}} = record, 39 | %{repo: repo}, 40 | insert_options 41 | ) do 42 | record 43 | |> cast() 44 | |> repo.insert!(insert_options) 45 | end 46 | 47 | defp cast(record) do 48 | record 49 | |> cast_all_fields 50 | |> cast_all_embeds 51 | |> cast_all_assocs 52 | end 53 | 54 | defp cast_all_fields(%{__struct__: schema} = struct) do 55 | schema 56 | |> schema_fields() 57 | |> Enum.reduce(struct, fn field_key, struct -> 58 | casted_value = cast_field(field_key, struct) 59 | 60 | Map.put(struct, field_key, casted_value) 61 | end) 62 | end 63 | 64 | defp cast_field(field_key, %{__struct__: schema} = struct) do 65 | field_type = schema.__schema__(:type, field_key) 66 | value = Map.get(struct, field_key) 67 | 68 | cast_value(field_type, value, struct) 69 | end 70 | 71 | defp cast_value(field_type, value, struct) do 72 | case Ecto.Type.cast(field_type, value) do 73 | {:ok, value} -> 74 | value 75 | 76 | _ -> 77 | raise "Failed to cast `#{inspect(value)}` of type #{inspect(field_type)} in #{inspect(struct)}." 78 | end 79 | end 80 | 81 | defp cast_all_embeds(%{__struct__: schema} = struct) do 82 | schema 83 | |> schema_embeds() 84 | |> Enum.reduce(struct, fn embed_key, struct -> 85 | casted_value = struct |> Map.get(embed_key) |> cast_embed(embed_key, struct) 86 | 87 | Map.put(struct, embed_key, casted_value) 88 | end) 89 | end 90 | 91 | defp cast_embed(embeds_many, embed_key, struct) when is_list(embeds_many) do 92 | Enum.map(embeds_many, &cast_embed(&1, embed_key, struct)) 93 | end 94 | 95 | defp cast_embed(embed, embed_key, %{__struct__: schema}) do 96 | if embed do 97 | embedding_reflection = schema.__schema__(:embed, embed_key) 98 | embed_type = embedding_reflection.related 99 | embed_type |> struct() |> Map.merge(embed) |> cast() 100 | end 101 | end 102 | 103 | defp cast_all_assocs(%{__struct__: schema} = struct) do 104 | assoc_keys = schema_associations(schema) 105 | 106 | Enum.reduce(assoc_keys, struct, fn assoc_key, struct -> 107 | casted_value = struct |> Map.get(assoc_key) |> cast_assoc(assoc_key, struct) 108 | 109 | Map.put(struct, assoc_key, casted_value) 110 | end) 111 | end 112 | 113 | defp cast_assoc(has_many_assoc, assoc_key, struct) when is_list(has_many_assoc) do 114 | Enum.map(has_many_assoc, &cast_assoc(&1, assoc_key, struct)) 115 | end 116 | 117 | defp cast_assoc(assoc, assoc_key, %{__struct__: schema}) do 118 | case assoc do 119 | %{__meta__: %{__struct__: Ecto.Schema.Metadata, state: :built}} -> 120 | cast(assoc) 121 | 122 | %{__struct__: Ecto.Association.NotLoaded} -> 123 | assoc 124 | 125 | %{__struct__: _} -> 126 | cast(assoc) 127 | 128 | %{} -> 129 | assoc_reflection = schema.__schema__(:association, assoc_key) 130 | assoc_type = assoc_reflection.related 131 | assoc_type |> struct() |> Map.merge(assoc) |> cast() 132 | 133 | nil -> 134 | nil 135 | end 136 | end 137 | 138 | defp schema_fields(schema) do 139 | schema_non_virtual_fields(schema) -- schema_embeds(schema) 140 | end 141 | 142 | defp schema_non_virtual_fields(schema) do 143 | schema.__schema__(:fields) 144 | end 145 | 146 | defp schema_embeds(schema) do 147 | schema.__schema__(:embeds) 148 | end 149 | 150 | defp schema_associations(schema) do 151 | schema.__schema__(:associations) 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/ex_machina/sequence.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.Sequence do 2 | @moduledoc """ 3 | Module for generating sequential values. 4 | 5 | Use `ExMachina.sequence/1` or `ExMachina.sequence/2` to generate 6 | sequential values instead of calling this module directly. 7 | """ 8 | 9 | use Agent 10 | 11 | @doc false 12 | def start_link(_) do 13 | Agent.start_link(fn -> Map.new() end, name: __MODULE__) 14 | end 15 | 16 | @doc """ 17 | Reset all sequences so that the next sequence starts from 0 18 | 19 | ## Example 20 | 21 | ExMachina.Sequence.next("joe") # "joe0" 22 | ExMachina.Sequence.next("joe") # "joe1" 23 | 24 | ExMachina.Sequence.reset() 25 | 26 | ExMachina.Sequence.next("joe") # resets so the return value is "joe0" 27 | 28 | You can use list as well 29 | 30 | ExMachina.Sequence.next("alphabet_sequence", ["A", "B"]) # "A" 31 | ExMachina.Sequence.next("alphabet_sequence", ["A", "B"]) # "B" 32 | 33 | ExMachina.Sequence.reset() 34 | 35 | ExMachina.Sequence.next("alphabet_sequence", ["A", "B"]) # resets so the return value is "A" 36 | 37 | If you want to reset sequences at the beginning of every test, put it in a 38 | `setup` block in your test. 39 | 40 | setup do 41 | ExMachina.Sequence.reset() 42 | end 43 | """ 44 | 45 | @spec reset() :: :ok 46 | def reset do 47 | Agent.update(__MODULE__, fn _ -> Map.new() end) 48 | end 49 | 50 | @doc """ 51 | Reset specific sequences so long as they already exist. The sequences 52 | specified will be reset to 0, while others will remain at their current index. 53 | 54 | You can reset a single sequence, 55 | 56 | ## Example 57 | 58 | ExMachina.Sequence.next(:alphabet, ["A", "B", "C"]) # "A" 59 | ExMachina.Sequence.next(:alphabet, ["A", "B", "C"]) # "B" 60 | 61 | ExMachina.Sequence.reset(:alphabet) 62 | 63 | ExMachina.Sequence.next(:alphabet, ["A", "B", "C"]) # "A" 64 | 65 | And you can also reset multiple sequences at once, 66 | 67 | ## Example 68 | 69 | ExMachina.Sequence.next(:numeric, [1, 2, 3]) # 1 70 | ExMachina.Sequence.next(:numeric, [1, 2, 3]) # 2 71 | ExMachina.Sequence.next("joe") # "joe0" 72 | ExMachina.Sequence.next("joe") # "joe1" 73 | 74 | ExMachina.Sequence.reset(["joe", :numeric]) 75 | 76 | ExMachina.Sequence.next(:numeric, [1, 2, 3]) # 1 77 | ExMachina.Sequence.next("joe") # "joe0" 78 | """ 79 | 80 | @spec reset(any()) :: :ok 81 | def reset(sequence_names) when is_list(sequence_names) do 82 | Agent.update(__MODULE__, fn sequences -> 83 | Enum.reduce(sequence_names, sequences, &Map.put(&2, &1, 0)) 84 | end) 85 | end 86 | 87 | def reset(sequence_name) do 88 | Agent.update(__MODULE__, fn sequences -> 89 | Map.put(sequences, sequence_name, 0) 90 | end) 91 | end 92 | 93 | @doc false 94 | def next(sequence_name) when is_binary(sequence_name) do 95 | next(sequence_name, &(sequence_name <> to_string(&1))) 96 | end 97 | 98 | @doc false 99 | def next(sequence_name) do 100 | raise( 101 | ArgumentError, 102 | "Sequence name must be a string, got #{inspect(sequence_name)} instead" 103 | ) 104 | end 105 | 106 | @doc false 107 | def next(sequence_name, [_ | _] = list) do 108 | length = length(list) 109 | 110 | Agent.get_and_update(__MODULE__, fn sequences -> 111 | current_value = Map.get(sequences, sequence_name, 0) 112 | index = rem(current_value, length) 113 | new_sequences = Map.put(sequences, sequence_name, index + 1) 114 | {value, _} = List.pop_at(list, index) 115 | {value, new_sequences} 116 | end) 117 | end 118 | 119 | @doc false 120 | def next(sequence_name, formatter, opts \\ []) do 121 | start_at = Keyword.get(opts, :start_at, 0) 122 | 123 | Agent.get_and_update(__MODULE__, fn sequences -> 124 | current_value = Map.get(sequences, sequence_name, start_at) 125 | new_sequences = Map.put(sequences, sequence_name, current_value + 1) 126 | {formatter.(current_value), new_sequences} 127 | end) 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/ex_machina/strategy.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Warning.UnsafeToAtom 2 | defmodule ExMachina.Strategy do 3 | @moduledoc ~S""" 4 | Module for making new strategies for working with factories 5 | 6 | ## Example 7 | 8 | defmodule MyApp.JsonEncodeStrategy do 9 | # The function_name will be used to generate functions in your factory 10 | # This example adds json_encode/1, json_encode/2, json_encode/3, 11 | # json_encode_pair/2 and json_encode_list/3 12 | use ExMachina.Strategy, function_name: :json_encode 13 | 14 | # Define a function for handling the records. 15 | # Takes the form of "handle_#{function_name}" 16 | def handle_json_encode(record, %{encoder: encoder}) do 17 | encoder.encode!(record) 18 | end 19 | 20 | # Optionally, define a function for handling records and taking in 21 | # options at the function level 22 | def handle_json_encode(record, %{encoder: encoder}, encoding_opts) do 23 | encoder.encode!(record, encoding_opts) 24 | end 25 | end 26 | 27 | defmodule MyApp.JsonFactory do 28 | use ExMachina 29 | use MyApp.JsonEncodeStrategy, encoder: Poison 30 | 31 | def user_factory do 32 | %User{name: "John"} 33 | end 34 | end 35 | 36 | # Will build and then return a JSON encoded version of the user. 37 | MyApp.JsonFactories.json_encode(:user) 38 | 39 | The arguments sent to the handling function are 40 | 41 | 1. The built record 42 | 2. The options passed to the strategy 43 | 3. The options passed to the function as a third argument 44 | 45 | The options sent as the second argument are always converted to a map. The 46 | options are anything you passed when you `use` your strategy in your factory, 47 | merged together with `%{factory_module: FactoryItWasCalledFrom}`. 48 | 49 | This allows for customizing the strategy, and for calling other functions on 50 | the factory if needed. 51 | 52 | See `ExMachina.EctoStrategy` in the ExMachina repo, and the docs for 53 | `name_from_struct/1` for more examples. 54 | 55 | The options sent as the third argument come directly from the options passed 56 | to the function being called. These could be function-level overrides of the 57 | options passed when you `use` the strategy, or they could be other 58 | customizations needed at the level of the function. 59 | 60 | See `c:ExMachina.Ecto.insert/3` for an example. 61 | """ 62 | 63 | @doc false 64 | defmacro __using__(function_name: function_name) do 65 | quote do 66 | @doc false 67 | def function_name, do: unquote(function_name) 68 | 69 | defmacro __using__(opts) do 70 | custom_strategy_module = __MODULE__ 71 | function_name = custom_strategy_module.function_name() 72 | handle_response_function_name = :"handle_#{function_name}" 73 | 74 | quote do 75 | def unquote(function_name)(already_built_record, function_opts) 76 | when is_map(already_built_record) do 77 | opts = 78 | unquote(opts) 79 | |> Map.new() 80 | |> Map.merge(%{factory_module: __MODULE__}) 81 | 82 | apply( 83 | unquote(custom_strategy_module), 84 | unquote(handle_response_function_name), 85 | [already_built_record, opts, function_opts] 86 | ) 87 | end 88 | 89 | def unquote(function_name)(already_built_record) when is_map(already_built_record) do 90 | opts = unquote(opts) |> Map.new() |> Map.merge(%{factory_module: __MODULE__}) 91 | 92 | apply( 93 | unquote(custom_strategy_module), 94 | unquote(handle_response_function_name), 95 | [already_built_record, opts] 96 | ) 97 | end 98 | 99 | def unquote(function_name)(factory_name, attrs, opts) do 100 | record = ExMachina.build(__MODULE__, factory_name, attrs) 101 | 102 | unquote(function_name)(record, opts) 103 | end 104 | 105 | def unquote(function_name)(factory_name, attrs) do 106 | record = ExMachina.build(__MODULE__, factory_name, attrs) 107 | 108 | unquote(function_name)(record) 109 | end 110 | 111 | def unquote(function_name)(factory_name) do 112 | record = ExMachina.build(__MODULE__, factory_name, %{}) 113 | 114 | unquote(function_name)(record) 115 | end 116 | 117 | def unquote(:"#{function_name}_pair")(factory_name, attrs, opts) do 118 | unquote(:"#{function_name}_list")(2, factory_name, attrs, opts) 119 | end 120 | 121 | def unquote(:"#{function_name}_pair")(factory_name, attrs \\ %{}) do 122 | unquote(:"#{function_name}_list")(2, factory_name, attrs) 123 | end 124 | 125 | def unquote(:"#{function_name}_list")(number_of_records, factory_name, attrs, opts) do 126 | stream = 127 | Stream.repeatedly(fn -> 128 | unquote(function_name)(factory_name, attrs, opts) 129 | end) 130 | 131 | Enum.take(stream, number_of_records) 132 | end 133 | 134 | def unquote(:"#{function_name}_list")(number_of_records, factory_name, attrs \\ %{}) do 135 | stream = 136 | Stream.repeatedly(fn -> 137 | unquote(function_name)(factory_name, attrs) 138 | end) 139 | 140 | Enum.take(stream, number_of_records) 141 | end 142 | end 143 | end 144 | end 145 | end 146 | 147 | defmacro __using__(opts) do 148 | raise """ 149 | expected function_name as an option, instead got #{inspect(opts)}. 150 | 151 | Example: use ExMachina.Strategy, function_name: :json_encode 152 | """ 153 | end 154 | 155 | @doc ~S""" 156 | Returns the factory name from a struct. Useful for strategies with callbacks. 157 | 158 | This function can be useful when you want to call other functions based on the 159 | type of struct passed in. For example, if you wanted to call a function on the 160 | factory module before JSON encoding. 161 | 162 | ## Examples 163 | 164 | ExMachina.Strategy.name_from_struct(%User{}) # Returns :user 165 | ExMachina.Strategy.name_from_struct(%MyUser{}) # Returns :my_user 166 | ExMachina.Strategy.name_from_struct(%MyApp.MyTask{}) # Returns :my_task 167 | 168 | ## Implementing callback functions with name_from_struct/1 169 | 170 | defmodule MyApp.JsonEncodeStrategy do 171 | use ExMachina.Strategy, function_name: :json_encode 172 | 173 | def handle_json_encode(record, %{factory_module: factory_module}) do 174 | # If the record was a %User{} this would return :before_encode_user 175 | callback_func_name = :"before_encode_#{ExMachina.Strategy.name_from_struct(record)}" 176 | 177 | if callback_defined?(factory_module, callback_func_name) do 178 | # First call the callback function 179 | apply(factory_module, callback_func_name, [record]) 180 | # Then encode it 181 | |> Poison.encode! 182 | else 183 | # Otherwise, encode it without calling any callback 184 | Poison.encode!(record) 185 | end 186 | end 187 | 188 | defp callback_defined?(module, func_name) do 189 | Code.ensure_loaded?(module) && function_exported?(module, func_name, 1) 190 | end 191 | end 192 | """ 193 | 194 | @spec name_from_struct(struct) :: atom 195 | def name_from_struct(%{__struct__: struct_name} = _struct) do 196 | struct_name 197 | |> Module.split() 198 | |> List.last() 199 | |> Macro.underscore() 200 | |> String.downcase() 201 | |> String.to_atom() 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/ex_machina/undefined_factory_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.UndefinedFactoryError do 2 | @moduledoc """ 3 | Error raised when trying to build or create a factory that is undefined. 4 | """ 5 | 6 | defexception [:message] 7 | 8 | def exception(factory_name) do 9 | message = """ 10 | No factory defined for #{inspect(factory_name)}. 11 | 12 | Please check for typos or define your factory: 13 | 14 | def #{factory_name}_factory do 15 | ... 16 | end 17 | """ 18 | 19 | %__MODULE__{message: message} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ex_machina, 7 | description: "A factory library by the creators of FactoryBot (née FactoryGirl)", 8 | version: "2.8.0", 9 | elixir: "~> 1.11", 10 | elixirc_paths: elixirc_paths(Mix.env()), 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | docs: docs(), 14 | package: package(), 15 | source_url: "https://github.com/beam-community/ex_machina", 16 | test_coverage: [tool: ExCoveralls], 17 | preferred_cli_env: [ 18 | coveralls: :test, 19 | "coveralls.detail": :test, 20 | "coveralls.html": :test, 21 | "coveralls.circle": :test 22 | ] 23 | ] 24 | end 25 | 26 | def application do 27 | [ 28 | extra_applications: [:logger], 29 | mod: {ExMachina, []} 30 | ] 31 | end 32 | 33 | defp deps do 34 | [ 35 | {:ecto, "~> 2.2 or ~> 3.0", optional: true}, 36 | {:ecto_sql, "~> 3.0", optional: true}, 37 | 38 | # Dev and Test dependencies 39 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 40 | {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, 41 | {:doctor, "~> 0.22.0", only: [:dev, :test], runtime: false}, 42 | {:excoveralls, "~> 0.18.1", only: :test}, 43 | {:ex_doc, "~> 0.32", only: [:dev, :test], runtime: false}, 44 | {:postgrex, "~> 0.17", only: :test} 45 | ] 46 | end 47 | 48 | defp docs do 49 | [ 50 | extras: ["README.md", "CHANGELOG.md", "LICENSE.md"], 51 | main: "readme" 52 | ] 53 | end 54 | 55 | defp package do 56 | [ 57 | maintainers: ["BEAM Community"], 58 | files: ~w(lib mix.exs .formatter.exs README.md CHANGELOG.md LICENSE.md), 59 | licenses: ["MIT"], 60 | links: %{ 61 | Changelog: "https://github.com/beam-community/ex_machina/releases", 62 | GitHub: "https://github.com/beam-community/ex_machina" 63 | } 64 | ] 65 | end 66 | 67 | defp elixirc_paths(:test), do: ["lib", "test/support"] 68 | defp elixirc_paths(_), do: ["lib"] 69 | end 70 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 4 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 5 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 7 | "doctor": {:hex, :doctor, "0.22.0", "223e1cace1f16a38eda4113a5c435fa9b10d804aa72d3d9f9a71c471cc958fe7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "96e22cf8c0df2e9777dc55ebaa5798329b9028889c4023fed3305688d902cd5b"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 9 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 10 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 11 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 12 | "ex_doc": {:hex, :ex_doc, "0.38.1", "bae0a0bd5b5925b1caef4987e3470902d072d03347114ffe03a55dbe206dd4c2", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "754636236d191b895e1e4de2ebb504c057fe1995fdfdd92e9d75c4b05633008b"}, 13 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 14 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 15 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 16 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 17 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 18 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 20 | "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, 21 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 22 | } 23 | -------------------------------------------------------------------------------- /priv/test_repo/migrations/1_migrate_all.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.TestRepo.Migrations.MigrateAll do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add(:name, :string) 7 | add(:admin, :boolean) 8 | add(:net_worth, :decimal) 9 | add(:db_value, :string) 10 | end 11 | 12 | execute(~S""" 13 | CREATE FUNCTION set_db_value() 14 | RETURNS TRIGGER 15 | LANGUAGE plpgsql 16 | AS $$ 17 | BEGIN 18 | NEW.db_value := 'made in db'; 19 | RETURN NEW; 20 | END; 21 | $$; 22 | """) 23 | 24 | execute(~S""" 25 | CREATE TRIGGER gen_db_value 26 | BEFORE INSERT ON users 27 | FOR EACH ROW 28 | EXECUTE FUNCTION set_db_value(); 29 | """) 30 | 31 | create table(:publishers) do 32 | add(:pub_number, :string) 33 | end 34 | 35 | create(unique_index(:publishers, [:pub_number])) 36 | 37 | create table(:articles) do 38 | add(:title, :string) 39 | add(:author_id, :integer) 40 | add(:editor_id, :integer) 41 | add(:publisher_id, :integer) 42 | add(:visits, :decimal) 43 | add(:published_at, :utc_datetime) 44 | end 45 | 46 | create table(:comments) do 47 | add(:article_id, :integer) 48 | add(:author, :map) 49 | add(:links, {:array, :map}, default: []) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/ex_machina/ecto_strategy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.EctoStrategyTest do 2 | use ExMachina.EctoCase 3 | 4 | alias ExMachina.TestFactory 5 | alias ExMachina.User 6 | 7 | defmodule FactoryWithNoRepo do 8 | use ExMachina.EctoStrategy 9 | 10 | def whatever_factory do 11 | %{} 12 | end 13 | end 14 | 15 | test "raises helpful error message if no repo is provided" do 16 | assert_raise RuntimeError, ~r/expected :repo to be given/, fn -> 17 | FactoryWithNoRepo.insert(:whatever) 18 | end 19 | end 20 | 21 | test "insert/1 inserts the record into the repo" do 22 | model = TestFactory.insert(%User{name: "John"}) 23 | 24 | new_user = ExMachina.TestRepo.one!(User) 25 | 26 | assert model.id 27 | assert model.name == new_user.name 28 | end 29 | 30 | test "insert/1 raises if a map is passed" do 31 | message = "%{foo: \"bar\"} is not an Ecto model. Use `build` instead" 32 | 33 | assert_raise ArgumentError, message, fn -> 34 | TestFactory.insert(%{foo: "bar"}) 35 | end 36 | end 37 | 38 | test "insert/1 raises if a non-Ecto struct is passed" do 39 | message = "%{__struct__: Foo.Bar} is not an Ecto model. Use `build` instead" 40 | 41 | assert_raise ArgumentError, message, fn -> 42 | TestFactory.insert(%{__struct__: Foo.Bar}) 43 | end 44 | end 45 | 46 | test "insert/1 casts all values" do 47 | model = TestFactory.insert(:user, net_worth: 300) 48 | 49 | assert model.net_worth == Decimal.new(300) 50 | end 51 | 52 | test "insert/1 casts belongs_to associations" do 53 | built_author = TestFactory.build(:user, net_worth: 300) 54 | model = TestFactory.insert(:article, author: built_author) 55 | 56 | assert model.author.net_worth == Decimal.new(300) 57 | end 58 | 59 | test "insert/1 casts has_many associations" do 60 | built_article = TestFactory.build(:article, visits: 10, author: nil) 61 | model = TestFactory.insert(:user, articles: [built_article]) 62 | 63 | assert List.first(model.articles).visits == Decimal.new(10) 64 | end 65 | 66 | test "insert/1 casts embedded associations" do 67 | author = %ExMachina.Author{name: "Paul", salary: 10.3} 68 | link = %ExMachina.Link{url: "wow", rating: 4.5} 69 | 70 | comment = 71 | TestFactory.insert(:comment_with_embedded_assocs, 72 | author: author, 73 | links: [link] 74 | ) 75 | 76 | assert comment.author.name == author.name 77 | assert comment.author.salary == Decimal.from_float(author.salary) 78 | assert List.first(comment.links).url == link.url 79 | assert List.first(comment.links).rating == Decimal.from_float(link.rating) 80 | end 81 | 82 | test "insert/1 ignores virtual fields" do 83 | user = TestFactory.insert(:user, password: "foo") 84 | 85 | assert user.id != nil 86 | end 87 | 88 | test "insert/1 sets nil values" do 89 | model = TestFactory.insert(:article, author: nil) 90 | 91 | assert model.author == nil 92 | end 93 | 94 | test "insert/1 casts bare maps" do 95 | model = TestFactory.insert(:article, author: %{net_worth: 300}) 96 | 97 | assert model.author.net_worth == Decimal.new(300) 98 | end 99 | 100 | test "insert/1 casts lists of bare maps" do 101 | model = TestFactory.insert(:article, comments: [%{author: %{name: "John Doe", salary: 300}}]) 102 | 103 | assert hd(model.comments).author.salary == Decimal.new(300) 104 | end 105 | 106 | test "insert/1 casts bare maps for embeds" do 107 | model = TestFactory.insert(:comment_with_embedded_assocs, author: %{salary: 300}) 108 | 109 | assert model.author.salary == Decimal.new(300) 110 | end 111 | 112 | test "insert/1 casts lists of bare maps for embeds" do 113 | model = 114 | TestFactory.insert(:comment_with_embedded_assocs, 115 | links: [%{url: "http://thoughtbot.com", rating: 5}] 116 | ) 117 | 118 | assert hd(model.links).rating == Decimal.new(5) 119 | end 120 | 121 | test "insert/1 casts associations recursively" do 122 | editor = TestFactory.build(:user, net_worth: 300) 123 | article = TestFactory.build(:article, editor: editor, author: nil) 124 | author = TestFactory.insert(:user, articles: [article]) 125 | 126 | assert List.first(author.articles).editor.net_worth == Decimal.new(300) 127 | end 128 | 129 | test "insert/1 assigns params that aren't in the schema" do 130 | publisher_assoc = ExMachina.Article.__schema__(:association, :publisher) 131 | publisher_struct = publisher_assoc.related 132 | publisher_fields = publisher_struct.__schema__(:fields) 133 | 134 | refute Enum.member?(publisher_fields, :name) 135 | 136 | publisher = :publisher |> TestFactory.build() |> Map.merge(%{name: "name"}) 137 | model = TestFactory.insert(:article, publisher: publisher) 138 | 139 | assert model.publisher.name == "name" 140 | end 141 | 142 | test "passed in attrs can override associations" do 143 | my_user = TestFactory.insert(:user, name: "Jane") 144 | 145 | article = TestFactory.insert(:article, author: my_user) 146 | 147 | assert article.author == my_user 148 | end 149 | 150 | test "insert/3 allows options to be passed to the repo" do 151 | with_args = TestFactory.insert(:user, [name: "Jane"], returning: true) 152 | assert with_args.id 153 | assert with_args.name == "Jane" 154 | assert with_args.db_value 155 | 156 | without_args = TestFactory.insert(:user, [], returning: true) 157 | assert without_args.id 158 | assert without_args.db_value 159 | 160 | with_struct = :user |> TestFactory.build() |> TestFactory.insert(returning: true) 161 | assert with_struct.id 162 | assert with_struct.db_value 163 | 164 | without_opts = :user |> TestFactory.build() |> TestFactory.insert() 165 | assert without_opts.id 166 | refute without_opts.db_value 167 | end 168 | 169 | test "insert_pair/3 allows options to be passed to the repo" do 170 | [with_args | _] = TestFactory.insert_pair(:user, [name: "Jane"], returning: true) 171 | assert with_args.id 172 | assert with_args.name == "Jane" 173 | assert with_args.db_value 174 | 175 | [without_args | _] = TestFactory.insert_pair(:user, [], returning: true) 176 | assert without_args.id 177 | assert without_args.db_value 178 | end 179 | 180 | test "insert_list/4 allows options to be passed to the repo" do 181 | [with_args | _] = TestFactory.insert_list(2, :user, [name: "Jane"], returning: true) 182 | assert with_args.id 183 | assert with_args.name == "Jane" 184 | assert with_args.db_value 185 | 186 | [without_args | _] = TestFactory.insert_list(2, :user, [], returning: true) 187 | assert without_args.id 188 | assert without_args.db_value 189 | end 190 | 191 | test "insert/1 raises a friendly error when casting invalid types" do 192 | message = ~r/Failed to cast `:invalid` of type ExMachina.InvalidType/ 193 | 194 | assert_raise RuntimeError, message, fn -> 195 | TestFactory.insert(:invalid_cast, invalid: :invalid) 196 | end 197 | end 198 | 199 | test "insert/1 raises if attempting to insert already inserted record" do 200 | message = ~r/You called `insert` on a record that has already been inserted./ 201 | 202 | assert_raise RuntimeError, message, fn -> 203 | :user |> TestFactory.insert(name: "Maximus") |> TestFactory.insert() 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /test/ex_machina/ecto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.EctoTest do 2 | use ExMachina.EctoCase 3 | 4 | alias ExMachina.Article 5 | alias ExMachina.Publisher 6 | alias ExMachina.TestFactory 7 | alias ExMachina.User 8 | 9 | describe "when the :repo option is not provided" do 10 | defmodule NoRepoTestFactory do 11 | use ExMachina.Ecto 12 | 13 | def user_factory do 14 | %ExMachina.User{ 15 | name: "John Doe", 16 | admin: false 17 | } 18 | end 19 | end 20 | 21 | test "insert, insert_pair and insert_list raise helpful error messages if no repo was provided" do 22 | message = """ 23 | insert/1 is not available unless you provide the :repo option. Example: 24 | 25 | use ExMachina.Ecto, repo: MyApp.Repo 26 | """ 27 | 28 | assert_raise RuntimeError, message, fn -> 29 | NoRepoTestFactory.insert(:user) 30 | end 31 | 32 | assert_raise RuntimeError, message, fn -> 33 | NoRepoTestFactory.insert_pair(:user) 34 | end 35 | 36 | assert_raise RuntimeError, message, fn -> 37 | NoRepoTestFactory.insert_list(3, :user) 38 | end 39 | end 40 | 41 | test "params_for/1 works without a repo" do 42 | user_params = NoRepoTestFactory.params_for(:user) 43 | 44 | assert user_params == %{name: "John Doe", admin: false} 45 | end 46 | 47 | test "string_params_for/1 works without a repo" do 48 | user_params = NoRepoTestFactory.string_params_for(:user) 49 | 50 | assert user_params == %{"name" => "John Doe", "admin" => false} 51 | end 52 | end 53 | 54 | describe "insert/2 insert_pair/2 insert_list/3" do 55 | test "insert, insert_pair and insert_list inserts records" do 56 | assert %User{} = :user |> TestFactory.build() |> TestFactory.insert() 57 | assert %User{} = TestFactory.insert(:user) 58 | assert %User{} = TestFactory.insert(:user, admin: true) 59 | 60 | assert [%User{}, %User{}] = TestFactory.insert_pair(:user) 61 | assert [%User{}, %User{}] = TestFactory.insert_pair(:user, admin: true) 62 | 63 | assert [%User{}, %User{}, %User{}] = TestFactory.insert_list(3, :user) 64 | assert [%User{}, %User{}, %User{}] = TestFactory.insert_list(3, :user, admin: true) 65 | end 66 | 67 | test "insert_list/3 handles the number 0" do 68 | assert [] = TestFactory.insert_list(0, :user) 69 | end 70 | 71 | test "lazy records get evaluated with insert/2 and insert_* functions" do 72 | assert %Article{publisher: %Publisher{}} = 73 | TestFactory.insert(:article, publisher: fn -> TestFactory.build(:publisher) end) 74 | 75 | [%Article{publisher: publisher1}, %Article{publisher: publisher2}] = 76 | TestFactory.insert_pair(:article, publisher: fn -> TestFactory.build(:publisher) end) 77 | 78 | assert publisher1 != publisher2 79 | 80 | [publisher1, publisher2, publisher3] = 81 | TestFactory.insert_list(3, :article, publisher: fn -> TestFactory.build(:publisher) end) 82 | 83 | assert publisher1.author != publisher2.author 84 | assert publisher2.author != publisher3.author 85 | assert publisher3.author != publisher1.author 86 | end 87 | end 88 | 89 | describe "params_for/2" do 90 | test "params_for/2 removes Ecto specific fields" do 91 | assert TestFactory.params_for(:user) == %{ 92 | name: "John Doe", 93 | admin: false, 94 | articles: [] 95 | } 96 | end 97 | 98 | test "params_for/2 leaves ids that are not auto-generated" do 99 | assert TestFactory.params_for(:custom) == %{ 100 | non_autogenerated_id: 1, 101 | name: "Testing" 102 | } 103 | end 104 | 105 | test "params_for/2 removes fields with nil values" do 106 | assert TestFactory.params_for(:user, admin: nil) == %{ 107 | name: "John Doe", 108 | articles: [] 109 | } 110 | end 111 | 112 | test "params_for/2 keeps foreign keys for persisted belongs_to associations" do 113 | editor = TestFactory.insert(:user) 114 | 115 | article_params = 116 | TestFactory.params_for( 117 | :article, 118 | title: "foo", 119 | editor: editor 120 | ) 121 | 122 | assert article_params == %{ 123 | title: "foo", 124 | editor_id: editor.id 125 | } 126 | end 127 | 128 | test "params_for/2 deletes unpersisted belongs_to associations" do 129 | article_params = 130 | TestFactory.params_for( 131 | :article, 132 | title: "foo", 133 | editor: TestFactory.build(:user) 134 | ) 135 | 136 | assert article_params == %{ 137 | title: "foo" 138 | } 139 | end 140 | 141 | test "params_for/2 recursively deletes unpersisted belongs_to associations" do 142 | article = TestFactory.build(:article, editor: TestFactory.build(:user)) 143 | 144 | user_params = TestFactory.params_for(:user, articles: [article]) 145 | 146 | assert user_params[:articles] == [ 147 | %{ 148 | title: article.title 149 | } 150 | ] 151 | end 152 | 153 | test "params_for/2 converts has_one associations to params" do 154 | article = TestFactory.build(:article) 155 | 156 | user_params = TestFactory.params_for(:user, best_article: article) 157 | 158 | assert user_params[:best_article] == %{title: article.title} 159 | end 160 | 161 | test "params_for/2 works with has_many associations containing maps" do 162 | article = %{title: "Foobar"} 163 | 164 | user_params = TestFactory.params_for(:user, articles: [article]) 165 | 166 | assert user_params.articles == [%{title: article.title}] 167 | end 168 | 169 | test "params_for/2 converts embeds_one into a map" do 170 | author = %ExMachina.Author{name: "Author", salary: 1.0} 171 | comment_params = TestFactory.params_for(:comment_with_embedded_assocs, author: author) 172 | assert comment_params.author == %{name: "Author", salary: 1.0} 173 | end 174 | 175 | test "params_for/2 accepts maps for embeds" do 176 | author = %{name: "Author", salary: 1.0} 177 | comment_params = TestFactory.params_for(:comment_with_embedded_assocs, author: author) 178 | assert comment_params.author == %{name: "Author", salary: 1.0} 179 | end 180 | 181 | test "params_for/2 converts embeds_many into a list of maps" do 182 | links = [ 183 | %ExMachina.Link{url: "https://thoughtbot.com", rating: 5}, 184 | %ExMachina.Link{url: "https://github.com", rating: 4} 185 | ] 186 | 187 | comment_params = TestFactory.params_for(:comment_with_embedded_assocs, links: links) 188 | 189 | assert comment_params.links == [ 190 | %{url: "https://thoughtbot.com", rating: 5}, 191 | %{url: "https://github.com", rating: 4} 192 | ] 193 | end 194 | 195 | test "params_for/2 handles nested embeds" do 196 | links = [ 197 | %ExMachina.Link{ 198 | url: "https://thoughtbot.com", 199 | rating: 5, 200 | metadata: %ExMachina.Metadata{text: "foo"} 201 | } 202 | ] 203 | 204 | comment_params = TestFactory.params_for(:comment_with_embedded_assocs, links: links) 205 | assert List.first(comment_params.links).metadata == %{text: "foo"} 206 | end 207 | end 208 | 209 | describe "string_params_for/2" do 210 | test "string_params_for/2 produces maps similar to ones built with params_for/2, but the keys are strings" do 211 | assert TestFactory.string_params_for(:user) == %{ 212 | "name" => "John Doe", 213 | "admin" => false, 214 | "articles" => [] 215 | } 216 | end 217 | 218 | test "string_params_for/2 converts structs into maps with strings as keys" do 219 | net_worth = %Money{amount: 2_000} 220 | 221 | user_params = TestFactory.string_params_for(:user, net_worth: net_worth) 222 | 223 | assert user_params["net_worth"] == %{"amount" => 2_000} 224 | end 225 | 226 | test "string_params_for/2 converts has_one association into map with strings as keys" do 227 | article = TestFactory.build(:article) 228 | 229 | user_params = TestFactory.string_params_for(:user, best_article: article) 230 | 231 | assert user_params["best_article"] == %{"title" => article.title} 232 | end 233 | 234 | test "string_params_for/2 converts has_many associations into a list of maps with strings as keys" do 235 | article = TestFactory.build(:article, title: "Foo") 236 | 237 | user_params = TestFactory.string_params_for(:user, articles: [article]) 238 | 239 | assert user_params["articles"] == [%{"title" => article.title}] 240 | end 241 | 242 | test "string_params_for/2 converts embeds_one into a map with strings as keys" do 243 | author = %ExMachina.Author{name: "Author", salary: 1.0} 244 | 245 | comment_params = 246 | TestFactory.string_params_for(:comment_with_embedded_assocs, author: author) 247 | 248 | assert comment_params["author"] == %{"name" => "Author", "salary" => 1.0} 249 | end 250 | 251 | test "string_params_for/2 converts embeds_many into a list of maps with strings as keys" do 252 | links = [ 253 | %ExMachina.Link{url: "https://thoughtbot.com", rating: 5}, 254 | %ExMachina.Link{url: "https://github.com", rating: 4} 255 | ] 256 | 257 | comment_params = TestFactory.string_params_for(:comment_with_embedded_assocs, links: links) 258 | 259 | assert comment_params["links"] == [ 260 | %{"url" => "https://thoughtbot.com", "rating" => 5}, 261 | %{"url" => "https://github.com", "rating" => 4} 262 | ] 263 | end 264 | 265 | test "string_params_for/2 converts map with datetime as expected" do 266 | published_at = DateTime.utc_now() 267 | article_params = TestFactory.string_params_for(:article, published_at: published_at) 268 | assert article_params["published_at"] == published_at 269 | end 270 | 271 | test "string_params_for/2 converts map with naive datetime as expected" do 272 | published_at = ~N[2000-01-01 23:00:07] 273 | article_params = TestFactory.string_params_for(:article, published_at: published_at) 274 | assert article_params["published_at"] == published_at 275 | end 276 | end 277 | 278 | describe "params_with_assocs/2" do 279 | test "params_with_assocs/2 inserts belongs_tos that are set by the factory" do 280 | assert has_association_in_schema?(ExMachina.Article, :editor) 281 | 282 | assert TestFactory.params_with_assocs(:article) == %{ 283 | title: "My Awesome Article", 284 | author_id: ExMachina.TestRepo.one!(User).id 285 | } 286 | end 287 | 288 | test "params_with_assocs/2 doesn't insert unloaded assocs" do 289 | not_loaded = %{__struct__: Ecto.Association.NotLoaded} 290 | 291 | assert TestFactory.params_with_assocs(:article, editor: not_loaded) == %{ 292 | title: "My Awesome Article", 293 | author_id: ExMachina.TestRepo.one!(User).id 294 | } 295 | end 296 | 297 | test "params_with_assocs/2 keeps has_many associations" do 298 | article = TestFactory.build(:article) 299 | 300 | user_params = TestFactory.params_with_assocs(:user, articles: [article]) 301 | 302 | assert user_params.articles == [%{title: article.title}] 303 | end 304 | 305 | test "params_with_assocs/2 removes fields with nil values" do 306 | assert has_association_in_schema?(ExMachina.User, :articles) 307 | 308 | assert TestFactory.params_with_assocs(:user, admin: nil) == %{ 309 | name: "John Doe", 310 | articles: [] 311 | } 312 | end 313 | end 314 | 315 | describe "string_params_with_assocs/2" do 316 | test "string_params_with_assocs/2 behaves like params_with_assocs/2 but the keys of the map are strings" do 317 | assert has_association_in_schema?(ExMachina.Article, :editor) 318 | 319 | assert TestFactory.string_params_with_assocs(:article) == %{ 320 | "title" => "My Awesome Article", 321 | "author_id" => ExMachina.TestRepo.one!(User).id 322 | } 323 | end 324 | end 325 | 326 | defp has_association_in_schema?(model, association_name) do 327 | :associations |> model.__schema__() |> Enum.member?(association_name) 328 | end 329 | end 330 | -------------------------------------------------------------------------------- /test/ex_machina/sequence_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.SequenceTest do 2 | use ExUnit.Case 3 | 4 | alias ExMachina.Sequence 5 | 6 | setup do 7 | Sequence.reset() 8 | end 9 | 10 | test "increments the sequence each time it is called" do 11 | assert "joe0" == Sequence.next(:name, &"joe#{&1}") 12 | assert "joe1" == Sequence.next(:name, &"joe#{&1}") 13 | end 14 | 15 | test "traverses a list each time it is called" do 16 | assert "A" == Sequence.next(:name, ["A", "B", "C"]) 17 | assert "B" == Sequence.next(:name, ["A", "B", "C"]) 18 | assert "C" == Sequence.next(:name, ["A", "B", "C"]) 19 | assert "A" == Sequence.next(:name, ["A", "B", "C"]) 20 | end 21 | 22 | test "updates different sequences independently" do 23 | assert "joe0" == Sequence.next(:name, &"joe#{&1}") 24 | assert "joe1" == Sequence.next(:name, &"joe#{&1}") 25 | assert 0 == Sequence.next(:month, & &1) 26 | assert 1 == Sequence.next(:month, & &1) 27 | end 28 | 29 | test "can optionally set starting integer" do 30 | assert "100" == Sequence.next(:dollars_in_cents, &"#{&1}", start_at: 100) 31 | assert "101" == Sequence.next(:dollars_in_cents, &"#{&1}") 32 | end 33 | 34 | test "lets you quickly create sequences" do 35 | assert "Comment Body0" == Sequence.next("Comment Body") 36 | assert "Comment Body1" == Sequence.next("Comment Body") 37 | end 38 | 39 | test "only accepts strings for sequence shortcut" do 40 | assert_raise ArgumentError, ~r/must be a string/, fn -> 41 | Sequence.next(:not_a_string) 42 | end 43 | end 44 | 45 | test "can reset sequences" do 46 | Sequence.next("joe") 47 | 48 | Sequence.reset() 49 | 50 | assert "joe0" == Sequence.next("joe") 51 | end 52 | 53 | test "can reset specific sequences" do 54 | Sequence.next(:alphabet, ["A", "B", "C"]) 55 | Sequence.next(:alphabet, ["A", "B", "C"]) 56 | Sequence.next(:numeric, [1, 2, 3]) 57 | Sequence.next(:numeric, [1, 2, 3]) 58 | Sequence.next("joe") 59 | Sequence.next("joe") 60 | 61 | Sequence.reset(["joe", :numeric]) 62 | 63 | assert 1 == Sequence.next(:numeric, [1, 2, 3]) 64 | assert "joe0" == Sequence.next("joe") 65 | assert "C" == Sequence.next(:alphabet, ["A", "B", "C"]) 66 | 67 | Sequence.reset(:alphabet) 68 | 69 | assert "A" == Sequence.next(:alphabet, ["A", "B", "C"]) 70 | assert 2 == Sequence.next(:numeric, [1, 2, 3]) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/ex_machina/strategy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.StrategyTest do 2 | use ExUnit.Case 3 | 4 | defmodule FakeJsonStrategy do 5 | use ExMachina.Strategy, function_name: :json_encode 6 | 7 | def handle_json_encode(record, opts) do 8 | send(self(), {:handle_json_encode, record, opts}) 9 | end 10 | 11 | def handle_json_encode(record, opts, function_opts) do 12 | send(self(), {:handle_json_encode, record, opts, function_opts}) 13 | end 14 | end 15 | 16 | defmodule JsonFactory do 17 | use ExMachina 18 | use FakeJsonStrategy, foo: :bar 19 | 20 | def user_factory do 21 | %{ 22 | name: "John" 23 | } 24 | end 25 | end 26 | 27 | test "name_from_struct returns the factory name based on passed in struct" do 28 | assert ExMachina.Strategy.name_from_struct(%{__struct__: User}) == :user 29 | assert ExMachina.Strategy.name_from_struct(%{__struct__: TwoWord}) == :two_word 30 | assert ExMachina.Strategy.name_from_struct(%{__struct__: NameSpace.TwoWord}) == :two_word 31 | end 32 | 33 | test "defines functions based on the strategy name" do 34 | strategy_options = %{foo: :bar, factory_module: JsonFactory} 35 | function_options = [encode: true] 36 | 37 | :user |> JsonFactory.build() |> JsonFactory.json_encode() 38 | built_user = JsonFactory.build(:user) 39 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 40 | refute_received {:handle_json_encode, _, _} 41 | 42 | JsonFactory.json_encode(:user) 43 | built_user = JsonFactory.build(:user) 44 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 45 | refute_received {:handle_json_encode, _, _} 46 | 47 | JsonFactory.json_encode(:user, name: "Jane") 48 | built_user = JsonFactory.build(:user, name: "Jane") 49 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 50 | refute_received {:handle_json_encode, _, _} 51 | 52 | JsonFactory.json_encode(:user, [name: "Jane"], function_options) 53 | built_user = JsonFactory.build(:user, name: "Jane") 54 | assert_received {:handle_json_encode, ^built_user, ^strategy_options, ^function_options} 55 | refute_received {:handle_json_encode, _, _} 56 | 57 | JsonFactory.json_encode_pair(:user) 58 | built_user = JsonFactory.build(:user) 59 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 60 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 61 | refute_received {:handle_json_encode, _, _} 62 | 63 | JsonFactory.json_encode_pair(:user, name: "Jane") 64 | built_user = JsonFactory.build(:user, name: "Jane") 65 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 66 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 67 | refute_received {:handle_json_encode, _, _} 68 | 69 | JsonFactory.json_encode_list(3, :user) 70 | built_user = JsonFactory.build(:user) 71 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 72 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 73 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 74 | refute_received {:handle_json_encode, _, _} 75 | 76 | JsonFactory.json_encode_list(3, :user, name: "Jane") 77 | built_user = JsonFactory.build(:user, name: "Jane") 78 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 79 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 80 | assert_received {:handle_json_encode, ^built_user, ^strategy_options} 81 | refute_received {:handle_json_encode, _, _} 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/ex_machina_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExMachinaTest do 2 | use ExUnit.Case 3 | 4 | defmodule FooBar do 5 | defstruct [:name] 6 | end 7 | 8 | defmodule Factory do 9 | use ExMachina 10 | 11 | def user_factory do 12 | %{ 13 | id: 3, 14 | name: "John Doe", 15 | admin: false 16 | } 17 | end 18 | 19 | def profile_factory do 20 | %{ 21 | username: sequence("username"), 22 | user: build(:user) 23 | } 24 | end 25 | 26 | def account_factory do 27 | %{ 28 | private: true, 29 | profile: fn -> build(:profile) end 30 | } 31 | end 32 | 33 | def admin_account_factory do 34 | %{ 35 | admin: true, 36 | profile: fn account -> build(:profile, admin: account.admin) end 37 | } 38 | end 39 | 40 | def email_factory do 41 | %{ 42 | email: sequence(:email, &"me-#{&1}@foo.com") 43 | } 44 | end 45 | 46 | def article_factory do 47 | %{ 48 | title: sequence("Post Title") 49 | } 50 | end 51 | 52 | def foo_bar_factory do 53 | %FooBar{} 54 | end 55 | 56 | def comment_factory(attrs) do 57 | %{name: name} = attrs 58 | 59 | username = sequence(:username, &"#{name}-#{&1}") 60 | 61 | comment = %{ 62 | author: "#{name} Doe", 63 | username: username 64 | } 65 | 66 | merge_attributes(comment, attrs) 67 | end 68 | 69 | def room_number_factory(attrs) do 70 | %{floor: floor_number} = attrs 71 | sequence(:room_number, &"#{floor_number}0#{&1}") 72 | end 73 | 74 | def money_factory do 75 | %{ 76 | cents: sequence(:cents, &"#{&1}", start_at: 600) 77 | } 78 | end 79 | end 80 | 81 | describe "sequence" do 82 | test "sequence/2 sequences a value" do 83 | assert "me-0@foo.com" == Factory.build(:email).email 84 | assert "me-1@foo.com" == Factory.build(:email).email 85 | end 86 | 87 | test "sequence/1 shortcut for creating sequences" do 88 | assert "Post Title0" == Factory.build(:article).title 89 | assert "Post Title1" == Factory.build(:article).title 90 | end 91 | 92 | test "sequence/3 allows for setting a starting value" do 93 | assert "600" == Factory.build(:money).cents 94 | assert "601" == Factory.build(:money).cents 95 | end 96 | end 97 | 98 | describe "build/2" do 99 | test "raises a helpful error if the factory is not defined" do 100 | assert_raise ExMachina.UndefinedFactoryError, fn -> 101 | Factory.build(:foo) 102 | end 103 | end 104 | 105 | test "build/2 returns the matching factory" do 106 | assert Factory.build(:user) == %{ 107 | id: 3, 108 | name: "John Doe", 109 | admin: false 110 | } 111 | end 112 | 113 | test "build/2 merges passed in options as keyword list" do 114 | assert Factory.build(:user, admin: true) == %{ 115 | id: 3, 116 | name: "John Doe", 117 | admin: true 118 | } 119 | end 120 | 121 | test "build/2 merges passed in options as a map" do 122 | assert Factory.build(:user, %{admin: true}) == %{ 123 | id: 3, 124 | name: "John Doe", 125 | admin: true 126 | } 127 | end 128 | 129 | test "build/2 raises if passing invalid keys to a struct factory" do 130 | assert_raise KeyError, fn -> 131 | Factory.build(:foo_bar, doesnt_exist: true) 132 | end 133 | end 134 | 135 | test "build/2 allows factories to have full control of provided arguments" do 136 | comment = Factory.build(:comment, name: "James") 137 | 138 | assert %{author: "James Doe", name: "James"} = comment 139 | assert String.starts_with?(comment[:username], "James-") 140 | end 141 | 142 | test "build/2 allows custom (non-map) factories to be built" do 143 | assert Factory.build(:room_number, floor: 5) == "500" 144 | assert Factory.build(:room_number, floor: 5) == "501" 145 | end 146 | 147 | test "build/2 accepts anonymous functions for a factory's attributes" do 148 | account = Factory.build(:account) 149 | 150 | assert %{username: _} = account.profile 151 | end 152 | 153 | test "build/2 accepts anonymous functions that use parent record in factory's definition" do 154 | assert %{profile: %{admin: true}} = Factory.build(:admin_account, admin: true) 155 | assert %{profile: %{admin: false}} = Factory.build(:admin_account, admin: false) 156 | end 157 | 158 | test "build/2 can take anonymous functions for attributes" do 159 | user = Factory.build(:user, foo_bar: fn -> Factory.build(:foo_bar) end) 160 | 161 | assert %FooBar{} = user.foo_bar 162 | end 163 | 164 | test "build/2 does not evaluate lazy attributes when factory definition has full control" do 165 | comment = Factory.build(:comment, name: "James", user: fn -> Factory.build(:user) end) 166 | 167 | assert is_function(comment.user) 168 | assert %{id: 3, name: "John Doe", admin: false} = comment.user.() 169 | end 170 | 171 | test "build/2 recursively builds nested lazy attributes" do 172 | lazy_profile = fn -> Factory.build(:profile, user: fn -> Factory.build(:user) end) end 173 | account = Factory.build(:account, profile: lazy_profile) 174 | 175 | assert %{username: _} = account.profile 176 | assert %{name: "John Doe", admin: false} = account.profile.user 177 | end 178 | 179 | test "build/2 lazily evaluates an attribute that is a list" do 180 | user = Factory.build(:user, profiles: fn -> [Factory.build(:profile)] end) 181 | 182 | profile = hd(user.profiles) 183 | 184 | assert Map.has_key?(profile, :username) 185 | assert Map.has_key?(profile, :user) 186 | end 187 | end 188 | 189 | describe "build_pair/2" do 190 | test "build_pair/2 builds 2 factories" do 191 | records = Factory.build_pair(:user, admin: true) 192 | 193 | expected_record = %{ 194 | id: 3, 195 | name: "John Doe", 196 | admin: true 197 | } 198 | 199 | assert records == [expected_record, expected_record] 200 | end 201 | 202 | test "build_pair/2 recursively builds many nested lazy attributes" do 203 | lazy_profile = fn -> Factory.build(:profile, user: fn -> Factory.build(:user) end) end 204 | [account1, account2] = Factory.build_pair(:account, profile: lazy_profile) 205 | 206 | assert account1.profile.username != account2.profile.username 207 | end 208 | end 209 | 210 | describe "build_list/3" do 211 | test "build_list/3 builds the factory the passed in number of times" do 212 | records = Factory.build_list(3, :user, admin: true) 213 | 214 | expected_record = %{ 215 | id: 3, 216 | name: "John Doe", 217 | admin: true 218 | } 219 | 220 | assert records == [expected_record, expected_record, expected_record] 221 | end 222 | 223 | test "build_list/3 handles the number 0" do 224 | assert [] = Factory.build_list(0, :user) 225 | end 226 | 227 | test "raises helpful error when using old create functions" do 228 | assert_raise RuntimeError, ~r/create\/1 has been removed/, fn -> 229 | Factory.create(:user) 230 | end 231 | 232 | assert_raise RuntimeError, ~r/create\/2 has been removed/, fn -> 233 | Factory.create(:user, admin: true) 234 | end 235 | 236 | assert_raise RuntimeError, ~r/create_pair\/2 has been removed/, fn -> 237 | Factory.create_pair(:user, admin: true) 238 | end 239 | 240 | assert_raise RuntimeError, ~r/create_list\/3 has been removed/, fn -> 241 | Factory.create_list(3, :user, admin: true) 242 | end 243 | end 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /test/support/ecto_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.EctoCase do 2 | use ExUnit.CaseTemplate 3 | 4 | setup do 5 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(ExMachina.TestRepo) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/models/article.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.Article do 2 | use Ecto.Schema 3 | 4 | schema "articles" do 5 | field(:title, :string) 6 | field(:visits, :decimal) 7 | field(:published_at, :utc_datetime) 8 | 9 | belongs_to(:author, ExMachina.User) 10 | belongs_to(:editor, ExMachina.User) 11 | belongs_to(:publisher, ExMachina.Publisher) 12 | has_many(:comments, ExMachina.Comment) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/models/comment.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.Comment do 2 | use Ecto.Schema 3 | 4 | schema "comments" do 5 | belongs_to(:article, ExMachina.Article) 6 | embeds_one(:author, ExMachina.Author) 7 | embeds_many(:links, ExMachina.Link) 8 | end 9 | end 10 | 11 | defmodule ExMachina.Author do 12 | use Ecto.Schema 13 | 14 | embedded_schema do 15 | field(:name) 16 | field(:salary, :decimal) 17 | end 18 | end 19 | 20 | defmodule ExMachina.Link do 21 | use Ecto.Schema 22 | 23 | embedded_schema do 24 | field(:url) 25 | field(:rating, :decimal) 26 | embeds_one(:metadata, ExMachina.Metadata) 27 | end 28 | end 29 | 30 | defmodule ExMachina.Metadata do 31 | use Ecto.Schema 32 | 33 | embedded_schema do 34 | field(:image_url, :string) 35 | field(:text, :string) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/models/custom.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.Custom do 2 | use Ecto.Schema 3 | 4 | @primary_key {:non_autogenerated_id, :integer, []} 5 | schema "customs" do 6 | field(:name, :string) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/support/models/invalid_cast.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.InvalidCast do 2 | use Ecto.Schema 3 | 4 | schema "invalid_casts" do 5 | field(:invalid, ExMachina.InvalidType) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/models/invalid_type.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.InvalidType do 2 | @behaviour Ecto.Type 3 | 4 | def type, do: :integer 5 | def equal?(_a, _b), do: false 6 | def embed_as(_), do: :self 7 | 8 | def cast(_), do: :error 9 | def load(_), do: :error 10 | def dump(_), do: :error 11 | end 12 | -------------------------------------------------------------------------------- /test/support/models/money.ex: -------------------------------------------------------------------------------- 1 | defmodule Money do 2 | defstruct amount: 0 3 | end 4 | -------------------------------------------------------------------------------- /test/support/models/publisher.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.Publisher do 2 | use Ecto.Schema 3 | 4 | schema "publishers" do 5 | field(:pub_number, :string) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.User do 2 | use Ecto.Schema 3 | 4 | schema "users" do 5 | field(:name, :string) 6 | field(:admin, :boolean) 7 | field(:net_worth, :decimal) 8 | field(:password, :string, virtual: true) 9 | field(:db_value, :string) 10 | 11 | has_many(:articles, ExMachina.Article, foreign_key: :author_id) 12 | has_many(:editors, through: [:articles, :editor]) 13 | has_one(:best_article, ExMachina.Article, foreign_key: :author_id) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/test_factory.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.TestFactory do 2 | use ExMachina.Ecto, repo: ExMachina.TestRepo 3 | 4 | def custom_factory do 5 | %ExMachina.Custom{ 6 | non_autogenerated_id: 1, 7 | name: "Testing" 8 | } 9 | end 10 | 11 | def user_factory do 12 | %ExMachina.User{ 13 | name: "John Doe", 14 | admin: false, 15 | articles: [], 16 | best_article: nil 17 | } 18 | end 19 | 20 | def publisher_factory do 21 | %ExMachina.Publisher{ 22 | pub_number: sequence("PUB_23") 23 | } 24 | end 25 | 26 | def article_factory do 27 | %ExMachina.Article{ 28 | title: "My Awesome Article", 29 | author: build(:user) 30 | } 31 | end 32 | 33 | def comment_with_embedded_assocs_factory do 34 | %ExMachina.Comment{} 35 | end 36 | 37 | def invalid_cast_factory do 38 | %ExMachina.InvalidCast{} 39 | end 40 | 41 | def user_map_factory do 42 | %{ 43 | id: 3, 44 | name: "John Doe", 45 | admin: false 46 | } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/test_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ExMachina.TestRepo do 2 | use Ecto.Repo, 3 | otp_app: :ex_machina, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Mix.Task.run("ecto.drop", ["quiet", "-r", "ExMachina.TestRepo"]) 2 | Mix.Task.run("ecto.create", ["quiet", "-r", "ExMachina.TestRepo"]) 3 | Mix.Task.run("ecto.migrate", ["-r", "ExMachina.TestRepo"]) 4 | 5 | ExMachina.TestRepo.start_link() 6 | ExUnit.start() 7 | 8 | Ecto.Adapters.SQL.Sandbox.mode(ExMachina.TestRepo, :manual) 9 | --------------------------------------------------------------------------------