├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── lib ├── lethe.ex └── lethe │ └── ops.ex ├── mix.exs ├── mix.lock └── test ├── lethe_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 | -------------------------------------------------------------------------------- /.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 | lethe-*.tar 24 | 25 | # Temporary files for e.g. tests 26 | /tmp 27 | 28 | # Mnesia bullshit 29 | /Mnesia*@*/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 - present amy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lethe 2 | 3 | > Lethe 4 | > /ˈli θi/ • *noun* (try it with [IPA Reader](http://ipa-reader.xyz)) 5 | > 1. *Classical Mythology.* A river in Hades whose water caused forgetfulness of the past in those who drank of it. 6 | 7 | A marginally-better (WIP) query DSL for Mnesia. Originally implemented for 8 | [신경](https://singyeong.org). 9 | 10 | Matchspecs suck, so this is a sorta-better alternative. 11 | 12 | **WARNING:** Currently, only read operations are supported. This may or may not 13 | change in the future. 14 | 15 | ### Things that may trip you up 16 | 17 | - The map functions `is_map_key` and `map_get` take arguments in the order 18 | `(key, map)`, NOT `(map, key)`! 19 | 20 | ## Roadmap 21 | 22 | - [x] Select all fields of a record 23 | - [x] Select some fields of a record 24 | - [x] Limit number of records returned 25 | - [x] Query operators 26 | - [x] `+`, `-`, `*`, `div`, `rem`, `>`, `>=`, `<`, `<=`, `!=` 27 | - [x] Bitwise operators 28 | - [x] Tuple, list, and map operators (`map_size`, `hd`, `tl`, `element`, etc.) 29 | - [x] Misc. math functions (`abs`, `trunc`, etc.) 30 | - [x] Boolean operators 31 | - [x] Logical AND/OR/etc. 32 | - [x] `is_pid`/`is_binary`/etc. 33 | - [ ] Write DSL 34 | 35 | ## Installation 36 | 37 | [Get it on Hex.](https://hex.pm/packages/lethe) 38 | 39 | [Read the docs.](https://hexdocs.pm/lethe) 40 | 41 | ## Usage 42 | 43 | ```Elixir 44 | # Create a table... 45 | table = :table 46 | :mnesia.create_schema [] 47 | :mnesia.start() 48 | :mnesia.create_table table, [attributes: [:integer, :string, :map]] 49 | 50 | # ...and add some indexes... 51 | :mnesia.add_table_index table, :integer 52 | :mnesia.add_table_index table, :string 53 | :mnesia.add_table_index table, :map 54 | 55 | # ...and some test data. 56 | n = fn -> :rand.uniform 1_000_000_000_000_000_000_000_000_000_000_000_000_000_000_000 end 57 | for i <- 1..10_000, do: :mnesia.dirty_write {table, i, "#{n.()}", %{i => "#{n.()}"}} 58 | 59 | # Now let's run some queries! 60 | # Lethe's query DSL allows you to use the names of your table attributes, 61 | # rather than forcing you to think about what their index in the record is, or 62 | # anything else like that. 63 | # Currently, Lethe requires that you compile your queries before running them. 64 | # This is primarily done to aid in debugging, and a `Lethe.compile_and_run/1` 65 | # is likely to happen in the future. 66 | 67 | # Select one record and all its fields 68 | {:ok, [{integer, string, map}]} = 69 | table 70 | |> Lethe.new 71 | |> Lethe.limit(1) 72 | |> Lethe.select_all 73 | |> Lethe.compile 74 | |> Lethe.run 75 | 76 | # Select all fields from all records 77 | # `Lethe.select_all` and `Lethe.limit(:all)` are the default settings. 78 | {:ok, all_records} = 79 | table 80 | |> Lethe.new 81 | |> Lethe.compile 82 | |> Lethe.run 83 | 84 | # Select a single field from a single record 85 | {:ok, [integer]} = 86 | table 87 | |> Lethe.new 88 | |> Lethe.limit(1) 89 | |> Lethe.select(:integer) 90 | |> Lethe.compile 91 | |> Lethe.run 92 | 93 | # Select a bunch of records at once 94 | {:ok, records} = 95 | table 96 | |> Lethe.new 97 | |> Lethe.limit(100) 98 | |> Lethe.compile 99 | |> Lethe.run 100 | 101 | # Select specific fields from a record 102 | {:ok, [{int, map}, {int, map}]} = 103 | table 104 | |> Lethe.new 105 | |> Lethe.limit(2) 106 | |> Lethe.select([:integer, :map]) 107 | |> Lethe.compile 108 | |> Lethe.run 109 | 110 | # Now let's use some operators! 111 | # Lethe internally rewrites all of these expressions into Mnesia guard form. 112 | 113 | # Select all values where :integer * 2 <= 10 114 | {:ok, res} = 115 | table 116 | |> Lethe.new 117 | |> Lethe.select(:integer) 118 | |> Lethe.where(:integer * 2 <= 10) 119 | |> Lethe.compile 120 | |> Lethe.run 121 | 122 | # Select all values where :integer * 2 >= 4 and :integer * 2 <= 10 123 | {:ok, res} = 124 | table 125 | |> Lethe.new 126 | |> Lethe.select(:integer) 127 | |> Lethe.where(:integer * 2 >= 4 and :integer * 2 <= 10) 128 | |> Lethe.compile 129 | |> Lethe.run 130 | 131 | # An example of a very complicated query 132 | {:ok, res} = 133 | table 134 | |> Lethe.new 135 | |> Lethe.select(:integer) 136 | |> Lethe.where( 137 | :integer * 2 == 666 138 | and is_map(:map) 139 | and is_map_key(:integer, :map) 140 | and map_get(:integer, :map) == :string 141 | ) 142 | |> Lethe.compile 143 | |> Lethe.run 144 | 145 | # Using external variables in queries 146 | i = 333 147 | 148 | {:ok, res} = 149 | table 150 | |> Lethe.new 151 | |> Lethe.select(:integer) 152 | |> Lethe.where(:integer * 2 == ^i * 2) 153 | |> Lethe.compile 154 | |> Lethe.run 155 | 156 | # Using atom literals in queries 157 | {:ok, res} = 158 | table 159 | |> Lethe.new 160 | |> Lethe.select(:atom) 161 | |> Lethe.where(:atom == &:atom) 162 | |> Lethe.compile 163 | |> Lethe.run 164 | 165 | # See the documentation on `Lethe.where/2` for a list of all available ops 166 | ``` -------------------------------------------------------------------------------- /lib/lethe.ex: -------------------------------------------------------------------------------- 1 | defmodule Lethe do 2 | @moduledoc """ 3 | ## Lethe 4 | 5 | Lethe is a user-friendly query DSL for Mnesia. Currently, Lethe is focused on 6 | providing a sane API for reads, but I might add support for writes later. 7 | 8 | The default options are: 9 | 10 | - Select all fields (`Lethe.select_all`) 11 | - Read lock (`:read`) 12 | - Select all values (`Lethe.limit(:all)`) 13 | 14 | ### Querying 15 | 16 | Querying is started with `Lethe.new/1`. This function takes a table name as 17 | its sole argument, and returns a new `Lethe.Query`. Querying is controlled 18 | with: 19 | 20 | - The fields that can be returned (`Lethe.select/2` / `Lethe.select_all/1`) 21 | - The number of records to return (`Lethe.limit/2`) 22 | - The specifics of how records are selected. (`Lethe.where/2`) 23 | """ 24 | 25 | use TypedStruct 26 | alias Lethe.Ops 27 | 28 | ############################################# 29 | ## Basic types for the query functionality ## 30 | ############################################# 31 | 32 | @typedoc """ 33 | The name of the Mnesia table to query against 34 | """ 35 | @type table() :: atom() 36 | 37 | @typedoc """ 38 | The fields being returned by the query. These are the names of the fields, 39 | not Mnesia's numeric selectors or anything of the like. 40 | """ 41 | @type field() :: atom() 42 | 43 | @typedoc """ 44 | The limit of values to return. If the limit is `0`, it is converted to `:all` 45 | internally. 46 | """ 47 | @type limit() :: :all | non_neg_integer() 48 | 49 | @typedoc """ 50 | The type of table lock to use. 51 | """ 52 | @type lock() :: :read | :write 53 | 54 | ## Mnesia transaction helpers ## 55 | 56 | @typedoc """ 57 | A successful transaction result. 58 | """ 59 | @type transaction_success(res) :: {:ok, res} 60 | 61 | @typedoc """ 62 | A failed transaction result. The inner term is the error returned by Mnesia. 63 | """ 64 | @type transaction_failure() :: {:error, {:transaction_aborted, term()}} 65 | 66 | @typedoc """ 67 | A result of an Mnesia transaction. 68 | """ 69 | @type transaction(res) :: transaction_success(res) | transaction_failure() 70 | 71 | @typedoc """ 72 | An Elixir expression. Used for `where` clauses. 73 | """ 74 | @type expression() :: term() 75 | 76 | ######################### 77 | ## Matchspec functions ## 78 | ######################### 79 | 80 | @typedoc """ 81 | A boolean function that can be invoked in a matchspec. These functions are 82 | used for operating on the values being queried over, such as: 83 | - "is X a pid?" 84 | - "is Y a key in X?" 85 | """ 86 | @type matchspec_bool_func() :: 87 | :is_atom 88 | | :is_float 89 | | :is_integer 90 | | :is_list 91 | | :is_number 92 | | :is_pid 93 | | :is_port 94 | | :is_reference 95 | | :is_tuple 96 | | :is_map 97 | | :map_is_key 98 | | :is_binary 99 | | :is_function 100 | | :is_record 101 | | :and 102 | | :or 103 | | :not 104 | | :xor 105 | | :andalso 106 | | :orelse 107 | 108 | @type matchspec_guard_func() :: 109 | matchspec_bool_func() 110 | | :abs 111 | | :element 112 | | :hd 113 | | :length 114 | | :map_get 115 | | :map_size 116 | | :node 117 | | :round 118 | | :size 119 | | :bit_size 120 | | :tl 121 | | :trunc 122 | | :+ 123 | | :- 124 | | :* 125 | | :div 126 | | :rem 127 | | :band 128 | | :bor 129 | | :bxor 130 | | :bnot 131 | | :bsl 132 | | :bsr 133 | | :> 134 | | :>= 135 | | :< 136 | | :"=<" 137 | | :"=:=" 138 | | :== 139 | | :"=/=" 140 | | :"/=" 141 | | :self 142 | 143 | ##################### 144 | ## Matchspec types ## 145 | ##################### 146 | 147 | @type result() :: atom() 148 | @type results() :: [result()] 149 | @type matchspec_any() :: :_ 150 | @type matchspec_all() :: :"$$" 151 | @type matchspec_variable() :: 152 | result() 153 | | matchspec_any() 154 | | matchspec_all() 155 | 156 | @type matchspec_guard() :: 157 | {matchspec_guard_func()} 158 | | {matchspec_guard_func(), matchspec_variable()} 159 | | {matchspec_guard_func(), matchspec_condition(), matchspec_condition()} 160 | | {matchspec_guard_func(), matchspec_condition(), term()} 161 | 162 | @type matchspec_condition() :: matchspec_variable() | matchspec_guard() 163 | 164 | @typedoc """ 165 | The first `tuple()` is a `{table(), result() | matchspec_any(), ...}` 166 | """ 167 | @type matchspec_element() :: {tuple(), [matchspec_condition()], results()} 168 | @type matchspec() :: [matchspec_element()] 169 | @type compiled_query() :: {table(), matchspec(), limit(), lock()} 170 | 171 | @type field_or_guard() :: field() | matchspec_guard() 172 | 173 | ############### 174 | ## Constants ## 175 | ############### 176 | 177 | @mnesia_specified_vars :"$$" 178 | 179 | ########################## 180 | ## Using macro for help ## 181 | ########################## 182 | 183 | ############# 184 | ## Structs ## 185 | ############# 186 | 187 | typedstruct module: Query do 188 | @moduledoc """ 189 | A Lethe query. Queries are what Lethe transforms into Mnesia-compatible 190 | matchspecs for processing. A query is constructed from nothing but a table 191 | name, provided to `Lethe.new/1`. Queries have sane defaults: 192 | 193 | - Return all fields 194 | - Read lock 195 | - Return all matches 196 | 197 | Queries can be updated with several functions: 198 | 199 | - `Lethe.where/2`: Add a constraint to the query, like a `WHERE` clause in 200 | SQL. 201 | - `Lethe.limit/2`: Limit the number of results returned by the query. 202 | - `Lethe.select/2`: Choose the specific fields returned by the query. 203 | - `Lethe.select_all/1`: Choose to return all fields. 204 | 205 | When you're finished creating a query, you must then compile it with 206 | `Lethe.compile/1`, which converts the query into a form that can be passed 207 | to Mnesia. Copmiled queries can be executed with `Lethe.run/1`; while it is 208 | possible to take the output from `Lethe.compile/1` and run it manually, 209 | doing so is not advised. 210 | """ 211 | 212 | field :table, Lethe.table() 213 | field :ops, [Lethe.matchspec_condition()] 214 | field :fields, %{required(Lethe.field()) => non_neg_integer()} 215 | field :select, [Lethe.field()] 216 | field :lock, Lethe.lock() 217 | field :limit, Lethe.limit() 218 | end 219 | 220 | ##################### 221 | ## Basic functions ## 222 | ##################### 223 | 224 | @doc """ 225 | Create a new query on the specified table. The names of the table attributes 226 | will be automatically loaded. 227 | """ 228 | @spec new(table()) :: __MODULE__.Query.t() 229 | def new(table) do 230 | keys = :mnesia.table_info table, :attributes 231 | key_map = 232 | keys 233 | |> Enum.with_index 234 | |> Enum.map(fn {k, i} -> {k, i + 1} end) 235 | |> Enum.into(%{}) 236 | 237 | %__MODULE__.Query{ 238 | table: table, 239 | ops: [], 240 | fields: key_map, 241 | # Select all fields by default 242 | select: [@mnesia_specified_vars], 243 | lock: :read, 244 | limit: :all, 245 | } 246 | end 247 | 248 | @doc """ 249 | Select all fields from the table. This is essentially Mnesia's `:"$$"` 250 | selector. 251 | """ 252 | @spec select_all(__MODULE__.Query.t()) :: __MODULE__.Query.t() 253 | def select_all(%__MODULE__.Query{} = query) do 254 | %{query | select: [@mnesia_specified_vars]} 255 | end 256 | 257 | @doc """ 258 | List specific fields to be selected from the table. Either a single atom or a 259 | list of atoms may be provided. 260 | """ 261 | @spec select(__MODULE__.Query.t(), field() | [field()]) :: __MODULE__.Query.t() 262 | def select(%__MODULE__.Query{} = query, field) when is_atom(field), do: select(query, [field]) 263 | 264 | def select(%__MODULE__.Query{} = query, [_ | _] = fields) do 265 | %{query | select: fields} 266 | end 267 | 268 | @doc """ 269 | Limit the number of results returned. The number is a non-negative integer, 270 | or the special atom `:all` to indicate all results being returned. Providing 271 | a limit of `0` is functionally equivalent to providing a limit of `:all`. 272 | """ 273 | @spec limit(__MODULE__.Query.t(), limit()) :: __MODULE__.Query.t() 274 | def limit(%__MODULE__.Query{} = query, :all) do 275 | %{query | limit: :all} 276 | end 277 | 278 | def limit(%__MODULE__.Query{} = query, limit) when limit >= 0 do 279 | %{query | limit: limit} 280 | end 281 | 282 | @doc """ 283 | Compiles the query into a tuple containing all the information needed to run 284 | it against an Mnesia database. 285 | """ 286 | @spec compile(__MODULE__.Query.t()) :: compiled_query() 287 | def compile(%__MODULE__.Query{ 288 | table: table, 289 | fields: fields, 290 | select: select, 291 | lock: lock, 292 | limit: limit, 293 | } = query) do 294 | # The spec is a list of matches. A match is defined as: 295 | # source = {@table, :$1, :$2, ...} 296 | # ops = [{:>, $1, 3}] 297 | # select = [:$1, :$2, ...] | [:$$] 298 | # {source, ops, select} 299 | # where: 300 | # - ops are `MatchCondition`s: https://erlang.org/doc/apps/erts/match_spec.html#grammar 301 | # - :$_ is a select-all 302 | # - :$$ is a select-all-in-match-head 303 | 304 | # Sometimes, we have fields being used in guards but not explicitly named 305 | # as part of the select, due to, say, not wanting them returned but still 306 | # wanting them to be queried on. We deal with this by recursively scanning 307 | # through all the guard tuples for any variable binds, then comparing them 308 | # to the ones we're selecting on. If a variable is selected OR bound by a 309 | # guard, then it's added to the match head so we can match on it properly. 310 | # This respects the fields the user wants returned (from `select/2`) while 311 | # still working correctly. 312 | {guard_binds, bound_guard_funcs} = search_for_unbound_variables query 313 | 314 | fields_as_vars = 315 | fields 316 | |> Enum.sort_by(&elem(&1, 1)) 317 | |> Enum.map(fn {field, index} -> 318 | all? = select == [@mnesia_specified_vars] 319 | selected? = field in select 320 | guard_bind? = MapSet.member? guard_binds, :"$#{index}" 321 | 322 | cond do 323 | not all? and (selected? or guard_bind?) -> 324 | :"$#{index}" 325 | 326 | not all? and not selected? and not guard_bind? -> 327 | :_ 328 | 329 | all? -> 330 | :"$#{index}" 331 | end 332 | end) 333 | 334 | select_as_vars = 335 | case select do 336 | [:"$$"] -> 337 | select 338 | 339 | [_ | _] when Kernel.length(select) != map_size(fields) -> 340 | [Enum.map(select, &field_to_var(query, &1))] 341 | 342 | _ -> 343 | select 344 | end 345 | 346 | source = List.to_tuple [table | fields_as_vars] 347 | matchspec = [{source, bound_guard_funcs, select_as_vars}] 348 | case limit do 349 | limit when limit in [0, :all] -> 350 | {table, matchspec, :all, lock} 351 | 352 | _ -> 353 | {table, matchspec, limit, lock} 354 | end 355 | end 356 | 357 | @doc """ 358 | Runs the query against Mnesia, ensuring that results are properly limited. 359 | """ 360 | @spec run(compiled_query()) :: transaction(term()) 361 | def run({table, matchspec, :all, lock}) do 362 | :mnesia.transaction(fn -> 363 | :mnesia.select table, matchspec, lock 364 | end) 365 | |> return_select_result_or_error 366 | end 367 | 368 | def run({table, matchspec, limit, lock}) do 369 | :mnesia.transaction(fn -> 370 | :mnesia.select table, matchspec, limit, lock 371 | end) 372 | |> return_select_result_or_error 373 | end 374 | 375 | ############# 376 | ## Helpers ## 377 | ############# 378 | 379 | defp return_select_result_or_error(mnesia_result) do 380 | case mnesia_result do 381 | {:atomic, [{match, _}]} -> 382 | {:ok, match} 383 | 384 | {:atomic, {match, _}} when is_list(match) -> 385 | # If we have this ridiculous select return result, it's suddenly really 386 | # not simple. 387 | # The data that gets returned looks like: 388 | # 389 | # { 390 | # :atomic, 391 | # { 392 | # [ 393 | # [data, ...], 394 | # ... 395 | # ], 396 | # { 397 | # op, 398 | # table, 399 | # {?, pid}, 400 | # node, 401 | # storage backend, 402 | # {ref, ?, ?, ref, ?, ?}, 403 | # ?, 404 | # ?, 405 | # ?, 406 | # query 407 | # } 408 | # } 409 | # } 410 | # 411 | # and we just care about the matches in the first element of the tuple 412 | # that comes after the :atomic. 413 | 414 | out = 415 | match 416 | |> Enum.map(fn 417 | [value] -> value 418 | [key | values] when is_list(values) and values != [] -> [key | values] |> List.flatten |> List.to_tuple 419 | value -> value 420 | end) 421 | 422 | {:ok, out} 423 | 424 | {:atomic, [_ | _] = match} -> 425 | out = 426 | match 427 | |> Enum.map(fn 428 | [x | []] -> x 429 | x when is_list(x) -> List.to_tuple x 430 | x -> x 431 | end) 432 | 433 | {:ok, out} 434 | 435 | {:atomic, []} -> 436 | {:ok, []} 437 | 438 | {:atomic, :"$end_of_table"} -> 439 | {:ok, []} 440 | 441 | {:aborted, reason} -> 442 | {:error, {:transaction_aborted, reason}} 443 | end 444 | end 445 | 446 | defp search_for_unbound_variables(%__MODULE__.Query{ops: guards} = query) when is_list(guards) do 447 | # Given a list of guards, recursively search for any and all bound 448 | # variables. This makes it easier to preserve a clean syntax, not need to 449 | # use macros, and still be able to ensure everything is properly bound. 450 | guards 451 | |> Enum.map(&search_guard(query, &1)) 452 | |> Enum.reduce({MapSet.new(), []}, fn {set, bound}, {acc, guards_out} -> 453 | acc_out = MapSet.union acc, set 454 | {acc_out, guards_out ++ [bound]} 455 | end) 456 | end 457 | 458 | defp search_guard(%__MODULE__.Query{} = query, tuple) do 459 | {vars, out} = 460 | tuple 461 | |> Tuple.to_list 462 | |> Enum.reduce({MapSet.new(), []}, fn elem, {vars, out} -> 463 | cond do 464 | is_atom(elem) -> 465 | elem 466 | |> Atom.to_string 467 | |> String.starts_with?("__lethe_literal") 468 | |> if do 469 | literal = 470 | elem 471 | |> Atom.to_string 472 | |> String.replace("__lethe_literal_", "") 473 | |> String.to_atom 474 | 475 | {vars, out ++ [literal]} 476 | else 477 | try do 478 | bind = field_to_var query, elem 479 | {MapSet.put(vars, bind), out ++ [bind]} 480 | rescue 481 | _ -> 482 | {vars, out ++ [elem]} 483 | end 484 | end 485 | 486 | 487 | is_tuple(elem) -> 488 | {inner_vars, inner_out} = search_guard query, elem 489 | {MapSet.union(vars, inner_vars), out ++ [inner_out]} 490 | 491 | true -> 492 | {vars, out ++ [elem]} 493 | end 494 | end) 495 | 496 | {vars, List.to_tuple(out)} 497 | end 498 | 499 | def field_to_var(%Lethe.Query{fields: fields}, field) do 500 | if Map.has_key?(fields, field) do 501 | field_num = Map.get fields, field 502 | :"$#{field_num}" 503 | else 504 | raise ArgumentError, "field '#{field}' not found in: #{inspect fields}" 505 | end 506 | end 507 | 508 | ################### 509 | ## Where clauses ## 510 | ################### 511 | 512 | def where_raw(%__MODULE__.Query{ops: ops} = query, raw) do 513 | %{query | ops: ops ++ [raw]} 514 | end 515 | 516 | @doc """ 517 | Adds a guard to the query. Guards are roughly analogous to `WHERE` clauses in 518 | SQL, but can operate on all the Elixir data types. Instead of needing to 519 | write out guards yourself, or suffer through the scary mess defined in 520 | `Lethe.Ops`, you can instead just write normal Elixir in your `where` calls, 521 | and Lethe will convert them into guard form. For example: 522 | 523 | # ... 524 | |> Lethe.where(:field_name * 2 <= 10) 525 | |> Lethe.where(:field_two == 7 and :field_three != "test") 526 | # ... 527 | 528 | Lethe `where` expressions are normal Elixir code. Some operators are 529 | rewritten from Elixir form to Mnesia form at compile time; for example, 530 | Elixir's `and` operator is rewritten to an `:andalso` Mnesia guard. 531 | 532 | However, Lethe `where` expressions differ from normal Elixir expressions in 533 | one major way: You cannot use external variables directly, but instead must 534 | pin them, or, in other words, use the `^` operator. For example: 535 | 536 | i = 0 537 | # ... 538 | # Note the pin (`^`) operator. This wil not work as expected otherwise, 539 | # and may return invalid results 540 | |> Lethe.where(:value == ^i * 2) 541 | 542 | A list of all available functions can be found here: 543 | https://erlang.org/doc/apps/erts/match_spec.html#grammar 544 | """ 545 | @spec where(__MODULE__.Query.t(), expression()) :: __MODULE__.Query.t() 546 | defmacro where(query, operation) do 547 | # Rewrite the expression into a form that can be safely quoted, without the 548 | # compiler yelling at us about it being an invalid AST. 549 | with {op, args} when is_atom(op) and is_list(args) <- Lethe.AstTransformer.__rewrite_into_quotable_form(operation) do 550 | # Once the primary op is rewritten, rewrite the args to the primary op as 551 | # well. This will recursively ensure the validity of all expressions. 552 | args = Enum.map args, &Lethe.AstTransformer.__rewrite_into_quotable_form/1 553 | quote do 554 | # Rewrite the op into an Mnesia-guard-friendly form as needed. 555 | rewritten_op = Lethe.AstTransformer.__rewrite_op unquote(op) 556 | # Transform all the args as well. 557 | transformed_args = Enum.map unquote(args), &Lethe.AstTransformer.__transform_op(unquote(query), &1) 558 | # Apply the relevant op functions to transform the expression into its 559 | # final Mnesia guard form. 560 | guard = apply Ops, rewritten_op, transformed_args 561 | # Add it to the query's ops. 562 | %{unquote(query) | ops: unquote(query).ops ++ [guard]} 563 | end 564 | end 565 | end 566 | 567 | defmodule AstTransformer do 568 | # More/less all of these functions recurse so that everything can be 569 | # properly processed. 570 | 571 | alias Lethe.Ops 572 | 573 | def __rewrite_into_quotable_form({:^, _, [{var, meta, nil}]}) do 574 | # When we're passed a pinned variable, rewrite it into the literal AST 575 | # for the variable, so that it gets used correctly 576 | quote do 577 | unquote({var, meta, nil}) 578 | end 579 | end 580 | def __rewrite_into_quotable_form({:&, _meta, [atom]}) do 581 | # When we're passed an &variable, rewrite it into the literal atom 582 | quote do 583 | unquote(String.to_atom("__lethe_literal_" <> Atom.to_string(atom))) 584 | end 585 | end 586 | def __rewrite_into_quotable_form({op, _, args}) when is_list(args) do 587 | # When we run into something that has args, we need to rewrite all of 588 | # them into quotable form so that they can be used properly. 589 | {op, __MODULE__.__rewrite_args(args)} 590 | end 591 | def __rewrite_into_quotable_form(op), do: op 592 | 593 | def __rewrite_args(args) when is_list(args), do: Enum.map(args, &Lethe.AstTransformer.__rewrite_into_quotable_form/1) 594 | def __rewrite_args(nil), do: nil 595 | 596 | def __transform_op(query, {op, args}) when is_list(args) do 597 | # Transform ops from Elixir form into Mnesia guard form where needed. 598 | apply Ops, __MODULE__.__rewrite_op(op), Enum.map(args, &__MODULE__.__transform_op(query, &1)) 599 | end 600 | def __transform_op(_, op), do: op 601 | 602 | def __rewrite_op(op) when is_atom(op) do 603 | case op do 604 | :"<=" -> :"=<" 605 | :and -> :andalso 606 | :or -> :orelse 607 | _ -> op 608 | end 609 | end 610 | end 611 | end 612 | -------------------------------------------------------------------------------- /lib/lethe/ops.ex: -------------------------------------------------------------------------------- 1 | defmodule Lethe.Ops do 2 | @moduledoc """ 3 | Auto-generated Mnesia guard helpers. YOU DO NOT WANT TO USE THIS MODULE EVER. 4 | """ 5 | 6 | import Kernel, except: [ 7 | is_atom: 1, 8 | is_float: 1, 9 | is_integer: 1, 10 | is_list: 1, 11 | is_number: 1, 12 | is_pid: 1, 13 | is_port: 1, 14 | is_reference: 1, 15 | is_tuple: 1, 16 | is_map: 1, 17 | is_map_key: 2, 18 | is_binary: 1, 19 | is_function: 1, 20 | ] 21 | 22 | @is_funcs [ 23 | :is_atom, 24 | :is_float, 25 | :is_integer, 26 | :is_list, 27 | :is_number, 28 | :is_pid, 29 | :is_port, 30 | :is_reference, 31 | :is_tuple, 32 | :is_map, 33 | :is_binary, 34 | :is_function, 35 | # TODO: This can't actually be defined as a function, how to fix? 36 | # :is_record, 37 | ] 38 | 39 | @logical_funcs [ 40 | :andalso, 41 | :orelse, 42 | :not, 43 | :xor, 44 | ] 45 | 46 | @transform_funcs [ 47 | :abs, 48 | :element, 49 | :hd, 50 | :length, 51 | :map_size, 52 | :round, 53 | :size, 54 | :bit_size, 55 | :tl, 56 | :trunc, 57 | ] 58 | 59 | @operator_funcs [ 60 | :+, 61 | :-, 62 | :*, 63 | :div, 64 | :rem, 65 | :band, 66 | :bor, 67 | :bxor, 68 | :bnot, 69 | :bsl, 70 | :bsr, 71 | :>, 72 | :>=, 73 | :<, 74 | :"=<", 75 | :"=:=", 76 | :==, 77 | :"=/=", 78 | :"/=", 79 | :is_map_key, 80 | :map_get, 81 | ] 82 | 83 | @constant_funcs [ 84 | :node, 85 | :self, 86 | ] 87 | 88 | @all_funcs MapSet.new( 89 | @is_funcs 90 | ++ @logical_funcs 91 | ++ @transform_funcs 92 | ++ @operator_funcs 93 | ++ @constant_funcs 94 | ) 95 | 96 | def all_funcs, do: @all_funcs 97 | 98 | for f <- @is_funcs do 99 | # @spec unquote(f)(Lethe.field()) :: Lethe.matchspec_guard() 100 | def unquote(f)(key) do 101 | {unquote(f), key} 102 | end 103 | end 104 | 105 | for f <- @logical_funcs do 106 | # @spec unquote(f)(Lethe.matchspec_guard(), Lethe.matchspec_guard()) :: Lethe.matchspec_guard 107 | def unquote(f)(left, right) do 108 | {unquote(f), left, right} 109 | end 110 | end 111 | 112 | for f <- @transform_funcs do 113 | # @spec unquote(f)(Lethe.field() | Lethe.matchspec_guard) :: Lethe.matchspec_guard() 114 | def unquote(f)(value) do 115 | {unquote(f), value} 116 | end 117 | end 118 | 119 | for f <- @operator_funcs do 120 | # @spec unquote(f)(Lethe.field() | Lethe.matchspec_guard(), Lethe.field | Lethe.matchspec_guard()) :: Lethe.matchspec_guard 121 | def unquote(f)(left, right) do 122 | {unquote(f), left, right} 123 | end 124 | end 125 | 126 | for f <- @constant_funcs do 127 | # @spec unquote(f)() :: Lethe.matchspec_guard() 128 | def unquote(f)() do 129 | {unquote(f)} 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Lethe.MixProject do 2 | use Mix.Project 3 | 4 | @repo_url "https://github.com/queer/lethe" 5 | 6 | def project do 7 | [ 8 | app: :lethe, 9 | version: "0.6.0", 10 | elixir: "~> 1.11", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | 14 | # Hex 15 | description: "A friendly query DSL for Mnesia", 16 | package: [ 17 | maintainers: ["amy"], 18 | links: %{"GitHub" => @repo_url}, 19 | licenses: ["MIT"], 20 | ], 21 | 22 | # Docs 23 | name: "Lethe", 24 | docs: [ 25 | homepage_url: @repo_url, 26 | source_url: @repo_url, 27 | extras: [ 28 | "README.md", 29 | ], 30 | ], 31 | ] 32 | end 33 | 34 | def application do 35 | [ 36 | extra_applications: [:logger, :mnesia], 37 | ] 38 | end 39 | 40 | defp deps do 41 | [ 42 | {:typed_struct, "~> 0.2.1"}, 43 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 3 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 4 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, 6 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 7 | "typed_struct": {:hex, :typed_struct, "0.2.1", "e1993414c371f09ff25231393b6430bd89d780e2a499ae3b2d2b00852f593d97", [:mix], [], "hexpm", "8f5218c35ec38262f627b2c522542f1eae41f625f92649c0af701a6fab2e11b3"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/lethe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LetheTest do 2 | use ExUnit.Case 3 | doctest Lethe 4 | 5 | @table :table 6 | 7 | setup_all do 8 | :mnesia.create_schema [] 9 | :mnesia.start() 10 | :mnesia.create_table @table, [attributes: [:integer, :string, :map, :atom]] 11 | :mnesia.add_table_index @table, :integer 12 | :mnesia.add_table_index @table, :string 13 | :mnesia.add_table_index @table, :map 14 | 15 | for i <- 1..10_000 do 16 | s = n() |> Integer.to_string 17 | :mnesia.dirty_write {@table, i, s, %{i => s}, :"#{i}"} 18 | end 19 | 20 | on_exit fn -> 21 | :mnesia.delete_table @table 22 | :mnesia.delete_schema [] 23 | :mnesia.stop() 24 | end 25 | end 26 | 27 | defp n, do: :rand.uniform 1_000_000_000_000_000_000_000_000_000_000_000_000_000_000_000 28 | 29 | test "run without ops works" do 30 | {:ok, [res]} = 31 | @table 32 | |> Lethe.new 33 | |> Lethe.select_all 34 | |> Lethe.limit(1) 35 | |> Lethe.compile 36 | |> Lethe.run 37 | 38 | {integer, string, map, atom} = res 39 | assert is_integer(integer) 40 | assert integer >= 0 41 | assert String.valid?(string) 42 | assert is_map(map) 43 | assert 1 == map_size(map) 44 | assert is_atom(atom) 45 | end 46 | 47 | describe "select/2" do 48 | test "selects single fields" do 49 | base = 50 | @table 51 | |> Lethe.new 52 | |> Lethe.limit(1) 53 | 54 | {:ok, [int]} = 55 | base 56 | |> Lethe.select(:integer) 57 | |> Lethe.compile 58 | |> Lethe.run 59 | 60 | assert is_integer(int) 61 | assert int >= 0 62 | 63 | {:ok, [string]} = 64 | base 65 | |> Lethe.select(:string) 66 | |> Lethe.compile 67 | |> Lethe.run 68 | 69 | assert is_binary(string) 70 | assert String.valid?(string) 71 | 72 | {:ok, [map]} = 73 | base 74 | |> Lethe.select(:map) 75 | |> Lethe.compile 76 | |> Lethe.run 77 | 78 | assert is_map(map) 79 | assert 1 == map_size(map) 80 | end 81 | 82 | test "selects adjacent fields" do 83 | {:ok, [{int, string}]} = 84 | @table 85 | |> Lethe.new 86 | |> Lethe.select([:integer, :string]) 87 | |> Lethe.limit(1) 88 | |> Lethe.compile 89 | |> Lethe.run 90 | 91 | assert is_integer(int) 92 | assert int >= 0 93 | assert is_binary(string) 94 | assert String.valid?(string) 95 | end 96 | 97 | test "selects non-adjacent fields" do 98 | {:ok, [{int, map}]} = 99 | @table 100 | |> Lethe.new 101 | |> Lethe.select([:integer, :map]) 102 | |> Lethe.limit(1) 103 | |> Lethe.compile 104 | |> Lethe.run 105 | 106 | assert is_integer(int) 107 | assert int >= 0 108 | assert is_map(map) 109 | assert 1 == map_size(map) 110 | end 111 | 112 | test "selects many records" do 113 | {:ok, results} = 114 | @table 115 | |> Lethe.new 116 | |> Lethe.select_all 117 | |> Lethe.limit(100) 118 | |> Lethe.compile 119 | |> Lethe.run 120 | 121 | assert 100 = length(results) 122 | end 123 | 124 | test "selects everything correctly" do 125 | {:ok, results} = 126 | @table 127 | |> Lethe.new 128 | |> Lethe.select_all 129 | |> Lethe.compile 130 | |> Lethe.run 131 | 132 | assert 10_000 == length(results) 133 | end 134 | end 135 | 136 | describe "where/2" do 137 | test "handles is-functions correctly" do 138 | {:ok, [integer]} = 139 | @table 140 | |> Lethe.new 141 | |> Lethe.select(:integer) 142 | |> Lethe.limit(1) 143 | |> Lethe.where(is_integer(:integer)) 144 | |> Lethe.compile 145 | |> Lethe.run 146 | 147 | assert is_integer(integer) 148 | assert integer >= 0 149 | end 150 | 151 | test "handles logical functions correctly" do 152 | {:ok, [integer]} = 153 | @table 154 | |> Lethe.new 155 | |> Lethe.select(:integer) 156 | |> Lethe.limit(1) 157 | |> Lethe.where(is_integer(:integer) and is_binary(:string)) 158 | |> Lethe.compile 159 | |> Lethe.run 160 | 161 | assert is_integer(integer) 162 | assert integer >= 0 163 | end 164 | 165 | test "handles comparison functions correctly" do 166 | {:ok, [{integer, string}]} = 167 | @table 168 | |> Lethe.new 169 | |> Lethe.select([:integer, :string]) 170 | |> Lethe.where(:integer == 5) 171 | |> Lethe.compile 172 | |> Lethe.run 173 | 174 | assert 5 == integer 175 | assert is_binary(string) 176 | assert String.valid?(string) 177 | end 178 | 179 | test "works when many operators used" do 180 | {:ok, res} = 181 | @table 182 | |> Lethe.new 183 | |> Lethe.select(:integer) 184 | |> Lethe.limit(:all) 185 | |> Lethe.where(:integer * 2 <= 10) 186 | |> Lethe.compile 187 | |> Lethe.run 188 | 189 | # We can't guarantee term ordering, so it's necessary to sort the output 190 | # first. 191 | assert [1, 2, 3, 4, 5] == Enum.sort(res) 192 | end 193 | 194 | test "handles map_size properly" do 195 | {:ok, [map]} = 196 | @table 197 | |> Lethe.new 198 | |> Lethe.select(:map) 199 | |> Lethe.limit(1) 200 | |> Lethe.where(map_size(:map) == 1) 201 | |> Lethe.compile 202 | |> Lethe.run 203 | 204 | assert 1 == map_size(map) 205 | end 206 | 207 | test "handles is_map_key properly" do 208 | {:ok, [{integer, map}]} = 209 | @table 210 | |> Lethe.new 211 | |> Lethe.select([:integer, :map]) 212 | |> Lethe.limit(1) 213 | |> Lethe.where(:integer == 1) 214 | |> Lethe.where(is_map_key(1, :map)) 215 | |> Lethe.compile 216 | |> Lethe.run 217 | 218 | assert 1 == integer 219 | assert 1 == map_size(map) 220 | assert Map.has_key?(map, 1) 221 | end 222 | 223 | test "chains lots of operators correctly" do 224 | {:ok, [integer]} = 225 | @table 226 | |> Lethe.new 227 | |> Lethe.select(:integer) 228 | |> Lethe.where( 229 | :integer * 2 == 666 230 | and is_map(:map) 231 | and is_map_key(:integer, :map) 232 | and map_get(:integer, :map) == :string 233 | ) 234 | |> Lethe.compile 235 | |> Lethe.run 236 | 237 | assert 333 == integer 238 | end 239 | 240 | test "binds external variables correctly" do 241 | i = 333 242 | {:ok, [integer]} = 243 | @table 244 | |> Lethe.new 245 | |> Lethe.select(:integer) 246 | |> Lethe.where(:integer * 2 == ^i * 2) 247 | |> Lethe.compile 248 | |> Lethe.run 249 | 250 | assert 333 == integer 251 | 252 | a = 5 253 | b = 10 254 | {:ok, [integer]} = 255 | @table 256 | |> Lethe.new 257 | |> Lethe.select(:integer) 258 | |> Lethe.where(:integer == ^a and :integer * 2 == ^b) 259 | |> Lethe.compile 260 | |> Lethe.run 261 | 262 | assert 5 == integer 263 | end 264 | 265 | test "&-binds atom literals correctly" do 266 | {:ok, [integer]} = 267 | @table 268 | |> Lethe.new 269 | |> Lethe.select(:integer) 270 | |> Lethe.where(:atom == &:"5") 271 | |> Lethe.compile 272 | |> Lethe.run 273 | 274 | assert 5 == integer 275 | end 276 | end 277 | 278 | describe "where_raw/2" do 279 | test "allows raw matchspecs properly" do 280 | {:ok, [integer]} = 281 | @table 282 | |> Lethe.new 283 | |> Lethe.select(:integer) 284 | |> Lethe.where_raw({:==, :integer, 5}) 285 | |> Lethe.compile 286 | |> Lethe.run 287 | 288 | assert 5 == integer 289 | end 290 | end 291 | end 292 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------