Skip to content

mtarnovan/manole

Repository files navigation

Manole

Manole is a query builder for Ecto.

N.B. This is a toy project, if you're looking for something viable take a look at Flop.

Allows parsing of a filter and appending it to the given queryable.

A filter definition looks like this:

filter = %{
  combinator: :or,
  rules: [
    %{field: "name", operator: "=", value: "Alice"},
    %{
      combinator: :or,
      rules: [
        %{field: "name", operator: "=", value: "Bob"},
        %{field: "age", operator: ">", value: "30"},
        %{combinator: :and,
          rules: [
            %{field: "name", operator: "=", value: "Carol"},
            %{field: "age", operator: "<", value: "27"},
            %{field: "income", operator: ">", value: "100000"},
          ]
        }
      ]
    }
  ]
}

Given this filter and the following data:

  • Name: Alice, Age: 30, Income: 50000
  • Name: Bob, Age: 35, Income: 60000
  • Name: Carol, Age: 25, Income: 40000
alias Manole.{Repo, Person}
Repo.insert!(%Person{name: "Alice", age: 30, income: 50000})
Repo.insert!(%Person{name: "Bob", age: 35, income: 60000})
Repo.insert!(%Manole.Person{name: "Carol", age: 25, income: 40000})

We can build an Ecto query from it:

iex> {:ok, query} = Manole.build_query(Person, filter)
{:ok,
 #Ecto.Query<from p0 in Manole.Person,
  where: p0.name == ^"Alice" or
  (p0.name == ^"Bob" or p0.age > ^"30" or
     (p0.name == ^"Carol" and p0.age < ^"27" and p0.income > ^"100000"))>}
iex> Repo.all(query) |> Enum.map(& &1.name)
["Alice", "Bob"]

Carol is excluded because she does not match "Alice", "Bob", or "Age > 30", and fails the income requirement (> 100000) of the nested AND block.

Association Support

If a rule contains a field with dots, it is interpreted as an association. The queryable is inspected to check if it already has a named binding for that join, and if it doesn't, it is added automatically.

filter = %{
  rules: [%{value: "one", operator: :contains, field: "dogs.toys.name"}],
  combinator: :or
}

alias Manole.{Repo, Person, Factory}

Factory.insert_person_with_dog_and_toy(
  %{name: "Alice", age: 30},
  %{name: "Gigi"},
  %{name: "Bone", color: "blue"}
)
Factory.insert_person_with_dog_and_toy(
  %{name: "Bob", age: 30},
  %{name: "Jimbo"},
  %{name: "Trombone", color: "pink"}
)
Factory.insert_person_with_dog_and_toy(
  %{name: "Carol", age: 30},
  %{name: "Rex"},
  %{name: "Ball", color: "red"}
)
{:ok, query} = Manole.build_query(Person, filter)

would result in something like this:

Ecto.Query<from p0 in Manole.Person, join: d1 in assoc(p0, :dogs), as: :dogs,
  join: t2 in assoc(d1, :toys), as: :dogs_toys,
  where: ilike(as(:dogs_toys).name, ^"%one%")>

Allowlisting (Security)

An allowlist is a list of fields to allow on the input queryable and the associations. By default (if no allowlist is provided), all fields are allowed. If an allowlist is provided, only fields in the list are accessible.

Example:

Assuming the input queryable is a Post, an allowlist given as:

opts = [
  allowlist: [
    :title,
    comments: [:inserted_at, tags: [:name]]
  ]
]
Manole.build_query(Post, filter, opts)

this would allow filtering on post.title, post.comments.inserted_at and post.comments.tags.name.

If a field in the filter is not found in the allowlist, {:error, "Field '...' is not in allowlist"} is returned.

Supported Operators

  • =: Equal (==, eq)
  • !=: Not Equal (neq)
  • >: Greater Than (gt)
  • >=: Greater Than or Equal (gte)
  • <: Less Than (lt)
  • <=: Less Than or Equal (lte)
  • contains: Case-insensitive substring match (ilike %value%). Wildcards % and _ in the value are escaped.

TODOs

  • implement allowlisting
  • add support for joins and querying on association
  • remove dependency on libgraph
  • CI/CD Pipeline (GitHub Actions)
  • Expanded Operator Support (in, is_nil)
  • Test Coverage & Docs (mix coveralls, ExDoc)

About

Manole is an query builder for Ecto (Elixir)

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages