From 16d4bd1c6f3eb76fe2eeb6372b6b99084a5cfa32 Mon Sep 17 00:00:00 2001 From: nboisvert Date: Wed, 12 Mar 2025 20:10:07 -0400 Subject: [PATCH 1/9] Working on documentation --- flake.lock | 61 ++++++++++++ flake.nix | 28 ++++++ lib/loupe/language/ast.ex | 14 ++- lib/loupe/stream.ex | 125 +++++++++++++++++++++++++ lib/loupe/stream/comparator.ex | 78 +++++++++++++++ lib/loupe/stream/context.ex | 45 +++++++++ lib/loupe/stream/default_comparator.ex | 48 ++++++++++ mix.exs | 2 +- 8 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 lib/loupe/stream.ex create mode 100644 lib/loupe/stream/comparator.ex create mode 100644 lib/loupe/stream/context.ex create mode 100644 lib/loupe/stream/default_comparator.ex diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c753faa --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1741600792, + "narHash": "sha256-yfDy6chHcM7pXpMF4wycuuV+ILSTG486Z/vLx/Bdi6Y=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "ebe2788eafd539477f83775ef93c3c7e244421d3", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..537ec55 --- /dev/null +++ b/flake.nix @@ -0,0 +1,28 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + elixir_1_14 + erlang_25 + docker + ]; + + shellHook = '' + mix deps.get + ''; + }; + } + ); +} diff --git a/lib/loupe/language/ast.ex b/lib/loupe/language/ast.ex index 01f95ad..ffba758 100644 --- a/lib/loupe/language/ast.ex +++ b/lib/loupe/language/ast.ex @@ -71,14 +71,20 @@ defmodule Loupe.Language.Ast do @typedoc "Valid boolean operators" @type boolean_operator :: :or | :and + @typedoc "Json like object decoded" @type object :: {:object, [{binary(), literal() | object()}]} + @typedoc "Operator structure" + @type operator :: {operand(), binding(), literal()} + + @typedoc "Negated operator" + @type negated_operator :: {:not, operator()} + @typedoc "Validation composed predicates" @type predicate :: {boolean_operator(), predicate(), predicate()} - | {operand(), binding(), literal()} - | {:not, {operand(), binding(), literal()}} - | nil + | operator() + | negated_operator() @typedoc "Query quantifier to limit the query result count" @type quantifier :: :all | {:int, integer()} | {:range, range()} @@ -93,7 +99,7 @@ defmodule Loupe.Language.Ast do action: binary(), quantifier: quantifier(), schema: binary(), - predicates: predicate(), + predicates: predicate() | nil, external_identifiers: external_identifiers(), parameters: parameters() } diff --git a/lib/loupe/stream.ex b/lib/loupe/stream.ex new file mode 100644 index 0000000..ea76714 --- /dev/null +++ b/lib/loupe/stream.ex @@ -0,0 +1,125 @@ +defmodule Loupe.Stream do + @moduledoc """ + Filters a stream with Loupe's ast. Schema will likely have no impact + on the way this filtering is being done + """ + + alias Loupe.Language.Ast + alias Loupe.Stream.Comparator + alias Loupe.Stream.Context + + @type build_query_error :: any() + + @type option :: {:limit?, boolean()} | {:variables, map()} | Context.option() + + @doc """ + Queries a stream using an AST or a Query. The returned result will + be an enumerable function to be executed with `Enum` functions. + + For stream queries, the Schema is unused, up to you to perform pre-filtering + or put a static one in place if you want. + + ## Examples + + The following example will filter records whose age is greater than 18 + + iex> Loupe.Stream.query(~s|get A where age > 18|, [ + ...> %{age: 76}, + ...> %{age: 13}, + ...> %{age: 28}, + ...> %{age: 6}, + ...> %{age: 34}, + ...> ]) + [%{age: 76}] + + The same parsing rules applies here, so only one record is returned because + when quantifier is provided, it defaults to 1. One could use `all` to + get all the records that are matching or a range. + + + iex> Loupe.Stream.query(~s|get all A where age > 18|, [ + ...> %{age: 76}, + ...> %{age: 13}, + ...> %{age: 28}, + ...> %{age: 6}, + ...> %{age: 34}, + ...> ]) + [%{age: 76}, %{age: 28}, %{age: 34}] + + iex> Loupe.Stream.query(~s|get 2..3 A where age > 18|, [ + ...> %{age: 76}, + ...> %{age: 13}, + ...> %{age: 28}, + ...> %{age: 6}, + ...> %{age: 34}, + ...> ]) + [%{age: 28}, %{age: 34}] + + ### Overriding query's limit + + In case you wanna enforce a limit of your own to the stream and don't wanna + depend on the query's `quantifier`, you can pass `limit?: false` to the function + + iex> Loupe.Stream.query(~s|get 1 A where age > 18|, [ + ...> %{age: 76}, + ...> %{age: 13}, + ...> %{age: 28}, + ...> %{age: 6}, + ...> %{age: 34}, + ...> ], limit?: false) + [%{age: 76}, %{age: 28}, %{age: 34}] + """ + @spec query(String.t() | Ast.t(), Enumerable.t(), [option()]) :: + {:ok, Enumerable.t()} | {:error, build_query_error()} + def query(string_or_ast, enumerable, options \\ []) + + def query(string, enumerable, options) when is_binary(string) do + with {:ok, ast} <- Loupe.Language.compile(string) do + query(ast, enumerable, options) + end + end + + def query(%Ast{quantifier: quantifier} = ast, enumerable, options) do + variables = Keyword.get(options, :variables, %{}) + + context = + options + |> Keyword.get_lazy(:context, fn -> Context.new(options) end) + |> Context.apply_ast(ast) + |> Context.put_variables(variables) + + stream = + enumerable + |> Stream.filter(&matches_ast?(ast, &1, context)) + |> maybe_limit_records(quantifier, options) + + {:ok, stream} + end + + defp matches_ast?(%Ast{predicates: nil}, _element, _context) do + true + end + + defp matches_ast?(%Ast{predicates: predicates}, element, context) do + Comparator.compare(predicates, element, context) + end + + defp maybe_limit_records(enumerable, quantifier, options) do + if Keyword.get(options, :limit?, true) do + limit_records(enumerable, quantifier) + else + enumerable + end + end + + defp limit_records(enumerable, :all), do: enumerable + defp limit_records(enumerable, {:int, integer}), do: Stream.take(enumerable, integer) + + defp limit_records(enumerable, {:range, {lower_bound, upper_bound}}) do + lower_bound = lower_bound - 1 + + enumerable + |> Stream.drop(lower_bound) + |> Stream.take(upper_bound - lower_bound) + end +end diff --git a/lib/loupe/stream/comparator.ex b/lib/loupe/stream/comparator.ex new file mode 100644 index 0000000..5820008 --- /dev/null +++ b/lib/loupe/stream/comparator.ex @@ -0,0 +1,78 @@ +defmodule Loupe.Stream.Comparator do + @moduledoc """ + Behaviour to implement comparator. + """ + + alias Loupe.Language.Ast + alias Loupe.Stream.Context + + @doc "Compares a stream's value with a literal value" + @callback compare(Loupe.Language.Ast.operator()) :: boolean() + + @doc "Compares predicates inside a given map/structure tree" + @spec compare(Ast.predicate(), any(), Context.t()) :: boolean() + def compare({:and, left, right}, element, %Context{} = context) do + compare(left, element, context) and compare(right, element, context) + end + + def compare({:or, left, right}, element, %Context{} = context) do + compare(left, element, context) or compare(right, element, context) + end + + def compare({:not, operand}, element, %Context{} = context) do + not compare(operand, element, context) + end + + def compare(operand, element, %Context{comparator: comparator} = context) do + operand_with_values = + operand + |> fill_values(element, context) + |> unwrap_right_value(context) + + comparator.compare(operand_with_values) + end + + defp unwrap_right_value({operator, left, right}, context) do + {operator, left, unwrap_right_value(right, context)} + end + + defp unwrap_right_value(:empty, _), do: nil + defp unwrap_right_value(boolean, _) when is_boolean(boolean), do: boolean + defp unwrap_right_value({:int, int}, _), do: int + defp unwrap_right_value({:string, string}, _), do: string + defp unwrap_right_value({:float, float}, _), do: float + + defp unwrap_right_value({:identifier, identifier}, %Context{variables: variables}) do + Map.get(variables, identifier) + end + + defp fill_values({_ = operator, {:binding, [binding | rest_bindings]}, right}, element, context) do + case {get_value(element, binding), rest_bindings} do + {{:ok, value}, []} -> + {operator, value, right} + + {{:ok, value}, _} -> + fill_values({operator, {:binding, rest_bindings}, right}, value, context) + + {{:error, _}, _} -> + {operator, nil, right} + end + end + + defp get_value(map, key) when is_map(map) do + with :error <- Map.fetch(map, key), + :error <- fetch_atom_key(map, key) do + {:error, :key_missing} + end + end + + defp get_value(_, _), do: {:error, :not_map} + + defp fetch_atom_key(map, string) do + key = String.to_existing_atom(string) + Map.fetch(map, key) + rescue + _ -> + :error + end +end diff --git a/lib/loupe/stream/context.ex b/lib/loupe/stream/context.ex new file mode 100644 index 0000000..33981ba --- /dev/null +++ b/lib/loupe/stream/context.ex @@ -0,0 +1,45 @@ +defmodule Loupe.Stream.Context do + @moduledoc """ + Context to work with Stream filtering + """ + + alias Loupe.Language.Ast + alias Loupe.Stream.Context + + defstruct comparator: Loupe.Stream.DefaultComparator, + parameters: %{}, + variables: %{} + + @type comparator :: module() + + @type t :: %Context{ + comparator: comparator(), + parameters: Ast.parameters(), + variables: map() + } + + @default_comparator Loupe.Stream.DefaultComparator + + @type option :: {:comparator, comparator()} + + @doc "Creates a new context" + @spec new([option()]) :: t() + def new(options \\ []) do + comparator = Keyword.get(options, :comparator, @default_comparator) + variables = Keyword.get(options, :variables, %{}) + + %Context{comparator: comparator, variables: variables} + end + + @doc "Applies AST options in the context to prevent carrying the ast around" + @spec apply_ast(t(), Ast.t()) :: t() + def apply_ast(%Context{} = context, %Ast{} = ast) do + %Context{context | parameters: ast.parameters} + end + + @doc "Puts variable in context" + @spec put_variables(t(), map()) :: t() + def put_variables(%Context{} = context, variables) do + %Context{context | variables: variables} + end +end diff --git a/lib/loupe/stream/default_comparator.ex b/lib/loupe/stream/default_comparator.ex new file mode 100644 index 0000000..0054cc7 --- /dev/null +++ b/lib/loupe/stream/default_comparator.ex @@ -0,0 +1,48 @@ +defmodule Loupe.Stream.DefaultComparator do + @moduledoc """ + Default comparator that does strict comparison. + """ + @behaviour Loupe.Stream.Comparator + + @impl Loupe.Stream.Comparator + def compare({:=, nil, nil}), do: true + + def compare({_, nil, _}), do: false + + def compare({:=, left, right}) do + left == right + end + + def compare({:>=, left, right}) do + left >= right + end + + def compare({:>, left, right}) do + left > right + end + + def compare({:<=, left, right}) do + left <= right + end + + def compare({:<, left, right}) do + left < right + end + + def compare({:like, left, right}) do + left + |> to_string() + |> insensitive_like(to_string(right)) + end + + def compare({:in, left, right}) do + left in right + end + + defp insensitive_like(left, right) do + left_downcase = String.downcase(left) + right_downcase = String.downcase(right) + + left_downcase =~ right_downcase + end +end diff --git a/mix.exs b/mix.exs index 786951e..e9899b2 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Loupe.MixProject do [ app: :loupe, version: @version, - elixir: "~> 1.12", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), From ba87b4623994ca063d1be05ff5101fd6cd9a8889 Mon Sep 17 00:00:00 2001 From: nboisvert Date: Wed, 12 Mar 2025 20:37:49 -0400 Subject: [PATCH 2/9] Working on tests --- lib/loupe/stream.ex | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/loupe/stream.ex b/lib/loupe/stream.ex index ea76714..a23bd1e 100644 --- a/lib/loupe/stream.ex +++ b/lib/loupe/stream.ex @@ -23,36 +23,38 @@ defmodule Loupe.Stream do The following example will filter records whose age is greater than 18 - iex> Loupe.Stream.query(~s|get A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get A where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, ...> %{age: 6}, ...> %{age: 34}, ...> ]) + iex> Enum.to_list(stream) [%{age: 76}] The same parsing rules applies here, so only one record is returned because when quantifier is provided, it defaults to 1. One could use `all` to get all the records that are matching or a range. - - iex> Loupe.Stream.query(~s|get all A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get all A where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, ...> %{age: 6}, ...> %{age: 34}, ...> ]) + iex> Enum.to_list(stream) [%{age: 76}, %{age: 28}, %{age: 34}] - iex> Loupe.Stream.query(~s|get 2..3 A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get 2..3 A where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, ...> %{age: 6}, ...> %{age: 34}, ...> ]) + iex> Enum.to_list(stream) [%{age: 28}, %{age: 34}] ### Overriding query's limit @@ -60,14 +62,32 @@ defmodule Loupe.Stream do In case you wanna enforce a limit of your own to the stream and don't wanna depend on the query's `quantifier`, you can pass `limit?: false` to the function - iex> Loupe.Stream.query(~s|get 1 A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get 1 A where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, ...> %{age: 6}, ...> %{age: 34}, ...> ], limit?: false) + iex> Enum.to_list(stream) + [%{age: 76}, %{age: 28}, %{age: 34}] + + ### Using variables + + You can provide variables to your query with the `variables` option. Keys + must be string to match what is decoded from the query. + + + iex> {:ok, stream} = Loupe.Stream.query(~s|get 1 A where age > adult|, [ + ...> %{age: 76}, + ...> %{age: 13}, + ...> %{age: 28}, + ...> %{age: 6}, + ...> %{age: 34}, + ...> ], limit?: false, variables: %{"adult" => 18}) + iex> Enum.to_list(stream) [%{age: 76}, %{age: 28}, %{age: 34}] + """ @spec query(String.t() | Ast.t(), Enumerable.t(), [option()]) :: {:ok, Enumerable.t()} | {:error, build_query_error()} From 3e755e132e228696695da4d8aaf25454294f202a Mon Sep 17 00:00:00 2001 From: nboisvert Date: Thu, 13 Mar 2025 22:34:06 -0400 Subject: [PATCH 3/9] Add tests for default comparator --- lib/loupe/stream/comparator.ex | 2 + lib/loupe/stream/context.ex | 4 +- lib/loupe/stream/default_comparator.ex | 4 + test/loupe/stream/comparator_test.exs | 9 ++ test/loupe/stream/context_test.exs | 48 ++++++++ test/loupe/stream/default_comparator_test.exs | 105 ++++++++++++++++++ test/loupe/stream_test.exs | 7 ++ 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 test/loupe/stream/comparator_test.exs create mode 100644 test/loupe/stream/context_test.exs create mode 100644 test/loupe/stream/default_comparator_test.exs create mode 100644 test/loupe/stream_test.exs diff --git a/lib/loupe/stream/comparator.ex b/lib/loupe/stream/comparator.ex index 5820008..6cb0f98 100644 --- a/lib/loupe/stream/comparator.ex +++ b/lib/loupe/stream/comparator.ex @@ -23,6 +23,8 @@ defmodule Loupe.Stream.Comparator do not compare(operand, element, context) end + # TODO: Take care for cases where we have lists in there + # TODO: Figure a way to treat tuples def compare(operand, element, %Context{comparator: comparator} = context) do operand_with_values = operand diff --git a/lib/loupe/stream/context.ex b/lib/loupe/stream/context.ex index 33981ba..feb80b3 100644 --- a/lib/loupe/stream/context.ex +++ b/lib/loupe/stream/context.ex @@ -39,7 +39,7 @@ defmodule Loupe.Stream.Context do @doc "Puts variable in context" @spec put_variables(t(), map()) :: t() - def put_variables(%Context{} = context, variables) do - %Context{context | variables: variables} + def put_variables(%Context{variables: variables} = context, new_variables) do + %Context{context | variables: Map.merge(variables, new_variables)} end end diff --git a/lib/loupe/stream/default_comparator.ex b/lib/loupe/stream/default_comparator.ex index 0054cc7..a411690 100644 --- a/lib/loupe/stream/default_comparator.ex +++ b/lib/loupe/stream/default_comparator.ex @@ -9,6 +9,10 @@ defmodule Loupe.Stream.DefaultComparator do def compare({_, nil, _}), do: false + def compare({operator, atom, value}) when is_atom(atom) and not is_boolean(atom) do + compare({operator, to_string(atom), value}) + end + def compare({:=, left, right}) do left == right end diff --git a/test/loupe/stream/comparator_test.exs b/test/loupe/stream/comparator_test.exs new file mode 100644 index 0000000..cef6385 --- /dev/null +++ b/test/loupe/stream/comparator_test.exs @@ -0,0 +1,9 @@ +defmodule Loupe.Stream.ComparatorTest do + use Loupe.TestCase, async: true + + describe "compare/3" do + test "compares an operation with an item" do + raise "Impl me siouplait" + end + end +end diff --git a/test/loupe/stream/context_test.exs b/test/loupe/stream/context_test.exs new file mode 100644 index 0000000..8b7b7fd --- /dev/null +++ b/test/loupe/stream/context_test.exs @@ -0,0 +1,48 @@ +defmodule Loupe.Stream.ContextTest do + use Loupe.TestCase, async: true + + alias Loupe.Stream.Context + + describe "new/1" do + test "creates a new context" do + assert %Context{ + parameters: %{}, + comparator: Loupe.Stream.DefaultComparator, + variables: %{} + } == Context.new() + + assert %Context{ + parameters: %{}, + comparator: SomeComparator, + variables: %{"key" => "value"} + } == + Context.new( + comparator: SomeComparator, + variables: %{"key" => "value"} + ) + end + end + + describe "apply_ast/2" do + test "applies ast parameters" do + assert {:ok, ast} = + Loupe.Language.compile( + ~s|get all Keys{case: "lowercase"} where email like "hello"| + ) + + assert %Context{ + parameters: %{"case" => "lowercase"} + } = Context.apply_ast(Context.new(), ast) + end + end + + describe "put_variables/2" do + test "put variables in context" do + context = Context.new(variables: %{"key" => "value"}) + + assert %Context{ + variables: %{"other_key" => "other_value", "key" => "value"} + } = Context.put_variables(context, %{"other_key" => "other_value"}) + end + end +end diff --git a/test/loupe/stream/default_comparator_test.exs b/test/loupe/stream/default_comparator_test.exs new file mode 100644 index 0000000..1faf511 --- /dev/null +++ b/test/loupe/stream/default_comparator_test.exs @@ -0,0 +1,105 @@ +defmodule Loupe.Stream.DefaultComparatorTest do + use Loupe.TestCase, async: true + + alias Loupe.Stream.DefaultComparator + + describe "compare/1" do + test "compares with =" do + assert DefaultComparator.compare({:=, "string", "string"}) + assert DefaultComparator.compare({:=, 1, 1}) + assert DefaultComparator.compare({:=, 1.2, 1.2}) + assert DefaultComparator.compare({:=, :atom, "atom"}) + assert DefaultComparator.compare({:=, nil, nil}) + assert DefaultComparator.compare({:=, true, true}) + assert DefaultComparator.compare({:=, false, false}) + + refute DefaultComparator.compare({:=, "string", "not string"}) + refute DefaultComparator.compare({:=, 1, 2}) + refute DefaultComparator.compare({:=, 1.2, 2.1}) + refute DefaultComparator.compare({:=, :atom, "not atom"}) + refute DefaultComparator.compare({:=, nil, "not nil"}) + refute DefaultComparator.compare({:=, true, false}) + refute DefaultComparator.compare({:=, false, true}) + end + + test "compares with >=" do + assert DefaultComparator.compare({:>=, "b", "a"}) + assert DefaultComparator.compare({:>=, "b", "b"}) + assert DefaultComparator.compare({:>=, 5, 3}) + assert DefaultComparator.compare({:>=, 5, 5}) + assert DefaultComparator.compare({:>=, 5.0, 3.1}) + assert DefaultComparator.compare({:>=, 5.0, 5.0}) + + refute DefaultComparator.compare({:>=, "b", "c"}) + refute DefaultComparator.compare({:>=, 3, 5}) + refute DefaultComparator.compare({:>=, 3.0, 5.0}) + end + + test "compares with >" do + assert DefaultComparator.compare({:>, "c", "b"}) + assert DefaultComparator.compare({:>, 5, 3}) + assert DefaultComparator.compare({:>, 5.0, 3.1}) + + refute DefaultComparator.compare({:>, "b", "c"}) + refute DefaultComparator.compare({:>, "c", "c"}) + refute DefaultComparator.compare({:>, 3, 5}) + refute DefaultComparator.compare({:>, 5, 5}) + refute DefaultComparator.compare({:>, 3.0, 5.0}) + refute DefaultComparator.compare({:>, 5.0, 5.0}) + end + + test "compares with <" do + assert DefaultComparator.compare({:<, "b", "c"}) + assert DefaultComparator.compare({:<, 3, 5}) + assert DefaultComparator.compare({:<, 3.0, 5.0}) + + refute DefaultComparator.compare({:<, "c", "b"}) + refute DefaultComparator.compare({:<, "c", "c"}) + refute DefaultComparator.compare({:<, 5, 3}) + refute DefaultComparator.compare({:<, 5, 5}) + refute DefaultComparator.compare({:<, 5.0, 3.1}) + refute DefaultComparator.compare({:<, 5.0, 5.0}) + + end + + test "compares with <=" do + assert DefaultComparator.compare({:<=, "b", "c"}) + assert DefaultComparator.compare({:<=, "c", "c"}) + assert DefaultComparator.compare({:<=, 3, 5}) + assert DefaultComparator.compare({:<=, 5, 5}) + assert DefaultComparator.compare({:<=, 3.0, 5.0}) + assert DefaultComparator.compare({:<=, 5.0, 5.0}) + + refute DefaultComparator.compare({:<=, "c", "b"}) + refute DefaultComparator.compare({:<=, 5, 3}) + refute DefaultComparator.compare({:<=, 5.0, 3.1}) + end + + test "compares with like" do + assert DefaultComparator.compare({:like, "left", "left"}) + assert DefaultComparator.compare({:like, "left", "ef"}) + assert DefaultComparator.compare({:like, "left", "LEFT"}) + assert DefaultComparator.compare({:like, "LEFT", "left"}) + assert DefaultComparator.compare({:like, 2112, 2112}) + assert DefaultComparator.compare({:like, 5211245, 2112}) + assert DefaultComparator.compare({:like, 3.1415, 3.1415}) + assert DefaultComparator.compare({:like, 3.1415, 14}) + assert DefaultComparator.compare({:like, true, "ru"}) + + refute DefaultComparator.compare({:like, "left", "right"}) + refute DefaultComparator.compare({:like, 2112, 1212}) + refute DefaultComparator.compare({:like, 3.145, 12}) + refute DefaultComparator.compare({:like, true, "false"}) + end + + test "compares with in" do + assert DefaultComparator.compare({:in, 5, [1, 2, 5, 6]}) + assert DefaultComparator.compare({:in, "thing", ["some", "thing", "here"]}) + assert DefaultComparator.compare({:in, 5.4, [5.3, 5.4, 5.5, 5.6]}) + + refute DefaultComparator.compare({:in, 5.4, []}) + refute DefaultComparator.compare({:in, 5.4, [1.3, 2.4, 4.5, 6.5]}) + refute DefaultComparator.compare({:in, 5, [1, 2, 6]}) + end + end +end diff --git a/test/loupe/stream_test.exs b/test/loupe/stream_test.exs new file mode 100644 index 0000000..f963267 --- /dev/null +++ b/test/loupe/stream_test.exs @@ -0,0 +1,7 @@ +defmodule Loupe.StreamTest do + use Loupe.TestCase, async: true + doctest Loupe.Stream + + describe "query/3" do + end +end From 5c8b00853839c1c01479eb51988442b56f4c00f0 Mon Sep 17 00:00:00 2001 From: nboisvert Date: Fri, 14 Mar 2025 21:43:20 -0400 Subject: [PATCH 4/9] Add test fixtures --- lib/loupe/stream/comparator.ex | 59 ++- test/loupe/stream/comparator_test.exs | 1 - test/loupe/stream_test.exs | 55 +++ test/support/fixtures/issues.json | 582 ++++++++++++++++++++++++++ 4 files changed, 679 insertions(+), 18 deletions(-) create mode 100644 test/support/fixtures/issues.json diff --git a/lib/loupe/stream/comparator.ex b/lib/loupe/stream/comparator.ex index 6cb0f98..a77c85d 100644 --- a/lib/loupe/stream/comparator.ex +++ b/lib/loupe/stream/comparator.ex @@ -20,18 +20,22 @@ defmodule Loupe.Stream.Comparator do end def compare({:not, operand}, element, %Context{} = context) do - not compare(operand, element, context) + case compute_expression(operand, element, context) do + :empty -> false + other -> not other + end end - # TODO: Take care for cases where we have lists in there - # TODO: Figure a way to treat tuples - def compare(operand, element, %Context{comparator: comparator} = context) do - operand_with_values = - operand - |> fill_values(element, context) - |> unwrap_right_value(context) + def compare(operand, element, %Context{} = context) do + with :empty <- compute_expression(operand, element, context) do + false + end + end - comparator.compare(operand_with_values) + defp compute_expression(operand, element, %Context{} = context) do + operand + |> unwrap_right_value(context) + |> compare_value(element, context) end defp unwrap_right_value({operator, left, right}, context) do @@ -48,16 +52,37 @@ defmodule Loupe.Stream.Comparator do Map.get(variables, identifier) end - defp fill_values({_ = operator, {:binding, [binding | rest_bindings]}, right}, element, context) do - case {get_value(element, binding), rest_bindings} do - {{:ok, value}, []} -> - {operator, value, right} + defp compare_value(operand, elements, context) when is_tuple(elements) do + compare_value(operand, Tuple.to_list(elements), context) + end - {{:ok, value}, _} -> - fill_values({operator, {:binding, rest_bindings}, right}, value, context) + defp compare_value(_operand, [], _context) do + :empty + end + + defp compare_value(operand, elements, context) when is_list(elements) do + Enum.any?(elements, &compare_value(operand, &1, context)) + end - {{:error, _}, _} -> - {operator, nil, right} + defp compare_value( + {_ = operator, {:binding, [binding | rest_bindings]}, right}, + element, + %Context{comparator: comparator} = context + ) do + result = + case {get_value(element, binding), rest_bindings} do + {{:ok, value}, []} -> + {operator, value, right} + + {{:ok, value}, _} -> + compare_value({operator, {:binding, rest_bindings}, right}, value, context) + + {{:error, _}, _} -> + {operator, nil, right} + end + + with {_, _, _} <- result do + comparator.compare(result) end end diff --git a/test/loupe/stream/comparator_test.exs b/test/loupe/stream/comparator_test.exs index cef6385..6fa72ba 100644 --- a/test/loupe/stream/comparator_test.exs +++ b/test/loupe/stream/comparator_test.exs @@ -3,7 +3,6 @@ defmodule Loupe.Stream.ComparatorTest do describe "compare/3" do test "compares an operation with an item" do - raise "Impl me siouplait" end end end diff --git a/test/loupe/stream_test.exs b/test/loupe/stream_test.exs index f963267..87a79ba 100644 --- a/test/loupe/stream_test.exs +++ b/test/loupe/stream_test.exs @@ -2,6 +2,61 @@ defmodule Loupe.StreamTest do use Loupe.TestCase, async: true doctest Loupe.Stream + @json_data "./test/support/fixtures/issues.json" + |> File.read!() + |> Jason.decode!() + describe "query/3" do + test "filters a map stream for one item" do + assert {:ok, stream} = + Loupe.Stream.query(~s|get A where labels.name = "tech debt"|, @json_data) + + assert [%{"labels" => [%{"name" => "tech debt"}]}] = Enum.to_list(stream) + end + + test "filters a map stream for multiple item" do + assert {:ok, stream} = + Loupe.Stream.query(~s|get all A where labels.name not :empty|, @json_data) + + assert [ + %{ + "labels" => [ + %{"name" => "tech debt"} + ] + }, + %{ + "labels" => [ + %{"name" => "enhancement"}, + %{"name" => "implementation"} + ] + }, + %{ + "labels" => [ + %{"name" => "enhancement"}, + %{"name" => "syntax"} + ] + }, + %{ + "labels" => [ + %{"name" => "enhancement"}, + %{"name" => "implementation"} + ] + }, + ] = Enum.to_list(stream) + + assert [ + %{ + "labels" => [ + %{"name" => "tech debt"} + ] + }, + %{ + "labels" => [ + %{"name" => "enhancement"}, + %{"name" => "implementation"} + ] + } + ] = Enum.take(stream, 2) + end end end diff --git a/test/support/fixtures/issues.json b/test/support/fixtures/issues.json new file mode 100644 index 0000000..df69115 --- /dev/null +++ b/test/support/fixtures/issues.json @@ -0,0 +1,582 @@ +[ + { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/29", + "repository_url": "https://api.github.com/repos/nicklayb/loupe", + "labels_url": "https://api.github.com/repos/nicklayb/loupe/issues/29/labels{/name}", + "comments_url": "https://api.github.com/repos/nicklayb/loupe/issues/29/comments", + "events_url": "https://api.github.com/repos/nicklayb/loupe/issues/29/events", + "html_url": "https://github.com/nicklayb/loupe/pull/29", + "id": 2915475104, + "node_id": "PR_kwDOJIsQ-M6ObKzn", + "number": 29, + "title": "[WIP] Stream query", + "user": { + "login": "nicklayb", + "id": 15114240, + "node_id": "MDQ6VXNlcjE1MTE0MjQw", + "avatar_url": "https://avatars.githubusercontent.com/u/15114240?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/nicklayb", + "html_url": "https://github.com/nicklayb", + "followers_url": "https://api.github.com/users/nicklayb/followers", + "following_url": "https://api.github.com/users/nicklayb/following{/other_user}", + "gists_url": "https://api.github.com/users/nicklayb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nicklayb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nicklayb/subscriptions", + "organizations_url": "https://api.github.com/users/nicklayb/orgs", + "repos_url": "https://api.github.com/users/nicklayb/repos", + "events_url": "https://api.github.com/users/nicklayb/events{/privacy}", + "received_events_url": "https://api.github.com/users/nicklayb/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2025-03-13T00:38:16Z", + "updated_at": "2025-03-14T02:34:19Z", + "closed_at": null, + "author_association": "OWNER", + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "active_lock_reason": null, + "draft": false, + "pull_request": { + "url": "https://api.github.com/repos/nicklayb/loupe/pulls/29", + "html_url": "https://github.com/nicklayb/loupe/pull/29", + "diff_url": "https://github.com/nicklayb/loupe/pull/29.diff", + "patch_url": "https://github.com/nicklayb/loupe/pull/29.patch", + "merged_at": null + }, + "body": null, + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/29/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/29/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/28", + "repository_url": "https://api.github.com/repos/nicklayb/loupe", + "labels_url": "https://api.github.com/repos/nicklayb/loupe/issues/28/labels{/name}", + "comments_url": "https://api.github.com/repos/nicklayb/loupe/issues/28/comments", + "events_url": "https://api.github.com/repos/nicklayb/loupe/issues/28/events", + "html_url": "https://github.com/nicklayb/loupe/issues/28", + "id": 2527192788, + "node_id": "I_kwDOJIsQ-M6WoebU", + "number": 28, + "title": "Make dialyzer pass", + "user": { + "login": "nicklayb", + "id": 15114240, + "node_id": "MDQ6VXNlcjE1MTE0MjQw", + "avatar_url": "https://avatars.githubusercontent.com/u/15114240?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/nicklayb", + "html_url": "https://github.com/nicklayb", + "followers_url": "https://api.github.com/users/nicklayb/followers", + "following_url": "https://api.github.com/users/nicklayb/following{/other_user}", + "gists_url": "https://api.github.com/users/nicklayb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nicklayb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nicklayb/subscriptions", + "organizations_url": "https://api.github.com/users/nicklayb/orgs", + "repos_url": "https://api.github.com/users/nicklayb/repos", + "events_url": "https://api.github.com/users/nicklayb/events{/privacy}", + "received_events_url": "https://api.github.com/users/nicklayb/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 7469160927, + "node_id": "LA_kwDOJIsQ-M8AAAABvTJZ3w", + "url": "https://api.github.com/repos/nicklayb/loupe/labels/tech%20debt", + "name": "tech debt", + "color": "fbca04", + "default": false, + "description": "Things to be addressed for code quality and structure" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2024-09-15T20:47:07Z", + "updated_at": "2024-09-15T20:47:46Z", + "closed_at": null, + "author_association": "OWNER", + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "active_lock_reason": null, + "body": "Over the evolution of the project, structure and ast of the language but the types were not always updated accordingly.\r\n\r\nExpected:\r\n\r\n- External library aren't reporting in dialyzer anymore\r\n- Dialyzer reports no errors.", + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/28/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/28/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/26", + "repository_url": "https://api.github.com/repos/nicklayb/loupe", + "labels_url": "https://api.github.com/repos/nicklayb/loupe/issues/26/labels{/name}", + "comments_url": "https://api.github.com/repos/nicklayb/loupe/issues/26/comments", + "events_url": "https://api.github.com/repos/nicklayb/loupe/issues/26/events", + "html_url": "https://github.com/nicklayb/loupe/pull/26", + "id": 2521090485, + "node_id": "PR_kwDOJIsQ-M57PAKJ", + "number": 26, + "title": "Single Ampersand and Pipe", + "user": { + "login": "nicklayb", + "id": 15114240, + "node_id": "MDQ6VXNlcjE1MTE0MjQw", + "avatar_url": "https://avatars.githubusercontent.com/u/15114240?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/nicklayb", + "html_url": "https://github.com/nicklayb", + "followers_url": "https://api.github.com/users/nicklayb/followers", + "following_url": "https://api.github.com/users/nicklayb/following{/other_user}", + "gists_url": "https://api.github.com/users/nicklayb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nicklayb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nicklayb/subscriptions", + "organizations_url": "https://api.github.com/users/nicklayb/orgs", + "repos_url": "https://api.github.com/users/nicklayb/repos", + "events_url": "https://api.github.com/users/nicklayb/events{/privacy}", + "received_events_url": "https://api.github.com/users/nicklayb/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2024-09-12T01:01:42Z", + "updated_at": "2024-09-13T13:51:58Z", + "closed_at": null, + "author_association": "OWNER", + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "active_lock_reason": null, + "draft": true, + "pull_request": { + "url": "https://api.github.com/repos/nicklayb/loupe/pulls/26", + "html_url": "https://github.com/nicklayb/loupe/pull/26", + "diff_url": "https://github.com/nicklayb/loupe/pull/26.diff", + "patch_url": "https://github.com/nicklayb/loupe/pull/26.patch", + "merged_at": null + }, + "body": "This is WIP ~as right now it hangs where trying to extract the tokens~ this was due to tokens not being escaped in lexer", + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/26/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/26/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/24", + "repository_url": "https://api.github.com/repos/nicklayb/loupe", + "labels_url": "https://api.github.com/repos/nicklayb/loupe/issues/24/labels{/name}", + "comments_url": "https://api.github.com/repos/nicklayb/loupe/issues/24/comments", + "events_url": "https://api.github.com/repos/nicklayb/loupe/issues/24/events", + "html_url": "https://github.com/nicklayb/loupe/issues/24", + "id": 2513897068, + "node_id": "I_kwDOJIsQ-M6V1wZs", + "number": 24, + "title": "Provide a Map implementation", + "user": { + "login": "nicklayb", + "id": 15114240, + "node_id": "MDQ6VXNlcjE1MTE0MjQw", + "avatar_url": "https://avatars.githubusercontent.com/u/15114240?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/nicklayb", + "html_url": "https://github.com/nicklayb", + "followers_url": "https://api.github.com/users/nicklayb/followers", + "following_url": "https://api.github.com/users/nicklayb/following{/other_user}", + "gists_url": "https://api.github.com/users/nicklayb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nicklayb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nicklayb/subscriptions", + "organizations_url": "https://api.github.com/users/nicklayb/orgs", + "repos_url": "https://api.github.com/users/nicklayb/repos", + "events_url": "https://api.github.com/users/nicklayb/events{/privacy}", + "received_events_url": "https://api.github.com/users/nicklayb/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 5257523774, + "node_id": "LA_kwDOJIsQ-M8AAAABOV9yPg", + "url": "https://api.github.com/repos/nicklayb/loupe/labels/enhancement", + "name": "enhancement", + "color": "a2eeef", + "default": true, + "description": "New feature or request" + }, + { + "id": 7439383417, + "node_id": "LA_kwDOJIsQ-M8AAAABu2v7eQ", + "url": "https://api.github.com/repos/nicklayb/loupe/labels/implementation", + "name": "implementation", + "color": "387815", + "default": false, + "description": "Concerns a implementation (like Ecto)" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 1, + "created_at": "2024-09-09T12:56:20Z", + "updated_at": "2025-03-13T00:38:44Z", + "closed_at": null, + "author_association": "OWNER", + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "active_lock_reason": null, + "body": "Provide an implementation that allows crawling a Map using the predicates. Example:\r\n\r\n```\r\nplayers = [\r\n %{\"name\" => \"Neil Peart\", \"jobs\" => [\"song_writing\", \"drums\"]},\r\n %{\"name\" => \"Geddy Lee\", \"jobs\" => [\"bass\", \"keyboard\"]},\r\n %{\"name\" => \"Alex Lifeson\", \"jobs\" => [\"guitar\"]}\r\n]\r\n\r\nmap %{players: players}\r\n```\r\n\r\nAnd use query like \r\n```\r\nget players where jobs in [\"bass\", \"drum\"]\r\n# would return Neil Peart and Geddy Lee\r\n```\r\n\r\n## Considrations\r\n\r\n- Should be insensitive to the key types, should work on atom and string maps and even mixed up in the nesting (not mixed in the same map but mixed nested, example: `%{name: \"Neil Peart\", \"jobs\" => []}` wouldn't be accepted, but `%{band: \"Rush\", members: [%{\"name\" => \"Neil Peart\"}]}` would)\r\n- Variants should not be hardcoded and user implemented instead. So someone can have `get players where name:insensitive = \"geddy lee\"`. \r\n- Paths should be used for keys that cannot be identifier (keys with space, for instance), like: `get players where country[\"city name\"]` for a map that looks like `%{\"country\" => %{\"city name\" => city}}`.\r\n- The returned value of the impletation should probably by a lambda function that accepts a map or call it directly, to be determined. But it would be best to avoid crawling the whole map so maybe giving out another kind of structure that can directly use `get_in` on the map.\r\n", + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/24/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/24/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/17", + "repository_url": "https://api.github.com/repos/nicklayb/loupe", + "labels_url": "https://api.github.com/repos/nicklayb/loupe/issues/17/labels{/name}", + "comments_url": "https://api.github.com/repos/nicklayb/loupe/issues/17/comments", + "events_url": "https://api.github.com/repos/nicklayb/loupe/issues/17/events", + "html_url": "https://github.com/nicklayb/loupe/issues/17", + "id": 2134669765, + "node_id": "I_kwDOJIsQ-M5_PHnF", + "number": 17, + "title": "Support Single Pipe and Single Ampersand operators", + "user": { + "login": "nicklayb", + "id": 15114240, + "node_id": "MDQ6VXNlcjE1MTE0MjQw", + "avatar_url": "https://avatars.githubusercontent.com/u/15114240?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/nicklayb", + "html_url": "https://github.com/nicklayb", + "followers_url": "https://api.github.com/users/nicklayb/followers", + "following_url": "https://api.github.com/users/nicklayb/following{/other_user}", + "gists_url": "https://api.github.com/users/nicklayb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nicklayb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nicklayb/subscriptions", + "organizations_url": "https://api.github.com/users/nicklayb/orgs", + "repos_url": "https://api.github.com/users/nicklayb/repos", + "events_url": "https://api.github.com/users/nicklayb/events{/privacy}", + "received_events_url": "https://api.github.com/users/nicklayb/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 5257523774, + "node_id": "LA_kwDOJIsQ-M8AAAABOV9yPg", + "url": "https://api.github.com/repos/nicklayb/loupe/labels/enhancement", + "name": "enhancement", + "color": "a2eeef", + "default": true, + "description": "New feature or request" + }, + { + "id": 7439384505, + "node_id": "LA_kwDOJIsQ-M8AAAABu2v_uQ", + "url": "https://api.github.com/repos/nicklayb/loupe/labels/syntax", + "name": "syntax", + "color": "582517", + "default": false, + "description": "Concerns fundamental syntax" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2024-02-14T15:57:02Z", + "updated_at": "2024-09-09T12:57:43Z", + "closed_at": null, + "author_association": "OWNER", + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "active_lock_reason": null, + "body": "It would be nice to allow and `or` and `and` logic on the fields part. Example\n\n```\nget all User where bank_account | withdrawn_amount | deposited_amount > 100k\n```\n\nwhich would translate in\n\n```\r\nselect * from users where (bank_account > 100000 or withdrawn_amount > 100000 or deposited_amount > 100000)\n```\n\nAnd \n\n```\nget all User where bank_account & withdrawn_amount & deposited_amount > 100k\n```\n\nwhich would translate in\n\n```\nselect * from users where (bank_account > 100000 and withdrawn_amount > 100000 and deposited_amount > 100000)\n```", + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/17/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/17/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/14", + "repository_url": "https://api.github.com/repos/nicklayb/loupe", + "labels_url": "https://api.github.com/repos/nicklayb/loupe/issues/14/labels{/name}", + "comments_url": "https://api.github.com/repos/nicklayb/loupe/issues/14/comments", + "events_url": "https://api.github.com/repos/nicklayb/loupe/issues/14/events", + "html_url": "https://github.com/nicklayb/loupe/pull/14", + "id": 2098462196, + "node_id": "PR_kwDOJIsQ-M5k9opU", + "number": 14, + "title": "[WIP] Add extended queries", + "user": { + "login": "nicklayb", + "id": 15114240, + "node_id": "MDQ6VXNlcjE1MTE0MjQw", + "avatar_url": "https://avatars.githubusercontent.com/u/15114240?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/nicklayb", + "html_url": "https://github.com/nicklayb", + "followers_url": "https://api.github.com/users/nicklayb/followers", + "following_url": "https://api.github.com/users/nicklayb/following{/other_user}", + "gists_url": "https://api.github.com/users/nicklayb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nicklayb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nicklayb/subscriptions", + "organizations_url": "https://api.github.com/users/nicklayb/orgs", + "repos_url": "https://api.github.com/users/nicklayb/repos", + "events_url": "https://api.github.com/users/nicklayb/events{/privacy}", + "received_events_url": "https://api.github.com/users/nicklayb/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2024-01-24T15:00:00Z", + "updated_at": "2024-01-24T15:00:00Z", + "closed_at": null, + "author_association": "OWNER", + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "active_lock_reason": null, + "draft": false, + "pull_request": { + "url": "https://api.github.com/repos/nicklayb/loupe/pulls/14", + "html_url": "https://github.com/nicklayb/loupe/pull/14", + "diff_url": "https://github.com/nicklayb/loupe/pull/14.diff", + "patch_url": "https://github.com/nicklayb/loupe/pull/14.patch", + "merged_at": null + }, + "body": null, + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/14/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/14/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/10", + "repository_url": "https://api.github.com/repos/nicklayb/loupe", + "labels_url": "https://api.github.com/repos/nicklayb/loupe/issues/10/labels{/name}", + "comments_url": "https://api.github.com/repos/nicklayb/loupe/issues/10/comments", + "events_url": "https://api.github.com/repos/nicklayb/loupe/issues/10/events", + "html_url": "https://github.com/nicklayb/loupe/issues/10", + "id": 1755532833, + "node_id": "I_kwDOJIsQ-M5oo04h", + "number": 10, + "title": "Implements functions", + "user": { + "login": "nicklayb", + "id": 15114240, + "node_id": "MDQ6VXNlcjE1MTE0MjQw", + "avatar_url": "https://avatars.githubusercontent.com/u/15114240?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/nicklayb", + "html_url": "https://github.com/nicklayb", + "followers_url": "https://api.github.com/users/nicklayb/followers", + "following_url": "https://api.github.com/users/nicklayb/following{/other_user}", + "gists_url": "https://api.github.com/users/nicklayb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nicklayb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nicklayb/subscriptions", + "organizations_url": "https://api.github.com/users/nicklayb/orgs", + "repos_url": "https://api.github.com/users/nicklayb/repos", + "events_url": "https://api.github.com/users/nicklayb/events{/privacy}", + "received_events_url": "https://api.github.com/users/nicklayb/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 5257523774, + "node_id": "LA_kwDOJIsQ-M8AAAABOV9yPg", + "url": "https://api.github.com/repos/nicklayb/loupe/labels/enhancement", + "name": "enhancement", + "color": "a2eeef", + "default": true, + "description": "New feature or request" + }, + { + "id": 7439383417, + "node_id": "LA_kwDOJIsQ-M8AAAABu2v7eQ", + "url": "https://api.github.com/repos/nicklayb/loupe/labels/implementation", + "name": "implementation", + "color": "387815", + "default": false, + "description": "Concerns a implementation (like Ecto)" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2023-06-13T19:32:14Z", + "updated_at": "2024-09-09T12:58:07Z", + "closed_at": null, + "author_association": "OWNER", + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "active_lock_reason": null, + "body": "We would like a way to support common functions and user defined one. It might be useful to be able to use `now()` to refer to the current timestamp for instance or a way to get a contextual record.\n\n## Requirements\n\nSupport an identifier followed by parentheses like `function(\"something\")` or `now()`.\n\n### Goals\n\nBeing able to write `get Event where start_time > now()` that would convert to Database's `NOW()` function when querying.\nBeing able to write `get Event where id = current_id()` that would return a given value from the assign's map.\n\n## API proposal\n\nImplement a function `invoke/3` in EctoDefinition with the following signature\n\n```elixir\ndef invoke(function :: atom(), args :: list(String.t()), assigns :: map()) :: Ecto.Queryable.t()\n```\n\nWhere `assigns` would be usual user provided assigns containing various properties (like role or permission for instance). Invoke could return a raw value or a queryable, like a fragment.", + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/nicklayb/loupe/issues/10/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/10/timeline", + "performed_via_github_app": null, + "state_reason": null + } +] From 84dbb58fa316ebdd87f2a5534a9c152a61f025ae Mon Sep 17 00:00:00 2001 From: nboisvert Date: Fri, 14 Mar 2025 21:47:04 -0400 Subject: [PATCH 5/9] Bump action version --- .github/workflows/elixir.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index b6f75cb..a0b1e0d 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -40,7 +40,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Elixir - uses: erlef/setup-beam@v1.18.1 + uses: erlef/setup-beam@v1 with: version-file: .tool-versions version-type: strict From 63cf08863ba8c806c070ab6767b329ea5b7a0949 Mon Sep 17 00:00:00 2001 From: nboisvert Date: Fri, 14 Mar 2025 21:49:02 -0400 Subject: [PATCH 6/9] Bump image --- .github/workflows/elixir.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index a0b1e0d..976a1da 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -31,9 +31,9 @@ jobs: --health-retries 5 name: Build and test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 env: - ImageOS: ubuntu20 + ImageOS: ubuntu22 MIX_ENV: test GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From b09de2d4e75188a7a1668f3f473087a76527961a Mon Sep 17 00:00:00 2001 From: nboisvert Date: Fri, 14 Mar 2025 21:53:30 -0400 Subject: [PATCH 7/9] Update credo --- .github/workflows/elixir.yml | 9 +++++++-- lib/loupe/phoenix_live_view/live_component.ex | 6 +++++- mix.exs | 2 +- mix.lock | 8 ++++---- test/loupe/stream/default_comparator_test.exs | 3 +-- test/loupe/stream_test.exs | 2 +- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 976a1da..e0cf9a0 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -52,11 +52,16 @@ jobs: restore-keys: ${{ runner.os }}-mix- - name: Install dependencies run: mix deps.get + - name: Compiles without warnings run: mix compile - - name: Run credo - run: mix credo --strict + - name: Run tests run: mix test + + - name: Run credo + continue-on-error: true + run: mix credo --strict + - name: Run coverage run: mix coveralls.github diff --git a/lib/loupe/phoenix_live_view/live_component.ex b/lib/loupe/phoenix_live_view/live_component.ex index af8ead2..8296d4f 100644 --- a/lib/loupe/phoenix_live_view/live_component.ex +++ b/lib/loupe/phoenix_live_view/live_component.ex @@ -333,6 +333,10 @@ if Code.ensure_loaded?(Phoenix.Component) do end end - defp internal_key?(key), do: String.starts_with?(to_string(key), "__") + defp internal_key?(key) do + key + |> to_string() + |> String.starts_with?("__") + end end end diff --git a/mix.exs b/mix.exs index e9899b2..51b012b 100644 --- a/mix.exs +++ b/mix.exs @@ -58,7 +58,7 @@ defmodule Loupe.MixProject do {:phoenix_live_view, "~> 0.18", optional: true}, {:ecto, "~> 3.11", optional: true}, {:ecto_sql, "~> 3.11", optional: true}, - {:credo, "~> 1.6.7", only: [:dev, :test]}, + {:credo, "~> 1.7.11", only: [:dev, :test]}, {:excoveralls, "~> 0.16", only: :test}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index ab0be49..f742b3c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,12 @@ %{ - "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.7", "77de20ac77f0e53f20ca82c563520af0237c301a1ec3ab3bc598e8a96c7ee5d9", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2768b28bf3c2b4f788c995576b39b8cb5d47eb788526d93bd52206c1d8bf4b75"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, + "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark_parser": {:hex, :earmark_parser, "1.4.31", "a93921cdc6b9b869f519213d5bc79d9e218ba768d7270d46fdcf1c01bacff9e2", [:mix], [], "hexpm", "317d367ee0335ef037a87e46c91a2269fef6306413f731e8ec11fc45a7efd059"}, "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 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", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, @@ -17,7 +17,7 @@ "ex_doc": {:hex, :ex_doc, "0.29.2", "dfa97532ba66910b2a3016a4bbd796f41a86fc71dd5227e96f4c8581fdf0fdf0", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "6b5d7139eda18a753e3250e27e4a929f8d2c880dd0d460cb9986305dea3e03af"}, "excoveralls": {:hex, :excoveralls, "0.16.0", "41f4cfbf7caaa3bc2cf411db6f89c1f53afedf0f1fe8debac918be1afa19c668", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "401205356482ab99fb44d9812cd14dd83b65de8e7ae454697f8b34ba02ecd916"}, "exqlite": {:hex, :exqlite, "0.13.7", "5d8f8b38dd41b1f680294d3cd5c285aa76808cc3a7ff2d86b1ed63851e32fc55", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "713da47329cbbb7aae50280d4b9eed05b7cc1ecfd678712fbaac8c12f50878cd"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, diff --git a/test/loupe/stream/default_comparator_test.exs b/test/loupe/stream/default_comparator_test.exs index 1faf511..43c7843 100644 --- a/test/loupe/stream/default_comparator_test.exs +++ b/test/loupe/stream/default_comparator_test.exs @@ -59,7 +59,6 @@ defmodule Loupe.Stream.DefaultComparatorTest do refute DefaultComparator.compare({:<, 5, 5}) refute DefaultComparator.compare({:<, 5.0, 3.1}) refute DefaultComparator.compare({:<, 5.0, 5.0}) - end test "compares with <=" do @@ -81,7 +80,7 @@ defmodule Loupe.Stream.DefaultComparatorTest do assert DefaultComparator.compare({:like, "left", "LEFT"}) assert DefaultComparator.compare({:like, "LEFT", "left"}) assert DefaultComparator.compare({:like, 2112, 2112}) - assert DefaultComparator.compare({:like, 5211245, 2112}) + assert DefaultComparator.compare({:like, 5_211_245, 2112}) assert DefaultComparator.compare({:like, 3.1415, 3.1415}) assert DefaultComparator.compare({:like, 3.1415, 14}) assert DefaultComparator.compare({:like, true, "ru"}) diff --git a/test/loupe/stream_test.exs b/test/loupe/stream_test.exs index 87a79ba..5d604d8 100644 --- a/test/loupe/stream_test.exs +++ b/test/loupe/stream_test.exs @@ -41,7 +41,7 @@ defmodule Loupe.StreamTest do %{"name" => "enhancement"}, %{"name" => "implementation"} ] - }, + } ] = Enum.to_list(stream) assert [ From 7e41626bdc31373f2d710d44538a4afbc2023e27 Mon Sep 17 00:00:00 2001 From: nboisvert Date: Sat, 15 Mar 2025 01:34:36 -0400 Subject: [PATCH 8/9] Done testing and documentation --- README.md | 42 ++++-- VERSION | 1 + lib/loupe/ecto.ex | 8 +- lib/loupe/ecto/errors/missing_schema.ex | 13 ++ lib/loupe/language.ex | 4 +- lib/loupe/language/ast.ex | 7 +- lib/loupe/stream.ex | 14 +- lib/loupe/stream/comparator.ex | 38 ++++-- lib/loupe/stream/default_comparator.ex | 9 ++ mix.exs | 2 +- src/loupe_parser.yrl | 2 + test/loupe/ecto_test.exs | 6 + test/loupe/language_test.exs | 10 ++ test/loupe/stream/default_comparator_test.exs | 17 +++ test/loupe/stream_test.exs | 129 +++++++++++++++++- test/support/fixtures/issues.json | 5 +- 16 files changed, 267 insertions(+), 40 deletions(-) create mode 100644 VERSION create mode 100644 lib/loupe/ecto/errors/missing_schema.ex diff --git a/README.md b/README.md index aa9044c..ed60ca5 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,18 @@ Until Loupe reaches `1.x.x`, it's considered experimental. The syntax will chang ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed +Loupe is [available in Hex](https://hex.pm/docs/publish), the package can be installed by adding `loupe` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:loupe, "~> 0.1.0"} + {:loupe, "~> 0.10.0"} ] end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +Thhe documentation can be found at . ## Syntax @@ -37,7 +35,7 @@ get [quantifier?] [schema][parameters?] where [predicates] ``` - `quantifier` is how many records you want. You can provide a positive integer (`1`, `2`, `3` ...), a range (`1..10`, `10..20`, `50..100`) or `all`. -- `schema` needs to be an alphanumeric indentifier that you registered in the Definition (See [Ecto Usage](#ecto-usage) for exmaple). +- `schema` can be an alphanumeric indentifier that you registered in the Definition (See [Ecto Usage](#ecto-usage) for exmaple). The schema is required only for Ecto usage. - `parameters` is a json inspired map. It takes the format of `{key: "value"}`. Key is an identifier, but value can be any literal type (another object, string, int, float, boolean, list) - `predicates` needs to be a combinaison or operators and boolean operators. @@ -156,16 +154,36 @@ Once you have this definition, you can try some queries Repo.all(ecto_query) ``` +## Stream / Enumerable + +Support has been added to filter streams or enumerable. + +The same features applies and some more extra; + +- You can use a quantifier to limit the stream (`get 3 ...`) +- You can override the whole comparison logic +- You can use field variant as "modifier" through a custom `Loupe.Stream.Comparator` implementation. +- You can use sigil for more complex comparison + +### Example + +```elixir +posts = [ + %{title: "My post", comments: [%{comment: "Boring!", author: "Homer Simpsons"}]}, + %{title: "My second post", comments: [%{comment: "Respect my authorita!", author: "Eric Cartman"}]}, +] +{:ok, stream} = Loupe.Stream.query(~s|get where comments.author like "Eric"|, posts) +[%{title: "My second posts"}] = Enum.to_list(stream) +``` + ## Todo Here are some things that I would like Loupe to support: -- Sorting a query, current ideas involves - - `get all User order asc inserted_at` - - `get all User where age > 10 ordered asc inserted_at`. -- Support some more complex fields prefixed by ~ (or whatever syntax, inspired by elixir's sigils) like the examples below - - `get all Product where price = ~99.99$` and have that use the Elixir money lib. - - `get all Item where ratio = ~1/4` +- ~Sorting a query, current ideas involves~ + - This can be achieve with a parameter like `get User{order_by: "age"} where ...` and be handled manually by your application +- ~Support some more complex fields prefixed by ~ (or whatever syntax, inspired by elixir's sigils) like the examples below~ + - This has been implemented. Field variants can be used for composite fields and sigil can be used for expresions. - Implement a LiveView UI lib that shows the strucutres as expandable. Being able to click on a User's `posts` to automatically preload all of its nested Posts. - Also have "block" UI module where you can simply create a query from dropdowns in a form for non-power user. - Make lexer and parser swappable. Right now, you are stuck with the internal structure that I came up with. The idea would be to allow some to swap the syntax for anything they want. For instance, a french team could implement a french query language to give to their normal user. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..78bc1ab --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.10.0 diff --git a/lib/loupe/ecto.ex b/lib/loupe/ecto.ex index 5ff03be..b9dfd91 100644 --- a/lib/loupe/ecto.ex +++ b/lib/loupe/ecto.ex @@ -5,14 +5,16 @@ if Code.ensure_loaded?(Ecto) do should be completely decoupled from any Repo logic and leave that to the app's Repo """ import Ecto.Query + alias Loupe.Ecto.Context + alias Loupe.Ecto.Errors.MissingSchemaError alias Loupe.Ecto.Filter alias Loupe.Language alias Loupe.Language.Ast @root_binding :root - @type build_query_error :: any() + @type build_query_error :: MissingSchemaError.t() | any() @doc "Same as build_query/2 but with context or with implementation with no assigns" @spec build_query(Ast.t() | binary(), Context.implementation() | Context.t()) :: @@ -42,6 +44,10 @@ if Code.ensure_loaded?(Ecto) do defp maybe_compile_ast(string) when is_binary(string), do: Language.compile(string) defp maybe_compile_ast(%Ast{} = ast), do: {:ok, ast} + defp create_query(%Ast{schema: nil}, %Context{}) do + {:error, %MissingSchemaError{}} + end + defp create_query( %Ast{parameters: parameters} = ast, %Context{} = context diff --git a/lib/loupe/ecto/errors/missing_schema.ex b/lib/loupe/ecto/errors/missing_schema.ex new file mode 100644 index 0000000..218257e --- /dev/null +++ b/lib/loupe/ecto/errors/missing_schema.ex @@ -0,0 +1,13 @@ +defmodule Loupe.Ecto.Errors.MissingSchemaError do + @moduledoc "An error that occured in the lexer's step" + defexception [] + + alias Loupe.Ecto.Errors.MissingSchemaError + + @type t :: %MissingSchemaError{} + + @impl Exception + def message(%MissingSchemaError{}) do + "Ecto queries expect a schema, got nil" + end +end diff --git a/lib/loupe/language.ex b/lib/loupe/language.ex index b30c30e..aff84c9 100644 --- a/lib/loupe/language.ex +++ b/lib/loupe/language.ex @@ -21,7 +21,7 @@ defmodule Loupe.Language do def compile(charlist) do with {:ok, tokens, _} <- :loupe_lexer.string(charlist), {:ok, ast} <- :loupe_parser.parse(tokens) do - {:ok, new_ast(ast)} + new_ast(ast) else {:error, {line, :loupe_parser, messages}} -> {:error, %ParserError{line: line, message: messages}} @@ -32,6 +32,6 @@ defmodule Loupe.Language do end defp new_ast({action, quantifier, {schema, parameters}, predicates}) do - Ast.new(action, schema, quantifier, predicates, parameters) + {:ok, Ast.new(action, schema, quantifier, predicates, parameters)} end end diff --git a/lib/loupe/language/ast.ex b/lib/loupe/language/ast.ex index ffba758..efa7e66 100644 --- a/lib/loupe/language/ast.ex +++ b/lib/loupe/language/ast.ex @@ -131,11 +131,16 @@ defmodule Loupe.Language.Ast do {predicates, updated_external_identifiers} = walk_predicates(predicates, external_identifiers) + schema = + with binary when is_list(binary) <- binding do + to_string(binary) + end + %Ast{ action: to_string(action), quantifier: quantifier, predicates: predicates, - schema: to_string(binding), + schema: schema, external_identifiers: updated_external_identifiers, parameters: parameters } diff --git a/lib/loupe/stream.ex b/lib/loupe/stream.ex index a23bd1e..fa9195e 100644 --- a/lib/loupe/stream.ex +++ b/lib/loupe/stream.ex @@ -16,14 +16,14 @@ defmodule Loupe.Stream do Queries a stream using an AST or a Query. The returned result will be an enumerable function to be executed with `Enum` functions. - For stream queries, the Schema is unused, up to you to perform pre-filtering - or put a static one in place if you want. + For stream queries, the Schema is unused, up to you to perform + pre-filtering, put a static one in place or omit it. ## Examples The following example will filter records whose age is greater than 18 - iex> {:ok, stream} = Loupe.Stream.query(~s|get A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, @@ -37,7 +37,7 @@ defmodule Loupe.Stream do when quantifier is provided, it defaults to 1. One could use `all` to get all the records that are matching or a range. - iex> {:ok, stream} = Loupe.Stream.query(~s|get all A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get all where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, @@ -47,7 +47,7 @@ defmodule Loupe.Stream do iex> Enum.to_list(stream) [%{age: 76}, %{age: 28}, %{age: 34}] - iex> {:ok, stream} = Loupe.Stream.query(~s|get 2..3 A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get 2..3 where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, @@ -62,7 +62,7 @@ defmodule Loupe.Stream do In case you wanna enforce a limit of your own to the stream and don't wanna depend on the query's `quantifier`, you can pass `limit?: false` to the function - iex> {:ok, stream} = Loupe.Stream.query(~s|get 1 A where age > 18|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get 1 where age > 18|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, @@ -78,7 +78,7 @@ defmodule Loupe.Stream do must be string to match what is decoded from the query. - iex> {:ok, stream} = Loupe.Stream.query(~s|get 1 A where age > adult|, [ + iex> {:ok, stream} = Loupe.Stream.query(~s|get 1 where age > adult|, [ ...> %{age: 76}, ...> %{age: 13}, ...> %{age: 28}, diff --git a/lib/loupe/stream/comparator.ex b/lib/loupe/stream/comparator.ex index a77c85d..ae1fb31 100644 --- a/lib/loupe/stream/comparator.ex +++ b/lib/loupe/stream/comparator.ex @@ -1,6 +1,13 @@ defmodule Loupe.Stream.Comparator do @moduledoc """ - Behaviour to implement comparator. + Behaviour to implement comparator. It could be useful to + implement your own comparator to alter how fields are getting + compared. + + Overriding the comparator can allow someone to implement + variant casting (exmaple `field:upper` to uppercase values + automatically), alter the comparison logic or implement + sigil casting. """ alias Loupe.Language.Ast @@ -9,6 +16,17 @@ defmodule Loupe.Stream.Comparator do @doc "Compares a stream's value with a literal value" @callback compare(Loupe.Language.Ast.operator()) :: boolean() + @doc """ + Applies a field variant on a value. This can be used to have + expression like `value:upper` be automatically uppercased. + """ + @callback apply_variant(any(), String.t()) :: any() + + @doc """ + Casts a sigil to kind of value to be compared. + """ + @callback cast_sigil(char(), String.t()) :: any() + @doc "Compares predicates inside a given map/structure tree" @spec compare(Ast.predicate(), any(), Context.t()) :: boolean() def compare({:and, left, right}, element, %Context{} = context) do @@ -52,10 +70,6 @@ defmodule Loupe.Stream.Comparator do Map.get(variables, identifier) end - defp compare_value(operand, elements, context) when is_tuple(elements) do - compare_value(operand, Tuple.to_list(elements), context) - end - defp compare_value(_operand, [], _context) do :empty end @@ -70,7 +84,7 @@ defmodule Loupe.Stream.Comparator do %Context{comparator: comparator} = context ) do result = - case {get_value(element, binding), rest_bindings} do + case {get_value(element, binding, context), rest_bindings} do {{:ok, value}, []} -> {operator, value, right} @@ -86,14 +100,22 @@ defmodule Loupe.Stream.Comparator do end end - defp get_value(map, key) when is_map(map) do + defp get_value(nil, _, _) do + {:error, :not_map} + end + + defp get_value(value, {:variant, variant}, %Context{comparator: comparator}) do + {:ok, comparator.apply_variant(value, variant)} + end + + defp get_value(map, key, _) when is_map(map) do with :error <- Map.fetch(map, key), :error <- fetch_atom_key(map, key) do {:error, :key_missing} end end - defp get_value(_, _), do: {:error, :not_map} + defp get_value(_, _, _), do: {:error, :not_map} defp fetch_atom_key(map, string) do key = String.to_existing_atom(string) diff --git a/lib/loupe/stream/default_comparator.ex b/lib/loupe/stream/default_comparator.ex index a411690..e9c15e8 100644 --- a/lib/loupe/stream/default_comparator.ex +++ b/lib/loupe/stream/default_comparator.ex @@ -1,6 +1,9 @@ defmodule Loupe.Stream.DefaultComparator do @moduledoc """ Default comparator that does strict comparison. + + The default comparator does not implement any kind of + variant casting or sigil conversion. """ @behaviour Loupe.Stream.Comparator @@ -49,4 +52,10 @@ defmodule Loupe.Stream.DefaultComparator do left_downcase =~ right_downcase end + + @impl Loupe.Stream.Comparator + def apply_variant(value, _), do: value + + @impl Loupe.Stream.Comparator + def cast_sigil(_char, value), do: value end diff --git a/mix.exs b/mix.exs index 51b012b..71554f5 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule Loupe.MixProject do use Mix.Project @github "https://github.com/nicklayb/loupe" - @version "0.9.0" + @version "VERSION" |> File.read!() |> String.trim() def project do [ diff --git a/src/loupe_parser.yrl b/src/loupe_parser.yrl index 4d58701..e462664 100644 --- a/src/loupe_parser.yrl +++ b/src/loupe_parser.yrl @@ -15,6 +15,8 @@ expression -> identifier quantifier schema where predicates : {unwrap('$1'), '$2 expression -> identifier schema where predicates : {unwrap('$1'), {int, 1}, '$2', '$4'}. expression -> identifier quantifier schema : {unwrap('$1'), '$2', '$3', nil}. expression -> identifier schema : {unwrap('$1'), {int, 1}, '$2', nil}. +expression -> identifier where predicates : {unwrap('$1'), {int, 1}, {nil, nil}, '$3'}. +expression -> identifier quantifier where predicates : {unwrap('$1'), '$2', {nil, nil}, '$4'}. schema -> identifier object : {unwrap('$1'), '$2'}. schema -> identifier : {unwrap('$1'), nil}. diff --git a/test/loupe/ecto_test.exs b/test/loupe/ecto_test.exs index 449b09c..f9965e7 100644 --- a/test/loupe/ecto_test.exs +++ b/test/loupe/ecto_test.exs @@ -3,6 +3,7 @@ defmodule Loupe.EctoTest do alias Loupe.Ecto, as: LoupeEcto alias Loupe.Ecto.Context + alias Loupe.Ecto.Errors.MissingSchemaError alias Loupe.Test.Ecto.Post alias Loupe.Test.Ecto.Role alias Loupe.Test.Ecto.User @@ -253,5 +254,10 @@ defmodule Loupe.EctoTest do %{"order_by" => "-inserted_at"} ) end + + test "queries without schemas are returning an error" do + assert {:error, %MissingSchemaError{}} == + Loupe.Ecto.build_query(~s|get where name = "John"|, @implementation) + end end end diff --git a/test/loupe/language_test.exs b/test/loupe/language_test.exs index 81fafe8..bc0cdc7 100644 --- a/test/loupe/language_test.exs +++ b/test/loupe/language_test.exs @@ -19,6 +19,16 @@ defmodule Loupe.LanguageTest do test "compiles a different action" do assert {:ok, %Ast{action: "ecto"}} = Language.compile(@case) end + + @case ~s|get where email = "user@email.com"| + test "supports query without schema" do + assert {:ok, %Ast{quantifier: {:int, 1}, schema: nil}} = Language.compile(@case) + end + + @case ~s|get 12 where email = "user@email.com"| + test "supports query without schema but with quantifier" do + assert {:ok, %Ast{quantifier: {:int, 12}, schema: nil}} = Language.compile(@case) + end end describe "compile/1 quantifier" do diff --git a/test/loupe/stream/default_comparator_test.exs b/test/loupe/stream/default_comparator_test.exs index 43c7843..76fbc89 100644 --- a/test/loupe/stream/default_comparator_test.exs +++ b/test/loupe/stream/default_comparator_test.exs @@ -101,4 +101,21 @@ defmodule Loupe.Stream.DefaultComparatorTest do refute DefaultComparator.compare({:in, 5, [1, 2, 6]}) end end + + describe "apply_variant/2" do + test "noop for variants" do + Enum.each([1, 2.2, true, "hello", %{}], fn value -> + assert value == DefaultComparator.apply_variant(value, "something") + end) + end + end + + describe "cast_sigil/2" do + test "noop for sigils" do + Enum.each([1, 2.2, true, "hello", %{}], fn value -> + random_char = [Enum.random(?a..?z)] + assert value == DefaultComparator.cast_sigil(random_char, value) + end) + end + end end diff --git a/test/loupe/stream_test.exs b/test/loupe/stream_test.exs index 5d604d8..0ba71fa 100644 --- a/test/loupe/stream_test.exs +++ b/test/loupe/stream_test.exs @@ -2,22 +2,42 @@ defmodule Loupe.StreamTest do use Loupe.TestCase, async: true doctest Loupe.Stream + defmodule VariantComparator do + @behaviour Loupe.Stream.Comparator + + alias Loupe.Stream.Comparator + alias Loupe.Stream.DefaultComparator + + @impl Comparator + def compare(operator) do + DefaultComparator.compare(operator) + end + + @impl Comparator + def apply_variant(value, "upper") do + String.upcase(value) + end + + def apply_variant(value, _), do: value + + @impl Comparator + def cast_sigil(_, value), do: value + end + @json_data "./test/support/fixtures/issues.json" |> File.read!() |> Jason.decode!() describe "query/3" do - test "filters a map stream for one item" do - assert {:ok, stream} = - Loupe.Stream.query(~s|get A where labels.name = "tech debt"|, @json_data) + setup [:create_stream] + @tag query: ~s|get A where labels.name = "tech debt"| + test "filters a map stream for one item", %{stream: stream} do assert [%{"labels" => [%{"name" => "tech debt"}]}] = Enum.to_list(stream) end - test "filters a map stream for multiple item" do - assert {:ok, stream} = - Loupe.Stream.query(~s|get all A where labels.name not :empty|, @json_data) - + @tag query: ~s|get all A where labels.name not :empty| + test "filters a map stream for multiple item", %{stream: stream} do assert [ %{ "labels" => [ @@ -58,5 +78,100 @@ defmodule Loupe.StreamTest do } ] = Enum.take(stream, 2) end + + @tag query: ~s|get where draft| + test "filters map with true boolean value", %{stream: stream} do + assert [%{"number" => 26, "draft" => true}] = Enum.to_list(stream) + end + + @tag query: ~s|get where not draft| + test "filters map with false boolean value", %{stream: stream} do + assert [%{"number" => 29, "draft" => false}] = Enum.to_list(stream) + end + + @tag query: ~s|get where random_float > 5.0| + test "filters map with float", %{stream: stream} do + assert [%{"number" => 28, "random_float" => 8.1}] = Enum.to_list(stream) + end + + @tag query: ~s|get where only_valid_here not :empty| + test "filters map with a value that doesn't exist everywhere", %{stream: stream} do + assert [%{"number" => 10, "only_valid_here" => "hello"}] = Enum.to_list(stream) + end + + @tag query: ~s|get all where number = 17 or number = 29| + test "filters map with or operator", %{stream: stream} do + assert [%{"number" => 29}, %{"number" => 17}] = Enum.to_list(stream) + end + + @tag query: ~s|get where user.login = "nicklayb" and number = 29| + test "filters map with and operator", %{stream: stream} do + assert [%{"number" => 29, "user" => %{"login" => "nicklayb"}}] = Enum.to_list(stream) + end + + @tag query: ~s|get where assignee :empty| + test "filters map with null field", %{stream: stream} do + assert [%{"number" => 29, "assignee" => nil}] = Enum.to_list(stream) + end + + @tag [ + comparator: VariantComparator, + query: ~s|get where assignee:upper = "SOMETHING"| + ] + test "filters but ignores variant on null field", %{stream: stream} do + assert [] = Enum.to_list(stream) + end + + @tag [ + comparator: VariantComparator, + query: ~s|get where labels.name:upper = "TECH DEBT"| + ] + test "filters with variant", %{stream: stream} do + assert [%{"number" => 28, "labels" => [%{"name" => "tech debt"}]}] = Enum.to_list(stream) + end + + @tag query: ~s|get A| + test "filters nothing when no predicate", %{stream: stream} do + assert [%{"number" => 29}] = Enum.to_list(stream) + end + + @data [ + %{name: "Alex Lifeson", instruments: [%{name: "Guitar"}]}, + %{ + name: "Geddy Lee", + instruments: [%{name: "Bass"}, %{name: "Synthesizer"}, %{name: "Voice"}] + }, + %{name: "Neil Peart", instruments: [%{name: "Drums"}, %{name: "Lyrics"}]} + ] + @tag [ + query: ~s|get where instruments.name = "Drums"|, + data: @data + ] + test "filters atom-keyed map", %{stream: stream} do + assert [%{name: "Neil Peart"}] = Enum.to_list(stream) + end + + @tag [ + query: ~s|get where instruments.not_valid_atom = "Drums"|, + data: @data + ] + test "filters atom-keyed map fails gracefully if field can't be converted as atom", %{ + stream: stream + } do + assert_raise(ArgumentError, fn -> + String.to_existing_atom("not_valid_atom") + end) + + assert [] = Enum.to_list(stream) + end + end + + defp create_stream(context) do + query = Map.fetch!(context, :query) + comparator = Map.get(context, :comparator, Loupe.Stream.DefaultComparator) + data = Map.get_lazy(context, :data, fn -> @json_data end) + + assert {:ok, stream} = Loupe.Stream.query(query, data, comparator: comparator) + [stream: stream] end end diff --git a/test/support/fixtures/issues.json b/test/support/fixtures/issues.json index df69115..6170ff6 100644 --- a/test/support/fixtures/issues.json +++ b/test/support/fixtures/issues.json @@ -36,6 +36,7 @@ ], "state": "open", "locked": false, + "random_float": 2.4, "assignee": null, "assignees": [ @@ -88,6 +89,7 @@ "id": 2527192788, "node_id": "I_kwDOJIsQ-M6WoebU", "number": 28, + "random_float": 8.1, "title": "Make dialyzer pass", "user": { "login": "nicklayb", @@ -577,6 +579,7 @@ }, "timeline_url": "https://api.github.com/repos/nicklayb/loupe/issues/10/timeline", "performed_via_github_app": null, - "state_reason": null + "state_reason": null, + "only_valid_here": "hello" } ] From 762868f515ca47ddd22cb6f63a81cf2846854faf Mon Sep 17 00:00:00 2001 From: nboisvert Date: Sat, 15 Mar 2025 01:38:00 -0400 Subject: [PATCH 9/9] Remove unused file --- .../errors/{missing_schema.ex => missing_schema_error.ex} | 2 +- test/loupe/stream/comparator_test.exs | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) rename lib/loupe/ecto/errors/{missing_schema.ex => missing_schema_error.ex} (77%) delete mode 100644 test/loupe/stream/comparator_test.exs diff --git a/lib/loupe/ecto/errors/missing_schema.ex b/lib/loupe/ecto/errors/missing_schema_error.ex similarity index 77% rename from lib/loupe/ecto/errors/missing_schema.ex rename to lib/loupe/ecto/errors/missing_schema_error.ex index 218257e..a4d108d 100644 --- a/lib/loupe/ecto/errors/missing_schema.ex +++ b/lib/loupe/ecto/errors/missing_schema_error.ex @@ -1,5 +1,5 @@ defmodule Loupe.Ecto.Errors.MissingSchemaError do - @moduledoc "An error that occured in the lexer's step" + @moduledoc "Error that occurs when to schema is provided but is expected" defexception [] alias Loupe.Ecto.Errors.MissingSchemaError diff --git a/test/loupe/stream/comparator_test.exs b/test/loupe/stream/comparator_test.exs deleted file mode 100644 index 6fa72ba..0000000 --- a/test/loupe/stream/comparator_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule Loupe.Stream.ComparatorTest do - use Loupe.TestCase, async: true - - describe "compare/3" do - test "compares an operation with an item" do - end - end -end