From 25cd5843498062a08cc0369bf34538e0e877f3bb Mon Sep 17 00:00:00 2001 From: Mathias Polligkeit Date: Wed, 27 May 2020 12:49:07 +0200 Subject: [PATCH 1/3] fix invalid filter queries --- .github/workflows/ci.yml | 12 + CHANGELOG.md | 6 + config.exs | 3 + config/config.exs | 3 + config/dev.exs | 1 + config/test.exs | 13 + docker-compose.yml | 9 + lib/flop.ex | 30 +- lib/flop/schema.ex | 12 +- mix.exs | 12 +- mix.lock | 5 + priv/repo/migrations/.formatter.exs | 5 + .../20200527145236_create_test_tables.exs | 16 + test/flop_test.exs | 278 ++++++++++++------ test/support/factory.ex | 84 ++++++ test/support/fruit.ex | 12 +- test/support/pet.ex | 5 +- test/support/repo.ex | 5 + test/support/test_util.ex | 19 +- test/test_helper.exs | 2 + 20 files changed, 413 insertions(+), 119 deletions(-) create mode 100644 config.exs create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/test.exs create mode 100644 docker-compose.yml create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/migrations/20200527145236_create_test_tables.exs create mode 100644 test/support/factory.ex create mode 100644 test/support/repo.ex diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeba642..5c2a887 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,18 @@ jobs: otp: [21.3.8.9, 22.1.3] elixir: [1.8.2, 1.9.2, 1.10.3] + services: + postgres: + image: postgres:12-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v1.0.0 - uses: actions/setup-elixir@v1.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index b9afe6f..7ae5bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## Unreleased +## [0.4.0] - 2020-05-27 + ## Added - Added `=~` filter operator. +## Fixed + +- Query function wasn't generating valid where clauses for filters. + ## [0.3.0] - 2020-05-22 ### Added diff --git a/config.exs b/config.exs new file mode 100644 index 0000000..8233fe9 --- /dev/null +++ b/config.exs @@ -0,0 +1,3 @@ +use Mix.Config + +import_config "#{Mix.env()}.exs" diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..8233fe9 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +use Mix.Config + +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..bb19536 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,13 @@ +use Mix.Config + +config :flop, + ecto_repos: [Flop.Repo] + +config :flop, Flop.Repo, + username: "postgres", + password: "postgres", + database: "flop_test#{System.get_env("MIX_TEST_PARTITION")}", + hostname: "localhost", + pool: Ecto.Adapters.SQL.Sandbox + +config :logger, level: :warn diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7057755 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3.7" +services: + postgres: + image: postgres:12-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" diff --git a/lib/flop.ex b/lib/flop.ex index d91dc89..eb2fbf3 100644 --- a/lib/flop.ex +++ b/lib/flop.ex @@ -24,7 +24,7 @@ defmodule Flop do Add the `t:Flop.t/0` to a `t:Ecto.Queryable.t/0` with `Flop.query/2`. iex> params = %{"order_by" => ["name", "age"], "limit" => 5} - iex> {:ok, flop} = Flop.validate(params, for: Pet) + iex> {:ok, flop} = Flop.validate(params, for: Flop.Pet) {:ok, %Flop{ filters: [], @@ -35,8 +35,8 @@ defmodule Flop do page: nil, page_size: nil }} - iex> Pet |> Flop.query(flop) - #Ecto.Query Flop.Pet |> Flop.query(flop) + #Ecto.Query """ use Ecto.Schema @@ -112,15 +112,15 @@ defmodule Flop do ## Examples iex> flop = %Flop{limit: 10, offset: 19} - iex> Flop.query(Pet, flop) - #Ecto.Query + iex> Flop.query(Flop.Pet, flop) + #Ecto.Query Or enhance an already defined query: iex> require Ecto.Query iex> flop = %Flop{limit: 10} - iex> Pet |> Ecto.Query.where(species: "dog") |> Flop.query(flop) - #Ecto.Query + iex> Flop.Pet |> Ecto.Query.where(species: "dog") |> Flop.query(flop) + #Ecto.Query """ @spec query(Queryable.t(), Flop.t()) :: Queryable.t() def query(q, flop) do @@ -228,27 +228,27 @@ defmodule Flop do end def filter(q, %Filter{field: field, op: :==, value: value}), - do: Query.where(q, ^field == ^value) + do: Query.where(q, ^[{field, value}]) def filter(q, %Filter{field: field, op: :!=, value: value}), - do: Query.where(q, ^field != ^value) + do: Query.where(q, [r], field(r, ^field) != ^value) def filter(q, %Filter{field: field, op: :=~, value: value}) do query_value = "%#{value}%" - Query.where(q, ilike(^field, ^query_value)) + Query.where(q, [r], ilike(field(r, ^field), ^query_value)) end def filter(q, %Filter{field: field, op: :>=, value: value}), - do: Query.where(q, ^field >= ^value) + do: Query.where(q, [r], field(r, ^field) >= ^value) def filter(q, %Filter{field: field, op: :<=, value: value}), - do: Query.where(q, ^field <= ^value) + do: Query.where(q, [r], field(r, ^field) <= ^value) def filter(q, %Filter{field: field, op: :>, value: value}), - do: Query.where(q, ^field > ^value) + do: Query.where(q, [r], field(r, ^field) > ^value) def filter(q, %Filter{field: field, op: :<, value: value}), - do: Query.where(q, ^field < ^value) + do: Query.where(q, [r], field(r, ^field) < ^value) ## Validation @@ -294,7 +294,7 @@ defmodule Flop do that only allowed fields are used. iex> params = %{"order_by" => ["social_security_number"]} - iex> {:error, changeset} = Flop.validate(params, for: Pet) + iex> {:error, changeset} = Flop.validate(params, for: Flop.Pet) iex> changeset.valid? false iex> [order_by: {msg, [_, {_, enum}]}] = changeset.errors diff --git a/lib/flop/schema.ex b/lib/flop/schema.ex index a23a074..6e78b67 100644 --- a/lib/flop/schema.ex +++ b/lib/flop/schema.ex @@ -23,7 +23,7 @@ defprotocol Flop.Schema do After that, you can pass the module as the `:for` option to `Flop.validate/2`. - iex> Flop.validate(%Flop{order_by: [:name]}, for: Pet) + iex> Flop.validate(%Flop{order_by: [:name]}, for: Flop.Pet) {:ok, %Flop{ filters: [], @@ -36,7 +36,7 @@ defprotocol Flop.Schema do }} iex> {:error, changeset} = Flop.validate( - ...> %Flop{order_by: [:social_security_number]}, for: Pet + ...> %Flop{order_by: [:social_security_number]}, for: Flop.Pet ...> ) iex> changeset.valid? false @@ -66,7 +66,7 @@ defprotocol Flop.Schema do @doc """ Returns the filterable fields of a schema. - iex> Flop.Schema.filterable(%Pet{}) + iex> Flop.Schema.filterable(%Flop.Pet{}) [:name, :species] """ @spec filterable(any) :: [atom] @@ -75,7 +75,7 @@ defprotocol Flop.Schema do @doc """ Returns the sortable fields of a schema. - iex> Flop.Schema.sortable(%Pet{}) + iex> Flop.Schema.sortable(%Flop.Pet{}) [:name, :age, :species] """ @spec sortable(any) :: [atom] @@ -84,7 +84,7 @@ defprotocol Flop.Schema do @doc """ Returns the default limit of a schema. - iex> Flop.Schema.default_limit(%Fruit{}) + iex> Flop.Schema.default_limit(%Flop.Fruit{}) 50 """ @spec default_limit(any) :: pos_integer | nil @@ -93,7 +93,7 @@ defprotocol Flop.Schema do @doc """ Returns the maximum limit of a schema. - iex> Flop.Schema.max_limit(%Pet{}) + iex> Flop.Schema.max_limit(%Flop.Pet{}) 20 """ @spec max_limit(any) :: pos_integer | nil diff --git a/mix.exs b/mix.exs index c758eaa..012d733 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Flop.MixProject do def project do [ app: :flop, - version: "0.3.0", + version: "0.4.0", elixir: "~> 1.8", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), @@ -29,7 +29,8 @@ defmodule Flop.MixProject do docs: [ main: "readme", extras: ["README.md"] - ] + ], + aliases: aliases() ] end @@ -49,8 +50,11 @@ defmodule Flop.MixProject do {:credo, "~> 1.4.0", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.0.0", only: [:dev], runtime: false}, {:ecto, "~> 3.2"}, + {:ecto_sql, "~> 3.4", only: :test}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, + {:ex_machina, "~> 2.4", only: :test}, {:excoveralls, "~> 0.10", only: :test}, + {:postgrex, ">= 0.0.0", only: :test}, {:stream_data, "~> 0.5", only: [:dev, :test]} ] end @@ -66,4 +70,8 @@ defmodule Flop.MixProject do files: ~w(lib .formatter.exs mix.exs README* LICENSE*) ] end + + defp aliases do + [test: ["ecto.create --quiet", "ecto.migrate", "test"]] + end end diff --git a/mix.lock b/mix.lock index 5f1eede..a732cff 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,17 @@ %{ "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, + "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, "ecto": {:hex, :ecto, "3.4.4", "a2c881e80dc756d648197ae0d936216c0308370332c5e77a2325a10293eef845", [: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", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4bd3ad62abc3b21fb629f0f7a3dab23a192fca837d257dd08449fba7373561"}, + "ecto_sql": {:hex, :ecto_sql, "3.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, + "ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"}, "excoveralls": {:hex, :excoveralls, "0.12.3", "2142be7cb978a3ae78385487edda6d1aff0e482ffc6123877bb7270a8ffbcfe0", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "568a3e616c264283f5dea5b020783ae40eef3f7ee2163f7a67cbd7b35bcadada"}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, @@ -18,6 +22,7 @@ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, + "postgrex": {:hex, :postgrex, "0.15.4", "5d691c25fc79070705a2ff0e35ce0822b86a0ee3c6fdb7a4fb354623955e1aed", [: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", "306515b9d975fcb2478dc337a1d27dc3bf8af7cd71017c333fe9db3a3d211b0a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..3a510b2 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"], + line_length: 80 +] diff --git a/priv/repo/migrations/20200527145236_create_test_tables.exs b/priv/repo/migrations/20200527145236_create_test_tables.exs new file mode 100644 index 0000000..c03ad83 --- /dev/null +++ b/priv/repo/migrations/20200527145236_create_test_tables.exs @@ -0,0 +1,16 @@ +defmodule Flop.Repo.Migrations.CreateTestTables do + use Ecto.Migration + + def change do + create table(:pets) do + add(:name, :string) + add(:age, :integer) + add(:species, :string) + end + + create table(:fruits) do + add(:name, :string) + add(:familiy, :string) + end + end +end diff --git a/test/flop_test.exs b/test/flop_test.exs index 6cd6335..c29b09e 100644 --- a/test/flop_test.exs +++ b/test/flop_test.exs @@ -5,69 +5,69 @@ defmodule FlopTest do doctest Flop import Ecto.Query, only: [from: 2] + import Flop.Factory import Flop.TestUtil + alias Ecto.Adapters.SQL.Sandbox alias Ecto.Changeset alias Ecto.Query.BooleanExpr alias Ecto.Query.QueryExpr - alias Flop alias Flop.Filter + alias Flop.Fruit + alias Flop.Pet + alias Flop.Repo @base_query from p in Pet, where: p.age > 8, select: p.name + setup do + :ok = Sandbox.checkout(Repo) + end + describe "query/2" do test "adds order_by to query if set" do - flop = %Flop{order_by: [:species, :name], order_directions: [:asc, :desc]} + pets = insert_list(20, :pet) - assert [ - %QueryExpr{ - expr: [ - asc: {{_, _, [_, :species]}, _, _}, - desc: {{_, _, [_, :name]}, _, _} - ] - } - ] = Flop.query(Pet, flop).order_bys + sorted_pets = + Enum.sort( + pets, + &(&1.species < &2.species || + (&1.species == &2.species && &1.name >= &2.name)) + ) - assert [ - %QueryExpr{ - expr: [ - asc: {{_, _, [_, :species]}, _, _}, - desc: {{_, _, [_, :name]}, _, _} - ] - } - ] = Flop.query(@base_query, flop).order_bys + flop = %Flop{ + order_by: [:species, :name], + order_directions: [:asc, :desc] + } + + result = Pet |> Flop.query(flop) |> Repo.all() + assert result == sorted_pets end test "uses :asc as default direction" do - flop = %Flop{order_by: [:species, :name], order_directions: nil} + pets = insert_list(20, :pet) - assert [ - %QueryExpr{ - expr: [ - asc: {{_, _, [_, :species]}, _, _}, - asc: {{_, _, [_, :name]}, _, _} - ] - } - ] = Flop.query(Pet, flop).order_bys + # order by three fieds, no order directions passed - flop = %Flop{order_by: [:species, :name], order_directions: [:desc]} + flop = %Flop{order_by: [:species, :name, :age], order_directions: nil} + sorted_pets = Enum.sort_by(pets, &{&1.species, &1.name, &1.age}) + result = Pet |> Flop.query(flop) |> Repo.all() + assert result == sorted_pets - assert [ - %QueryExpr{ - expr: [ - desc: {{_, _, [_, :species]}, _, _}, - asc: {{_, _, [_, :name]}, _, _} - ] - } - ] = Flop.query(Pet, flop).order_bys + # order by three fields, one order direction passed - flop = %Flop{order_by: [:species], order_directions: [:desc]} + flop = %Flop{order_by: [:species, :name, :age], order_directions: [:desc]} - assert [ - %QueryExpr{ - expr: [desc: {{_, _, [_, :species]}, _, _}] - } - ] = Flop.query(Pet, flop).order_bys + sorted_pets = + Enum.sort( + pets, + &(&1.species > &2.species || + (&1.species == &2.species && + (&1.name < &2.name || + (&1.name == &2.name && &1.age <= &2.age)))) + ) + + result = Pet |> Flop.query(flop) |> Repo.all() + assert result == sorted_pets flop = %Flop{order_by: [:species], order_directions: [:desc, :desc]} @@ -79,57 +79,179 @@ defmodule FlopTest do end test "adds adds limit to query if set" do + insert_list(11, :pet) flop = %Flop{limit: 10} query = Flop.query(Pet, flop) - assert %QueryExpr{params: [{10, :integer}]} = query.limit + assert length(Repo.all(query)) == 10 end test "adds adds offset to query if set" do - flop = %Flop{offset: 14} - query = Flop.query(Pet, flop) + pets = insert_list(10, :pet) + + expected_pets = + pets + |> Enum.sort_by(&{&1.name, &1.species, &1.age}) + |> Enum.slice(4..10) - assert %QueryExpr{params: [{14, :integer}]} = query.offset + flop = %Flop{offset: 4, order_by: [:name, :species, :age]} + query = Flop.query(Pet, flop) + assert %QueryExpr{params: [{4, :integer}]} = query.offset + assert Repo.all(query) == expected_pets end test "adds adds limit and offset to query if page and page size are set" do - flop = %Flop{page: 1, page_size: 10} - assert %QueryExpr{params: [{0, :integer}]} = Flop.query(Pet, flop).offset - assert %QueryExpr{params: [{10, :integer}]} = Flop.query(Pet, flop).limit + pets = insert_list(40, :pet) + sorted_pets = Enum.sort_by(pets, &{&1.name, &1.species, &1.age}) + order_by = [:name, :species, :age] + + flop = %Flop{page: 1, page_size: 10, order_by: order_by} + query = Flop.query(Pet, flop) + assert %QueryExpr{params: [{0, :integer}]} = query.offset + assert %QueryExpr{params: [{10, :integer}]} = query.limit + assert Repo.all(query) == Enum.slice(sorted_pets, 0..9) + + flop = %Flop{page: 2, page_size: 10, order_by: order_by} + query = Flop.query(Pet, flop) + assert %QueryExpr{params: [{10, :integer}]} = query.offset + assert %QueryExpr{params: [{10, :integer}]} = query.limit + assert Repo.all(query) == Enum.slice(sorted_pets, 10..19) + + flop = %Flop{page: 3, page_size: 4, order_by: order_by} + query = Flop.query(Pet, flop) + assert %QueryExpr{params: [{8, :integer}]} = query.offset + assert %QueryExpr{params: [{4, :integer}]} = query.limit + assert Repo.all(query) == Enum.slice(sorted_pets, 8..11) + end + + property "applies equality filter" do + pets = insert_list(50, :pet) + + check all field <- member_of([:age, :name]), + values = Enum.map(pets, &Map.get(&1, field)), + query_value <- + one_of([member_of(values), value_by_field(field)]), + query_value != "" do + {:ok, flop} = + Flop.validate(%{ + filters: [ + %{field: field, op: :==, value: query_value} + ] + }) + + query = Flop.query(Pet, flop) + result = Repo.all(query) + assert Enum.all?(result, &(Map.get(&1, field) == query_value)) + + expected_pets = Enum.filter(pets, &(Map.get(&1, field) == query_value)) + assert length(result) == length(expected_pets) + end + end + + property "applies inequality filter" do + pets = insert_list(50, :pet) + + check all field <- member_of([:age, :name]), + values = Enum.map(pets, &Map.get(&1, field)), + query_value <- + one_of([member_of(values), value_by_field(field)]), + query_value != "" do + {:ok, flop} = + Flop.validate(%{ + filters: [ + %{field: field, op: :!=, value: query_value} + ] + }) + + query = Flop.query(Pet, flop) + result = Repo.all(query) + refute Enum.any?(result, &(Map.get(&1, field) == query_value)) + + expected_pets = Enum.filter(pets, &(Map.get(&1, field) != query_value)) + assert length(result) == length(expected_pets) + end + end + + property "applies ilike filter" do + pets = insert_list(50, :pet) + values = Enum.map(pets, & &1.name) + + check all some_value <- member_of(values), + str_length = String.length(some_value), + start_at <- integer(0..(str_length - 1)), + end_at <- integer(start_at..(str_length - 1)), + query_value = String.slice(some_value, start_at..end_at), + query_value != " " do + {:ok, flop} = + Flop.validate(%{ + filters: [ + %{field: :name, op: :=~, value: query_value} + ] + }) + + ci_query_value = String.downcase(query_value) + + expected_pets = + Enum.filter(pets, &(String.downcase(&1.name) =~ ci_query_value)) + + query = Flop.query(Pet, flop) + result = Repo.all(query) + assert Enum.all?(result, &(String.downcase(&1.name) =~ ci_query_value)) + + assert length(result) == length(expected_pets) + end + end + + defp filter_pets(pets, field, op, value), + do: Enum.filter(pets, pet_matches?(op, field, value)) + + defp pet_matches?(:<=, k, v), do: &(Map.get(&1, k) <= v) + defp pet_matches?(:<, k, v), do: &(Map.get(&1, k) < v) + defp pet_matches?(:>, k, v), do: &(Map.get(&1, k) > v) + defp pet_matches?(:>=, k, v), do: &(Map.get(&1, k) >= v) - flop = %Flop{page: 2, page_size: 10} - assert %QueryExpr{params: [{10, :integer}]} = Flop.query(Pet, flop).offset - assert %QueryExpr{params: [{10, :integer}]} = Flop.query(Pet, flop).limit + property "applies lte, lt, gt and gte filters" do + pets = insert_list(50, :pet_downcase) - flop = %Flop{page: 3, page_size: 4} - assert %QueryExpr{params: [{8, :integer}]} = Flop.query(Pet, flop).offset - assert %QueryExpr{params: [{4, :integer}]} = Flop.query(Pet, flop).limit + check all field <- member_of([:age, :name]), + op <- one_of([:<=, :<, :>, :>=]), + query_value <- compare_value_by_field(field) do + {:ok, flop} = + Flop.validate(%{ + filters: [ + %{field: field, op: op, value: query_value} + ] + }) + + expected_pets = + pets + |> filter_pets(field, op, query_value) + |> Enum.sort_by(&{&1.name, &1.species, &1.age}) + + query = Flop.query(Pet, flop) + + result = + query + |> Repo.all() + |> Enum.sort_by(&{&1.name, &1.species, &1.age}) + + assert result == expected_pets + end end property "adds where clauses for filters" do check all filter <- filter() do flop = %Flop{filters: [filter]} - %Filter{field: field, op: op, value: value} = filter + %Filter{op: op} = filter + query = Flop.query(Pet, flop) if op == :=~ do - query_value = "%" <> to_string(value) <> "%" - - assert [ - %BooleanExpr{ - expr: {:ilike, _, _}, - op: :and, - params: [{^field, _}, {^query_value, _}] - } - ] = Flop.query(Pet, flop).wheres + assert [%BooleanExpr{expr: {:ilike, _, _}, op: :and}] = query.wheres else - assert [ - %BooleanExpr{ - expr: {^op, _, _}, - op: :and, - params: [{^field, _}, {^value, _}] - } - ] = Flop.query(Pet, flop).wheres + assert [%BooleanExpr{expr: {^op, _, _}, op: :and}] = query.wheres end + + assert is_list(Repo.all(query)) end end @@ -142,16 +264,8 @@ defmodule FlopTest do } assert [ - %BooleanExpr{ - expr: {:>=, _, _}, - op: :and, - params: [{:age, _}, {4, _}] - }, - %BooleanExpr{ - expr: {:==, _, _}, - op: :and, - params: [{:name, _}, {"Bo", _}] - } + %BooleanExpr{expr: {:>=, _, _}, op: :and}, + %BooleanExpr{expr: {:==, _, _}, op: :and} ] = Flop.query(Pet, flop).wheres end diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 0000000..2e727e9 --- /dev/null +++ b/test/support/factory.ex @@ -0,0 +1,84 @@ +defmodule Flop.Factory do + @moduledoc false + use ExMachina.Ecto, repo: Flop.Repo + + alias Flop.Fruit + alias Flop.Pet + + def fruit_factory do + %Fruit{ + family: build(:fruit_family), + name: build(:name) + } + end + + def pet_factory do + %Pet{ + name: build(:name), + age: :rand.uniform(30), + species: build(:species) + } + end + + def pet_downcase_factory do + Map.update!(build(:pet), :name, &String.downcase/1) + end + + def fruit_family_factory(_) do + sequence(:fruit_family, [ + "Rosaceae", + "Lecythidaceae", + "Rubiaceae", + "Salicaceae", + "Sapotaceae" + ]) + end + + def name_factory(_) do + sequence(:name, [ + "Flossie Blackwell", + "Casey Pierce", + "Ingrid Gallagher", + "Emil Smith", + "Brittney Johnson", + "Rodney Carter", + "Brittany Villegas", + "Etta Romero", + "Loretta Norris", + "Eddie Becker", + "Floyd Holland", + "Bernardo Wade", + "Gay Rich", + "Harrison Brooks", + "Frederic Snow", + "Clay Sutton", + "Genevieve Singh", + "Albert Adkins", + "Bianca Schroeder", + "Rolando Barker", + "Billy Francis", + "Jody Hanna", + "Marisa Williamson", + "Kenton Hess", + "Carrol Simon" + ]) + end + + def species_factory(_) do + sequence(:species, [ + "C. lupus", + "F. catus", + "O. cuniculus", + "C. porcellus", + "V. pacos", + "C. bactrianus", + "E. africanus", + "M. putorius", + "C. aegagrus", + "L. glama", + "S. scrofa", + "R. norvegicus", + "O. aries" + ]) + end +end diff --git a/test/support/fruit.ex b/test/support/fruit.ex index d475283..521f49f 100644 --- a/test/support/fruit.ex +++ b/test/support/fruit.ex @@ -1,18 +1,14 @@ -defmodule Fruit do +defmodule Flop.Fruit do @moduledoc """ Defines an Ecto schema for testing. """ use Ecto.Schema @derive {Flop.Schema, - filterable: [:name, :species], - sortable: [:name, :age, :species], - default_limit: 50} + filterable: [:name, :family], sortable: [:name], default_limit: 50} - embedded_schema do + schema "fruits" do field :name, :string - field :age, :integer - field :species, :string - field :social_security_number, :string + field :family, :string end end diff --git a/test/support/pet.ex b/test/support/pet.ex index 2a82dd4..5452b10 100644 --- a/test/support/pet.ex +++ b/test/support/pet.ex @@ -1,4 +1,4 @@ -defmodule Pet do +defmodule Flop.Pet do @moduledoc """ Defines an Ecto schema for testing. """ @@ -9,10 +9,9 @@ defmodule Pet do sortable: [:name, :age, :species], max_limit: 20} - embedded_schema do + schema "pets" do field :name, :string field :age, :integer field :species, :string - field :social_security_number, :string end end diff --git a/test/support/repo.ex b/test/support/repo.ex new file mode 100644 index 0000000..4e6fb65 --- /dev/null +++ b/test/support/repo.ex @@ -0,0 +1,5 @@ +defmodule Flop.Repo do + use Ecto.Repo, + otp_app: :flop, + adapter: Ecto.Adapters.Postgres +end diff --git a/test/support/test_util.ex b/test/support/test_util.ex index e8f9375..de05c68 100644 --- a/test/support/test_util.ex +++ b/test/support/test_util.ex @@ -3,7 +3,6 @@ defmodule Flop.TestUtil do use ExUnitProperties - alias Flop.CustomTypes.Operator alias Flop.Filter @doc """ @@ -28,9 +27,23 @@ defmodule Flop.TestUtil do """ def filter do gen all field <- member_of([:age, :name]), - op <- member_of(Operator.__operators__()), - value <- one_of([integer(), float(), string(:alphanumeric)]) do + value <- value_by_field(field), + op <- operator_by_type(value) do %Filter{field: field, op: op, value: value} end end + + def value_by_field(:age), do: integer() + def value_by_field(:name), do: string(:alphanumeric, min_length: 1) + + def compare_value_by_field(:age), do: integer(1..30) + + def compare_value_by_field(:name), + do: string(?a..?z, min_length: 1, max_length: 3) + + defp operator_by_type(a) when is_binary(a), + do: member_of([:==, :!=, :=~, :<=, :<, :>=, :>]) + + defp operator_by_type(a) when is_number(a), + do: member_of([:==, :!=, :<=, :<, :>=, :>]) end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..4897e3b 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,3 @@ +{:ok, _pid} = Flop.Repo.start_link() +{:ok, _} = Application.ensure_all_started(:ex_machina) ExUnit.start() From 110eeabd839d54b7749a9427f2474676035eaef9 Mon Sep 17 00:00:00 2001 From: Mathias Polligkeit Date: Wed, 27 May 2020 20:21:32 +0200 Subject: [PATCH 2/3] bump version in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6da6bfe..bb20cd7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Add `flop` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:flop, "~> 0.2.0"} + {:flop, "~> 0.4.0"} ] end ``` From 818cf079ca71f10dec6db02ab6b4baaab5e45859 Mon Sep 17 00:00:00 2001 From: Mathias Polligkeit Date: Wed, 27 May 2020 20:25:47 +0200 Subject: [PATCH 3/3] expose postgres port on ci --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c2a887..63fde99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v1.0.0