├── .formatter.exs ├── .gitignore ├── .iex.exs ├── README.md ├── config ├── config.exs └── test.exs ├── example ├── .gitignore ├── .iex.exs ├── README.md ├── config │ └── config.exs ├── lib │ ├── example.ex │ ├── example │ │ ├── application.ex │ │ └── repo.ex │ ├── project.ex │ ├── task.ex │ ├── tasks │ │ └── populate.ex │ └── user.ex ├── mix.exs ├── mix.lock └── priv │ └── repo │ └── migrations │ └── 20191024192849_create_tables.exs ├── lib ├── ecto_query_string.ex └── ecto_query_string │ └── reflection.ex ├── mix.exs ├── mix.lock └── test ├── ecto_query_string └── reflection_test.exs ├── ecto_query_string_test.exs ├── support ├── bar.ex ├── foo.ex ├── query_helpers.ex ├── repo.ex └── user.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ecto_query_string-*.tar 24 | .elixir_ls/ 25 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | import Ecto.Query 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoQueryString 2 | 3 | Compose an `Ecto.Query` with a querystring 4 | 5 | ## Installation 6 | 7 | Add `:ecto_query_string` to your list of dependencies in `mix.exs`: 8 | 9 | ```elixir 10 | def deps do 11 | [ 12 | {:ecto_query_string, "~> 0.1.0"} 13 | ] 14 | end 15 | ``` 16 | 17 | ## Usage 18 | 19 | Say you have the following schemas: 20 | 21 | ```elixir 22 | defmodule Foo do 23 | use Ecto.Schema 24 | 25 | schema "foos" do 26 | field(:name, :string) 27 | field(:age, :integer) 28 | has_many(:bars, Bar) 29 | end 30 | end 31 | 32 | defmodule Bar do 33 | use Ecto.Schema 34 | 35 | schema "bars" do 36 | field(:title, :string) 37 | field(:likes, :integer) 38 | belongs_to(:foo, Foo) 39 | end 40 | end 41 | 42 | ``` 43 | 44 | You can do things like this: 45 | 46 | ```elixir 47 | query = Ecto.Query.from(user in User) 48 | query_string = "username=mrmicahcooper&greater:age=18&limit=10" 49 | EctoQueryString.query(query, query_string) 50 | 51 | query_params = %{"username" => "mrmicahcooper", "greater:age" => "18", "limit" => "10"} 52 | EctoQueryString.query(query, query_params) 53 | 54 | keyword_list = [username: "mrmicahcooper"] 55 | EctoQueryString.query(query, keyword_list) 56 | ``` 57 | 58 | And get: 59 | 60 | ```elixir 61 | Ecto.Query.from(u0 in User, 62 | where: u0.age > ^"18", 63 | where: u0.username == ^"mrmicahcooper", 64 | limit: ^"10" 65 | ) 66 | ``` 67 | 68 | Here is the full DSL 69 | 70 | ```elixir 71 | # Basic Queries 72 | "name=micah" => where: foo.name = ^"micah" 73 | "name=micah,bob" => where: foo.name in ^["micah", "bob"] 74 | "!name=micah" => where: foo.name != ^"micah" 75 | "!name=micah,bob" => where: foo.name not in ^["micah", "bob"] 76 | "like:foo=bar*" => where: like(x.foo, ^"bar%") 77 | "like:foo=*bar" => where: like(x.foo, ^"%bar") 78 | "like:name=*micah*" => where: like(foo.name, ^"%micah%") 79 | "ilike:name=micah*" => where: ilike(foo.name, ^"micah%") 80 | "ilike:name=*micah" => where: ilike(foo.name, ^"%micah") 81 | "ilike:foo=*bar*" => where: ilike(x.foo, ^"%bar%") 82 | "less:age=99" => where: foo.age < 99 83 | "lessequal:age=99" => where: foo.age <= 99 84 | "greater:age=40" => where: foo.age > 40 85 | "greaterequal:age=40" => where: foo.age >= 40 86 | "range:age=40:99" => where: foo.age < 99 and foo.age > 40 87 | "or:name=micah" => or_where: foo.name = ^"micah" 88 | "or:name=micah,bob" => or_where: foo.name in ^["micah", "bob"] 89 | "!or:name=bar" => or_where: foo.name != ^"bar" 90 | "!or:name=micah,bob" => or_where: foo.name not in ^["bar", "baz"] 91 | "select=foo,bar" => select: [:foo, :bar] 92 | "fields=foo,bar" => select: [:foo, :bar] 93 | "limit=.:99" => limit: 99 94 | "offset=40:." => offset: 40 95 | "between=40:99" => offset: 40, limit: 99 96 | "order=foo,-bar,baz" => order_by: [asc: :foo, desc: :bar, asc: :baz] 97 | 98 | # Incorporating Associated Tables 99 | "bars.title=micah" => join: bars in assoc(foo, :bars), where: bars.title = ^"micah" 100 | "bars.title=micah,bob" => join: bars in assoc(foo, :bars), where: bars.title in ^["micah", "bob"] 101 | "!bars.title=micah" => join: bars in assoc(foo, :bars), where: bars.title != ^"micah") 102 | "!bars.title=micah,bob" => join: bars in assoc(foo, :bars), where: bars.title not in ^["micah", "bob"]) 103 | "like:bars.title=micah*" => join: bars in assoc(foo, :bars), where: like(bars.title, ^"bar%") 104 | "like:bars.title=*micah" => join: bars in assoc(foo, :bars), where: like(bars.title, ^"%bar") 105 | "like:bars.title=*micah*" => join: bars in assoc(foo, :bars), where: like(bars.title, ^"%bar%") 106 | "ilike:bars.title=micah*" => join: bars in assoc(foo, :bars), where: ilike(bars.title, ^"micah%") 107 | "ilike:bars.title=*micah" => join: bars in assoc(foo, :bars), where: ilike(bars.title, ^"%micah") 108 | "ilike:bars.title=*micah* " => join: bars in assoc(foo, :bars), where: ilike(bars.title, ^"%micah%") 109 | "less:bars.likes=99" => join: bars in assoc(foo, :bars), where: bars.likes < 99 110 | "lessequal:bars.likes=99" => join: bars in assoc(foo, :bars), where: bars.likes <= 99 111 | "greater:bars.likes=99" => join: bars in assoc(foo, :bars), where: bars.likes > 99 112 | "greaterequal:bars.likes=99"=> join: bars in assoc(foo, :bars), where: bars.likes >= 99 113 | "range:bars.likes=40:99" => join: bars in assoc(foo, :bars), where: bars.likes< 99 and bars.likes > 40 114 | "or:bars.title=micah" => join: bars in assoc(foo, :bars), or_where: bars.title == ^"micah" 115 | "or:bars.title=micah,bob" => join: bars in assoc(foo, :bars), or_where: bars.title in ^["micah", "bob" 116 | "!or:bars.title=micah" => join: bars in assoc(foo, :bars), or_where: bars.title != ^"micah" 117 | "!or:bars.title=micah,bob" => join: bars in assoc(foo, :bars), or_where: bars.title not in ^["micah", "bob" 118 | "select=email,bars.title" => join: bars in assoc(foo, :bars), select: [{:bars, [:title]}, :email], preload: [:bars] 119 | 120 | # Maps and keyword lists are supported too 121 | %{"ilike:foo" => "*bar*"} => where: ilike(x.foo, ^"%bar%") 122 | [name: "micah"] => where: foo.name = ^"micah" 123 | ``` 124 | 125 | ## Caveats 126 | 127 | When using `select` - In order to hydrate the schema, you _must always_ at least `select=id` from every schema. Even nested schemas would need at least `select=id,foos.id` 128 | 129 | When using `order` - You cannot not (currently) order by nested fields. 130 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ecto_query_string, Repo, 4 | adapter: Ecto.Adapters.Postgres, 5 | database: "ecto_query_string_foo", 6 | username: "postgres", 7 | password: "postgres", 8 | hostname: "localhost" 9 | 10 | if config_env() == :test do 11 | import_config "test.exs" 12 | end 13 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ecto_query_string, 4 | ecto_repos: [ 5 | Repo 6 | ] 7 | 8 | config :logger, level: :info 9 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | example-*.tar 24 | 25 | -------------------------------------------------------------------------------- /example/.iex.exs: -------------------------------------------------------------------------------- 1 | import Ecto.Query 2 | import EctoQueryString 3 | alias Example.Repo 4 | alias Example.{User, Project, Task} 5 | 6 | query = from(user in User) 7 | 8 | querystring = 9 | "select=id,username,age,projects.description,tasks.name,tasks.id,tasks.project_id,projects.id,projects.user_id&limit=1" 10 | 11 | ecto_query_string = EctoQueryString.query(query, querystring) 12 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example app for using EctoQueryString 2 | 3 | ## Installation 4 | 5 | `mix deps.get` 6 | 7 | `mix ecto.setup` 8 | 9 | `mix example.populate` 10 | 11 | `iex -S mix` 12 | 13 | ```elixir 14 | query = from user in User 15 | queryString = EctoQueryString.query(query, "username=foo@example.com,select=email,username") 16 | Example.Repo.all(querystring) 17 | ``` 18 | -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :example, ecto_repos: [Example.Repo] 4 | 5 | config :example, Example.Repo, 6 | database: "example_repo", 7 | hostname: "localhost", 8 | show_sensitive_data_on_connection_error: true, 9 | pool_size: 10 10 | -------------------------------------------------------------------------------- /example/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | end 3 | -------------------------------------------------------------------------------- /example/lib/example/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | children = [ 10 | Example.Repo 11 | ] 12 | 13 | opts = [strategy: :one_for_one, name: Example.Supervisor] 14 | Supervisor.start_link(children, opts) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /example/lib/example/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Repo do 2 | use Ecto.Repo, 3 | otp_app: :example, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /example/lib/project.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Project do 2 | use Ecto.Schema 3 | 4 | schema "projects" do 5 | field(:name, :string) 6 | field(:description, :string) 7 | belongs_to(:user, Example.User) 8 | has_many(:tasks, Example.Task) 9 | 10 | timestamps() 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /example/lib/task.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Task do 2 | use Ecto.Schema 3 | 4 | schema "tasks" do 5 | field(:name, :string) 6 | field(:description, :string) 7 | field(:metadata, :map) 8 | belongs_to(:project, Example.Project) 9 | 10 | timestamps() 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /example/lib/tasks/populate.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Example.Populate do 2 | use Mix.Task 3 | 4 | def run(_) do 5 | Mix.Task.run("app.start") 6 | 7 | user = 8 | %Example.User{email: "foo@example.com", username: "foobar", age: 99} 9 | |> Example.Repo.insert!() 10 | 11 | user2 = 12 | %Example.User{email: "bar@example.com", username: "barbaz", age: 45} 13 | |> Example.Repo.insert!() 14 | 15 | project = 16 | %Example.Project{name: "Work", description: "do work", user_id: user.id} 17 | |> Example.Repo.insert!() 18 | 19 | project2 = 20 | %Example.Project{name: "Work Harder", description: "do work harder", user_id: user2.id} 21 | |> Example.Repo.insert!() 22 | 23 | %Example.Task{name: "task1", description: "task one", project_id: project.id} 24 | |> Example.Repo.insert!() 25 | 26 | %Example.Task{name: "task2", description: "task one", project_id: project.id} 27 | |> Example.Repo.insert!() 28 | 29 | %Example.Task{name: "task3", description: "task one", project_id: project.id} 30 | |> Example.Repo.insert!() 31 | 32 | %Example.Task{name: "task4", description: "task one", project_id: project2.id} 33 | |> Example.Repo.insert!() 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /example/lib/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.User do 2 | use Ecto.Schema 3 | 4 | schema "users" do 5 | field(:username, :string) 6 | field(:email, :string) 7 | field(:age, :integer) 8 | has_many(:projects, Example.Project) 9 | has_many(:tasks, through: [:projects, :tasks]) 10 | 11 | timestamps() 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example, 7 | version: "0.1.0", 8 | elixir: "~> 1.9", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {Example.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:ecto, "~> 3.2"}, 26 | {:ecto_sql, "~> 3.2"}, 27 | {:ecto_query_string, path: "../"}, 28 | {:jason, "~> 1.1"}, 29 | {:postgrex, ">= 0.0.0"} 30 | ] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /example/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 3 | "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, 5 | "ecto": {:hex, :ecto, "3.2.3", "51274df79862845b388733fddcf6f107d0c8c86e27abe7131fa98f8d30761bda", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 6 | "ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 8 | "postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, 10 | } 11 | -------------------------------------------------------------------------------- /example/priv/repo/migrations/20191024192849_create_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.Repo.Migrations.CreateTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION IF NOT EXISTS citext" 6 | execute "CREATE EXTENSION IF NOT EXISTS pg_trgm" 7 | 8 | create table(:users) do 9 | add :username, :string 10 | add :email, :citext 11 | add :age, :integer 12 | 13 | timestamps() 14 | end 15 | 16 | create table(:projects) do 17 | add :name, :string, null: false 18 | add :description, :text 19 | add :user_id, references(:users, on_delete: :delete_all) 20 | 21 | timestamps() 22 | end 23 | 24 | create table(:tasks) do 25 | add :name, :string, null: false 26 | add :description, :text 27 | add :metadata, :map, default: "{}" 28 | add :project_id, references(:projects, on_delete: :delete_all) 29 | 30 | timestamps() 31 | end 32 | 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/ecto_query_string.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoQueryString do 2 | import Ecto.Query 3 | 4 | alias EctoQueryString.Reflection 5 | import Logger, only: [debug: 1] 6 | 7 | @moduledoc """ 8 | 9 | Compose an `Ecto.Query` with a querystring 10 | 11 | ## Usage Say you have the following schemas: 12 | 13 | ``` 14 | defmodule Foo do use Ecto.Schema 15 | schema "foos" do 16 | field(:name, :string) 17 | field(:age, :integer) 18 | has_many(:bars, Bar) 19 | end 20 | end 21 | 22 | defmodule Bar do 23 | use Ecto.Schema 24 | 25 | schema "bars" do 26 | field(:title, :string) 27 | field(:likes, :integer) 28 | belongs_to(:foo, Foo) 29 | end 30 | end 31 | 32 | ``` 33 | 34 | You can do things like this: 35 | ``` 36 | query = Ecto.Query.from(user in User) 37 | query_string = "username=mrmicahcooper&greater:age=18&limit=10" 38 | EctoQueryString.query(query, query_string) 39 | ``` 40 | And get: 41 | ``` 42 | Ecto.Query.from(u0 in User, 43 | where: u0.age > ^"18", 44 | where: u0.username == ^"mrmicahcooper", 45 | limit: ^"10" 46 | ) 47 | ``` 48 | 49 | Here is the full DSL 50 | 51 | ``` 52 | # Basic Queries 53 | "name=micah" => where: foo.name = ^"micah" 54 | "name=micah,bob" => where: foo.name in ^["micah", "bob"] 55 | "!name=micah" => where: foo.name != ^"micah" 56 | "!name=micah,bob" => where: foo.name not in ^["micah", "bob"] 57 | "like:foo=bar*" => where: like(x.foo, ^"bar%") 58 | "like:foo=*bar" => where: like(x.foo, ^"%bar") 59 | "like:name=*micah*" => where: like(foo.name, ^"%micah%") 60 | "ilike:name=micah*" => where: ilike(foo.name, ^"micah%") 61 | "ilike:name=*micah" => where: ilike(foo.name, ^"%micah") 62 | "ilike:foo=*bar*" => where: ilike(x.foo, ^"%bar%") 63 | "less:age=99" => where: foo.age < 99 64 | "greater:age=40" => where: foo.age > 40 65 | "range:age=40:99" => where: foo.age < 99 and foo.age > 40 66 | "or:name=micah" => or_where: foo.name = ^"micah" 67 | "or:name=micah,bob" => or_where: foo.name in ^["micah", "bob"] 68 | "!or:name=bar" => or_where: foo.name != ^"bar" 69 | "!or:name=micah,bob" => or_where: foo.name not in ^["bar", "baz"] 70 | "select=foo,bar" => select: [:foo, :bar] 71 | "fields=foo,bar" => select: [:foo, :bar] 72 | "limit=.:99" => limit: 99 73 | "offset=40:." => offset: 40 74 | "between=40:99" => offset: 40, limit: 99 75 | "order=foo,-bar,baz" => order_by: [asc: :foo, desc: :bar, asc: :baz] 76 | 77 | # Incorporating Associated Tables 78 | "bars.title=micah" => join: bars in assoc(foo, :bars), where: bars.title = ^"micah" 79 | "bars.title=micah,bob" => join: bars in assoc(foo, :bars), where: bars.title in ^["micah", "bob"] 80 | "!bars.title=micah" => join: bars in assoc(foo, :bars), where: bars.title != ^"micah") 81 | "!bars.title=micah,bob" => join: bars in assoc(foo, :bars), where: bars.title not in ^["micah", "bob"]) 82 | "like:bars.title=micah*" => join: bars in assoc(foo, :bars), where: like(bars.title, ^"bar%") 83 | "like:bars.title=*micah" => join: bars in assoc(foo, :bars), where: like(bars.title, ^"%bar") 84 | "like:bars.title=*micah*" => join: bars in assoc(foo, :bars), where: like(bars.title, ^"%bar%") 85 | "ilike:bars.title=micah*" => join: bars in assoc(foo, :bars), where: ilike(bars.title, ^"micah%") 86 | "ilike:bars.title=*micah" => join: bars in assoc(foo, :bars), where: ilike(bars.title, ^"%micah") 87 | "ilike:bars.title=*micah* " => join: bars in assoc(foo, :bars), where: ilike(bars.title, ^"%micah%") 88 | "less:bars.likes=99" => join: bars in assoc(foo, :bars), where: bars.likes < 99 89 | "greater:bars.likes=99" => join: bars in assoc(foo, :bars), where: bars.likes > 99 90 | "range:bars.likes=40:99" => join: bars in assoc(foo, :bars), where: bars.likes< 99 and bars.likes > 40 91 | "or:bars.title=micah" => join: bars in assoc(foo, :bars), or_where: bars.title == ^"micah" 92 | "or:bars.title=micah,bob" => join: bars in assoc(foo, :bars), or_where: bars.title in ^["micah", "bob" 93 | "!or:bars.title=micah" => join: bars in assoc(foo, :bars), or_where: bars.title != ^"micah" 94 | "!or:bars.title=micah,bob" => join: bars in assoc(foo, :bars), or_where: bars.title not in ^["micah", "bob" 95 | "select=email,bars.title" => join: bars in assoc(foo, :bars), select: [{:bars, [:title]}, :email], preload: [:bars] 96 | 97 | # Maps and keyword lists are supported too 98 | %{"ilike:foo" => "*bar*"} => where: ilike(x.foo, ^"%bar%") 99 | [name: "micah"] => where: foo.name = ^"micah" 100 | ``` 101 | """ 102 | 103 | @spec query(Ecto.Query, binary() | map() | keyword() | nil) :: Ecto.Query 104 | @doc """ 105 | Uses a querystring or a map of params to extend an `Ecto.Query` 106 | 107 | This DSL provides basic query functions with the goal of handling the 108 | majority of your filtering, ordering, and basic selects. 109 | 110 | """ 111 | def query(query, ""), do: query(query, []) 112 | def query(query, nil), do: query(query, []) 113 | 114 | def query(query, params) when is_map(params) do 115 | params = params |> Enum.into([]) 116 | query(query, params) 117 | end 118 | 119 | def query(query, params) when is_list(params) do 120 | query = Enum.reduce(params, query, &dynamic_segment/2) 121 | debug(inspect(query)) 122 | query 123 | end 124 | 125 | def query(query, querystring) when is_binary(querystring) do 126 | params = 127 | querystring 128 | |> URI.decode() 129 | |> URI.query_decoder() 130 | |> Enum.to_list() 131 | 132 | query(query, params) 133 | end 134 | 135 | @doc false 136 | def queryable(query, field, value \\ nil) do 137 | value = 138 | if value do 139 | String.split(value, ",") |> Enum.map(&String.trim/1) 140 | end 141 | 142 | schema = Reflection.source_schema(query) 143 | 144 | case String.split(field, ".", trim: true) do 145 | [field] -> 146 | {field, type} = Reflection.field(schema, field) 147 | {:field, field, type, value} 148 | 149 | [assoc, field] -> 150 | if assoc_schema = Reflection.assoc_schema(schema, assoc) do 151 | assoc = String.to_atom(assoc) 152 | {field, type} = Reflection.field(assoc_schema, field) 153 | {:assoc, assoc, field, type, value} 154 | end 155 | 156 | _ -> 157 | nil 158 | end 159 | end 160 | 161 | def selectable([field], {query, acc}) do 162 | case Reflection.source_schema(query) |> Reflection.field(field) do 163 | {nil, :no_field} -> 164 | {query, acc} 165 | 166 | {selection_field, _type} -> 167 | new_acc = update_in(acc[nil], &[selection_field | List.wrap(&1)]) 168 | {query, new_acc} 169 | end 170 | end 171 | 172 | def selectable([assoc, field], {query, acc}) do 173 | field = 174 | Reflection.source_schema(query) 175 | |> Reflection.assoc_schema(assoc) 176 | |> Reflection.field(field) 177 | 178 | case field do 179 | {nil, :no_field} -> 180 | {query, acc} 181 | 182 | {assoc_selection_field, _type} -> 183 | field = String.to_atom(assoc) 184 | new_acc = update_in(acc[field], &[assoc_selection_field | List.wrap(&1)]) 185 | {query, new_acc} 186 | end 187 | end 188 | 189 | defp select_into({nil, value}, acc), do: acc ++ value 190 | defp select_into({key, value}, acc), do: [{key, value} | acc] 191 | 192 | defp order_field("-" <> field), do: {:desc, field} 193 | defp order_field(field), do: {:asc, field} 194 | 195 | defp select_foreign_key({assoc_field, attributes}, source_schema) do 196 | foreign_key = Reflection.foreign_key(source_schema, assoc_field) 197 | 198 | {assoc_field, [:id, foreign_key] ++ attributes} 199 | end 200 | 201 | defp select_foreign_key(field, _acc), do: field 202 | 203 | defp dynamic_segment({"order", values}, acc) do 204 | fields = values |> String.split(",", trim: true) |> Enum.map(&order_field/1) 205 | schema_fields = acc |> Reflection.source_schema() |> Reflection.schema_fields() 206 | 207 | order_values = 208 | for {order, field} <- fields, field in schema_fields do 209 | {order, String.to_atom(field)} 210 | end 211 | 212 | from(acc, order_by: ^order_values) 213 | end 214 | 215 | defp dynamic_segment({"select", value}, acc) do 216 | source_schema = Reflection.source_schema(acc) 217 | 218 | select_segment = 219 | value 220 | |> String.split(",", trim: true) 221 | |> Enum.map(&String.split(&1, ".", trim: true)) 222 | |> Enum.reduce({acc, []}, &selectable/2) 223 | |> elem(1) 224 | |> Enum.reduce([], &select_into/2) 225 | |> Enum.map(&select_foreign_key(&1, source_schema)) 226 | 227 | join_fields = for {key, _} <- select_segment, uniq: true, do: key 228 | 229 | select_fields = 230 | if join_fields != [] do 231 | primary_keys = Reflection.primary_keys(source_schema) 232 | select_segment ++ primary_keys 233 | else 234 | select_segment 235 | end 236 | 237 | acc = 238 | Enum.reduce(join_fields, acc, fn assoc_field, query -> 239 | from(parent in query, 240 | join: child in assoc(parent, ^assoc_field), 241 | preload: [{^assoc_field, child}] 242 | ) 243 | end) 244 | 245 | from(acc, select: ^select_fields) 246 | end 247 | 248 | defp dynamic_segment({"fields", value}, acc) do 249 | dynamic_segment({"select", value}, acc) 250 | end 251 | 252 | defp dynamic_segment({"limit", value}, acc), do: from(acc, limit: ^value) 253 | defp dynamic_segment({"offset", value}, acc), do: from(acc, offset: ^value) 254 | 255 | defp dynamic_segment({"greater:" <> key, value}, acc) do 256 | case queryable(acc, key) do 257 | {:field, nil, _type, _} -> 258 | acc 259 | 260 | {:field, key, type, _} -> 261 | from(query in acc, where: field(query, ^key) > ^date_time_format(value, type)) 262 | 263 | {:assoc, assoc_field, key, type, _} -> 264 | from(parent in acc, 265 | join: child in assoc(parent, ^assoc_field), 266 | where: field(child, ^key) > ^date_time_format(value, type) 267 | ) 268 | 269 | _ -> 270 | acc 271 | end 272 | end 273 | 274 | defp dynamic_segment({"greaterequal:" <> key, value}, acc) do 275 | case queryable(acc, key) do 276 | {:field, nil, _type, _} -> 277 | acc 278 | 279 | {:field, key, type, _} -> 280 | from(query in acc, where: field(query, ^key) >= ^date_time_format(value, type)) 281 | 282 | {:assoc, assoc_field, key, type, _} -> 283 | from(parent in acc, 284 | join: child in assoc(parent, ^assoc_field), 285 | where: field(child, ^key) >= ^date_time_format(value, type) 286 | ) 287 | 288 | _ -> 289 | acc 290 | end 291 | end 292 | 293 | defp dynamic_segment({"less:" <> key, value}, acc) do 294 | case queryable(acc, key) do 295 | {:field, nil, _type, _} -> 296 | acc 297 | 298 | {:field, key, type, _} -> 299 | from(query in acc, where: field(query, ^key) < ^date_time_format(value, type)) 300 | 301 | {:assoc, assoc_field, key, type, _} -> 302 | from(parent in acc, 303 | join: child in assoc(parent, ^assoc_field), 304 | where: field(child, ^key) < ^date_time_format(value, type) 305 | ) 306 | 307 | _ -> 308 | acc 309 | end 310 | end 311 | 312 | defp dynamic_segment({"lessequal:" <> key, value}, acc) do 313 | case queryable(acc, key) do 314 | {:field, nil, _type, _} -> 315 | acc 316 | 317 | {:field, key, type, _} -> 318 | from(query in acc, where: field(query, ^key) <= ^date_time_format(value, type)) 319 | 320 | {:assoc, assoc_field, key, type, _} -> 321 | from(parent in acc, 322 | join: child in assoc(parent, ^assoc_field), 323 | where: field(child, ^key) <= ^date_time_format(value, type) 324 | ) 325 | 326 | _ -> 327 | acc 328 | end 329 | end 330 | 331 | defp dynamic_segment({"range:" <> key, value}, acc) do 332 | case queryable(acc, key) do 333 | {:field, nil, _type, _} -> 334 | acc 335 | 336 | {:field, key, _type, _} -> 337 | case String.split(value, ":", trim: true) do 338 | [".", "."] -> 339 | acc 340 | 341 | [".", max] -> 342 | from(query in acc, where: field(query, ^key) < ^max) 343 | 344 | [min, "."] -> 345 | from(query in acc, where: field(query, ^key) > ^min) 346 | 347 | [min, max] -> 348 | from(query in acc, 349 | where: field(query, ^key) > ^min and field(query, ^key) < ^max 350 | ) 351 | 352 | _else -> 353 | acc 354 | end 355 | 356 | {:assoc, assoc_field, key, _type, _} -> 357 | case String.split(value, ":", trim: true) do 358 | [".", "."] -> 359 | acc 360 | 361 | [".", max] -> 362 | from(parent in acc, 363 | join: child in assoc(parent, ^assoc_field), 364 | where: field(child, ^key) < ^max 365 | ) 366 | 367 | [min, "."] -> 368 | from(parent in acc, 369 | join: child in assoc(parent, ^assoc_field), 370 | where: field(child, ^key) > ^min 371 | ) 372 | 373 | [min, max] -> 374 | from(parent in acc, 375 | join: child in assoc(parent, ^assoc_field), 376 | where: field(child, ^key) > ^min and field(child, ^key) < ^max 377 | ) 378 | 379 | _else -> 380 | acc 381 | end 382 | 383 | _ -> 384 | acc 385 | end 386 | end 387 | 388 | defp dynamic_segment({"ilike:" <> key, value}, acc) do 389 | value = String.replace(value, ~r/\*+/, "%") 390 | 391 | case queryable(acc, key) do 392 | {:field, nil, _type, _} -> 393 | acc 394 | 395 | {:field, key, type, _} -> 396 | from(query in acc, where: ilike(field(query, ^key), ^date_time_format(value, type))) 397 | 398 | {:assoc, assoc_field, key, type, _} -> 399 | from(parent in acc, 400 | join: child in assoc(parent, ^assoc_field), 401 | where: ilike(field(child, ^key), ^date_time_format(value, type)) 402 | ) 403 | 404 | _ -> 405 | acc 406 | end 407 | end 408 | 409 | defp dynamic_segment({"like:" <> key, value}, acc) do 410 | value = String.replace(value, ~r/\*+/, "%") 411 | 412 | case queryable(acc, key) do 413 | {:field, nil, _type, _} -> 414 | acc 415 | 416 | {:field, key, type, _} -> 417 | from(query in acc, where: like(field(query, ^key), ^date_time_format(value, type))) 418 | 419 | {:assoc, assoc_field, key, type, _} -> 420 | from(parent in acc, 421 | join: child in assoc(parent, ^assoc_field), 422 | where: like(field(child, ^key), ^date_time_format(value, type)) 423 | ) 424 | 425 | _ -> 426 | acc 427 | end 428 | end 429 | 430 | defp dynamic_segment({"!or:" <> key, value}, acc) do 431 | case queryable(acc, key, value) do 432 | {:field, nil, _type, _} -> 433 | acc 434 | 435 | {_, _, _type, nil} -> 436 | acc 437 | 438 | {:field, key, type, [value]} -> 439 | from(query in acc, or_where: field(query, ^key) != ^date_time_format(value, type)) 440 | 441 | {:field, key, type, value} when is_list(value) -> 442 | from(query in acc, or_where: field(query, ^key) not in ^date_time_format(value, type)) 443 | 444 | {:assoc, assoc_field, key, type, [value]} -> 445 | from(parent in acc, 446 | join: child in assoc(parent, ^assoc_field), 447 | or_where: field(child, ^key) != ^date_time_format(value, type) 448 | ) 449 | 450 | {:assoc, assoc_field, key, type, value} when is_list(value) -> 451 | from(parent in acc, 452 | join: child in assoc(parent, ^assoc_field), 453 | or_where: field(child, ^key) not in ^date_time_format(value, type) 454 | ) 455 | 456 | _ -> 457 | acc 458 | end 459 | end 460 | 461 | defp dynamic_segment({"!" <> key, value}, acc) do 462 | case queryable(acc, key, value) do 463 | {:field, nil, _type, _} -> 464 | acc 465 | 466 | {_, _, _type, nil} -> 467 | acc 468 | 469 | {:field, key, type, [value]} -> 470 | from(query in acc, where: field(query, ^key) != ^date_time_format(value, type)) 471 | 472 | {:field, key, type, value} when is_list(value) -> 473 | from(query in acc, where: field(query, ^key) not in ^date_time_format(value, type)) 474 | 475 | {:assoc, assoc_field, key, type, [value]} -> 476 | from(parent in acc, 477 | join: child in assoc(parent, ^assoc_field), 478 | where: field(child, ^key) != ^date_time_format(value, type) 479 | ) 480 | 481 | {:assoc, assoc_field, key, type, value} when is_list(value) -> 482 | from(parent in acc, 483 | join: child in assoc(parent, ^assoc_field), 484 | where: field(child, ^key) not in ^date_time_format(value, type) 485 | ) 486 | 487 | _ -> 488 | acc 489 | end 490 | end 491 | 492 | defp dynamic_segment({"or:" <> key, value}, acc) do 493 | case queryable(acc, key, value) do 494 | {:field, nil, _type, _} -> 495 | acc 496 | 497 | {_, _, _type, nil} -> 498 | acc 499 | 500 | {:field, key, type, [value]} -> 501 | from(query in acc, or_where: field(query, ^key) == ^date_time_format(value, type)) 502 | 503 | {:field, key, type, value} when is_list(value) -> 504 | from(query in acc, or_where: field(query, ^key) in ^date_time_format(value, type)) 505 | 506 | {:assoc, assoc_field, key, type, [value]} -> 507 | from(parent in acc, 508 | join: child in assoc(parent, ^assoc_field), 509 | or_where: field(child, ^key) == ^date_time_format(value, type) 510 | ) 511 | 512 | {:assoc, assoc_field, key, type, value} when is_list(value) -> 513 | from(parent in acc, 514 | join: child in assoc(parent, ^assoc_field), 515 | or_where: field(child, ^key) in ^date_time_format(value, type) 516 | ) 517 | 518 | _ -> 519 | acc 520 | end 521 | end 522 | 523 | defp dynamic_segment({key, value}, acc) do 524 | case queryable(acc, key, value) do 525 | {:field, nil, _type, _} -> 526 | acc 527 | 528 | {_, _, _type, nil} -> 529 | acc 530 | 531 | {:field, key, type, [value]} -> 532 | from(query in acc, where: field(query, ^key) == ^date_time_format(value, type)) 533 | 534 | {:field, key, type, value} when is_list(value) -> 535 | from(query in acc, where: field(query, ^key) in ^date_time_format(value, type)) 536 | 537 | {:assoc, assoc_field, key, type, [value]} -> 538 | from(parent in acc, 539 | join: child in assoc(parent, ^assoc_field), 540 | where: field(child, ^key) == ^date_time_format(value, type) 541 | ) 542 | 543 | {:assoc, assoc_field, key, type, value} when is_list(value) -> 544 | from(parent in acc, 545 | join: child in assoc(parent, ^assoc_field), 546 | where: field(child, ^key) in ^date_time_format(value, type) 547 | ) 548 | 549 | _ -> 550 | acc 551 | end 552 | end 553 | 554 | @datetime_types ~w[naive_datetime naive_datetime_usec utc_datetime utc_datetime_usec]a 555 | def date_time_format(value, type) when type in @datetime_types and is_binary(value) do 556 | length = String.length(value) 557 | date_string = value <> String.slice("0000-00-00 00:00:00.000000Z", length..-1) 558 | 559 | case Ecto.Type.cast(type, date_string) do 560 | {:ok, naive} -> 561 | naive.__struct__.to_string(naive) 562 | 563 | _else -> 564 | value 565 | end 566 | end 567 | 568 | @time_types ~w[time time_usec]a 569 | def date_time_format(value, type) when type in @time_types and is_binary(value) do 570 | length = String.length(value) 571 | date_string = value <> String.slice("00:00:00.000000Z", length..-1) 572 | 573 | case Ecto.Type.cast(type, date_string) do 574 | {:ok, naive} -> 575 | naive.__struct__.to_string(naive) 576 | 577 | _else -> 578 | value 579 | end 580 | end 581 | 582 | def date_time_format(value, _type), do: value 583 | end 584 | -------------------------------------------------------------------------------- /lib/ecto_query_string/reflection.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoQueryString.Reflection do 2 | @moduledoc """ 3 | Provides some conveniences to work with Queries and Schemas 4 | 5 | You can use this module to make sure the passed in fields are valid for 6 | the Query /Schema 7 | """ 8 | 9 | @spec source_schema(Ecto.Query) :: Ecto.Schema 10 | @doc """ 11 | Find the source of an `Ecto.Query` 12 | """ 13 | def source_schema(query) do 14 | query.from.source |> elem(1) 15 | end 16 | 17 | @spec schema_fields(Ecto.Schema) :: list(:binary) 18 | @doc """ 19 | Return all the fields of the passed in `Ecto.Schema` 20 | 21 | The fields are returned as strings 22 | """ 23 | def schema_fields(schema) do 24 | schema.__schema__(:fields) |> Enum.map(&to_string/1) 25 | end 26 | 27 | @spec has_field?(Ecto.Schema, binary()) :: boolean() 28 | @doc """ 29 | Check if an `Ecto.Schema` has the passed in field 30 | """ 31 | def has_field?(schema, field_name) when is_binary(field_name) do 32 | field_name in schema_fields(schema) 33 | end 34 | 35 | @spec field(Ecto.Schema, binary()) :: {atom(), atom()} | {nil, :no_field} 36 | @doc """ 37 | Get the `:atom` representation of a field if it exists in the passed in `Ecto.Schema` 38 | """ 39 | def field(schema, field_name) when is_binary(field_name) do 40 | if has_field?(schema, field_name) do 41 | field = String.to_existing_atom(field_name) 42 | {field, schema.__schema__(:type, field)} 43 | else 44 | {nil, :no_field} 45 | end 46 | end 47 | 48 | @spec has_assoc?(Ecto.Schema, binary()) :: boolean() 49 | @doc """ 50 | Check if an `Ecto.Schema` has the passed in association 51 | """ 52 | def has_assoc?(schema, assoc_name) when is_binary(assoc_name) do 53 | list = 54 | schema.__schema__(:associations) 55 | |> Enum.map(&to_string/1) 56 | 57 | assoc_name in list 58 | end 59 | 60 | @spec assoc_schema(Ecto.Schema, binary()) :: Ecto.Schema 61 | @doc """ 62 | Return an associated schema 63 | """ 64 | def assoc_schema(schema, assoc_name) when is_binary(assoc_name) do 65 | if has_assoc?(schema, assoc_name) do 66 | assoc = String.to_atom(assoc_name) 67 | 68 | case schema.__schema__(:association, assoc) do 69 | %{related: related} -> 70 | related 71 | 72 | %{through: [through, child_assoc]} -> 73 | through_schema = assoc_schema(schema, through) 74 | assoc_schema(through_schema, child_assoc) 75 | end 76 | end 77 | end 78 | 79 | def assoc_schema(schema, assoc) when is_atom(assoc) do 80 | case schema.__schema__(:association, assoc) do 81 | %{related: related} -> 82 | related 83 | 84 | %{through: [through, child_assoc]} -> 85 | through_schema = assoc_schema(schema, through) 86 | assoc_schema(through_schema, child_assoc) 87 | end 88 | end 89 | 90 | @spec foreign_key(Ecto.Schema, atom()) :: atom() 91 | @doc """ 92 | Return an the foreign key of a schema's association 93 | """ 94 | def foreign_key(schema, assoc) when is_atom(assoc) do 95 | case schema.__schema__(:association, assoc) do 96 | %{related: _related, related_key: key} -> 97 | key 98 | 99 | %{through: [through, child_assoc]} -> 100 | through_schema = assoc_schema(schema, through) 101 | foreign_key(through_schema, child_assoc) 102 | end 103 | end 104 | 105 | @spec primary_key(Ecto.Schema) :: atom() 106 | @doc """ 107 | Return the primary key of a schema 108 | """ 109 | def primary_key(schema) do 110 | schema.__schema__(:primary_key) |> List.first() 111 | end 112 | 113 | @spec primary_keys(Ecto.Schema) :: atom() 114 | @doc """ 115 | Return all the primary keys of a schema 116 | """ 117 | def primary_keys(schema) do 118 | schema.__schema__(:primary_key) 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoQueryString.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_query_string, 7 | version: "0.2.2", 8 | description: "Easy querying with ecto and query string params", 9 | elixir: ">= 1.9.1", 10 | name: "EctoQueryString", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | start_permanent: Mix.env() == :prod, 13 | source_url: "http://github.com/mrmicahcooper/ecto_query_string", 14 | deps: deps(), 15 | package: package(), 16 | docs: docs() 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | extra_applications: [:logger] 24 | ] 25 | end 26 | 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Run "mix help deps" to learn about dependencies. 31 | defp deps do 32 | [ 33 | {:ecto, "~> 3.0"}, 34 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 35 | {:ecto_sql, "~> 3.10", only: [:dev, :test]}, 36 | {:postgrex, "~> 0.17", only: [:dev, :test]} 37 | ] 38 | end 39 | 40 | defp package do 41 | [ 42 | maintainers: ["Micah Cooper"], 43 | licenses: ["Apache 2.0"], 44 | links: %{"GitHub" => "https://github.com/mrmicahcooper/ecto_query_string"} 45 | ] 46 | end 47 | 48 | defp docs do 49 | [ 50 | extras: [ 51 | "README.md" 52 | ] 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 3 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 4 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 5 | "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, 7 | "ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"}, 8 | "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, 9 | "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, 10 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 14 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 15 | "postgrex": {:hex, :postgrex, "0.17.1", "01c29fd1205940ee55f7addb8f1dc25618ca63a8817e56fac4f6846fc2cddcbe", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "14b057b488e73be2beee508fb1955d8db90d6485c6466428fe9ccf1d6692a555"}, 16 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 17 | } 18 | -------------------------------------------------------------------------------- /test/ecto_query_string/reflection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoQueryString.ReflectionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias EctoQueryString.Reflection 5 | import Ecto.Query 6 | 7 | describe "schema_fields/1" do 8 | test "return string representations of a schema's fields" do 9 | assert Reflection.schema_fields(Foo) == ~w[id foo title description user_id] 10 | end 11 | end 12 | 13 | describe "has_field?/2" do 14 | test "existing field returns true" do 15 | assert Reflection.has_field?(Foo, "title") == true 16 | end 17 | 18 | test "non existing field returns true" do 19 | assert Reflection.has_field?(Foo, "x") == false 20 | end 21 | end 22 | 23 | describe "field/2" do 24 | test "returns field if it exists in the schema" do 25 | assert Reflection.field(Foo, "title") == {:title, :string} 26 | end 27 | 28 | test "returns nil if the field doesn't exist" do 29 | assert Reflection.field(Foo, "noop") == {nil, :no_field} 30 | end 31 | end 32 | 33 | describe "has_assoc?/2" do 34 | test "existing assoc returns true", _ do 35 | assert Reflection.has_assoc?(Foo, "bars") == true 36 | end 37 | 38 | test "non existing assoc returns false", _ do 39 | assert Reflection.has_assoc?(Foo, "bazes") == false 40 | end 41 | end 42 | 43 | describe "assoc_schema/2" do 44 | test "returns the associated schema if present" do 45 | assert Reflection.assoc_schema(Foo, "bars") == Bar 46 | end 47 | 48 | test "returns the associated schema from a `through` if present" do 49 | assert Reflection.assoc_schema(User, "foobars") == Bar 50 | end 51 | 52 | test "returns nil associated schema if absent" do 53 | assert Reflection.assoc_schema(Foo, "bazes") == nil 54 | end 55 | end 56 | 57 | describe "source_schema/1" do 58 | test "returns the source schema from a query" do 59 | query = from(f in Foo) 60 | 61 | assert Reflection.source_schema(query) == Foo 62 | end 63 | end 64 | 65 | describe "primary_key/1" do 66 | test "returns the primary_key for a schema" do 67 | assert Reflection.primary_key(Foo) == :id 68 | end 69 | end 70 | 71 | describe "primary_keys/1" do 72 | test "returns the all primary_keys for a schema" do 73 | assert Reflection.primary_keys(Foo) == [:id] 74 | end 75 | end 76 | 77 | describe "foreign_key/2" do 78 | test "returns the foreign key for relatonship" do 79 | assert Reflection.foreign_key(Foo, :bars) == :foo_id 80 | end 81 | 82 | test "returns the foreign key for through relatonship" do 83 | assert Reflection.foreign_key(User, :foobars) == :foo_id 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/ecto_query_string_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoQueryStringTest do 2 | use ExUnit.Case, async: true 3 | use QueryHelpers 4 | import EctoQueryString 5 | import Ecto.Query 6 | 7 | setup do 8 | query = from(user in User) 9 | {:ok, %{query: query}} 10 | end 11 | 12 | test ".queryable returns the field if its in the query's schema" do 13 | query = from(f in Foo) 14 | assert queryable(query, "title") == {:field, :title, :string, nil} 15 | assert queryable(query, "description") == {:field, :description, :string, nil} 16 | assert queryable(query, "bar") == {:field, nil, :no_field, nil} 17 | assert queryable(query, "bars.name") == {:assoc, :bars, :name, :string, nil} 18 | 19 | assert queryable(query, "bars.name", "one, two") == 20 | {:assoc, :bars, :name, :string, ["one", "two"]} 21 | end 22 | 23 | test "all", %{query: query} do 24 | querystring = "" 25 | string_query = query(query, querystring) 26 | expected_query = from(user in User) 27 | assert_queries_match(string_query, expected_query) 28 | end 29 | 30 | test "WHERE Key = value", %{query: query} do 31 | querystring = "foo=bar&username=foo" 32 | string_query = query(query, querystring) 33 | expected_query = from(user in User, where: user.username == ^"foo") 34 | assert_queries_match(string_query, expected_query) 35 | end 36 | 37 | test("WHERE key IN value", %{query: query}) do 38 | querystring = "email=user@clank.us,micah@clank.us&username=mrmicahcooper" 39 | string_query = query(query, querystring) 40 | 41 | expected_query = 42 | from( 43 | user in User, 44 | where: user.email in ^["user@clank.us", "micah@clank.us"], 45 | where: user.username == ^"mrmicahcooper" 46 | ) 47 | 48 | assert_queries_match(string_query, expected_query) 49 | end 50 | 51 | test("JOINS t2 ON t1.foreign_key = t1.primary_key WHERE t2.key = value") do 52 | querystring = "bars.name=coolname" 53 | query = from(f in Foo) 54 | string_query = query(query, querystring) 55 | 56 | expected_query = 57 | from( 58 | foo in Foo, 59 | join: bars in assoc(foo, :bars), 60 | where: bars.name == ^"coolname" 61 | ) 62 | 63 | assert_queries_match(string_query, expected_query) 64 | end 65 | 66 | test("JOINS t2 ON t1.foreign_key = t1.primary_key WHERE t2.key IN value") do 67 | querystring = "bars.name=cool,name" 68 | query = from(f in Foo) 69 | string_query = query(query, querystring) 70 | 71 | expected_query = 72 | from( 73 | foo in Foo, 74 | join: bars in assoc(foo, :bars), 75 | where: bars.name in ^~w[cool name] 76 | ) 77 | 78 | assert_queries_match(string_query, expected_query) 79 | end 80 | 81 | test "WHERE Key != value", %{query: query} do 82 | querystring = "!username=mrmicahcooper" 83 | string_query = query(query, querystring) 84 | expected_query = from(user in User, where: user.username != ^"mrmicahcooper") 85 | assert_queries_match(string_query, expected_query) 86 | end 87 | 88 | test "WHERE key LIKE value%", %{query: query} do 89 | querystring = URI.encode_www_form("like:email=user**") 90 | string_query = query(query, querystring) 91 | expected_query = from(user in User, where: like(user.email, ^"user%")) 92 | assert_queries_match(string_query, expected_query) 93 | end 94 | 95 | test("JOINS t2 ON t1.foreign_key = t1.primary_key WHERE t2.key LIKE value") do 96 | querystring = "like:bars.name=micah*" 97 | query = from(f in Foo) 98 | string_query = query(query, querystring) 99 | 100 | expected_query = 101 | from( 102 | foo in Foo, 103 | join: bars in assoc(foo, :bars), 104 | where: like(bars.name, ^"micah%") 105 | ) 106 | 107 | assert_queries_match(string_query, expected_query) 108 | end 109 | 110 | test "WHERE key LIKE %value", %{query: query} do 111 | querystring = "like:email=*clank.us" 112 | string_query = query(query, querystring) 113 | expected_query = from(user in User, where: like(user.email, ^"%clank.us")) 114 | assert_queries_match(string_query, expected_query) 115 | end 116 | 117 | test("JOINS t2 ON t1.foreign_key = t1.primary_key WHERE t2.key ILIKE value") do 118 | querystring = "ilike:bars.name=micah*" 119 | query = from(f in Foo) 120 | string_query = query(query, querystring) 121 | 122 | expected_query = 123 | from( 124 | foo in Foo, 125 | join: bars in assoc(foo, :bars), 126 | where: ilike(bars.name, ^"micah%") 127 | ) 128 | 129 | assert_queries_match(string_query, expected_query) 130 | end 131 | 132 | test "WHERE key LIKE %value%", %{query: query} do 133 | querystring = "like:email=*clank.us*" 134 | string_query = query(query, querystring) 135 | expected_query = from(user in User, where: like(user.email, ^"%clank.us%")) 136 | assert_queries_match(string_query, expected_query) 137 | end 138 | 139 | test "WHERE key ILIKE value%", %{query: query} do 140 | querystring = "ilike:email=*clank.us*" 141 | string_query = query(query, querystring) 142 | expected_query = from(user in User, where: ilike(user.email, ^"%clank.us%")) 143 | assert_queries_match(string_query, expected_query) 144 | end 145 | 146 | test "WHERE key ILIKE %value", %{query: query} do 147 | querystring = "ilike:email=*clank.us" 148 | string_query = query(query, querystring) 149 | expected_query = from(user in User, where: ilike(user.email, ^"%clank.us")) 150 | assert_queries_match(string_query, expected_query) 151 | end 152 | 153 | test "WHERE key ILIKE %value%", %{query: query} do 154 | querystring = "ilike:email=*@*" 155 | string_query = query(query, querystring) 156 | expected_query = from(user in User, where: ilike(user.email, ^"%@%")) 157 | assert_queries_match(string_query, expected_query) 158 | end 159 | 160 | test "WHERE key > value", %{query: query} do 161 | querystring = "greater:age=30" 162 | string_query = query(query, querystring) 163 | expected_query = from(user in User, where: user.age > ^"30") 164 | assert_queries_match(string_query, expected_query) 165 | end 166 | 167 | test "WHERE key > date_value", %{query: query} do 168 | querystring = "greater:inserted_at=2021-01-01" 169 | string_query = query(query, querystring) 170 | expected_query = from(user in User, where: user.inserted_at > ^"2021-01-01 00:00:00") 171 | 172 | assert_queries_match(string_query, expected_query) 173 | 174 | assert Ecto.Adapters.SQL.to_sql(:all, Repo, string_query) == 175 | { 176 | ~S|SELECT u0."id", u0."username", u0."email", u0."age", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."inserted_at" > $1)|, 177 | [~N[2021-01-01 00:00:00]] 178 | } 179 | end 180 | 181 | test "WHERE key >= value", %{query: query} do 182 | querystring = "greaterequal:age=30" 183 | string_query = query(query, querystring) 184 | expected_query = from(user in User, where: user.age >= ^"30") 185 | assert_queries_match(string_query, expected_query) 186 | end 187 | 188 | test "JOINS t2 ON t1.foreign_key = t1.primary_key key > value", %{query: query} do 189 | querystring = "greater:bars.age=30" 190 | string_query = query(query, querystring) 191 | 192 | expected_query = 193 | from( 194 | user in User, 195 | join: bars in assoc(user, :bars), 196 | where: bars.age > ^"30" 197 | ) 198 | 199 | assert_queries_match(string_query, expected_query) 200 | end 201 | 202 | test "WHERE key < value", %{query: query} do 203 | querystring = "less:age=100" 204 | string_query = query(query, querystring) 205 | expected_query = from(user in User, where: user.age < ^"100") 206 | assert_queries_match(string_query, expected_query) 207 | end 208 | 209 | test "WHERE key <= value", %{query: query} do 210 | querystring = "lessequal:age=100" 211 | string_query = query(query, querystring) 212 | expected_query = from(user in User, where: user.age <= ^"100") 213 | assert_queries_match(string_query, expected_query) 214 | end 215 | 216 | test "JOINS t2 ON t1.foreign_key = t1.primary_key WHERE key < value", %{query: query} do 217 | querystring = "less:bars.age=100" 218 | string_query = query(query, querystring) 219 | 220 | expected_query = 221 | from( 222 | user in User, 223 | join: bars in assoc(user, :bars), 224 | where: bars.age < ^"100" 225 | ) 226 | 227 | assert_queries_match(string_query, expected_query) 228 | end 229 | 230 | test "WHERE key < max and key > min ", %{query: query} do 231 | querystring = "range:age=100:200" 232 | string_query = query(query, querystring) 233 | expected_query = from(user in User, where: user.age > ^"100" and user.age < ^"200") 234 | assert_queries_match(string_query, expected_query) 235 | end 236 | 237 | test "WHERE key < max and key > . (anything)", %{query: query} do 238 | querystring = "range:age=.:100" 239 | string_query = query(query, querystring) 240 | expected_query = from(user in User, where: user.age < ^"100") 241 | assert_queries_match(string_query, expected_query) 242 | 243 | assert Ecto.Adapters.SQL.to_sql(:all, Repo, string_query) == 244 | { 245 | ~S|SELECT u0."id", u0."username", u0."email", u0."age", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."age" < $1)|, 246 | [100] 247 | } 248 | end 249 | 250 | test "WHERE key < . (anything) and key > min ", %{query: query} do 251 | querystring = "range:age=100:." 252 | string_query = query(query, querystring) 253 | expected_query = from(user in User, where: user.age > ^"100") 254 | assert_queries_match(string_query, expected_query) 255 | end 256 | 257 | 258 | test "JOINS t2 ON t1.foreign_key = t1.primary_key WHERE key < value AND key > min", %{ 259 | query: query 260 | } do 261 | querystring = "range:bars.age=100:200" 262 | string_query = query(query, querystring) 263 | 264 | expected_query = 265 | from( 266 | user in User, 267 | join: bars in assoc(user, :bars), 268 | where: bars.age > ^"100" and bars.age < ^"200" 269 | ) 270 | 271 | assert_queries_match(string_query, expected_query) 272 | end 273 | 274 | test "LIMIT max", %{query: query} do 275 | querystring = "limit=2" 276 | string_query = query(query, querystring) 277 | expected_query = from(user in User, limit: ^"2") 278 | assert_queries_match(string_query, expected_query) 279 | end 280 | 281 | test "OFFSET min", %{query: query} do 282 | querystring = "offset=2" 283 | string_query = query(query, querystring) 284 | expected_query = from(user in User, offset: ^"2") 285 | assert_queries_match(string_query, expected_query) 286 | end 287 | 288 | test "WHERE key NOT IN value", %{query: query} do 289 | querystring = "!email=a@b.co,c@d.co" 290 | string_query = query(query, querystring) 291 | expected_query = from(user in User, where: user.email not in ^["a@b.co", "c@d.co"]) 292 | assert_queries_match(string_query, expected_query) 293 | end 294 | 295 | test "JOINS t2 ON t1.foreign_key = t1.primary_key key != value", %{query: query} do 296 | querystring = "!bars.name=foo" 297 | string_query = query(query, querystring) 298 | 299 | expected_query = 300 | from(user in User, 301 | join: bars in assoc(user, :bars), 302 | where: bars.name != ^"foo" 303 | ) 304 | 305 | assert_queries_match(string_query, expected_query) 306 | end 307 | 308 | test "JOINS t2 ON t1.foreign_key = t1.primary_key WHERE key NOT IN value", %{query: query} do 309 | querystring = "!bars.name=foo,bar" 310 | string_query = query(query, querystring) 311 | 312 | expected_query = 313 | from(user in User, 314 | join: bars in assoc(user, :bars), 315 | where: bars.name not in ^["foo", "bar"] 316 | ) 317 | 318 | assert_queries_match(string_query, expected_query) 319 | end 320 | 321 | test "OR WHERE key = value", %{query: query} do 322 | querystring = "or:email=a@b.co" 323 | string_query = query(query, querystring) 324 | expected_query = from(user in User, or_where: user.email == ^"a@b.co") 325 | assert_queries_match(string_query, expected_query) 326 | end 327 | 328 | test "OR WHERE key != value", %{query: query} do 329 | querystring = "!or:email=a@b.co" 330 | string_query = query(query, querystring) 331 | expected_query = from(user in User, or_where: user.email != ^"a@b.co") 332 | assert_queries_match(string_query, expected_query) 333 | end 334 | 335 | test "JOINS t2 ON t1.foreign_key = t1.primary_key OR WHERE key != value", %{query: query} do 336 | querystring = "!or:bars.name=foo" 337 | string_query = query(query, querystring) 338 | 339 | expected_query = 340 | from(user in User, 341 | join: bars in assoc(user, :bars), 342 | or_where: bars.name != ^"foo" 343 | ) 344 | 345 | assert_queries_match(string_query, expected_query) 346 | end 347 | 348 | test "JOINS t2 ON t1.foreign_key = t1.primary_key OR WHERE key == value", %{query: query} do 349 | querystring = "or:bars.name=foo" 350 | string_query = query(query, querystring) 351 | 352 | expected_query = 353 | from(user in User, 354 | join: bars in assoc(user, :bars), 355 | or_where: bars.name == ^"foo" 356 | ) 357 | 358 | assert_queries_match(string_query, expected_query) 359 | end 360 | 361 | test "JOINS t2 ON t1.foreign_key = t1.primary_key OR WHERE key in value", %{query: query} do 362 | querystring = "or:bars.name=foo,bar" 363 | string_query = query(query, querystring) 364 | 365 | expected_query = 366 | from(user in User, 367 | join: bars in assoc(user, :bars), 368 | or_where: bars.name in ^["foo", "bar"] 369 | ) 370 | 371 | assert_queries_match(string_query, expected_query) 372 | end 373 | 374 | test "OR WHERE key NOT IN value", %{query: query} do 375 | querystring = "!or:email=a@b.co,c@d.co" 376 | string_query = query(query, querystring) 377 | expected_query = from(user in User, or_where: user.email not in ^["a@b.co", "c@d.co"]) 378 | assert_queries_match(string_query, expected_query) 379 | end 380 | 381 | test "JOINS t2 ON t1.foreign_key = t1.primary_key OR WHERE t2.key NOT IN t2.value", %{ 382 | query: query 383 | } do 384 | querystring = "!or:bars.name=foo,bar" 385 | string_query = query(query, querystring) 386 | 387 | expected_query = 388 | from(user in User, 389 | join: bars in assoc(user, :bars), 390 | or_where: bars.name not in ^["foo", "bar"] 391 | ) 392 | 393 | assert_queries_match(string_query, expected_query) 394 | end 395 | 396 | test "OR WHERE key IN value", %{query: query} do 397 | querystring = "or:email=a@b.co,c@d.co" 398 | string_query = query(query, querystring) 399 | expected_query = from(user in User, or_where: user.email in ^["a@b.co", "c@d.co"]) 400 | assert_queries_match(string_query, expected_query) 401 | end 402 | 403 | test "JOINS t2 ON t1.foreign_key = t1.primary_key SELECT t2.value", %{query: query} do 404 | querystring = "select=username,email,bars.name,bars.content,foos.title,foobars.name" 405 | 406 | string_query = query(query, querystring) 407 | 408 | expected_query = 409 | from(user in User, 410 | join: bars in assoc(user, :bars), 411 | join: foos in assoc(user, :foos), 412 | join: foobars in assoc(user, :foobars), 413 | select: [ 414 | {:bars, [:id, :user_id, :content, :name]}, 415 | {:foos, [:id, :user_id, :title]}, 416 | {:foobars, [:id, :foo_id, :name]}, 417 | :email, 418 | :username, 419 | :id 420 | ], 421 | preload: [foos: foos, bars: bars, foobars: foobars] 422 | ) 423 | 424 | assert_queries_match(string_query, expected_query) 425 | end 426 | 427 | test "SELECT values", %{query: query} do 428 | querystring = "select=username,email" 429 | string_query = query(query, querystring) 430 | expected_query = from(user in User, select: ^[:email, :username]) 431 | assert_queries_match(string_query, expected_query) 432 | end 433 | 434 | test "SELECT values with 'fields'", %{query: query} do 435 | querystring = "fields=username,email" 436 | string_query = query(query, querystring) 437 | expected_query = from(user in User, select: ^[:email, :username]) 438 | assert_queries_match(string_query, expected_query) 439 | end 440 | 441 | test "ORDER_BY values with order", %{query: query} do 442 | querystring = "order=username,-email" 443 | string_query = query(query, querystring) 444 | expected_query = from(user in User, order_by: ^[asc: :username, desc: :email]) 445 | assert_queries_match(string_query, expected_query) 446 | end 447 | 448 | test "ORDER_BY values DESC with sort", %{query: query} do 449 | querystring = "order=-username" 450 | string_query = query(query, querystring) 451 | expected_query = from(user in User, order_by: ^[desc: :username]) 452 | assert_queries_match(string_query, expected_query) 453 | end 454 | 455 | describe "date_format/2" do 456 | test "date string for naive_datetime" do 457 | assert date_time_format("2023-01-01", :naive_datetime) == "2023-01-01 00:00:00" 458 | assert date_time_format("2023-01-01 12:12", :naive_datetime) == "2023-01-01 12:12:00" 459 | end 460 | 461 | test "date string for naive_datetime_usec" do 462 | assert date_time_format("2023-01-01", :naive_datetime_usec) == "2023-01-01 00:00:00.000000" 463 | end 464 | 465 | test "date string for utc_datetime_usec" do 466 | assert date_time_format("2023-01-01", :utc_datetime_usec) == "2023-01-01 00:00:00.000000Z" 467 | 468 | assert date_time_format("2023-01-01T00:00:00.000000Z", :utc_datetime_usec) == 469 | "2023-01-01 00:00:00.000000Z" 470 | end 471 | 472 | test "time string for time" do 473 | assert date_time_format("11:01:31", :time) == "11:01:31" 474 | assert date_time_format("11:01:31.123436", :time) == "11:01:31" 475 | end 476 | 477 | test "time string for time_usec" do 478 | assert date_time_format("11:01:31.123436", :time_usec) == "11:01:31.123436" 479 | end 480 | 481 | test "non date or time" do 482 | assert date_time_format("mrmicahcooper", :string) == "mrmicahcooper" 483 | end 484 | 485 | test "actual date passed in" do 486 | assert date_time_format(~T[00:00:00], :time) == ~T[00:00:00] 487 | end 488 | 489 | end 490 | end 491 | -------------------------------------------------------------------------------- /test/support/bar.ex: -------------------------------------------------------------------------------- 1 | defmodule Bar do 2 | use Ecto.Schema 3 | 4 | schema "bars" do 5 | field(:bar, :integer) 6 | field(:name, :string) 7 | field(:age, :integer) 8 | field(:content, :string) 9 | belongs_to(:foo, Foo) 10 | belongs_to(:user, User) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/support/foo.ex: -------------------------------------------------------------------------------- 1 | defmodule Foo do 2 | use Ecto.Schema 3 | 4 | schema "foos" do 5 | field(:foo, :integer) 6 | field(:title, :string) 7 | field(:description, :string) 8 | has_many(:bars, Bar) 9 | belongs_to(:user, User) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/support/query_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule QueryHelpers do 2 | defmacro __using__(_) do 3 | quote do 4 | def assert_queries_match(query1, query2) do 5 | assert cleaned(query1.wheres) == cleaned(query2.wheres) 6 | assert cleaned(query1.limit) == cleaned(query2.limit) 7 | assert cleaned(query1.offset) == cleaned(query2.offset) 8 | assert cleaned(query1.order_bys) == cleaned(query2.order_bys) 9 | assert cleaned(query1.joins) == cleaned(query2.joins) 10 | assert cleaned(query1.select) == cleaned(query2.select) 11 | end 12 | 13 | def cleaned(%{} = map), do: Map.drop(map, [:file, :line, :on]) 14 | def cleaned(nil), do: nil 15 | def cleaned(maps), do: Enum.map(maps, &cleaned/1) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Repo do 2 | use Ecto.Repo, 3 | otp_app: :ecto_query_string, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /test/support/user.ex: -------------------------------------------------------------------------------- 1 | defmodule User do 2 | use Ecto.Schema 3 | 4 | schema "users" do 5 | field(:username, :string) 6 | field(:email, :string) 7 | field(:age, :integer) 8 | field(:password, :string, virtual: true) 9 | field(:password_digest, :string) 10 | has_many(:bars, Bar) 11 | has_many(:foos, Foo) 12 | has_many(:foobars, through: [:foos, :bars]) 13 | timestamps() 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Repo.start_link() 2 | ExUnit.start() 3 | --------------------------------------------------------------------------------