From 2c58cf0b3a51ae91d3177b50892500693bd98617 Mon Sep 17 00:00:00 2001 From: Arno Dirlam Date: Sun, 27 Oct 2024 19:05:03 +0100 Subject: [PATCH] Add docs to Dx.Defd module and functions --- README.md | 8 ++- lib/dx.ex | 3 +- lib/dx/defd.ex | 155 ++++++++++++++++++++++++++++++++++++++++++++++++- mix.exs | 4 +- 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e5c1bc8b..772be716 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Last Updated](https://img.shields.io/github/last-commit/elixir-dx/dx/main)](https://github.com/elixir-dx/dx/tree/main) ![CI](https://github.com/elixir-dx/dx/actions/workflows/ci.yml/badge.svg) + Dx enables you to write Elixir code as if all your Ecto data is already (pre)loaded. ### Example @@ -34,9 +35,12 @@ This can be called using Dx.Defd.load!(MyApp.Core.Authorization.visible_lists(user)) ``` -All data needed is loaded automatically: The association `role`, and either all `Schema.List` matching the filter (translated to a single SQL query) or the user's associated lists, depending on whether it's an admin. +`Dx.Defd.load!/2` loads all required data automatically: The association `role`, and either all +`Schema.List` matching the filter (translated to a single SQL query) or the user's associated lists, +depending on whether it's an admin. These function can just as well be called for many users, and Dx will load data efficiently (with batching and concurrently). + ## Demo @@ -76,6 +80,7 @@ Import the formatter rules in `.formatter.exs`: ] ``` + ## Background Most server backends for web and mobile applications are split between @@ -226,6 +231,7 @@ will be translated to database queries, if both - `and`, `or`, `&&` - `Enum.any?/2`, `Enum.all?/2` - `DateTime.compare/2` + ### Roadmap diff --git a/lib/dx.ex b/lib/dx.ex index 9c66b68a..0c34a896 100644 --- a/lib/dx.ex +++ b/lib/dx.ex @@ -1,6 +1,7 @@ defmodule Dx do @moduledoc """ - This is the main entry for using the Dx API. + This module used to be the main entry for using the Dx API. It is now deprecated. + Please use `Dx.Defd` instead. - `get/3` evaluates the given predicate(s) using only the (pre)loaded data available, and returns the result(s) - `load/3` is like `get`, but loads any additional data as needed diff --git a/lib/dx/defd.ex b/lib/dx/defd.ex index 16608f68..270ef719 100644 --- a/lib/dx/defd.ex +++ b/lib/dx/defd.ex @@ -1,10 +1,22 @@ defmodule Dx.Defd do + @external_resource Path.expand("./README.md") + @moduledoc File.read!(Path.expand("./README.md")) + |> String.split("") + |> Enum.drop(1) + |> Enum.take_every(2) + |> Enum.join() + alias Dx.Defd.Ast alias Dx.Defd.Util alias Dx.Evaluation, as: Eval @eval_var Macro.var(:eval, Dx.Defd.Compiler) + @doc """ + Like `load!/2` but returns `{:ok, result}` on success, `{:error, error}` on failure. + + See `load!/2` for a raising alternative, and `get!/2` and `get/2` for non-loading alternatives. + """ defmacro load(call, opts \\ []) do defd_call = call_to_defd(call, __ENV__) @@ -16,6 +28,29 @@ defmodule Dx.Defd do |> mark_use(call) end + @doc """ + Wrap a `defd` function call to run it repeatedly, loading all required data. + Raises an error if unsuccessful. + + See `load/2`, `get!/2` and `get/2` for non-raising and/or non-loading alternatives. + + ## Example + + defmodule MyApp.Core.Authorization do + import Dx.Defd + + defd visible_lists(user) do + if user.role.name == "Admin" do + Enum.filter(Schema.List, &(&1.title == "Main list")) + else + user.lists + end + end + end + + # Will raise if data loading fails + Dx.Defd.load!(MyApp.Core.Authorization.visible_lists(user)) + """ defmacro load!(call, opts \\ []) do defd_call = call_to_defd(call, __ENV__) @@ -27,6 +62,16 @@ defmodule Dx.Defd do |> mark_use(call) end + @doc """ + Like `load!/2` but returns a result tuple and evaluates without loading any data. + + Returns either + - `{:ok, result}` on success + - `{:error, error}` on failure + - `{:not_loaded, data_reqs}` if required data is missing + + See `get!/2` for a raising alternative, and `load!/2` and `load/2` for loading alternatives. + """ defmacro get(call, opts \\ []) do defd_call = call_to_defd(call, __ENV__) @@ -38,6 +83,11 @@ defmodule Dx.Defd do |> mark_use(call) end + @doc """ + Like `load!/2` but evaluates without loading any data. + + See `get/2` for a non-raising alternative, and `load!/2` and `load/2` for loading alternatives. + """ defmacro get!(call, opts \\ []) do quote do Dx.Defd.get(unquote(call), unquote(opts)) @@ -92,25 +142,126 @@ defmodule Dx.Defd do end end + @doc false defmacro defd(call) do define_defd(:def, call, __CALLER__) end + @doc """ + Defines a function that automatically loads required data. + + `defd` functions are similar to regular Elixir functions defined with `def`, + but they allow you to write code as if all data is already loaded. + Dx will automatically handle loading the necessary data from the database + when the function is called. + + ## Usage + + ```elixir + defmodule MyApp.Core.Authorization do + import Dx.Defd + + defd visible_lists(user) do + if admin?(user) do + Enum.filter(Schema.List, &(&1.title == "Main list")) + else + user.lists + end + end + + defd admin?(user) do + user.role.name == "Admin" + end + end + ``` + + This can be called using + + ```elixir + Dx.Defd.load!(MyApp.Core.Authorization.visible_lists(user)) + ``` + + ## Important notes + + - `defd` functions should be pure and not have side effects. + - They should not rely on external state or perform I/O operations. + - Calls to non-`defd` functions should be wrapped in `non_dx/1`. + - To call a `defd` function from regular Elixir code, wrap it in `Dx.Defd.load!/1`. + + ## Options + + Additional options can be passed via a `@dx` attribute right before the `defd` definition: + + ```elixir + @dx def: :original + defd visible_lists(user) do + # ... + end + ``` + + Available options: + + - `def:` - Determines what the generated non-defd function should do. + - `:warn` (default) - Call the `defd` function wrapped in `Dx.Defd.load!/1` and emit a warning + asking to make the wrapping explicit. + - `:no_warn` - Call the `defd` function wrapped in `Dx.Defd.load!/1` without emitting a warning. + - `:original` - Keep the original function definition. This means, the original function can still + be called directly without being changed by Dx. The `defd` version *must* be called from other + `defd` functions or by wrapping the call in `Dx.Defd.load!/1`. This can be useful when migrating + existing code to Dx. + - `debug:` - Takes one or multiple flags for printing generated code to the console. These *can* get + *very* verbose, because Dx generates code for many combinations of cases. All flags have a `_raw` + variant that prints the code without syntax highlighting. + - `:original` - Prints the original function definition as passed to defd. All macros are already + expanded at this point. + - `:def` - Prints the `def` version, which is the generated non-defd function. See the `def:` option. + - `:defd` - Prints the `defd` function definition. + - `:final_args` - Prints the `final_args` version, which is similar to the `defd` version but + can be slightly shorter for some internal optimizations. This is also the version used + as the entrypoint when calling `Dx.Defd.load!/1`. + - `:scope` - Prints the `scope` version, which is used to translate the function to SQL. + It returns AST-like data structures with embedded `defd` code fallbacks. + - `:all` - Enables all the flags. + + """ defmacro defd(call, do: block) do define_defd(:def, call, block, __CALLER__) end + @doc false defmacro defdp(call) do define_defd(:defp, call, __CALLER__) end + @doc "Private version of `defd/2`." defmacro defdp(call, do: block) do define_defd(:defp, call, block, __CALLER__) end @doc """ - Used to wrap calls to non-Dx defined functions. - It doesn't run any code, but makes these calls explicit and mutes Dx compiler warnings. + Used to wrap calls to non-Dx defined functions within a `defd` function. + + When writing `defd` functions, any calls to regular Elixir functions (non-`defd` functions) + should be wrapped with `non_dx/1`. This makes the external calls explicit and suppresses + Dx compiler warnings. + + ## Example + + defmodule MyApp.Core.Stats do + import Dx.Defd + + def calculate_percentage(value, total) do + (value / total) * 100 + end + + defd user_completion_rate(user) do + completed = length(user.completed_tasks) + total = length(user.all_tasks) + + # Wrap the regular def function call with non_dx + non_dx(calculate_percentage(completed, total)) + end + end """ def non_dx(code) do code diff --git a/mix.exs b/mix.exs index 6c2c018e..1f4bf1ad 100644 --- a/mix.exs +++ b/mix.exs @@ -19,7 +19,7 @@ defmodule Dx.MixProject do defp package do [ - description: "Inference engine written in Elixir", + description: "Automatic data loading for Elixir functions", files: ["lib", "mix.exs", "README*", "VERSION"], maintainers: ["Arno Dirlam"], licenses: ["MIT"], @@ -69,7 +69,7 @@ defmodule Dx.MixProject do defp docs do [ - main: "Dx", + main: "Dx.Defd", extra_section: "Guides (old)", extras: Path.wildcard("docs/**/*.md"), groups_for_extras: [