Skip to content

Commit

Permalink
Change interpolation syntax to be similar to string interpolation
Browse files Browse the repository at this point in the history
After a discussion with Ecto maintainers

* elixir-ecto/ecto#4239 (comment)
* https://elixirforum.com/t/how-to-pass-named-or-numbered-parameters-to-ectos-fragment/49525/5?u=tessi
* https://groups.google.com/g/elixir-ecto/c/gEqI9lE3HGE

it seems like this feature will not be part of Ecto proper. However, the discussion revealed
a better implementation of this library (thanks @bamorim !) by using string interpolation instead of atom-like names.

This gives:

* better syntax highlighting for param names within fragment query strings
* better compilation time (for usage of the macro as well as for this lib)
* less dependencies for this library
  • Loading branch information
tessi committed Jul 23, 2023
1 parent adc7356 commit 532944e
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 153 deletions.
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Types of changes

- `Added` for new features.
- `Changed` for changes in existing functionality.
- `Deprecated` for soon-to-be removed features.
- `Removed` for now removed features.
- `Fixed` for any bug fixes.
- `Security` in case of vulnerabilities.

## unreleased

put your changes here

## [0.1.0] - 2023-07-24

### Changed

* the `EctoNamedFragment` module needs to be imported now (no more `use`)
* 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)`
* this allows better syntax highlighting of param names within the query
* it also drastically simplifies implementation of this library and improves compile times of this library as well as of the named_fragment macro


## [0.1.0] - 2023-07-17

### Added

* initial release
16 changes: 6 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,26 @@
Add `ecto_named_fragment` to your list of dependencies in `mix.exs`:

def deps do
[{:ecto_named_fragment, "~> 0.1.0"}]
[{:ecto_named_fragment, "~> 0.2.0"}]
end

## Usage

Instead of using Ectos `fragment` with ?-based interpolation, `named_fragment` allows you to use named params in your fragments.
`named_fragment` is implemented as a macro on top of Ecto's `fragment` macro.

So `named_fragment("coalesce(:a, :b, :a)", a: 1, b: 2)` will be converted to `fragment("coalesce(?, ?, ?)", 1, 2, 1)` at compile-time.

To use the `named_fragment` macro, `use EctoNamedFragment` in your module:
`named_fragment` is implemented as a macro on top of Ecto's `fragment` macro.

So `named_fragment("coalesce(#{:a}, #{:b}, #{:a})", a: 1, b: 2)` will
be converted to `fragment("coalesce(?, ?, ?)", 1, 2, 1)` at compile-time.

```elixir
defmodule TestQuery do
import Ecto.Query
use EctoNamedFragment
import EctoNamedFragment

def test_query do
left = 1
right = 2

query = from u in "users",
select: named_fragment("coalesce(:left, :right)", left: "example", right: "input")
select: named_fragment("coalesce(#{:left}, #{:right})", left: "example", right: "input")

Repo.all(query)
end
Expand Down
40 changes: 8 additions & 32 deletions lib/ecto_named_fragment.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule EctoNamedFragment do
alias EctoNamedFragment.ConvertToEctoFragment

@moduledoc """
`EctoNamedFragment` is a library for using named-params in Ecto fragments.
Expand All @@ -7,54 +9,28 @@ defmodule EctoNamedFragment do
`named_fragment` is implemented as a macro on top of Ecto's `fragment` macro.
So `named_fragment("coalesce(:a, :b, :a)", a: 1, b: 2)` will
So `named_fragment("coalesce(#{:a}, #{:b}, #{:a})", a: 1, b: 2)` will
be converted to `fragment("coalesce(?, ?, ?)", 1, 2, 1)` at compile-time.
To use the `named_fragment` macro, `use EctoNamedFragment` in your module:
```elixir
defmodule TestQuery do
import Ecto.Query
use EctoNamedFragment
import EctoNamedFragment
def test_query do
query = from u in "users",
select: named_fragment("coalesce(:left, :right)", left: "example", right: "input")
select: named_fragment("coalesce(#{:left}, #{:right})", left: "example", right: "input")
Repo.all(query)
end
end
```
"""
alias EctoNamedFragment.ConvertToPositionedArgs
defmacro named_fragment(query, params) when is_list(params) do
{query, frags} = ConvertToEctoFragment.call(query, params)

defmacro __using__(_opts) do
quote do
@doc """
An Ecto fragment() with named params.
This macro converts a named-params fragment into the default Ecto
fragment with positioned params.
```elixir
named_fragment("coalesce(:left, :right)", left: "example", right: "input")
```
into
```elixir
fragment("coalesce(?, ?)", "example", "input")
```
"""
defmacro named_fragment(query, args) when is_binary(query) and is_list(args) do
case ConvertToPositionedArgs.call(query, args) do
{:ok, query, args} ->
quote do: fragment(unquote_splicing([query | args]))

{:error, reason} ->
raise "error converting named fragment: #{inspect(reason)}"
end
end
fragment(unquote(query), unquote_splicing(frags))
end
end
end
37 changes: 37 additions & 0 deletions lib/ecto_named_fragment/convert_to_ecto_fragment.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
defmodule EctoNamedFragment.ConvertToEctoFragment do
@moduledoc false

import EctoNamedFragment.Exceptions, only: [error!: 1]

def call({:<<>>, _, pieces}, params) do
if not Keyword.keyword?(params) do
error!(
"named_fragment(...) expect a keyword list as the last argument, got: #{Macro.to_string(params)}"
)
end

query =
pieces
|> Enum.map(fn
"" <> binary -> binary
_ -> "?"
end)
|> Enum.join()

frags =
Enum.flat_map(pieces, fn
{:"::", _, [{_, _, [val]} | _]} when is_atom(val) ->
[Keyword.fetch!(params, val)]

{:"::", _, [{_, _, [val]} | _]} ->
error!(
"names in named_fragment(...) queries must be atoms, got: #{Macro.to_string(val)}"
)

_ ->
[]
end)

{query, frags}
end
end
46 changes: 0 additions & 46 deletions lib/ecto_named_fragment/convert_to_positioned_args.ex

This file was deleted.

23 changes: 23 additions & 0 deletions lib/ecto_named_fragment/exceptions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
defmodule EctoNamedFragment.Exceptions do
defmodule CompileError do
@moduledoc """
Raised at compilation time when the named fragment cannot be compiled.
"""
defexception [:message]
end

def error!(message) when is_binary(message) do
{:current_stacktrace, [_ | t]} = Process.info(self(), :current_stacktrace)

t =
Enum.drop_while(t, fn
{mod, _, _, _} ->
String.starts_with?(Atom.to_string(mod), ["Elixir.EctoNamedFragment."])

_ ->
false
end)

reraise CompileError, [message: message], t
end
end
27 changes: 0 additions & 27 deletions lib/ecto_named_fragment/fragment_query_parser.ex

This file was deleted.

6 changes: 2 additions & 4 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule EctoNamedFragment.MixProject do
def project do
[
app: :ecto_named_fragment,
version: "0.1.0",
version: "0.2.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps(),
Expand All @@ -25,10 +25,8 @@ defmodule EctoNamedFragment.MixProject do

defp deps do
[
{:nimble_parsec, "~> 1.3"},

# Dev, Test
{:ecto, "~> 3.10", only: [:dev, :test]},
{:ecto, ">= 3.0.0", only: [:dev, :test]},
{:ex_doc, "~> 0.30.3", only: [:dev, :test]},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false}
]
Expand Down
68 changes: 68 additions & 0 deletions test/ecto_named_fragment/convert_to_ecto_fragment_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
defmodule ConvertToEctoFragmentTest do
use ExUnit.Case
doctest EctoNamedFragment

alias EctoNamedFragment.ConvertToEctoFragment
alias EctoNamedFragment.Exceptions.CompileError

test "builds a fragment query string and splits params from kw list" do
assert ConvertToEctoFragment.call(
quote do
"foo(#{:a}, #{:b})"
end,
a: 0,
b: 1
) ==
{"foo(?, ?)", [0, 1]}
end

test "allows repeated param names" do
assert ConvertToEctoFragment.call(
quote do
"foo(#{:a}, #{:b}, #{:a})"
end,
a: 0,
b: 1
) ==
{"foo(?, ?, ?)", [0, 1, 0]}
end

test "raises for unknown params" do
assert_raise KeyError,
"key :b not found in: [a: 0]",
fn ->
ConvertToEctoFragment.call(
quote do
"foo(#{:a}, #{:b})"
end,
a: 0
)
end
end

test "raises on non-atom names" do
assert_raise CompileError,
"names in named_fragment(...) queries must be atoms, got: a",
fn ->
ConvertToEctoFragment.call(
quote do
"foo(#{a})"
end,
a: 0
)
end
end

test "raises on non-kw name lists" do
assert_raise CompileError,
"named_fragment(...) expect a keyword list as the last argument, got: [\"a\"]",
fn ->
ConvertToEctoFragment.call(
quote do
"foo(#{:a})"
end,
["a"]
)
end
end
end
25 changes: 0 additions & 25 deletions test/ecto_named_fragment/convert_to_positioned_args_test.exs

This file was deleted.

Loading

0 comments on commit 532944e

Please sign in to comment.