Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
59f8bff
add credo and optional SQLite3 dependencies
waseigo Sep 15, 2025
f4175ba
include migration scripts
waseigo Sep 15, 2025
a8cbd96
ignore SQLite database files
waseigo Sep 15, 2025
abf7d4c
add test configuration for SQLite3
waseigo Sep 15, 2025
b7eacaf
add commented-out configuration for SQLite
waseigo Sep 15, 2025
b7b878d
replace `fetch_env/2` with `compile_env/2`, fix multi-line doctests, …
waseigo Sep 15, 2025
61dab88
switch single-clause `with` to `case`
waseigo Sep 15, 2025
97a0048
add `:sqlite3` to `case` block
waseigo Sep 15, 2025
3f243a1
`@moduledoc false` (implementation details)
waseigo Sep 15, 2025
ff5161f
add `@moduledoc`
waseigo Sep 15, 2025
c71e7f2
switch to `compile_env/2`, add `@moduledoc`, disable locking for SQLite3
waseigo Sep 15, 2025
41df9c2
add `@moduledoc`, fix multi-line doctests, fix constraint name with p…
waseigo Sep 15, 2025
08a02cd
determine JSON type with `get_env/2` of `:db`; `mix format`
waseigo Sep 15, 2025
4595722
tag tests that require locking to exlude them from SQLite-based tests
waseigo Sep 15, 2025
f312522
exclude tests that require locking when using SQLite
waseigo Sep 15, 2025
2b0bab3
add `@moduledoc`
waseigo Sep 15, 2025
966cbaf
update `README.md`
waseigo Sep 15, 2025
a98a138
disable Credo warning for large numbers and underscores
waseigo Sep 15, 2025
7a6ca4e
`@moduledoc false`
waseigo Sep 15, 2025
a309f47
Replace compile-time attributes with runtime functions
waseigo Jan 18, 2026
dd36cef
Update account_balance.ex
waseigo Jan 18, 2026
a4b6bb5
Update line.ex
waseigo Jan 18, 2026
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
217 changes: 217 additions & 0 deletions .credo.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/",
"web/",
"apps/*/lib/",
"apps/*/src/",
"apps/*/test/",
"apps/*/web/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: false,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},

#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
{Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},

#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
# {Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},

#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},

#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.UnsafeExec, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},

#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.OneArityFunctionInPipe, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}

# {Credo.Check.Refactor.MapInto, []},

#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}
2 changes: 1 addition & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter}.exs", "{config,lib,test,priv}/**/*.{ex,exs}"]
]
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ ex_double_entry-*.tar
/tmp/

mix.lock

# SQLite files
*.db*
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ An Elixir double-entry library inspired by Ruby's [DoubleEntry](https://github.c

- Postgres 9.4+ (for `JSONB` support)
- MySQL 8.0+ (for row locking support)
- SQLite3 (database locking, WAL mode)

## Installation

Expand All @@ -20,6 +21,9 @@ def deps do
# pick one DB package
{:postgrex, ">= 0.0.0"},
{:myxql, ">= 0.0.0"},
{:exqlite, ">= 0.0.0"},
# for SQLite3 also add Ecto SQLite3:
{:ecto_sqlite3, ">= 0.0.0"}
]
end
```
Expand Down Expand Up @@ -71,10 +75,8 @@ config :ex_double_entry,
```elixir
# creates a new account with 0 balance
ExDoubleEntry.make_account!(
# identifier of the account, in atom
:savings,
# currency can be any arbitrary atom
currency: :USD,
:savings, # identifier of the account, in atom
currency: :USD, # currency can be any arbitrary atom
# optional, scope can be any arbitrary string
#
# due to DB index on `NULL` values, scope value can only be `nil` (stored as
Expand Down Expand Up @@ -159,6 +161,10 @@ ExDoubleEntry.lock_accounts([account_a, account_b], fn ->
end)
```

### Locking and SQLite3

SQLite3 is supported but **does not offer row-level locking**. Instead, it uses **database-level locking**, serializing all write operations. When using SQLite3 (configured with `db: :sqlite3`), `lock_accounts/2` and `transfer!/1` rely on Write-Ahead Logging (WAL) mode to ensure atomicity through transaction serialization. This is sufficient for low-concurrency environments (e.g., development, testing, or single-user applications). For high-concurrency production systems, prefer Postgres (9.4+) or MySQL (8.0+).

## License

Licensed under [MIT](LICENSE.md).
7 changes: 7 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ config :ex_double_entry, ExDoubleEntry.Repo,
database: "ex_double_entry_dev",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox

# When using SQLite
# config :ex_double_entry, ExDoubleEntry.Repo,
# database: Path.expand("../ex_double_entry_dev.db", __DIR__),
# pool_size: 5,
# stacktrace: true,
# show_sensitive_data_on_connection_error: true
17 changes: 17 additions & 0 deletions config/test_sqlite3.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Config

config :ex_double_entry,
db: :sqlite3

config :ex_double_entry, ExDoubleEntry.Repo,
database: System.get_env("SQLITE_DB_PATH", "ex_double_entry_test.db"),
adapter: Ecto.Adapters.SQLite3,
journal_mode: :wal,
pool_size: 1,
pool: Ecto.Adapters.SQL.Sandbox,
show_sensitive_data_on_connection_error: true,
timeout: :infinity,
queue_target: 200,
queue_interval: 10

config :logger, level: :info
32 changes: 19 additions & 13 deletions lib/ex_double_entry.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
defmodule ExDoubleEntry do
@db_table_prefix Application.fetch_env!(:ex_double_entry, :db_table_prefix)
@repo Application.fetch_env!(:ex_double_entry, :repo)
@moduledoc """
Contains the main public API, delegating to children modules.

def db_table_prefix, do: @db_table_prefix
# Key functions (delegated)

def repo, do: @repo
- `make_account!/2`: Creates a new account with zero balance.
- `lookup_account!/2`: Retrieves an existing account.
- `lock_accounts/2`: Locks multiple accounts and executes a function atomically (only on PostgreSQL and MySQL).
- `transfer!/1`: Performs a validated, atomic transfer (raising on errors).
- `transfer/1`: Non-raising variant of `transfer!/1`.
"""

def db_table_prefix, do: Application.get_env(:ex_double_entry, :db_table_prefix, nil)

def repo, do: Application.fetch_env!(:ex_double_entry, :repo)

@doc """
## Examples
Expand All @@ -26,22 +35,19 @@ defmodule ExDoubleEntry do
## Examples

iex> [ExDoubleEntry.make_account!(:savings)] |> ExDoubleEntry.lock_accounts(fn -> true end)
{:ok, true}
`{:ok, true}`
"""
defdelegate lock_accounts(accounts, fun), to: ExDoubleEntry.AccountBalance, as: :lock_multi!

@doc """
## Examples

iex> %ExDoubleEntry.Transfer{
iex> money: Money.new(42, :USD),
iex> from: %ExDoubleEntry.Account{identifier: :checking, currency: :USD, balance: Money.new(42, :USD), positive_only?: false},
iex> to: %ExDoubleEntry.Account{identifier: :savings, currency: :USD, balance: Money.new(0, :USD)},
iex> code: :deposit
iex> }
iex> |> ExDoubleEntry.transfer!()
iex> |> Tuple.to_list()
iex> |> List.first()
...> money: Money.new(42, :USD),
...> from: %ExDoubleEntry.Account{identifier: :checking, currency: :USD, balance: Money.new(42, :USD), positive_only?: false},
...> to: %ExDoubleEntry.Account{identifier: :savings, currency: :USD, balance: Money.new(0, :USD)},
...> code: :deposit
...> } |> ExDoubleEntry.transfer!() |> Tuple.to_list() |> List.first()
:ok
"""
defdelegate transfer!(transfer), to: ExDoubleEntry.Transfer, as: :perform!
Expand Down
6 changes: 3 additions & 3 deletions lib/ex_double_entry/application.ex
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
defmodule ExDoubleEntry.Application do
@moduledoc false
use Application

@impl true
def start(_type, _args) do
children =
with {:ok, repos} <- Application.fetch_env(:ex_double_entry, :ecto_repos) do
case Application.fetch_env(:ex_double_entry, :ecto_repos) do
# ecto_repos are only required for development and test
repos
else
{:ok, repos} -> repos
_ -> []
end

Expand Down
1 change: 1 addition & 0 deletions lib/ex_double_entry/ecto_types/currency.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
if Code.ensure_loaded?(Ecto.Type) do
defmodule ExDoubleEntry.EctoType.Currency do
@moduledoc false
if macro_exported?(Ecto.Type, :__using__, 1) do
use Ecto.Type
else
Expand Down
1 change: 1 addition & 0 deletions lib/ex_double_entry/ecto_types/identifier.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
if Code.ensure_loaded?(Ecto.Type) do
defmodule ExDoubleEntry.EctoType.Identifier do
@moduledoc false
if macro_exported?(Ecto.Type, :__using__, 1) do
use Ecto.Type
else
Expand Down
1 change: 1 addition & 0 deletions lib/ex_double_entry/ecto_types/scope.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
if Code.ensure_loaded?(Ecto.Type) do
defmodule ExDoubleEntry.EctoType.Scope do
@moduledoc false
if macro_exported?(Ecto.Type, :__using__, 1) do
use Ecto.Type
else
Expand Down
Loading