Hey all!
I found myself needing to do complex conditional logic based on boolean flags. I found myself drawing it up as a truth table and thought it’d be nice to have a way to translate that directly to the logic code, so I made this small 1-file library: Truly
Truly
provides a convenient and human-readable way to store complex conditional logic trees.
You can immediately use Truly.evaluate/2
to evaluate the truth table or pass the truth table
around for repeat use.
You might find this useful for things like feature flags, where depending on the combination
of boolean flags you want different behaviors or paths.
This can also make the design much more self-documented, where the intent behind a large
logic ladder becomes quite clear.
EDIT: Replacing the original examples with these for 0.2.0
Usage
First, you must import Truly
. Then you have the
~TRULY
sigil available.
All column names and result values must be valid (and existing) atoms.
Table cells can only be boolean values.
You must provide an exhaustive truth table, meaning that you provide
each combination of column values.
Basic Example
import Truly
columns = [:flag_a, :flag_b, :flag_c]
categories = [:cat1, :cat2, :cat3]
{:ok, tt} = ~TRULY"""
| flag_a | flag_b | flag_c | |
|----------|---------|----------|----------|
| false | false | false | cat1 |
| false | false | true | cat1 |
| false | true | false | cat2 |
| false | true | true | cat1 |
| true | false | false | cat3 |
| true | false | true | cat1 |
| true | true | false | cat2 |
| true | true | true | cat3 |
"""
Truly.evaluate!(tt,[flag_a: true, flag_b: true, flag_c: true])
flag_a = false
flag_b = true
flag_c = false
Truly.evaluate(tt,binding())
Practical Example
Imagine you’re writing the backend for your social media app
called PitterPatter
. You want to allow users to direct message
each other, but you want to enforce certain rules around this.
You have the following struct representing your User
:
defmodule User do
defstruct [:dms_open, :locked]
end
You want to control when messages are allowed to be sent according to
the sender’s :locked
account status, the receiver’s :dms_open
setting,
as well as if the two are friends.
Different combinations of these result in different behavior.
We can define the truth table, and since the result column
can be any atom, we can directly pass the function that we want
to call:
defmodule PitterPatter do
import Truly
def are_friends(_user1, _user2), do: Enum.random([true,false]) |> IO.inspect(label: "Are friends?")
# We must specify these atoms before the truth table since the atoms must exist already
@flags [:dms_open, :locked]
# Have different functions for different behaviors. You could imagine there
# can be any number of these <= # rows
def send_message(_sender,_receiver,_message), do: "Message Sent!"
def deny_message(_sender,_receiver,_message), do: "Sorry, you can't send that message!"
# Specify our truth table
# For the sake of simplicity we stick to 3 variables
# Also notice that you can use any existing atom (in this case the boolean atoms)
# in the cells
@tt ~TRULY"""
| dms_open | are_friends | locked | |
|----------|--------------|----------|-------------------|
| false | false | false | deny_message |
| false | false | true | deny_message |
| false | true | false | send_message |
| false | true | true | deny_message |
| true | false | false | send_message |
| true | false | true | deny_message |
| true | true | false | send_message |
| true | true | true | deny_message |
"""r # <- Notice the `r` modifier after the table
# This is effectively like a `!` function, that will
# unpack the return tuple and raise on error
def direct_message(sender, receiver, message) do
table = @tt
flags =
[
dms_open: receiver.dms_open,
are_friends: are_friends(sender,receiver),
locked: sender.locked
]
apply(__MODULE__,Truly.evaluate!(table,flags),[sender,receiver,message])
end
end
And just like that, a call to Truly.evaluate!
performs all of the various checks
needed as well as routes to the appropriate function depending on the state passed in.
Let’s see how we would use this Let’s set up some User
s:
sender = %User{dms_open: true, locked: false}
receiver = %User{dms_open: true, locked: false}
And now you can run PitterPatter.direct_message
, and you will see that
as the :are_friends
status changes (since it’s determined randomly above),
the result changes according to the rows in the truth table.
PitterPatter.direct_message(sender, receiver, "Hey, can you talk?")
Modifiers
r
- This is effectively like a!
function, that will unpack the return tuple and raise on errors
Skip validation – when this modifier is present, we will not check that the
truth table is exhaustive (accounts for each possible combination based on present values).
Example With Modifiers
import Truly
columns = [DMS, ARE_FRIENDS, LOCKED]
dms_open_enums = [:friends_only, :public, :closed]
results = [:deny_message, :send_message]
t2 = ~TRULY(
| DMS | ARE_FRIENDS | |
|----------------|--------------|--------------------------------------------------|
| friends_only | false | error, You Must Be Friends to Message This User |
| friends_only | true | send_message |
| public | false | send_message |
| public | true | send_message |
| closed | true | error, This User Does Not Accept Direct Messages |
| closed | false | error, This User Does Not Accept Direct Messages |
)rs
Notice that you can use any existing atom in any cell, and even add error messages in the
result column.
If the last row of the previous table were removed, it would result in an error
due to having the s
modifier present.
Here’s some examples of it in action:
https://x.com/ac_alejos/status/1777879977746206816