Dx.Defd (dx v0.3.5)
View SourceDx enables you to write Elixir code as if all your Ecto data is already (pre)loaded.
Example
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
endThis can be called using
Dx.Defd.load!(MyApp.Core.Authorization.visible_lists(user))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).
Background
Most server backends for web and mobile applications are split between the actual application and at least one database. In their day-to-day programming, most Elixir developers have to keep that in mind and think about how to store data in the database, and when and how to load it. It's so deeply engrained that we often take this problem for granted, having integrated it in how we think about code and code architecture. For example, Phoenix (the most popular web framework for Elixir) has API contexts that suggest structuring apps into modules that act as a boundary (or interface) to the rest of the code. Within these, data is loaded and returned. Since it's a generic interface, the simplest approach is to load all data that's possibly needed, and return it. However, as the app grows in functionality and thus complexity, this may become a lot of data. And it's still necessary to think about what to return, where it's needed, and how to slice it.
Imagine this problem would not exist. Enter Dx.
With Dx, Elixir developers don't have to think about loading data from the database at all. You just write Elixir code, as if all data is already loaded and readily available.
How it works
When working with data in the database, you define Elixir functions
using defd instead of def (the regular Elixir function definition).
The defd function must be imported from the Dx.Defd module.
Within defd functions, you can write regular Elixir code, accessing
all fields and associations as if they're already loaded. You can also
call other defd functions and structure your code in modules as usual.
When the app is compiled, Dx translates your defd code into multiple
versions with different ways to load data:
- Data loading: Any data that might need to be loaded is wrapped in a
check that either returns the already loaded data, or returns a "data
requirement". Dx runs the code at the entry point (the first function
that's a
defdfunction) and either receives the result, or receives a number of data requirements. These are loaded, using the dataloader library under the hood. Then the code is run again, this time either returning the result, or more data requirements, and so on. - Data querying: Parts of the code may be translated to "data scopes",
which are used to generate database queries out of your code. For
example, using the standard library function
Enum.filterin adefdfunction will try to translate the condition (the anonymous function passed as second argument) into a database query. When successful, the data will not be loaded and then filtered in Elixir, but will already be filtered in the database.
All this happens automatically in the background. Parts of the work are done when compiling your code. Other parts are done when running it.
Caveats
Dx is designed with great care for developer experience. You can just start using it, and will get warnings with explanations if something should or must be done differently. It still helps to understand the main limitations:
Pure functions
Dx translates your code into different other versions of it. The translated
versions may then be run any number of times, more or less often than the
original would have been run. Thus, that any code defined using defd should
be functionally pure. This means, it should not have any side effects.
- When the same code is run with the same arguments, it must always return the same result. Examples for non-pure code are using date and time, or random numbers.
defdfunctions should also not modify any external state, such as modifying data in the database, or printing text to the console. Except if it's fine that the modification is applied multiple times.
Calling non-defd functions
You can call non-defd functions from within defd functions. However, Dx
can't "look into" them. No data inside them will be loaded, and they can
never be translated to database queries. They will also be run any number
of times, so they should be pure functions as well.
Dx will ask you to wrap the call in a non_dx/1 function call. This is
just to make clear that the called function is not defined using defd
when reading the code.
Finding good entry points
Any time a defd function is called from a regular Elixir function,
that's an entry point. That's where any needed data will be loaded.
Dx will ask you to wrap the call in a Dx.Defd.load!/1 function call.
This is just to make clear that the called function is an entry point
to defd land and data may be loaded here.
It may help to create dedicated modules for all defd functions. They
are usually the core of the application, with much of the (business) logic.
Any code calling into them - the entry points - in contrast, are outside
these modules, for example in a API function, a Phoenix controller, or an
Oban worker. This is where the data is loaded, whereas the defd modules
consist only of pure functions with (business) logic.
Filter conditions in Elixir vs. SQL
Conditions can behave quite differently in SQL vs. Elixir. In the future, Dx will fully translate all nuances correctly, but for now, you have to keep that in mind yourself.
NULLnever matches anything in SQL, but it does in Elixir. For example,title != "TODO"whentitle = nilwill match in Elixir, but not match in SQL. Thus,nilcases must be handled individually:is_nil(title) or title != "TODO"- Dx joins
has_oneandbelongs_toassociations usingLEFT JOINin SQL. This means, you can happily access association chains, even if interim assocation parts do not exist. This would crash in Elixir, but in SQL, all fields just appear asNULL. Thus, the presence of associations should be checked individually:not is_nil (list.creator) and is_nil(list.creator.deleted_at)
Currently supported
Syntax
All syntax except for and with is supported in defd and (private) defdp functions.
Standard library
Translatable to database queries
Functions
will be translated to database queries, if both
- the first argument is either
- a schema module, f.ex.
Enum.filter(Todo.Task, fn task -> task.priority == "high" end) - the result of another function listed above
- a schema module, f.ex.
- the function passed as second argument (if any) consists only of functions listed above or:
==,<,>and,or,&&Enum.any?/2,Enum.all?/2DateTime.compare/2
Summary
Functions
Defines a function that automatically loads required data.
Private version of defd/2.
Like load!/2 but returns a result tuple and evaluates without loading any data.
Like load!/2 but evaluates without loading any data.
Like load!/2 but returns {:ok, result} on success, {:error, error} on failure.
Wrap a defd function call to run it repeatedly, loading all required data.
Raises an error if unsuccessful.
Used to wrap calls to non-Dx defined functions within a defd function.
Functions
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
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
endThis can be called using
Dx.Defd.load!(MyApp.Core.Authorization.visible_lists(user))Important notes
defdfunctions should be pure and not have side effects.- They should not rely on external state or perform I/O operations.
- Calls to non-
defdfunctions should be wrapped innon_dx/1. - To call a
defdfunction from regular Elixir code, wrap it inDx.Defd.load!/1.
Options
Additional options can be passed via a @dx attribute right before the defd definition:
@dx def: :original
defd visible_lists(user) do
# ...
endAvailable options:
def:- Determines what the generated non-defd function should do.:warn(default) - Call thedefdfunction wrapped inDx.Defd.load!/1and emit a warning asking to make the wrapping explicit.:no_warn- Call thedefdfunction wrapped inDx.Defd.load!/1without emitting a warning.:original- Keep the original function definition. This means, the original function can still be called directly without being changed by Dx. Thedefdversion must be called from otherdefdfunctions or by wrapping the call inDx.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_rawvariant 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 thedefversion, which is the generated non-defd function. See thedef:option.:defd- Prints thedefdfunction definition.:final_args- Prints thefinal_argsversion, which is similar to thedefdversion but can be slightly shorter for some internal optimizations. This is also the version used as the entrypoint when callingDx.Defd.load!/1.:scope- Prints thescopeversion, which is used to translate the function to SQL. It returns AST-like data structures with embeddeddefdcode fallbacks.:all- Enables all the flags.
Private version of defd/2.
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.
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.
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.
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))
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