├── .formatter.exs ├── .github └── workflow │ └── ci.yml ├── .gitignore ├── README.html ├── README.md ├── lib ├── active_memory.ex ├── adapters │ ├── adapter.ex │ ├── ets.ex │ ├── ets │ │ └── helpers.ex │ ├── helpers.ex │ ├── mnesia.ex │ └── mnesia │ │ ├── helpers.ex │ │ └── migration.ex ├── query │ ├── match_guards.ex │ ├── match_spec.ex │ └── query.ex ├── store.ex └── table.ex ├── mix.exs ├── mix.lock └── test ├── adapters ├── ets │ └── helpers_test.exs ├── ets_test.exs ├── helpers_test.exs ├── mnesia │ ├── .DS_Store │ ├── helpers_test.exs │ └── migration_test.exs └── mnesia_test.exs ├── query ├── match_guards_test.exs ├── match_spec_test.exs └── query_test.exs ├── store_test.exs ├── support ├── dogs │ ├── dog.ex │ ├── dog_seeds.exs │ └── store.ex ├── people │ ├── person.ex │ ├── person_seeds.exs │ └── store.ex └── whales │ ├── store.ex │ └── whale.ex ├── table_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflow/ci.yml: -------------------------------------------------------------------------------- 1 | name: ActiveMemory CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | test: 13 | name: Build and test 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | include: 19 | - elixir: '1.13' 20 | otp: '24.0' 21 | - elixir: '1.14' 22 | otp: '25.0' 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Set up Elixir 27 | uses: erlef/setup-beam@988e02bfe678367a02564f65ca2e37726dc0268f 28 | with: 29 | otp-version: ${{matrix.otp}} 30 | elixir-version: ${{matrix.elixir}} 31 | 32 | - name: Restore dependencies cache 33 | uses: actions/cache@v3 34 | with: 35 | path: deps 36 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 37 | restore-keys: ${{ runner.os }}-mix- 38 | - name: Install dependencies 39 | run: mix deps.get 40 | - name: Run tests 41 | run: mix test 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | active_memory-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | #ingore apple file stuff 29 | *.DS_Store -------------------------------------------------------------------------------- /README.html: -------------------------------------------------------------------------------- 1 | README

ActiveMemory

1070 | 1071 |

A Simple ORM for ETS and Mnesia

1072 |

Please note!

1073 | 1074 |

This is still a work in progess and feedback is appreciated

1075 |

Overview

1076 |

A package to help bring the power of in memory storage with ETS and Mnesia to your Elixir application.

1077 |

ActiveMemory provides a simple interface and configuration which abstracts the ETS and Mnesia specifics and provides a common interface called a Store.

1078 |

Example setup

1079 |
    1080 |
  1. Define a Table with attributes
  2. 1081 |
  3. Define a Store with configuration settings or accept the defaults (most applications should be fine with defaults).
  4. 1082 |
  5. Your app is ready!
  6. 1083 |
1084 |

Example Table: 1085 |

defmodule MyApp.People.Person do
1086 |   use ActiveMemory.Table attributes: [
1087 |     :uuid, 
1088 |     :email, 
1089 |     :first_name,
1090 |     :last_name,
1091 |     :department,
1092 |     :start_date,
1093 |     :active,
1094 |     :admin?
1095 |   ]
1096 | end
1097 | 
1098 | Example Mnesia Store (default): 1099 |
defmodule MyApp.People.Store do
1100 |   use ActiveMemory.Store,
1101 |     table: MyApp.People.Person
1102 | end
1103 | 
1104 | Example ETS Store: 1105 |
defmodule MyApp.People.Store do
1106 |   use ActiveMemory.Store,
1107 |     table: MyApp.People.Person,
1108 |     type: :ets
1109 | end
1110 | 

1111 |

Add the Store to your application supervision tree: 1112 |

defmodule MyApp.Application do
1113 |   # code..
1114 |   def start(_type, _args) do
1115 |     children = [
1116 |       # other children
1117 |       MyApp.People.Store,
1118 |       # other children
1119 |     ]
1120 |     # code..
1121 |   end
1122 | end
1123 | 

1124 |

Now you have the default Store methods available!

1125 |

Store API

1126 | 1135 |

Query interface

1136 |

There are two different query types availabe to make finding the records in your store.

1137 |

The Attribute query syntax

1138 |

Attribute matching allows you to provide a map of attributes to search by. 1139 |

Store.one(%{uuid: "a users uuid"})
1140 | Store.select(%{department: "accounting", admin?: false, active: true})
1141 | 

1142 |

The match query syntax

1143 |

Using the match macro you can strucure a basic query.
1144 |

query = match(:department == "sales" or :department == "marketing" and :start_date > last_month)
1145 | Store.select(query)
1146 | 

1147 |

Seeding

1148 |

When starting a Store there is an option to provide a valid seed file and have the Store auto load seeds contained in the file. 1149 |

defmodule MyApp.People.Store do
1150 |   use ActiveMemory.Store,
1151 |     table: MyApp.People.Person,
1152 |     seed_file: Path.expand("person_seeds.exs", __DIR__)
1153 | end
1154 | 

1155 |

Before init

1156 |

All stores are GenServers and have init functions. While those are abstracted you can still specify methods to run during the init phase of the GenServer startup. Use the before_init keyword and add the methods as tuples with the arguments. 1157 |

defmodule MyApp.People.Store do
1158 |   use ActiveMemory.Store,
1159 |     table: MyApp.People.Person,
1160 |     before_init: [{:run_me, ["arg1", "arg2"]}, {:run_me_too, []}]
1161 | end
1162 | 

1163 |

Initial State

1164 |

All stores are GenServers and thus have a state. The default state is an array as such: 1165 |

%{started_at: "date time when first started", table_name: MyApp.People.Store}
1166 | 
1167 | This default state can be overwritten with a new state structure or values by supplying a method to the keyword ``

1168 |
defmodule MyApp.People.Store do
1169 |   use ActiveMemory.Store,
1170 |     table: MyApp.People.Person,
1171 |     before_init: [{:run_me, ["arg1", "arg2"]}, {:run_me_too, []}]
1172 | end
1173 | 
1174 | 1175 |

Installation

1176 |

The package can be installed 1177 | by adding active_memory to your list of dependencies in mix.exs:

1178 |
def deps do
1179 |   [
1180 |     {:active_memory, "~> 0.1.0"}
1181 |   ]
1182 | end
1183 | 
1184 | 1185 |

Potential Use Cases

1186 |

There are many reasons to be leveraging the power of in memory store and the awesome tools of Mnesia and ETS in your Elixir applications.

1187 |

Storing config settings and Application secrets

1188 |

Instad of having hard coded secrets and application settings crowding your config files store them in an in memory table. Privde your application a small UI to support the secrets and settings and you can update while the application is running in a matter of seconds.

1189 |

One Time Use Tokens

1190 |

Perfect for short lived tokens such as password reset tokens, 2FA tokens, majic links (passwordless login) etc. Store the tokens and any other needed data into an ActiveMemory.Store and reduce the burden of your database and provide your users a better experience with faster responses.

1191 |

API Keys

1192 |

If your application has a small set of API Keys (ex: less than a thousand) for clients accessing your API, then store the keys along with any relavent information into an ActiveMemory.Storeand reduce the burden of your database and provide your users a better experience with faster responses.

1193 |

JWT Encryption Keys

1194 |

If your application uses JWT then you can store the keys in an ActiveMemory.Store and provide fast access for encrypting JWT’s and publishing the public keys on an endpoint so consumers can verify the tokens.

1195 |

Admin User Management

1196 |

Create an ActiveMemory.Store to manage your admins easily and safely.

1197 |

and many many many more…

-------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

ActiveMemory

2 | 3 | ## **A Simple ORM for ETS and Mnesia** 4 | 5 |

Please note!

6 |

This is still a work in progess and feedback is appreciated

7 | 8 | ## Overview 9 | 10 | A package to help bring the power of in memory storage with ETS and Mnesia to your Elixir application. 11 | 12 | ActiveMemory provides a simple interface and configuration which abstracts the ETS and Mnesia specifics and provides a common interface called a `Store`. 13 | 14 | ## Example setup 15 | 1. Define a `Table` with attributes. 16 | 2. Define a `Store` with configuration settings or accept the defaults (most applications should be fine with defaults). 17 | 3. Add the `Store` to your application supervision tree. 18 | 19 | Your app is ready! 20 | 21 | Example Table: 22 | ```elixir 23 | defmodule MyApp.People.Person do 24 | use ActiveMemory.Table, 25 | options: [index: [:last, :cylon?]] 26 | 27 | attributes do 28 | field(:email) 29 | field(:first) 30 | field(:last) 31 | field(:hair_color) 32 | field(:age) 33 | field(:cylon?) 34 | end 35 | end 36 | 37 | There is also optional auto-generation of uuid 38 | 39 | attributes auto_generate_uuid: true do 40 | field(:email) 41 | field(:first) 42 | field(:last) 43 | field(:hair_color) 44 | field(:age) 45 | field(:cylon?) 46 | end 47 | ``` 48 | Example Mnesia Store (default): 49 | ```elixir 50 | defmodule MyApp.People.Store do 51 | use ActiveMemory.Store, 52 | table: MyApp.People.Person 53 | end 54 | ``` 55 | Example ETS Store: 56 | ```elixir 57 | defmodule MyApp.People.Store do 58 | use ActiveMemory.Store, 59 | table: MyApp.People.Person, 60 | type: :ets 61 | end 62 | ``` 63 | 64 | Add the `Store` to your application supervision tree: 65 | ```elixir 66 | defmodule MyApp.Application do 67 | # code.. 68 | def start(_type, _args) do 69 | children = [ 70 | # other children 71 | MyApp.People.Store, 72 | # other children 73 | ] 74 | # code.. 75 | end 76 | end 77 | ``` 78 | 79 | Now you have the default `Store` methods available! 80 | 81 | ## Store API 82 | - `Store.all/0` Get all records stored 83 | - `Store.delete/1` Delete the record provided 84 | - `Store.delete_all/0` Delete all records stored 85 | - `Store.one/1` Get one record matching either an attributes search or `match` query 86 | - `Store.select/1` Get all records matching either an attributes search or `match` query 87 | - `Store.withdraw/1` Get one record matching either an attributes search or `match` query, delete the record and return it 88 | - `Store.write/1` Write a record into the memmory table 89 | 90 | ## Query interface 91 | There are two different query types available to help make finding the records in your store easier. 92 | ### The Attribute query syntax 93 | Attribute matching allows you to provide a map of attributes to search by. 94 | ```elixir 95 | Store.one(%{uuid: "a users uuid"}) 96 | Store.select(%{department: "accounting", admin?: false, active: true}) 97 | ``` 98 | ### The `match` query syntax 99 | Using the `match` macro you can structure a basic query. 100 | ```elixir 101 | query = match(:department == "sales" or :department == "marketing" and :start_date > last_month) 102 | Store.select(query) 103 | ``` 104 | ## Seeding 105 | When starting a `Store` there is an option to provide a valid seed file and have the `Store` auto load seeds contained in the file. 106 | ```elixir 107 | defmodule MyApp.People.Store do 108 | use ActiveMemory.Store, 109 | table: MyApp.People.Person, 110 | seed_file: Path.expand("person_seeds.exs", __DIR__) 111 | end 112 | ``` 113 | 114 | ## Before `init` 115 | All stores are `GenServers` and have `init` functions. While those are abstracted you can still specify methods to run during the `init` phase of the GenServer startup. Use the `before_init` keyword and add the methods as tuples with the arguments. 116 | ```elixir 117 | defmodule MyApp.People.Store do 118 | use ActiveMemory.Store, 119 | table: MyApp.People.Person, 120 | before_init: [{:run_me, ["arg1", "arg2", ...]}, {:run_me_too, []}] 121 | end 122 | ``` 123 | 124 | ## Initial State 125 | All stores are `GenServers` and thus have a state. The default state is an array as such: 126 | ```elixir 127 | %{started_at: "date time when first started", table_name: MyApp.People.Store} 128 | ``` 129 | This default state can be overwritten with a new state structure or values by supplying a method and arguments as a tuple to the keyword `initial_state`. 130 | 131 | ```elixir 132 | defmodule MyApp.People.Store do 133 | use ActiveMemory.Store, 134 | table: MyApp.People.Person, 135 | initial_state: {:initial_state_method, ["arg1", "arg2", ...]} 136 | end 137 | ``` 138 | 139 | ## Installation 140 | 141 | The package can be installed 142 | by adding `active_memory` to your list of dependencies in `mix.exs`: 143 | 144 | ```elixir 145 | def deps do 146 | [ 147 | {:active_memory, "~> 0.2.1"} 148 | ] 149 | end 150 | ``` 151 | 152 | Check out the ([documentation](https://hex.pm/packages/active_memory)) 153 | 154 | ## Potential Use Cases 155 | There are many reasons to be leveraging the power of in memory store and the awesome tools of [Mnesia](https://www.erlang.org/doc/man/mnesia.html) and [ETS](https://www.erlang.org/doc/man/ets.html) in your Elixir applications. 156 | 157 | ### Storing config settings and Application secrets 158 | Instead of having hard coded secrets and application settings crowding your config files store them in an in memory table. Provide your application a small UI to support the secrets and settings and you can update while the application is running in a matter of seconds. 159 | 160 | ### One Time Use Tokens 161 | Perfect for short lived tokens such as password reset tokens, 2FA tokens, magic links (password less login) etc. Store the tokens along with any other needed data into an `ActiveMemory.Store` to reduce the burden of your database and provide your users a better experience with faster responses. 162 | 163 | ### API Keys for clients 164 | For applications which have a fixed set of API Keys or a relativly small set of API keys (less than a few thousand). Store the keys along with any relevent information into an `ActiveMemory.Store` to reduce the burden of your database and provide your users a better experience with faster responses. 165 | 166 | ### JWT Encryption Keys 167 | Applications using JWT's can store the keys in an `ActiveMemory.Store` and provide fast access for encrypting JWT's and fast access for publishing the public keys on an endpoint for token verification by consuming clients. 168 | 169 | ### Admin User Management 170 | Create an `ActiveMemory.Store` to manage your admins easily and safely. 171 | 172 | **and many many many more...** 173 | 174 | ## Demo Application 175 | The following Repo is a demo application using ActiveMemory and MnesiaManager concept. 176 | - [BeamDemo](https://github.com/SullysMustyRuby/BeamDemo) 177 | - [MnesiaManager](https://github.com/SullysMustyRuby/ActiveMemoryManager) 178 | 179 | ## Planned Enhancements 180 | - Allow pass through `:ets` and `mnesia` options for table creation 181 | - Allow pass through `:ets` and `mnesia` syntax for searches 182 | - Mnesia co-ordination with Docker instance for backup and disk persistance 183 | - Enhance `match` query syntax 184 | - Select option for certain fields 185 | - Group results 186 | 187 | Any suggestions appreciated. 188 | -------------------------------------------------------------------------------- /lib/active_memory.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory do 2 | @moduledoc """ 3 | Bring the power of in memory storage with ETS and Mnesia to your Elixir application. 4 | 5 | ActiveMemory provides a simple interface and configuration which abstracts the ETS and Mnesia specifics and provides a common interface called a `Store`. 6 | 7 | ## Example setup 8 | 1. Define a `Table` with attributes. 9 | 2. Define a `Store` with configuration settings or accept the defaults (most applications should be fine with defaults). 10 | 3. Add the `Store` to your application supervision tree. 11 | 12 | Your app is ready! 13 | 14 | Example Table: 15 | ```elixir 16 | defmodule Test.Support.People.Person do 17 | use ActiveMemory.Table, 18 | options: [index: [:last, :cylon?]] 19 | 20 | attributes do 21 | field :email 22 | field :first 23 | field :last 24 | field :hair_color 25 | field :age 26 | field :cylon? 27 | end 28 | end 29 | ``` 30 | Example Mnesia Store (default): 31 | ```elixir 32 | defmodule MyApp.People.Store do 33 | use ActiveMemory.Store, 34 | table: MyApp.People.Person 35 | end 36 | ``` 37 | Example ETS Store: 38 | ```elixir 39 | defmodule MyApp.People.Store do 40 | use ActiveMemory.Store, 41 | type: :ets 42 | end 43 | ``` 44 | 45 | Add the `Store` to your application supervision tree: 46 | ```elixir 47 | defmodule MyApp.Application do 48 | # code.. 49 | def start(_type, _args) do 50 | children = [ 51 | # other children 52 | MyApp.People.Store, 53 | # other children 54 | ] 55 | # code.. 56 | end 57 | ``` 58 | 59 | """ 60 | end 61 | -------------------------------------------------------------------------------- /lib/adapters/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Adapter do 2 | @moduledoc false 3 | 4 | @callback all(atom()) :: list(map()) 5 | 6 | @callback create_table(atom(), map()) :: :ok | {:error, any()} 7 | 8 | @callback delete(map(), atom()) :: :ok | {:error, any()} 9 | 10 | @callback delete_all(atom()) :: :ok | {:error, any()} 11 | 12 | @callback one(map(), atom()) :: {:ok, map()} | {:error, any()} 13 | 14 | @callback one(list(any()), atom()) :: {:ok, map()} | {:error, any()} 15 | 16 | @callback select(map(), atom()) :: {:ok, list(map())} | {:error, any()} 17 | 18 | @callback select(list(any()), atom()) :: {:ok, list(map())} | {:error, any()} 19 | 20 | @callback write(map(), atom()) :: {:ok, map()} | {:error, any()} 21 | end 22 | -------------------------------------------------------------------------------- /lib/adapters/ets.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Ets do 2 | @moduledoc false 3 | 4 | alias ActiveMemory.Adapters.Adapter 5 | alias ActiveMemory.Adapters.Ets.Helpers 6 | alias ActiveMemory.Query.{MatchGuards, MatchSpec} 7 | 8 | @behaviour Adapter 9 | 10 | def all(table) do 11 | :ets.tab2list(table) 12 | |> Task.async_stream(fn record -> to_struct(record, table) end) 13 | |> Enum.into([], fn {:ok, struct} -> struct end) 14 | end 15 | 16 | def create_table(table, _options) do 17 | options = table.__attributes__(:table_options) 18 | 19 | try do 20 | :ets.new(table, [:named_table | options]) 21 | :ok 22 | rescue 23 | ArgumentError -> {:error, :create_table_failed} 24 | end 25 | end 26 | 27 | def delete(struct, table) do 28 | with ets_tuple when is_tuple(ets_tuple) <- to_tuple(struct), 29 | true <- :ets.delete_object(table, ets_tuple) do 30 | :ok 31 | else 32 | _ -> {:error, :delete_failure} 33 | end 34 | end 35 | 36 | def delete_all(table) do 37 | :ets.delete_all_objects(table) 38 | end 39 | 40 | def one(query_map, table) when is_map(query_map) do 41 | with {:ok, query} <- MatchGuards.build(table, query_map), 42 | [record | []] when is_tuple(record) <- match_query(query, table) do 43 | {:ok, to_struct(record, table)} 44 | else 45 | [] -> {:ok, nil} 46 | records when is_list(records) -> {:error, :more_than_one_result} 47 | {:error, message} -> {:error, message} 48 | end 49 | end 50 | 51 | def one(query, table) when is_tuple(query) do 52 | with [record | []] when is_tuple(record) <- select_query(query, table) do 53 | {:ok, to_struct(record, table)} 54 | else 55 | [] -> {:ok, nil} 56 | records when is_list(records) -> {:error, :more_than_one_result} 57 | {:error, message} -> {:error, message} 58 | end 59 | end 60 | 61 | def select(query_map, table) when is_map(query_map) do 62 | with {:ok, query} <- MatchGuards.build(table, query_map), 63 | records when is_list(records) <- match_query(query, table) do 64 | {:ok, to_struct(records, table)} 65 | else 66 | [] -> {:ok, []} 67 | {:error, message} -> {:error, message} 68 | end 69 | end 70 | 71 | def select(query, table) when is_tuple(query) do 72 | with records when is_list(records) <- select_query(query, table) do 73 | {:ok, to_struct(records, table)} 74 | else 75 | [] -> {:ok, []} 76 | {:error, message} -> {:error, message} 77 | end 78 | end 79 | 80 | def write(struct, table) do 81 | with ets_tuple when is_tuple(ets_tuple) <- 82 | to_tuple(struct), 83 | true <- :ets.insert(table, ets_tuple) do 84 | {:ok, struct} 85 | else 86 | false -> {:error, :write_fail} 87 | {:error, message} -> {:error, message} 88 | end 89 | end 90 | 91 | defp match_query(query, table) do 92 | :ets.match_object(table, query) 93 | end 94 | 95 | defp select_query(query, table) do 96 | query_map = :erlang.apply(table, :__attributes__, [:query_map]) 97 | match_head = :erlang.apply(table, :__attributes__, [:match_head]) 98 | 99 | match_query = MatchSpec.build(query, query_map, match_head) 100 | :ets.select(table, match_query) 101 | end 102 | 103 | defp to_struct(records, table) when is_list(records) do 104 | Enum.into(records, [], fn record -> to_struct(record, table) end) 105 | end 106 | 107 | defp to_struct(record, table) when is_tuple(record), do: Helpers.to_struct(record, table) 108 | 109 | defp to_tuple(record), do: Helpers.to_tuple(record) 110 | end 111 | -------------------------------------------------------------------------------- /lib/adapters/ets/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Ets.Helpers do 2 | @moduledoc false 3 | 4 | @types [:set, :ordered_set, :bag, :duplicate_bag] 5 | @access [:public, :protected, :private] 6 | @default_options [type: :set, access: :public] 7 | 8 | def build_match_head(query_map) do 9 | query_map 10 | |> Enum.into([], fn {_key, value} -> value end) 11 | |> List.to_tuple() 12 | end 13 | 14 | def build_options(:defaults), do: [:set, :public] 15 | 16 | def build_options(options) do 17 | @default_options 18 | |> Keyword.merge(options) 19 | |> Enum.into([], fn {key, value} -> validate_option(key, value) end) 20 | |> Enum.reject(&is_nil/1) 21 | end 22 | 23 | def to_struct(tuple, module) when is_tuple(tuple), 24 | do: struct(module, build_struct(module.__attributes__(:query_fields), tuple)) 25 | 26 | def to_tuple(%{__struct__: module} = struct) do 27 | module.__attributes__(:query_fields) 28 | |> Enum.into([], fn key -> Map.get(struct, key) end) 29 | |> List.to_tuple() 30 | end 31 | 32 | defp build_struct(attributes, tuple) do 33 | attributes 34 | |> Enum.with_index(fn element, index -> {element, elem(tuple, index)} end) 35 | |> Enum.into(%{}) 36 | end 37 | 38 | defp validate_option(:type, type) do 39 | case Enum.member?(@types, type) do 40 | true -> type 41 | false -> hd(@types) 42 | end 43 | end 44 | 45 | defp validate_option(:access, access) do 46 | case Enum.member?(@access, access) do 47 | true -> access 48 | false -> hd(@access) 49 | end 50 | end 51 | 52 | defp validate_option(:decentralized_counters, true), 53 | do: {:decentralized_counters, true} 54 | 55 | defp validate_option(:compressed, true), 56 | do: :compressed 57 | 58 | defp validate_option(:read_concurrency, true), 59 | do: {:read_concurrency, true} 60 | 61 | defp validate_option(:write_concurrency, true), 62 | do: {:write_concurrency, true} 63 | 64 | defp validate_option(:write_concurrency, :auto), 65 | do: {:write_concurrency, :auto} 66 | 67 | defp validate_option(_key, _value), do: nil 68 | end 69 | -------------------------------------------------------------------------------- /lib/adapters/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Helpers do 2 | @moduledoc false 3 | 4 | alias ActiveMemory.Adapters.{Ets, Mnesia} 5 | alias ActiveMemory.Adapters.Ets.Helpers, as: EtsHelpers 6 | alias ActiveMemory.Adapters.Mnesia.Helpers, as: MnesiaHelpers 7 | 8 | def build_match_head(query_map, _table_name, Ets) do 9 | EtsHelpers.build_match_head(query_map) 10 | end 11 | 12 | def build_match_head(query_map, table_name, Mnesia) do 13 | MnesiaHelpers.build_match_head(query_map, table_name) 14 | end 15 | 16 | def build_options(options, :ets), do: EtsHelpers.build_options(options) 17 | 18 | def build_options(options, :mnesia), do: MnesiaHelpers.build_options(options) 19 | 20 | def build_query_map(struct_attrs) do 21 | Enum.with_index(struct_attrs, fn element, index -> 22 | {strip_defaults(element), :"$#{index + 1}"} 23 | end) 24 | end 25 | 26 | def set_adapter(:ets), do: Ets 27 | 28 | def set_adapter(:mnesia), do: Mnesia 29 | 30 | defp strip_defaults(element) when is_atom(element), do: element 31 | 32 | defp strip_defaults({element, _defaults}) when is_atom(element), do: element 33 | end 34 | -------------------------------------------------------------------------------- /lib/adapters/mnesia.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Mnesia do 2 | @moduledoc false 3 | 4 | alias ActiveMemory.Adapters.Adapter 5 | alias ActiveMemory.Adapters.Mnesia.{Helpers, Migration} 6 | alias ActiveMemory.Query.{MatchGuards, MatchSpec} 7 | 8 | @behaviour Adapter 9 | 10 | def all(table) do 11 | case match_object(:mnesia.table_info(table, :wild_pattern)) do 12 | {:atomic, []} -> [] 13 | {:atomic, records} -> Enum.into(records, [], &to_struct(&1, table)) 14 | {:error, message} -> {:error, message} 15 | end 16 | end 17 | 18 | def create_table(table, _options) do 19 | options = 20 | [attributes: table.__attributes__(:query_fields)] 21 | |> Keyword.merge(table.__attributes__(:table_options)) 22 | 23 | case :mnesia.create_table(table, options) do 24 | {:atomic, :ok} -> 25 | :mnesia.wait_for_tables([table], 5000) 26 | 27 | {:aborted, {:not_active, _table, new_node}} -> 28 | :mnesia.change_config(:extra_db_nodes, [new_node]) 29 | create_table(table, options) 30 | 31 | {:aborted, {:already_exists, _table}} -> 32 | Migration.migrate_table_options(table) 33 | 34 | {:aborted, {:already_exists, _table, _node}} -> 35 | Migration.migrate_table_options(table) 36 | 37 | {:error, message} -> 38 | {:error, message} 39 | end 40 | end 41 | 42 | def delete(struct, table) do 43 | case delete_object(struct, table) do 44 | {:atomic, :ok} -> :ok 45 | {:error, message} -> {:error, message} 46 | end 47 | end 48 | 49 | def delete_all(table) do 50 | :mnesia.clear_table(table) 51 | end 52 | 53 | def one(query_map, table) when is_map(query_map) do 54 | with {:ok, query} <- MatchGuards.build(table, query_map), 55 | match_query <- Tuple.insert_at(query, 0, table), 56 | {:atomic, record} when length(record) == 1 <- match_object(match_query) do 57 | {:ok, to_struct(hd(record), table)} 58 | else 59 | {:atomic, []} -> {:ok, nil} 60 | {:atomic, records} when is_list(records) -> {:error, :more_than_one_result} 61 | {:error, message} -> {:error, message} 62 | end 63 | end 64 | 65 | def one(query, table) when is_tuple(query) do 66 | with match_spec = build_mnesia_match_spec(query, table), 67 | {:atomic, record} when length(record) == 1 <- select_object(match_spec, table) do 68 | {:ok, to_struct(hd(record), table)} 69 | else 70 | {:atomic, []} -> {:ok, nil} 71 | {:atomic, records} when is_list(records) -> {:error, :more_than_one_result} 72 | {:error, message} -> {:error, message} 73 | end 74 | end 75 | 76 | def select(query_map, table) when is_map(query_map) do 77 | with {:ok, query} <- MatchGuards.build(table, query_map), 78 | match_query <- Tuple.insert_at(query, 0, table), 79 | {:atomic, records} when is_list(records) <- match_object(match_query) do 80 | {:ok, to_struct(records, table)} 81 | else 82 | {:atomic, []} -> {:ok, []} 83 | {:error, message} -> {:error, message} 84 | end 85 | end 86 | 87 | def select(query, table) when is_tuple(query) do 88 | with match_spec = build_mnesia_match_spec(query, table), 89 | {:atomic, records} when records != [] <- select_object(match_spec, table) do 90 | {:ok, to_struct(records, table)} 91 | else 92 | {:atomic, []} -> {:ok, []} 93 | {:error, message} -> {:error, message} 94 | end 95 | end 96 | 97 | def write(struct, table) do 98 | case write_object(to_tuple(struct), table) do 99 | {:atomic, :ok} -> {:ok, struct} 100 | {:error, message} -> {:error, message} 101 | end 102 | end 103 | 104 | defp delete_object(struct, table) do 105 | :mnesia.transaction(fn -> 106 | :mnesia.delete_object(table, to_tuple(struct), :write) 107 | end) 108 | end 109 | 110 | defp match_object(query) do 111 | :mnesia.transaction(fn -> 112 | :mnesia.match_object(query) 113 | end) 114 | end 115 | 116 | defp select_object(match_spec, table) do 117 | :mnesia.transaction(fn -> 118 | :mnesia.select(table, match_spec, :read) 119 | end) 120 | end 121 | 122 | defp write_object(object, table) do 123 | :mnesia.transaction(fn -> 124 | :mnesia.write(table, object, :write) 125 | end) 126 | end 127 | 128 | defp build_mnesia_match_spec(query, table) do 129 | query_map = :erlang.apply(table, :__attributes__, [:query_map]) 130 | match_head = :erlang.apply(table, :__attributes__, [:match_head]) 131 | 132 | MatchSpec.build(query, query_map, match_head) 133 | end 134 | 135 | defp to_struct(records, table) when is_list(records) do 136 | Enum.into(records, [], &to_struct(&1, table)) 137 | end 138 | 139 | defp to_struct(record, table) when is_tuple(record), 140 | do: Helpers.to_struct(record, table) 141 | 142 | defp to_tuple(struct), do: Helpers.to_tuple(struct) 143 | end 144 | -------------------------------------------------------------------------------- /lib/adapters/mnesia/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Mnesia.Helpers do 2 | @moduledoc false 3 | 4 | @types [:set, :ordered_set, :bag] 5 | 6 | def build_match_head(query_map, table_name) do 7 | query_map 8 | |> Enum.into([], fn {_key, value} -> value end) 9 | |> List.to_tuple() 10 | |> Tuple.insert_at(0, table_name) 11 | end 12 | 13 | def build_options(:defaults), do: [] 14 | 15 | def build_options(options) do 16 | options 17 | |> Enum.into([], fn {key, value} -> validate_option(key, value) end) 18 | |> Enum.reject(&is_nil/1) 19 | end 20 | 21 | def to_struct(tuple, module) when is_tuple(tuple), 22 | do: 23 | struct( 24 | module, 25 | build_struct(module.__attributes__(:query_fields), Tuple.delete_at(tuple, 0)) 26 | ) 27 | 28 | def to_tuple(%{__struct__: module} = struct) do 29 | module.__attributes__(:query_fields) 30 | |> Enum.into([], fn key -> Map.get(struct, key) end) 31 | |> List.to_tuple() 32 | |> Tuple.insert_at(0, module) 33 | end 34 | 35 | defp build_struct(attributes, tuple) do 36 | attributes 37 | |> Enum.with_index(fn element, index -> {element, elem(tuple, index)} end) 38 | |> Enum.into(%{}) 39 | end 40 | 41 | defp validate_option(:access_mode, :read_only), do: {:access_mode, :read_only} 42 | 43 | defp validate_option(:access_mode, :read_write), do: {:access_mode, :read_write} 44 | 45 | defp validate_option(:disc_copies, node_list) when is_list(node_list), 46 | do: {:disc_copies, node_list} 47 | 48 | defp validate_option(:disc_only_copies, node_list) when is_list(node_list), 49 | do: {:disc_only_copies, node_list} 50 | 51 | defp validate_option(:index, index_list) when is_list(index_list), 52 | do: {:index, index_list} 53 | 54 | defp validate_option(:load_order, integer) when is_integer(integer) and integer >= 0, 55 | do: {:load_order, integer} 56 | 57 | defp validate_option(:majority, false), do: {:majority, false} 58 | 59 | defp validate_option(:majority, true), do: {:majority, true} 60 | 61 | defp validate_option(:ram_copies, node_list) when is_list(node_list), 62 | do: {:ram_copies, node_list} 63 | 64 | defp validate_option(:type, type) do 65 | case Enum.member?(@types, type) do 66 | true -> {:type, type} 67 | false -> {:type, hd(@types)} 68 | end 69 | end 70 | 71 | defp validate_option(_, _), do: nil 72 | end 73 | -------------------------------------------------------------------------------- /lib/adapters/mnesia/migration.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Mnesia.Migration do 2 | @moduledoc """ 3 | Migrations will get run on app startup and are designed to modify :mnesia's schema. 4 | 5 | ## Table Copies 6 | In the `options` of an ActiveMemory.Table, the copy type and nodes which should have them can be specified. 7 | 8 | ### Ram copies 9 | Tables that only reside in ram on the nodes specified. The default is `node()` 10 | Example table using default setting: 11 | ```elixir 12 | defmodule Test.Support.Dogs.Dog do 13 | use ActiveMemory.Table, 14 | options: [compressed: true, read_concurrency: true] 15 | . 16 | # module code 17 | . 18 | end 19 | ``` 20 | The default will be `[node()]` and this table will reside on the `node()` ram. 21 | Example table spcifing nodes and ram copies: 22 | ```elixir 23 | defmodule Test.Support.Dogs.Dog do 24 | use ActiveMemory.Table, 25 | options: [compressed: true, read_concurrency: true, ram_copes: [node() | Node.list()] 26 | . 27 | # module code 28 | . 29 | end 30 | ``` 31 | All the active nodes in Node.list() and node() will have ram copes of the table. 32 | 33 | ### Disc copies 34 | Disc copy tables reside **both** in ram and disc on the nodes specified. 35 | In order to persist to disc the schema must be setup on at lest one running node. 36 | The default is [] (no nodes). 37 | Example table spcifing nodes and disc copies: 38 | ```elixir 39 | defmodule Test.Support.Dogs.Dog do 40 | use ActiveMemory.Table, 41 | options: [compressed: true, read_concurrency: true, disc_copes: [node()] 42 | . 43 | # module code 44 | . 45 | end 46 | ``` 47 | The table will have a ram copy and disc copy on `node()` 48 | 49 | ### Disc only copies 50 | Disc oly tables reside **only** on disc on the nodes specified. 51 | In order to persist to disc the schema must be setup on at lest one running node. 52 | The default is [] (no nodes). 53 | Example table spcifing nodes and disc copies: 54 | ```elixir 55 | defmodule Test.Support.Dogs.Dog do 56 | use ActiveMemory.Table, 57 | options: [compressed: true, read_concurrency: true, disc_only_copes: [node()] 58 | . 59 | # module code 60 | . 61 | end 62 | ``` 63 | The table will only have a disc copy on `node()` 64 | 65 | ## Table Read and Write Access 66 | Mnesia tables can be set to `read_only` or `read_write`. The default is `read_write`. 67 | Read only tables updates cannot be performed. 68 | if you need to change the access use the following syntax: `[access_mode: :read_only]` 69 | 70 | ## Table Types 71 | Tables can be either a `:set`, `:ordered_set`, or a `:bag`. The default is `:set` 72 | if you need to change the type use the following syntax: `[type: :bag]` 73 | 74 | ## Indexes 75 | If Indexes are desired specify an atom attribute list for which Mnesia is to build and maintain an extra index table. 76 | The qlc query compiler may be able to optimize queries if there are indexes available. 77 | To specify Indexes use the following syntax: `[index: [:age, :hair_color, :cylon?]]` 78 | 79 | ## Table Load Order 80 | The load order priority is by default 0 (zero) but can be set to any integer. The tables with the highest load order priority are loaded first at startup. 81 | If you need to change the load order use the following syntax: `[load_order: 2]` 82 | 83 | ## Majority 84 | If true, any (non-dirty) update to the table is aborted, unless a majority of the table replicas are available for the commit. When used on a fragmented table, all fragments are given the same the same majority setting. 85 | If you need to modify the majority use the following syntax: `[majority: true]` 86 | """ 87 | 88 | def migrate_table_options(table) do 89 | table.__attributes__(:table_options) 90 | |> migrate_table_copies_to_add(table) 91 | |> migrate_table_copies_to_delete(table) 92 | |> migrate_access_mode(table) 93 | |> migrate_indexes(table) 94 | |> migrate_load_order(table) 95 | |> migrate_majority(table) 96 | 97 | :ok 98 | end 99 | 100 | # Supporting methods in alphabetical order 101 | defp add_copy_type([], _table, _copy_type), do: :ok 102 | 103 | defp add_copy_type(nodes, table, copy_type) do 104 | for node <- nodes do 105 | case :mnesia.add_table_copy(table, node, copy_type) do 106 | {:aborted, {:already_exists, _, _}} -> 107 | change_table_copy_type(table, node, copy_type) 108 | 109 | {:atomic, :ok} -> 110 | :ok 111 | end 112 | end 113 | 114 | :ok 115 | end 116 | 117 | defp add_copy_types(options_nodes, table, copy_type) do 118 | table 119 | |> :mnesia.table_info(copy_type) 120 | |> Enum.sort() 121 | |> compare_nodes_to_add(options_nodes) 122 | |> add_copy_type(table, copy_type) 123 | end 124 | 125 | defp add_indexes([], _table), do: nil 126 | 127 | defp add_indexes(indexes, table) do 128 | Enum.each(indexes, fn index -> :mnesia.add_table_index(table, index) end) 129 | end 130 | 131 | defp change_table_copy_type(table, node, copy_type) do 132 | case :mnesia.change_table_copy_type(table, node, copy_type) do 133 | {:atomic, :ok} -> :ok 134 | other -> other 135 | end 136 | end 137 | 138 | defp compare_nodes_to_add([], options_nodes), do: options_nodes 139 | 140 | defp compare_nodes_to_add(_current_nodes, []), do: [] 141 | 142 | defp compare_nodes_to_add(current_nodes, options_nodes) do 143 | options_nodes -- current_nodes 144 | end 145 | 146 | defp compare_nodes_to_remove([], _options_nodes), do: [] 147 | 148 | defp compare_nodes_to_remove(current_nodes, []), do: current_nodes 149 | 150 | defp compare_nodes_to_remove(current_nodes, options_nodes) do 151 | current_nodes -- options_nodes 152 | end 153 | 154 | defp copy_type_validation(_ram_nodes, [], []), do: :ok 155 | 156 | defp copy_type_validation([], _disc_nodes, []), do: :ok 157 | 158 | defp copy_type_validation([], [], _disc_only_nodes), do: :ok 159 | 160 | defp copy_type_validation([], disc_nodes, disc_only_nodes) do 161 | disc_nodes_validation(disc_nodes, disc_only_nodes) 162 | end 163 | 164 | defp copy_type_validation(ram_nodes, [], disc_only_nodes) do 165 | ram_nodes 166 | |> Enum.any?(&Enum.member?(disc_only_nodes, &1)) 167 | |> parse_check(:ram_copies) 168 | end 169 | 170 | defp copy_type_validation(ram_nodes, disc_nodes, []) do 171 | ram_nodes 172 | |> Enum.any?(&Enum.member?(disc_nodes, &1)) 173 | |> parse_check(:ram_copies) 174 | end 175 | 176 | defp copy_type_validation(ram_nodes, disc_nodes, disc_only_nodes) do 177 | with {:ok, :ram_copies} <- ram_nodes_validation(ram_nodes, disc_nodes, disc_only_nodes), 178 | {:ok, :disc_copies} <- disc_nodes_validation(disc_nodes, disc_only_nodes) do 179 | :ok 180 | end 181 | end 182 | 183 | defp delete_copy_type([], _table), do: :ok 184 | 185 | defp delete_copy_type(nodes, table) do 186 | Enum.each(nodes, &:mnesia.del_table_copy(table, &1)) 187 | end 188 | 189 | defp delete_indexes([], _table), do: nil 190 | 191 | defp delete_indexes(indexes, table) do 192 | Enum.each(indexes, fn index -> :mnesia.del_table_index(table, index) end) 193 | end 194 | 195 | defp disc_nodes_validation(disc_nodes, disc_only_nodes) do 196 | disc_nodes 197 | |> Enum.any?(&Enum.member?(disc_only_nodes, &1)) 198 | |> parse_check(:disc_copies) 199 | end 200 | 201 | defp get_indexes([], _attributes), do: [] 202 | 203 | defp get_indexes(indexes, attributes) do 204 | indexes 205 | |> Enum.map(fn index -> Enum.at(attributes, index - 2) end) 206 | end 207 | 208 | defp migrate_access_mode(options, table) do 209 | option = Keyword.get(options, :access_mode, :read_write) 210 | 211 | case :mnesia.table_info(table, :access_mode) do 212 | ^option -> :ok 213 | _ -> :mnesia.change_table_access_mode(table, option) 214 | end 215 | 216 | options 217 | end 218 | 219 | defp migrate_indexes(options, table) do 220 | new_indexes = Keyword.get(options, :index, []) 221 | indexes = :mnesia.table_info(table, :index) 222 | 223 | current_indexes = get_indexes(indexes, :mnesia.table_info(table, :attributes)) 224 | 225 | add_indexes(new_indexes -- current_indexes, table) 226 | delete_indexes(current_indexes -- new_indexes, table) 227 | options 228 | end 229 | 230 | defp migrate_load_order(options, table) do 231 | load_order = Keyword.get(options, :load_order, 0) 232 | 233 | case :mnesia.table_info(table, :load_order) do 234 | ^load_order -> :ok 235 | _ -> :mnesia.change_table_load_order(table, load_order) 236 | end 237 | 238 | options 239 | end 240 | 241 | defp migrate_majority(options, table) do 242 | majority = Keyword.get(options, :majority, false) 243 | 244 | case :mnesia.table_info(table, :majority) do 245 | ^majority -> :ok 246 | _ -> :mnesia.change_table_majority(table, majority) 247 | end 248 | 249 | options 250 | end 251 | 252 | defp migrate_table_copies_to_add(options, table) do 253 | options_disc_nodes = Keyword.get(options, :disc_copies, []) |> Enum.sort() 254 | 255 | options_ram_nodes = 256 | Keyword.get(options, :ram_copies, ram_copy_default(options_disc_nodes)) |> Enum.sort() 257 | 258 | options_disc_only_nodes = Keyword.get(options, :disc_only_copies, []) |> Enum.sort() 259 | 260 | with :ok <- 261 | copy_type_validation(options_ram_nodes, options_disc_nodes, options_disc_only_nodes), 262 | :ok <- add_copy_types(options_ram_nodes, table, :ram_copies), 263 | :ok <- add_copy_types(options_disc_nodes, table, :disc_copies), 264 | :ok <- 265 | add_copy_types( 266 | options_disc_only_nodes, 267 | table, 268 | :disc_only_copies 269 | ) do 270 | options 271 | end 272 | end 273 | 274 | defp ram_copy_default(options_disc_nodes) do 275 | case Enum.member?(options_disc_nodes, node()) do 276 | true -> [] 277 | false -> [node()] 278 | end 279 | end 280 | 281 | defp migrate_table_copies_to_delete(options, table) do 282 | with :ok <- remove_copy_types(options, table, :ram_copies, [node()]), 283 | :ok <- remove_copy_types(options, table, :disc_copies), 284 | :ok <- remove_copy_types(options, table, :disc_only_copies) do 285 | options 286 | end 287 | end 288 | 289 | defp parse_check(false, _copy_type), do: :ok 290 | 291 | defp parse_check(true, copy_type), 292 | do: {:error, "#{copy_type} options are invalid. Please read the documentation"} 293 | 294 | defp remove_copy_types(options, table, copy_type, default_nodes \\ []) do 295 | options_nodes = 296 | options 297 | |> Keyword.get(copy_type, default_nodes) 298 | |> Enum.sort() 299 | 300 | table 301 | |> :mnesia.table_info(copy_type) 302 | |> Enum.sort() 303 | |> compare_nodes_to_remove(options_nodes) 304 | |> delete_copy_type(table) 305 | end 306 | 307 | defp ram_nodes_validation(ram_nodes, disc_nodes, disc_only_nodes) do 308 | ram_nodes 309 | |> Enum.any?(&(Enum.member?(disc_nodes, &1) or Enum.member?(disc_only_nodes, &1))) 310 | |> parse_check(:ram_copies) 311 | end 312 | end 313 | -------------------------------------------------------------------------------- /lib/query/match_guards.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Query.MatchGuards do 2 | @moduledoc false 3 | 4 | def build(table, query_map) do 5 | table.__attributes__(:query_fields) 6 | |> validate_query(query_map) 7 | |> build_match_tuple(query_map) 8 | end 9 | 10 | defp validate_query(attributes, query_map) do 11 | case Enum.all?(query_map, fn {key, _value} -> Enum.member?(attributes, key) end) do 12 | true -> {:ok, attributes} 13 | false -> {:error, :query_schema_mismatch} 14 | end 15 | end 16 | 17 | defp build_match_tuple({:ok, attributes}, query_map) do 18 | query = 19 | attributes 20 | |> Enum.into([], fn key -> Map.get(query_map, key, :_) end) 21 | |> List.to_tuple() 22 | 23 | {:ok, query} 24 | end 25 | 26 | defp build_match_tuple(error, _keywords), do: error 27 | end 28 | -------------------------------------------------------------------------------- /lib/query/match_spec.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Query.MatchSpec do 2 | @moduledoc false 3 | 4 | @result [:"$_"] 5 | 6 | def build(query, query_map, match_head) do 7 | [{match_head, [reduce(query, query_map)], @result}] 8 | end 9 | 10 | defp reduce({operand, lhs, rhs}, query_map) when is_tuple(lhs) and is_tuple(rhs) do 11 | {translate(operand), reduce(lhs, query_map), reduce(rhs, query_map)} 12 | end 13 | 14 | defp reduce({operand, lhs, rhs}, query_map) when is_tuple(lhs) do 15 | {translate(operand), reduce(lhs, query_map), rhs} 16 | end 17 | 18 | defp reduce({operand, lhs, rhs}, query_map) when is_tuple(rhs) do 19 | {translate(operand), lhs, reduce(rhs, query_map)} 20 | end 21 | 22 | defp reduce({operand, attribute, value}, query_map) 23 | when is_atom(attribute) and not is_tuple(value) and is_map(query_map) do 24 | variable = Map.get(query_map, attribute, attribute) 25 | {translate(operand), variable, value} 26 | end 27 | 28 | defp reduce({operand, attribute, value}, query_map) 29 | when is_atom(attribute) and not is_tuple(value) do 30 | variable = Keyword.get(query_map, attribute, attribute) 31 | {translate(operand), variable, value} 32 | end 33 | 34 | defp reduce({atom, meta, _} = ast, _query_map) when is_atom(atom) and is_list(meta), do: ast 35 | 36 | defp translate(:<=), do: :"=<" 37 | 38 | defp translate(:!=), do: :"/=" 39 | 40 | defp translate(:===), do: :"=:=" 41 | 42 | defp translate(:!==), do: :"=/=" 43 | 44 | defp translate(operand), do: operand 45 | end 46 | -------------------------------------------------------------------------------- /lib/query/query.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Query do 2 | @moduledoc """ 3 | 4 | ## The `match` query syntax 5 | Using the `match` macro you can structure a basic query. 6 | ```elixir 7 | query = match(:department == "sales" or :department == "marketing" and :start_date > last_month) 8 | Store.select(query) 9 | ``` 10 | """ 11 | defmacro match(query) do 12 | reduce(query) 13 | end 14 | 15 | defp reduce({operand, _meta, [lhs, rhs]}) do 16 | quote do 17 | {unquote(operand), unquote(reduce(lhs)), unquote(reduce(rhs))} 18 | end 19 | end 20 | 21 | defp reduce({atom, meta, _} = ast) when is_atom(atom) and is_list(meta) do 22 | quote do 23 | unquote(ast) 24 | end 25 | end 26 | 27 | defp reduce(value) when is_atom(value), do: value 28 | 29 | defp reduce(value) when is_binary(value), do: value 30 | 31 | defp reduce(value) when is_integer(value), do: value 32 | end 33 | -------------------------------------------------------------------------------- /lib/store.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Store do 2 | @moduledoc """ 3 | # The Store 4 | 5 | ## Store API 6 | - `Store.all/0` Get all records stored 7 | - `Store.delete/1` Delete the record provided 8 | - `Store.delete_all/0` Delete all records stored 9 | - `Store.one/1` Get one record matching either an attributes search or `match` query 10 | - `Store.select/1` Get all records matching either an attributes search or `match` query 11 | - `Store.withdraw/1` Get one record matching either an attributes search or `match` query, delete the record and return it 12 | - `Store.write/1` Write a record into the memmory table 13 | 14 | ## Seeding 15 | When starting a `Store` there is an option to provide a valid seed file and have the `Store` auto load seeds contained in the file. 16 | ```elixir 17 | defmodule MyApp.People.Store do 18 | use ActiveMemory.Store, 19 | table: MyApp.People.Person, 20 | seed_file: Path.expand("person_seeds.exs", __DIR__) 21 | end 22 | ``` 23 | 24 | ## Before `init` 25 | All stores are `GenServers` and have `init` functions. While those are abstracted you can still specify methods to run during the `init` phase of the GenServer startup. Use the `before_init` keyword and add the methods as tuples with the arguments. 26 | ```elixir 27 | defmodule MyApp.People.Store do 28 | use ActiveMemory.Store, 29 | table: MyApp.People.Person, 30 | before_init: [{:run_me, ["arg1", "arg2", ...]}, {:run_me_too, []}] 31 | end 32 | ``` 33 | 34 | ## Initial State 35 | All stores are `GenServers` and thus have a state. The default state is an array as such: 36 | ```elixir 37 | %{started_at: "date time when first started", table: MyApp.People.Store} 38 | ``` 39 | This default state can be overwritten with a new state structure or values by supplying a method and arguments as a tuple to the keyword `initial_state`. 40 | 41 | ```elixir 42 | defmodule MyApp.People.Store do 43 | use ActiveMemory.Store, 44 | table: MyApp.People.Person, 45 | initial_state: {:initial_state_method, ["arg1", "arg2", ...]} 46 | end 47 | ``` 48 | """ 49 | 50 | defmacro __using__(opts) do 51 | quote do 52 | import unquote(__MODULE__) 53 | 54 | use GenServer 55 | 56 | opts = unquote(Macro.expand(opts, __CALLER__)) 57 | 58 | @table Keyword.get(opts, :table) 59 | @before_init Keyword.get(opts, :before_init, :default) 60 | @seed_file Keyword.get(opts, :seed_file, nil) 61 | @initial_state Keyword.get(opts, :initial_state, :default) 62 | 63 | def start_link(_opts \\ []) do 64 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 65 | end 66 | 67 | @impl true 68 | def init(_) do 69 | with :ok <- create_table(), 70 | {:ok, :seed_success} <- run_seeds_file(@seed_file), 71 | :ok <- before_init(@before_init), 72 | {:ok, initial_state} <- initial_state(@initial_state) do 73 | {:ok, initial_state} 74 | end 75 | end 76 | 77 | @spec all() :: list(map()) 78 | def all, do: :erlang.apply(@table.__attributes__(:adapter), :all, [@table]) 79 | 80 | def create_table do 81 | :erlang.apply(@table.__attributes__(:adapter), :create_table, [@table, []]) 82 | end 83 | 84 | @spec all() :: :ok | {:error, any()} 85 | def delete(%{__struct__: @table} = struct) do 86 | :erlang.apply(@table.__attributes__(:adapter), :delete, [struct, @table]) 87 | end 88 | 89 | def delete(nil), do: :ok 90 | 91 | def delete(_), do: {:error, :bad_schema} 92 | 93 | @spec delete_all() :: :ok | {:error, any()} 94 | def delete_all do 95 | :erlang.apply(@table.__attributes__(:adapter), :delete_all, [@table]) 96 | end 97 | 98 | @spec one(map() | list(any())) :: {:ok, map()} | {:error, any()} 99 | def one(query) do 100 | :erlang.apply(@table.__attributes__(:adapter), :one, [query, @table]) 101 | end 102 | 103 | def reload_seeds do 104 | GenServer.call(__MODULE__, :reload_seeds) 105 | end 106 | 107 | @spec select(map() | list(any())) :: {:ok, list(map())} | {:error, any()} 108 | def select(query) when is_map(query) do 109 | :erlang.apply(@table.__attributes__(:adapter), :select, [query, @table]) 110 | end 111 | 112 | def select({_operand, _lhs, _rhs} = query) do 113 | :erlang.apply(@table.__attributes__(:adapter), :select, [query, @table]) 114 | end 115 | 116 | def select(_), do: {:error, :bad_select_query} 117 | 118 | def state do 119 | GenServer.call(__MODULE__, :state) 120 | end 121 | 122 | @spec withdraw(map() | list(any())) :: {:ok, map()} | {:error, any()} 123 | def withdraw(query) do 124 | with {:ok, %{} = record} <- one(query), 125 | :ok <- delete(record) do 126 | {:ok, record} 127 | else 128 | {:ok, nil} -> {:ok, nil} 129 | {:error, message} -> {:error, message} 130 | end 131 | end 132 | 133 | @spec write(map()) :: {:ok, map()} | {:error, any()} 134 | def write(%@table{} = struct) do 135 | case Map.has_key?(struct, :uuid) do 136 | true -> write_with_uuid(struct) 137 | false -> normal_write(struct) 138 | end 139 | end 140 | 141 | def write(_), do: {:error, :bad_schema} 142 | 143 | defp write_with_uuid(%@table{} = struct) do 144 | case Map.get(struct, :uuid) do 145 | nil -> 146 | with_uuid = Map.put(struct, :uuid, UUID.uuid4()) 147 | :erlang.apply(@table.__attributes__(:adapter), :write, [with_uuid, @table]) 148 | 149 | uuid when is_binary(uuid) -> 150 | :erlang.apply(@table.__attributes__(:adapter), :write, [struct, @table]) 151 | end 152 | end 153 | 154 | def normal_write(%@table{} = struct) do 155 | :erlang.apply(@table.__attributes__(:adapter), :write, [struct, @table]) 156 | end 157 | 158 | @impl true 159 | def handle_call(:reload_seeds, _from, state) do 160 | {:reply, run_seeds_file(@seed_file), state} 161 | end 162 | 163 | @impl true 164 | def handle_call(:state, _from, state), do: {:reply, state, state} 165 | 166 | defp before_init(:default), do: :ok 167 | 168 | defp before_init({method, args}) when is_list(args) do 169 | :erlang.apply(__MODULE__, method, args) 170 | end 171 | 172 | defp before_init(methods) when is_list(methods) do 173 | methods 174 | |> Enum.into([], &before_init(&1)) 175 | |> Enum.all?(&(&1 == :ok)) 176 | |> case do 177 | true -> :ok 178 | _ -> {:error, :before_init_failure} 179 | end 180 | end 181 | 182 | defp initial_state(:default) do 183 | {:ok, 184 | %{ 185 | started_at: DateTime.utc_now(), 186 | table_name: @table 187 | }} 188 | end 189 | 190 | defp initial_state({method, args}) do 191 | :erlang.apply(__MODULE__, method, args) 192 | end 193 | 194 | defp run_seeds_file(nil), do: {:ok, :seed_success} 195 | 196 | defp run_seeds_file(file) when is_binary(file) do 197 | with {seeds, _} when is_list(seeds) <- Code.eval_file(@seed_file), 198 | true <- write_seeds(seeds) do 199 | {:ok, :seed_success} 200 | else 201 | {:error, message} -> {:error, message} 202 | _ -> {:error, :seed_failure} 203 | end 204 | end 205 | 206 | defp write_seeds(seeds) do 207 | seeds 208 | |> Task.async_stream(&write(&1)) 209 | |> Enum.all?(fn {:ok, {result, _seed}} -> result == :ok end) 210 | end 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/table.ex: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Table do 2 | @moduledoc """ 3 | 4 | Define your table attributes and options. 5 | 6 | Example Table (without auto generated uuid): 7 | ```elixir 8 | defmodule Test.Support.People.Person do 9 | use ActiveMemory.Table, 10 | options: [index: [:last, :cylon?]] 11 | 12 | attributes do 13 | field :email 14 | field :first 15 | field :last 16 | field :hair_color 17 | field :age 18 | field :cylon? 19 | end 20 | end 21 | ``` 22 | ### Auto Generated UUID 23 | A table can have an auto generated UUID. Specify the option `auto_generate_uuid: true` in the 24 | attributes as an option. 25 | 26 | Example Table with auto generated uuid: 27 | ```elixir 28 | defmodule Test.Support.People.Person do 29 | use ActiveMemory.Table, 30 | options: [index: [:last, :cylon?]] 31 | 32 | attributes auto_generate_uuid: true do 33 | field :email 34 | field :first 35 | field :last 36 | field :hair_color 37 | field :age 38 | field :cylon? 39 | end 40 | end 41 | ``` 42 | 43 | ## Options when creating tables 44 | `ActiveMemory.Table` support almost all of the same options as `:ets` and `:mneisia`. 45 | Please be aware that the options are different for `:ets` and `:mneisia`. 46 | Further reading can be found with [ETS docs](https://www.erlang.org/doc/man/ets.html) and [Mnesia docs](https://www.erlang.org/doc/man/mnesia.html). 47 | All options should be structured as a [Keyword list](https://hexdocs.pm/elixir/1.12/Keyword.html). 48 | 49 | Example: 50 | ```elixir 51 | use ActiveMemory.Table, 52 | type: :ets, 53 | options: [compressed: true, read_concurrency: true, type: :protected] 54 | ``` 55 | 56 | ### Mnesia Options 57 | #### Table Read and Write Access 58 | Mnesia tables can be set to `read_only` or `read_write`. The default is `read_write`. 59 | Read only tables updates cannot be performed. 60 | if you need to change the access use the following syntax: `[access_mode: :read_only]` 61 | 62 | #### Table Types 63 | Tables can be either a `:set`, `:ordered_set`, or a `:bag`. The default is `:set` 64 | if you need to change the type use the following syntax: `[type: :bag]` 65 | 66 | #### Disk Copies 67 | A list of nodes can be specified to maintain disk copies of the table. Nodes specified will recieve a replica of the table. Disk copy talbes still maintain a ram copy of the table as well. 68 | By default all tables are `ram_copies` and no `disc_copies` are specified. 69 | if you need to specify nodes use following syntax: `[disc_copies: [node1, node2, node3, ...]]` 70 | 71 | #### Disk Only Copies 72 | A list of nodes can be specified to maintain only disk copies. A disc only table replica is kept on disc only and unlike the other replica types, the contents of the replica do not reside in RAM. These replicas are considerably slower than replicas held in RAM. 73 | if you need to specify nodes use following syntax: `[disc_only_copies: [node1, node2, node3, ...]]` 74 | 75 | #### Ram Copies 76 | A list of nodes can be specified to maintain ram copies of the table. Nodes specified will recieve a replica of the table. 77 | By default all tables are set to ram_copies: `[ram_copies: [node()]]` 78 | if you need to specify nodes use following syntax: `[ram_copies: [node1, node2, node3, ...]]` 79 | 80 | #### Indexes 81 | If Indexes are desired specify an atom attribute list for which Mnesia is to build and maintain an extra index table. 82 | The qlc query compiler may be able to optimize queries if there are indexes available. 83 | To specify Indexes use the following syntax: `[index: [:age, :hair_color, :cylon?]]` 84 | 85 | #### Table Load Order 86 | The load order priority is by default 0 (zero) but can be set to any integer. The tables with the highest load order priority are loaded first at startup. 87 | If you need to change the load order use the following syntax: `[load_order: 2]` 88 | 89 | #### Majority 90 | If true, any (non-dirty) update to the table is aborted, unless a majority of the table replicas are available for the commit. When used on a fragmented table, all fragments are given the same the same majority setting. 91 | If you need to modify the majority use the following syntax: `[majority: true]` 92 | 93 | ### ETS Options 94 | #### Table Access 95 | Access options are: `:public` `:protected` or `:private`. The default access is `:public` 96 | if you need to change the access use the following syntax: `[access: :private]` 97 | 98 | #### Table Types 99 | Tables can be either a `:set`, `:ordered_set`, `:bag`, or a `:duplicate_bag`. The default is `:set` 100 | if you need to change the type use the following syntax: `[type: :bag]` 101 | 102 | #### Compression 103 | Compression can be used to help shrink the size of the memory the data consumes, however this does mean the access is slower. 104 | The default is `false` where no compression happens. 105 | if you need to change the compression use the following syntax: `[Compression: true]` 106 | 107 | #### Read Concurrency 108 | From ETS documentation: 109 | Performance tuning. Defaults to `false`. When set to true, the table is optimized for concurrent read operations. When this option is enabled read operations become much cheaper; especially on systems with multiple physical processors. However, switching between read and write operations becomes more expensive. 110 | You typically want to enable this option when concurrent read operations are much more frequent than write operations, or when concurrent reads and writes comes in large read and write bursts (that is, many reads not interrupted by writes, and many writes not interrupted by reads). 111 | You typically do not want to enable this option when the common access pattern is a few read operations interleaved with a few write operations repeatedly. In this case, you would get a performance degradation by enabling this option. 112 | Option read_concurrency can be combined with option write_concurrency. You typically want to combine these when large concurrent read bursts and large concurrent write bursts are common. 113 | if you need to change the read_concurrency use the following syntax: `[read_concurrency: true]` 114 | 115 | #### Write Concurrency 116 | From ETS documentation: 117 | Performance tuning. Defaults to `false`, in which case an operation that mutates (writes to) the table obtains exclusive access, blocking any concurrent access of the same table until finished. If set to true, the table is optimized for concurrent write access. Different objects of the same table can be mutated (and read) by concurrent processes. This is achieved to some degree at the expense of memory consumption and the performance of sequential access and concurrent reading. 118 | The auto alternative for the write_concurrency option is similar to the true option but automatically adjusts the synchronization granularity during runtime depending on how the table is used. This is the recommended write_concurrency option when using Erlang/OTP 25 and above as it performs well in most scenarios. 119 | The write_concurrency option can be combined with the options read_concurrency and decentralized_counters. You typically want to combine write_concurrency with read_concurrency when large concurrent read bursts and large concurrent write bursts are common; for more information, see option read_concurrency. It is almost always a good idea to combine the write_concurrency option with the decentralized_counters option. 120 | Notice that this option does not change any guarantees about atomicity and isolation. Functions that makes such promises over many objects (like insert/2) gain less (or nothing) from this option. 121 | The memory consumption inflicted by both write_concurrency and read_concurrency is a constant overhead per table for set, bag and duplicate_bag when the true alternative for the write_concurrency option is not used. For all tables with the auto alternative and ordered_set tables with true alternative the memory overhead depends on the amount of actual detected concurrency during runtime. The memory overhead can be especially large when both write_concurrency and read_concurrency are combined. 122 | if you need to change the write_concurrency use the following syntax: `[write_concurrency: true]` or `[write_concurrency: :auto]` 123 | 124 | #### Decentralized Counters 125 | From ETS documentation: 126 | Performance tuning. Defaults to true for all tables with the write_concurrency option set to auto. For tables of type ordered_set the option also defaults to true when the write_concurrency option is set to true. The option defaults to false for all other configurations. This option has no effect if the write_concurrency option is set to false. 127 | When this option is set to true, the table is optimized for frequent concurrent calls to operations that modify the tables size and/or its memory consumption (e.g., insert/2 and delete/2). The drawback is that calls to info/1 and info/2 with size or memory as the second argument can get much slower when the decentralized_counters option is turned on. 128 | When this option is enabled the counters for the table size and memory consumption are distributed over several cache lines and the scheduling threads are mapped to one of those cache lines. The erl option +dcg can be used to control the number of cache lines that the counters are distributed over. 129 | if you need to change the decentralized_counters use the following syntax: `[decentralized_counters: true]` 130 | 131 | """ 132 | alias ActiveMemory.Adapters.Helpers 133 | 134 | defmacro __using__(opts) do 135 | quote do 136 | import ActiveMemory.Table, only: [attributes: 1, attributes: 2] 137 | 138 | Module.register_attribute(__MODULE__, :active_memory_fields, accumulate: true) 139 | Module.register_attribute(__MODULE__, :active_memory_query_fields, accumulate: true) 140 | Module.register_attribute(__MODULE__, :active_memory_field_sources, accumulate: true) 141 | 142 | opts = unquote(Macro.expand(opts, __CALLER__)) 143 | 144 | table_type = Keyword.get(opts, :type, :mnesia) 145 | 146 | table_options = Keyword.get(opts, :options, :defaults) 147 | 148 | Module.put_attribute(__MODULE__, :adapter, Helpers.set_adapter(table_type)) 149 | 150 | Module.put_attribute( 151 | __MODULE__, 152 | :table_options, 153 | Helpers.build_options(table_options, table_type) 154 | ) 155 | end 156 | end 157 | 158 | defmacro attributes(opts \\ [], do: block) do 159 | define_attributes(opts, block) 160 | end 161 | 162 | defmacro field(name, opts \\ []) do 163 | quote do 164 | ActiveMemory.Table.__field__( 165 | __MODULE__, 166 | unquote(name), 167 | unquote(opts) 168 | ) 169 | end 170 | end 171 | 172 | @doc false 173 | def __after_compile__(%{module: _module}, _) do 174 | :ok 175 | end 176 | 177 | @doc false 178 | def __attributes__(fields, field_sources) do 179 | load = 180 | for name <- fields do 181 | if alias = field_sources[name] do 182 | {name, {:source, alias}} 183 | else 184 | name 185 | end 186 | end 187 | 188 | dump = 189 | for name <- fields do 190 | {name, field_sources[name] || name} 191 | end 192 | 193 | field_sources_quoted = 194 | for name <- fields do 195 | {[:field_source, name], field_sources[name] || name} 196 | end 197 | 198 | single_arg = [ 199 | {[:dump], dump |> Map.new() |> Macro.escape()}, 200 | {[:load], load |> Macro.escape()} 201 | ] 202 | 203 | catch_all = [ 204 | {[:field_source, quote(do: _)], nil} 205 | ] 206 | 207 | [ 208 | single_arg, 209 | field_sources_quoted, 210 | catch_all 211 | ] 212 | end 213 | 214 | @doc false 215 | def __field__(mod, name, opts) do 216 | define_field(mod, name, opts) 217 | end 218 | 219 | defp define_field(mod, name, opts) do 220 | put_struct_field(mod, name, Keyword.get(opts, :default)) 221 | 222 | Module.put_attribute(mod, :active_memory_query_fields, name) 223 | 224 | Module.put_attribute(mod, :active_memory_fields, name) 225 | end 226 | 227 | defp define_attributes(options, block) do 228 | prelude = 229 | quote do 230 | opts = unquote(options) 231 | 232 | @after_compile ActiveMemory.Table 233 | 234 | auto_generate_uuid = Keyword.get(opts, :auto_generate_uuid, false) 235 | 236 | Module.put_attribute(__MODULE__, :auto_generate_uuid, auto_generate_uuid) 237 | 238 | Module.register_attribute(__MODULE__, :active_memory_struct_fields, accumulate: true) 239 | 240 | if auto_generate_uuid do 241 | ActiveMemory.Table.__field__( 242 | __MODULE__, 243 | :uuid, 244 | primary_key: true, 245 | autogenerate: true 246 | ) 247 | end 248 | 249 | try do 250 | import ActiveMemory.Table 251 | unquote(block) 252 | after 253 | :ok 254 | end 255 | end 256 | 257 | postlude = 258 | quote unquote: false do 259 | fields = @active_memory_fields |> Enum.reverse() 260 | active_memory_query_fields = @active_memory_query_fields |> Enum.reverse() 261 | field_sources = @active_memory_field_sources |> Enum.reverse() 262 | query_fields = Enum.map(active_memory_query_fields, & &1) 263 | query_map = Helpers.build_query_map(query_fields) 264 | 265 | defstruct Enum.reverse(@active_memory_struct_fields) 266 | 267 | def __attributes__(:adapter), do: unquote(Macro.escape(@adapter)) 268 | 269 | def __attributes__(:auto_generate_uuid), do: unquote(Macro.escape(@auto_generate_uuid)) 270 | 271 | def __attributes__(:match_head), 272 | do: 273 | Helpers.build_match_head( 274 | unquote(query_map), 275 | unquote(__MODULE__), 276 | unquote(Macro.escape(@adapter)) 277 | ) 278 | 279 | def __attributes__(:query_fields), do: unquote(query_fields) 280 | 281 | def __attributes__(:query_map), do: unquote(query_map) 282 | 283 | def __attributes__(:table_options), do: unquote(Macro.escape(@table_options)) 284 | 285 | for clauses <- 286 | ActiveMemory.Table.__attributes__( 287 | fields, 288 | field_sources 289 | ), 290 | {args, body} <- clauses do 291 | def __attributes__(unquote_splicing(args)), do: unquote(body) 292 | end 293 | end 294 | 295 | quote do 296 | unquote(prelude) 297 | unquote(postlude) 298 | end 299 | end 300 | 301 | defp put_struct_field(mod, name, assoc) do 302 | fields = Module.get_attribute(mod, :active_memory_struct_fields) 303 | 304 | if List.keyfind(fields, name, 0) do 305 | raise ArgumentError, 306 | "field/association #{inspect(name)} already exists on attributes, you must either remove the duplication or choose a different name" 307 | end 308 | 309 | Module.put_attribute(mod, :active_memory_struct_fields, {name, assoc}) 310 | end 311 | end 312 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.MixProject do 2 | use Mix.Project 3 | 4 | @app :active_memory 5 | @author "Erin Boeger" 6 | @github "https://github.com/SullysMustyRuby/active_memory" 7 | @license "MIT" 8 | @name "ActiveMemory" 9 | @version "0.2.4" 10 | 11 | def project do 12 | [ 13 | app: @app, 14 | version: @version, 15 | author: @author, 16 | description: description(), 17 | elixir: "~> 1.13", 18 | start_permanent: Mix.env() == :prod, 19 | deps: deps(), 20 | package: package(), 21 | 22 | # ExDoc 23 | name: @name, 24 | source_url: @github, 25 | homepage_url: @github, 26 | docs: [ 27 | main: @name, 28 | canonical: "https://hexdocs.pm/#{@app}", 29 | extras: ["README.md"] 30 | ], 31 | aliases: [ 32 | test: "test --no-start" 33 | ] 34 | ] 35 | end 36 | 37 | # Run "mix help compile.app" to learn about applications. 38 | def application do 39 | [ 40 | extra_applications: [:logger, :mnesia] 41 | ] 42 | end 43 | 44 | defp description do 45 | "A Simple ORM for ETS and Mnesia" 46 | end 47 | 48 | # Run "mix help deps" to learn about dependencies. 49 | defp deps do 50 | [ 51 | {:uuid, "~> 1.1"}, 52 | {:ex_doc, "~> 0.27", only: :dev, runtime: false}, 53 | {:local_cluster, "~> 1.2", only: [:test]} 54 | ] 55 | end 56 | 57 | defp package do 58 | [ 59 | name: @app, 60 | maintainers: [@author], 61 | licenses: [@license], 62 | files: ~w(mix.exs lib README.md), 63 | links: %{"Github" => @github} 64 | ] 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, 4 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, 5 | "global_flags": {:hex, :global_flags, "1.0.0", "ee6b864979a1fb38d1fbc67838565644baf632212bce864adca21042df036433", [:rebar3], [], "hexpm", "85d944cecd0f8f96b20ce70b5b16ebccedfcd25e744376b131e89ce61ba93176"}, 6 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 7 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 8 | "local_cluster": {:hex, :local_cluster, "1.2.1", "8eab3b8a387680f0872eacfb1a8bd5a91cb1d4d61256eec6a655b07ac7030c73", [:mix], [{:global_flags, "~> 1.0", [hex: :global_flags, repo: "hexpm", optional: false]}], "hexpm", "aae80c9bc92c911cb0be085fdeea2a9f5b88f81b6bec2ff1fec244bb0acc232c"}, 9 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 14 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, 15 | } 16 | -------------------------------------------------------------------------------- /test/adapters/ets/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Ets.HelpersTest do 2 | use ExUnit.Case 3 | 4 | alias ActiveMemory.Adapters.Ets.Helpers 5 | alias Test.Support.Dogs.Dog 6 | 7 | describe "build_match_head/1" do 8 | test "returns a tuple formatted for simple key list" do 9 | query_map = [name: :"$1", breed: :"$2", weight: :"$3", fixed?: :"$4"] 10 | 11 | assert {:"$1", :"$2", :"$3", :"$4"} == 12 | Helpers.build_match_head(query_map) 13 | end 14 | end 15 | 16 | describe "build_options/1" do 17 | test "returns a list of valid :ets options" do 18 | options = [type: :ordered_set, access: :protected, compressed: true] 19 | assert [:ordered_set, :protected, :compressed] == Helpers.build_options(options) 20 | 21 | options = [ 22 | type: :ordered_set, 23 | access: :protected, 24 | read_concurrency: true, 25 | write_concurrency: true 26 | ] 27 | 28 | assert [ 29 | :ordered_set, 30 | :protected, 31 | {:read_concurrency, true}, 32 | {:write_concurrency, true} 33 | ] == Helpers.build_options(options) 34 | end 35 | 36 | test "ignores invalid or malformed options" do 37 | options = [type: :large, access: :sure, compressed: "yes"] 38 | assert [:set, :public] == Helpers.build_options(options) 39 | end 40 | 41 | test "returns defaults when no options" do 42 | assert [:set, :public] == Helpers.build_options(:defaults) 43 | assert [:set, :public] == Helpers.build_options([]) 44 | end 45 | end 46 | 47 | describe "to_struct/2" do 48 | test "returns a valid struct for the module provided" do 49 | dog = 50 | {"gem", "Shaggy Black Lab", 30, ~U[2022-07-07 19:47:50.978684Z], false, %{toy: "frizbee"}} 51 | 52 | assert Helpers.to_struct(dog, Dog) == %Dog{ 53 | dob: ~U[2022-07-07 19:47:50.978684Z], 54 | name: "gem", 55 | breed: "Shaggy Black Lab", 56 | nested: %{toy: "frizbee"}, 57 | weight: 30, 58 | fixed?: false 59 | } 60 | end 61 | end 62 | 63 | describe "to_tuple/1" do 64 | dog = %Dog{ 65 | dob: ~U[2022-07-07 19:47:50.978684Z], 66 | name: "gem", 67 | breed: "Shaggy Black Lab", 68 | nested: %{toy: "frizbee"}, 69 | weight: 30, 70 | fixed?: false 71 | } 72 | 73 | assert Helpers.to_tuple(dog) == 74 | {"gem", "Shaggy Black Lab", 30, ~U[2022-07-07 19:47:50.978684Z], false, 75 | %{toy: "frizbee"}} 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/adapters/ets_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.EtsTest do 2 | use ExUnit.Case, async: false 3 | doctest ActiveMemory 4 | 5 | alias Test.Support.Dogs.Dog 6 | alias Test.Support.Dogs.Store, as: DogStore 7 | alias Test.Support.People.Person 8 | 9 | import ActiveMemory.Query 10 | 11 | setup_all do 12 | {:ok, pid} = DogStore.start_link() 13 | 14 | on_exit(fn -> Process.exit(pid, :kill) end) 15 | 16 | {:ok, %{pid: pid}} 17 | end 18 | 19 | setup do 20 | on_exit(fn -> :ets.delete_all_objects(Dog) end) 21 | 22 | :ok 23 | end 24 | 25 | describe "all/0" do 26 | test "retuns all records" do 27 | write_seeds() 28 | dogs = DogStore.all() 29 | 30 | assert length(dogs) == 11 31 | end 32 | 33 | test "returns empty list if table empty" do 34 | assert DogStore.all() == [] 35 | end 36 | end 37 | 38 | describe "delete/1" do 39 | setup do 40 | write_seeds() 41 | 42 | :ok 43 | end 44 | 45 | test "removes the record and returns ok" do 46 | {:ok, dog} = DogStore.one(%{name: "poopsie", breed: "Poodle"}) 47 | 48 | assert DogStore.delete(dog) == :ok 49 | 50 | assert {:ok, nil} == DogStore.one(%{name: "poopsie", breed: "Poodle"}) 51 | end 52 | 53 | test "returns ok for a record that does not exist" do 54 | {:ok, poopsie} = DogStore.one(%{name: "poopsie", breed: "Poodle"}) 55 | assert DogStore.delete(poopsie) == :ok 56 | 57 | assert {:ok, nil} == DogStore.one(%{name: "poopsie", breed: "Poodle"}) 58 | assert DogStore.delete(poopsie) == :ok 59 | end 60 | 61 | test "returns error for a record with a different schema" do 62 | person = %Person{ 63 | email: "erin@galactica.com", 64 | first: "erin", 65 | last: "boeger", 66 | hair_color: "bald", 67 | age: 99, 68 | cylon?: true 69 | } 70 | 71 | assert DogStore.delete(person) == {:error, :bad_schema} 72 | end 73 | end 74 | 75 | describe "one/1 with a map query" do 76 | setup do 77 | write_seeds() 78 | :ok 79 | end 80 | 81 | test "returns the record matching the query" do 82 | {:ok, dog} = DogStore.one(%{name: "gem", breed: "Shaggy Black Lab"}) 83 | 84 | assert dog.name == "gem" 85 | assert dog.breed == "Shaggy Black Lab" 86 | end 87 | 88 | test "returns nil when no record matches" do 89 | assert {:ok, nil} == DogStore.one(%{breed: "wolf", name: "tiberious"}) 90 | end 91 | 92 | test "returns error when more than one record" do 93 | assert DogStore.one(%{breed: "PitBull", weight: 60}) == 94 | {:error, :more_than_one_result} 95 | end 96 | 97 | test "returns error when bad keys are in the query" do 98 | assert {:error, :query_schema_mismatch} == 99 | DogStore.one(%{shoe_size: "13", lipstick: "pink"}) 100 | end 101 | end 102 | 103 | describe "one/1 with a match query" do 104 | setup do 105 | write_seeds() 106 | 107 | :ok 108 | end 109 | 110 | test "retuns the records that match a simple equals query" do 111 | query = match(:name == "gem" and :breed == "Shaggy Black Lab") 112 | {:ok, dog} = DogStore.one(query) 113 | 114 | assert dog.name == "gem" 115 | assert dog.breed == "Shaggy Black Lab" 116 | end 117 | 118 | test "returns the records that match an 'and' query" do 119 | query = match(:breed == "Husky" and :weight >= 50) 120 | {:ok, dog} = DogStore.one(query) 121 | 122 | assert dog.breed == "Husky" 123 | assert dog.weight >= 50 124 | end 125 | 126 | test "returns nil list when no record matches" do 127 | query = match(:name == "tiberious" and :breed == "Wolf") 128 | assert {:ok, nil} == DogStore.one(query) 129 | end 130 | 131 | test "returns error when more than one record" do 132 | query = match(:breed == "PitBull" and :fixed? == true) 133 | assert DogStore.one(query) == {:error, :more_than_one_result} 134 | end 135 | end 136 | 137 | describe "select/1 with a map query" do 138 | setup do 139 | write_seeds() 140 | :ok 141 | end 142 | 143 | test "returns all records matching the query" do 144 | {:ok, [record]} = DogStore.select(%{breed: "Husky"}) 145 | 146 | assert record.breed == "Husky" 147 | 148 | {:ok, records} = DogStore.select(%{breed: "PitBull", fixed?: true}) 149 | assert length(records) == 2 150 | 151 | for record <- records do 152 | assert record.breed == "PitBull" 153 | assert record.fixed? 154 | end 155 | end 156 | 157 | test "returns nil when no records match" do 158 | assert {:ok, []} == DogStore.select(%{breed: "wolf", name: "tiberious"}) 159 | end 160 | 161 | test "returns error when bad keys are in the query" do 162 | assert {:error, :query_schema_mismatch} == 163 | DogStore.select(%{shoe_size: "13", lipstick: "pink"}) 164 | end 165 | end 166 | 167 | describe "select/1 with a match query" do 168 | setup do 169 | write_seeds() 170 | 171 | :ok 172 | end 173 | 174 | test "retuns the records that match a simple equals query" do 175 | query = match(:fixed? == false) 176 | 177 | {:ok, dogs} = DogStore.select(query) 178 | 179 | assert length(dogs) == 8 180 | 181 | for dog <- dogs do 182 | refute dog.fixed? 183 | end 184 | end 185 | 186 | test "returns the records that match an 'and' query" do 187 | query = match(:breed == "PitBull" and :weight > 45) 188 | {:ok, dogs} = DogStore.select(query) 189 | 190 | assert length(dogs) > 2 191 | 192 | for dog <- dogs do 193 | assert dog.breed == "PitBull" 194 | assert dog.weight > 45 195 | end 196 | end 197 | 198 | test "suceeds with a variable in the match" do 199 | weight = 40 200 | query = match(:weight <= weight) 201 | {:ok, dogs} = DogStore.select(query) 202 | 203 | for dog <- dogs do 204 | assert dog.weight <= 40 205 | end 206 | end 207 | 208 | test "returns the records that match a multiple 'or' query" do 209 | weight = 50 210 | query = match(:breed == "PitBull" or :breed == "Labrador" or :weight >= weight) 211 | 212 | {:ok, dogs} = DogStore.select(query) 213 | 214 | assert length(dogs) == 6 215 | 216 | for dog <- dogs do 217 | assert Enum.member?(["PitBull", "Labrador"], dog.breed) || dog.weight >= 50 218 | end 219 | end 220 | 221 | test "returns the records that match a multiple 'or' with 'and' query" do 222 | query = match(:fixed? == true or (:breed == "Labrador" and :weight <= 45)) 223 | {:ok, dogs} = DogStore.select(query) 224 | 225 | assert length(dogs) == 2 226 | 227 | for dog <- dogs do 228 | assert dog.fixed? == true or dog.breed == "Labrador" or dog.weight <= 45 229 | end 230 | end 231 | 232 | test "returns records that match a <= and !=" do 233 | query = match(:breed != "PitBull" and :weight <= 30) 234 | {:ok, dogs} = DogStore.select(query) 235 | 236 | assert length(dogs) == 2 237 | 238 | for dog <- dogs do 239 | assert dog.breed != "PitBull" 240 | assert dog.weight <= 30 241 | end 242 | end 243 | 244 | test "returns records that match a !=" do 245 | query = match(:breed != "Poodle" and :weight != 60) 246 | {:ok, dogs} = DogStore.select(query) 247 | 248 | assert length(dogs) == 4 249 | 250 | for dog <- dogs do 251 | assert dog.breed != "Poodle" 252 | assert dog.weight != 60 253 | end 254 | end 255 | end 256 | 257 | describe "withdraw/1 with a map query" do 258 | setup do 259 | write_seeds() 260 | 261 | :ok 262 | end 263 | 264 | test "returns the record matching the query and deletes the record" do 265 | {:ok, dog} = DogStore.withdraw(%{name: "codo", breed: "Husky"}) 266 | 267 | assert dog.name == "codo" 268 | assert dog.breed == "Husky" 269 | 270 | assert DogStore.one(%{name: "codo", breed: "Husky"}) == {:ok, nil} 271 | end 272 | 273 | test "returns nil list when no dog matches" do 274 | assert {:ok, nil} == DogStore.withdraw(%{name: "tiberious", breed: "T-Rex"}) 275 | end 276 | 277 | test "returns error when more than one dog" do 278 | assert DogStore.withdraw(%{breed: "PitBull", fixed?: true}) == 279 | {:error, :more_than_one_result} 280 | end 281 | 282 | test "returns error when bad keys are in the query" do 283 | assert {:error, :query_schema_mismatch} == 284 | DogStore.withdraw(%{shoe_size: "13", lipstick: "pink"}) 285 | end 286 | end 287 | 288 | describe "withdraw/1 with a match query" do 289 | setup do 290 | write_seeds() 291 | 292 | :ok 293 | end 294 | 295 | test "retuns the dogs that match a simple equals query" do 296 | query = match(:name == "gem" and :breed == "Shaggy Black Lab") 297 | {:ok, dog} = DogStore.withdraw(query) 298 | 299 | assert dog.name == "gem" 300 | assert dog.breed == "Shaggy Black Lab" 301 | 302 | assert DogStore.one(query) == {:ok, nil} 303 | end 304 | 305 | test "returns the dogs that match an 'and' query" do 306 | query = match(:breed == "schnauzer" and :name == "bill") 307 | {:ok, dog} = DogStore.withdraw(query) 308 | 309 | assert dog.breed == "schnauzer" 310 | assert dog.name == "bill" 311 | 312 | assert DogStore.one(query) == {:ok, nil} 313 | end 314 | 315 | test "returns nil list when no dog matches" do 316 | query = match(:name == "tiberious" and :breed == "T-Rex") 317 | assert {:ok, nil} == DogStore.withdraw(query) 318 | end 319 | 320 | test "returns error when more than one dog" do 321 | query = match(:breed == "PitBull" and :fixed? == true) 322 | assert DogStore.withdraw(query) == {:error, :more_than_one_result} 323 | end 324 | end 325 | 326 | describe "write/1" do 327 | test "writes the record with the correct schema" do 328 | record = %Dog{ 329 | breed: "Shaggy Black Lab", 330 | weight: "30", 331 | fixed?: false, 332 | name: "gem" 333 | } 334 | 335 | assert DogStore.all() == [] 336 | {:ok, _record} = DogStore.write(record) 337 | [new_record] = DogStore.all() 338 | assert new_record.breed == "Shaggy Black Lab" 339 | assert new_record.weight == "30" 340 | assert new_record.fixed? == false 341 | assert new_record.name == "gem" 342 | end 343 | 344 | test "returns error for a record with no schema" do 345 | record = %{breed: "Shaggy Black Lab", weight: "30", fixed?: false, name: "gem"} 346 | 347 | assert {:error, :bad_schema} == DogStore.write(record) 348 | end 349 | 350 | test "returns error for a record with a different schema" do 351 | person = %Person{ 352 | email: "erin@galactica.com", 353 | first: "erin", 354 | last: "boeger", 355 | hair_color: "bald", 356 | age: 99, 357 | cylon?: true 358 | } 359 | 360 | assert {:error, :bad_schema} == DogStore.write(person) 361 | end 362 | end 363 | 364 | defp write_seeds do 365 | {seeds, _} = 366 | File.cwd!() 367 | |> Path.join(["/test/support/dogs/", "dog_seeds.exs"]) 368 | |> Code.eval_file() 369 | 370 | Enum.each(seeds, fn seed -> DogStore.write(seed) end) 371 | end 372 | end 373 | -------------------------------------------------------------------------------- /test/adapters/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.HelpersTest do 2 | use ExUnit.Case 3 | 4 | alias ActiveMemory.Adapters.Helpers 5 | 6 | describe "build_query_map/1" do 7 | test "returns a list of tuples indexed for simple key attributes" do 8 | attributes = [:name, :breed, :weight, :fixed?] 9 | 10 | assert [name: :"$1", breed: :"$2", weight: :"$3", fixed?: :"$4"] == 11 | Helpers.build_query_map(attributes) 12 | end 13 | 14 | test "returns a list of tuples indexed for complex attributes with defaults" do 15 | attributes = [:name, :breed, :weight, fixed?: true, nested: %{one: nil, default: true}] 16 | 17 | assert [name: :"$1", breed: :"$2", weight: :"$3", fixed?: :"$4", nested: :"$5"] == 18 | Helpers.build_query_map(attributes) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/adapters/mnesia/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SullysMustyRuby/active_memory/7cabf24bfd617fb614d338ea44499455bc38079b/test/adapters/mnesia/.DS_Store -------------------------------------------------------------------------------- /test/adapters/mnesia/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Mnesia.HelpersTest do 2 | use ExUnit.Case 3 | 4 | alias ActiveMemory.Adapters.Mnesia.Helpers 5 | alias Test.Support.People.Person 6 | 7 | describe "build_match_head/1" do 8 | test "returns a tuple formatted for simple key list" do 9 | query_map = [name: :"$1", breed: :"$2", weight: :"$3", fixed?: :"$4"] 10 | 11 | assert {Dog, :"$1", :"$2", :"$3", :"$4"} == 12 | Helpers.build_match_head(query_map, Dog) 13 | end 14 | end 15 | 16 | describe "build_options/1" do 17 | test "returns a valid Mnesia keyword list of options" do 18 | options = [ 19 | access_mode: :read_only, 20 | disc_copies: [node()], 21 | load_order: 3, 22 | majority: true, 23 | index: [:name, :uuid, :email], 24 | type: :bag 25 | ] 26 | 27 | assert Helpers.build_options(options) == options 28 | end 29 | 30 | test "ignores invalid options" do 31 | options = [load_order: -1, majority: "yup", size: "huh?"] 32 | 33 | assert Helpers.build_options(options) == [] 34 | end 35 | end 36 | 37 | describe "to_struct/2" do 38 | test "returns a valid struct for the module provided" do 39 | person = 40 | {Person, "some-uuid", "caprica@galactica.com", "caprica", "boeger", "blonde", 31, true} 41 | 42 | assert Helpers.to_struct(person, Person) == %Person{ 43 | uuid: "some-uuid", 44 | email: "caprica@galactica.com", 45 | first: "caprica", 46 | last: "boeger", 47 | hair_color: "blonde", 48 | age: 31, 49 | cylon?: true 50 | } 51 | end 52 | end 53 | 54 | describe "to_tuple/1" do 55 | person = %Person{ 56 | uuid: "some-uuid", 57 | email: "caprica@galactica.com", 58 | first: "caprica", 59 | last: "boeger", 60 | hair_color: "blonde", 61 | age: 31, 62 | cylon?: true 63 | } 64 | 65 | assert Helpers.to_tuple(person) == 66 | {Person, "some-uuid", "caprica@galactica.com", "caprica", "boeger", "blonde", 31, 67 | true} 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/adapters/mnesia/migration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.Mnesia.MigrationTest do 2 | use ExUnit.Case, async: false 3 | 4 | @moduledoc """ 5 | By default these tests are skipped becasue they cause errors. 6 | To run these tests make sure the empd daemon is running. 7 | In your terminal: 8 | ```bash 9 | $ epmd -daemon 10 | ``` 11 | In test_helper.exs uncomment the following line: 12 | ```elixir 13 | # :ok = LocalCluster.start() 14 | ``` 15 | Don`t forget to commnt out the line above when completed. 16 | then run: 17 | ```elixir 18 | mix test test/adapters/mnesia/migration_test.exs --include migration 19 | ``` 20 | 21 | """ 22 | 23 | alias Test.Support.People.{Person, Store} 24 | alias Test.Support.Whales.Store, as: WhaleStore 25 | alias Test.Support.Whales.Whale 26 | 27 | describe "migrate_table_options/1" do 28 | setup do 29 | File.cwd!() |> Path.join("Mnesia.manager@127.0.0.1") |> File.rm_rf() 30 | 31 | on_exit(fn -> File.cwd!() |> Path.join("Mnesia.manager@127.0.0.1") |> File.rm_rf() end) 32 | 33 | {:ok, %{}} 34 | end 35 | 36 | @tag :migration 37 | test "updates the access_mode on startup" do 38 | assert :mnesia.create_table(Person, 39 | access_mode: :read_only, 40 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 41 | index: [:last, :cylon?], 42 | ram_copies: [node()], 43 | type: :set 44 | ) == {:atomic, :ok} 45 | 46 | assert :mnesia.table_info(Person, :access_mode) == :read_only 47 | 48 | {:ok, _pid} = Store.start_link() 49 | 50 | assert :mnesia.table_info(Person, :access_mode) == :read_write 51 | end 52 | 53 | @tag :migration 54 | test "updates the disc copies on startup" do 55 | [app_instance] = LocalCluster.start_nodes("app_instance", 1) 56 | 57 | {[:stopped, :stopped], []} = :rpc.multicall(:mnesia, :stop, []) 58 | :ok = :mnesia.delete_schema([Node.self() | Node.list()]) 59 | :ok = :mnesia.create_schema([node()]) 60 | {[:ok, :ok], []} = :rpc.multicall(:mnesia, :start, []) 61 | 62 | assert :mnesia.create_table(Person, 63 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 64 | index: [:last, :cylon?], 65 | disc_copies: [node()], 66 | ram_copies: [app_instance], 67 | type: :set 68 | ) == {:atomic, :ok} 69 | 70 | assert :mnesia.table_info(Person, :disc_copies) == [node()] 71 | 72 | {:ok, _pid} = Store.start_link() 73 | 74 | assert :mnesia.table_info(Person, :disc_copies) == [] 75 | assert :mnesia.table_info(Person, :ram_copies) == [node()] 76 | 77 | :ok = LocalCluster.stop_nodes([app_instance]) 78 | end 79 | 80 | @tag :migration 81 | test "updates the disc_only_copies on startup" do 82 | [app_instance] = LocalCluster.start_nodes("app_instance", 1) 83 | 84 | {[:stopped, :stopped], []} = :rpc.multicall(:mnesia, :stop, []) 85 | :ok = :mnesia.delete_schema([Node.self() | Node.list()]) 86 | :ok = :mnesia.create_schema([node()]) 87 | {[:ok, :ok], []} = :rpc.multicall(:mnesia, :start, []) 88 | 89 | assert :mnesia.create_table(Person, 90 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 91 | index: [:last, :cylon?], 92 | disc_only_copies: [node()], 93 | ram_copies: [app_instance], 94 | type: :set 95 | ) == {:atomic, :ok} 96 | 97 | assert :mnesia.table_info(Person, :disc_only_copies) == [:"manager@127.0.0.1"] 98 | 99 | {:ok, _pid} = Store.start_link() 100 | 101 | assert :mnesia.table_info(Person, :disc_only_copies) == [] 102 | assert :mnesia.table_info(Person, :ram_copies) == [:"manager@127.0.0.1"] 103 | 104 | :ok = LocalCluster.stop_nodes([app_instance]) 105 | end 106 | 107 | @tag :migration 108 | test "removes the local ram_copy on startup" do 109 | [app_instance] = LocalCluster.start_nodes("app_instance", 1) 110 | 111 | :stopped = :mnesia.stop() 112 | :ok = :mnesia.create_schema([node()]) 113 | :ok = :mnesia.start() 114 | 115 | assert :mnesia.create_table(Whale, 116 | attributes: [:email, :first, :last, :hair_color, :age], 117 | index: [:first, :last], 118 | ram_copies: [node()], 119 | type: :set 120 | ) == {:atomic, :ok} 121 | 122 | assert :mnesia.table_info(Whale, :ram_copies) == [:"manager@127.0.0.1"] 123 | 124 | {:ok, _pid} = WhaleStore.start_link() 125 | 126 | assert :mnesia.table_info(Whale, :ram_copies) == [app_instance] 127 | 128 | :ok = LocalCluster.stop_nodes([app_instance]) 129 | end 130 | 131 | @tag :migration 132 | test "replaces the indexes on startup" do 133 | :stopped = :mnesia.stop() 134 | :ok = :mnesia.delete_schema([node()]) 135 | :ok = :mnesia.start() 136 | 137 | assert :mnesia.create_table(Person, 138 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 139 | index: [:email, :first], 140 | type: :set 141 | ) == {:atomic, :ok} 142 | 143 | assert :mnesia.table_info(Person, :index) == [4, 3] 144 | 145 | {:ok, pid} = Store.start_link() 146 | 147 | assert :mnesia.table_info(Person, :index) == [8, 5] 148 | 149 | Process.exit(pid, :kill) 150 | end 151 | 152 | @tag :migration 153 | test "adds new indexes on startup if none exist" do 154 | :stopped = :mnesia.stop() 155 | :ok = :mnesia.delete_schema([node()]) 156 | :ok = :mnesia.start() 157 | 158 | assert :mnesia.create_table(Person, 159 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 160 | type: :set 161 | ) == {:atomic, :ok} 162 | 163 | assert :mnesia.table_info(Person, :index) == [] 164 | 165 | {:ok, pid} = Store.start_link() 166 | 167 | assert :mnesia.table_info(Person, :index) == [8, 5] 168 | 169 | Process.exit(pid, :kill) 170 | end 171 | 172 | @tag :migration 173 | test "removes old indexes on startup" do 174 | :stopped = :mnesia.stop() 175 | :ok = :mnesia.delete_schema([node()]) 176 | :ok = :mnesia.start() 177 | 178 | assert :mnesia.create_table(Person, 179 | index: [:last, :first, :cylon?], 180 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 181 | type: :set 182 | ) == {:atomic, :ok} 183 | 184 | assert :mnesia.table_info(Person, :index) == [8, 4, 5] 185 | 186 | {:ok, pid} = Store.start_link() 187 | 188 | assert :mnesia.table_info(Person, :index) == [5, 8] 189 | 190 | Process.exit(pid, :kill) 191 | end 192 | 193 | @tag :migration 194 | test "updates the migrate load order on startup" do 195 | :stopped = :mnesia.stop() 196 | :ok = :mnesia.create_schema([node()]) 197 | :ok = :mnesia.start() 198 | 199 | assert :mnesia.create_table(Person, 200 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 201 | index: [:last, :cylon?], 202 | load_order: 5, 203 | type: :set 204 | ) == {:atomic, :ok} 205 | 206 | assert :mnesia.table_info(Person, :load_order) == 5 207 | 208 | {:ok, pid} = Store.start_link() 209 | 210 | assert :mnesia.table_info(Person, :load_order) == 0 211 | 212 | Process.exit(pid, :kill) 213 | end 214 | 215 | @tag :migration 216 | test "updates the majority on startup" do 217 | :stopped = :mnesia.stop() 218 | :ok = :mnesia.create_schema([node()]) 219 | :ok = :mnesia.start() 220 | 221 | assert :mnesia.create_table(Person, 222 | attributes: [:uuid, :email, :first, :last, :hair_color, :age, :cylon?], 223 | index: [:last, :cylon?], 224 | majority: true, 225 | type: :set 226 | ) == {:atomic, :ok} 227 | 228 | info = :mnesia.table_info(Person, :all) 229 | 230 | assert Keyword.get(info, :majority) 231 | 232 | {:ok, pid} = Store.start_link() 233 | 234 | updated = :mnesia.table_info(Person, :all) 235 | 236 | refute Keyword.get(updated, :majority) 237 | 238 | Process.exit(pid, :kill) 239 | end 240 | end 241 | end 242 | -------------------------------------------------------------------------------- /test/adapters/mnesia_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Adapters.MneisaTest do 2 | use ExUnit.Case 3 | doctest ActiveMemory 4 | 5 | alias Test.Support.People.Person 6 | alias Test.Support.People.Store, as: PeopleStore 7 | alias Test.Support.Dogs.Dog 8 | 9 | setup_all do 10 | {:ok, pid} = PeopleStore.start_link() 11 | 12 | on_exit(fn -> :mnesia.delete_table(Person) end) 13 | on_exit(fn -> Process.exit(pid, :kill) end) 14 | 15 | {:ok, %{pid: pid}} 16 | end 17 | 18 | describe "all/0" do 19 | setup do 20 | on_exit(fn -> :mnesia.clear_table(Person) end) 21 | 22 | :ok 23 | end 24 | 25 | test "retuns all records" do 26 | people = PeopleStore.all() 27 | 28 | assert length(people) == 10 29 | 30 | for person <- people do 31 | assert person.__struct__ == Person 32 | end 33 | end 34 | 35 | test "returns empty list if table empty" do 36 | assert PeopleStore.all() == [] 37 | end 38 | end 39 | 40 | describe "delete/1" do 41 | setup do 42 | write_seeds() 43 | 44 | on_exit(fn -> :mnesia.clear_table(Person) end) 45 | 46 | :ok 47 | end 48 | 49 | test "removes the record and returns ok" do 50 | {:ok, karl} = PeopleStore.one(%{first: "karl", last: "agathon"}) 51 | 52 | assert PeopleStore.delete(karl) == :ok 53 | 54 | assert {:ok, nil} == PeopleStore.one(%{first: "karl", last: "agathon"}) 55 | end 56 | 57 | test "returns ok for a record that does not exist" do 58 | {:ok, karl} = PeopleStore.one(%{first: "karl", last: "agathon"}) 59 | 60 | assert PeopleStore.delete(karl) == :ok 61 | 62 | assert {:ok, nil} == PeopleStore.one(%{first: "karl", last: "agathon"}) 63 | 64 | assert PeopleStore.delete(karl) == :ok 65 | end 66 | 67 | test "returns error for a record with a different schema" do 68 | dog = %Dog{ 69 | breed: "PitBull", 70 | weight: 60, 71 | fixed?: "yes", 72 | name: "smegol" 73 | } 74 | 75 | assert PeopleStore.delete(dog) == {:error, :bad_schema} 76 | end 77 | end 78 | 79 | describe "one/1 with a map query" do 80 | setup do 81 | write_seeds() 82 | 83 | on_exit(fn -> :mnesia.clear_table(Person) end) 84 | 85 | :ok 86 | end 87 | 88 | test "returns the record matching the query" do 89 | {:ok, record} = PeopleStore.one(%{first: "erin", last: "boeger"}) 90 | 91 | assert record.first == "erin" 92 | assert record.last == "boeger" 93 | end 94 | 95 | test "returns nil list when no record matches" do 96 | assert {:ok, nil} == PeopleStore.one(%{first: "tiberious", last: "kirk"}) 97 | end 98 | 99 | test "returns error when more than one record" do 100 | assert PeopleStore.one(%{hair_color: "brown", cylon?: false}) == 101 | {:error, :more_than_one_result} 102 | end 103 | 104 | test "returns error when bad keys are in the query" do 105 | assert {:error, :query_schema_mismatch} == 106 | PeopleStore.one(%{shoe_size: "13", lipstick: "pink"}) 107 | end 108 | end 109 | 110 | describe "one/1 with a match query" do 111 | import ActiveMemory.Query 112 | 113 | setup do 114 | write_seeds() 115 | 116 | on_exit(fn -> :mnesia.clear_table(Person) end) 117 | 118 | :ok 119 | end 120 | 121 | test "retuns the records that match a simple equals query" do 122 | query = match(:first == "erin" and :last == "boeger") 123 | {:ok, person} = PeopleStore.one(query) 124 | 125 | assert person.first == "erin" 126 | assert person.last == "boeger" 127 | end 128 | 129 | test "returns the records that match an 'and' query" do 130 | query = match(:hair_color == "bald" and :age > 98) 131 | {:ok, person} = PeopleStore.one(query) 132 | 133 | assert person.hair_color == "bald" 134 | assert person.first == "erin" 135 | end 136 | 137 | test "returns nil list when no record matches" do 138 | query = match(:first == "tiberious" and :last == "kirk") 139 | assert {:ok, nil} == PeopleStore.one(query) 140 | end 141 | 142 | test "returns error when more than one record" do 143 | query = match(:hair_color == "brown" and :cylon? == false) 144 | assert PeopleStore.one(query) == {:error, :more_than_one_result} 145 | end 146 | end 147 | 148 | describe "select/1 with a map query" do 149 | setup do 150 | on_exit(fn -> :mnesia.clear_table(Person) end) 151 | 152 | :ok 153 | end 154 | 155 | test "returns all records matching the query" do 156 | for hair_color <- ["bald", "blonde", "black", "blue"] do 157 | %Person{ 158 | email: "#{hair_color}@here.com", 159 | first: "erin", 160 | last: "boeger", 161 | hair_color: hair_color 162 | } 163 | |> PeopleStore.write() 164 | end 165 | 166 | {:ok, [record]} = PeopleStore.select(%{hair_color: "blonde"}) 167 | 168 | assert record.email == "blonde@here.com" 169 | assert record.hair_color == "blonde" 170 | 171 | {:ok, records} = PeopleStore.select(%{first: "erin", last: "boeger"}) 172 | assert length(records) == 4 173 | 174 | for record <- records do 175 | assert record.first == "erin" 176 | assert record.last == "boeger" 177 | end 178 | end 179 | 180 | test "returns nil when no records match" do 181 | assert {:ok, []} == PeopleStore.select(%{first: "tiberious", last: "kirk"}) 182 | end 183 | 184 | test "returns error when bad keys are in the query" do 185 | assert {:error, :query_schema_mismatch} == 186 | PeopleStore.select(%{shoe_size: "13", lipstick: "pink"}) 187 | end 188 | end 189 | 190 | describe "select/1 with a match query" do 191 | import ActiveMemory.Query 192 | 193 | setup do 194 | write_seeds() 195 | 196 | on_exit(fn -> :mnesia.clear_table(Person) end) 197 | 198 | :ok 199 | end 200 | 201 | test "retuns the records that match a simple equals query" do 202 | query = match(:cylon? == true) 203 | {:ok, people} = PeopleStore.select(query) 204 | 205 | assert length(people) == 3 206 | 207 | for person <- people do 208 | assert person.cylon? 209 | end 210 | end 211 | 212 | test "returns the records that match an 'and' query" do 213 | query = match(:hair_color == "brown" and :age > 45) 214 | {:ok, people} = PeopleStore.select(query) 215 | 216 | assert length(people) > 1 217 | 218 | for person <- people do 219 | assert person.hair_color == "brown" 220 | assert person.age > 45 221 | end 222 | end 223 | 224 | test "returns the records that match a multiple 'or' query" do 225 | query = match(:first == "erin" or :first == "laura" or :first == "galan") 226 | {:ok, people} = PeopleStore.select(query) 227 | 228 | assert length(people) == 3 229 | 230 | for person <- people do 231 | assert Enum.member?(["erin", "laura", "galan"], person.first) 232 | end 233 | end 234 | 235 | test "returns the records that match a multiple 'or' with 'and' query" do 236 | query = match(:cylon? == true or (:hair_color == "blonde" and :age < 50)) 237 | {:ok, people} = PeopleStore.select(query) 238 | 239 | assert length(people) == 4 240 | 241 | for person <- people do 242 | if !person.cylon?, do: assert(person.hair_color == "blonde" && person.age < 50) 243 | end 244 | end 245 | 246 | test "returns records that match a !=" do 247 | query = match(:hair_color != "brown" and :age != 31) 248 | {:ok, people} = PeopleStore.select(query) 249 | 250 | assert length(people) == 3 251 | 252 | for person <- people do 253 | assert person.hair_color != "brown" 254 | assert person.age != 31 255 | end 256 | end 257 | end 258 | 259 | describe "withdraw/1 with a map query" do 260 | setup do 261 | write_seeds() 262 | 263 | on_exit(fn -> :mnesia.clear_table(Person) end) 264 | 265 | :ok 266 | end 267 | 268 | test "returns the record matching the query and deletes the record" do 269 | {:ok, record} = PeopleStore.withdraw(%{first: "erin", last: "boeger"}) 270 | 271 | assert record.first == "erin" 272 | assert record.last == "boeger" 273 | 274 | assert PeopleStore.one(%{first: "erin", last: "boeger"}) == {:ok, nil} 275 | end 276 | 277 | test "returns nil list when no record matches" do 278 | assert {:ok, nil} == PeopleStore.withdraw(%{first: "tiberious", last: "kirk"}) 279 | end 280 | 281 | test "returns error when more than one record" do 282 | assert PeopleStore.withdraw(%{hair_color: "brown", cylon?: false}) == 283 | {:error, :more_than_one_result} 284 | end 285 | 286 | test "returns error when bad keys are in the query" do 287 | assert {:error, :query_schema_mismatch} == 288 | PeopleStore.withdraw(%{shoe_size: "13", lipstick: "pink"}) 289 | end 290 | end 291 | 292 | describe "withdraw/1 with a match query" do 293 | import ActiveMemory.Query 294 | 295 | setup do 296 | write_seeds() 297 | 298 | on_exit(fn -> :mnesia.clear_table(Person) end) 299 | 300 | :ok 301 | end 302 | 303 | test "retuns the records that match a simple equals query" do 304 | query = match(:first == "erin" and :last == "boeger") 305 | {:ok, person} = PeopleStore.withdraw(query) 306 | 307 | assert person.first == "erin" 308 | assert person.last == "boeger" 309 | 310 | assert PeopleStore.one(query) == {:ok, nil} 311 | end 312 | 313 | test "returns the records that match an 'and' query" do 314 | query = match(:hair_color == "bald" and :age > 98) 315 | {:ok, person} = PeopleStore.withdraw(query) 316 | 317 | assert person.hair_color == "bald" 318 | assert person.first == "erin" 319 | 320 | assert PeopleStore.one(query) == {:ok, nil} 321 | end 322 | 323 | test "returns nil list when no record matches" do 324 | query = match(:first == "tiberious" and :last == "kirk") 325 | assert {:ok, nil} == PeopleStore.withdraw(query) 326 | end 327 | 328 | test "returns error when more than one record" do 329 | query = match(:hair_color == "brown" and :cylon? == false) 330 | assert PeopleStore.withdraw(query) == {:error, :more_than_one_result} 331 | end 332 | end 333 | 334 | describe "write/1" do 335 | setup do 336 | on_exit(fn -> :mnesia.clear_table(Person) end) 337 | 338 | :ok 339 | end 340 | 341 | test "writes the record with the correct schema" do 342 | record = %Person{ 343 | email: "test@here.com", 344 | first: "erin", 345 | last: "boeger", 346 | hair_color: "bald" 347 | } 348 | 349 | assert PeopleStore.all() == [] 350 | {:ok, _record} = PeopleStore.write(record) 351 | [new_record] = PeopleStore.all() 352 | assert new_record.email == "test@here.com" 353 | assert new_record.first == "erin" 354 | assert new_record.last == "boeger" 355 | assert new_record.hair_color == "bald" 356 | assert new_record.uuid != nil 357 | end 358 | 359 | test "returns error for a record with no schema" do 360 | record = %{email: "test@here.com", first: "erin", last: "boeger", hair_color: "bald"} 361 | 362 | assert {:error, :bad_schema} == PeopleStore.write(record) 363 | end 364 | 365 | test "returns error for a record with a different schema" do 366 | dog = %Dog{ 367 | breed: "PitBull", 368 | weight: 60, 369 | fixed?: "yes", 370 | name: "smegol" 371 | } 372 | 373 | assert {:error, :bad_schema} == PeopleStore.write(dog) 374 | end 375 | end 376 | 377 | defp write_seeds do 378 | {seeds, _} = 379 | File.cwd!() 380 | |> Path.join(["/test/support/people/", "person_seeds.exs"]) 381 | |> Code.eval_file() 382 | 383 | Enum.each(seeds, fn seed -> PeopleStore.write(seed) end) 384 | end 385 | end 386 | -------------------------------------------------------------------------------- /test/query/match_guards_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Query.MatchGuardsTest do 2 | use ExUnit.Case 3 | 4 | alias ActiveMemory.Query.MatchGuards 5 | 6 | alias Test.Support.People.{Person, Store} 7 | 8 | setup_all do 9 | {:ok, pid} = Store.start_link() 10 | 11 | on_exit(fn -> :mnesia.delete_table(Person) end) 12 | on_exit(fn -> Process.exit(pid, :kill) end) 13 | 14 | {:ok, %{pid: pid}} 15 | end 16 | 17 | describe "build_match_query/2" do 18 | test "returns the query strings in the correct positions" do 19 | query_map = %{last: "boeger", age: 35} 20 | 21 | assert {:ok, {:_, :_, :_, "boeger", :_, 35, :_}} == MatchGuards.build(Person, query_map) 22 | end 23 | 24 | test "returns error for keys that do not match" do 25 | query_map = %{ears: "two", nose: "kinda big"} 26 | 27 | assert {:error, :query_schema_mismatch} == MatchGuards.build(Person, query_map) 28 | end 29 | 30 | test "returns query strings when variables are used" do 31 | last = "boeger" 32 | age = 35 33 | query_map = %{last: last, age: age} 34 | 35 | assert {:ok, {:_, :_, :_, "boeger", :_, 35, :_}} == MatchGuards.build(Person, query_map) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/query/match_spec_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.Query.MatchSpecTest do 2 | use ExUnit.Case 3 | 4 | alias ActiveMemory.Query.MatchSpec 5 | alias Test.Support.Dogs.Dog 6 | 7 | import ActiveMemory.Query 8 | 9 | describe "build/2" do 10 | test "returns a properly formatted match_spec" do 11 | query = match(:breed == "PitBull" and :weight > 40 and :fixed? == false) 12 | 13 | query_map = :erlang.apply(Dog, :__attributes__, [:query_map]) 14 | match_head = :erlang.apply(Dog, :__attributes__, [:match_head]) 15 | 16 | [{match_head, query, result}] = MatchSpec.build(query, query_map, match_head) 17 | 18 | assert match_head == {:"$1", :"$2", :"$3", :"$4", :"$5", :"$6"} 19 | 20 | assert query == [ 21 | {:and, {:and, {:==, :"$2", "PitBull"}, {:>, :"$3", 40}}, {:==, :"$5", false}} 22 | ] 23 | 24 | assert result == [:"$_"] 25 | end 26 | 27 | test "returns variables interpolated" do 28 | breed = "PitBull" 29 | weight = 40 30 | fixed = false 31 | query = match(:breed == breed and :weight > weight and :fixed? == fixed) 32 | 33 | query_map = :erlang.apply(Dog, :__attributes__, [:query_map]) 34 | match_head = :erlang.apply(Dog, :__attributes__, [:match_head]) 35 | 36 | [{_match_head, query, _result}] = MatchSpec.build(query, query_map, match_head) 37 | 38 | assert query == [ 39 | {:and, {:and, {:==, :"$2", "PitBull"}, {:>, :"$3", 40}}, {:==, :"$5", false}} 40 | ] 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/query/query_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.QueryTest do 2 | use ExUnit.Case 3 | 4 | import ActiveMemory.Query 5 | 6 | describe "match" do 7 | test "turns the syntax into proper match" do 8 | assert {:or, {:==, :name, "erin"}, {:==, :name, "tiberious"}} == 9 | match(:name == "erin" or :name == "tiberious") 10 | 11 | assert {:or, {:and, {:==, :name, "erin"}, {:==, :name, "tiberious"}}, {:<, :age, 35}} == 12 | match((:name == "erin" and :name == "tiberious") or :age < 35) 13 | end 14 | 15 | test "resolves variables in the match" do 16 | now = DateTime.utc_now() 17 | assert match(:date == now) == {:==, :date, now} 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/store_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.StoreTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Test.Support.Dogs.Dog 5 | alias Test.Support.Dogs.Store, as: DogStore 6 | alias Test.Support.People.Store, as: PeopleStore 7 | alias Test.Support.People.Person 8 | 9 | describe "init with options" do 10 | test "with a valid seed file populates the store" do 11 | {:ok, pid} = PeopleStore.start_link() 12 | 13 | people = PeopleStore.all() 14 | assert length(people) == 10 15 | 16 | :mnesia.delete_table(Person) 17 | Process.exit(pid, :kill) 18 | end 19 | 20 | test "with a table error" do 21 | assert Dog == :ets.new(Dog, [:named_table, :public, read_concurrency: true]) 22 | 23 | Process.flag(:trap_exit, true) 24 | 25 | assert {:error, {:bad_return_value, {:error, :create_table_failed}}} == 26 | DogStore.start_link() 27 | 28 | :ets.delete(Dog) 29 | end 30 | 31 | test "with before_init method" do 32 | {:ok, pid} = DogStore.start_link() 33 | {:ok, dog} = DogStore.one(%{name: "Blue"}) 34 | 35 | assert dog.name == "Blue" 36 | 37 | :ets.delete(Dog) 38 | Process.exit(pid, :kill) 39 | end 40 | 41 | test "initial state with no method returns table name and started_at" do 42 | {:ok, pid} = PeopleStore.start_link() 43 | 44 | state = PeopleStore.state() 45 | assert state.table_name == Person 46 | assert DateTime.diff(DateTime.utc_now(), state.started_at) < 10 47 | 48 | :mnesia.delete_table(Person) 49 | Process.exit(pid, :kill) 50 | end 51 | 52 | test "initial state with a method returns method state" do 53 | {:ok, pid} = DogStore.start_link() 54 | 55 | state = DogStore.state() 56 | assert state.key == "value" 57 | assert state.next == "next_value" 58 | assert DateTime.diff(DateTime.utc_now(), state.now) < 10 59 | 60 | :ets.delete(Dog) 61 | Process.exit(pid, :kill) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/support/dogs/dog.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.Support.Dogs.Dog do 2 | use ActiveMemory.Table, 3 | type: :ets, 4 | options: [compressed: true, read_concurrency: true] 5 | 6 | attributes do 7 | field(:name) 8 | field(:breed) 9 | field(:weight) 10 | field(:dob) 11 | field(:fixed?, default: true) 12 | field(:nested) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/dogs/dog_seeds.exs: -------------------------------------------------------------------------------- 1 | [ 2 | %Test.Support.Dogs.Dog{ 3 | dob: DateTime.utc_now() |> DateTime.add(-1000, :second), 4 | name: "gem", 5 | breed: "Shaggy Black Lab", 6 | weight: 30, 7 | fixed?: false 8 | }, 9 | %Test.Support.Dogs.Dog{ 10 | dob: DateTime.utc_now() |> DateTime.add(-1000, :second), 11 | name: "smegol", 12 | breed: "PitBull", 13 | weight: 60, 14 | fixed?: true 15 | }, 16 | %Test.Support.Dogs.Dog{ 17 | dob: DateTime.utc_now() |> DateTime.add(-1000, :second), 18 | name: "bling bling", 19 | breed: "PitBull", 20 | weight: 60, 21 | fixed?: true 22 | }, 23 | %Test.Support.Dogs.Dog{ 24 | dob: DateTime.utc_now() |> DateTime.add(-1000, :second), 25 | name: "bull", 26 | breed: "PitBull mix", 27 | weight: 45, 28 | fixed?: false 29 | }, 30 | %Test.Support.Dogs.Dog{ 31 | dob: DateTime.utc_now() |> DateTime.add(-1000, :second), 32 | name: "codo", 33 | breed: "Husky", 34 | weight: 50, 35 | fixed?: false 36 | }, 37 | %Test.Support.Dogs.Dog{ 38 | dob: DateTime.utc_now() |> DateTime.add(-100_000, :second), 39 | name: "bogo", 40 | breed: "Begal", 41 | weight: 60, 42 | fixed?: false 43 | }, 44 | %Test.Support.Dogs.Dog{ 45 | dob: DateTime.utc_now() |> DateTime.add(-100_000, :second), 46 | name: "poopsie", 47 | breed: "Poodle", 48 | weight: 25, 49 | fixed?: false 50 | }, 51 | %Test.Support.Dogs.Dog{ 52 | dob: DateTime.utc_now() |> DateTime.add(-100_000, :second), 53 | name: "bill", 54 | breed: "schnauzer", 55 | weight: 35, 56 | fixed?: false 57 | }, 58 | %Test.Support.Dogs.Dog{ 59 | dob: DateTime.utc_now() |> DateTime.add(-100_000, :second), 60 | name: "hue", 61 | breed: "Labrador", 62 | weight: 60, 63 | fixed?: false 64 | }, 65 | %Test.Support.Dogs.Dog{ 66 | dob: DateTime.utc_now() |> DateTime.add(-100_000, :second), 67 | name: "lady", 68 | breed: "PitBull", 69 | weight: 60, 70 | fixed?: false 71 | } 72 | ] 73 | -------------------------------------------------------------------------------- /test/support/dogs/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.Support.Dogs.Store do 2 | use ActiveMemory.Store, 3 | table: Test.Support.Dogs.Dog, 4 | before_init: [{:run_me, ["Blue"]}], 5 | initial_state: {:initial_state, ["value", "next_value"]} 6 | 7 | def run_me(name) do 8 | %Test.Support.Dogs.Dog{ 9 | name: name, 10 | breed: "English PitBull", 11 | weight: 40, 12 | fixed?: false 13 | } 14 | |> write() 15 | 16 | :ok 17 | end 18 | 19 | def initial_state(arg, arg2) do 20 | {:ok, 21 | %{ 22 | key: arg, 23 | next: arg2, 24 | now: DateTime.utc_now() 25 | }} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/people/person.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.Support.People.Person do 2 | use ActiveMemory.Table, 3 | options: [ 4 | index: [:last, :cylon?] 5 | ] 6 | 7 | attributes auto_generate_uuid: true do 8 | field(:email) 9 | field(:first) 10 | field(:last) 11 | field(:hair_color) 12 | field(:age) 13 | field(:cylon?) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/people/person_seeds.exs: -------------------------------------------------------------------------------- 1 | [ 2 | %Test.Support.People.Person{ 3 | email: "erin@galactica.com", 4 | first: "erin", 5 | last: "boeger", 6 | hair_color: "bald", 7 | age: 99, 8 | cylon?: true 9 | }, 10 | %Test.Support.People.Person{ 11 | email: "caprica@galactica.com", 12 | first: "caprica", 13 | last: "boeger", 14 | hair_color: "blonde", 15 | age: 31, 16 | cylon?: true 17 | }, 18 | %Test.Support.People.Person{ 19 | email: "cpt_adama@galactica.com", 20 | first: "willian", 21 | last: "adama", 22 | hair_color: "bald", 23 | age: 55, 24 | cylon?: false 25 | }, 26 | %Test.Support.People.Person{ 27 | email: "kara@galactica.com", 28 | first: "kara", 29 | last: "thrace", 30 | hair_color: "blonde", 31 | age: 32, 32 | cylon?: false 33 | }, 34 | %Test.Support.People.Person{ 35 | email: "dualla@galactica.com", 36 | first: "anastasia", 37 | last: "dualla", 38 | hair_color: "brown", 39 | age: 99, 40 | cylon?: false 41 | }, 42 | %Test.Support.People.Person{ 43 | email: "laura@galactica.com", 44 | first: "laura", 45 | last: "roslin", 46 | hair_color: "brown", 47 | age: 65, 48 | cylon?: false 49 | }, 50 | %Test.Support.People.Person{ 51 | email: "galan@galactica.com", 52 | first: "galan", 53 | last: "tyrol", 54 | hair_color: "brown", 55 | age: 35, 56 | cylon?: true 57 | }, 58 | %Test.Support.People.Person{ 59 | email: "karl@galactica.com", 60 | first: "karl", 61 | last: "agathon", 62 | hair_color: "brown", 63 | age: 35, 64 | cylon?: false 65 | }, 66 | %Test.Support.People.Person{ 67 | email: "helena@galactica.com", 68 | first: "helena", 69 | last: "cain", 70 | hair_color: "brown", 71 | age: 45, 72 | cylon?: false 73 | }, 74 | %Test.Support.People.Person{ 75 | email: "cally@galactica.com", 76 | first: "cally", 77 | last: "tyrol", 78 | hair_color: "brown", 79 | age: 31, 80 | cylon?: false 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /test/support/people/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.Support.People.Store do 2 | use ActiveMemory.Store, 3 | table: Test.Support.People.Person, 4 | seed_file: Path.expand("person_seeds.exs", __DIR__) 5 | end 6 | -------------------------------------------------------------------------------- /test/support/whales/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.Support.Whales.Store do 2 | use ActiveMemory.Store, 3 | table: Test.Support.Whales.Whale 4 | end 5 | -------------------------------------------------------------------------------- /test/support/whales/whale.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.Support.Whales.Whale do 2 | use ActiveMemory.Table, 3 | options: [index: [:first, :last], ram_copies: [:"app_instance1@127.0.0.1"]] 4 | 5 | attributes do 6 | field(:email) 7 | field(:first) 8 | field(:last) 9 | field(:hair_color) 10 | field(:age) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ActiveMemory.TableTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Test.Support.Whales.Whale 5 | alias Test.Support.People.Person 6 | 7 | describe "__attributes__" do 8 | test "defines the attributes" do 9 | assert Whale.__attributes__(:query_fields) == [ 10 | :email, 11 | :first, 12 | :last, 13 | :hair_color, 14 | :age 15 | ] 16 | 17 | assert Whale.__attributes__(:query_map) == [ 18 | email: :"$1", 19 | first: :"$2", 20 | last: :"$3", 21 | hair_color: :"$4", 22 | age: :"$5" 23 | ] 24 | 25 | assert Whale.__attributes__(:adapter) == ActiveMemory.Adapters.Mnesia 26 | 27 | assert Whale.__attributes__(:table_options) == [ 28 | {:index, [:first, :last]}, 29 | {:ram_copies, [:"app_instance1@127.0.0.1"]} 30 | ] 31 | 32 | assert Whale.__attributes__(:match_head) == 33 | {Test.Support.Whales.Whale, :"$1", :"$2", :"$3", :"$4", :"$5"} 34 | 35 | refute Whale.__attributes__(:auto_generate_uuid) 36 | 37 | assert Person.__attributes__(:auto_generate_uuid) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("support/dogs/dog.ex", __DIR__) 2 | Code.require_file("support/dogs/store.ex", __DIR__) 3 | Code.require_file("support/people/person.ex", __DIR__) 4 | Code.require_file("support/people/store.ex", __DIR__) 5 | Code.require_file("support/whales/whale.ex", __DIR__) 6 | Code.require_file("support/whales/store.ex", __DIR__) 7 | 8 | # start the current node as a manager uncomment for running migration tests 9 | # :ok = LocalCluster.start() 10 | 11 | # start your application tree manually 12 | Application.ensure_all_started(:active_memory) 13 | 14 | ExUnit.start(exclude: [:migration], max_cases: 1, seed: 0) 15 | --------------------------------------------------------------------------------