diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index b6f75cb..e0cf9a0 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -31,16 +31,16 @@ 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 }} 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 @@ -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/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/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/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_error.ex b/lib/loupe/ecto/errors/missing_schema_error.ex new file mode 100644 index 0000000..a4d108d --- /dev/null +++ b/lib/loupe/ecto/errors/missing_schema_error.ex @@ -0,0 +1,13 @@ +defmodule Loupe.Ecto.Errors.MissingSchemaError do + @moduledoc "Error that occurs when to schema is provided but is expected" + 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 01f95ad..efa7e66 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() } @@ -125,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/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/lib/loupe/stream.ex b/lib/loupe/stream.ex new file mode 100644 index 0000000..fa9195e --- /dev/null +++ b/lib/loupe/stream.ex @@ -0,0 +1,145 @@ +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, 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 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> {:ok, stream} = Loupe.Stream.query(~s|get all 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> {:ok, stream} = Loupe.Stream.query(~s|get 2..3 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 + + 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 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 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()} + 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..ae1fb31 --- /dev/null +++ b/lib/loupe/stream/comparator.ex @@ -0,0 +1,127 @@ +defmodule Loupe.Stream.Comparator do + @moduledoc """ + 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 + alias Loupe.Stream.Context + + @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 + 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 + case compute_expression(operand, element, context) do + :empty -> false + other -> not other + end + end + + def compare(operand, element, %Context{} = context) do + with :empty <- compute_expression(operand, element, context) do + false + end + end + + 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 + {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 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 + + defp compare_value( + {_ = operator, {:binding, [binding | rest_bindings]}, right}, + element, + %Context{comparator: comparator} = context + ) do + result = + case {get_value(element, binding, context), 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 + + 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 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..feb80b3 --- /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{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 new file mode 100644 index 0000000..e9c15e8 --- /dev/null +++ b/lib/loupe/stream/default_comparator.ex @@ -0,0 +1,61 @@ +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 + + @impl Loupe.Stream.Comparator + def compare({:=, nil, nil}), do: true + + 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 + + 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 + + @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 786951e..71554f5 100644 --- a/mix.exs +++ b/mix.exs @@ -2,13 +2,13 @@ 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 [ app: :loupe, version: @version, - elixir: "~> 1.12", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), @@ -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/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/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..76fbc89 --- /dev/null +++ b/test/loupe/stream/default_comparator_test.exs @@ -0,0 +1,121 @@ +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, 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"}) + + 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 + + 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 new file mode 100644 index 0000000..0ba71fa --- /dev/null +++ b/test/loupe/stream_test.exs @@ -0,0 +1,177 @@ +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 + 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 + + @tag query: ~s|get all A where labels.name not :empty| + test "filters a map stream for multiple item", %{stream: stream} do + 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 + + @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 new file mode 100644 index 0000000..6170ff6 --- /dev/null +++ b/test/support/fixtures/issues.json @@ -0,0 +1,585 @@ +[ + { + "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, + "random_float": 2.4, + "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, + "random_float": 8.1, + "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, + "only_valid_here": "hello" + } +]