Bylaw.Ecto.Query

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 {: .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.3.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 c: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

Database-backed deterministic ordering

Bylaw.Ecto.Query.Checks.DeterministicOrder trusts root Ecto primary keys by default. To recognize other verified unique keys, pass a zero-arity resolver that returns database columns by {database_schema, table}.

Build the check list at runtime when the resolver closes over a repo:

defp query_checks do
[
{Bylaw.Ecto.Query.Checks.DeterministicOrder,
unique_keys: fn ->
Bylaw.Db.Adapters.Postgres.UniqueKeys.fetch!(__MODULE__)
end}
]
end
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

The resolver may use bylaw_postgres, another database integration, or static application-owned metadata. Bylaw invokes it only when an ordered query has not already been proven deterministic by its reflected primary key. Resolver failures and malformed catalogues raise instead of silently falling back.

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.