├── .credo.exs ├── .dialyzer_ignore.exs ├── .formatter.exs ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _config.yml ├── config ├── config.exs ├── dev.exs └── test.exs ├── coveralls.json ├── docker-compose.yml ├── guides ├── cheatsheets │ └── schema.cheatmd └── recipes │ └── partial_uuid_filter.md ├── lib ├── flop.ex └── flop │ ├── adapter.ex │ ├── adapter │ ├── ecto.ex │ └── ecto │ │ └── operators.ex │ ├── cursor.ex │ ├── custom_types │ ├── any.ex │ ├── existing_atom.ex │ └── like.ex │ ├── errors.ex │ ├── field_info.ex │ ├── filter.ex │ ├── meta.ex │ ├── misc.ex │ ├── nimble_schemas.ex │ ├── relay.ex │ ├── schema.ex │ └── validation.ex ├── mix.exs ├── mix.lock ├── renovate.json └── test ├── adapters └── ecto │ ├── cases │ └── flop_test.exs │ ├── mysql │ ├── all_test.exs │ ├── migration.exs │ └── test_helper.exs │ ├── postgres │ ├── all_test.exs │ ├── migration.exs │ └── test_helper.exs │ └── sqlite │ ├── all_test.exs │ ├── migration.exs │ └── test_helper.exs ├── base ├── flop │ ├── cursor_test.exs │ ├── custom_types │ │ ├── any_test.exs │ │ └── existing_atom_test.exs │ ├── filter_test.exs │ ├── meta_test.exs │ ├── misc_test.exs │ ├── relay_test.exs │ ├── schema_test.exs │ └── validation_test.exs ├── flop_test.exs └── test_helper.exs └── support ├── distance.ex ├── distance_type.ex ├── factory.ex ├── fruit.ex ├── generators.ex ├── owner.ex ├── pet.ex ├── test_util.ex ├── vegetable.ex └── walking_distances.ex /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "test/"], 25 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # Load and configure plugins here: 29 | # 30 | plugins: [], 31 | # 32 | # If you create your own checks, you must specify the source files for 33 | # them here, so they can be loaded by Credo before running the analysis. 34 | # 35 | requires: [], 36 | # 37 | # If you want to enforce a style guide and need a more traditional linting 38 | # experience, you can change `strict` to `true` below: 39 | # 40 | strict: true, 41 | # 42 | # To modify the timeout for parsing files, change this value: 43 | # 44 | parse_timeout: 5000, 45 | # 46 | # If you want to use uncolored output by default, you can change `color` 47 | # to `false` below: 48 | # 49 | color: true, 50 | # 51 | # You can customize the parameters of any check by adding a second element 52 | # to the tuple. 53 | # 54 | # To disable a check put `false` as second element: 55 | # 56 | # {Credo.Check.Design.DuplicatedCode, false} 57 | # 58 | checks: %{ 59 | enabled: [ 60 | # 61 | ## Consistency Checks 62 | # 63 | {Credo.Check.Consistency.ExceptionNames, []}, 64 | {Credo.Check.Consistency.LineEndings, []}, 65 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 66 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 67 | {Credo.Check.Consistency.SpaceInParentheses, []}, 68 | {Credo.Check.Consistency.TabsOrSpaces, []}, 69 | 70 | # 71 | ## Design Checks 72 | # 73 | # You can customize the priority of any check 74 | # Priority values are: `low, normal, high, higher` 75 | # 76 | {Credo.Check.Design.AliasUsage, 77 | [ 78 | priority: :low, 79 | if_nested_deeper_than: 2, 80 | if_called_more_often_than: 0 81 | ]}, 82 | # You can also customize the exit_status of each check. 83 | # If you don't want TODO comments to cause `mix credo` to fail, just 84 | # set this value to 0 (zero). 85 | # 86 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 87 | {Credo.Check.Design.TagFIXME, []}, 88 | 89 | # 90 | ## Readability Checks 91 | # 92 | {Credo.Check.Readability.AliasOrder, []}, 93 | {Credo.Check.Readability.FunctionNames, []}, 94 | {Credo.Check.Readability.LargeNumbers, []}, 95 | {Credo.Check.Readability.MaxLineLength, 96 | [priority: :low, max_length: 80]}, 97 | {Credo.Check.Readability.ModuleAttributeNames, []}, 98 | {Credo.Check.Readability.ModuleDoc, []}, 99 | {Credo.Check.Readability.ModuleNames, []}, 100 | {Credo.Check.Readability.ParenthesesInCondition, []}, 101 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 102 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 103 | {Credo.Check.Readability.PredicateFunctionNames, []}, 104 | {Credo.Check.Readability.PreferImplicitTry, []}, 105 | {Credo.Check.Readability.RedundantBlankLines, []}, 106 | {Credo.Check.Readability.Semicolons, []}, 107 | {Credo.Check.Readability.SpaceAfterCommas, []}, 108 | {Credo.Check.Readability.StringSigils, []}, 109 | {Credo.Check.Readability.TrailingBlankLine, []}, 110 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 111 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 112 | {Credo.Check.Readability.VariableNames, []}, 113 | {Credo.Check.Readability.WithSingleClause, []}, 114 | 115 | # 116 | ## Refactoring Opportunities 117 | # 118 | {Credo.Check.Refactor.Apply, []}, 119 | {Credo.Check.Refactor.CondStatements, []}, 120 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 121 | {Credo.Check.Refactor.FunctionArity, []}, 122 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 123 | {Credo.Check.Refactor.MatchInCondition, []}, 124 | {Credo.Check.Refactor.MapJoin, []}, 125 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 126 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 127 | {Credo.Check.Refactor.Nesting, []}, 128 | {Credo.Check.Refactor.UnlessWithElse, []}, 129 | {Credo.Check.Refactor.WithClauses, []}, 130 | {Credo.Check.Refactor.FilterCount, []}, 131 | {Credo.Check.Refactor.FilterFilter, []}, 132 | {Credo.Check.Refactor.RejectReject, []}, 133 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 134 | 135 | # 136 | ## Warnings 137 | # 138 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 139 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 140 | {Credo.Check.Warning.Dbg, []}, 141 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 142 | {Credo.Check.Warning.IExPry, []}, 143 | {Credo.Check.Warning.IoInspect, []}, 144 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 145 | {Credo.Check.Warning.OperationOnSameValues, []}, 146 | {Credo.Check.Warning.OperationWithConstantResult, []}, 147 | {Credo.Check.Warning.RaiseInsideRescue, []}, 148 | {Credo.Check.Warning.SpecWithStruct, []}, 149 | {Credo.Check.Warning.WrongTestFileExtension, []}, 150 | {Credo.Check.Warning.UnusedEnumOperation, []}, 151 | {Credo.Check.Warning.UnusedFileOperation, []}, 152 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 153 | {Credo.Check.Warning.UnusedListOperation, []}, 154 | {Credo.Check.Warning.UnusedPathOperation, []}, 155 | {Credo.Check.Warning.UnusedRegexOperation, []}, 156 | {Credo.Check.Warning.UnusedStringOperation, []}, 157 | {Credo.Check.Warning.UnusedTupleOperation, []}, 158 | {Credo.Check.Warning.UnsafeExec, []}, 159 | 160 | # 161 | ## Checks disabled by default 162 | # 163 | 164 | {Credo.Check.Readability.SinglePipe, []}, 165 | {Credo.Check.Readability.StrictModuleLayout, []}, 166 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 167 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 168 | {Credo.Check.Refactor.FilterReject, []}, 169 | {Credo.Check.Refactor.MapMap, []}, 170 | {Credo.Check.Refactor.PipeChainStart, []}, 171 | {Credo.Check.Refactor.RejectFilter, []}, 172 | {Credo.Check.Warning.MapGetUnsafePass, []} 173 | ], 174 | disabled: [ 175 | # 176 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 177 | 178 | # 179 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 180 | # and be sure to use `mix credo --strict` to see low priority checks) 181 | # 182 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 183 | {Credo.Check.Consistency.UnusedVariableNames, []}, 184 | {Credo.Check.Design.DuplicatedCode, []}, 185 | {Credo.Check.Design.SkipTestWithoutComment, []}, 186 | {Credo.Check.Readability.AliasAs, []}, 187 | {Credo.Check.Readability.BlockPipe, []}, 188 | {Credo.Check.Readability.ImplTrue, []}, 189 | {Credo.Check.Readability.MultiAlias, []}, 190 | {Credo.Check.Readability.NestedFunctionCalls, []}, 191 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 192 | {Credo.Check.Readability.OnePipePerLine, []}, 193 | {Credo.Check.Readability.SeparateAliasRequire, []}, 194 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 195 | {Credo.Check.Readability.Specs, []}, 196 | {Credo.Check.Refactor.ABCSize, []}, 197 | {Credo.Check.Refactor.AppendSingleItem, []}, 198 | {Credo.Check.Refactor.IoPuts, []}, 199 | {Credo.Check.Refactor.ModuleDependencies, []}, 200 | {Credo.Check.Refactor.NegatedIsNil, []}, 201 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 202 | {Credo.Check.Refactor.VariableRebinding, []}, 203 | {Credo.Check.Warning.LazyLogging, []}, 204 | {Credo.Check.Warning.LeakyEnvironment, []}, 205 | {Credo.Check.Warning.MixEnv, []}, 206 | {Credo.Check.Warning.UnsafeToAtom, []} 207 | 208 | # {Credo.Check.Refactor.MapInto, []}, 209 | 210 | # 211 | # Custom checks can be created using `mix credo.gen.check`. 212 | # 213 | ] 214 | } 215 | } 216 | ] 217 | } 218 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: [ 4 | "{mix,.formatter}.exs", 5 | "{config,lib,test}/**/*.{ex,exs}" 6 | ], 7 | line_length: 80, 8 | import_deps: [:ecto, :stream_data] 9 | ] 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @woylie 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug] " 4 | labels: ["bug", "triage"] 5 | assignees: [] 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Summary 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Steps to reproduce 15 | value: 1. ... 16 | - type: textarea 17 | attributes: 18 | label: Expected behaviour 19 | - type: textarea 20 | attributes: 21 | label: Actual behaviour 22 | - type: textarea 23 | attributes: 24 | label: Elixir/Erlang version 25 | description: "`elixir -v`" 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Flop and Ecto versions 31 | description: "`mix deps | grep 'flop\\| ecto \\|ecto_sql'`" 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Additional context 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: feature request 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | open-pull-requests-limit: 0 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | day: saturday 9 | ignore: 10 | - dependency-name: ecto 11 | versions: 12 | - 3.5.6 13 | - 3.5.7 14 | - 3.5.8 15 | - dependency-name: dialyxir 16 | versions: 17 | - 1.1.0 18 | - dependency-name: excoveralls 19 | versions: 20 | - 0.14.0 21 | - dependency-name: ex_machina 22 | versions: 23 | - 2.6.0 24 | - dependency-name: postgrex 25 | versions: 26 | - 0.15.8 27 | - package-ecosystem: github-actions 28 | open-pull-requests-limit: 0 29 | directory: "/" 30 | schedule: 31 | interval: weekly 32 | day: saturday 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | ELIXIR_VERSION: "1.18" 5 | OTP_VERSION: "27" 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - main 14 | 15 | jobs: 16 | test-coverage: 17 | runs-on: ubuntu-24.04 18 | name: Test Coverage 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | MIX_ENV: test 22 | services: 23 | postgres: 24 | image: postgres:17-alpine 25 | env: 26 | POSTGRES_USER: postgres 27 | POSTGRES_PASSWORD: postgres 28 | options: >- 29 | --health-cmd pg_isready 30 | --health-interval 10s 31 | --health-timeout 5s 32 | --health-retries 5 33 | ports: 34 | - 5432:5432 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: erlef/setup-beam@v1 38 | id: beam 39 | with: 40 | otp-version: ${{ env.OTP_VERSION }} 41 | elixir-version: ${{ env.ELIXIR_VERSION }} 42 | - name: Restore dependencies and build cache 43 | uses: actions/cache@v4 44 | with: 45 | path: | 46 | _build 47 | deps 48 | key: ${{ runner.os }}-otp-${{ steps.beam.outputs.otp-version }}-elixir-${{ steps.beam.outputs.elixir-version }}-mix-${{ hashFiles('mix.lock') }} 49 | restore-keys: ${{ runner.os }}-otp-${{ steps.beam.outputs.otp-version }}-elixir-${{ steps.beam.outputs.elixir-version }}- 50 | - name: Install Dependencies 51 | run: | 52 | mix local.rebar --force 53 | mix local.hex --force 54 | mix deps.get 55 | - name: Compile 56 | run: mix compile --warnings-as-errors 57 | - name: Run Tests 58 | run: mix coveralls.json.all --warnings-as-errors 59 | - uses: codecov/codecov-action@v5 60 | with: 61 | files: ./cover/excoveralls.json 62 | 63 | tests: 64 | runs-on: ubuntu-24.04 65 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 66 | strategy: 67 | matrix: 68 | include: 69 | - { elixir: 1.14, otp: 24 } 70 | - { elixir: 1.14, otp: 25 } 71 | - { elixir: 1.15, otp: 24 } 72 | - { elixir: 1.15, otp: 25 } 73 | - { elixir: 1.15, otp: 26 } 74 | - { elixir: 1.16, otp: 24 } 75 | - { elixir: 1.16, otp: 25 } 76 | - { elixir: 1.16, otp: 26 } 77 | - { elixir: 1.17, otp: 25 } 78 | - { elixir: 1.17, otp: 26 } 79 | - { elixir: 1.17, otp: 27 } 80 | - { elixir: 1.18, otp: 25 } 81 | - { elixir: 1.18, otp: 26 } 82 | - { elixir: 1.18, otp: 27 } 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | services: 86 | postgres: 87 | image: postgres:17-alpine 88 | env: 89 | POSTGRES_USER: postgres 90 | POSTGRES_PASSWORD: postgres 91 | options: >- 92 | --health-cmd pg_isready 93 | --health-interval 10s 94 | --health-timeout 5s 95 | --health-retries 5 96 | ports: 97 | - 5432:5432 98 | steps: 99 | - uses: actions/checkout@v4 100 | - uses: erlef/setup-beam@v1 101 | id: beam 102 | with: 103 | otp-version: ${{ matrix.otp }} 104 | elixir-version: ${{ matrix.elixir }} 105 | - name: Restore dependencies and build cache 106 | uses: actions/cache@v4 107 | with: 108 | path: | 109 | _build 110 | deps 111 | key: ${{ runner.os }}-otp-${{ steps.beam.outputs.otp-version }}-elixir-${{ steps.beam.outputs.elixir-version }}-mix-${{ hashFiles('mix.lock') }} 112 | restore-keys: ${{ runner.os }}-otp-${{ steps.beam.outputs.otp-version }}-elixir-${{ steps.beam.outputs.elixir-version }}- 113 | - name: Install Dependencies 114 | run: | 115 | mix local.rebar --force 116 | mix local.hex --force 117 | mix deps.get 118 | - name: Run Base Tests 119 | run: mix test 120 | - name: Run Postgres Tests 121 | run: mix test.postgres 122 | 123 | matrix-results: 124 | if: ${{ always() }} 125 | runs-on: ubuntu-24.04 126 | name: Tests 127 | needs: 128 | - tests 129 | steps: 130 | - run: | 131 | result="${{ needs.tests.result }}" 132 | if [[ $result == "success" ]]; then 133 | exit 0 134 | else 135 | exit 1 136 | fi 137 | 138 | code-quality: 139 | runs-on: ubuntu-24.04 140 | name: Code Quality 141 | env: 142 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 143 | 144 | services: 145 | postgres: 146 | image: postgres:17-alpine 147 | env: 148 | POSTGRES_USER: postgres 149 | POSTGRES_PASSWORD: postgres 150 | options: >- 151 | --health-cmd pg_isready 152 | --health-interval 10s 153 | --health-timeout 5s 154 | --health-retries 5 155 | ports: 156 | - 5432:5432 157 | 158 | steps: 159 | - uses: actions/checkout@v4 160 | - uses: erlef/setup-beam@v1 161 | id: beam 162 | with: 163 | otp-version: ${{ env.OTP_VERSION }} 164 | elixir-version: ${{ env.ELIXIR_VERSION }} 165 | 166 | - name: Restore dependencies and build cache 167 | uses: actions/cache@v4 168 | with: 169 | path: | 170 | _build 171 | deps 172 | key: ${{ runner.os }}-otp-${{ steps.beam.outputs.otp-version }}-elixir-${{ steps.beam.outputs.elixir-version }}-mix-${{ hashFiles('mix.lock') }} 173 | restore-keys: ${{ runner.os }}-otp-${{ steps.beam.outputs.otp-version }}-elixir-${{ steps.beam.outputs.elixir-version }}- 174 | - name: Restore PLT cache 175 | uses: actions/cache@v4 176 | id: plt_cache 177 | with: 178 | key: | 179 | ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-plt-${{ hashFiles('mix.lock') }} 180 | restore-keys: | 181 | ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-plt- 182 | path: | 183 | .plts 184 | - name: Install Dependencies 185 | run: | 186 | mix local.rebar --force 187 | mix local.hex --force 188 | mix deps.get 189 | - name: Compile 190 | run: mix compile --warnings-as-errors 191 | - name: Run Formatter 192 | run: mix format --check-formatted 193 | - name: Run Linter 194 | run: mix credo 195 | - name: Run Hex Audit 196 | run: mix hex.audit 197 | - name: Generate docs 198 | run: mix docs 199 | - name: Run Dialyzer 200 | run: mix dialyzer 201 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | flop-*.tar 24 | 25 | # Ignore dialyzer PLT 26 | .plts 27 | 28 | # Test SQLite DB lives here 29 | /tmp/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | cover/ 3 | deps/ 4 | doc/ 5 | mix.lock 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mathias Polligkeit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flop 2 | 3 | ![CI](https://github.com/woylie/flop/workflows/CI/badge.svg) [![Hex](https://img.shields.io/hexpm/v/flop)](https://hex.pm/packages/flop) [![codecov](https://codecov.io/gh/woylie/flop/branch/main/graph/badge.svg?token=32BSY8O2LI)](https://codecov.io/gh/woylie/flop) 4 | 5 | Flop is an Elixir library designed to easily apply filtering, ordering, and 6 | pagination to your Ecto queries. 7 | 8 | ## Features 9 | 10 | - **Offset-based pagination:** Allows pagination through `offset`/`limit` or 11 | `page`/`page_size` parameters. 12 | - **Cursor-based pagination:** Also known as key set pagination, provides a more 13 | efficient alternative to offset-based pagination. Compatible with Relay 14 | pagination arguments. 15 | - **Sorting:** Applies sort parameters on multiple fields in any direction. 16 | - **Filtering:** Allows complex data filtering using multiple conditions, 17 | operators, and fields. 18 | - **Parameter validation:** Ensures the validity of provided parameters. 19 | - **Configurable filterable and sortable fields:** Only applies parameters to 20 | the fields that were explicitly configured as filterable or sortable. 21 | - **Join fields:** Allows the application of pagination, sort, and filter 22 | parameters on any named binding. Provides functions to help you to avoid 23 | unnecessary join clauses. 24 | - **Compound fields:** Provides the ability to apply filter parameters on 25 | multiple string fields, for example for a full name filter. 26 | - **Custom fields:** Provides an escape hatch for filters that Flop is not able 27 | to build on its own. 28 | - **Relay connection formatter:** Formats the connection in Relay style, 29 | providing edges, nodes, and page info. 30 | - **UI helpers and URL builders through 31 | [Flop Phoenix](https://hex.pm/packages/flop_phoenix):** Pagination, sortable 32 | tables and filter forms. 33 | 34 | ## Installation 35 | 36 | To get started, add `flop` to your dependencies list in your project's `mix.exs` 37 | file: 38 | 39 | ```elixir 40 | def deps do 41 | [ 42 | {:flop, "~> 0.26.3"} 43 | ] 44 | end 45 | ``` 46 | 47 | You can also configure a default repo for Flop by adding the following line to 48 | your config file: 49 | 50 | ```elixir 51 | config :flop, repo: MyApp.Repo 52 | ``` 53 | 54 | Instead of configuring Flop globally, you can also use a configuration module. 55 | Please refer to the Flop module documentation for more information. 56 | 57 | ## Usage 58 | 59 | ### Define sortable and filterable fields 60 | 61 | To define sortable and filterable fields in your Ecto schema, you can derive 62 | `Flop.Schema`. This step is optional but highly recommended, particularly when 63 | the parameters passed to Flop's functions are user-provided. Deriving 64 | `Flop.Schema` ensures that Flop applies filtering and sorting parameters only to 65 | the fields you've explicitly configured. 66 | 67 | ```elixir 68 | defmodule MyApp.Pet do 69 | use Ecto.Schema 70 | 71 | @derive { 72 | Flop.Schema, 73 | filterable: [:name, :species], 74 | sortable: [:name, :age, :species] 75 | } 76 | 77 | schema "pets" do 78 | field :name, :string 79 | field :age, :integer 80 | field :species, :string 81 | field :social_security_number, :string 82 | end 83 | end 84 | ``` 85 | 86 | Besides sortable and filterable fields, `Flop.Schema` also allows the definition 87 | of join fields, compound fields, or custom fields. You can also set maximum or 88 | default limits, among other options. For a comprehensive list of available 89 | options, check the `Flop.Schema` documentation. 90 | 91 | ### Query data 92 | 93 | Use the `Flop.validate_and_run/3` or `Flop.validate_and_run!/3` function to both 94 | validate the parameters and fetch data from the database, and acquire pagination 95 | metadata in one operation. 96 | 97 | Here is an example of how you might use this in your code: 98 | 99 | ```elixir 100 | defmodule MyApp.Pets do 101 | import Ecto.Query, warn: false 102 | 103 | alias Ecto.Changeset 104 | alias MyApp.{Pet, Repo} 105 | 106 | @spec list_pets(map) :: 107 | {:ok, {[Pet.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()} 108 | def list_pets(params \\ %{}) do 109 | Flop.validate_and_run(Pet, params, for: Pet) 110 | end 111 | end 112 | ``` 113 | 114 | The `for` option sets the Ecto schema for which you derived `Flop.Schema`. If 115 | you haven't derived `Flop.Schema` as described above, this option can be 116 | omitted. However, this is not recommended unless all parameters are generated 117 | internally and are guaranteed to be safe. 118 | 119 | On success, `Flop.validate_and_run/3` returns an `:ok` tuple. The second element 120 | of this tuple is another tuple containing the fetched data and metadata. 121 | 122 | ```elixir 123 | {:ok, {[%Pet{}], %Flop.Meta{}}} 124 | ``` 125 | 126 | You can learn more about the `Flop.Meta` struct in the 127 | [module documentation](https://hexdocs.pm/flop/Flop.Meta.html). 128 | 129 | Alternatively, you may separate parameter validation and data fetching into 130 | different steps using the Flop.validate/2, Flop.validate!/2, and Flop.run/3 131 | functions. This allows you to manipulate the validated parameters, to modify the 132 | query depending on the parameters, or to move the parameter validation to a 133 | different layer of your application. 134 | 135 | ```elixir 136 | with {:ok, flop} <- Flop.validate(params, for: Pet) do 137 | Flop.run(Pet, flop, for: Pet) 138 | end 139 | ``` 140 | 141 | The aforementioned functions internally call the lower-level functions 142 | `Flop.all/3`, `Flop.meta/3`, and `Flop.count/3`. If you have advanced 143 | requirements, you might prefer to use these functions directly. However, it's 144 | important to note that these lower-level functions do not validate the 145 | parameters. If parameters are generated based on user input, they should always 146 | be validated first using `Flop.validate/2` or `Flop.validate!/2` to ensure safe 147 | execution. 148 | 149 | The examples above assume that you configured a default repo. However, you can 150 | also pass the repo directly to the functions: 151 | 152 | ```elixir 153 | Flop.validate_and_run(Pet, flop, repo: MyApp.Repo) 154 | Flop.all(Pet, flop, repo: MyApp.Repo) 155 | Flop.meta(Pet, flop, repo: MyApp.Repo) 156 | ``` 157 | 158 | For more detailed information, refer the 159 | [documentation](https://hexdocs.pm/flop/readme.html). 160 | 161 | ## Parameter format 162 | 163 | The Flop library requires parameters to be provided in a specific format as a 164 | map. This map can be translated into a URL query parameter string, typically 165 | for use in a web framework like Phoenix. 166 | 167 | ### Pagination 168 | 169 | #### Offset / limit 170 | 171 | You can specify an offset to start from and a limit to the number of results. 172 | 173 | ```elixir 174 | %{offset: 20, limit: 10} 175 | ``` 176 | 177 | This translates to the following query parameter string: 178 | 179 | ```html 180 | ?offset=20&limit=10 181 | ``` 182 | 183 | #### Page / page size 184 | 185 | You can specify the page number and the size of each page. 186 | 187 | ```elixir 188 | %{page: 2, page_size: 10} 189 | ``` 190 | 191 | This translates to the following query parameter string: 192 | 193 | ```html 194 | ?page=2&page_size=10 195 | ``` 196 | 197 | #### Cursor 198 | 199 | You can fetch a specific number of results before or after a given cursor. 200 | 201 | ```elixir 202 | %{first: 10, after: "g3QAAAABZAACaWRiAAACDg=="} 203 | %{last: 10, before: "g3QAAAABZAACaWRiAAACDg=="} 204 | ``` 205 | 206 | These translate to the following query parameter strings: 207 | 208 | ```html 209 | ?first=10&after=g3QAAAABZAACaWRiAAACDg== 210 | ?last=10&before=g3QAAAABZAACaWRiAAACDg== 211 | ``` 212 | 213 | ### Ordering 214 | 215 | To sort the results, specify fields to order by and the direction of sorting for 216 | each field. 217 | 218 | ```elixir 219 | %{order_by: [:name, :age], order_directions: [:asc, :desc]} 220 | ``` 221 | 222 | This translates to the following query parameter string: 223 | 224 | ```html 225 | ?order_by[]=name&order_by[]=age&order_directions[]=asc&order_directions[]=desc 226 | ``` 227 | 228 | ### Filters 229 | 230 | You can filter the results by providing a field, an operator, and a value. The 231 | operator is optional and defaults to `==`. Multiple filters are combined with a 232 | logical `AND`. At the moment, combining filters with `OR` is not supported. 233 | 234 | ```elixir 235 | %{filters: [%{field: :name, op: :ilike_and, value: "Jane"}]} 236 | ``` 237 | 238 | This translates to the following query parameter string: 239 | 240 | ```html 241 | ?filters[0][field]=name&filters[0][op]=ilike_and&filters[0][value]=Jane 242 | ``` 243 | 244 | Refer to the `Flop.Filter` documentation and `t:Flop.t/0` type documentation for 245 | more details on using filters. 246 | 247 | ## Internal parameters 248 | 249 | Flop is designed to manage parameters that come from the user side. While it is 250 | possible to alter those parameters and append extra filters upon receiving them, 251 | it is advisable to clearly differentiate parameters coming from outside and the 252 | parameters that your application adds internally. 253 | 254 | Consider the scenario where you need to scope a query based on the current user. 255 | In this case, it is better to create a separate function that introduces the 256 | necessary `WHERE` clauses: 257 | 258 | ```elixir 259 | def list_pets(%{} = params, %User{} = current_user) do 260 | Pet 261 | |> scope(current_user) 262 | |> Flop.validate_and_run(params, for: Pet) 263 | end 264 | 265 | defp scope(q, %User{role: :admin}), do: q 266 | defp scope(q, %User{id: user_id}), do: where(q, user_id: ^user_id) 267 | ``` 268 | 269 | If you need to add extra filters that are only used internally and aren't 270 | exposed to the user, you can pass them as a separate argument. This same 271 | argument can be used to override certain options depending on the context in 272 | which the function is called. 273 | 274 | ```elixir 275 | def list_pets(%{} = params, opts \\ [], %User{} = current_user) do 276 | flop_opts = 277 | opts 278 | |> Keyword.take([ 279 | :default_limit, 280 | :default_pagination_type, 281 | :pagination_types 282 | ]) 283 | |> Keyword.put(:for, Pet) 284 | 285 | Pet 286 | |> scope(current_user) 287 | |> apply_filters(opts) 288 | |> Flop.validate_and_run(params, flop_opts) 289 | end 290 | 291 | defp scope(q, %User{role: :admin}), do: q 292 | defp scope(q, %User{id: user_id}), do: where(q, user_id: ^user_id) 293 | 294 | defp apply_filters(q, opts) do 295 | Enum.reduce(opts, q, fn 296 | {:last_health_check, dt}, q -> where(q, [p], p.last_health_check < ^dt) 297 | {:reminder_service, bool}, q -> where(q, [p], p.reminder_service == ^bool) 298 | _, q -> q 299 | end) 300 | end 301 | ``` 302 | 303 | With this approach, you maintain a clean separation between user-driven 304 | parameters and system-driven parameters, leading to more maintainable and less 305 | error-prone code. 306 | 307 | ## Relay and Absinthe 308 | 309 | The `Flop.Relay` module is useful if you are using 310 | [absinthe](https://hex.pm/packages/absinthe) with 311 | [absinthe_relay](https://hex.pm/packages/absinthe_relay), or if you simply need 312 | to adhere to the Relay cursor specification. This module provides functions that 313 | help transform query responses into a format compatible with Relay. 314 | 315 | Consider the scenario where you have defined node objects for owners and pets, 316 | along with a connection field for pets on the owner node object. 317 | 318 | ```elixir 319 | node object(:owner) do 320 | field :name, non_null(:string) 321 | field :email, non_null(:string) 322 | 323 | connection field :pets, node_type: :pet do 324 | resolve &MyAppWeb.Resolvers.Pet.list_pets/2 325 | end 326 | end 327 | 328 | node object(:pet) do 329 | field :name, non_null(:string) 330 | field :age, non_null(:integer) 331 | field :species, non_null(:string) 332 | end 333 | 334 | connection(node_type: :pet) 335 | ``` 336 | 337 | Absinthe Relay will establish the arguments `after`, `before`, `first` and 338 | `last` on the `pets` field. These argument names align with those used by Flop, facilitating their application. 339 | 340 | Next, we'll define a `list_pets_by_owner/2` function in the Pets context. 341 | 342 | ```elixir 343 | defmodule MyApp.Pets do 344 | import Ecto.Query 345 | 346 | alias MyApp.{Owner, Pet, Repo} 347 | 348 | @spec list_pets_by_owner(Owner.t(), map) :: 349 | {:ok, {[Pet.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()} 350 | def list_pets_by_owner(%Owner{id: owner_id}, params \\ %{}) do 351 | Pet 352 | |> where(owner_id: ^owner_id) 353 | |> Flop.validate_and_run(params, for: Pet) 354 | end 355 | end 356 | ``` 357 | 358 | Now, within your resolver, you merely need to invoke the function and call 359 | `Flop.Relay.connection_from_result/1`, which transforms the result into a tuple 360 | composed of the edges and the page_info, as required by `absinthe_relay`. 361 | 362 | ```elixir 363 | defmodule MyAppWeb.Resolvers.Pet do 364 | alias MyApp.{Owner, Pet} 365 | 366 | def list_pets(args, %{source: %Owner{} = owner} = resolution) do 367 | with {:ok, result} <- Pets.list_pets_by_owner(owner, args) do 368 | {:ok, Flop.Relay.connection_from_result(result)} 369 | end 370 | end 371 | end 372 | ``` 373 | 374 | In case you want to introduce additional filter arguments, you can employ 375 | `Flop.nest_filters/3` to convert simple filter arguments into Flop filters, 376 | without necessitating API users to understand the Flop filter format. 377 | 378 | Let's add `name` and `species` filter arguments to the `pets` connection field. 379 | 380 | ```elixir 381 | node object(:owner) do 382 | field :name, non_null(:string) 383 | field :email, non_null(:string) 384 | 385 | connection field :pets, node_type: :pet do 386 | arg :name, :string 387 | arg :species, :string 388 | 389 | resolve &MyAppWeb.Resolvers.Pet.list_pets/2 390 | end 391 | end 392 | ``` 393 | 394 | Assuming that these fields have been already configured as filterable with 395 | `Flop.Schema`, we can use `Flop.nest_filters/3` to take the filter arguments and 396 | transform them into a list of Flop filters. 397 | 398 | ```elixir 399 | defmodule MyAppWeb.Resolvers.Pet do 400 | alias MyApp.{Owner, Pet} 401 | 402 | def list_pets(args, %{source: %Owner{} = owner} = resolution) do 403 | args = nest_filters(args, [:name, :species]) 404 | 405 | with {:ok, result} <- Pets.list_pets_by_owner(owner, args) do 406 | {:ok, Flop.Relay.connection_from_result(result)} 407 | end 408 | end 409 | end 410 | ``` 411 | 412 | `Flop.nest_filters/3` uses the equality operator `:==` by default. 413 | You can override the default operator per field. 414 | 415 | ```elixir 416 | args = nest_filters(args, [:name, :species], operators: %{name: :ilike_and}) 417 | ``` 418 | 419 | ## Flop Phoenix 420 | 421 | [Flop Phoenix](https://hex.pm/packages/flop_phoenix) is a companion library that 422 | provides Phoenix components for pagination, sortable tables, and filter forms, 423 | usable with both Phoenix LiveView and in dead views. It also defines helper 424 | functions to build URLs with Flop query parameters. 425 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman 2 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :flop, 4 | ecto_repos: [Flop.Repo], 5 | repo: Flop.Repo 6 | 7 | config :stream_data, 8 | max_runs: if(System.get_env("CI"), do: 100, else: 50), 9 | max_run_time: if(System.get_env("CI"), do: 3000, else: 200) 10 | 11 | config :logger, level: :warning 12 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "treat_no_relevant_lines_as_covered": true, 3 | "minimum_coverage": 80, 4 | "skip_files": ["test"] 5 | } 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:17-alpine 4 | environment: 5 | POSTGRES_USER: postgres 6 | POSTGRES_PASSWORD: postgres 7 | ports: 8 | - "5432:5432" 9 | mysql: 10 | image: mysql:9.3 11 | environment: 12 | MYSQL_ALLOW_EMPTY_PASSWORD: true 13 | ports: 14 | - "3306:3306" 15 | -------------------------------------------------------------------------------- /guides/cheatsheets/schema.cheatmd: -------------------------------------------------------------------------------- 1 | # Schema configuration 2 | 3 | ## Basics 4 | {: .col-2} 5 | 6 | ### Minimal configuration 7 | 8 | ```elixir 9 | defmodule Pet do 10 | use Ecto.Schema 11 | 12 | @derive { 13 | Flop.Schema, 14 | filterable: [:name, :species], 15 | sortable: [:name, :age] 16 | } 17 | 18 | schema "pets" do 19 | field :name, :string 20 | field :age, :integer 21 | field :species, :string 22 | end 23 | end 24 | ``` 25 | 26 | ### Options 27 | 28 | #### Limit 29 | 30 | ```elixir 31 | @derive { 32 | Flop.Schema, 33 | filterable: [:name, :species], 34 | sortable: [:name, :age], 35 | max_limit: 100, 36 | default_limit: 50 37 | } 38 | ``` 39 | 40 | #### Order 41 | 42 | ```elixir 43 | @derive { 44 | Flop.Schema, 45 | filterable: [:name, :species], 46 | sortable: [:name, :age], 47 | default_order: %{ 48 | order_by: [:name, :age], 49 | order_directions: [:asc, :desc] 50 | } 51 | } 52 | ``` 53 | 54 | #### Pagination types 55 | 56 | ```elixir 57 | @derive { 58 | Flop.Schema, 59 | filterable: [:name, :species], 60 | sortable: [:name, :age], 61 | pagination_types: [:first, :last], 62 | default_pagination_type: :first 63 | } 64 | ``` 65 | 66 | ## Alias fields 67 | {: .col-2} 68 | 69 | ### Schema 70 | 71 | ```elixir 72 | defmodule Owner do 73 | use Ecto.Schema 74 | 75 | @derive { 76 | Flop.Schema, 77 | filterable: [:name], 78 | sortable: [:name, :pet_count], 79 | adapter_opts: [ 80 | alias_fields: [:pet_count] 81 | ] 82 | } 83 | 84 | schema "owners" do 85 | field :name, :string 86 | has_many :pets, Pet 87 | end 88 | end 89 | ``` 90 | 91 | ### Query 92 | 93 | ```elixir 94 | params = %{order_by: [:pet_count]} 95 | 96 | Owner 97 | |> join(:left, [o], p in assoc(o, :pets), as: :pets) 98 | |> group_by([o], o.id) 99 | |> select( 100 | [o, pets: p], 101 | {o.id, p.id |> count() |> selected_as(:pet_count)} 102 | ) 103 | |> Flop.validate_and_run(params, for: Owner) 104 | ``` 105 | 106 | ## Compound fields 107 | {: .col-2} 108 | 109 | ### Schema 110 | 111 | ```elixir 112 | defmodule User do 113 | use Ecto.Schema 114 | 115 | @derive { 116 | Flop.Schema, 117 | filterable: [:full_name], 118 | sortable: [:full_name], 119 | adapter_opts: [ 120 | compound_fields: [ 121 | full_name: [:family_name, :given_name] 122 | ] 123 | ] 124 | } 125 | 126 | schema "users" do 127 | field :family_name, :string 128 | field :given_name, :string 129 | end 130 | end 131 | ``` 132 | 133 | ### Query 134 | 135 | ```elixir 136 | params = %{ 137 | filters: [ 138 | %{field: :full_name, op: :ilike_and, value: "pea"} 139 | ] 140 | } 141 | 142 | Flop.validate_and_run(User, params, for: Owner) 143 | ``` 144 | 145 | ## Join fields 146 | {: .col-2} 147 | 148 | ### Schema 149 | 150 | #### Owner 151 | 152 | ```elixir 153 | defmodule Owner do 154 | use Ecto.Schema 155 | 156 | @derive { 157 | Flop.Schema, 158 | filterable: [:name, :pet_age], 159 | sortable: [:name], 160 | adapter_opts: [ 161 | join_fields: [ 162 | pet_age: [ 163 | binding: :pets, 164 | field: :age, 165 | ecto_type: :integer 166 | ] 167 | ] 168 | ] 169 | } 170 | 171 | schema "owners" do 172 | field :name, :string 173 | has_many :pets, Pet 174 | end 175 | end 176 | ``` 177 | 178 | #### Pet 179 | 180 | ```elixir 181 | defmodule Pet do 182 | use Ecto.Schema 183 | 184 | schema "pets" do 185 | field :age, :integer 186 | end 187 | end 188 | ``` 189 | 190 | ### Query 191 | 192 | #### Only filtering or sorting 193 | 194 | ```elixir 195 | params = %{ 196 | filters: [ 197 | %{field: :pet_age, op: :==, value: 8} 198 | ] 199 | } 200 | 201 | Owner 202 | |> join([o], p in assoc(o, :pets), as: :pets) 203 | |> Flop.validate_and_run(params, for: Pet) 204 | ``` 205 | 206 | #### With preload 207 | 208 | ```elixir 209 | Owner 210 | |> join([o], p in assoc(o, :pets), as: :pets) 211 | |> preload([pets: p], pets: p) 212 | |> Flop.validate_and_run(params, for: Pet) 213 | ``` 214 | 215 | ## Join field for nested association 216 | {: .col-2} 217 | 218 | ### Schema 219 | 220 | #### Owner 221 | 222 | ```elixir 223 | defmodule Owner do 224 | use Ecto.Schema 225 | 226 | @derive { 227 | Flop.Schema, 228 | filterable: [:name, :toy_description], 229 | sortable: [:name], 230 | adapter_opts: [ 231 | join_fields: [ 232 | pet_age: [ 233 | binding: :toys, 234 | field: :description, 235 | ecto_type: :string, 236 | # only needed with cursor pagination when sorting 237 | # by the join field, so that Flop can find the 238 | # cursor value 239 | path: [:pets, :toys] 240 | ] 241 | ] 242 | ] 243 | } 244 | 245 | schema "owners" do 246 | field :name, :string 247 | has_many :pets, Pet 248 | end 249 | end 250 | ``` 251 | 252 | #### Pet 253 | 254 | ```elixir 255 | defmodule Pet do 256 | use Ecto.Schema 257 | 258 | schema "pets" do 259 | field :age, :integer 260 | has_many :toys, Toy 261 | end 262 | end 263 | ``` 264 | 265 | #### Toy 266 | 267 | ```elixir 268 | defmodule Toy do 269 | use Ecto.Schema 270 | 271 | schema "toys" do 272 | field :description, :string 273 | end 274 | end 275 | ``` 276 | 277 | ### Query with preload 278 | 279 | ```elixir 280 | params = %{order_by: [:toy_description]} 281 | 282 | Owner 283 | |> join([o], p in assoc(o, :pets), as: :pets) 284 | |> join([pets: p], t in assoc(p, :toys), as: :toys) 285 | |> preload([pets: p, toys: t], pets: {p, toys: t}) 286 | |> Flop.validate_and_run(params, for: Owner) 287 | ``` 288 | 289 | ## Join field for subquery 290 | {: .col-2} 291 | 292 | ### Schema 293 | 294 | #### Owner 295 | 296 | ```elixir 297 | defmodule Owner do 298 | use Ecto.Schema 299 | 300 | @derive { 301 | Flop.Schema, 302 | filterable: [:name], 303 | sortable: [:name, :pet_count], 304 | adapter_opts: [ 305 | join_fields: [ 306 | pet_count: [ 307 | binding: :pet_count, 308 | field: :count 309 | ] 310 | ] 311 | ] 312 | } 313 | 314 | schema "owners" do 315 | field :name, :string 316 | has_many :pets, Pet 317 | end 318 | end 319 | ``` 320 | 321 | #### Pet 322 | 323 | ```elixir 324 | defmodule Pet do 325 | use Ecto.Schema 326 | 327 | schema "pets" do 328 | field :age, :integer 329 | end 330 | end 331 | ``` 332 | 333 | ### Query 334 | 335 | ```elixir 336 | params = %{filters: [%{field: :pet_count, op: :>, value: 2}]} 337 | 338 | pet_count_query = 339 | Pet 340 | |> where([p], parent_as(:owner).id == p.owner_id) 341 | |> select([p], %{count: count(p)}) 342 | 343 | q = 344 | Owner 345 | |> from(as: :owner) 346 | |> join(:inner_lateral, [o], p in subquery(pet_count_query), 347 | as: :pet_count 348 | ) 349 | |> Flop.validate_and_run(params, for: Owner) 350 | ``` 351 | 352 | ## Custom fields 353 | {: .col-2} 354 | 355 | ### Schema 356 | 357 | ```elixir 358 | defmodule Pet do 359 | use Ecto.Schema 360 | 361 | @derive { 362 | Flop.Schema, 363 | filterable: [:name, :human_age], 364 | sortable: [:name], 365 | adapter_opts: [ 366 | custom_fields: [ 367 | human_age: [ 368 | filter: {CustomFilters, :human_age, []}, 369 | ecto_type: :integer 370 | ] 371 | ] 372 | ] 373 | } 374 | 375 | schema "pets" do 376 | field :name, :string 377 | field :age, :integer 378 | end 379 | end 380 | ``` 381 | 382 | ### Custom filter function 383 | 384 | ```elixir 385 | defmodule CustomFilters do 386 | import Ecto.Query 387 | 388 | def human_age(q, %Flop.Filter{value: value, op: op}, _) do 389 | case Ecto.Type.cast(:integer, value) do 390 | {:ok, human_years} -> 391 | value_in_dog_years = round(human_years / 7) 392 | 393 | case op do 394 | :== -> where(q, [p], p == ^value_in_dog_years) 395 | :!= -> where(q, [p], p != ^value_in_dog_years) 396 | :> -> where(q, [p], p > ^value_in_dog_years) 397 | :< -> where(q, [p], p < ^value_in_dog_years) 398 | :>= -> where(q, [p], p >= ^value_in_dog_years) 399 | :<= -> where(q, [p], p <= ^value_in_dog_years) 400 | end 401 | 402 | :error -> 403 | # cannot cast filter value, ignore 404 | q 405 | end 406 | end 407 | end 408 | ``` 409 | 410 | ### Query 411 | 412 | ```elixir 413 | params = %{ 414 | filters: [ 415 | %{field: :human_age, op: :==, value: 30} 416 | ] 417 | } 418 | 419 | Flop.validate_and_run(Pet, params, for: Pet) 420 | ``` 421 | -------------------------------------------------------------------------------- /guides/recipes/partial_uuid_filter.md: -------------------------------------------------------------------------------- 1 | # Partial UUID Filter 2 | 3 | Flop attempts to cast filter values as the type of the underlying Ecto schema 4 | field. If the value cannot be cast, an error is returned for that filter value, 5 | or if the `replace_invalid_params` option is set, the invalid filter will be 6 | removed from the query. 7 | 8 | In the case of binary IDs (UUIDs), this means that the user has to pass 9 | the full ID to apply a filter on the ID column. In some cases, you may prefer 10 | to allow users to search for partial UUIDs. You can achieve this by defining a 11 | custom filter. 12 | 13 | ## Filter Module 14 | 15 | First, we add a generic custom filter function for partial UUID matches to a 16 | separate module. 17 | 18 | ```elixir 19 | defmodule MyApp.Filters do 20 | import Ecto.Query 21 | 22 | def partial_uuid_filter(q, %Flop.Filter{value: value}, opts) do 23 | field = Keyword.fetch!(opts, :field) 24 | 25 | case Ecto.Type.cast(Ecto.UUID, value) do 26 | {:ok, id} -> 27 | where(q, [r], field(r, ^field) == ^id) 28 | 29 | :error -> 30 | term = "%#{String.trim(value)}%" 31 | where(q, [r], ilike(type(field(r, ^field), :string), ^term)) 32 | end 33 | end 34 | end 35 | ``` 36 | 37 | The function takes an Ecto query and a `Flop.Filter` struct as 38 | values. It also accepts a `field` option, which must be set to the Ecto schema 39 | field on which this filter is applied. This way, we can reuse the custom 40 | filter for filtering on foreign keys as well. 41 | 42 | We first attempt to cast the filter value as an `Ecto.UUID`. If this succeeds, 43 | we know that we have a complete and valid UUID and can apply an equality filter 44 | directly. 45 | 46 | If the value is not a valid `Ecto.UUID`, we have a partial ID. We create a 47 | search term and apply an `ilike` function in the query. We have to cast the 48 | column as a string, because the binary ID type does not support `ilike`. 49 | 50 | Note that we ignore the filter operator here and always use `ilike`. If you want 51 | to support other filter operators, you can match on the `op` field of the 52 | `Flop.Filter` struct. 53 | 54 | ## Ecto Schema 55 | 56 | In the Ecto schema, we can now define a custom field that references our filter 57 | function and pass the `field` as an option. We also need to mark the field as 58 | filterable. 59 | 60 | ```elixir 61 | @derive {Flop.Schema, 62 | filterable: [:partial_id], 63 | # ... 64 | adapter_opts: [ 65 | custom_fields: [ 66 | partial_id: [ 67 | filter: {MyApp.Filters, :partial_uuid_filter, [field: :id]}, 68 | ecto_type: :string 69 | ] 70 | ] 71 | ]} 72 | ``` 73 | 74 | ## Complete Example 75 | 76 | ```elixir 77 | defmodule MyApp.Pet do 78 | use Ecto.Schema 79 | 80 | import Ecto.Query 81 | 82 | @derive {Flop.Schema, 83 | filterable: [:partial_id], 84 | sortable: [:name], 85 | default_order: %{ 86 | order_by: [:name], 87 | order_directions: [:asc] 88 | }, 89 | adapter_opts: [ 90 | custom_fields: [ 91 | partial_id: [ 92 | filter: {MyApp.Filters, :partial_uuid_filter, [field: :id]}, 93 | ecto_type: :string 94 | ] 95 | ] 96 | ]} 97 | 98 | @primary_key {:id, Ecto.UUID, autogenerate: true} 99 | 100 | schema "pets" do 101 | field :name, :string 102 | end 103 | end 104 | 105 | defmodule MyApp.Filters do 106 | import Ecto.Query 107 | 108 | def partial_uuid_filter(q, %Flop.Filter{value: value}, opts) do 109 | field = Keyword.fetch!(opts, :field) 110 | 111 | case Ecto.Type.cast(Ecto.UUID, value) do 112 | {:ok, id} -> 113 | where(q, [r], field(r, ^field) == ^id) 114 | 115 | :error -> 116 | term = "%#{String.trim(value)}%" 117 | where(q, [r], ilike(type(field(r, ^field), :string), ^term)) 118 | end 119 | end 120 | end 121 | ``` 122 | -------------------------------------------------------------------------------- /lib/flop/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Adapter do 2 | @moduledoc false 3 | 4 | @type queryable :: term 5 | @type opts :: keyword 6 | @type cursor_fields :: [ 7 | {Flop.order_direction(), atom, any, Flop.FieldInfo.t() | nil} 8 | ] 9 | 10 | @callback init_backend_opts(keyword, keyword, module) :: keyword 11 | 12 | # Struct is available when deriving protocol. Change when protocol is 13 | # replaced. 14 | @callback init_schema_opts(keyword, keyword, module, struct) :: map 15 | 16 | @callback fields(struct, adapter_opts) :: [{field, Flop.FieldInfo.t()}] 17 | when adapter_opts: map, 18 | field: atom 19 | 20 | @callback apply_filter(queryable, Flop.Filter.t(), struct, keyword) :: 21 | queryable 22 | 23 | @callback apply_order_by(queryable, keyword, opts) :: queryable 24 | 25 | @callback apply_limit_offset( 26 | queryable, 27 | limit | nil, 28 | offset | nil, 29 | opts 30 | ) :: queryable 31 | when limit: non_neg_integer, offset: non_neg_integer 32 | 33 | @callback apply_page_page_size(queryable, page, page_size, opts) :: queryable 34 | when page: pos_integer, page_size: pos_integer 35 | 36 | @callback apply_cursor(queryable, cursor_fields, opts) :: queryable 37 | 38 | @doc """ 39 | Takes a queryable and returns the total count. 40 | 41 | Flop will pass the queryable with filter parameters applied, but without 42 | pagination or sorting parameters. 43 | """ 44 | @callback count(queryable, opts) :: non_neg_integer 45 | 46 | @doc """ 47 | Executes a list query. 48 | 49 | The first argument is a queryable, for example an `Ecto.Queryable.t()` or any 50 | other format depending on the adapter. 51 | """ 52 | @callback list(queryable, opts) :: [any] 53 | 54 | @callback get_field(any, atom, Flop.FieldInfo.t()) :: any 55 | end 56 | -------------------------------------------------------------------------------- /lib/flop/adapter/ecto.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Adapter.Ecto do 2 | @moduledoc false 3 | 4 | @behaviour Flop.Adapter 5 | 6 | import Ecto.Query 7 | import Flop.Adapter.Ecto.Operators 8 | 9 | alias Ecto.Query 10 | alias Flop.FieldInfo 11 | alias Flop.Filter 12 | alias Flop.NimbleSchemas 13 | 14 | require Logger 15 | 16 | @operators [ 17 | :==, 18 | :!=, 19 | :empty, 20 | :not_empty, 21 | :>=, 22 | :<=, 23 | :>, 24 | :<, 25 | :in, 26 | :contains, 27 | :not_contains, 28 | :like, 29 | :not_like, 30 | :=~, 31 | :ilike, 32 | :not_ilike, 33 | :not_in, 34 | :like_and, 35 | :like_or, 36 | :ilike_and, 37 | :ilike_or 38 | ] 39 | 40 | @backend_options [ 41 | repo: [required: true], 42 | query_opts: [type: :keyword_list, default: []] 43 | ] 44 | 45 | @schema_options [ 46 | join_fields: [ 47 | type: :keyword_list, 48 | default: [], 49 | keys: [ 50 | *: [ 51 | type: 52 | {:or, 53 | [ 54 | keyword_list: [ 55 | binding: [type: :atom, required: true], 56 | field: [type: :atom, required: true], 57 | ecto_type: [type: :any], 58 | path: [type: {:list, :atom}] 59 | ], 60 | tuple: [:atom, :atom] 61 | ]} 62 | ] 63 | ] 64 | ], 65 | compound_fields: [ 66 | type: :keyword_list, 67 | default: [], 68 | keys: [ 69 | *: [ 70 | type: {:list, :atom} 71 | ] 72 | ] 73 | ], 74 | custom_fields: [ 75 | type: :keyword_list, 76 | default: [], 77 | keys: [ 78 | *: [ 79 | type: :keyword_list, 80 | keys: [ 81 | filter: [ 82 | type: {:tuple, [:atom, :atom, :keyword_list]}, 83 | required: true 84 | ], 85 | ecto_type: [type: :any], 86 | bindings: [type: {:list, :atom}], 87 | operators: [type: {:list, :atom}] 88 | ] 89 | ] 90 | ] 91 | ], 92 | alias_fields: [ 93 | type: {:list, :atom}, 94 | default: [] 95 | ] 96 | ] 97 | 98 | @backend_options NimbleOptions.new!(@backend_options) 99 | @schema_options NimbleOptions.new!(@schema_options) 100 | 101 | defp __backend_options__, do: @backend_options 102 | defp __schema_options__, do: @schema_options 103 | 104 | @impl Flop.Adapter 105 | def init_backend_opts(_opts, backend_opts, caller_module) do 106 | NimbleSchemas.validate!( 107 | backend_opts, 108 | __backend_options__(), 109 | Flop, 110 | caller_module 111 | ) 112 | end 113 | 114 | @impl Flop.Adapter 115 | def init_schema_opts(opts, schema_opts, caller_module, struct) do 116 | schema_opts = 117 | NimbleSchemas.validate!( 118 | schema_opts, 119 | __schema_options__(), 120 | Flop.Schema, 121 | caller_module 122 | ) 123 | 124 | schema_opts 125 | |> validate_no_duplicate_fields!() 126 | |> normalize_schema_opts() 127 | |> validate_alias_fields!(opts) 128 | |> validate_compound_fields!(struct) 129 | |> validate_custom_fields!(opts) 130 | end 131 | 132 | @impl Flop.Adapter 133 | def fields(struct, opts) do 134 | alias_fields(opts) ++ 135 | compound_fields(opts) ++ 136 | custom_fields(opts) ++ 137 | join_fields(opts) ++ 138 | schema_fields(struct) 139 | end 140 | 141 | defp alias_fields(%{alias_fields: alias_fields}) do 142 | Enum.map(alias_fields, &{&1, %FieldInfo{extra: %{type: :alias}}}) 143 | end 144 | 145 | defp compound_fields(%{compound_fields: compound_fields}) do 146 | Enum.map(compound_fields, fn {field, fields} -> 147 | {field, 148 | %FieldInfo{ 149 | ecto_type: :string, 150 | operators: [ 151 | :=~, 152 | :like, 153 | :not_like, 154 | :like_and, 155 | :like_or, 156 | :ilike, 157 | :not_ilike, 158 | :ilike_and, 159 | :ilike_or, 160 | :empty, 161 | :not_empty 162 | ], 163 | extra: %{fields: fields, type: :compound} 164 | }} 165 | end) 166 | end 167 | 168 | defp join_fields(%{join_fields: join_fields}) do 169 | Enum.map(join_fields, fn 170 | {field, %{} = field_opts} -> 171 | extra = field_opts |> Map.delete(:ecto_type) |> Map.put(:type, :join) 172 | 173 | {field, 174 | %FieldInfo{ 175 | ecto_type: field_opts.ecto_type, 176 | extra: extra 177 | }} 178 | end) 179 | end 180 | 181 | defp custom_fields(%{custom_fields: custom_fields}) do 182 | Enum.map(custom_fields, fn {field, field_opts} -> 183 | extra = 184 | field_opts 185 | |> Map.drop([:ecto_type, :operators]) 186 | |> Map.put(:type, :custom) 187 | 188 | {field, 189 | %FieldInfo{ 190 | ecto_type: field_opts.ecto_type, 191 | operators: field_opts.operators, 192 | extra: extra 193 | }} 194 | end) 195 | end 196 | 197 | defp schema_fields(%module{} = struct) do 198 | struct 199 | |> Map.from_struct() 200 | |> Enum.reject(fn 201 | {_, %Ecto.Association.NotLoaded{}} -> true 202 | {:__meta__, _} -> true 203 | _ -> false 204 | end) 205 | |> Enum.map(fn {field, _} -> 206 | {field, 207 | %FieldInfo{ 208 | ecto_type: {:from_schema, module, field}, 209 | extra: %{type: :normal, field: field} 210 | }} 211 | end) 212 | end 213 | 214 | @impl Flop.Adapter 215 | def get_field(%{} = item, _field, %FieldInfo{ 216 | extra: %{type: :compound, fields: fields} 217 | }) do 218 | Enum.map_join(fields, " ", &get_field(item, &1, %FieldInfo{})) 219 | end 220 | 221 | def get_field(%{} = item, _field, %FieldInfo{ 222 | extra: %{type: :join, path: path} 223 | }) do 224 | Enum.reduce(path, item, fn 225 | field, %{} = acc -> Map.get(acc, field) 226 | _, _ -> nil 227 | end) 228 | end 229 | 230 | def get_field(%{} = item, field, %FieldInfo{}) do 231 | Map.get(item, field) 232 | end 233 | 234 | @impl Flop.Adapter 235 | def apply_filter( 236 | query, 237 | %Flop.Filter{field: field} = filter, 238 | schema_struct, 239 | opts 240 | ) do 241 | case get_field_info(schema_struct, field) do 242 | %FieldInfo{extra: %{type: :custom} = custom_opts} -> 243 | {mod, fun, custom_filter_opts} = Map.fetch!(custom_opts, :filter) 244 | 245 | opts = 246 | opts 247 | |> Keyword.get(:extra_opts, []) 248 | |> Keyword.merge(custom_filter_opts) 249 | 250 | apply(mod, fun, [query, filter, opts]) 251 | 252 | field_info -> 253 | Query.where(query, ^build_op(schema_struct, field_info, filter)) 254 | end 255 | end 256 | 257 | @impl Flop.Adapter 258 | def apply_order_by(query, directions, opts) do 259 | if has_order_bys?(query) do 260 | Logger.warning( 261 | "The query you passed to flop includes order_by. This may interfere with Flop's ordering and pagination features." 262 | ) 263 | end 264 | 265 | case opts[:for] do 266 | nil -> 267 | Query.order_by(query, ^directions) 268 | 269 | module -> 270 | struct = struct(module) 271 | 272 | Enum.reduce(directions, query, fn {_, field} = expr, acc_query -> 273 | field_info = Flop.Schema.field_info(struct, field) 274 | apply_order_by_field(acc_query, expr, field_info, struct) 275 | end) 276 | end 277 | end 278 | 279 | defp has_order_bys?(query) when is_atom(query), do: false 280 | defp has_order_bys?(%Ecto.Query{order_bys: []}), do: false 281 | defp has_order_bys?(%Ecto.Query{order_bys: [_ | _]}), do: true 282 | 283 | defp apply_order_by_field( 284 | q, 285 | {direction, _}, 286 | %FieldInfo{ 287 | extra: %{type: :join, binding: binding, field: field} 288 | }, 289 | _ 290 | ) do 291 | order_by(q, [{^binding, r}], [{^direction, field(r, ^field)}]) 292 | end 293 | 294 | defp apply_order_by_field( 295 | q, 296 | {direction, _}, 297 | %FieldInfo{ 298 | extra: %{type: :compound, fields: fields} 299 | }, 300 | struct 301 | ) do 302 | Enum.reduce(fields, q, fn field, acc_query -> 303 | field_info = Flop.Schema.field_info(struct, field) 304 | apply_order_by_field(acc_query, {direction, field}, field_info, struct) 305 | end) 306 | end 307 | 308 | defp apply_order_by_field( 309 | q, 310 | {direction, field}, 311 | %FieldInfo{extra: %{type: :alias}}, 312 | _ 313 | ) do 314 | order_by(q, [{^direction, selected_as(^field)}]) 315 | end 316 | 317 | defp apply_order_by_field(q, order_expr, _, _) do 318 | order_by(q, ^order_expr) 319 | end 320 | 321 | @impl Flop.Adapter 322 | def apply_limit_offset(query, limit, offset, _opts) do 323 | query 324 | |> apply_limit(limit) 325 | |> apply_offset(offset) 326 | end 327 | 328 | defp apply_limit(q, nil), do: q 329 | defp apply_limit(q, limit), do: Query.limit(q, ^limit) 330 | 331 | defp apply_offset(q, nil), do: q 332 | defp apply_offset(q, offset), do: Query.offset(q, ^offset) 333 | 334 | @impl Flop.Adapter 335 | def apply_page_page_size(query, page, page_size, _opts) do 336 | offset_for_page = (page - 1) * page_size 337 | 338 | query 339 | |> limit(^page_size) 340 | |> offset(^offset_for_page) 341 | end 342 | 343 | @impl Flop.Adapter 344 | def apply_cursor(q, cursor_fields, _opts) do 345 | where_dynamic = cursor_dynamic(cursor_fields) 346 | Query.where(q, ^where_dynamic) 347 | end 348 | 349 | defp cursor_dynamic([]), do: true 350 | 351 | defp cursor_dynamic([{_, _, _, %FieldInfo{extra: %{type: :compound}}} | t]) do 352 | Logger.warning( 353 | "Flop: Cursor pagination is not supported for compound fields. Ignored." 354 | ) 355 | 356 | cursor_dynamic(t) 357 | end 358 | 359 | defp cursor_dynamic([{_, _, _, %FieldInfo{extra: %{type: :alias}}} | _]) do 360 | raise "alias fields are not supported in cursor pagination" 361 | end 362 | 363 | # no cursor value, last cursor field 364 | defp cursor_dynamic([{_, _, nil, _}]) do 365 | true 366 | end 367 | 368 | # no cursor value, more cursor fields to come 369 | defp cursor_dynamic([{_, _, nil, _} | [{_, _, _, _} | _] = tail]) do 370 | cursor_dynamic(tail) 371 | end 372 | 373 | # join field ascending, last cursor field 374 | defp cursor_dynamic([ 375 | {direction, _, cursor_value, 376 | %FieldInfo{extra: %{binding: binding, field: field, type: :join}}} 377 | ]) 378 | when direction in [:asc, :asc_nulls_first, :asc_nulls_last] do 379 | dynamic( 380 | [{^binding, r}], 381 | field(r, ^field) > type(^cursor_value, field(r, ^field)) 382 | ) 383 | end 384 | 385 | # join field descending, last cursor field 386 | defp cursor_dynamic([ 387 | {direction, _, cursor_value, 388 | %FieldInfo{extra: %{binding: binding, field: field, type: :join}}} 389 | ]) 390 | when direction in [:desc, :desc_nulls_first, :desc_nulls_last] do 391 | dynamic( 392 | [{^binding, r}], 393 | field(r, ^field) < type(^cursor_value, field(r, ^field)) 394 | ) 395 | end 396 | 397 | # join field ascending, more cursor fields to come 398 | defp cursor_dynamic([ 399 | {direction, _, cursor_value, 400 | %FieldInfo{extra: %{binding: binding, field: field, type: :join}}} 401 | | [{_, _, _, _} | _] = tail 402 | ]) 403 | when direction in [:asc, :asc_nulls_first, :asc_nulls_last] do 404 | dynamic( 405 | [{^binding, r}], 406 | field(r, ^field) >= type(^cursor_value, field(r, ^field)) and 407 | (field(r, ^field) > type(^cursor_value, field(r, ^field)) or 408 | ^cursor_dynamic(tail)) 409 | ) 410 | end 411 | 412 | # join field descending, more cursor fields to come 413 | defp cursor_dynamic([ 414 | {direction, _, cursor_value, 415 | %FieldInfo{extra: %{binding: binding, field: field, type: :join}}} 416 | | [{_, _, _, _} | _] = tail 417 | ]) 418 | when direction in [:desc, :desc_nulls_first, :desc_nulls_last] do 419 | dynamic( 420 | [{^binding, r}], 421 | field(r, ^field) <= type(^cursor_value, field(r, ^field)) and 422 | (field(r, ^field) < type(^cursor_value, field(r, ^field)) or 423 | ^cursor_dynamic(tail)) 424 | ) 425 | end 426 | 427 | # any other field type ascending, last cursor field 428 | defp cursor_dynamic([{direction, field, cursor_value, _}]) 429 | when direction in [:asc, :asc_nulls_first, :asc_nulls_last] do 430 | dynamic([r], field(r, ^field) > type(^cursor_value, field(r, ^field))) 431 | end 432 | 433 | # any other field type descending, last cursor field 434 | defp cursor_dynamic([{direction, field, cursor_value, _}]) 435 | when direction in [:desc, :desc_nulls_first, :desc_nulls_last] do 436 | dynamic([r], field(r, ^field) < type(^cursor_value, field(r, ^field))) 437 | end 438 | 439 | # any other field type ascending, more cursor fields to come 440 | defp cursor_dynamic([ 441 | {direction, field, cursor_value, _} | [{_, _, _, _} | _] = tail 442 | ]) 443 | when direction in [:asc, :asc_nulls_first, :asc_nulls_last] do 444 | dynamic( 445 | [r], 446 | field(r, ^field) >= type(^cursor_value, field(r, ^field)) and 447 | (field(r, ^field) > type(^cursor_value, field(r, ^field)) or 448 | ^cursor_dynamic(tail)) 449 | ) 450 | end 451 | 452 | # any other field type descending, more cursor fields to come 453 | defp cursor_dynamic([ 454 | {direction, field, cursor_value, _} | [{_, _, _, _} | _] = tail 455 | ]) 456 | when direction in [:desc, :desc_nulls_first, :desc_nulls_last] do 457 | dynamic( 458 | [r], 459 | field(r, ^field) <= type(^cursor_value, field(r, ^field)) and 460 | (field(r, ^field) < type(^cursor_value, field(r, ^field)) or 461 | ^cursor_dynamic(tail)) 462 | ) 463 | end 464 | 465 | @impl Flop.Adapter 466 | def list(query, opts) do 467 | apply_on_repo(:all, "all", [query], opts) 468 | end 469 | 470 | @impl Flop.Adapter 471 | def count(query, opts) do 472 | query = count_query(query) 473 | apply_on_repo(:aggregate, "count", [query, :count], opts) 474 | end 475 | 476 | defp count_query(query) do 477 | query = 478 | query 479 | |> Query.exclude(:preload) 480 | |> Query.exclude(:order_by) 481 | |> Query.exclude(:select) 482 | 483 | case query do 484 | %{group_bys: group_bys} = query when group_bys != [] -> 485 | query 486 | |> Query.select(%{}) 487 | |> Query.subquery() 488 | 489 | query -> 490 | query 491 | end 492 | end 493 | 494 | defp apply_on_repo(repo_fn, flop_fn, args, opts) do 495 | # use nested adapter_opts if set 496 | opts = Flop.get_option(:adapter_opts, opts) || opts 497 | 498 | repo = 499 | Flop.get_option(:repo, opts) || 500 | raise Flop.NoRepoError, function_name: flop_fn 501 | 502 | opts = query_opts(opts) 503 | 504 | apply(repo, repo_fn, args ++ [opts]) 505 | end 506 | 507 | defp query_opts(opts) do 508 | default_opts = Application.get_env(:flop, :query_opts, []) 509 | Keyword.merge(default_opts, Keyword.get(opts, :query_opts, [])) 510 | end 511 | 512 | ## Filter query builder 513 | 514 | for op <- [:like_and, :like_or, :ilike_and, :ilike_or] do 515 | {field_op, combinator} = 516 | case op do 517 | :ilike_and -> {:ilike, :and} 518 | :ilike_or -> {:ilike, :or} 519 | :like_and -> {:like, :and} 520 | :like_or -> {:like, :or} 521 | end 522 | 523 | defp build_op( 524 | schema_struct, 525 | %FieldInfo{extra: %{type: :compound, fields: fields}}, 526 | %Filter{op: unquote(op), value: value} 527 | ) do 528 | fields = Enum.map(fields, &get_field_info(schema_struct, &1)) 529 | 530 | value = 531 | case value do 532 | v when is_binary(v) -> String.split(v) 533 | v when is_list(v) -> v 534 | end 535 | 536 | reduce_dynamic(unquote(combinator), value, fn substring -> 537 | Enum.reduce(fields, false, fn field, inner_dynamic -> 538 | dynamic_for_field = 539 | build_op(schema_struct, field, %Filter{ 540 | field: field, 541 | op: unquote(field_op), 542 | value: substring 543 | }) 544 | 545 | dynamic([r], ^inner_dynamic or ^dynamic_for_field) 546 | end) 547 | end) 548 | end 549 | end 550 | 551 | defp build_op( 552 | schema_struct, 553 | %FieldInfo{extra: %{type: :compound, fields: fields}}, 554 | %Filter{op: op} = filter 555 | ) 556 | when op in [:=~, :like, :not_like, :ilike, :not_ilike, :not_empty] do 557 | fields 558 | |> Enum.map(&get_field_info(schema_struct, &1)) 559 | |> Enum.reduce(false, fn field, dynamic -> 560 | dynamic_for_field = 561 | build_op(schema_struct, field, %{filter | field: field}) 562 | 563 | dynamic([r], ^dynamic or ^dynamic_for_field) 564 | end) 565 | end 566 | 567 | defp build_op( 568 | schema_struct, 569 | %FieldInfo{extra: %{type: :compound, fields: fields}}, 570 | %Filter{op: :empty} = filter 571 | ) do 572 | fields 573 | |> Enum.map(&get_field_info(schema_struct, &1)) 574 | |> Enum.reduce(true, fn field, dynamic -> 575 | dynamic_for_field = 576 | build_op(schema_struct, field, %{filter | field: field}) 577 | 578 | dynamic([r], ^dynamic and ^dynamic_for_field) 579 | end) 580 | end 581 | 582 | defp build_op( 583 | _schema_struct, 584 | %FieldInfo{extra: %{type: :compound}}, 585 | %Filter{op: op, value: _value} = _filter 586 | ) 587 | when op in [ 588 | :==, 589 | :!=, 590 | :<=, 591 | :<, 592 | :>=, 593 | :>, 594 | :in, 595 | :not_in, 596 | :contains, 597 | :not_contains 598 | ] do 599 | # value = value |> String.split() |> Enum.join(" ") 600 | # filter = %{filter | value: value} 601 | # compare value with concatenated fields 602 | Logger.warning( 603 | "Flop: Operator '#{op}' not supported for compound fields. Ignored." 604 | ) 605 | 606 | true 607 | end 608 | 609 | defp build_op( 610 | %module{}, 611 | %FieldInfo{extra: %{type: :normal, field: field}}, 612 | %Filter{op: op, value: value} 613 | ) 614 | when op in [:empty, :not_empty] do 615 | ecto_type = module.__schema__(:type, field) 616 | value = value in [true, "true"] 617 | value = if op == :not_empty, do: !value, else: value 618 | 619 | case array_or_map(ecto_type) do 620 | :array -> dynamic([r], empty(:array) == ^value) 621 | :map -> dynamic([r], empty(:map) == ^value) 622 | :other -> dynamic([r], empty(:other) == ^value) 623 | end 624 | end 625 | 626 | defp build_op( 627 | _schema_struct, 628 | %FieldInfo{ 629 | ecto_type: ecto_type, 630 | extra: %{type: :join, binding: binding, field: field} 631 | }, 632 | %Filter{op: op, value: value} 633 | ) 634 | when op in [:empty, :not_empty] do 635 | value = value in [true, "true"] 636 | value = if op == :not_empty, do: !value, else: value 637 | 638 | case array_or_map(ecto_type) do 639 | :array -> dynamic([{^binding, r}], empty(:array) == ^value) 640 | :map -> dynamic([{^binding, r}], empty(:map) == ^value) 641 | :other -> dynamic([{^binding, r}], empty(:other) == ^value) 642 | end 643 | end 644 | 645 | for op <- @operators do 646 | {fragment, prelude, combinator} = op_config(op) 647 | 648 | defp build_op( 649 | _schema_struct, 650 | %FieldInfo{extra: %{type: :normal, field: field}}, 651 | %Filter{op: unquote(op), value: value} 652 | ) do 653 | unquote(prelude) 654 | build_dynamic(unquote(fragment), false, unquote(combinator)) 655 | end 656 | 657 | defp build_op( 658 | _schema_struct, 659 | %FieldInfo{extra: %{type: :join, binding: binding, field: field}}, 660 | %Filter{op: unquote(op), value: value} 661 | ) do 662 | unquote(prelude) 663 | build_dynamic(unquote(fragment), true, unquote(combinator)) 664 | end 665 | end 666 | 667 | defp array_or_map({:array, _}), do: :array 668 | defp array_or_map({:map, _}), do: :map 669 | defp array_or_map(:map), do: :map 670 | defp array_or_map(_), do: :other 671 | 672 | defp get_field_info(nil, field), 673 | do: %FieldInfo{extra: %{type: :normal, field: field}} 674 | 675 | defp get_field_info(struct, field) when is_atom(field) do 676 | Flop.Schema.field_info(struct, field) 677 | end 678 | 679 | ## Option normalization 680 | 681 | defp normalize_schema_opts(opts) do 682 | opts 683 | |> Map.new() 684 | |> Map.update!(:compound_fields, &normalize_compound_fields/1) 685 | |> Map.update!(:custom_fields, &normalize_custom_fields/1) 686 | |> Map.update!(:join_fields, &normalize_join_fields/1) 687 | end 688 | 689 | defp normalize_compound_fields(fields) do 690 | Enum.into(fields, %{}) 691 | end 692 | 693 | defp normalize_custom_fields(fields) do 694 | Enum.into(fields, %{}, &normalize_custom_field_opts/1) 695 | end 696 | 697 | defp normalize_custom_field_opts({name, opts}) when is_list(opts) do 698 | opts = %{ 699 | filter: Keyword.fetch!(opts, :filter), 700 | ecto_type: Keyword.get(opts, :ecto_type), 701 | operators: Keyword.get(opts, :operators), 702 | bindings: Keyword.get(opts, :bindings, []) 703 | } 704 | 705 | {name, opts} 706 | end 707 | 708 | defp normalize_join_fields(fields) do 709 | Enum.into(fields, %{}, &normalize_join_field_opts/1) 710 | end 711 | 712 | defp normalize_join_field_opts({name, opts}) when is_list(opts) do 713 | binding = Keyword.fetch!(opts, :binding) 714 | field = Keyword.fetch!(opts, :field) 715 | 716 | opts = %{ 717 | binding: binding, 718 | field: field, 719 | path: opts[:path] || [binding, field], 720 | ecto_type: Keyword.get(opts, :ecto_type) 721 | } 722 | 723 | {name, opts} 724 | end 725 | 726 | ## Option validation 727 | 728 | defp validate_no_duplicate_fields!(opts) when is_list(opts) do 729 | duplicates = 730 | opts 731 | |> Keyword.take([ 732 | :alias_fields, 733 | :compound_fields, 734 | :custom_fields, 735 | :join_fields 736 | ]) 737 | |> Enum.flat_map(fn 738 | {:alias_fields, fields} -> fields 739 | {_, fields} -> Keyword.keys(fields) 740 | end) 741 | |> duplicates() 742 | 743 | if duplicates != [] do 744 | raise ArgumentError, """ 745 | duplicate fields 746 | 747 | Alias field, compound field, custom field and join field names must be 748 | unique. These field names were used multiple times: 749 | 750 | #{inspect(duplicates)} 751 | """ 752 | end 753 | 754 | opts 755 | end 756 | 757 | defp validate_alias_fields!( 758 | %{alias_fields: alias_fields} = adapter_opts, 759 | opts 760 | ) do 761 | filterable = Keyword.fetch!(opts, :filterable) 762 | illegal_fields = Enum.filter(alias_fields, &(&1 in filterable)) 763 | 764 | if illegal_fields != [] do 765 | raise ArgumentError, """ 766 | cannot filter by alias fields 767 | 768 | Alias fields are not allowed to be filterable. These alias fields were 769 | configured as filterable: 770 | 771 | #{inspect(illegal_fields)} 772 | 773 | Use custom fields if you want to implement custom filtering. 774 | """ 775 | end 776 | 777 | adapter_opts 778 | end 779 | 780 | defp validate_compound_fields!( 781 | %{compound_fields: compound_fields} = adapter_opts, 782 | struct 783 | ) do 784 | known_fields = 785 | Keyword.keys(schema_fields(struct) ++ join_fields(adapter_opts)) 786 | 787 | Enum.each(compound_fields, fn {field, fields} -> 788 | unknown_fields = Enum.reject(fields, &(&1 in known_fields)) 789 | 790 | if unknown_fields != [] do 791 | raise ArgumentError, """ 792 | compound field references unknown field(s) 793 | 794 | Compound fields must reference existing fields, but #{inspect(field)} 795 | references: 796 | 797 | #{inspect(unknown_fields)} 798 | """ 799 | end 800 | end) 801 | 802 | adapter_opts 803 | end 804 | 805 | defp validate_custom_fields!( 806 | %{custom_fields: custom_fields} = adapter_opts, 807 | opts 808 | ) do 809 | sortable = Keyword.fetch!(opts, :sortable) 810 | 811 | illegal_fields = 812 | custom_fields 813 | |> Map.keys() 814 | |> Enum.filter(&(&1 in sortable)) 815 | 816 | if illegal_fields != [] do 817 | raise ArgumentError, """ 818 | cannot sort by custom fields 819 | 820 | Custom fields are not allowed to be sortable. These custom fields were 821 | configured as sortable: 822 | 823 | #{inspect(illegal_fields)} 824 | 825 | Use alias fields if you want to implement custom sorting. 826 | """ 827 | end 828 | 829 | adapter_opts 830 | end 831 | 832 | defp duplicates(fields) do 833 | fields 834 | |> Enum.frequencies() 835 | |> Enum.filter(fn {_, count} -> count > 1 end) 836 | |> Enum.map(fn {field, _} -> field end) 837 | end 838 | end 839 | -------------------------------------------------------------------------------- /lib/flop/adapter/ecto/operators.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Adapter.Ecto.Operators do 2 | @moduledoc false 3 | 4 | import Ecto.Query 5 | 6 | defmacro build_dynamic(fragment, binding?, _combinator = nil) do 7 | binding_arg = binding_arg(binding?) 8 | 9 | quote do 10 | dynamic(unquote(binding_arg), unquote(fragment)) 11 | end 12 | end 13 | 14 | defmacro build_dynamic(fragment, binding?, :and) do 15 | binding_arg = binding_arg(binding?) 16 | 17 | quote do 18 | filter_condition = 19 | Enum.reduce(var!(value), true, fn substring, dynamic -> 20 | dynamic(unquote(binding_arg), ^dynamic and unquote(fragment)) 21 | end) 22 | 23 | dynamic(unquote(binding_arg), ^filter_condition) 24 | end 25 | end 26 | 27 | defmacro build_dynamic(fragment, binding?, :or) do 28 | binding_arg = binding_arg(binding?) 29 | 30 | quote do 31 | filter_condition = 32 | Enum.reduce(var!(value), false, fn substring, dynamic -> 33 | dynamic(unquote(binding_arg), ^dynamic or unquote(fragment)) 34 | end) 35 | 36 | dynamic(unquote(binding_arg), ^filter_condition) 37 | end 38 | end 39 | 40 | def reduce_dynamic(:and, values, inner_func) do 41 | Enum.reduce(values, true, fn value, dynamic -> 42 | dynamic([r], ^dynamic and ^inner_func.(value)) 43 | end) 44 | end 45 | 46 | def reduce_dynamic(:or, values, inner_func) do 47 | Enum.reduce(values, false, fn value, dynamic -> 48 | dynamic([r], ^dynamic or ^inner_func.(value)) 49 | end) 50 | end 51 | 52 | defp binding_arg(true) do 53 | quote do 54 | [{^var!(binding), r}] 55 | end 56 | end 57 | 58 | defp binding_arg(false) do 59 | quote do 60 | [r] 61 | end 62 | end 63 | 64 | def op_config(:==) do 65 | fragment = 66 | quote do 67 | field(r, ^var!(field)) == ^var!(value) 68 | end 69 | 70 | {fragment, nil, nil} 71 | end 72 | 73 | def op_config(:!=) do 74 | fragment = 75 | quote do 76 | field(r, ^var!(field)) != ^var!(value) 77 | end 78 | 79 | {fragment, nil, nil} 80 | end 81 | 82 | def op_config(:>=) do 83 | fragment = 84 | quote do 85 | field(r, ^var!(field)) >= ^var!(value) 86 | end 87 | 88 | {fragment, nil, nil} 89 | end 90 | 91 | def op_config(:<=) do 92 | fragment = 93 | quote do 94 | field(r, ^var!(field)) <= ^var!(value) 95 | end 96 | 97 | {fragment, nil, nil} 98 | end 99 | 100 | def op_config(:>) do 101 | fragment = 102 | quote do 103 | field(r, ^var!(field)) > ^var!(value) 104 | end 105 | 106 | {fragment, nil, nil} 107 | end 108 | 109 | def op_config(:<) do 110 | fragment = 111 | quote do 112 | field(r, ^var!(field)) < ^var!(value) 113 | end 114 | 115 | {fragment, nil, nil} 116 | end 117 | 118 | def op_config(:empty) do 119 | fragment = empty() 120 | {fragment, nil, nil} 121 | end 122 | 123 | def op_config(:not_empty) do 124 | fragment = 125 | quote do 126 | not unquote(empty()) 127 | end 128 | 129 | {fragment, nil, nil} 130 | end 131 | 132 | def op_config(:in) do 133 | fragment = 134 | quote do 135 | field(r, ^var!(field)) in ^var!(value) 136 | end 137 | 138 | {fragment, nil, nil} 139 | end 140 | 141 | def op_config(:contains) do 142 | fragment = 143 | quote do 144 | ^var!(value) in field(r, ^var!(field)) 145 | end 146 | 147 | {fragment, nil, nil} 148 | end 149 | 150 | def op_config(:not_contains) do 151 | fragment = 152 | quote do 153 | ^var!(value) not in field(r, ^var!(field)) 154 | end 155 | 156 | {fragment, nil, nil} 157 | end 158 | 159 | def op_config(:like) do 160 | fragment = 161 | quote do 162 | like(field(r, ^var!(field)), ^var!(value)) 163 | end 164 | 165 | prelude = prelude(:add_wildcard) 166 | {fragment, prelude, nil} 167 | end 168 | 169 | def op_config(:not_like) do 170 | fragment = 171 | quote do 172 | not like(field(r, ^var!(field)), ^var!(value)) 173 | end 174 | 175 | prelude = prelude(:add_wildcard) 176 | {fragment, prelude, nil} 177 | end 178 | 179 | def op_config(:=~) do 180 | fragment = 181 | quote do 182 | ilike(field(r, ^var!(field)), ^var!(value)) 183 | end 184 | 185 | prelude = prelude(:add_wildcard) 186 | {fragment, prelude, nil} 187 | end 188 | 189 | def op_config(:ilike) do 190 | fragment = 191 | quote do 192 | ilike(field(r, ^var!(field)), ^var!(value)) 193 | end 194 | 195 | prelude = prelude(:add_wildcard) 196 | {fragment, prelude, nil} 197 | end 198 | 199 | def op_config(:not_ilike) do 200 | fragment = 201 | quote do 202 | not ilike(field(r, ^var!(field)), ^var!(value)) 203 | end 204 | 205 | prelude = prelude(:add_wildcard) 206 | {fragment, prelude, nil} 207 | end 208 | 209 | def op_config(:not_in) do 210 | fragment = 211 | quote do 212 | field(r, ^var!(field)) not in ^var!(processed_value) and 213 | not (^var!(reject_nil?) and is_nil(field(r, ^var!(field)))) 214 | end 215 | 216 | prelude = 217 | quote do 218 | var!(reject_nil?) = nil in var!(value) 219 | 220 | var!(processed_value) = 221 | if var!(reject_nil?), 222 | do: Enum.reject(var!(value), &is_nil(&1)), 223 | else: var!(value) 224 | end 225 | 226 | {fragment, prelude, nil} 227 | end 228 | 229 | def op_config(:like_and) do 230 | fragment = 231 | quote do 232 | like(field(r, ^var!(field)), ^substring) 233 | end 234 | 235 | combinator = :and 236 | prelude = prelude(:maybe_split_search_text) 237 | 238 | {fragment, prelude, combinator} 239 | end 240 | 241 | def op_config(:like_or) do 242 | fragment = 243 | quote do 244 | like(field(r, ^var!(field)), ^substring) 245 | end 246 | 247 | combinator = :or 248 | prelude = prelude(:maybe_split_search_text) 249 | 250 | {fragment, prelude, combinator} 251 | end 252 | 253 | def op_config(:ilike_and) do 254 | fragment = 255 | quote do 256 | ilike(field(r, ^var!(field)), ^substring) 257 | end 258 | 259 | combinator = :and 260 | prelude = prelude(:maybe_split_search_text) 261 | 262 | {fragment, prelude, combinator} 263 | end 264 | 265 | def op_config(:ilike_or) do 266 | fragment = 267 | quote do 268 | ilike(field(r, ^var!(field)), ^substring) 269 | end 270 | 271 | combinator = :or 272 | prelude = prelude(:maybe_split_search_text) 273 | 274 | {fragment, prelude, combinator} 275 | end 276 | 277 | defp empty do 278 | quote do 279 | is_nil(field(r, ^var!(field))) == ^var!(value) 280 | end 281 | end 282 | 283 | defmacro empty(:array) do 284 | quote do 285 | is_nil(field(r, ^var!(field))) or 286 | field(r, ^var!(field)) == type(^[], ^var!(ecto_type)) 287 | end 288 | end 289 | 290 | defmacro empty(:map) do 291 | quote do 292 | is_nil(field(r, ^var!(field))) or 293 | field(r, ^var!(field)) == type(^%{}, ^var!(ecto_type)) 294 | end 295 | end 296 | 297 | defmacro empty(:other) do 298 | quote do 299 | is_nil(field(r, ^var!(field))) 300 | end 301 | end 302 | 303 | defp prelude(:add_wildcard) do 304 | quote do 305 | var!(value) = Flop.Misc.add_wildcard(var!(value)) 306 | end 307 | end 308 | 309 | defp prelude(:maybe_split_search_text) do 310 | quote do 311 | var!(value) = 312 | if is_binary(var!(value)) do 313 | Flop.Misc.split_search_text(var!(value)) 314 | else 315 | Enum.map(var!(value), &Flop.Misc.add_wildcard/1) 316 | end 317 | end 318 | end 319 | end 320 | -------------------------------------------------------------------------------- /lib/flop/cursor.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Cursor do 2 | @moduledoc """ 3 | Functions for encoding, decoding and extracting cursor values. 4 | """ 5 | 6 | @doc """ 7 | Encodes a cursor value. 8 | 9 | Flop.Cursor.encode(%{email: "peter@mail", name: "Peter"}) 10 | "g3QAAAACdwRuYW1lbQAAAAVQZXRlcncFZW1haWxtAAAACnBldGVyQG1haWw=" 11 | """ 12 | @doc since: "0.8.0" 13 | @spec encode(map()) :: binary() 14 | def encode(key) do 15 | Base.url_encode64(:erlang.term_to_binary(key, minor_version: 2)) 16 | end 17 | 18 | @doc """ 19 | Decodes a cursor value. 20 | 21 | Returns `:error` if the cursor cannot be decoded or the decoded term is not a 22 | map with atom keys. 23 | 24 | iex> Flop.Cursor.decode("g3QAAAABZAACaWRiAAACDg==") 25 | {:ok, %{id: 526}} 26 | 27 | iex> Flop.Cursor.decode("AAAH") 28 | :error 29 | 30 | iex> f = fn a -> a + 1 end 31 | iex> cursor = Flop.Cursor.encode(%{a: f}) 32 | iex> Flop.Cursor.decode(cursor) 33 | :error 34 | 35 | iex> cursor = Flop.Cursor.encode(a: "b") 36 | iex> Flop.Cursor.decode(cursor) 37 | :error 38 | 39 | iex> cursor = Flop.Cursor.encode(%{"a" => "b"}) 40 | iex> Flop.Cursor.decode(cursor) 41 | :error 42 | 43 | Trying to decode a cursor that contains non-existent atoms also results in an 44 | error. 45 | 46 | iex> Flop.Cursor.decode("g3QAAAABZAAGYmFybmV5ZAAGcnViYmVs") 47 | :error 48 | """ 49 | @doc since: "0.8.0" 50 | @spec decode(binary()) :: {:ok, map()} | :error 51 | def decode(cursor) do 52 | with {:ok, binary} <- Base.url_decode64(cursor), 53 | {:ok, term} <- safe_binary_to_term(binary) do 54 | sanitize(term) 55 | 56 | if is_map(term) && term |> Map.keys() |> Enum.all?(&is_atom/1), 57 | do: {:ok, term}, 58 | else: :error 59 | end 60 | rescue 61 | _e in RuntimeError -> :error 62 | end 63 | 64 | @doc """ 65 | Same as `Flop.Cursor.decode/1`, but raises an error if the cursor is invalid. 66 | 67 | iex> Flop.Cursor.decode!("g3QAAAABZAACaWRiAAACDg==") 68 | %{id: 526} 69 | """ 70 | @doc since: "0.9.0" 71 | @spec decode!(binary()) :: map() 72 | def decode!(cursor) do 73 | case decode(cursor) do 74 | {:ok, decoded} -> decoded 75 | :error -> raise Flop.InvalidCursorError, cursor: cursor 76 | end 77 | end 78 | 79 | defp safe_binary_to_term(term) do 80 | {:ok, :erlang.binary_to_term(term, [:safe])} 81 | rescue 82 | _e in ArgumentError -> :error 83 | end 84 | 85 | defp sanitize(term) 86 | when is_atom(term) or is_number(term) or is_binary(term) do 87 | term 88 | end 89 | 90 | defp sanitize([]), do: [] 91 | defp sanitize([h | t]), do: [sanitize(h) | sanitize(t)] 92 | 93 | defp sanitize(%{} = term) do 94 | :maps.fold( 95 | fn key, value, acc -> 96 | sanitize(key) 97 | sanitize(value) 98 | acc 99 | end, 100 | term, 101 | term 102 | ) 103 | end 104 | 105 | defp sanitize(term) when is_tuple(term) do 106 | term 107 | |> Tuple.to_list() 108 | |> sanitize() 109 | end 110 | 111 | defp sanitize(_) do 112 | raise "invalid cursor value" 113 | end 114 | 115 | @doc """ 116 | Retrieves the start and end cursors from a query result. 117 | 118 | iex> results = [%{name: "Mary"}, %{name: "Paul"}, %{name: "Peter"}] 119 | iex> order_by = [:name] 120 | iex> 121 | iex> {start_cursor, end_cursor} = 122 | ...> Flop.Cursor.get_cursors(results, order_by) 123 | {"g3QAAAABdwRuYW1lbQAAAARNYXJ5", "g3QAAAABdwRuYW1lbQAAAAVQZXRlcg=="} 124 | iex> 125 | iex> Flop.Cursor.decode(start_cursor) 126 | {:ok, %{name: "Mary"}} 127 | iex> Flop.Cursor.decode(end_cursor) 128 | {:ok, %{name: "Peter"}} 129 | 130 | If the result set is empty, the cursor values will be `nil`. 131 | 132 | iex> Flop.Cursor.get_cursors([], [:id]) 133 | {nil, nil} 134 | 135 | The default function to retrieve the cursor value from the query result is 136 | `Flop.Cursor.get_cursor_from_node/2`, which expects the query result to be a 137 | map or a 2-tuple. You can set the `cursor_value_func` option to use 138 | another function. Flop also comes with `Flop.Cursor.get_cursor_from_edge/2`. 139 | 140 | If the records in the result set are not maps, you can define a custom cursor 141 | value function like this: 142 | 143 | iex> results = [{"Mary", 1936}, {"Paul", 1937}, {"Peter", 1938}] 144 | iex> cursor_func = fn {name, year}, order_fields -> 145 | ...> Enum.into(order_fields, %{}, fn 146 | ...> :name -> {:name, name} 147 | ...> :year -> {:year, year} 148 | ...> end) 149 | ...> end 150 | iex> opts = [cursor_value_func: cursor_func] 151 | iex> 152 | iex> {start_cursor, end_cursor} = 153 | ...> Flop.Cursor.get_cursors(results, [:name, :year], opts) 154 | {"g3QAAAACdwRuYW1lbQAAAARNYXJ5dwR5ZWFyYgAAB5A=", 155 | "g3QAAAACdwRuYW1lbQAAAAVQZXRlcncEeWVhcmIAAAeS"} 156 | iex> 157 | iex> Flop.Cursor.decode(start_cursor) 158 | {:ok, %{name: "Mary", year: 1936}} 159 | iex> Flop.Cursor.decode(end_cursor) 160 | {:ok, %{name: "Peter", year: 1938}} 161 | """ 162 | @doc since: "0.8.0" 163 | @spec get_cursors([any], [atom], [Flop.option()]) :: 164 | {binary(), binary()} | {nil, nil} 165 | def get_cursors(results, order_by, opts \\ []) do 166 | cursor_value_func = cursor_value_func(opts) 167 | 168 | case results do 169 | [] -> 170 | {nil, nil} 171 | 172 | [first | _] -> 173 | { 174 | first |> cursor_value_func.(order_by) |> encode(), 175 | results 176 | |> List.last() 177 | |> cursor_value_func.(order_by) 178 | |> encode() 179 | } 180 | end 181 | end 182 | 183 | @doc """ 184 | Takes a tuple with the node and the edge and the `order_by` field list and 185 | returns the cursor value derived from the edge map. 186 | 187 | If a map is passed instead of a tuple, it retrieves the cursor value from that 188 | map. 189 | 190 | This function can be used for the `:cursor_value_func` option. See also 191 | `Flop.Cursor.get_cursor_from_node/2`. 192 | 193 | iex> record = %{id: 20, name: "George", age: 62} 194 | iex> edge = %{id: 25, relation: "sibling"} 195 | iex> 196 | iex> Flop.Cursor.get_cursor_from_edge({record, edge}, [:id]) 197 | %{id: 25} 198 | iex> Flop.Cursor.get_cursor_from_edge({record, edge}, [:id, :relation]) 199 | %{id: 25, relation: "sibling"} 200 | iex> Flop.Cursor.get_cursor_from_edge(record, [:id]) 201 | %{id: 20} 202 | 203 | If the edge is a struct that derives `Flop.Schema`, join and compound fields 204 | are resolved according to the configuration. 205 | 206 | iex> record = %{id: 25, relation: "sibling"} 207 | iex> edge = %MyApp.Pet{ 208 | ...> name: "George", 209 | ...> owner: %MyApp.Owner{name: "Carl"} 210 | ...> } 211 | iex> 212 | iex> Flop.Cursor.get_cursor_from_edge({record, edge}, [:owner_name]) 213 | %{owner_name: "Carl"} 214 | iex> Flop.Cursor.get_cursor_from_edge(edge, [:owner_name]) 215 | %{owner_name: "Carl"} 216 | 217 | iex> record = %{id: 25, relation: "sibling"} 218 | iex> edge = %MyApp.Pet{ 219 | ...> given_name: "George", 220 | ...> family_name: "Gooney" 221 | ...> } 222 | iex> Flop.Cursor.get_cursor_from_edge({record, edge}, [:full_name]) 223 | %{full_name: "Gooney George"} 224 | iex> Flop.Cursor.get_cursor_from_edge(edge, [:full_name]) 225 | %{full_name: "Gooney George"} 226 | """ 227 | @doc since: "0.11.0" 228 | @spec get_cursor_from_edge({map, map} | map, [atom]) :: map 229 | def get_cursor_from_edge({_, %{} = item}, order_by) do 230 | Enum.into(order_by, %{}, fn field -> 231 | {field, Flop.Schema.get_field(item, field)} 232 | end) 233 | end 234 | 235 | def get_cursor_from_edge(%{} = item, order_by) do 236 | Enum.into(order_by, %{}, fn field -> 237 | {field, Flop.Schema.get_field(item, field)} 238 | end) 239 | end 240 | 241 | @doc """ 242 | Takes a tuple with the node and the edge and the `order_by` field list and 243 | returns the cursor value derived from the node map. 244 | 245 | If a map is passed instead of a tuple, it retrieves the cursor value from that 246 | map. 247 | 248 | This function is used as a default if no `:cursor_value_func` option is 249 | set. See also `Flop.Cursor.get_cursor_from_edge/2`. 250 | 251 | iex> record = %{id: 20, name: "George", age: 62} 252 | iex> edge = %{id: 25, relation: "sibling"} 253 | iex> 254 | iex> Flop.Cursor.get_cursor_from_node({record, edge}, [:id]) 255 | %{id: 20} 256 | iex> Flop.Cursor.get_cursor_from_node({record, edge}, [:id, :name]) 257 | %{id: 20, name: "George"} 258 | iex> Flop.Cursor.get_cursor_from_node(record, [:id]) 259 | %{id: 20} 260 | 261 | If the node is a struct that derives `Flop.Schema`, join and compound fields 262 | are resolved according to the configuration. 263 | 264 | iex> record = %MyApp.Pet{ 265 | ...> name: "George", 266 | ...> owner: %MyApp.Owner{name: "Carl"} 267 | ...> } 268 | iex> edge = %{id: 25, relation: "sibling"} 269 | iex> 270 | iex> Flop.Cursor.get_cursor_from_node({record, edge}, [:owner_name]) 271 | %{owner_name: "Carl"} 272 | iex> Flop.Cursor.get_cursor_from_node(record, [:owner_name]) 273 | %{owner_name: "Carl"} 274 | 275 | iex> record = %MyApp.Pet{ 276 | ...> given_name: "George", 277 | ...> family_name: "Gooney" 278 | ...> } 279 | iex> edge = %{id: 25, relation: "sibling"} 280 | iex> Flop.Cursor.get_cursor_from_node({record, edge}, [:full_name]) 281 | %{full_name: "Gooney George"} 282 | iex> Flop.Cursor.get_cursor_from_node(record, [:full_name]) 283 | %{full_name: "Gooney George"} 284 | """ 285 | @doc since: "0.11.0" 286 | @spec get_cursor_from_node({map, map} | map, [atom]) :: map 287 | def get_cursor_from_node({%{} = item, _}, order_by) do 288 | Enum.into(order_by, %{}, fn field -> 289 | {field, Flop.Schema.get_field(item, field)} 290 | end) 291 | end 292 | 293 | def get_cursor_from_node(%{} = item, order_by) do 294 | Enum.into(order_by, %{}, fn field -> 295 | {field, Flop.Schema.get_field(item, field)} 296 | end) 297 | end 298 | 299 | @doc false 300 | def cursor_value_func(opts \\ []) do 301 | opts[:cursor_value_func] || 302 | Application.get_env(:flop, :cursor_value_func) || 303 | (&get_cursor_from_node/2) 304 | end 305 | end 306 | -------------------------------------------------------------------------------- /lib/flop/custom_types/any.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.CustomTypes.Any do 2 | @moduledoc false 3 | use Ecto.Type 4 | 5 | # Since we only need this custom type to be able to work with Ecto embedded 6 | # schemas and Ecto.Changeset validation, the only relevant function here is 7 | # `cast/1`, which only returns the value as is. The other functions are only 8 | # here for the sake of the Ecto.Type behaviour. We don't actually dump/load 9 | # this type into/from a database, and you should not misuse this type for 10 | # that. 11 | 12 | def cast(value), do: {:ok, value} 13 | 14 | # coveralls-ignore-start 15 | # This type is only used for casting values. The load and dump functions will 16 | # never be called. 17 | def type, do: :string 18 | def load(_), do: :error 19 | def dump(_), do: :error 20 | # coveralls-ignore-stop 21 | end 22 | -------------------------------------------------------------------------------- /lib/flop/custom_types/existing_atom.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.CustomTypes.ExistingAtom do 2 | @moduledoc false 3 | use Ecto.Type 4 | 5 | def cast(a) when is_binary(a) do 6 | {:ok, String.to_existing_atom(a)} 7 | rescue 8 | ArgumentError -> :error 9 | end 10 | 11 | def cast(a) when is_atom(a) do 12 | {:ok, a} 13 | end 14 | 15 | def cast(_), do: :error 16 | 17 | # coveralls-ignore-start 18 | # This type is only used for casting values. The load and dump functions will 19 | # never be called. 20 | def type, do: :string 21 | def load(_), do: :error 22 | def dump(_), do: :error 23 | # coveralls-ignore-stop 24 | end 25 | -------------------------------------------------------------------------------- /lib/flop/custom_types/like.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.CustomTypes.Like do 2 | @moduledoc false 3 | use Ecto.Type 4 | 5 | # Custom ecto type for casting values for (i)like_and/or operators. Attempts 6 | # to cast the value as either a string or a list of strings. 7 | 8 | def cast(value) do 9 | case Ecto.Type.cast(:string, value) do 10 | {:ok, cast_value} -> 11 | {:ok, cast_value} 12 | 13 | _ -> 14 | case Ecto.Type.cast({:array, :string}, value) do 15 | {:ok, cast_value} -> {:ok, cast_value} 16 | _ -> :error 17 | end 18 | end 19 | end 20 | 21 | # coveralls-ignore-start 22 | # This type is only used for casting values. The load and dump functions will 23 | # never be called. 24 | def type, do: :string 25 | def load(_), do: :error 26 | def dump(_), do: :error 27 | # coveralls-ignore-stop 28 | end 29 | -------------------------------------------------------------------------------- /lib/flop/errors.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.InvalidConfigError do 2 | @moduledoc false 3 | 4 | defexception [:caller, :key, :message, :value, :keys_path, :module] 5 | 6 | def message(%{} = err) do 7 | """ 8 | invalid Flop configuration 9 | 10 | #{hint(err.module, module_name(err.caller))} 11 | 12 | #{err.message} 13 | """ 14 | end 15 | 16 | @doc false 17 | def from_nimble(%NimbleOptions.ValidationError{} = error, opts) do 18 | %__MODULE__{ 19 | caller: Keyword.fetch!(opts, :caller), 20 | key: error.key, 21 | keys_path: error.keys_path, 22 | message: Exception.message(error), 23 | module: Keyword.fetch!(opts, :module), 24 | value: error.value 25 | } 26 | end 27 | 28 | defp hint(Flop, caller_name) do 29 | "An invalid option was passed to `use Flop` in the module `#{caller_name}`." 30 | end 31 | 32 | defp hint(Flop.Schema, caller_name) do 33 | "An invalid option was passed to `@derive Flop.Schema` in the module `#{caller_name}`." 34 | end 35 | 36 | defp module_name(module) do 37 | module 38 | |> Module.split() 39 | |> Enum.reject(&(&1 == Elixir)) 40 | |> Enum.join(".") 41 | end 42 | end 43 | 44 | defmodule Flop.InvalidCursorError do 45 | @moduledoc """ 46 | Raised when an invalid cursor is passed to the decode function. 47 | 48 | A cursor might be invalid if it is malformed, not in the expected format, or 49 | contains unexpected data types. 50 | """ 51 | 52 | defexception [:cursor] 53 | 54 | def message(%{cursor: cursor}) do 55 | """ 56 | invalid cursor 57 | 58 | Attempted to decode an invalid pagination cursor: 59 | 60 | #{inspect(cursor)} 61 | """ 62 | end 63 | end 64 | 65 | defmodule Flop.InvalidParamsError do 66 | @moduledoc """ 67 | Raised when parameter validation fails. 68 | 69 | This can occur under a number of circumstances, such as: 70 | 71 | - Pagination parameters are improperly formatted or invalid. 72 | - Filter values are incompatible with the respective field's type or specified 73 | operator. 74 | - Filters are applied on fields that have not been configured as filterable. 75 | - Ordering parameters are applied on fields that have not been configured as 76 | sortable. 77 | 78 | """ 79 | 80 | @type t :: %__MODULE__{ 81 | errors: keyword, 82 | params: map 83 | } 84 | 85 | defexception [:errors, :params] 86 | 87 | def message(%{errors: errors, params: params}) do 88 | """ 89 | invalid Flop parameters 90 | 91 | The parameters provided to Flop: 92 | 93 | #{format(params)} 94 | 95 | Resulted in the following validation errors: 96 | 97 | #{format(errors)} 98 | """ 99 | end 100 | 101 | defp format(s) do 102 | s 103 | |> inspect(pretty: true) 104 | |> String.split("\n") 105 | |> Enum.map_join("\n", fn s -> " " <> s end) 106 | end 107 | end 108 | 109 | defmodule Flop.InvalidDirectionsError do 110 | @moduledoc """ 111 | An error that is raised when invalid directions are passed. 112 | """ 113 | 114 | defexception [:directions] 115 | 116 | def message(%{directions: directions}) do 117 | """ 118 | invalid `:directions` option 119 | 120 | Expected: A 2-tuple of order directions, e.g. `{:asc, :desc}`. 121 | 122 | Received: #{inspect(directions)}" 123 | 124 | The valid order directions are: 125 | 126 | - :asc 127 | - :asc_nulls_first 128 | - :asc_nulls_last 129 | - :desc 130 | - :desc_nulls_first 131 | - :desc_nulls_last 132 | """ 133 | end 134 | end 135 | 136 | defmodule Flop.InvalidDefaultOrderError do 137 | defexception [:sortable_fields, :unsortable_fields] 138 | 139 | def exception(args) do 140 | %__MODULE__{ 141 | sortable_fields: Enum.sort(args[:sortable_fields]), 142 | unsortable_fields: Enum.sort(args[:unsortable_fields]) 143 | } 144 | end 145 | 146 | def message(%{ 147 | sortable_fields: sortable_fields, 148 | unsortable_fields: unsortable_fields 149 | }) do 150 | """ 151 | invalid default order 152 | 153 | The following fields are not sortable, but were used for the default order: 154 | 155 | #{inspect(unsortable_fields, pretty: true, width: 76)} 156 | 157 | The sortable fields in your schema are: 158 | 159 | #{inspect(sortable_fields, pretty: true, width: 76)} 160 | """ 161 | end 162 | end 163 | 164 | defmodule Flop.InvalidDefaultPaginationTypeError do 165 | defexception [:default_pagination_type, :pagination_types] 166 | 167 | def message(%{ 168 | default_pagination_type: default_pagination_type, 169 | pagination_types: pagination_types 170 | }) do 171 | """ 172 | default pagination type not allowed 173 | 174 | The default pagination type (#{inspect(default_pagination_type)}) set on the 175 | schema is not included in the allowed pagination types. 176 | 177 | You derived your schema configuration similar to: 178 | 179 | @derive { 180 | Flop.Schema, 181 | # ... 182 | default_pagination_type: #{inspect(default_pagination_type)} 183 | pagination_types: #{inspect(pagination_types)} 184 | } 185 | 186 | Here are a few ways to address this issue: 187 | 188 | - add the default pagination type to the `pagination_types` 189 | option of the schema 190 | - change the `default_pagination_type` option to one of the 191 | types set with the `pagination_types` option 192 | - remove the `default_pagination_type` option from the schema 193 | - remove the `pagination_types` option from the schema 194 | """ 195 | end 196 | end 197 | 198 | defmodule Flop.NoRepoError do 199 | defexception [:function_name] 200 | 201 | def message(%{function_name: function_name}) do 202 | """ 203 | no Ecto repo configured 204 | 205 | You attempted to call `Flop.#{function_name}/3` (or its equivalent in a Flop 206 | backend module), but no Ecto repo was specified. 207 | 208 | Specify the repo in one of the following ways. 209 | 210 | Explicitly pass the repo to the function: 211 | 212 | Flop.#{function_name}(MyApp.Item, %Flop{}, repo: MyApp.Repo) 213 | 214 | Set a global default repo in your config: 215 | 216 | config :flop, repo: MyApp.Repo 217 | 218 | Define a backend module and pass the repo as an option: 219 | 220 | defmodule MyApp.Flop do 221 | use Flop, repo: MyApp.Repo 222 | end 223 | """ 224 | end 225 | end 226 | 227 | defmodule Flop.UnknownFieldError do 228 | defexception [:known_fields, :unknown_fields, :option] 229 | 230 | def exception(args) do 231 | %__MODULE__{ 232 | known_fields: Enum.sort(args[:known_fields]), 233 | unknown_fields: Enum.sort(args[:unknown_fields]), 234 | option: args[:option] 235 | } 236 | end 237 | 238 | def message(%{ 239 | known_fields: known_fields, 240 | unknown_fields: unknown_fields, 241 | option: option 242 | }) do 243 | """ 244 | unknown #{option} field(s) 245 | 246 | There are unknown #{option} fields in your schema configuration: 247 | 248 | #{inspect(unknown_fields, pretty: true, width: 76)} 249 | 250 | The known fields in your schema are: 251 | 252 | #{inspect(known_fields, pretty: true, width: 76)} 253 | """ 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/flop/field_info.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.FieldInfo do 2 | @moduledoc """ 3 | Defines a struct that holds the information about a schema field. 4 | 5 | This struct is mainly for use by adapters. 6 | """ 7 | 8 | @typedoc """ 9 | Contains the information about a schema field. 10 | 11 | - `ecto_type` - The Ecto type of the field. This value is used to determine 12 | which operators can be used on the field and to determine how to cast 13 | filter values. 14 | - `operators` - The allowed filter operators on this field. If `nil`, the 15 | allowed operators are determined based on the `ecto_type`. If set, the 16 | given operator list is used instead. 17 | - `extra` - A map with additional configuration for the field. The contents 18 | depend on the specific adapter. 19 | """ 20 | @type t :: %__MODULE__{ 21 | ecto_type: Flop.Schema.ecto_type() | nil, 22 | operators: [Flop.Filter.op()] | nil, 23 | extra: map 24 | } 25 | 26 | defstruct [:ecto_type, :extra, :operators] 27 | end 28 | -------------------------------------------------------------------------------- /lib/flop/meta.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Meta do 2 | @moduledoc """ 3 | Defines a struct for holding meta information of a query result. 4 | """ 5 | 6 | @typedoc """ 7 | Meta information for a query result. 8 | 9 | - `:flop` - The `Flop` struct used in the query. 10 | - `:schema` - The schema module passed as `for` option. 11 | - `:backend` - The backend module if the query was made using a module with 12 | `use Flop`. 13 | - `:current_offset` - The `:offset` value used in the query when using 14 | offset-based pagination or a derived value when using page-based pagination. 15 | Always `nil` when using cursor-based pagination. 16 | - `:current_page` - The `:page` value used in the query when using page-based 17 | pagination or a derived value when using offset-based pagination. Note that 18 | the value will be rounded if the offset lies between pages. Always `nil` 19 | when using cursor-based pagination. 20 | - `:errors` - Any validation errors that occurred. The format is the same as 21 | the result of `Ecto.Changeset.traverse_errors(changeset, & &1)`. 22 | - `:previous_offset`, `:next_offset`, `:previous_page`, `:next_page` - Values 23 | based on `:current_page` and `:current_offset`/`page_size`. Always `nil` 24 | when using cursor-based pagination. 25 | - `:start_cursor`, `:end_cursor` - The cursors of the first and last record 26 | in the result set. Only set when using cursor-based pagination with 27 | `:first`/`:after` or `:last`/`:before`. 28 | - `:has_previous_page?`, `:has_next_page?` - Set in all pagination types. 29 | Note that `:has_previous_page?` is always `true` when using cursor-based 30 | pagination with `:first` and `:after` is set; likewise, `:has_next_page?` is 31 | always `true` when using cursor-based pagination with `:before` and `:last` 32 | is set. 33 | - `:page_size` - The page size or limit of the query. Set to the `:first` 34 | or `:last` parameter when using cursor-based pagination. 35 | - `:params` - The original, unvalidated params that were passed. Only set 36 | if validation errors occurred. 37 | - `:total_count` - The total count of records for the given query. Always 38 | `nil` when using cursor-based pagination. 39 | - `:total_pages` - The total page count based on the total record count and 40 | the page size. Always `nil` when using cursor-based pagination. 41 | """ 42 | @type t :: %__MODULE__{ 43 | backend: module | nil, 44 | current_offset: non_neg_integer | nil, 45 | current_page: pos_integer | nil, 46 | end_cursor: String.t() | nil, 47 | errors: [{atom, term}], 48 | flop: Flop.t(), 49 | has_next_page?: boolean, 50 | has_previous_page?: boolean, 51 | next_offset: non_neg_integer | nil, 52 | next_page: pos_integer | nil, 53 | opts: keyword, 54 | page_size: pos_integer | nil, 55 | params: %{optional(String.t()) => term()}, 56 | previous_offset: non_neg_integer | nil, 57 | previous_page: pos_integer | nil, 58 | schema: module | nil, 59 | start_cursor: String.t() | nil, 60 | total_count: non_neg_integer | nil, 61 | total_pages: non_neg_integer | nil 62 | } 63 | 64 | defstruct [ 65 | :backend, 66 | :current_offset, 67 | :current_page, 68 | :end_cursor, 69 | :next_offset, 70 | :next_page, 71 | :page_size, 72 | :previous_offset, 73 | :previous_page, 74 | :schema, 75 | :start_cursor, 76 | :total_count, 77 | :total_pages, 78 | errors: [], 79 | flop: %Flop{}, 80 | has_next_page?: false, 81 | has_previous_page?: false, 82 | opts: [], 83 | params: %{} 84 | ] 85 | 86 | @doc """ 87 | Returns a `Flop.Meta` struct with the given params, errors, and opts. 88 | 89 | This function is used internally to build error responses in case of 90 | validation errors. You can use it to add additional parameter validation. 91 | 92 | The given parameters parameters are normalized before being added to the 93 | struct. The errors have to be passed as a keyword list (same format as the 94 | result of `Ecto.Changeset.traverse_errors(changeset, & &1)`). 95 | 96 | ## Example 97 | 98 | In this list function, the given parameters are first validated with 99 | `Flop.validate/2`, which returns a `Flop` struct on success. You can then pass 100 | that struct to a custom validation function, along with the original 101 | parameters and the opts, which both are needed to call this function. 102 | 103 | def list_pets(%{} = params) do 104 | opts = [for: Pet] 105 | 106 | with {:ok, %Flop{} = flop} <- Flop.validate(params, opts), 107 | {:ok, %Flop{} = flop} <- custom_validation(flop, params, opts) do 108 | Flop.run(Pet, flop, for: Pet) 109 | end 110 | end 111 | 112 | In your custom validation function, you can retrieve and manipulate the filter 113 | values in the `Flop` struct with the functions defined in the `Flop.Filter` 114 | module. 115 | 116 | defp custom_validation(%Flop{} = flop, %{} = params, opts) do 117 | %{value: date} = Flop.Filter.get(flop.filters, :date) 118 | 119 | if date && Date.compare(date, Date.utc_today()) != :lt do 120 | errors = [filters: [{"date must be in the past", []}]] 121 | {:error, Flop.Meta.with_errors(params, errors, opts)} 122 | else 123 | {:ok, flop} 124 | end 125 | end 126 | 127 | Note that in this example, `Flop.Filter.get/2` is used, which only returns the 128 | first filter in the given filter list. Depending on how you use Flop, the 129 | filter list may have multiple entries for the same field. In that case, you 130 | may need to either use `Flop.Filter.get_all/2` and apply the validation on all 131 | returned filters, or reduce over the whole filter list. The latter has the 132 | advantage that you can attach the error to the actual list entry. 133 | 134 | def custom_validation(%Flop{} = flop, %{} = params, opts) do 135 | filter_errors = 136 | flop.filters 137 | |> Enum.reduce([], &validate_filter/2) 138 | |> Enum.reverse() 139 | 140 | if Enum.any?(filter_errors, &(&1 != [])) do 141 | errors = [filters: filter_errors] 142 | {:error, Flop.Meta.with_errors(params, errors, opts)} 143 | else 144 | {:ok, flop} 145 | end 146 | end 147 | 148 | defp validate_filter(%Flop.Filter{field: :date, value: date}, acc) 149 | when is_binary(date) do 150 | date = Date.from_iso8601!(date) 151 | 152 | if Date.compare(date, Date.utc_today()) != :lt, 153 | do: [[value: [{"date must be in the past", []}]] | acc], 154 | else: [[] | acc] 155 | end 156 | 157 | defp validate_filter(%Flop.Filter{}, acc), do: [[] | acc] 158 | """ 159 | @doc since: "0.19.0" 160 | @spec with_errors(map, keyword, keyword) :: t() 161 | def with_errors(%{} = params, errors, opts) 162 | when is_list(errors) and is_list(opts) do 163 | %__MODULE__{ 164 | backend: opts[:backend], 165 | errors: errors, 166 | opts: opts, 167 | params: convert_params(params), 168 | schema: opts[:for] 169 | } 170 | end 171 | 172 | defp convert_params(params) do 173 | params 174 | |> map_to_string_keys() 175 | |> filters_to_list() 176 | end 177 | 178 | defp filters_to_list(%{"filters" => filters} = params) when is_map(filters) do 179 | filters = 180 | filters 181 | |> Enum.map(fn {index, filter} -> {String.to_integer(index), filter} end) 182 | |> Enum.sort_by(fn {index, _} -> index end) 183 | |> Enum.map(fn {_, filter} -> filter end) 184 | 185 | Map.put(params, "filters", filters) 186 | end 187 | 188 | defp filters_to_list(params), do: params 189 | 190 | defp map_to_string_keys(value) when is_struct(value), do: value 191 | 192 | defp map_to_string_keys(%{} = params) do 193 | Enum.into(params, %{}, fn 194 | {key, value} when is_atom(key) -> 195 | {Atom.to_string(key), map_to_string_keys(value)} 196 | 197 | {key, value} when is_binary(key) -> 198 | {key, map_to_string_keys(value)} 199 | end) 200 | end 201 | 202 | defp map_to_string_keys(values) when is_list(values), 203 | do: Enum.map(values, &map_to_string_keys/1) 204 | 205 | defp map_to_string_keys(value), do: value 206 | end 207 | -------------------------------------------------------------------------------- /lib/flop/misc.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Misc do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Adds wildcard at the beginning and end of a string for partial matches. 6 | 7 | Escapes `%` and `_` within the given string. 8 | 9 | iex> add_wildcard("borscht") 10 | "%borscht%" 11 | 12 | iex> add_wildcard("bor%t") 13 | "%bor\\\\%t%" 14 | 15 | iex> add_wildcard("bor_cht") 16 | "%bor\\\\_cht%" 17 | """ 18 | def add_wildcard(value, escape_char \\ "\\") when is_binary(value) do 19 | "%" <> 20 | String.replace(value, ["\\", "%", "_"], &"#{escape_char}#{&1}") <> 21 | "%" 22 | end 23 | 24 | @doc """ 25 | Splits a search text into tokens. 26 | 27 | iex> split_search_text("borscht batchoy gumbo") 28 | ["%borscht%", "%batchoy%", "%gumbo%"] 29 | """ 30 | def split_search_text(s), do: s |> String.split() |> Enum.map(&add_wildcard/1) 31 | end 32 | -------------------------------------------------------------------------------- /lib/flop/nimble_schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.NimbleSchemas do 2 | @moduledoc false 3 | 4 | @backend_option [ 5 | adapter: [type: :atom, default: Flop.Adapter.Ecto], 6 | adapter_opts: [ 7 | type: :keyword_list, 8 | default: [] 9 | ], 10 | cursor_value_func: [type: {:fun, 2}], 11 | default_limit: [type: :integer, default: 50], 12 | max_limit: [type: :integer, default: 1000], 13 | default_pagination_type: [ 14 | type: {:in, [:offset, :page, :first, :last]}, 15 | default: :offset 16 | ], 17 | filtering: [ 18 | type: :boolean, 19 | default: true 20 | ], 21 | ordering: [ 22 | type: :boolean, 23 | default: true 24 | ], 25 | pagination: [ 26 | type: :boolean, 27 | default: true 28 | ], 29 | pagination_types: [ 30 | type: {:list, {:in, [:offset, :page, :first, :last]}}, 31 | default: [:offset, :page, :first, :last] 32 | ], 33 | repo: [], 34 | query_opts: [type: :keyword_list, default: []] 35 | ] 36 | 37 | @schema_option [ 38 | adapter: [type: :atom, default: Flop.Adapter.Ecto], 39 | adapter_opts: [ 40 | type: :keyword_list, 41 | default: [] 42 | ], 43 | filterable: [type: {:list, :atom}, required: true], 44 | sortable: [type: {:list, :atom}, required: true], 45 | default_order: [ 46 | type: :map, 47 | keys: [ 48 | order_by: [type: {:list, :atom}], 49 | order_directions: [ 50 | type: 51 | {:list, 52 | {:in, 53 | [ 54 | :asc, 55 | :asc_nulls_first, 56 | :asc_nulls_last, 57 | :desc, 58 | :desc_nulls_first, 59 | :desc_nulls_last 60 | ]}} 61 | ] 62 | ] 63 | ], 64 | default_limit: [type: :integer], 65 | max_limit: [type: :integer], 66 | pagination_types: [ 67 | type: {:list, {:in, [:offset, :page, :first, :last]}} 68 | ], 69 | default_pagination_type: [ 70 | type: {:in, [:offset, :page, :first, :last]} 71 | ], 72 | join_fields: [ 73 | type: :keyword_list, 74 | default: [], 75 | keys: [ 76 | *: [ 77 | type: :keyword_list, 78 | keys: [ 79 | binding: [type: :atom, required: true], 80 | field: [type: :atom, required: true], 81 | ecto_type: [type: :any], 82 | path: [type: {:list, :atom}] 83 | ] 84 | ] 85 | ] 86 | ], 87 | compound_fields: [ 88 | type: :keyword_list, 89 | default: [], 90 | keys: [ 91 | *: [ 92 | type: {:list, :atom} 93 | ] 94 | ] 95 | ], 96 | custom_fields: [ 97 | type: :keyword_list, 98 | default: [], 99 | keys: [ 100 | *: [ 101 | type: :keyword_list, 102 | keys: [ 103 | filter: [ 104 | type: {:tuple, [:atom, :atom, :keyword_list]}, 105 | required: true 106 | ], 107 | ecto_type: [type: :any], 108 | bindings: [type: {:list, :atom}], 109 | operators: [type: {:list, :atom}] 110 | ] 111 | ] 112 | ] 113 | ], 114 | alias_fields: [ 115 | type: {:list, :atom}, 116 | default: [] 117 | ] 118 | ] 119 | 120 | @schema_option_schema @schema_option 121 | def schema_option_schema, do: @schema_option_schema 122 | 123 | @backend_option NimbleOptions.new!(@backend_option) 124 | @schema_option NimbleOptions.new!(@schema_option) 125 | 126 | def validate!(opts, schema_id, module, caller) when is_atom(schema_id) do 127 | validate!(opts, schema(schema_id), module, caller) 128 | end 129 | 130 | def validate!(opts, %NimbleOptions{} = schema, module, caller) do 131 | case NimbleOptions.validate(opts, schema) do 132 | {:ok, opts} -> 133 | opts 134 | 135 | {:error, err} -> 136 | raise Flop.InvalidConfigError.from_nimble(err, 137 | caller: caller, 138 | module: module 139 | ) 140 | end 141 | end 142 | 143 | defp schema(:backend_option), do: @backend_option 144 | defp schema(:schema_option), do: @schema_option 145 | end 146 | -------------------------------------------------------------------------------- /lib/flop/relay.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Relay do 2 | @moduledoc """ 3 | Helpers to turn query results into Relay formats. 4 | """ 5 | 6 | alias Flop.Cursor 7 | alias Flop.Meta 8 | 9 | @type connection :: %{ 10 | edges: [edge()], 11 | page_info: page_info() 12 | } 13 | 14 | @type edge :: %{ 15 | cursor: binary, 16 | node: any 17 | } 18 | 19 | @type page_info :: %{ 20 | has_previous_page: boolean, 21 | has_next_page: boolean, 22 | start_cursor: binary, 23 | end_cursor: binary 24 | } 25 | 26 | @doc """ 27 | Takes the query results returned by `Flop.run/3`, `Flop.validate_and_run/3` 28 | or `Flop.validate_and_run!/3` and turns them into the Relay connection 29 | format. 30 | 31 | ## Example 32 | 33 | iex> flop = %Flop{order_by: [:name]} 34 | iex> meta = %Flop.Meta{flop: flop, start_cursor: "a", end_cursor: "b"} 35 | iex> result = {[%MyApp.Fruit{name: "Apple", family: "Rosaceae"}], meta} 36 | iex> Flop.Relay.connection_from_result(result) 37 | %{ 38 | edges: [ 39 | %{ 40 | cursor: "g3QAAAABdwRuYW1lbQAAAAVBcHBsZQ==", 41 | node: %MyApp.Fruit{family: "Rosaceae", id: nil, name: "Apple"} 42 | } 43 | ], 44 | page_info: %{ 45 | end_cursor: "b", 46 | has_next_page: false, 47 | has_previous_page: false, 48 | start_cursor: "a" 49 | } 50 | } 51 | 52 | See `Flop.Relay.edges_from_result/2` for an example of adding additional 53 | fields to the edge. 54 | 55 | ## Options 56 | 57 | - `:cursor_value_func`: 2-arity function that takes an item from the query 58 | result and the `order_by` fields and returns the unencoded cursor value. 59 | """ 60 | @doc since: "0.8.0" 61 | @spec connection_from_result({[any], Meta.t()}, [Flop.option()]) :: 62 | connection() 63 | def connection_from_result({items, meta}, opts \\ []) when is_list(items) do 64 | %{ 65 | edges: edges_from_result({items, meta}, opts), 66 | page_info: page_info_from_meta(meta) 67 | } 68 | end 69 | 70 | @doc """ 71 | Takes a `Flop.Meta` struct and returns a map with the Relay page info. 72 | 73 | ## Example 74 | 75 | iex> Flop.Relay.page_info_from_meta(%Flop.Meta{ 76 | ...> has_previous_page?: true, 77 | ...> has_next_page?: true, 78 | ...> start_cursor: "a", 79 | ...> end_cursor: "b" 80 | ...> }) 81 | %{ 82 | has_previous_page: true, 83 | has_next_page: true, 84 | start_cursor: "a", 85 | end_cursor: "b" 86 | } 87 | """ 88 | @doc since: "0.8.0" 89 | @spec page_info_from_meta(Meta.t()) :: page_info() 90 | def page_info_from_meta(%Meta{} = meta) do 91 | %{ 92 | has_previous_page: meta.has_previous_page? || false, 93 | has_next_page: meta.has_next_page? || false, 94 | start_cursor: meta.start_cursor, 95 | end_cursor: meta.end_cursor 96 | } 97 | end 98 | 99 | @doc """ 100 | Turns a list of query results into Relay edges. 101 | 102 | ## Simple queries 103 | 104 | If your query returns a list of maps or structs, the function will return 105 | the a list of edges with `:cursor` and `:node` as only fields. 106 | 107 | iex> flop = %Flop{order_by: [:name]} 108 | iex> meta = %Flop.Meta{flop: flop} 109 | iex> result = {[%MyApp.Fruit{name: "Apple", family: "Rosaceae"}], meta} 110 | iex> Flop.Relay.edges_from_result(result) 111 | [ 112 | %{ 113 | cursor: "g3QAAAABdwRuYW1lbQAAAAVBcHBsZQ==", 114 | node: %MyApp.Fruit{name: "Apple", family: "Rosaceae"} 115 | } 116 | ] 117 | 118 | ## Supplying additional edge information 119 | 120 | If the query result is a list of 2-tuples, this is interpreted as a tuple 121 | of the node information and the edge information. For example, if you have a 122 | query like this: 123 | 124 | Group 125 | |> where([g], g.id == ^group_id) 126 | |> join(:left, [g], m in assoc(g, :members)) 127 | |> select([g, m], {m, map(m, [:role])}) 128 | 129 | Then your query result looks something like: 130 | 131 | [{%Member{id: 242, name: "Carl"}, %{role: :owner}}] 132 | 133 | In this case, the members are the nodes, and the maps with the roles is seen 134 | as edge information. 135 | 136 | [ 137 | %{ 138 | cursor: "AE98RNSTNGN", 139 | node: %Member{id: 242, name: "Carl"}, 140 | role: :owner 141 | } 142 | ] 143 | 144 | Note that in this case, the whole tuple will be passed to the cursor value 145 | function, so that the cursor can be based on both node and edge fields. 146 | 147 | Here's an example with fruit that overrides the cursor value function: 148 | 149 | iex> flop = %Flop{order_by: [:name]} 150 | iex> meta = %Flop.Meta{flop: flop} 151 | iex> items = [{%MyApp.Fruit{name: "Apple"}, %{preparation: :grated}}] 152 | iex> func = fn {fruit, _edge}, order_by -> Map.take(fruit, order_by) end 153 | iex> Flop.Relay.edges_from_result( 154 | ...> {items, meta}, 155 | ...> cursor_value_func: func 156 | ...> ) 157 | [ 158 | %{ 159 | cursor: "g3QAAAABdwRuYW1lbQAAAAVBcHBsZQ==", 160 | node: %MyApp.Fruit{name: "Apple"}, 161 | preparation: :grated 162 | } 163 | ] 164 | 165 | ## Options 166 | 167 | - `:cursor_value_func`: 2-arity function that takes an item from the query 168 | result and the `order_by` fields and returns the unencoded cursor value. 169 | """ 170 | @doc since: "0.8.0" 171 | @spec edges_from_result({[{any, any}] | [any], Meta.t()}, [Flop.option()]) :: 172 | [edge()] 173 | def edges_from_result( 174 | {items, %Meta{flop: %Flop{order_by: order_by}}}, 175 | opts \\ [] 176 | ) do 177 | cursor_value_func = Cursor.cursor_value_func(opts) 178 | Enum.map(items, &build_edge(&1, order_by, cursor_value_func)) 179 | end 180 | 181 | defp build_edge({node, nil}, order_by, cursor_value_func) do 182 | build_edge({node, %{}}, order_by, cursor_value_func) 183 | end 184 | 185 | defp build_edge({node, edge_info} = item, order_by, cursor_value_func) do 186 | edge_info 187 | |> Map.put(:cursor, get_cursor(item, order_by, cursor_value_func)) 188 | |> Map.put(:node, node) 189 | end 190 | 191 | defp build_edge(node, order_by, cursor_value_func) do 192 | %{ 193 | cursor: get_cursor(node, order_by, cursor_value_func), 194 | node: node 195 | } 196 | end 197 | 198 | defp get_cursor(node, order_by, cursor_value_func) do 199 | node |> cursor_value_func.(order_by) |> Cursor.encode() 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /lib/flop/validation.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Validation do 2 | @moduledoc false 3 | 4 | alias Ecto.Changeset 5 | alias Flop.Cursor 6 | alias Flop.Filter 7 | 8 | @spec changeset(map, [Flop.option()]) :: Changeset.t() 9 | def changeset(%{} = params, opts) do 10 | replace_invalid_params? = Keyword.get(opts, :replace_invalid_params, false) 11 | 12 | %Flop{} 13 | |> Changeset.cast(params, []) 14 | |> cast_pagination(params, opts) 15 | |> cast_order(params, opts) 16 | |> cast_filters(opts) 17 | |> validate_exclusive( 18 | [ 19 | [:first, :after], 20 | [:last, :before], 21 | [:limit, :offset], 22 | [:page, :page_size] 23 | ], 24 | message: "cannot combine multiple pagination types", 25 | replace_invalid_params: replace_invalid_params? 26 | ) 27 | |> validate_sortable(opts) 28 | |> put_default_order(opts) 29 | |> validate_pagination(opts) 30 | |> maybe_remove_invalid_filters(replace_invalid_params?) 31 | end 32 | 33 | defp maybe_remove_invalid_filters(changeset, true) do 34 | changeset = 35 | Changeset.update_change(changeset, :filters, fn 36 | nil -> 37 | nil 38 | 39 | changesets when is_list(changesets) -> 40 | Enum.filter(changesets, fn %Changeset{valid?: valid?} -> valid? end) 41 | end) 42 | 43 | if changeset.errors == [], do: %{changeset | valid?: true}, else: changeset 44 | end 45 | 46 | defp maybe_remove_invalid_filters(changeset, _), do: changeset 47 | 48 | defp cast_pagination(changeset, params, opts) do 49 | if Flop.get_option(:pagination, opts, true) do 50 | fields = 51 | :pagination_types 52 | |> Flop.get_option(opts, [:first, :last, :offset, :page]) 53 | |> Enum.flat_map(&pagination_params_for_type/1) 54 | 55 | Changeset.cast(changeset, params, fields) 56 | else 57 | changeset 58 | end 59 | end 60 | 61 | defp pagination_params_for_type(:page), do: [:page, :page_size] 62 | defp pagination_params_for_type(:offset), do: [:limit, :offset] 63 | defp pagination_params_for_type(:first), do: [:first, :after] 64 | defp pagination_params_for_type(:last), do: [:last, :before] 65 | 66 | defp cast_order(changeset, params, opts) do 67 | if Flop.get_option(:ordering, opts, true), 68 | do: Changeset.cast(changeset, params, [:order_by, :order_directions]), 69 | else: changeset 70 | end 71 | 72 | defp cast_filters(changeset, opts) do 73 | if Flop.get_option(:filtering, opts, true) do 74 | Changeset.cast_embed(changeset, :filters, 75 | with: &Filter.changeset(&1, &2, opts) 76 | ) 77 | else 78 | changeset 79 | end 80 | end 81 | 82 | # Takes a list of field groups and validates that no fields from multiple 83 | # groups are set. 84 | @spec validate_exclusive(Changeset.t(), [[atom]], keyword) :: Changeset.t() 85 | defp validate_exclusive(changeset, field_groups, opts) do 86 | changes = changeset.changes 87 | 88 | changed_field_groups = 89 | Enum.filter(field_groups, fn fields -> 90 | Enum.any?(fields, &Map.has_key?(changes, &1)) 91 | end) 92 | 93 | if length(changed_field_groups) > 1 do 94 | key = List.first(List.first(changed_field_groups)) 95 | 96 | if opts[:replace_invalid_params] do 97 | field_groups 98 | |> List.flatten() 99 | |> Enum.reduce(changeset, &Changeset.delete_change(&2, &1)) 100 | else 101 | Changeset.add_error( 102 | changeset, 103 | key, 104 | opts[:message] || "invalid combination of field groups" 105 | ) 106 | end 107 | else 108 | changeset 109 | end 110 | end 111 | 112 | defp validate_pagination(changeset, opts) do 113 | pagination_type = get_pagination_type(changeset, opts) 114 | validate_by_pagination_type(changeset, pagination_type, opts) 115 | end 116 | 117 | defp validate_by_pagination_type(changeset, :first, opts) do 118 | replace_invalid_params? = opts[:replace_invalid_params] 119 | 120 | changeset 121 | |> validate_and_maybe_delete( 122 | :first, 123 | &validate_limit/3, 124 | opts, 125 | replace_invalid_params? 126 | ) 127 | |> put_default_limit(:first, opts) 128 | |> Changeset.validate_required([:first, :order_by]) 129 | |> Changeset.validate_length(:order_by, min: 1) 130 | |> validate_and_maybe_delete( 131 | :after, 132 | &validate_cursor/3, 133 | opts, 134 | replace_invalid_params? 135 | ) 136 | end 137 | 138 | defp validate_by_pagination_type(changeset, :last, opts) do 139 | replace_invalid_params? = opts[:replace_invalid_params] 140 | 141 | changeset 142 | |> validate_and_maybe_delete( 143 | :last, 144 | &validate_limit/3, 145 | opts, 146 | replace_invalid_params? 147 | ) 148 | |> put_default_limit(:last, opts) 149 | |> Changeset.validate_required([:last, :order_by]) 150 | |> Changeset.validate_length(:order_by, min: 1) 151 | |> validate_and_maybe_delete( 152 | :before, 153 | &validate_cursor/3, 154 | opts, 155 | replace_invalid_params? 156 | ) 157 | end 158 | 159 | defp validate_by_pagination_type(changeset, :offset, opts) do 160 | replace_invalid_params? = opts[:replace_invalid_params] 161 | 162 | changeset 163 | |> validate_and_maybe_delete( 164 | :limit, 165 | &validate_limit/3, 166 | opts, 167 | replace_invalid_params? 168 | ) 169 | |> put_default_limit(:limit, opts) 170 | |> Changeset.validate_required([:limit]) 171 | |> validate_and_maybe_delete( 172 | :offset, 173 | &validate_offset/3, 174 | opts, 175 | replace_invalid_params? 176 | ) 177 | |> put_default_value(:offset, 0) 178 | end 179 | 180 | defp validate_by_pagination_type(changeset, :page, opts) do 181 | replace_invalid_params? = opts[:replace_invalid_params] 182 | 183 | changeset 184 | |> validate_and_maybe_delete( 185 | :page_size, 186 | &validate_limit/3, 187 | opts, 188 | replace_invalid_params? 189 | ) 190 | |> put_default_limit(:page_size, opts) 191 | |> Changeset.validate_required([:page_size]) 192 | |> validate_and_maybe_delete( 193 | :page, 194 | &validate_page/3, 195 | opts, 196 | replace_invalid_params? 197 | ) 198 | |> put_default_value(:page, 1) 199 | end 200 | 201 | defp validate_by_pagination_type(changeset, pagination_type, opts) 202 | when pagination_type in [nil, false] do 203 | put_default_limit(changeset, :limit, opts) 204 | end 205 | 206 | defp validate_and_maybe_delete( 207 | changeset, 208 | field, 209 | validate_func, 210 | opts, 211 | true 212 | ) do 213 | validated_changeset = validate_func.(changeset, field, opts) 214 | 215 | if validated_changeset.errors[field] do 216 | changeset 217 | |> Changeset.delete_change(field) 218 | |> Map.update!(:errors, &Keyword.delete(&1, field)) 219 | else 220 | validated_changeset 221 | end 222 | end 223 | 224 | defp validate_and_maybe_delete( 225 | changeset, 226 | field, 227 | validate_func, 228 | opts, 229 | _ 230 | ) do 231 | validate_func.(changeset, field, opts) 232 | end 233 | 234 | defp validate_offset(changeset, field, _opts) do 235 | Changeset.validate_number(changeset, field, greater_than_or_equal_to: 0) 236 | end 237 | 238 | defp validate_limit(changeset, field, opts) do 239 | changeset 240 | |> Changeset.validate_number(field, greater_than: 0) 241 | |> validate_within_max_limit(field, opts) 242 | end 243 | 244 | defp validate_page(changeset, field, _opts) do 245 | Changeset.validate_number(changeset, field, greater_than: 0) 246 | end 247 | 248 | defp validate_sortable(changeset, opts) do 249 | sortable_fields = Flop.get_option(:sortable, opts) 250 | 251 | if sortable_fields do 252 | if opts[:replace_invalid_params] do 253 | order_by = get_value(changeset, :order_by) || [] 254 | 255 | order_directions = 256 | get_value(changeset, :order_directions) || [] 257 | 258 | {new_order_by, new_order_directions} = 259 | remove_unsortable_fields(order_by, order_directions, sortable_fields) 260 | 261 | changeset 262 | |> Changeset.put_change(:order_by, new_order_by) 263 | |> Changeset.put_change(:order_directions, new_order_directions) 264 | |> Map.update!( 265 | :errors, 266 | &Keyword.drop(&1, [:order_by, :order_directions]) 267 | ) 268 | else 269 | Changeset.validate_subset(changeset, :order_by, sortable_fields) 270 | end 271 | else 272 | changeset 273 | end 274 | end 275 | 276 | defp remove_unsortable_fields(order_by, order_directions, sortable_fields) do 277 | Enum.reduce( 278 | order_by, 279 | {order_by, order_directions}, 280 | fn field, {acc_order_by, acc_order_directions} -> 281 | if field in sortable_fields do 282 | {acc_order_by, acc_order_directions} 283 | else 284 | index = Enum.find_index(acc_order_by, &(&1 == field)) 285 | 286 | {List.delete_at(acc_order_by, index), 287 | List.delete_at(acc_order_directions, index)} 288 | end 289 | end 290 | ) 291 | end 292 | 293 | defp validate_within_max_limit(changeset, field, opts) do 294 | if max_limit = Flop.get_option(:max_limit, opts) do 295 | Changeset.validate_number(changeset, field, 296 | less_than_or_equal_to: max_limit 297 | ) 298 | else 299 | changeset 300 | end 301 | end 302 | 303 | defp validate_cursor(changeset, field, _opts) do 304 | encoded_cursor = get_value(changeset, field) 305 | order_fields = get_value(changeset, :order_by) 306 | 307 | if encoded_cursor && order_fields do 308 | validate_cursors_match_order_fields( 309 | changeset, 310 | field, 311 | encoded_cursor, 312 | order_fields 313 | ) 314 | else 315 | changeset 316 | end 317 | end 318 | 319 | defp validate_cursors_match_order_fields( 320 | changeset, 321 | field, 322 | encoded_cursor, 323 | order_fields 324 | ) do 325 | case Cursor.decode(encoded_cursor) do 326 | {:ok, cursor_map} -> 327 | if Enum.sort(Map.keys(cursor_map)) == Enum.sort(order_fields), 328 | do: Changeset.put_change(changeset, :decoded_cursor, cursor_map), 329 | else: 330 | Changeset.add_error(changeset, field, "does not match order fields") 331 | 332 | :error -> 333 | Changeset.add_error(changeset, field, "is invalid") 334 | end 335 | end 336 | 337 | defp put_default_limit(changeset, field, opts) do 338 | default_limit = Flop.get_option(:default_limit, opts) 339 | put_default_value(changeset, field, default_limit) 340 | end 341 | 342 | defp put_default_order(changeset, opts) do 343 | order_by = get_value(changeset, :order_by) 344 | 345 | if is_nil(order_by) || order_by == [] do 346 | case Flop.get_option(:default_order, opts) do 347 | %{} = default_order -> 348 | changeset 349 | |> Changeset.put_change(:order_by, default_order[:order_by]) 350 | |> Changeset.put_change( 351 | :order_directions, 352 | default_order[:order_directions] 353 | ) 354 | 355 | _ -> 356 | changeset 357 | |> Changeset.put_change(:order_by, nil) 358 | |> Changeset.put_change(:order_directions, nil) 359 | end 360 | else 361 | changeset 362 | end 363 | end 364 | 365 | defp put_default_value(changeset, _, nil), do: changeset 366 | defp put_default_value(changeset, _, false), do: changeset 367 | 368 | defp put_default_value(%{changes: changes} = changeset, field, default) do 369 | case changes do 370 | %{^field => value} when not is_nil(value) -> changeset 371 | _ -> Changeset.put_change(changeset, field, default) 372 | end 373 | end 374 | 375 | defp get_pagination_type(%Changeset{} = changeset, opts) do 376 | cond do 377 | any_change_or_errors?(changeset, :first, :after) -> :first 378 | any_change_or_errors?(changeset, :last, :before) -> :last 379 | any_change_or_errors?(changeset, :page, :page_size) -> :page 380 | any_change_or_errors?(changeset, :limit, :offset) -> :offset 381 | true -> Flop.get_option(:default_pagination_type, opts) 382 | end 383 | end 384 | 385 | defp any_change_or_errors?( 386 | %Changeset{changes: changes, errors: errors}, 387 | field_a, 388 | field_b 389 | ) do 390 | case changes do 391 | %{^field_a => value} when not is_nil(value) -> 392 | true 393 | 394 | %{^field_b => value} when not is_nil(value) -> 395 | true 396 | 397 | _ -> 398 | Keyword.has_key?(errors, field_a) || Keyword.has_key?(errors, field_b) 399 | end 400 | end 401 | 402 | defp get_value(%Changeset{changes: changes}, field) do 403 | case changes do 404 | %{^field => value} -> value 405 | _ -> nil 406 | end 407 | end 408 | end 409 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/woylie/flop" 5 | @version "0.26.3" 6 | @adapters ~w(postgres sqlite mysql) 7 | 8 | def project do 9 | [ 10 | app: :flop, 11 | version: @version, 12 | elixir: "~> 1.11", 13 | start_permanent: Mix.env() == :prod, 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | deps: deps(), 16 | test_coverage: [tool: ExCoveralls], 17 | test_paths: test_paths(System.get_env("ECTO_ADAPTER")), 18 | preferred_cli_env: [ 19 | "coveralls.detail": :test, 20 | "coveralls.github": :test, 21 | "coveralls.html": :test, 22 | "coveralls.html.all": :test, 23 | "coveralls.json": :test, 24 | "coveralls.json.all": :test, 25 | "coveralls.post": :test, 26 | "ecto.create": :test, 27 | "ecto.drop": :test, 28 | "ecto.migrate": :test, 29 | "ecto.reset": :test, 30 | "test.all": :test, 31 | "test.adapters": :test, 32 | coveralls: :test, 33 | dialyzer: :test 34 | ], 35 | dialyzer: [ 36 | ignore_warnings: ".dialyzer_ignore.exs", 37 | list_unused_filters: true, 38 | plt_file: {:no_warn, ".plts/dialyzer.plt"} 39 | ], 40 | name: "Flop", 41 | source_url: @source_url, 42 | homepage_url: @source_url, 43 | description: description(), 44 | package: package(), 45 | docs: docs(), 46 | aliases: aliases(), 47 | consolidate_protocols: Mix.env() != :test 48 | ] 49 | end 50 | 51 | defp elixirc_paths(:test), do: ["lib", "test/support"] 52 | defp elixirc_paths(_), do: ["lib"] 53 | 54 | # Run "mix help compile.app" to learn about applications. 55 | def application do 56 | [ 57 | extra_applications: [:logger] 58 | ] 59 | end 60 | 61 | # Run "mix help deps" to learn about dependencies. 62 | defp deps do 63 | [ 64 | {:credo, "== 1.7.12", only: [:dev, :test], runtime: false}, 65 | {:dialyxir, "== 1.4.5", only: [:dev, :test], runtime: false}, 66 | {:ecto, "~> 3.11"}, 67 | {:ecto_sql, "== 3.12.1", only: :test}, 68 | {:ex_doc, "== 0.38.2", only: :dev, runtime: false}, 69 | {:ex_machina, "== 2.8.0", only: :test}, 70 | {:makeup_diff, "== 0.1.1", only: :dev, runtime: false}, 71 | {:excoveralls, "== 0.18.5", only: :test}, 72 | {:myxql, "== 0.7.1", only: :test}, 73 | {:nimble_options, "~> 1.0"}, 74 | {:postgrex, "== 0.20.0", only: :test}, 75 | {:ecto_sqlite3, "== 0.19.0", only: :test}, 76 | {:stream_data, "== 1.2.0", only: [:dev, :test]} 77 | ] 78 | end 79 | 80 | defp description do 81 | "Filtering, ordering and pagination with Ecto." 82 | end 83 | 84 | defp package do 85 | [ 86 | licenses: ["MIT"], 87 | links: %{ 88 | "GitHub" => @source_url, 89 | "Changelog" => @source_url <> "/blob/main/CHANGELOG.md", 90 | "Sponsor" => "https://github.com/sponsors/woylie" 91 | }, 92 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG*) 93 | ] 94 | end 95 | 96 | defp docs do 97 | [ 98 | main: "readme", 99 | extra_section: "GUIDES", 100 | extras: [ 101 | "guides/cheatsheets/schema.cheatmd", 102 | "guides/recipes/partial_uuid_filter.md", 103 | "README.md", 104 | "CHANGELOG.md" 105 | ], 106 | source_ref: @version, 107 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"], 108 | groups_for_extras: [ 109 | Recipes: ~r/recipes\/.?/, 110 | Cheatsheets: ~r/cheatsheets\/.?/ 111 | ], 112 | groups_for_docs: [ 113 | "Query Functions": &(&1[:group] == :queries), 114 | "Parameter Manipulation": &(&1[:group] == :parameters), 115 | Miscellaneous: &(&1[:group] == :miscellaneous) 116 | ] 117 | ] 118 | end 119 | 120 | defp aliases do 121 | [ 122 | "test.all": ["test", "test.adapters"], 123 | "test.mysql": &test_adapters(["mysql"], &1), 124 | "test.postgres": &test_adapters(["postgres"], &1), 125 | "test.sqlite": &test_adapters(["sqlite"], &1), 126 | "test.adapters": &test_adapters/1, 127 | "coveralls.html.all": [ 128 | "test.adapters --cover", 129 | "coveralls.html --import-cover cover" 130 | ], 131 | "coveralls.json.all": [ 132 | # only run postgres and base tests for coverage until sqlite tests are 133 | # fixed 134 | fn _ -> test_adapters(["postgres"], ["--cover"]) end, 135 | "coveralls.json --import-cover cover" 136 | ] 137 | ] 138 | end 139 | 140 | defp test_paths(adapter) when adapter in @adapters, 141 | do: ["test/adapters/ecto/#{adapter}"] 142 | 143 | defp test_paths(nil), do: ["test/base"] 144 | 145 | defp test_paths(adapter) do 146 | raise """ 147 | unknown Ecto adapter 148 | 149 | Expected ECTO_ADAPTER to be one of: #{inspect(@adapters)} 150 | 151 | Got: #{inspect(adapter)} 152 | """ 153 | end 154 | 155 | defp test_adapters(adapters \\ @adapters, args) do 156 | for adapter <- adapters do 157 | IO.puts("==> Running tests for ECTO_ADAPTER=#{adapter} mix test") 158 | 159 | {_, res} = 160 | System.cmd( 161 | "mix", 162 | ["test", ansi_option(), "--export-coverage=#{adapter}" | args], 163 | into: IO.binstream(:stdio, :line), 164 | env: [{"ECTO_ADAPTER", adapter}] 165 | ) 166 | 167 | if res > 0 do 168 | System.at_exit(fn _ -> exit({:shutdown, 1}) end) 169 | end 170 | end 171 | end 172 | 173 | defp ansi_option do 174 | if IO.ANSI.enabled?(), do: "--color", else: "--no-color" 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, 4 | "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"}, 5 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 6 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 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 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.19.0", "00030bbaba150369ff3754bbc0d2c28858e8f528ae406bf6997d1772d3a03203", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "297b16750fe229f3056fe32afd3247de308094e8b0298aef0d73a8493ce97c81"}, 12 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 13 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 14 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [: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", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 15 | "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, 16 | "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"}, 17 | "exqlite": {:hex, :exqlite, "0.30.1", "a85ed253ab7304c3733a74d3bc62b68afb0c7245ce30416aa6f9d0cfece0e58f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "15714871147d8d6c12be034013d351ce670e02c09b7f49accabb23e9290d80a0"}, 18 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 19 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 20 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 21 | "makeup_diff": {:hex, :makeup_diff, "0.1.1", "01498f8c95970081297837eaf4686b6f3813e535795b8421f15ace17a59aea37", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "fadb0bf014bd328badb7be986eadbce1a29955dd51c27a9e401c3045cf24184e"}, 22 | "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"}, 23 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 24 | "myxql": {:hex, :myxql, "0.7.1", "7c7b75aa82227cd2bc9b7fbd4de774fb19a1cdb309c219f411f82ca8860f8e01", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a491cdff53353a09b5850ac2d472816ebe19f76c30b0d36a43317a67c9004936"}, 25 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 26 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 27 | "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"}, 28 | "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, 29 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 30 | } 31 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>woylie/renovate-presets:library"] 4 | } 5 | -------------------------------------------------------------------------------- /test/adapters/ecto/mysql/all_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../cases/flop_test.exs", __DIR__) 2 | -------------------------------------------------------------------------------- /test/adapters/ecto/mysql/migration.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.Repo.Mysql.Migration do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:owners) do 6 | add(:age, :integer) 7 | add(:email, :string) 8 | add(:name, :string) 9 | add(:attributes, :map) 10 | add(:extra, {:map, :string}) 11 | end 12 | 13 | create table(:pets) do 14 | add(:age, :integer) 15 | add(:family_name, :string) 16 | add(:given_name, :string) 17 | add(:name, :string) 18 | add(:owner_id, references(:owners)) 19 | add(:species, :string) 20 | add(:mood, :string) 21 | end 22 | 23 | create table(:fruits) do 24 | add(:family, :string) 25 | add(:name, :string) 26 | add(:attributes, :map) 27 | add(:extra, {:map, :string}) 28 | add(:owner_id, references(:owners)) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/adapters/ecto/mysql/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:flop, :async_integration_tests, true) 2 | 3 | # Configure PG connection 4 | Application.put_env(:flop, Flop.Repo, 5 | username: "root", 6 | password: "", 7 | database: "flop_test#{System.get_env("MIX_TEST_PARTITION")}", 8 | hostname: "localhost", 9 | port: 3306, 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | ) 12 | 13 | defmodule Flop.Repo do 14 | use Ecto.Repo, 15 | otp_app: :flop, 16 | adapter: Ecto.Adapters.MyXQL 17 | end 18 | 19 | defmodule Flop.Integration.Case do 20 | use ExUnit.CaseTemplate 21 | alias Ecto.Adapters.SQL.Sandbox 22 | 23 | setup do 24 | :ok = Sandbox.checkout(Flop.Repo) 25 | end 26 | 27 | setup do 28 | %{ecto_adapter: :mysql} 29 | end 30 | end 31 | 32 | Code.require_file("migration.exs", __DIR__) 33 | 34 | {:ok, _} = 35 | Ecto.Adapters.MyXQL.ensure_all_started(Flop.Repo.config(), :temporary) 36 | 37 | # Load up the repository, start it, and run migrations 38 | Ecto.Adapters.MyXQL.storage_down(Flop.Repo.config()) 39 | Ecto.Adapters.MyXQL.storage_up(Flop.Repo.config()) 40 | 41 | {:ok, _pid} = Flop.Repo.start_link() 42 | 43 | Ecto.Migrator.up(Flop.Repo, 0, Flop.Repo.Mysql.Migration, log: true) 44 | 45 | Ecto.Adapters.SQL.Sandbox.mode(Flop.Repo, :manual) 46 | 47 | {:ok, _} = Application.ensure_all_started(:ex_machina) 48 | ExUnit.start(exclude: [:prefix]) 49 | -------------------------------------------------------------------------------- /test/adapters/ecto/postgres/all_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../cases/flop_test.exs", __DIR__) 2 | -------------------------------------------------------------------------------- /test/adapters/ecto/postgres/migration.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.Repo.Postgres.Migration do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute( 6 | "CREATE TYPE public.distance AS (unit varchar, value float);", 7 | "DROP TYPE public.distance;" 8 | ) 9 | 10 | create table(:owners) do 11 | add(:age, :integer) 12 | add(:email, :string) 13 | add(:name, :string) 14 | add(:tags, {:array, :string}) 15 | add(:attributes, :map) 16 | add(:extra, {:map, :string}) 17 | end 18 | 19 | create table(:pets) do 20 | add(:age, :integer) 21 | add(:family_name, :string) 22 | add(:given_name, :string) 23 | add(:name, :string) 24 | add(:owner_id, references(:owners)) 25 | add(:species, :string) 26 | add(:mood, :string) 27 | add(:tags, {:array, :string}) 28 | end 29 | 30 | create table(:fruits) do 31 | add(:family, :string) 32 | add(:name, :string) 33 | add(:attributes, :map) 34 | add(:extra, {:map, :string}) 35 | add(:owner_id, references(:owners)) 36 | end 37 | 38 | create table(:walking_distances) do 39 | add(:trip, :distance) 40 | end 41 | 42 | # create pets table in other schema 43 | 44 | execute("CREATE SCHEMA other_schema;", "DROP SCHEMA other_schema;") 45 | 46 | create table(:pets, prefix: "other_schema") do 47 | add(:age, :integer) 48 | add(:family_name, :string) 49 | add(:given_name, :string) 50 | add(:name, :string) 51 | add(:owner_id, :integer) 52 | add(:species, :string) 53 | add(:mood, :string) 54 | add(:tags, {:array, :string}) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/adapters/ecto/postgres/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:flop, :async_integration_tests, true) 2 | 3 | # Configure PG connection 4 | Application.put_env(:flop, Flop.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "flop_test#{System.get_env("MIX_TEST_PARTITION")}", 8 | hostname: "localhost", 9 | pool: Ecto.Adapters.SQL.Sandbox 10 | ) 11 | 12 | defmodule Flop.Repo do 13 | use Ecto.Repo, 14 | otp_app: :flop, 15 | adapter: Ecto.Adapters.Postgres 16 | end 17 | 18 | defmodule Flop.Integration.Case do 19 | use ExUnit.CaseTemplate 20 | alias Ecto.Adapters.SQL.Sandbox 21 | 22 | setup do 23 | :ok = Sandbox.checkout(Flop.Repo) 24 | end 25 | 26 | setup do 27 | %{ecto_adapter: :postgres} 28 | end 29 | end 30 | 31 | Code.require_file("migration.exs", __DIR__) 32 | 33 | {:ok, _} = 34 | Ecto.Adapters.Postgres.ensure_all_started(Flop.Repo.config(), :temporary) 35 | 36 | # Load up the repository, start it, and run migrations 37 | Ecto.Adapters.Postgres.storage_down(Flop.Repo.config()) 38 | Ecto.Adapters.Postgres.storage_up(Flop.Repo.config()) 39 | 40 | {:ok, _pid} = Flop.Repo.start_link() 41 | 42 | Ecto.Migrator.up(Flop.Repo, 0, Flop.Repo.Postgres.Migration, log: true) 43 | 44 | Ecto.Adapters.SQL.Sandbox.mode(Flop.Repo, :manual) 45 | 46 | {:ok, _} = Application.ensure_all_started(:ex_machina) 47 | ExUnit.start() 48 | -------------------------------------------------------------------------------- /test/adapters/ecto/sqlite/all_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("../cases/flop_test.exs", __DIR__) 2 | -------------------------------------------------------------------------------- /test/adapters/ecto/sqlite/migration.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.Repo.SQLite.Migration do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:owners) do 6 | add(:age, :integer) 7 | add(:email, :string) 8 | add(:name, :string) 9 | add(:tags, {:array, :string}) 10 | add(:attributes, :map) 11 | add(:extra, {:map, :string}) 12 | end 13 | 14 | create table(:pets) do 15 | add(:age, :integer) 16 | add(:family_name, :string) 17 | add(:given_name, :string) 18 | add(:name, :string) 19 | add(:owner_id, references(:owners)) 20 | add(:species, :string) 21 | add(:mood, :string) 22 | add(:tags, {:array, :string}) 23 | end 24 | 25 | create table(:fruits) do 26 | add(:family, :string) 27 | add(:name, :string) 28 | add(:attributes, :map) 29 | add(:extra, {:map, :string}) 30 | add(:owner_id, references(:owners)) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/adapters/ecto/sqlite/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:flop, :async_integration_tests, false) 2 | 3 | # Configure SQLite db 4 | Application.put_env(:flop, Flop.Repo, 5 | database: "tmp/test.db", 6 | pool: Ecto.Adapters.SQL.Sandbox, 7 | show_sensitive_data_on_connection_error: true 8 | ) 9 | 10 | defmodule Flop.Repo do 11 | use Ecto.Repo, 12 | otp_app: :flop, 13 | adapter: Ecto.Adapters.SQLite3 14 | end 15 | 16 | defmodule Flop.Integration.Case do 17 | use ExUnit.CaseTemplate 18 | alias Ecto.Adapters.SQL.Sandbox 19 | 20 | setup do 21 | :ok = Sandbox.checkout(Flop.Repo) 22 | end 23 | 24 | setup do 25 | %{ecto_adapter: :sqlite} 26 | end 27 | end 28 | 29 | Code.require_file("migration.exs", __DIR__) 30 | 31 | {:ok, _} = 32 | Ecto.Adapters.SQLite3.ensure_all_started(Flop.Repo.config(), :temporary) 33 | 34 | # Load up the repository, start it, and run migrations 35 | _ = Ecto.Adapters.SQLite3.storage_down(Flop.Repo.config()) 36 | :ok = Ecto.Adapters.SQLite3.storage_up(Flop.Repo.config()) 37 | 38 | {:ok, _pid} = Flop.Repo.start_link() 39 | 40 | :ok = Ecto.Migrator.up(Flop.Repo, 0, Flop.Repo.SQLite.Migration, log: false) 41 | 42 | {:ok, _} = Application.ensure_all_started(:ex_machina) 43 | ExUnit.start(exclude: [:composite_type, :ilike, :prefix]) 44 | -------------------------------------------------------------------------------- /test/base/flop/cursor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.CursorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Flop.Cursor 5 | 6 | doctest Flop.Cursor 7 | 8 | describe "encoding/decoding" do 9 | test "encoding and decoding returns original value" do 10 | value = %{a: "b", c: [:d], e: {:f, "g", 5}, h: ~U[2020-09-25 11:09:41Z]} 11 | assert value |> Cursor.encode() |> Cursor.decode() == {:ok, value} 12 | end 13 | 14 | test "cursor value containing function results in error" do 15 | value = %{a: fn b -> b * 2 end} 16 | assert value |> Cursor.encode() |> Cursor.decode() == :error 17 | end 18 | 19 | test "decode!/1 raises error for invalid cursor" do 20 | assert_raise Flop.InvalidCursorError, fn -> 21 | Cursor.decode!("AAAH") 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/base/flop/custom_types/any_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.CustomTypes.AnyTest do 2 | use ExUnit.Case, async: true 3 | alias Flop.CustomTypes.Any 4 | 5 | describe "cast/1" do 6 | test "casts any value" do 7 | assert Any.cast(1) == {:ok, 1} 8 | assert Any.cast(1.2) == {:ok, 1.2} 9 | assert Any.cast(nil) == {:ok, nil} 10 | assert Any.cast(true) == {:ok, true} 11 | assert Any.cast("a") == {:ok, "a"} 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/base/flop/custom_types/existing_atom_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.CustomTypes.ExistingAtomTest do 2 | use ExUnit.Case, async: true 3 | alias Flop.CustomTypes.ExistingAtom 4 | 5 | describe "cast/1" do 6 | test "casts strings" do 7 | assert ExistingAtom.cast("==") == {:ok, :==} 8 | end 9 | 10 | test "casts atoms" do 11 | assert ExistingAtom.cast(:==) == {:ok, :==} 12 | end 13 | 14 | test "doesn't cast to non-existent atoms" do 15 | assert ExistingAtom.cast("noatomlikethis") == :error 16 | end 17 | 18 | test "returns error for other types" do 19 | assert ExistingAtom.cast(1) == :error 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/base/flop/filter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.FilterTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Flop.Filter 5 | alias MyApp.Pet 6 | 7 | doctest Flop.Filter, import: true 8 | 9 | defmodule SchemaWithoutDerive do 10 | use Ecto.Schema 11 | 12 | schema "whatever" do 13 | field :name, :string 14 | field :age, :integer 15 | end 16 | end 17 | 18 | describe "allowed_operators/1" do 19 | test "returns a list of operators for each Ecto type" do 20 | types = [ 21 | :id, 22 | :binary_id, 23 | :integer, 24 | :float, 25 | :boolean, 26 | :string, 27 | :binary, 28 | {:array, :integer}, 29 | :map, 30 | {:map, :integer}, 31 | :decimal, 32 | :date, 33 | :time, 34 | :time_usec, 35 | :naive_datetime, 36 | :naive_datetime_usec, 37 | :utc_datetime, 38 | :utc_datetime_usec, 39 | {:parameterized, {Ecto.Enum, %{type: :string}}}, 40 | {:ecto_enum, [:one, :two]}, 41 | {:from_schema, MyApp.Pet, :mood} 42 | ] 43 | 44 | for type <- types do 45 | assert [op | _] = ops_for_type = Filter.allowed_operators(type) 46 | assert is_atom(op) 47 | 48 | ops_for_field = 49 | Filter.allowed_operators(%Flop.FieldInfo{ecto_type: type}) 50 | 51 | assert ops_for_type == ops_for_field 52 | end 53 | end 54 | 55 | test "returns list of operators for enum" do 56 | types = [ 57 | # by internal representation Ecto < 3.12.0 58 | {:parameterized, Ecto.Enum, %{type: :string}}, 59 | # by internal representation Ecto >= 3.12.0 60 | {:parameterized, {Ecto.Enum, %{type: :string}}}, 61 | # same with init function 62 | Ecto.ParameterizedType.init(Ecto.Enum, values: [:one, :two]), 63 | # by convenience format 64 | {:ecto_enum, [:one, :two]}, 65 | # by reference 66 | {:from_schema, MyApp.Pet, :mood} 67 | ] 68 | 69 | expected_ops = [ 70 | :==, 71 | :!=, 72 | :empty, 73 | :not_empty, 74 | :<=, 75 | :<, 76 | :>=, 77 | :>, 78 | :in, 79 | :not_in 80 | ] 81 | 82 | for type <- types do 83 | assert Filter.allowed_operators(type) == expected_ops 84 | 85 | assert Filter.allowed_operators(%Flop.FieldInfo{ecto_type: type}) == 86 | expected_ops 87 | end 88 | end 89 | 90 | test "returns a list of operators for unknown types" do 91 | assert [op | _] = Filter.allowed_operators(:unicorn) 92 | assert is_atom(op) 93 | end 94 | end 95 | 96 | describe "allowed_operators/2" do 97 | test "returns a list of operators for the given module and field" do 98 | assert Filter.allowed_operators(Pet, :age) == [ 99 | :==, 100 | :!=, 101 | :empty, 102 | :not_empty, 103 | :<=, 104 | :<, 105 | :>=, 106 | :>, 107 | :in, 108 | :not_in 109 | ] 110 | end 111 | 112 | test "returns a list of operators for a schema without derive" do 113 | assert Filter.allowed_operators(SchemaWithoutDerive, :name) == [ 114 | :==, 115 | :!=, 116 | :=~, 117 | :empty, 118 | :not_empty, 119 | :<=, 120 | :<, 121 | :>=, 122 | :>, 123 | :in, 124 | :not_in, 125 | :like, 126 | :not_like, 127 | :like_and, 128 | :like_or, 129 | :ilike, 130 | :not_ilike, 131 | :ilike_and, 132 | :ilike_or 133 | ] 134 | 135 | assert Filter.allowed_operators(SchemaWithoutDerive, :age) == [ 136 | :==, 137 | :!=, 138 | :empty, 139 | :not_empty, 140 | :<=, 141 | :<, 142 | :>=, 143 | :>, 144 | :in, 145 | :not_in 146 | ] 147 | end 148 | 149 | test "returns a list of operators for a join field with ecto_type" do 150 | assert Filter.allowed_operators(Pet, :owner_name) == 151 | Filter.allowed_operators(:string) 152 | end 153 | 154 | test "returns a list of operators for a join field without ecto_type" do 155 | assert Filter.allowed_operators(Pet, :owner_age) == 156 | Filter.allowed_operators(:unknown) 157 | end 158 | 159 | test "returns a list of operators for a custom field with ecto_type" do 160 | assert Filter.allowed_operators(Pet, :reverse_name) == 161 | Filter.allowed_operators(:string) 162 | end 163 | 164 | test "returns a list of operators for a custom field with operators" do 165 | assert Filter.allowed_operators(Pet, :custom) == [:==] 166 | end 167 | 168 | test "returns a list of operators for a compound field" do 169 | assert Filter.allowed_operators(Pet, :full_name) == [ 170 | :=~, 171 | :like, 172 | :not_like, 173 | :like_and, 174 | :like_or, 175 | :ilike, 176 | :not_ilike, 177 | :ilike_and, 178 | :ilike_or, 179 | :empty, 180 | :not_empty 181 | ] 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /test/base/flop/meta_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.MetaTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Flop.Meta, import: true 5 | end 6 | -------------------------------------------------------------------------------- /test/base/flop/misc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.MiscTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Flop.Misc, import: true 5 | end 6 | -------------------------------------------------------------------------------- /test/base/flop/relay_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.RelayTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest Flop.Relay 5 | 6 | describe "edges_from_result/2" do 7 | test "allows edges to be nil" do 8 | flop = %Flop{order_by: [:name]} 9 | meta = %Flop.Meta{flop: flop} 10 | items = [{%MyApp.Fruit{name: "Apple"}, nil}] 11 | func = fn {fruit, _edge}, order_by -> Map.take(fruit, order_by) end 12 | 13 | assert Flop.Relay.edges_from_result({items, meta}, 14 | cursor_value_func: func 15 | ) == [ 16 | %{ 17 | cursor: "g3QAAAABdwRuYW1lbQAAAAVBcHBsZQ==", 18 | node: %MyApp.Fruit{name: "Apple"} 19 | } 20 | ] 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/base/flop/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Flop.SchemaTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias __MODULE__.Panini 5 | alias Flop.Schema 6 | 7 | doctest Flop.Schema, import: true 8 | 9 | defmodule Panini do 10 | use Ecto.Schema 11 | 12 | @derive {Flop.Schema, 13 | filterable: [:name, :age], 14 | sortable: [:name, :age, :topping_count], 15 | default_limit: 20, 16 | max_limit: 50, 17 | default_order: %{ 18 | order_by: [:name, :age], 19 | order_directions: [:desc, :asc] 20 | }, 21 | compound_fields: [name_or_email: [:name, :email]], 22 | join_fields: [ 23 | topping_name: [ 24 | binding: :toppings, 25 | field: :name 26 | ] 27 | ], 28 | alias_fields: [:topping_count], 29 | custom_fields: [ 30 | inserted_at: [ 31 | filter: {__MODULE__, :date_filter, [some: "option"]}, 32 | ecto_type: :date 33 | ] 34 | ]} 35 | 36 | schema "paninis" do 37 | field :name, :string 38 | field :email, :string 39 | field :age, :integer 40 | end 41 | end 42 | 43 | test "default_order/1 returns the default order passed as an option" do 44 | assert Schema.default_order(%Panini{}) == %{ 45 | order_by: [:name, :age], 46 | order_directions: [:desc, :asc] 47 | } 48 | end 49 | 50 | test "default_limit/1 returns the default limit passed as option" do 51 | assert Schema.default_limit(%Panini{}) == 20 52 | end 53 | 54 | test "max_limit/1 returns the max limit passed as option" do 55 | assert Schema.max_limit(%Panini{}) == 50 56 | end 57 | 58 | test "calling default_limit/1 without deriving raises error" do 59 | assert_raise Protocol.UndefinedError, fn -> 60 | Schema.default_limit(%{}) 61 | end 62 | end 63 | 64 | test "calling default_order/1 without deriving raises error" do 65 | assert_raise Protocol.UndefinedError, fn -> 66 | Schema.default_order(%{}) 67 | end 68 | end 69 | 70 | test "calling field_info/2 without deriving raises error" do 71 | assert_raise Protocol.UndefinedError, fn -> 72 | Schema.field_info(%{}, :field) 73 | end 74 | end 75 | 76 | test "calling filterable/1 without deriving raises error" do 77 | assert_raise Protocol.UndefinedError, fn -> 78 | Schema.filterable(%{}) 79 | end 80 | end 81 | 82 | test "calling get_field/2 without deriving raises error" do 83 | assert_raise Protocol.UndefinedError, fn -> 84 | Schema.get_field(:a, :field) 85 | end 86 | end 87 | 88 | test "get_field/2 has default implementation for maps" do 89 | assert Schema.get_field(%{wait: "what?"}, :wait) == "what?" 90 | end 91 | 92 | test "calling max_limit/1 without deriving raises error" do 93 | assert_raise Protocol.UndefinedError, fn -> 94 | Schema.max_limit(%{}) 95 | end 96 | end 97 | 98 | test "calling sortable/1 without deriving raises error" do 99 | assert_raise Protocol.UndefinedError, fn -> 100 | Schema.sortable(%{}) 101 | end 102 | end 103 | 104 | test "calling pagination_types/1 without deriving raises error" do 105 | assert_raise Protocol.UndefinedError, fn -> 106 | Schema.pagination_types(%{}) 107 | end 108 | end 109 | 110 | test "calling default_pagination_type/1 without deriving raises error" do 111 | assert_raise Protocol.UndefinedError, fn -> 112 | Schema.default_pagination_type(%{}) 113 | end 114 | end 115 | 116 | describe "__deriving__/3" do 117 | test "raises if default_pagination_type is not allowed" do 118 | assert_raise Flop.InvalidDefaultPaginationTypeError, fn -> 119 | defmodule Bulgur do 120 | @derive { 121 | Flop.Schema, 122 | filterable: [], 123 | sortable: [], 124 | default_pagination_type: :first, 125 | pagination_types: [:page] 126 | } 127 | defstruct [:name] 128 | end 129 | end 130 | end 131 | 132 | test "raises if filterable field is unknown" do 133 | assert_raise Flop.UnknownFieldError, fn -> 134 | defmodule Pita do 135 | @derive {Flop.Schema, filterable: [:smell], sortable: []} 136 | defstruct [:name] 137 | end 138 | end 139 | end 140 | 141 | test "raises if sortable field is unknown" do 142 | assert_raise Flop.UnknownFieldError, fn -> 143 | defmodule Marmelade do 144 | @derive {Flop.Schema, filterable: [], sortable: [:smell]} 145 | defstruct [:name] 146 | end 147 | end 148 | end 149 | 150 | test "raises if default order field is not sortable" do 151 | assert_raise Flop.InvalidDefaultOrderError, fn -> 152 | defmodule Broomstick do 153 | @derive { 154 | Flop.Schema, 155 | filterable: [], 156 | sortable: [:name], 157 | default_order: %{order_by: [:age], order_directions: [:desc]} 158 | } 159 | defstruct [:name, :age] 160 | end 161 | end 162 | end 163 | 164 | test "raises if compound field references unknown field" do 165 | error = 166 | assert_raise ArgumentError, fn -> 167 | defmodule Potato do 168 | @derive { 169 | Flop.Schema, 170 | filterable: [], 171 | sortable: [], 172 | compound_fields: [full_name: [:family_name, :given_name]] 173 | } 174 | defstruct [:family_name] 175 | end 176 | end 177 | 178 | assert error.message =~ "unknown field" 179 | end 180 | 181 | test "raises if compound field uses existing join field name" do 182 | error = 183 | assert_raise ArgumentError, fn -> 184 | defmodule Cannelloni do 185 | @derive { 186 | Flop.Schema, 187 | filterable: [], 188 | sortable: [], 189 | join_fields: [ 190 | name: [ 191 | binding: :eater, 192 | field: :name 193 | ] 194 | ], 195 | compound_fields: [name: [:name, :nickname]] 196 | } 197 | defstruct [:name, :nickname] 198 | end 199 | end 200 | 201 | assert error.message =~ "duplicate field" 202 | end 203 | 204 | test "raises if alias field uses existing compound field name" do 205 | error = 206 | assert_raise ArgumentError, fn -> 207 | defmodule Pickles do 208 | @derive { 209 | Flop.Schema, 210 | filterable: [], 211 | sortable: [], 212 | compound_fields: [name: [:name, :nickname]], 213 | alias_fields: [:name] 214 | } 215 | defstruct [:id] 216 | end 217 | end 218 | 219 | assert error.message =~ "duplicate field" 220 | end 221 | 222 | test "raises if alias field uses existing join field name" do 223 | error = 224 | assert_raise ArgumentError, fn -> 225 | defmodule Juice do 226 | @derive { 227 | Flop.Schema, 228 | filterable: [], 229 | sortable: [], 230 | join_fields: [ 231 | owner_name: [ 232 | binding: :owner, 233 | field: :name 234 | ] 235 | ], 236 | alias_fields: [:owner_name] 237 | } 238 | defstruct [:id] 239 | end 240 | end 241 | 242 | assert error.message =~ "duplicate field" 243 | end 244 | 245 | test "raises if custom field uses existing compound field name" do 246 | error = 247 | assert_raise ArgumentError, fn -> 248 | defmodule Pasta do 249 | @derive { 250 | Flop.Schema, 251 | filterable: [], 252 | sortable: [], 253 | compound_fields: [name: [:name, :nickname]], 254 | custom_fields: [ 255 | name: [ 256 | filter: {__MODULE__, :some_function, []} 257 | ] 258 | ] 259 | } 260 | defstruct [:id, :nickname] 261 | end 262 | end 263 | 264 | assert error.message =~ "duplicate field" 265 | end 266 | 267 | test "raises if custom field uses existing join field name" do 268 | error = 269 | assert_raise ArgumentError, fn -> 270 | defmodule Vegetable do 271 | @derive { 272 | Flop.Schema, 273 | filterable: [], 274 | sortable: [], 275 | join_fields: [ 276 | owner_name: [ 277 | binding: :owner, 278 | field: :name 279 | ] 280 | ], 281 | custom_fields: [ 282 | owner_name: [ 283 | filter: {__MODULE__, :some_function, []} 284 | ] 285 | ] 286 | } 287 | defstruct [:id] 288 | end 289 | end 290 | 291 | assert error.message =~ "duplicate field" 292 | end 293 | 294 | test "raises if custom field uses existing alias field name" do 295 | error = 296 | assert_raise ArgumentError, fn -> 297 | defmodule Cranberry do 298 | @derive { 299 | Flop.Schema, 300 | filterable: [], 301 | sortable: [], 302 | alias_fields: [:name], 303 | custom_fields: [ 304 | name: [ 305 | filter: {__MODULE__, :some_function, []} 306 | ] 307 | ] 308 | } 309 | defstruct [:id] 310 | end 311 | end 312 | 313 | assert error.message =~ "duplicate field" 314 | end 315 | 316 | test "does not raise if alias field uses existing schema field name" do 317 | defmodule Vegetaburu do 318 | @derive { 319 | Flop.Schema, 320 | filterable: [], sortable: [], alias_fields: [:nickname] 321 | } 322 | defstruct [:name, :nickname] 323 | end 324 | end 325 | 326 | test "raises error if alias field is added to filterable list" do 327 | error = 328 | assert_raise ArgumentError, fn -> 329 | defmodule Bejitaburu do 330 | @derive { 331 | Flop.Schema, 332 | filterable: [:count], sortable: [], alias_fields: [:count] 333 | } 334 | defstruct [:id] 335 | end 336 | end 337 | 338 | assert error.message =~ "cannot filter by alias field" 339 | end 340 | end 341 | 342 | test "raises error if custom field is added to sortable list" do 343 | error = 344 | assert_raise ArgumentError, fn -> 345 | defmodule Parsley do 346 | @derive { 347 | Flop.Schema, 348 | filterable: [], 349 | sortable: [:inserted_at], 350 | custom_fields: [ 351 | inserted_at: [filter: {__MODULE__, :some_function, []}] 352 | ] 353 | } 354 | defstruct [:id, :inserted_at] 355 | end 356 | end 357 | 358 | assert error.message =~ "cannot sort by custom field" 359 | end 360 | end 361 | -------------------------------------------------------------------------------- /test/base/flop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlopTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Ecto.Query 5 | 6 | alias __MODULE__.TestProvider 7 | alias Flop.Meta 8 | alias MyApp.Fruit 9 | alias MyApp.Pet 10 | alias MyApp.Vegetable 11 | 12 | defmodule TestProvider do 13 | use Flop, repo: Flop.Repo, default_limit: 35 14 | end 15 | 16 | describe "validate/1" do 17 | test "returns Flop struct" do 18 | assert Flop.validate(%Flop{}) == {:ok, %Flop{limit: 50}} 19 | assert Flop.validate(%{}) == {:ok, %Flop{limit: 50}} 20 | end 21 | 22 | test "returns error if parameters are invalid" do 23 | assert {:error, %Meta{} = meta} = 24 | Flop.validate( 25 | %{ 26 | limit: -1, 27 | filters: [%{field: :name}, %{field: :age, op: "approx"}] 28 | }, 29 | for: Pet 30 | ) 31 | 32 | assert meta.flop == %Flop{} 33 | assert meta.schema == Pet 34 | 35 | assert meta.params == %{ 36 | "limit" => -1, 37 | "filters" => [ 38 | %{"field" => :name}, 39 | %{"field" => :age, "op" => "approx"} 40 | ] 41 | } 42 | 43 | assert [{"must be greater than %{number}", _}] = 44 | Keyword.get(meta.errors, :limit) 45 | 46 | assert [[], [op: [{"is invalid", _}]]] = 47 | Keyword.get(meta.errors, :filters) 48 | end 49 | 50 | test "returns error for struct values" do 51 | assert {:error, %Meta{} = meta} = 52 | Flop.validate( 53 | %{filters: [%{field: :age, op: :>=, value: ~D[2015-01-01]}]}, 54 | for: Pet 55 | ) 56 | 57 | assert meta.params == %{ 58 | "filters" => [ 59 | %{"field" => :age, "op" => :>=, "value" => ~D[2015-01-01]} 60 | ] 61 | } 62 | end 63 | 64 | test "returns error if operator is not allowed for field" do 65 | assert {:error, %Meta{} = meta} = 66 | Flop.validate( 67 | %{filters: [%{field: :age, op: "=~", value: 20}]}, 68 | for: Pet 69 | ) 70 | 71 | assert meta.flop == %Flop{} 72 | assert meta.schema == Pet 73 | 74 | assert meta.params == %{ 75 | "filters" => [%{"field" => :age, "op" => "=~", "value" => 20}] 76 | } 77 | 78 | assert [ 79 | [ 80 | op: [ 81 | {"is invalid", 82 | [ 83 | allowed_operators: [ 84 | :==, 85 | :!=, 86 | :empty, 87 | :not_empty, 88 | :<=, 89 | :<, 90 | :>=, 91 | :>, 92 | :in, 93 | :not_in 94 | ] 95 | ]} 96 | ] 97 | ] 98 | ] = Keyword.get(meta.errors, :filters) 99 | end 100 | 101 | test "returns filter params as list if passed as a map" do 102 | assert {:error, %Meta{} = meta} = 103 | Flop.validate( 104 | %{ 105 | limit: -1, 106 | filters: %{ 107 | "0" => %{field: :name}, 108 | "1" => %{field: :age, op: "approx"} 109 | } 110 | }, 111 | for: Pet 112 | ) 113 | 114 | assert meta.params == %{ 115 | "limit" => -1, 116 | "filters" => [ 117 | %{"field" => :name}, 118 | %{"field" => :age, "op" => "approx"} 119 | ] 120 | } 121 | end 122 | end 123 | 124 | describe "validate!/1" do 125 | test "returns a flop struct" do 126 | assert Flop.validate!(%Flop{}) == %Flop{limit: 50} 127 | assert Flop.validate!(%{}) == %Flop{limit: 50} 128 | end 129 | 130 | test "raises if params are invalid" do 131 | error = 132 | assert_raise Flop.InvalidParamsError, fn -> 133 | Flop.validate!(%{ 134 | limit: -1, 135 | filters: [%{field: :name}, %{field: :age, op: "approx"}] 136 | }) 137 | end 138 | 139 | assert error.params == 140 | %{ 141 | "limit" => -1, 142 | "filters" => [ 143 | %{"field" => :name}, 144 | %{"field" => :age, "op" => "approx"} 145 | ] 146 | } 147 | 148 | assert [{"must be greater than %{number}", _}] = 149 | Keyword.get(error.errors, :limit) 150 | 151 | assert [[], [op: [{"is invalid", _}]]] = 152 | Keyword.get(error.errors, :filters) 153 | end 154 | end 155 | 156 | describe "named_bindings/3" do 157 | test "returns used binding names with order_by and filters" do 158 | flop = %Flop{ 159 | filters: [ 160 | # join fields 161 | %Flop.Filter{field: :owner_age, op: :==, value: 5}, 162 | %Flop.Filter{field: :owner_name, op: :==, value: "George"}, 163 | # compound field 164 | %Flop.Filter{field: :full_name, op: :==, value: "George the Dog"} 165 | ], 166 | # join field and normal field 167 | order_by: [:owner_name, :age] 168 | } 169 | 170 | assert Flop.named_bindings(flop, Pet) == [:owner] 171 | end 172 | 173 | test "allows disabling order fields" do 174 | flop = %Flop{order_by: [:owner_name, :age]} 175 | assert Flop.named_bindings(flop, Pet, order: false) == [] 176 | assert Flop.named_bindings(flop, Pet, order: true) == [:owner] 177 | end 178 | 179 | test "returns used binding names with order_by" do 180 | flop = %Flop{ 181 | # join field and normal field 182 | order_by: [:owner_name, :age] 183 | } 184 | 185 | assert Flop.named_bindings(flop, Pet) == [:owner] 186 | end 187 | 188 | test "returns used binding names with filters" do 189 | flop = %Flop{ 190 | filters: [ 191 | # join fields 192 | %Flop.Filter{field: :owner_age, op: :==, value: 5}, 193 | %Flop.Filter{field: :owner_name, op: :==, value: "George"}, 194 | # compound field 195 | %Flop.Filter{field: :full_name, op: :==, value: "George the Dog"} 196 | ] 197 | } 198 | 199 | assert Flop.named_bindings(flop, Pet) == [:owner] 200 | end 201 | 202 | test "returns used binding names with custom filter using bindings opt" do 203 | flop = %Flop{ 204 | filters: [ 205 | %Flop.Filter{field: :with_bindings, op: :==, value: 5} 206 | ] 207 | } 208 | 209 | assert Flop.named_bindings(flop, Vegetable) == [:curious] 210 | end 211 | 212 | test "returns empty list if no join fields are used" do 213 | flop = %Flop{ 214 | filters: [ 215 | # compound field 216 | %Flop.Filter{field: :full_name, op: :==, value: "George the Dog"} 217 | ], 218 | # normal field 219 | order_by: [:age] 220 | } 221 | 222 | assert Flop.named_bindings(flop, Pet) == [] 223 | end 224 | 225 | test "returns empty list if there are no filters and order fields" do 226 | assert Flop.named_bindings(%Flop{}, Pet) == [] 227 | end 228 | end 229 | 230 | describe "with_named_bindings/4" do 231 | test "adds necessary bindings to query" do 232 | query = Pet 233 | opts = [for: Pet] 234 | 235 | flop = %Flop{ 236 | filters: [ 237 | # join fields 238 | %Flop.Filter{field: :owner_age, op: :==, value: 5}, 239 | %Flop.Filter{field: :owner_name, op: :==, value: "George"}, 240 | # compound field 241 | %Flop.Filter{field: :full_name, op: :==, value: "George the Dog"} 242 | ], 243 | # join field and normal field 244 | order_by: [:owner_name, :age] 245 | } 246 | 247 | fun = fn q, :owner -> 248 | join(q, :left, [p], o in assoc(p, :owner), as: :owner) 249 | end 250 | 251 | new_query = Flop.with_named_bindings(query, flop, fun, opts) 252 | assert Ecto.Query.has_named_binding?(new_query, :owner) 253 | end 254 | 255 | test "allows disabling order fields" do 256 | query = Pet 257 | flop = %Flop{order_by: [:owner_name, :age]} 258 | 259 | fun = fn q, :owner -> 260 | join(q, :left, [p], o in assoc(p, :owner), as: :owner) 261 | end 262 | 263 | opts = [for: Pet, order: false] 264 | new_query = Flop.with_named_bindings(query, flop, fun, opts) 265 | assert new_query == query 266 | 267 | opts = [for: Pet, order: true] 268 | new_query = Flop.with_named_bindings(query, flop, fun, opts) 269 | assert Ecto.Query.has_named_binding?(new_query, :owner) 270 | end 271 | 272 | test "returns query unchanged if no bindings are required" do 273 | query = Pet 274 | opts = [for: Pet] 275 | 276 | assert Flop.with_named_bindings( 277 | query, 278 | %Flop{}, 279 | fn _, _ -> nil end, 280 | opts 281 | ) == query 282 | end 283 | end 284 | 285 | describe "push_order/3" do 286 | test "raises error if invalid directions option is passed" do 287 | for flop <- [%Flop{}, %Flop{order_by: [:name], order_directions: [:asc]}], 288 | directions <- [{:up, :down}, "up,down"] do 289 | assert_raise Flop.InvalidDirectionsError, fn -> 290 | Flop.push_order(flop, :name, directions: directions) 291 | end 292 | end 293 | end 294 | end 295 | 296 | describe "get_option/3" do 297 | test "returns value from option list" do 298 | # sanity check 299 | default_limit = Flop.Schema.default_limit(%Fruit{}) 300 | assert default_limit && default_limit != 40 301 | 302 | assert Flop.get_option( 303 | :default_limit, 304 | [default_limit: 40, backend: TestProvider, for: Fruit], 305 | 1 306 | ) == 40 307 | end 308 | 309 | test "falls back to schema option" do 310 | # sanity check 311 | assert default_limit = Flop.Schema.default_limit(%Fruit{}) 312 | 313 | assert Flop.get_option( 314 | :default_limit, 315 | [backend: TestProvider, for: Fruit], 316 | 1 317 | ) == default_limit 318 | end 319 | 320 | test "falls back to backend config if schema option is not set" do 321 | # sanity check 322 | assert Flop.Schema.default_limit(%Pet{}) == nil 323 | 324 | assert Flop.get_option( 325 | :default_limit, 326 | [backend: TestProvider, for: Pet], 327 | 1 328 | ) == 35 329 | end 330 | 331 | test "falls back to backend config if :for option is not set" do 332 | assert Flop.get_option(:default_limit, [backend: TestProvider], 1) == 35 333 | end 334 | 335 | test "falls back to default value" do 336 | assert Flop.get_option(:default_limit, []) == 50 337 | end 338 | 339 | test "falls back to default value passed to function" do 340 | assert Flop.get_option(:some_option, [], 2) == 2 341 | end 342 | 343 | test "falls back to nil" do 344 | assert Flop.get_option(:some_option, []) == nil 345 | end 346 | end 347 | end 348 | -------------------------------------------------------------------------------- /test/base/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/support/distance.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Distance do 2 | @moduledoc "Distance type" 3 | 4 | defstruct unit: nil, value: nil 5 | end 6 | -------------------------------------------------------------------------------- /test/support/distance_type.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.DistanceType do 2 | @moduledoc "A simple distance ecto type" 3 | 4 | use Ecto.Type 5 | 6 | alias Flop.Distance 7 | 8 | @impl Ecto.Type 9 | def type, do: :distance 10 | 11 | @impl Ecto.Type 12 | def cast(nil), do: {:ok, nil} 13 | def cast(%Distance{} = distance), do: {:ok, distance} 14 | 15 | def cast(%{unit: unit, value: distance}), 16 | do: {:ok, %Distance{unit: unit, value: distance}} 17 | 18 | def cast(_), do: :error 19 | 20 | @impl Ecto.Type 21 | def dump(nil), do: {:ok, nil} 22 | 23 | def dump(%Distance{} = distance), 24 | do: {:ok, {distance.unit, distance.value}} 25 | 26 | def dump(_), do: :error 27 | 28 | @impl Ecto.Type 29 | def load(nil), do: {:ok, nil} 30 | 31 | def load({unit, distance}), 32 | do: {:ok, %Distance{unit: unit, value: distance}} 33 | end 34 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Factory do 2 | @moduledoc false 3 | use ExMachina.Ecto, repo: Flop.Repo 4 | 5 | alias MyApp.Fruit 6 | alias MyApp.Owner 7 | alias MyApp.Pet 8 | 9 | @family [ 10 | "Rosaceae", 11 | "Lecythidaceae", 12 | "Rubiaceae", 13 | "Salicaceae", 14 | "Sapotaceae" 15 | ] 16 | 17 | @given_name [ 18 | "Albert", 19 | "Bernardo", 20 | "Bianca", 21 | "Billy", 22 | "Brittany", 23 | "Brittney", 24 | "Carrol", 25 | "Casey", 26 | "Clay", 27 | "Eddie", 28 | "Emil", 29 | "Etta", 30 | "Flossie", 31 | "Floyd", 32 | "Frederic", 33 | "Gay", 34 | "Genevieve", 35 | "Harrison", 36 | "Ingrid", 37 | "Jody", 38 | "Kenton", 39 | "Loretta", 40 | "Marisa", 41 | "Rodney", 42 | "Rolando" 43 | ] 44 | 45 | @family_name [ 46 | "Adkins", 47 | "Barker", 48 | "Becker", 49 | "Blackwell", 50 | "Brooks", 51 | "Carter", 52 | "Francis", 53 | "Gallagher", 54 | "Hanna", 55 | "Hess", 56 | "Holland", 57 | "Johnson", 58 | "Norris", 59 | "Pierce", 60 | "Rich", 61 | "Romero", 62 | "Schroeder", 63 | "Simon", 64 | "Singh", 65 | "Smith", 66 | "Snow", 67 | "Sutton", 68 | "Villegas", 69 | "Wade", 70 | "Williamson" 71 | ] 72 | 73 | @species [ 74 | "C. lupus", 75 | "F. catus", 76 | "O. cuniculus", 77 | "C. porcellus", 78 | "V. pacos", 79 | "C. bactrianus", 80 | "E. africanus", 81 | "M. putorius", 82 | "C. aegagrus", 83 | "L. glama", 84 | "S. scrofa", 85 | "R. norvegicus", 86 | "O. aries" 87 | ] 88 | 89 | @tags [ 90 | "catalunya", 91 | "cateyes", 92 | "catlady", 93 | "catlife", 94 | "catlove", 95 | "caturday", 96 | "doge", 97 | "doggie", 98 | "doggo", 99 | "doglife", 100 | "doglove", 101 | "dogmodel", 102 | "dogmom", 103 | "dogscorner", 104 | "petscorner" 105 | ] 106 | 107 | def fruit_factory do 108 | %Fruit{ 109 | family: build(:fruit_family), 110 | name: build(:name) 111 | } 112 | end 113 | 114 | def owner_factory do 115 | %Owner{ 116 | age: :rand.uniform(100), 117 | email: build(:species), 118 | name: build(:name), 119 | tags: Enum.take_random(@tags, Enum.random(1..5)) 120 | } 121 | end 122 | 123 | def pet_factory do 124 | %Pet{ 125 | age: :rand.uniform(30), 126 | family_name: sequence(:family_name, @family_name), 127 | given_name: sequence(:given_name, @given_name), 128 | name: build(:name), 129 | species: build(:species), 130 | tags: Enum.take_random(@tags, Enum.random(1..5)) 131 | } 132 | end 133 | 134 | def pet_with_owner_factory do 135 | %Pet{ 136 | age: :rand.uniform(30), 137 | family_name: sequence(:family_name, @family_name), 138 | given_name: sequence(:given_name, @given_name), 139 | name: build(:name), 140 | owner: build(:owner), 141 | species: build(:species), 142 | tags: Enum.take_random(@tags, Enum.random(1..5)) 143 | } 144 | end 145 | 146 | def pet_downcase_factory do 147 | Map.update!(build(:pet), :name, &String.downcase/1) 148 | end 149 | 150 | def fruit_family_factory(_) do 151 | sequence(:fruit_family, @family) 152 | end 153 | 154 | def email_factory(_) do 155 | prefix = 156 | :name 157 | |> build() 158 | |> String.downcase() 159 | |> String.replace(" ", "@") 160 | 161 | prefix <> ".com" 162 | end 163 | 164 | def name_factory(_) do 165 | sequence(:name, @given_name) <> " " <> sequence(:name, @family_name) 166 | end 167 | 168 | def species_factory(_) do 169 | sequence(:species, @species) 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /test/support/fruit.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Fruit do 2 | @moduledoc """ 3 | Defines an Ecto schema for testing. 4 | """ 5 | use Ecto.Schema 6 | 7 | alias MyApp.Owner 8 | 9 | @derive {Flop.Schema, 10 | filterable: [ 11 | :name, 12 | :family, 13 | :attributes, 14 | :extra, 15 | :owner_attributes, 16 | :owner_extra 17 | ], 18 | sortable: [:id, :name], 19 | join_fields: [ 20 | owner_attributes: [ 21 | binding: :owner, 22 | field: :attributes, 23 | path: [:owner, :attributes], 24 | ecto_type: {:map, :string} 25 | ], 26 | owner_extra: [ 27 | binding: :owner, 28 | field: :extra, 29 | path: [:owner, :extra], 30 | ecto_type: :map 31 | ] 32 | ], 33 | default_limit: 60, 34 | default_order: %{ 35 | order_by: [:name], 36 | order_directions: [:asc] 37 | }, 38 | pagination_types: [:first, :last, :offset]} 39 | 40 | schema "fruits" do 41 | field :name, :string 42 | field :family, :string 43 | field :attributes, :map 44 | field :extra, {:map, :string} 45 | 46 | belongs_to :owner, Owner 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/generators.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.Generators do 2 | @moduledoc false 3 | use ExUnitProperties 4 | 5 | alias Flop.Filter 6 | 7 | @dialyzer {:nowarn_function, [filter: 0, pagination_parameters: 1, pet: 0]} 8 | 9 | @order_directions [ 10 | :asc, 11 | :asc_nulls_first, 12 | :asc_nulls_last, 13 | :desc, 14 | :desc_nulls_first, 15 | :desc_nulls_last 16 | ] 17 | 18 | @whitespace ["\u0020", "\u2000", "\u3000"] 19 | 20 | def pet do 21 | gen all name <- string(:alphanumeric, min_length: 2), 22 | age <- integer(1..500), 23 | species <- string(:alphanumeric, min_length: 2) do 24 | %{name: name, age: age, species: species} 25 | end 26 | end 27 | 28 | def filterable_pet_field do 29 | member_of(Flop.Schema.filterable(%MyApp.Pet{})) 30 | end 31 | 32 | def filterable_pet_field(:string) do 33 | member_of([:full_name, :name, :owner_name, :pet_and_owner_name, :species]) 34 | end 35 | 36 | def filterable_pet_field(:integer) do 37 | member_of([:age, :owner_age]) 38 | end 39 | 40 | def uniq_list_of_strings(len) do 41 | uniq_list_of(string(:alphanumeric, min_length: 2), length: len) 42 | end 43 | 44 | def uniq_list_of_pets(opts) do 45 | length_range = Keyword.fetch!(opts, :length) 46 | 47 | gen all length <- integer(length_range), 48 | names <- uniq_list_of_strings(length), 49 | family_names <- uniq_list_of_strings(length), 50 | given_names <- uniq_list_of_strings(length), 51 | owners <- uniq_list_of_owners(length), 52 | ages <- uniq_list_of(integer(1..500), length: length), 53 | species <- uniq_list_of_strings(length) do 54 | [names, ages, species, family_names, given_names, owners] 55 | |> Enum.zip() 56 | |> Enum.map(fn {name, age, species, family_name, given_name, owner} -> 57 | %MyApp.Pet{ 58 | name: name, 59 | age: age, 60 | species: species, 61 | family_name: family_name, 62 | given_name: given_name, 63 | owner: owner 64 | } 65 | end) 66 | end 67 | end 68 | 69 | def uniq_list_of_owners(len) do 70 | gen all names <- uniq_list_of_strings(len), 71 | ages <- uniq_list_of(integer(1..500), length: len), 72 | emails <- uniq_list_of_strings(len) do 73 | [names, ages, emails] 74 | |> Enum.zip() 75 | |> Enum.map(fn {name, age, email} -> 76 | %MyApp.Owner{name: name, age: age, email: email} 77 | end) 78 | end 79 | end 80 | 81 | def pagination_parameters(type) when type in [:offset, :page] do 82 | gen all val_1 <- positive_integer(), 83 | val_2 <- one_of([positive_integer(), constant(nil)]) do 84 | [a, b] = Enum.shuffle([val_1, val_2]) 85 | 86 | case type do 87 | :offset -> %{offset: a, limit: b} 88 | :page -> %{page: a, page_size: b} 89 | end 90 | end 91 | end 92 | 93 | def pagination_parameters(type) when type in [:first, :last] do 94 | gen all val_1 <- positive_integer(), 95 | val_2 <- one_of([string(:alphanumeric), constant(nil)]) do 96 | case type do 97 | :first -> %{first: val_1, after: val_2} 98 | :last -> %{last: val_1, before: val_2} 99 | end 100 | end 101 | end 102 | 103 | def filter do 104 | gen all field <- member_of([:age, :name, :owner_name]), 105 | value <- value_by_field(field), 106 | op <- operator_by_type(value) do 107 | %Filter{field: field, op: op, value: value} 108 | end 109 | end 110 | 111 | def value_by_field(:age), do: integer() 112 | 113 | def value_by_field(:name), 114 | do: string(:alphanumeric, min_length: 1) 115 | 116 | def value_by_field(:owner_age), do: integer() 117 | 118 | def value_by_field(:owner_name), 119 | do: string(:alphanumeric, min_length: 1) 120 | 121 | def compare_value_by_field(:age), do: integer(1..30) 122 | 123 | def compare_value_by_field(:name), 124 | do: string(?a..?z, min_length: 1, max_length: 3) 125 | 126 | def compare_value_by_field(:owner_age), do: integer(1..100) 127 | 128 | defp operator_by_type(a) when is_binary(a), 129 | do: 130 | member_of([ 131 | :==, 132 | :!=, 133 | :=~, 134 | :<=, 135 | :<, 136 | :>=, 137 | :>, 138 | :like, 139 | :not_like, 140 | :like_and, 141 | :like_or, 142 | :ilike, 143 | :not_ilike, 144 | :ilike_and, 145 | :ilike_or 146 | ]) 147 | 148 | defp operator_by_type(a) when is_number(a), 149 | do: member_of([:==, :!=, :<=, :<, :>=, :>]) 150 | 151 | def cursor_fields(%{} = schema) do 152 | schema 153 | |> Flop.Schema.sortable() 154 | |> Enum.shuffle() 155 | |> constant() 156 | end 157 | 158 | def order_directions(%{} = schema) do 159 | field_count = 160 | schema 161 | |> Flop.Schema.sortable() 162 | |> length() 163 | 164 | @order_directions 165 | |> member_of() 166 | |> list_of(length: field_count) 167 | end 168 | 169 | @doc """ 170 | Generates a random sub string for the given string. Empty sub strings are 171 | filtered. 172 | """ 173 | def substring(s) when is_binary(s) do 174 | str_length = String.length(s) 175 | 176 | gen all start_at <- integer(0..(str_length - 1)), 177 | end_at <- integer(start_at..(str_length - 1)), 178 | query_value = String.slice(s, start_at..end_at), 179 | query_value != " " do 180 | query_value 181 | end 182 | end 183 | 184 | @doc """ 185 | Generates a search string consisting of two random substrings 186 | or a list of search strings consisting of two random substrings from the given 187 | string. 188 | """ 189 | def search_text_or_list(s) when is_binary(s) do 190 | gen all string_or_list <- one_of([search_text(s), search_text_list(s)]) do 191 | string_or_list 192 | end 193 | end 194 | 195 | defp search_text_list(s) when is_binary(s) do 196 | str_length = String.length(s) 197 | 198 | gen all start_at_a <- integer(0..(str_length - 2)), 199 | end_at_a <- integer((start_at_a + 1)..(str_length - 1)), 200 | start_at_b <- integer(0..(str_length - 2)), 201 | end_at_b <- integer((start_at_b + 1)..(str_length - 1)), 202 | query_value_a <- 203 | s 204 | |> String.slice(start_at_a..end_at_a) 205 | |> String.trim() 206 | |> constant(), 207 | query_value_a != "", 208 | query_value_b <- 209 | s 210 | |> String.slice(start_at_b..end_at_b) 211 | |> String.trim() 212 | |> constant(), 213 | query_value_b != "" do 214 | [query_value_a, query_value_b] 215 | end 216 | end 217 | 218 | defp search_text(s) when is_binary(s) do 219 | gen all whitespace_character <- member_of(@whitespace), 220 | text_list <- search_text_list(s) do 221 | Enum.join(text_list, whitespace_character) 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /test/support/owner.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Owner do 2 | @moduledoc """ 3 | Defines an Ecto schema for testing. 4 | """ 5 | use Ecto.Schema 6 | 7 | alias MyApp.Pet 8 | 9 | @derive { 10 | Flop.Schema, 11 | filterable: [ 12 | :name, 13 | :pet_mood_as_reference, 14 | :pet_mood_as_enum, 15 | :pet_mood_as_parameterized_type 16 | ], 17 | sortable: [:name, :age], 18 | join_fields: [ 19 | pet_age: [ 20 | binding: :pets, 21 | field: :age 22 | ], 23 | pet_mood_as_reference: [ 24 | binding: :pets, 25 | field: :mood, 26 | ecto_type: {:from_schema, Pet, :mood} 27 | ], 28 | pet_mood_as_enum: [ 29 | binding: :pets, 30 | field: :mood, 31 | ecto_type: {:ecto_enum, [:happy, :playful]} 32 | ], 33 | pet_mood_as_parameterized_type: [ 34 | binding: :pets, 35 | field: :mood, 36 | ecto_type: 37 | Ecto.ParameterizedType.init(Ecto.Enum, values: [:happy, :playful]) 38 | ] 39 | ], 40 | compound_fields: [age_and_pet_age: [:age, :pet_age]], 41 | alias_fields: [:pet_count], 42 | default_pagination_type: :page 43 | } 44 | 45 | schema "owners" do 46 | field :age, :integer 47 | field :email, :string 48 | field :name, :string 49 | field :tags, {:array, :string}, default: [] 50 | field :pet_count, :integer, virtual: true 51 | field :attributes, :map 52 | field :extra, {:map, :string} 53 | 54 | has_many :pets, Pet 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/support/pet.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Pet do 2 | @moduledoc """ 3 | Defines an Ecto schema for testing. 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Query 7 | 8 | alias MyApp.Owner 9 | 10 | @derive { 11 | Flop.Schema, 12 | filterable: [ 13 | :age, 14 | :full_name, 15 | :mood, 16 | :name, 17 | :owner_age, 18 | :owner_name, 19 | :owner_tags, 20 | :pet_and_owner_name, 21 | :species, 22 | :tags, 23 | :custom, 24 | :reverse_name 25 | ], 26 | sortable: [:name, :age, :owner_name, :owner_age], 27 | max_limit: 1000, 28 | adapter_opts: [ 29 | compound_fields: [ 30 | full_name: [:family_name, :given_name], 31 | pet_and_owner_name: [:name, :owner_name] 32 | ], 33 | join_fields: [ 34 | owner_age: [ 35 | binding: :owner, 36 | field: :age 37 | ], 38 | owner_name: [ 39 | binding: :owner, 40 | field: :name, 41 | path: [:owner, :name], 42 | ecto_type: :string 43 | ], 44 | owner_tags: [ 45 | binding: :owner, 46 | field: :tags, 47 | ecto_type: {:array, :string} 48 | ] 49 | ], 50 | custom_fields: [ 51 | custom: [ 52 | filter: {__MODULE__, :test_custom_filter, [some: :options]}, 53 | operators: [:==] 54 | ], 55 | reverse_name: [ 56 | filter: {__MODULE__, :reverse_name_filter, []}, 57 | ecto_type: :string 58 | ] 59 | ] 60 | ] 61 | } 62 | 63 | schema "pets" do 64 | field :age, :integer 65 | field :family_name, :string 66 | field :given_name, :string 67 | field :name, :string 68 | field :species, :string 69 | field :mood, Ecto.Enum, values: [:happy, :relaxed, :playful] 70 | field :tags, {:array, :string}, default: [] 71 | 72 | belongs_to :owner, Owner 73 | end 74 | 75 | def test_custom_filter(query, %Flop.Filter{value: value} = filter, opts) do 76 | :options = Keyword.fetch!(opts, :some) 77 | send(self(), {:filter, {filter, opts}}) 78 | 79 | if value == "some_value" do 80 | where(query, false) 81 | else 82 | query 83 | end 84 | end 85 | 86 | def reverse_name_filter(query, %Flop.Filter{value: value}, _) do 87 | reversed = value 88 | where(query, [p], p.name == ^reversed) 89 | end 90 | 91 | def get_field(%__MODULE__{owner: %Owner{age: age}}, :owner_age), do: age 92 | def get_field(%__MODULE__{owner: nil}, :owner_age), do: nil 93 | def get_field(%__MODULE__{owner: %Owner{name: name}}, :owner_name), do: name 94 | def get_field(%__MODULE__{owner: nil}, :owner_name), do: nil 95 | def get_field(%__MODULE__{owner: %Owner{tags: tags}}, :owner_tags), do: tags 96 | def get_field(%__MODULE__{owner: nil}, :owner_tags), do: nil 97 | 98 | def get_field(%__MODULE__{} = pet, field) 99 | when field in [:name, :age, :species, :tags], 100 | do: Map.get(pet, field) 101 | 102 | def get_field(%__MODULE__{} = pet, field) 103 | when field in [:full_name, :pet_and_owner_name], 104 | do: random_value_for_compound_field(pet, field) 105 | 106 | def random_value_for_compound_field( 107 | %__MODULE__{family_name: family_name, given_name: given_name}, 108 | :full_name 109 | ), 110 | do: Enum.random([family_name, given_name]) 111 | 112 | def random_value_for_compound_field( 113 | %__MODULE__{name: name, owner: %Owner{name: owner_name}}, 114 | :pet_and_owner_name 115 | ), 116 | do: Enum.random([name, owner_name]) 117 | 118 | def concatenated_value_for_compound_field( 119 | %__MODULE__{family_name: family_name, given_name: given_name}, 120 | :full_name 121 | ), 122 | do: family_name <> " " <> given_name 123 | 124 | def concatenated_value_for_compound_field( 125 | %__MODULE__{name: name, owner: %Owner{name: owner_name}}, 126 | :pet_and_owner_name 127 | ), 128 | do: name <> " " <> owner_name 129 | end 130 | -------------------------------------------------------------------------------- /test/support/test_util.ex: -------------------------------------------------------------------------------- 1 | defmodule Flop.TestUtil do 2 | @moduledoc false 3 | 4 | import Ecto.Query 5 | import Flop.Factory 6 | 7 | alias Ecto.Adapters.SQL.Sandbox 8 | alias Flop.FieldInfo 9 | alias Flop.Repo 10 | alias MyApp.Fruit 11 | alias MyApp.Pet 12 | 13 | def checkin_checkout do 14 | :ok = Sandbox.checkin(Repo) 15 | :ok = Sandbox.checkout(Repo) 16 | end 17 | 18 | @doc """ 19 | Takes a list of items and applies filter operators on the list using 20 | `Enum.filter/2`. 21 | 22 | The function supports regular fields, join fields and compound fields. The 23 | associations need to be preloaded if join fields are used. 24 | """ 25 | def filter_items(items, field, op, value \\ nil, ecto_adapter \\ nil) 26 | 27 | def filter_items([], _, _, _, _), do: [] 28 | 29 | def filter_items( 30 | [struct | _] = items, 31 | field, 32 | op, 33 | value, 34 | ecto_adapter 35 | ) 36 | when is_atom(field) do 37 | case Flop.Schema.field_info(struct, field) do 38 | %FieldInfo{ecto_type: ecto_type, extra: %{type: :join}} = field_info 39 | when not is_nil(ecto_type) -> 40 | filter_func = matches?(op, value, ecto_adapter) 41 | 42 | Enum.filter(items, fn item -> 43 | item |> get_field(field_info) |> filter_func.() 44 | end) 45 | 46 | %FieldInfo{extra: %{type: type}} = field_info 47 | when type in [:normal, :join] -> 48 | filter_func = matches?(op, value, ecto_adapter) 49 | 50 | Enum.filter(items, fn item -> 51 | item |> get_field(field_info) |> filter_func.() 52 | end) 53 | 54 | %FieldInfo{extra: %{type: :compound, fields: fields}} -> 55 | Enum.filter( 56 | items, 57 | &apply_filter_to_compound_fields(&1, fields, op, value, ecto_adapter) 58 | ) 59 | end 60 | end 61 | 62 | defp apply_filter_to_compound_fields(_pet, _fields, op, _value, _ecto_adapter) 63 | when op in [ 64 | :==, 65 | :=~, 66 | :<=, 67 | :<, 68 | :>=, 69 | :>, 70 | :in, 71 | :not_in, 72 | :contains, 73 | :not_contains 74 | ] do 75 | true 76 | end 77 | 78 | defp apply_filter_to_compound_fields(pet, fields, :empty, value, ecto_adapter) do 79 | filter_func = matches?(:empty, value, ecto_adapter) 80 | 81 | Enum.all?(fields, fn field -> 82 | field_info = Flop.Schema.field_info(%Pet{}, field) 83 | pet |> get_field(field_info) |> filter_func.() 84 | end) 85 | end 86 | 87 | defp apply_filter_to_compound_fields( 88 | pet, 89 | fields, 90 | :like_and, 91 | value, 92 | ecto_adapter 93 | ) do 94 | value = if is_binary(value), do: String.split(value), else: value 95 | 96 | Enum.all?(value, fn substring -> 97 | filter_func = matches?(:like, substring, ecto_adapter) 98 | 99 | Enum.any?(fields, fn field -> 100 | field_info = Flop.Schema.field_info(%Pet{}, field) 101 | pet |> get_field(field_info) |> filter_func.() 102 | end) 103 | end) 104 | end 105 | 106 | defp apply_filter_to_compound_fields( 107 | pet, 108 | fields, 109 | :ilike_and, 110 | value, 111 | ecto_adapter 112 | ) do 113 | value = if is_binary(value), do: String.split(value), else: value 114 | 115 | Enum.all?(value, fn substring -> 116 | filter_func = matches?(:ilike, substring, ecto_adapter) 117 | 118 | Enum.any?(fields, fn field -> 119 | field_info = Flop.Schema.field_info(%Pet{}, field) 120 | pet |> get_field(field_info) |> filter_func.() 121 | end) 122 | end) 123 | end 124 | 125 | defp apply_filter_to_compound_fields( 126 | pet, 127 | fields, 128 | :like_or, 129 | value, 130 | ecto_adapter 131 | ) do 132 | value = if is_binary(value), do: String.split(value), else: value 133 | 134 | Enum.any?(value, fn substring -> 135 | filter_func = matches?(:like, substring, ecto_adapter) 136 | 137 | Enum.any?(fields, fn field -> 138 | field_info = Flop.Schema.field_info(%Pet{}, field) 139 | pet |> get_field(field_info) |> filter_func.() 140 | end) 141 | end) 142 | end 143 | 144 | defp apply_filter_to_compound_fields( 145 | pet, 146 | fields, 147 | :ilike_or, 148 | value, 149 | ecto_adapter 150 | ) do 151 | value = if is_binary(value), do: String.split(value), else: value 152 | 153 | Enum.any?(value, fn substring -> 154 | filter_func = matches?(:ilike, substring, ecto_adapter) 155 | 156 | Enum.any?(fields, fn field -> 157 | field_info = Flop.Schema.field_info(%Pet{}, field) 158 | pet |> get_field(field_info) |> filter_func.() 159 | end) 160 | end) 161 | end 162 | 163 | defp apply_filter_to_compound_fields(pet, fields, op, value, ecto_adapter) do 164 | filter_func = matches?(op, value, ecto_adapter) 165 | 166 | Enum.any?(fields, fn field -> 167 | field_info = Flop.Schema.field_info(%Pet{}, field) 168 | pet |> get_field(field_info) |> filter_func.() 169 | end) 170 | end 171 | 172 | defp get_field(pet, %FieldInfo{extra: %{type: :normal, field: field}}), 173 | do: Map.fetch!(pet, field) 174 | 175 | defp get_field(pet, %FieldInfo{extra: %{type: :join, path: [a, b]}}), 176 | do: pet |> Map.fetch!(a) |> Map.fetch!(b) 177 | 178 | defp matches?(:==, v, _), do: &(&1 == v) 179 | defp matches?(:!=, v, _), do: &(&1 != v) 180 | defp matches?(:empty, _, _), do: &empty?(&1) 181 | defp matches?(:not_empty, _, _), do: &(!empty?(&1)) 182 | defp matches?(:<=, v, _), do: &(&1 <= v) 183 | defp matches?(:<, v, _), do: &(&1 < v) 184 | defp matches?(:>, v, _), do: &(&1 > v) 185 | defp matches?(:>=, v, _), do: &(&1 >= v) 186 | defp matches?(:in, v, _), do: &(&1 in v) 187 | defp matches?(:not_in, v, _), do: &(&1 not in v) 188 | defp matches?(:contains, v, _), do: &(v in &1) 189 | defp matches?(:not_contains, v, _), do: &(v not in &1) 190 | 191 | defp matches?(:like, v, :sqlite) do 192 | v = String.downcase(v) 193 | &(String.downcase(&1) =~ v) 194 | end 195 | 196 | defp matches?(:like, v, _), do: &(&1 =~ v) 197 | 198 | defp matches?(:not_like, v, :sqlite) do 199 | v = String.downcase(v) 200 | &(String.downcase(&1) =~ v == false) 201 | end 202 | 203 | defp matches?(:not_like, v, _), do: &(&1 =~ v == false) 204 | defp matches?(:=~, v, ecto_adapter), do: matches?(:ilike, v, ecto_adapter) 205 | 206 | defp matches?(:ilike, v, _) do 207 | v = String.downcase(v) 208 | &(String.downcase(&1) =~ v) 209 | end 210 | 211 | defp matches?(:not_ilike, v, _) do 212 | v = String.downcase(v) 213 | &(String.downcase(&1) =~ v == false) 214 | end 215 | 216 | defp matches?(:like_and, v, :sqlite) when is_binary(v) do 217 | values = v |> String.downcase() |> String.split() 218 | &Enum.all?(values, fn v -> String.downcase(&1) =~ v end) 219 | end 220 | 221 | defp matches?(:like_and, v, :sqlite) do 222 | values = Enum.map(v, &String.downcase/1) 223 | &Enum.all?(values, fn v -> String.downcase(&1) =~ v end) 224 | end 225 | 226 | defp matches?(:like_and, v, _) when is_binary(v) do 227 | values = String.split(v) 228 | &Enum.all?(values, fn v -> &1 =~ v end) 229 | end 230 | 231 | defp matches?(:like_and, v, _), do: &Enum.all?(v, fn v -> &1 =~ v end) 232 | 233 | defp matches?(:like_or, v, :sqlite) when is_binary(v) do 234 | values = v |> String.downcase() |> String.split() 235 | &Enum.any?(values, fn v -> String.downcase(&1) =~ v end) 236 | end 237 | 238 | defp matches?(:like_or, v, :sqlite) do 239 | values = Enum.map(v, &String.downcase/1) 240 | &Enum.any?(values, fn v -> String.downcase(&1) =~ v end) 241 | end 242 | 243 | defp matches?(:like_or, v, _) when is_binary(v) do 244 | values = String.split(v) 245 | &Enum.any?(values, fn v -> &1 =~ v end) 246 | end 247 | 248 | defp matches?(:like_or, v, _), do: &Enum.any?(v, fn v -> &1 =~ v end) 249 | 250 | defp matches?(:ilike_and, v, _) when is_binary(v) do 251 | values = v |> String.downcase() |> String.split() 252 | &Enum.all?(values, fn v -> String.downcase(&1) =~ v end) 253 | end 254 | 255 | defp matches?(:ilike_and, v, _) do 256 | values = Enum.map(v, &String.downcase/1) 257 | &Enum.all?(values, fn v -> String.downcase(&1) =~ v end) 258 | end 259 | 260 | defp matches?(:ilike_or, v, _) when is_binary(v) do 261 | values = v |> String.downcase() |> String.split() 262 | &Enum.any?(values, fn v -> String.downcase(&1) =~ v end) 263 | end 264 | 265 | defp matches?(:ilike_or, v, _) do 266 | values = Enum.map(v, &String.downcase/1) 267 | &Enum.any?(values, fn v -> String.downcase(&1) =~ v end) 268 | end 269 | 270 | defp empty?(nil), do: true 271 | defp empty?([]), do: true 272 | defp empty?(map) when map == %{}, do: true 273 | defp empty?(_), do: false 274 | 275 | @doc """ 276 | Inserts a list of items using `Flop.Factory` and sorts the list by `:id` 277 | field. 278 | """ 279 | def insert_list_and_sort(count, factory, args \\ []) do 280 | count |> insert_list(factory, args) |> Enum.sort_by(& &1.id) 281 | end 282 | 283 | @doc """ 284 | Query that returns all pets with owners joined and preloaded. 285 | """ 286 | def pets_with_owners_query do 287 | Pet 288 | |> join(:left, [p], o in assoc(p, :owner), as: :owner) 289 | |> preload(:owner) 290 | end 291 | 292 | @doc """ 293 | Queries all pets using `Flop.all`. Preloads the owners and sorts by Pet ID. 294 | """ 295 | def query_pets_with_owners(params, opts \\ []) do 296 | flop = 297 | Flop.validate!(params, 298 | for: Pet, 299 | max_limit: 999_999_999, 300 | default_limit: 999_999_999 301 | ) 302 | 303 | sort? = opts[:sort] || true 304 | 305 | q = 306 | Pet 307 | |> join(:left, [p], o in assoc(p, :owner), as: :owner) 308 | |> preload(:owner) 309 | 310 | q = if sort?, do: order_by(q, [p], p.id), else: q 311 | 312 | opts = opts |> Keyword.take([:extra_opts]) |> Keyword.put(:for, Pet) 313 | 314 | Flop.all(q, flop, opts) 315 | end 316 | 317 | @doc """ 318 | Queries all fruits using `Flop.all`. Preloads the owners and sorts by 319 | Fruit ID. 320 | """ 321 | def query_fruits_with_owners(params, opts \\ []) do 322 | flop_opts = [ 323 | for: Fruit, 324 | max_limit: 999_999_999, 325 | default_limit: 999_999_999 326 | ] 327 | 328 | sort? = opts[:sort] || true 329 | 330 | params = 331 | if sort?, 332 | do: Map.merge(params, %{order_by: [:id], order_directions: [:asc]}), 333 | else: params 334 | 335 | flop = Flop.validate!(params, flop_opts) 336 | 337 | q = 338 | Fruit 339 | |> join(:left, [f], o in assoc(f, :owner), as: :owner) 340 | |> preload(:owner) 341 | 342 | opts = Keyword.merge(flop_opts, Keyword.take(opts, [:extra_opts])) 343 | Flop.all(q, flop, opts) 344 | end 345 | 346 | @doc """ 347 | A helper that transforms changeset errors into a map of messages. 348 | 349 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 350 | assert "password is too short" in errors_on(changeset).password 351 | assert %{password: ["password is too short"]} = errors_on(changeset) 352 | 353 | Brought to you by Phoenix. 354 | """ 355 | def errors_on(changeset) do 356 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 357 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 358 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 359 | end) 360 | end) 361 | end 362 | end 363 | -------------------------------------------------------------------------------- /test/support/vegetable.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.Vegetable do 2 | @moduledoc """ 3 | Defines an Ecto schema for testing. 4 | """ 5 | use Ecto.Schema 6 | 7 | @derive {Flop.Schema, 8 | filterable: [:name, :family, :with_bindings], 9 | sortable: [:name], 10 | default_limit: 60, 11 | default_order: %{ 12 | order_by: [:name], 13 | order_directions: [:asc] 14 | }, 15 | pagination_types: [:page], 16 | custom_fields: [ 17 | with_bindings: [ 18 | filter: {__MODULE__, :custom_filter, []}, 19 | bindings: [:curious] 20 | ] 21 | ]} 22 | 23 | schema "vegetables" do 24 | field :name, :string 25 | field :family, :string 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/walking_distances.ex: -------------------------------------------------------------------------------- 1 | defmodule MyApp.WalkingDistances do 2 | @moduledoc """ 3 | Defines an Ecto schema for testing. 4 | """ 5 | use Ecto.Schema 6 | 7 | alias Ecto.Changeset 8 | alias Flop.DistanceType 9 | 10 | @derive { 11 | Flop.Schema, 12 | filterable: [ 13 | :trip 14 | ], 15 | sortable: [:trip], 16 | default_order: %{ 17 | order_by: [:trip], 18 | order_directions: [:desc] 19 | } 20 | } 21 | 22 | schema "walking_distances" do 23 | field :trip, DistanceType 24 | end 25 | 26 | def changeset(%__MODULE__{} = module, attr) do 27 | Changeset.cast(module, attr, [:trip]) 28 | end 29 | end 30 | --------------------------------------------------------------------------------