├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir-ci.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── lib ├── ecto_fragment_extras.ex └── ecto_fragment_extras │ ├── convert_inline_params.ex │ ├── convert_named_params.ex │ └── exceptions.ex ├── mix.exs ├── mix.lock └── test ├── ecto_fragment_extras ├── convert_inline_params_test.exs └── convert_named_params_test.exs ├── ecto_fragment_extras_test.exs ├── readme_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - tessi 11 | -------------------------------------------------------------------------------- /.github/workflows/elixir-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | ELIXIR_VERSION: 1.14.4 11 | OTP_VERSION: 26.0 12 | MIX_ENV: test 13 | 14 | jobs: 15 | deps: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | deps-cache-key: ${{ steps.get-cache-key.outputs.key }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: erlef/setup-beam@v1 22 | with: 23 | otp-version: ${{ env.OTP_VERSION }} 24 | elixir-version: ${{ env.ELIXIR_VERSION }} 25 | 26 | - id: get-cache-key 27 | run: echo "key=mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }}" >> $GITHUB_OUTPUT 28 | 29 | - uses: actions/cache@v2 30 | id: cache-deps 31 | with: 32 | path: | 33 | deps 34 | _build 35 | key: ${{ steps.get-cache-key.outputs.key }} 36 | 37 | - run: mix do deps.get, deps.compile 38 | if: steps.cache-deps.outputs.cache-hit != 'true' 39 | 40 | credo: 41 | needs: deps 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | - uses: erlef/setup-beam@v1 46 | with: 47 | otp-version: ${{ env.OTP_VERSION }} 48 | elixir-version: ${{ env.ELIXIR_VERSION }} 49 | 50 | - uses: actions/cache@v2 51 | id: cache-deps 52 | with: 53 | path: | 54 | deps 55 | _build 56 | key: ${{ needs.deps.outputs.deps-cache-key }} 57 | 58 | - run: mix credo 59 | 60 | format: 61 | needs: deps 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v3 65 | - uses: erlef/setup-beam@v1 66 | with: 67 | otp-version: ${{ env.OTP_VERSION }} 68 | elixir-version: ${{ env.ELIXIR_VERSION }} 69 | 70 | - run: mix format --check-formatted 71 | 72 | docs: 73 | needs: deps 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@v3 77 | - uses: erlef/setup-beam@v1 78 | with: 79 | otp-version: ${{ env.OTP_VERSION }} 80 | elixir-version: ${{ env.ELIXIR_VERSION }} 81 | 82 | - uses: actions/cache@v2 83 | id: cache-deps 84 | with: 85 | path: | 86 | deps 87 | _build 88 | key: ${{ needs.deps.outputs.deps-cache-key }} 89 | 90 | - run: mix docs 91 | 92 | test: 93 | needs: deps 94 | runs-on: ubuntu-latest 95 | steps: 96 | - uses: actions/checkout@v3 97 | - uses: erlef/setup-beam@v1 98 | with: 99 | otp-version: ${{ env.OTP_VERSION }} 100 | elixir-version: ${{ env.ELIXIR_VERSION }} 101 | 102 | - uses: actions/cache@v2 103 | id: cache-deps 104 | with: 105 | path: | 106 | deps 107 | _build 108 | key: ${{ needs.deps.outputs.deps-cache-key }} 109 | 110 | - run: mix test 111 | -------------------------------------------------------------------------------- /.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 | ecto_fragment_extras-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | Types of changes 9 | 10 | - `Added` for new features. 11 | - `Changed` for changes in existing functionality. 12 | - `Deprecated` for soon-to-be removed features. 13 | - `Removed` for now removed features. 14 | - `Fixed` for any bug fixes. 15 | - `Security` in case of vulnerabilities. 16 | 17 | ## unreleased 18 | 19 | put your changes here 20 | 21 | ## [0.3.0] - 2023-08-05 22 | 23 | * renamed library from `ecto_named_fragment` to `ecto_fragment_extras` because it contains a little more than just the named_fragment() macro now 👇 24 | * added inline fragments which allow inlining fragment params into the query string: 25 | 26 | ```elixir 27 | inline_fragment("coalesce(#{users.name}, #{^default_name})") 28 | ``` 29 | * added `frag/1` and `frag/2` as a shorthand for inline and named fragments depending on the arity it is called with. 30 | 31 | ## [0.2.0] - 2023-07-24 32 | 33 | ### Changed 34 | 35 | * the `EctoNamedFragment` module needs to be imported now (no more `use`) 36 | * changed interpolation syntax to use string interpolation (#{}). Please change your query strings from `named_fragment("foo(:a, :b)", a:1, b:2)` to `named_fragment("foo(#{:a}, #{:b})", a:1, b:2)` 37 | * this allows better syntax highlighting of param names within the query 38 | * it also drastically simplifies implementation of this library and improves compile times of this library as well as of the named_fragment macro 39 | 40 | 41 | ## [0.1.0] - 2023-07-17 42 | 43 | ### Added 44 | 45 | * initial release 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2023 Philipp Tessenow 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoFragmentExtras 2 | 3 | `EctoFragmentExtras` is a collection of macros to enhance Ecto's `fragment()` macro. 4 | 5 | It adds the following variants which are all macros compiling into regular ecto fragments: 6 | 7 | * `named_fragment("coalesce(#{:name}, #{:default})", name: user.name, default: ^default_name)` 8 | * `inline_fragment("coalesce(#{user.name}, #{^default_name})")` 9 | 10 | ## Installation 11 | 12 | Add `ecto_fragment_extras` to your list of dependencies in `mix.exs`: 13 | 14 | def deps do 15 | [{:ecto_fragment_extras, "~> 0.3.0"}] 16 | end 17 | 18 | ## Usage 19 | 20 | ### Named Params 21 | 22 | Instead of using Ectos `fragment` with ?-based interpolation, `named_fragment` allows you to use named params in your fragments. 23 | 24 | `named_fragment` is implemented as a macro on top of Ecto's `fragment` macro. 25 | 26 | So `named_fragment("coalesce(#{:a}, #{:b}, #{:a})", a: 1, b: 2)` will 27 | be converted to `fragment("coalesce(?, ?, ?)", 1, 2, 1)` at compile-time. 28 | 29 | ```elixir 30 | defmodule TestQuery do 31 | import Ecto.Query 32 | import EctoFragmentExtras 33 | 34 | def test_query do 35 | query = from u in "users", 36 | select: named_fragment("coalesce(#{:left}, #{:right})", left: "example", right: "input") 37 | 38 | Repo.all(query) 39 | end 40 | end 41 | ``` 42 | 43 | ### Inline Params 44 | 45 | Instead of using Ectos `fragment` with ?-based interpolation, `inline_fragment` allows you to use inline params directly in your fragment query string. 46 | 47 | `inline_fragment` is implemented as a macro on top of Ecto's `fragment` macro and, thus, as safe regarding SQL escaping. 48 | Please note that it is only safe as long as the interpolation happens within the macro call. 49 | 50 | So `inline_fragment("coalesce(#{1}, #{2})")` will be converted to `fragment("coalesce(?, ?, ?)", 1, 2)` at compile-time. 51 | 52 | ```elixir 53 | defmodule TestQuery do 54 | import Ecto.Query 55 | import EctoFragmentExtras 56 | 57 | def test_query do 58 | default = "Jane Doe" 59 | query = from u in "users", select: inline_fragment("coalesce(#{u.name}, #{^default})") 60 | 61 | Repo.all(query) 62 | end 63 | end 64 | ``` 65 | 66 | ### `frag` - a helper combining both 67 | 68 | `frag` is a shorthand which combines both (inline and named fragments) depending on the number of arguments 69 | it is called with. 70 | When called with just the query string, it interpolates like `inline_fragment/1`, when called 71 | with an additional keyword list param it works like `named_fragment/2`. 72 | 73 | ## License 74 | 75 | The entire project is under the MIT License. Please read [the LICENSE file](./LICENSE.md). 76 | 77 | ### Licensing 78 | 79 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, shall be licensed as above, without any additional terms or conditions. 80 | -------------------------------------------------------------------------------- /lib/ecto_fragment_extras.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFragmentExtras do 2 | @moduledoc ~S""" 3 | `EctoFragmentExtras` is a collection of macros to enhance Ecto fragments. 4 | 5 | Instead of using Ectos `fragment` with ?-based interpolation, this module 6 | provides macros (that expand into regular Ecto fragments) which allow 7 | named or inline params. 8 | """ 9 | 10 | alias EctoFragmentExtras.ConvertInlineParams 11 | alias EctoFragmentExtras.ConvertNamedParams 12 | 13 | @doc """ 14 | A variant of Ecto fragment() which supports named params. 15 | 16 | Nameds params are provided in a keyword list as the last argument. 17 | The fragments query string can reference named params using the 18 | `#{:name}` syntax where `name` is a key in the keyword list. 19 | 20 | So `named_fragment("coalesce(#{:a}, #{:b}, #{:a})", a: 1, b: 2)` will 21 | be expanded to `fragment("coalesce(?, ?, ?)", 1, 2, 1)` at compile-time. 22 | 23 | ```elixir 24 | defmodule TestQuery do 25 | import Ecto.Query 26 | import EctoFragmentExtras 27 | 28 | def test_query do 29 | default = "Jane Doe" 30 | query = from users in "users", 31 | select: named_fragment("coalesce(#{:a}, #{:b})", a: users.name, b: ^default) 32 | 33 | Repo.all(query) 34 | end 35 | end 36 | ``` 37 | """ 38 | defmacro named_fragment(query, params) when is_list(params) do 39 | {query, frags} = ConvertNamedParams.call(query, params) 40 | 41 | quote do 42 | fragment(unquote(query), unquote_splicing(frags)) 43 | end 44 | end 45 | 46 | @doc ~S""" 47 | A variant of Ecto fragment() which supports params inlined in the query string. 48 | 49 | Inline params are provided within the fragment query string as 50 | `#{}`-style string interpolation. Since this is a macro which expands to a regular 51 | Ecto fragment, those interpolations are safe (they are no real string interpolations, 52 | and pose no extra SQL injection risk compared to regular fragments). 53 | 54 | So `inline_fragment("coalesce(#{user.name}, #{^default})")` will 55 | be expanded to `fragment("coalesce(?, ?)", user.name, ^default)` at compile-time. 56 | 57 | ```elixir 58 | defmodule TestQuery do 59 | import Ecto.Query 60 | import EctoFragmentExtras 61 | 62 | def test_query do 63 | default = "Jane Doe" 64 | query = from users in "users", 65 | select: inline_fragment("coalesce(#{users.name}, #{^default})") 66 | 67 | Repo.all(query) 68 | end 69 | end 70 | ``` 71 | 72 | SAFETY WARNING: Please note that it is NOT safe to first interpolate a string which is passed in to this macro. 73 | Unsafe example: 74 | 75 | ```elixir 76 | def unsafe_query(user_name) do 77 | default = "Jane Doe" 78 | fragment_string = "coalesce(#{user_name}, #{^default})" 79 | query = from users in "users", 80 | select: inline_fragment(fragment_string) 81 | 82 | Repo.all(query) 83 | end 84 | end 85 | ``` 86 | 87 | The code above is UNSAFE, because the interpolation is done before the macro is called and the macro 88 | receives the already interpolated string. This prohibits the macro from safely escaping the query params. 89 | If the `user_name` param comes from untrusted user input, this can lead to SQL injection. 90 | """ 91 | defmacro inline_fragment(query) do 92 | {query, frags} = ConvertInlineParams.call(query) 93 | 94 | quote do 95 | fragment(unquote(query), unquote_splicing(frags)) 96 | end 97 | end 98 | 99 | @doc ~S""" 100 | A variant of Ecto fragment() which supports params inlined in the query string or named params depending on the number of arguments. 101 | 102 | When called with only a query string, this macro behaves like `inline_fragment/1`. 103 | When called with a query string and a keyword list, this macro behaves like `named_fragment/2`. 104 | 105 | ```elixir 106 | defmodule TestQuery do 107 | import Ecto.Query 108 | import EctoFragmentExtras 109 | 110 | @default_name "Jane Doe" 111 | 112 | def test_query_named do 113 | query = from users in "users", 114 | select: frag("coalesce(#{:a}, #{:b})", a: users.name, b: ^@default_name) 115 | 116 | Repo.all(query) 117 | end 118 | 119 | def test_query_inline do 120 | query = from users in "users", 121 | select: frag("coalesce(#{users.name}, #{^@default_name})") 122 | 123 | Repo.all(query) 124 | end 125 | end 126 | ``` 127 | """ 128 | defmacro frag(query) do 129 | quote do 130 | inline_fragment(unquote(query)) 131 | end 132 | end 133 | 134 | defmacro frag(query, params) do 135 | quote do 136 | named_fragment(unquote(query), unquote(params)) 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/ecto_fragment_extras/convert_inline_params.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFragmentExtras.ConvertInlineParams do 2 | @moduledoc false 3 | 4 | def call({:<<>>, _meta, pieces}) do 5 | query = 6 | Enum.map_join(pieces, fn 7 | binary when is_binary(binary) -> binary 8 | _ -> "?" 9 | end) 10 | 11 | frags = 12 | Enum.flat_map(pieces, fn 13 | {:"::", _, [{_, _, [val]} | _]} -> [val] 14 | _ -> [] 15 | end) 16 | 17 | {query, frags} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ecto_fragment_extras/convert_named_params.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFragmentExtras.ConvertNamedParams do 2 | @moduledoc false 3 | 4 | import EctoFragmentExtras.Exceptions, only: [error!: 1] 5 | 6 | def call({:<<>>, _meta, pieces}, params) do 7 | if not Keyword.keyword?(params) do 8 | error!( 9 | "named_fragment(...) expect a keyword list as the last argument, got: #{Macro.to_string(params)}" 10 | ) 11 | end 12 | 13 | query = 14 | Enum.map_join(pieces, fn 15 | binary when is_binary(binary) -> binary 16 | _ -> "?" 17 | end) 18 | 19 | frags = 20 | Enum.flat_map(pieces, fn 21 | {:"::", _, [{_, _, [val]} | _]} when is_atom(val) -> 22 | [Keyword.fetch!(params, val)] 23 | 24 | {:"::", _, [{_, _, [val]} | _]} -> 25 | error!( 26 | "names in named_fragment(...) queries must be atoms, got: #{Macro.to_string(val)}" 27 | ) 28 | 29 | _ -> 30 | [] 31 | end) 32 | 33 | {query, frags} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/ecto_fragment_extras/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoFragmentExtras.Exceptions do 2 | defmodule CompileError do 3 | @moduledoc """ 4 | Raised at compilation time when the named fragment cannot be compiled. 5 | """ 6 | defexception [:message] 7 | end 8 | 9 | def error!(message) when is_binary(message) do 10 | {:current_stacktrace, [_ | t]} = Process.info(self(), :current_stacktrace) 11 | 12 | t = 13 | Enum.drop_while(t, fn 14 | {mod, _, _, _} -> 15 | String.starts_with?(Atom.to_string(mod), ["Elixir.EctoFragmentExtras."]) 16 | 17 | _ -> 18 | false 19 | end) 20 | 21 | reraise CompileError, [message: message], t 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoFragmentExtras.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_fragment_extras, 7 | version: "0.3.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | package: package() 13 | ] 14 | end 15 | 16 | def application do 17 | [ 18 | extra_applications: [:logger] 19 | ] 20 | end 21 | 22 | defp description() do 23 | "A collection of macros to enhance Ectos fragment()" 24 | end 25 | 26 | defp deps do 27 | [ 28 | # Dev, Test 29 | {:ecto, ">= 3.0.0", only: [:dev, :test]}, 30 | {:ex_doc, "~> 0.37.3", only: [:dev, :test]}, 31 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false} 32 | ] 33 | end 34 | 35 | defp package() do 36 | [ 37 | # These are the default files included in the package 38 | files: ~w[ 39 | lib 40 | .formatter.exs 41 | mix.exs 42 | README.md 43 | LICENSE.md 44 | ], 45 | licenses: ["MIT"], 46 | links: %{ 47 | "GitHub" => "https://github.com/tessi/ecto_fragment_extras", 48 | "Docs" => "https://hexdocs.pm/ecto_fragment_extras" 49 | }, 50 | source_url: "https://github.com/tessi/ecto_fragment_extras" 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [: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", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"}, 4 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 5 | "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, 6 | "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 8 | "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"}, 9 | "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 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", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, 10 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 11 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [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", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 12 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 13 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 14 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 15 | "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"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 18 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 19 | } 20 | -------------------------------------------------------------------------------- /test/ecto_fragment_extras/convert_inline_params_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConvertInlineParamsTest do 2 | use ExUnit.Case, async: true 3 | doctest EctoFragmentExtras 4 | 5 | alias EctoFragmentExtras.ConvertInlineParams 6 | 7 | test "building a fragment query string and splits params" do 8 | assert ConvertInlineParams.call( 9 | quote do 10 | "foo(#{0}, #{1})" 11 | end 12 | ) == 13 | {"foo(?, ?)", [0, 1]} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/ecto_fragment_extras/convert_named_params_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConvertNamedParamsTest do 2 | use ExUnit.Case, async: true 3 | doctest EctoFragmentExtras 4 | 5 | alias EctoFragmentExtras.ConvertNamedParams 6 | alias EctoFragmentExtras.Exceptions.CompileError 7 | 8 | test "builds a fragment query string and splits params from kw list" do 9 | assert ConvertNamedParams.call( 10 | quote do 11 | "foo(#{:a}, #{:b})" 12 | end, 13 | a: 0, 14 | b: 1 15 | ) == 16 | {"foo(?, ?)", [0, 1]} 17 | end 18 | 19 | test "allows repeated param names" do 20 | assert ConvertNamedParams.call( 21 | quote do 22 | "foo(#{:a}, #{:b}, #{:a})" 23 | end, 24 | a: 0, 25 | b: 1 26 | ) == 27 | {"foo(?, ?, ?)", [0, 1, 0]} 28 | end 29 | 30 | test "raises for unknown params" do 31 | assert_raise KeyError, 32 | "key :b not found in: [a: 0]", 33 | fn -> 34 | ConvertNamedParams.call( 35 | quote do 36 | "foo(#{:a}, #{:b})" 37 | end, 38 | a: 0 39 | ) 40 | end 41 | end 42 | 43 | test "raises on non-atom names" do 44 | assert_raise CompileError, 45 | "names in named_fragment(...) queries must be atoms, got: a", 46 | fn -> 47 | ConvertNamedParams.call( 48 | quote do 49 | "foo(#{a})" 50 | end, 51 | a: 0 52 | ) 53 | end 54 | end 55 | 56 | test "raises on non-kw name lists" do 57 | assert_raise CompileError, 58 | "named_fragment(...) expect a keyword list as the last argument, got: [\"a\"]", 59 | fn -> 60 | ConvertNamedParams.call( 61 | quote do 62 | "foo(#{:a})" 63 | end, 64 | ["a"] 65 | ) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/ecto_fragment_extras_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoFragmentExtrasTest do 2 | use ExUnit.Case, async: true 3 | doctest EctoFragmentExtras 4 | 5 | import Ecto.Query 6 | import EctoFragmentExtras 7 | 8 | describe "named_fragment/2" do 9 | test "expands named params into a regular ecto fragment" do 10 | default_name = "pin" 11 | 12 | query = 13 | from(u in "users", 14 | select: 15 | named_fragment( 16 | "coalesce(#{:name}, #{:default_name})", 17 | name: u.name, 18 | default_name: ^default_name 19 | ) 20 | ) 21 | 22 | assert inspect(query) == 23 | "#Ecto.Query" 24 | end 25 | 26 | test "interpolating repeated params" do 27 | query = from(u in "users", select: named_fragment("foo(#{:a}, #{:a})", a: u.name)) 28 | 29 | assert inspect(query) == 30 | "#Ecto.Query" 31 | end 32 | end 33 | 34 | describe "inline_fragment/1" do 35 | test "expands inline params into a regular ecto fragment" do 36 | default_name = "pin" 37 | query = from(u in "users", select: inline_fragment("coalesce(#{u.name}, #{^default_name})")) 38 | 39 | assert inspect(query) == 40 | "#Ecto.Query" 41 | end 42 | end 43 | 44 | describe "frag/1 && frag/2 combined inline_fragment/1 and named_fragment/2" do 45 | test "expands named params into a regular ecto fragment" do 46 | default_name = "pin" 47 | 48 | query = 49 | from(u in "users", 50 | select: 51 | frag( 52 | "coalesce(#{:name}, #{:default_name})", 53 | name: u.name, 54 | default_name: ^default_name 55 | ) 56 | ) 57 | 58 | assert inspect(query) == 59 | "#Ecto.Query" 60 | end 61 | 62 | test "expands inline params into a regular ecto fragment" do 63 | default_name = "pin" 64 | query = from(u in "users", select: frag("coalesce(#{u.name}, #{^default_name})")) 65 | 66 | assert inspect(query) == 67 | "#Ecto.Query" 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/readme_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReadmeTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "version in readme matches mix.exs" do 5 | readme_markdown = File.read!(Path.join(__DIR__, "../README.md")) 6 | mix_config = Mix.Project.config() 7 | version = mix_config[:version] 8 | assert version == "0.3.0" 9 | assert readme_markdown =~ ~s({:ecto_fragment_extras, "~> #{version}"}) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------