{"id":19210899,"url":"https://github.com/mathieuprog/query_builder","last_synced_at":"2026-01-12T09:22:27.174Z","repository":{"id":46861471,"uuid":"227075699","full_name":"mathieuprog/query_builder","owner":"mathieuprog","description":"Compose Ecto queries without effort","archived":false,"fork":false,"pushed_at":"2026-01-10T19:38:54.000Z","size":666,"stargazers_count":82,"open_issues_count":0,"forks_count":8,"subscribers_count":2,"default_branch":"master","last_synced_at":"2026-01-10T22:14:40.386Z","etag":null,"topics":["ecto","elixir","elixir-lang","phoenix","query-builder"],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mathieuprog.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"mathieuprog"}},"created_at":"2019-12-10T09:15:39.000Z","updated_at":"2026-01-10T19:38:58.000Z","dependencies_parsed_at":"2022-08-26T16:10:45.034Z","dependency_job_id":"4366a5a5-0e1c-4005-9e7a-7047747d9406","html_url":"https://github.com/mathieuprog/query_builder","commit_stats":{"total_commits":92,"total_committers":2,"mean_commits":46.0,"dds":0.07608695652173914,"last_synced_commit":"1442894a0ebf8ed5b3ee9d62fda99c797ab4d979"},"previous_names":[],"tags_count":31,"template":false,"template_full_name":null,"purl":"pkg:github/mathieuprog/query_builder","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fquery_builder","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fquery_builder/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fquery_builder/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fquery_builder/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mathieuprog","download_url":"https://codeload.github.com/mathieuprog/query_builder/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mathieuprog%2Fquery_builder/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28337689,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-12T06:09:07.588Z","status":"ssl_error","status_checked_at":"2026-01-12T06:05:18.301Z","response_time":98,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ecto","elixir","elixir-lang","phoenix","query-builder"],"created_at":"2024-11-09T13:39:42.606Z","updated_at":"2026-01-12T09:22:27.168Z","avatar_url":"https://github.com/mathieuprog.png","language":"Elixir","funding_links":["https://github.com/sponsors/mathieuprog"],"categories":[],"sub_categories":[],"readme":"# Query Builder\n\nQueryBuilder is a thin layer over Ecto that builds composable queries from plain Elixir data structures.\n\n## Index\n\n- [Key Features](#key-features)\n- [Setup](#setup)\n- [Feature Overview](#feature-overview)\n- [Examples](#examples)\n\n## Key Features\n\n### Controller/Resolver‑Driven Query Options\n\nControllers/GraphQL resolvers can pass filter/sort/page options into a single context list function via `from_opts/2`, without creating new context functions per option or writing custom option-handling logic in the context.\n\n```elixir\ndef list_users(opts \\\\ []) do\n  User\n  |\u003e QueryBuilder.where(deleted: false)\n  |\u003e QueryBuilder.from_opts(opts)\n  |\u003e Repo.all()\nend\n\n# controller/resolver\nlist_users(where: [name: \"Alice\"], order_by: [desc: :inserted_at], limit: 50)\n```\n\nTo let external callers request preloads safely, expose explicit `include:` keys that the context allowlists via `includes:`:\n\n```elixir\ndef list_users(opts \\\\ []) do\n  includes = [role: :role]\n\n  User\n  |\u003e QueryBuilder.where(deleted: false)\n  |\u003e QueryBuilder.from_opts(opts, includes: includes)\n  |\u003e Repo.all()\nend\n\n# controller/resolver\nlist_users(where: [name: \"Alice\"], include: [:role])\n```\n\nFor optional params, use `maybe_where/*` / `maybe_order_by/*` to conditionally apply clauses.\n\n```elixir\ndef list_users(opts \\\\ []) do\n  include_deleted? = Keyword.get(opts, :include_deleted?, false)\n  qb_opts = Keyword.drop(opts, [:include_deleted?])\n\n  User\n  |\u003e QueryBuilder.maybe_where(not include_deleted?, deleted: false)\n  |\u003e QueryBuilder.maybe_order_by(not Keyword.has_key?(qb_opts, :order_by), desc: :inserted_at, desc: :id)\n  |\u003e QueryBuilder.from_opts(qb_opts)\n  |\u003e Repo.all()\nend\n```\n\n### Data‑Driven Query Composition\n\nQueryBuilder lets you express complex filtering and composition as plain Elixir data, without positional binding gymnastics and without manually building `dynamic/2` trees for the common cases.\n\nFor example, QueryBuilder lets you express “OR of AND groups” directly as nested lists:\n\n```elixir\n# (name == \"Alice\") OR (name == \"Bob\" AND deleted == false)\nor_groups = [[name: \"Alice\"], [name: \"Bob\", deleted: false]]\n\nUser\n|\u003e QueryBuilder.where(active: true)\n|\u003e QueryBuilder.where_any(or_groups)\n|\u003e Repo.all()\n```\n\nIn Ecto, when the OR groups come from runtime data (e.g. controller params), you typically have to reduce them into a `dynamic/2` expression:\n\n```elixir\n# (name == \"Alice\") OR (name == \"Bob\" AND deleted == false)\nor_groups = [[name: \"Alice\"], [name: \"Bob\", deleted: false]]\n\nor_dynamic =\n  Enum.reduce(or_groups, dynamic([u], false), fn group, or_acc -\u003e\n    and_dynamic =\n      Enum.reduce(group, dynamic([u], true), fn {field, value}, and_acc -\u003e\n        dynamic([u], ^and_acc and field(u, ^field) == ^value)\n      end)\n\n    dynamic([u], ^or_acc or ^and_dynamic)\n  end)\n\nUser\n|\u003e where([u], u.active == true)\n|\u003e where(^or_dynamic)\n```\n\n### Assoc Queries Without Binding Boilerplate\n\nQueryBuilder lets you reference association fields with `@` tokens (e.g. `:name@role`) instead of manually writing joins and positional binding lists.\n\n```elixir\nUser\n|\u003e QueryBuilder.order_by(:role, asc: :name@role, asc: :nickname)\n|\u003e Repo.all()\n```\n\nEcto:\n\n```elixir\nUser\n|\u003e join(:left, [u], r in assoc(u, :role))\n|\u003e order_by([u, r], asc: r.name, asc: u.nickname)\n|\u003e Repo.all()\n```\n\nImplicit joins created by QueryBuilder default to `LEFT`. If an association must exist, use a `!` join marker in `assoc_fields` (e.g. `:role!`) or call `inner_join/2` explicitly.\n\n```elixir\n# Only users that have a role named \"admin\"\nUser\n|\u003e QueryBuilder.where(:role!, name@role: \"admin\")\n|\u003e Repo.all()\n```\n\n### Rich Filter DSL (Operators + Field Comparisons)\n\nBeyond `{field, value}` equality, you can use `{field, operator, value}` for common operators (ranges, membership, text search). For field-to-field comparisons, use the `@self` marker as the value.\n\n```elixir\nnickname_query = \"admin\"\n\nfilters = [\n  {:nickname, :contains, nickname_query, [case: :i]},\n  {:inserted_at, :ge, from},\n  {:id, :in, ids}\n]\n\nUser\n|\u003e QueryBuilder.where(filters)\n|\u003e Repo.all()\n```\n\n```elixir\nUser\n|\u003e QueryBuilder.where_exists_subquery([authored_articles: :comments],\n  scope: [],\n  where: [\n    {:body@comments, :contains, :nickname@self, [case: :insensitive]}\n  ]\n)\n|\u003e Repo.all()\n```\n\n### Keyset/Cursor-Based Pagination\n\n`paginate/3` returns an opaque cursor derived from your `order_by`; pass it back unchanged to fetch the next/previous page.\n\n```elixir\n# First page (no cursor)\npagination_opts = [page_size: 10]\n\n%{paginated_entries: users, pagination: page} =\n  User\n  |\u003e QueryBuilder.order_by(asc: :nickname, desc: :email)\n  |\u003e QueryBuilder.paginate(Repo, pagination_opts)\n\n# Next page: pass back the opaque cursor returned in pagination\npagination_opts =\n  Keyword.merge(pagination_opts,\n    cursor: page.cursor_for_entries_after,\n    direction: :after\n  )\n\n%{paginated_entries: next_users, pagination: next_page} =\n  User\n  |\u003e QueryBuilder.order_by(asc: :nickname, desc: :email)\n  |\u003e QueryBuilder.paginate(Repo, pagination_opts)\n```\n\n`paginate/3` is an alias for `paginate_cursor/3`. For offset/row pagination (no cursor), use `paginate_offset/3`.\n\n### Higher‑Level Query Helpers\n\nQueryBuilder also includes higher-level helpers that are verbose to write correctly in raw Ecto.\n\n```elixir\nalias QueryBuilder, as: QB\n\n# Latest child row per parent\nUser\n|\u003e QB.left_join_latest(:authored_articles, order_by: [desc: :inserted_at, desc: :id])\n|\u003e Repo.all()\n# =\u003e [{%User{}, %Article{} | nil}, ...]\n\n# Top N rows per group\nPost\n|\u003e QB.top_n_per(partition_by: [:subreddit_id], order_by: [desc: :score, desc: :id], n: 3)\n|\u003e Repo.all()\n```\n\n### Custom, User-Defined Query Operations (Extension)\n\n`QueryBuilder.Extension` lets you build an app-specific “QB module” that adds your own query operations on top of QueryBuilder.\n\n```elixir\ndefmodule MyApp.QB do\n  use QueryBuilder.Extension, from_opts_full_ops: [:where_initcap]\n  import Ecto.Query\n\n  def where_initcap(query, field, value) do\n    where(query, fn resolve -\u003e\n      {field, binding} = resolve.(field)\n      dynamic([{^binding, x}], fragment(\"initcap(?)\", field(x, ^field)) == ^value)\n    end)\n  end\nend\n\n# trusted/internal (full mode)\nalias MyApp.QB\n\nMyApp.User\n|\u003e QB.where_initcap(:name, \"Alice\")\n|\u003e Repo.all()\n```\n\n## Setup\n\nAdd `query_builder` as a dependency:\n\n```elixir\ndef deps do\n  [\n    {:query_builder, \"~\u003e 2.1\"}\n  ]\nend\n```\n\n## Feature Overview\n\n### Operations\n\n- Filtering: `where/*`, `where_any/*`, `maybe_where/*`\n- Sorting: `order_by/*`, `maybe_order_by/*`\n- Offset pagination: `limit/2`, `offset/2`\n- Keyset pagination: `paginate/3` (cursor-based)\n- Joins: `inner_join/2`, `left_join/4`, `left_join_leaf/4`, `left_join_path/4`\n- To-many existence filters: `where_exists_subquery/3`, `where_not_exists_subquery/3`, `where_has/3`, `where_missing/3`\n- Preloads: `preload_separate/2`, `preload_separate_scoped/3`, `preload_through_join/2`\n- Selection \u0026 distinctness: `select/*`, `select_merge/*`, `distinct/*`, `distinct_roots/1` (Postgres-only)\n- Grouping \u0026 aggregates: `group_by/*`, `having/*`, `having_any/*`, aggregates (`count/*`, `count_distinct/1`, `avg/1`, `sum/1`, `min/1`, `max/1`, `array_agg/*` (Postgres-only))\n- Postgres query patterns: `top_n_per/*`, `first_per/*`, `left_join_latest/3`, `left_join_top_n/3`\n\n### Tokens, assoc paths, and join intent\n\n- Tokens are atoms/strings: `:field`, `:field@assoc`, or full paths like `:field@assoc@nested_assoc...`.\n- `field@assoc` is shorthand and raises if `@assoc` is ambiguous; use a full-path token to disambiguate.\n- Assoc paths (`assoc_fields`) support join markers:\n  - `:role` (neutral): reuse an existing join qualifier if already joined; otherwise QueryBuilder defaults to `LEFT`\n  - `:role?`: force `LEFT`\n  - `:role!`: force `INNER`\n- `@self` marks field-to-field comparisons (e.g. `{:inserted_at@comments, :gt, :inserted_at@self}`).\n\n### `from_opts`\n\n- `from_opts/2` defaults to boundary mode (for controllers/resolvers): allowlists `where`, `where_any`, `order_by`, `limit`, `offset`.\n- To expose preloads safely at the boundary, let callers pass `include: [...]` and pass an `includes:` allowlist to `from_opts/3` (contexts define the meaning of each include key).\n- `from_opts/3` with `mode: :full` enables the full QueryBuilder surface (use when the caller knows the base query’s implementation/shape).\n- `args/*` wraps multiple arguments for `from_opts(..., mode: :full)` (e.g. calling `where/4`, `order_by/3`, `select/3`, or extension ops).\n\n### Extensions\n\n- `QueryBuilder.Extension` lets you define an app-specific module that wraps QueryBuilder and adds custom operations you can call directly.\n- If you use `from_opts` on that module, you must explicitly allowlist which custom operations are callable via `from_opts_full_ops: [...]` (full mode) and optionally `boundary_ops_user_asserted: [...]` (boundary mode).\n\n### Utilities\n\n- `new/1`: wrap an existing Ecto queryable into a `%QueryBuilder.Query{}`.\n- `subquery/2`: build an `Ecto.SubQuery` using QueryBuilder operations (`from_opts(..., mode: :full)` + `Ecto.Query.subquery/1`).\n- `default_page_size/0`: reads `config :query_builder, :default_page_size`.\n\n## Examples\n\n### Filter “has related rows” (to-many) without duplicates\n\nFilter root rows through a to-many association via correlated `EXISTS(...)` without join-multiplying roots.\n\n```elixir\nalias QueryBuilder, as: QB\n\nUser\n|\u003e QB.where_has(:authored_articles, published@authored_articles: true)\n|\u003e Repo.all()\n```\n\n### Filter “missing related rows” (to-many)\n\nFilter root rows through a to-many association via correlated `NOT EXISTS(...)`.\n\n```elixir\nalias QueryBuilder, as: QB\n\nUser\n|\u003e QB.where_missing(:authored_articles)\n|\u003e Repo.all()\n```\n\n### Ensure unique roots after joining a to-many association (Postgres)\n\nWhen you must join a to-many association and still want unique root rows (especially with `limit/offset`), use `distinct_roots/1`.\n\n```elixir\nalias QueryBuilder, as: QB\n\nUser\n|\u003e QB.left_join(:authored_articles)\n|\u003e QB.order_by(asc: :id)\n|\u003e QB.order_by(:authored_articles, desc: :inserted_at@authored_articles, desc: :id@authored_articles)\n|\u003e QB.distinct_roots()\n|\u003e QB.offset(20)\n|\u003e QB.limit(10)\n|\u003e Repo.all()\n```\n\n### Scoped separate preload (Ecto query-preload equivalent)\n\nPreload a direct association with an explicit scope using a separate query (`preload_separate_scoped/3`).\n\n```elixir\nalias QueryBuilder, as: QB\n\nUser\n|\u003e QB.preload_separate_scoped(:authored_articles,\n  where: [published: true],\n  order_by: [desc: :inserted_at]\n)\n|\u003e Repo.all()\n```\n\n### Join-scoped preload (preload only joined rows)\n\nPreload an association *through its join binding* so preloaded rows reflect the join (including join `on:` filters).\n\n```elixir\nalias QueryBuilder, as: QB\n\nUser\n|\u003e QB.left_join(:authored_articles, published@authored_articles: true)\n|\u003e QB.preload_through_join(:authored_articles)\n|\u003e Repo.all()\n```\n\n### Nested join semantics: LEFT every hop vs INNER path + LEFT leaf\n\nChoose whether intermediate hops in a nested path are `INNER` (`left_join_leaf/4`) or `LEFT` (`left_join_path/4`).\n\n```elixir\nalias QueryBuilder, as: QB\n\n# INNER authored_articles, LEFT comments\nq1 = User |\u003e QB.left_join_leaf([authored_articles: :comments])\n\n# LEFT authored_articles, LEFT comments\nq2 = User |\u003e QB.left_join_path([authored_articles: :comments])\n```\n\n### Grouping + HAVING with aggregate helpers\n\nGroup and filter groups using `group_by/*` + `having/*` with aggregate helpers like `count/0`.\n\n```elixir\nalias QueryBuilder, as: QB\n\nUser\n|\u003e QB.group_by(:role, :name@role)\n|\u003e QB.having([{QB.count(:id), :gt, 10}])\n|\u003e QB.select({:name@role, QB.count(:id)})\n|\u003e Repo.all()\n```\n\n### `array_agg` with `DISTINCT`, `ORDER BY`, and `FILTER` (Postgres)\n\nBuild grouped results with Postgres aggregates like `array_agg` (including `FILTER (WHERE ...)`).\n\n```elixir\nalias QueryBuilder, as: QB\n\nArticle\n|\u003e QB.group_by(:author_id)\n|\u003e QB.select(%{\n  author_id: :author_id,\n  publisher_ids:\n    QB.array_agg(:publisher_id,\n      distinct?: true,\n      order_by: [asc: :publisher_id],\n      filter: [{:publisher_id, :ne, nil}]\n    )\n})\n|\u003e Repo.all()\n```\n\n### Build an `IN (subquery)` using QueryBuilder ops\n\nUse `subquery/2` to build an `Ecto.SubQuery` from QueryBuilder options and use it in filters.\n\n```elixir\nalias QueryBuilder, as: QB\n\nactive_user_ids =\n  QB.subquery(User,\n    where: [active: true],\n    select: :id\n  )\n\nArticle\n|\u003e QB.where({:author_id, :in, active_user_ids})\n|\u003e Repo.all()\n```\n\n### Top N children per parent (Postgres, LATERAL)\n\nFetch up to N association rows per parent via `LEFT JOIN LATERAL` and group `{parent, child}` rows in Elixir.\n\n```elixir\nalias QueryBuilder, as: QB\n\nrows =\n  User\n  |\u003e QB.left_join_top_n(:authored_articles, n: 3, order_by: [desc: :inserted_at, desc: :id])\n  |\u003e Repo.all()\n\ntop_articles_by_user_id =\n  Enum.group_by(rows, fn {u, _a} -\u003e u.id end, fn {_u, a} -\u003e a end)\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmathieuprog%2Fquery_builder","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmathieuprog%2Fquery_builder","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmathieuprog%2Fquery_builder/lists"}