Validate prepared Ecto.Query structs before they run, so invalid query patterns are easier to catch and harder to ship.

Use bylaw_ecto_query to enforce application-specific query invariants, keep queries readable and maintainable, and codify conventions around ordering, filtering, and other query behavior. Callers choose checks explicitly and pass them to Bylaw.Ecto.Query.validate/3 or Bylaw.Ecto.Query.validate/4.

Warning

bylaw_ecto_query inspects prepared %Ecto.Query{} structs. Ecto exposes Ecto.Query.t(), but the internal shape of query expressions is not a stable extension API. Review and run your enabled checks when upgrading Ecto.

Installation

Add :bylaw_ecto_query to your dependencies:

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

Usage

For repo-wide validation, choose the query checks you want to enforce and pass them explicitly to Bylaw.Ecto.Query.validate/3 from Ecto.Repo.prepare_query/3:

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  @query_checks [
    Bylaw.Ecto.Query.Checks.RequiredOrder,
    Bylaw.Ecto.Query.Checks.DeterministicOrder,
    {Bylaw.Ecto.Query.Checks.MandatoryWhereKeys,
     rules: [fields: [:organization_id]]},
    {Bylaw.Ecto.Query.Checks.ExplicitVisibilityPredicates,
     rules: [
       [where: [ecto_schemas: [Post]], fields: [:deleted_at, :archived_at]],
       [where: [tables: ["comments"]], fields: [:deleted_at]]
     ]}
  ]

  @impl Ecto.Repo
  def prepare_query(operation, query, opts) do
    case Bylaw.Ecto.Query.validate(
           operation,
           query,
           @query_checks,
           Keyword.get(opts, :bylaw, [])
         ) do
      :ok -> {query, opts}
      {:error, issues} -> raise Bylaw.Ecto.Query.Issue.format_many(issues)
    end
  end
end

Call-site overrides

Ecto passes repo call options to prepare_query/3, but Bylaw only uses them when your repo explicitly passes them to validate/4:

def prepare_query(operation, query, opts) do
  case Bylaw.Ecto.Query.validate(
         operation,
         query,
         @query_checks,
         Keyword.get(opts, :bylaw, [])
       ) do
    :ok -> {query, opts}
    {:error, issues} -> raise Bylaw.Ecto.Query.Issue.format_many(issues)
  end
end
Repo.all(query, bylaw: false)

Repo.all(query,
  bylaw: [
    {Bylaw.Ecto.Query.Checks.RequiredOrder, validate: false}
  ])

Repo.all(query,
  bylaw: [
    {Bylaw.Ecto.Query.Checks.MandatoryWhereKeys,
     rules: [fields: [:account_id]]}
  ])

Call-site specs replace matching repo-wide specs entirely and append new checks after unchanged repo-wide checks.

If you want to enable validation only in certain environments, gate the call with your own application config:

# config/dev.exs and config/test.exs
config :my_app, :bylaw, validate_ecto_queries?: true

# config/prod.exs
config :my_app, :bylaw, validate_ecto_queries?: false
defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  @query_checks [
    Bylaw.Ecto.Query.Checks.RequiredOrder,
    {Bylaw.Ecto.Query.Checks.MandatoryWhereKeys,
     rules: [fields: [:organization_id]]}
  ]

  @impl Ecto.Repo
  def prepare_query(operation, query, opts) do
    if bylaw_ecto_query_enabled?() do
      validate_query!(operation, query)
    end

    {query, opts}
  end

  defp validate_query!(operation, query) do
    case Bylaw.Ecto.Query.validate(operation, query, @query_checks) do
      :ok -> :ok
      {:error, issues} -> raise Bylaw.Ecto.Query.Issue.format_many(issues)
    end
  end

  defp bylaw_ecto_query_enabled? do
    :my_app
    |> Application.get_env(:bylaw, [])
    |> Keyword.get(:validate_ecto_queries?, false)
  end
end

This config belongs to :my_app. bylaw_ecto_query does not read application config or register checks globally.

Rules DSL

Every check can be scoped with rules:. Rule scope is shared across checks; check-specific rule options stay specific to each check.

A bare module applies that check globally with its defaults:

@query_checks [
  Bylaw.Ecto.Query.Checks.RequiredOrder
]

{Check, rules: [...]} runs the check only when at least one rule scope matches. A single global rule can use the shorthand keyword form:

@query_checks [
  {Bylaw.Ecto.Query.Checks.MandatoryWhereKeys,
   rules: [fields: [:organization_id]]}
]

Scoped rules use the list-of-rules form:

@query_checks [
  {Bylaw.Ecto.Query.Checks.ExplicitVisibilityPredicates,
   rules: [
     [where: [ecto_schemas: [Post]], fields: [:deleted_at, :archived_at]],
     [where: [tables: ["comments"]], fields: [:deleted_at]]
   ]}
]

Scope keys are the same for every check:

KeyMeaning
where:Run the rule when at least one matcher matches. Omitted where: means the rule applies globally.
except:Suppress the rule when at least one matcher matches, even if where: also matches.

Matchers use plural keys with list values:

rules: [
  where: [
    ecto_schemas: [Post],
    tables: ["posts"],
    db_schemas: ["public"],
    operations: [:all, :stream]
  ]
]

Checks with no check-specific rule options accept only shared scope keys and validate: false inside rules. Checks with required rule options validate those options only for matching rules, so non-matching scoped rules do not need to be valid for the current query. Top-level validate: false is a check spec option that disables the whole check, especially when passed through call-site overrides. Rule-level validate: false disables only that rule.

Built-in checks live under Bylaw.Ecto.Query.Checks.*. Start with the checks that match your application invariants; each check module documents its own examples, notes, options, and copyable rule examples.