Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
42 changes: 30 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://hexdocs.pm/loupe>.
Thhe documentation can be found at <https://hexdocs.pm/loupe>.

## Syntax

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.10.0
61 changes: 61 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -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
'';
};
}
);
}
8 changes: 7 additions & 1 deletion lib/loupe/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()) ::
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions lib/loupe/ecto/errors/missing_schema_error.ex
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions lib/loupe/language.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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
21 changes: 16 additions & 5 deletions lib/loupe/language/ast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion lib/loupe/phoenix_live_view/live_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading