├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── daat.ex └── daat │ ├── dependency.ex │ └── invalid_dependency_error.ex ├── mix.exs ├── mix.lock └── test ├── daat_test.exs ├── examples ├── interval_test.exs └── queue_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 | daat-*.tar 24 | 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased] 6 | 7 | ## [0.2.0] - 2020-03-10 8 | 9 | ### Added 10 | 11 | - Validation of behaviours when specified in a dependency declaration. This may cause compilation errors in previously compiling code if that code makes use of an incomplete or invalid behaviour implementation -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Quinn Wilton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Daat 2 | [![hex.pm version](https://img.shields.io/hexpm/v/daat.svg?style=flat)](https://hex.pm/packages/daat) [![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](http://hexdocs.pm/daat/) [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](https://github.com/quinnwilton/daat/blob/master/LICENSE) 3 | 4 | > Daʻat is not always depicted in representations of the sefirot; and could be abstractly considered an "empty slot" into which the germ of any other sefirot can be placed. 5 | > — [Wikipedia](https://en.wikipedia.org/wiki/Da%27at) 6 | 7 | Daat is an experimental library meant to provide [parameterized modules](https://caml.inria.fr/pub/docs/oreilly-book/html/book-ora132.html) to Elixir. 8 | 9 | This library is mostly untested, and should be used at your risk. 10 | 11 | ## Installation 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:daat, "~> 0.2.0"} 17 | ] 18 | end 19 | ``` 20 | 21 | ## Examples 22 | 23 | Examples can be found in the [test](https://github.com/quinnwilton/daat/blob/master/test/examples) directory 24 | 25 | - [Data Structural Bootstrapping](https://github.com/QuinnWilton/daat/blob/master/test/examples/queue_test.exs) 26 | - [Polymorphic Intervals](https://github.com/QuinnWilton/daat/blob/master/test/examples/interval_test.exs) 27 | 28 | ## Motivation 29 | 30 | Imagine that you have a module named `UserService`, that exposes a function named `follow/2`. When called, the system sends an email to the user being followed. It would be nice if we could extract actually sending the email from this module, so that we aren't coupling ourselves to a specific email client, and so that we can inject mocks into the service for testing purposes. 31 | 32 | Typically, Elixir programmers might do this in one of two ways: 33 | 34 | - Adding a `send_email` argument to the function, which expects a callback responsible for sending the email 35 | - Fetching the implementation of `send_email` from configuration at runtime 36 | 37 | Both of these approaches work, but they have some drawbacks: 38 | 39 | - Adding callbacks to all of our function signatures shifts complexity to the caller, and makes for more complicated function signatures 40 | - Storing callbacks in global configuration means losing out on the ability to run multiple instances of the module at once. This might be okay for production environments, but in testing it removes the ability to run all of your tests concurrently 41 | - Because this dependency injection happens at runtime, we are unable to confirm, at compile-time, that the dependencies being passed to a module conform to that modue's requirements 42 | 43 | By using parameterized, or higher-order modules, we can instead define a module that specifies an interface, and acts as a generator for modules of that interface. By then passing our dependencies to this generator, we are able to dynamically create new modules that implement our desired behaviour. This approach addresses all three points above. 44 | 45 | That being said, this library is highly experimental, and I'm still working out the ideal interface and syntax for supportng this behaviour. If you have ideas, I'd love to hear them! 46 | 47 | Here's an example of the above use-case: 48 | 49 | ```elixir 50 | import Daat 51 | 52 | # UserService has one dependency: a function named `send_email/2` 53 | defpmodule UserService, send_email: 2 do 54 | def follow(user, follower) do 55 | send_email().(user.email, "You have been followed by: #{follower.name}") 56 | end 57 | end 58 | 59 | definst(UserService, MockUserService, send_email: fn to, body -> :ok end) 60 | 61 | user = %{name: "Janice", email: "janice@example.com"} 62 | follower = %{name: "Chris", email: "chris@example.com"} 63 | 64 | MockUserService.follow(user, follower) 65 | ``` 66 | 67 | You're also able to specify that a dependency should be a module. If that module defines a behaviour, then the dependency will be validated as implementating that behaviour. 68 | 69 | ```elixir 70 | import Daat 71 | 72 | defmodule Mailer do 73 | @callback send_email(to :: String.t(), body :: String.t()) :: :ok 74 | end 75 | 76 | defmodule MockMailer do 77 | @behaviour Mailer 78 | 79 | @impl Mailer 80 | def send_email(_to, _body) do 81 | :ok 82 | end 83 | end 84 | 85 | # UserService has one dependency: a function named `send_email/2` 86 | defpmodule UserService, mailer: Mailer do 87 | def follow(user, follower) do 88 | mailer().send_email(user.email, "You have been followed by: #{follower.name}") 89 | end 90 | end 91 | 92 | definst(UserService, MockUserService, mailer: MockMailer) 93 | 94 | user = %{name: "Janice", email: "janice@example.com"} 95 | follower = %{name: "Chris", email: "chris@example.com"} 96 | 97 | MockUserService.follow(user, follower) 98 | ``` 99 | 100 | ## Acknowledgements 101 | 102 | This library was inspired by [a talk given by @expede](https://codesync.global/speaker/brooklyn-zelenka/#623old-ideas-made-new) at Code BEAM SF 2020 -------------------------------------------------------------------------------- /lib/daat.ex: -------------------------------------------------------------------------------- 1 | defmodule Daat do 2 | defmacro defpmodule(name, dependencies, do: body) do 3 | quote do 4 | defmodule unquote(name) do 5 | def __dependencies__() do 6 | unquote(dependencies) 7 | end 8 | 9 | defmacro __using__(_opts) do 10 | unquote(Macro.escape(body)) 11 | end 12 | end 13 | end 14 | end 15 | 16 | defmacro definst(pmodule, instance, dependencies) do 17 | quote do 18 | require Daat.Dependency 19 | 20 | Daat.Dependency.validate( 21 | unquote(dependencies), 22 | unquote(pmodule), 23 | unquote(instance) 24 | ) 25 | 26 | defmodule unquote(instance) do 27 | use unquote(pmodule) 28 | 29 | Daat.Dependency.inject(unquote(dependencies), unquote(pmodule)) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/daat/dependency.ex: -------------------------------------------------------------------------------- 1 | defmodule Daat.Dependency do 2 | alias Daat.InvalidDependencyError 3 | 4 | def validate(dependencies, pmodule, instance) do 5 | for {dep_name, dep_value} <- dependencies do 6 | decl = Keyword.fetch!(pmodule.__dependencies__, dep_name) 7 | 8 | valid = 9 | cond do 10 | is_integer(decl) -> 11 | is_function(dep_value, decl) 12 | 13 | is_atom(decl) -> 14 | Code.ensure_loaded(decl) 15 | 16 | cond do 17 | function_exported?(decl, :behaviour_info, 1) and is_atom(dep_value) -> 18 | Code.ensure_loaded(dep_value) 19 | 20 | Enum.all?(decl.behaviour_info(:callbacks), fn {fun, arity} -> 21 | function_exported?(dep_value, fun, arity) 22 | end) 23 | 24 | :else -> 25 | is_atom(dep_value) 26 | end 27 | end 28 | 29 | if not valid do 30 | raise InvalidDependencyError.new(pmodule, instance, dep_name) 31 | end 32 | end 33 | end 34 | 35 | defmacro inject(dependencies, pmodule) do 36 | dependency_reference = 37 | quote unquote: false do 38 | unquote(dependency) 39 | end 40 | 41 | quote do 42 | for {dependency, _value} <- unquote(pmodule).__dependencies__() do 43 | defp unquote(dependency_reference)() do 44 | Keyword.fetch!(unquote(dependencies), unquote(dependency_reference)) 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/daat/invalid_dependency_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Daat.InvalidDependencyError do 2 | alias __MODULE__ 3 | 4 | @type t :: %InvalidDependencyError{ 5 | message: String.t(), 6 | pmodule: module(), 7 | instance: module(), 8 | dependency: atom() 9 | } 10 | 11 | defexception message: "Invalid dependency passed", 12 | pmodule: nil, 13 | instance: nil, 14 | dependency: nil 15 | 16 | @spec new(module(), module(), atom()) :: t() 17 | def new(pmodule, instance, dependency_name) do 18 | %InvalidDependencyError{ 19 | pmodule: pmodule, 20 | instance: instance, 21 | dependency: dependency_name, 22 | message: "#{instance} does not conform to the definition of #{pmodule}.#{dependency_name}" 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Daat.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :daat, 7 | version: "0.2.0", 8 | elixir: "~> 1.10", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | 12 | # Docs 13 | name: "Daat", 14 | docs: docs(), 15 | 16 | # Hex 17 | description: "Parameterized modules for Elixir", 18 | package: package() 19 | ] 20 | end 21 | 22 | defp deps do 23 | [ 24 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 25 | {:stream_data, "~> 0.1", only: :test} 26 | ] 27 | end 28 | 29 | defp docs do 30 | [ 31 | extras: ["README.md"], 32 | main: "readme", 33 | source_url: "https://github.com/quinnwilton/daat" 34 | ] 35 | end 36 | 37 | defp package do 38 | [ 39 | licenses: ["MIT"], 40 | links: %{"GitHub" => "https://github.com/quinnwilton/daat"}, 41 | maintainers: ["Quinn Wilton"] 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 3 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, 4 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 6 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 7 | "stream_data": {:hex, :stream_data, "0.4.3", "62aafd870caff0849a5057a7ec270fad0eb86889f4d433b937d996de99e3db25", [:mix], [], "hexpm", "7dafd5a801f0bc897f74fcd414651632b77ca367a7ae4568778191fc3bf3a19a"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/daat_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DaatTest do 2 | use ExUnit.Case 3 | 4 | import Daat 5 | 6 | alias Daat.InvalidDependencyError 7 | 8 | defmodule Encoder do 9 | @callback encode!(term) :: binary 10 | end 11 | 12 | defmodule MapEncoder do 13 | @behaviour Encoder 14 | 15 | def encode!(%{__struct__: _} = struct) do 16 | Map.drop(struct, [:__struct__]) 17 | end 18 | end 19 | 20 | defpmodule Person, encoder: Encoder, formatter: 1 do 21 | defstruct [:name] 22 | 23 | def speak(%__MODULE__{name: name}, message) do 24 | "#{name}: #{formatter().(message)}" 25 | end 26 | 27 | def encode!(%__MODULE__{} = person) do 28 | encoder().encode!(person) 29 | end 30 | end 31 | 32 | definst(Person, LoudPerson, encoder: MapEncoder, formatter: fn s -> String.upcase(s) end) 33 | definst(Person, QuietPerson, encoder: MapEncoder, formatter: fn s -> String.downcase(s) end) 34 | 35 | test "parameterized modules are generated" do 36 | loud_person = %LoudPerson{name: "Joe"} 37 | quiet_person = %QuietPerson{name: "Mike"} 38 | 39 | assert "Joe: HELLO MIKE" == LoudPerson.speak(loud_person, "hello mike") 40 | assert %{name: "Joe"} == LoudPerson.encode!(loud_person) 41 | 42 | assert "Mike: hello joe" == QuietPerson.speak(quiet_person, "HELLO JOE") 43 | assert %{name: "Mike"} == QuietPerson.encode!(quiet_person) 44 | end 45 | 46 | test "function dependencies are validated by arity" do 47 | defpmodule Validation.FunctionDependency, dep: 2 do 48 | def call(a, b) do 49 | dep().(a, b) 50 | end 51 | end 52 | 53 | assert_raise(InvalidDependencyError, fn -> 54 | definst(Validation.FunctionDependency, Validation.FunctionDependency.TooFew, 55 | dep: fn a -> a end 56 | ) 57 | end) 58 | 59 | assert_raise(InvalidDependencyError, fn -> 60 | definst(Validation.FunctionDependency, Validation.FunctionDependency.TooMany, 61 | dep: fn a, b, c -> a * b * c end 62 | ) 63 | end) 64 | 65 | definst(Validation.FunctionDependency, Validation.FunctionDependency.Valid, 66 | dep: fn a, b -> a * b end 67 | ) 68 | 69 | assert 6 == Validation.FunctionDependency.Valid.call(2, 3) 70 | end 71 | 72 | test "module dependencies are validated" do 73 | defmodule Validation.ModuleDependency.Behaviour do 74 | @callback call(term) :: term 75 | end 76 | 77 | defmodule Validation.ModuleDependency.Behaviour.Impl do 78 | def call(a), do: a 79 | end 80 | 81 | defmodule Validation.ModuleDependency.Behaviour.Wrong do 82 | def foo(), do: 5 83 | end 84 | 85 | defpmodule Validation.ModuleDependency, dep: Validation.ModuleDependency.Behaviour do 86 | def call(a) do 87 | dep().call(a) 88 | end 89 | end 90 | 91 | assert_raise(InvalidDependencyError, fn -> 92 | definst(Validation.ModuleDependency, Validation.ModuleDependency.WrongType, 93 | dep: fn a -> a end 94 | ) 95 | end) 96 | 97 | assert_raise(InvalidDependencyError, fn -> 98 | definst(Validation.ModuleDependency, Validation.ModuleDependency.WrongBehaviour, 99 | dep: Validation.ModuleDependency.Behaviour.Wrong 100 | ) 101 | end) 102 | 103 | definst(Validation.ModuleDependency, Validation.ModuleDependency.Valid, 104 | dep: Validation.ModuleDependency.Behaviour.Impl 105 | ) 106 | 107 | assert 5 == Validation.ModuleDependency.Valid.call(5) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/examples/interval_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Daat.Examples.IntervalTest do 2 | use ExUnit.Case 3 | 4 | import Daat 5 | 6 | @moduledoc """ 7 | In this example, we define a parameterized module, Interval, which provides 8 | a generic interface for constructing and working with intervals of arbitrary 9 | elements. 10 | 11 | We impose that an instance of Interval includes a module, `comparable`, which 12 | defines a `compare/2` function. Note that this is the same interface used by 13 | Elixir's built-in sorting functions, as of Elixir 1.10. As a result, we are 14 | able to easily instantiate the module using the `Date` or `DateTime` modules, 15 | in order to define a module meant for working with intervals or `Date` or 16 | `DateTime` elements respectively. 17 | 18 | I don't do it in this example, but if we were to define property tests for the 19 | abstract Interval pmodule, we would then be able to easily test that instances 20 | of the Interval pmodule pass those same tests. 21 | """ 22 | 23 | defmodule Comparable do 24 | @callback compare(term, term) :: :lt | :eq | :gt 25 | end 26 | 27 | defmodule ComparableNumber do 28 | def compare(a, b) do 29 | cond do 30 | a < b -> :lt 31 | a == b -> :eq 32 | a > b -> :gt 33 | end 34 | end 35 | end 36 | 37 | defpmodule Interval, comparable: Comparable do 38 | @type t :: 39 | {term, term} 40 | | :empty 41 | 42 | def create(low, high) do 43 | if comparable().compare(low, high) == :gt do 44 | :empty 45 | else 46 | {low, high} 47 | end 48 | end 49 | 50 | def empty?(:empty), do: true 51 | def empty?(_), do: false 52 | 53 | def contains?(:empty, _), do: false 54 | 55 | def contains?({low, high}, item) do 56 | compare_low = comparable().compare(item, low) 57 | compare_high = comparable().compare(item, high) 58 | 59 | case {compare_low, compare_high} do 60 | {:eq, _} -> true 61 | {_, :eq} -> true 62 | {:gt, :lt} -> true 63 | _ -> false 64 | end 65 | end 66 | 67 | def intersect(t1, t2) do 68 | min = fn x, y -> 69 | case comparable().compare(x, y) do 70 | :lt -> x 71 | _ -> y 72 | end 73 | end 74 | 75 | max = fn x, y -> 76 | case comparable().compare(x, y) do 77 | :gt -> x 78 | _ -> y 79 | end 80 | end 81 | 82 | case {t1, t2} do 83 | {:empty, _} -> 84 | :empty 85 | 86 | {_, :empty} -> 87 | :empty 88 | 89 | {{l1, h1}, {l2, h2}} -> 90 | create(max.(l1, l2), min.(h1, h2)) 91 | end 92 | end 93 | end 94 | 95 | definst(Interval, DateInterval, comparable: Date) 96 | definst(Interval, DateTimeInterval, comparable: DateTime) 97 | definst(Interval, NumberInterval, comparable: ComparableNumber) 98 | 99 | test "contains?/2 works with Dates" do 100 | today = Date.utc_today() 101 | 102 | yesterday = Date.add(today, -1) 103 | tomorrow = Date.add(today, 1) 104 | 105 | ereyesterday = Date.add(today, -2) 106 | overmorrow = Date.add(today, 2) 107 | 108 | interval = DateInterval.create(yesterday, tomorrow) 109 | 110 | assert true == DateInterval.contains?(interval, yesterday) 111 | assert true == DateInterval.contains?(interval, today) 112 | assert true == DateInterval.contains?(interval, tomorrow) 113 | assert false == DateInterval.contains?(interval, ereyesterday) 114 | assert false == DateInterval.contains?(interval, overmorrow) 115 | end 116 | 117 | test "contains?/2 works with DateTimes" do 118 | datetime1 = DateTime.utc_now() 119 | datetime2 = DateTime.add(datetime1, 1) 120 | datetime3 = DateTime.add(datetime1, 2) 121 | datetime4 = DateTime.add(datetime1, 3) 122 | datetime5 = DateTime.add(datetime1, 4) 123 | 124 | interval = DateTimeInterval.create(datetime2, datetime4) 125 | 126 | assert true == DateTimeInterval.contains?(interval, datetime2) 127 | assert true == DateTimeInterval.contains?(interval, datetime3) 128 | assert true == DateTimeInterval.contains?(interval, datetime4) 129 | assert false == DateTimeInterval.contains?(interval, datetime1) 130 | assert false == DateTimeInterval.contains?(interval, datetime5) 131 | end 132 | 133 | test "contains?/2 works with numbers" do 134 | interval = NumberInterval.create(0, 10) 135 | 136 | assert true == NumberInterval.contains?(interval, 0) 137 | assert true == NumberInterval.contains?(interval, 5) 138 | assert true == NumberInterval.contains?(interval, 10) 139 | assert false == NumberInterval.contains?(interval, -1) 140 | assert false == NumberInterval.contains?(interval, 11) 141 | end 142 | 143 | test "intersect/2" do 144 | interval1 = NumberInterval.create(0, 10) 145 | interval2 = NumberInterval.create(3, 15) 146 | 147 | assert {3, 10} == NumberInterval.intersect(interval1, interval2) 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /test/examples/queue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Daat.Examples.QueueTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | import Daat 6 | 7 | @moduledoc """ 8 | This is a really silly example, but hear me out. 9 | 10 | Here I define a behaviour for a Stack data structure, and then I define 11 | a parameterized Queue module that gets defined in terms of a Stack. This 12 | allows me to instantiate Queues using different underlying Stack 13 | implementations. 14 | 15 | There's a few reasons you might want to do this: 16 | 1) Oftentimes, efficient purely functional datastructures are defined 17 | using a technique known as data-structural bootstrapping, in which 18 | a simple (but inefficient) data structure is used to construct 19 | an efficient one. For more information on this, see 20 | [Purely Functional Data Structures](https://www.cs.cmu.edu/~rwh/theses/okasaki.pdf) 21 | 22 | 2) Sometimes, for efficiency reasons, we need to implement a complicated, 23 | but hard to verify data structure. If such a data structure is difficult 24 | to test, it may be worthwhile to also implement a simpler, inefficient 25 | data structure, that can be used as an oracle for testing the complicated 26 | structure. By then depending on a parameterized version of the data 27 | structure, we can avoid duplicating that code for both variants, and 28 | needing to maintain and test both copies independently. 29 | 30 | It's a contrived example, but this test acts as a demonstration of both 31 | techniques. 32 | 33 | First, we demonstrate how an efficient queue can be implemented in terms of 34 | two (abstract) stacks. 35 | 36 | Then we demonstrate how property testing can be used to verify that a simple 37 | implementation of such a queue (using a list) is equivalent to a more 38 | complicated implementation (using Church encodings). 39 | 40 | In practice, this example doesn't make a ton of sense, but it was fun to write, 41 | and I think it shows off the power of using one implementation as an oracle 42 | to test another. I don't know about you, but I can't tell that ChurchStack 43 | is correct just from looking at it. 44 | """ 45 | 46 | defmodule Stack do 47 | @callback empty() :: term() 48 | @callback empty?(term()) :: boolean() 49 | @callback push(term(), term()) :: term() 50 | @callback pop(term()) :: {:ok, term()} | :empty 51 | @callback top(term()) :: {:ok, term()} | :empty 52 | end 53 | 54 | defmodule ListStack do 55 | @behaviour Stack 56 | 57 | def empty(), do: [] 58 | 59 | def empty?([]), do: true 60 | def empty?(_), do: false 61 | 62 | def push(s, x), do: [x | s] 63 | 64 | def pop([]), do: :empty 65 | def pop([_ | xs]), do: {:ok, xs} 66 | 67 | def top([]), do: :empty 68 | def top([x | _]), do: {:ok, x} 69 | end 70 | 71 | defmodule ChurchStack do 72 | @behaviour Stack 73 | 74 | def empty(), do: fn n, _ -> n.() end 75 | def empty?(l), do: l.(fn -> true end, fn _, _ -> false end) 76 | def push(l, x), do: fn _, p -> p.(x, l) end 77 | def pop(l), do: l.(fn -> :empty end, fn _, b -> {:ok, b} end) 78 | def top(l), do: l.(fn -> :empty end, fn a, _ -> {:ok, a} end) 79 | end 80 | 81 | defpmodule Queue, stack: Stack do 82 | @type t() :: {term(), term()} 83 | 84 | def empty(), do: {stack().empty(), stack().empty()} 85 | 86 | def empty?({s1, s2}) do 87 | stack().empty?(s1) and stack().empty?(s2) 88 | end 89 | 90 | def enqueue({s1, s2}, x) do 91 | {stack().push(s1, x), s2} 92 | end 93 | 94 | def dequeue({s1, s2} = q) do 95 | cond do 96 | empty?(q) -> 97 | :empty 98 | 99 | stack().empty?(s2) -> 100 | dequeue({stack().empty(), reverse(s1)}) 101 | 102 | :else -> 103 | {:ok, top} = stack().top(s2) 104 | {:ok, tail} = stack().pop(s2) 105 | 106 | {top, {s1, tail}} 107 | end 108 | end 109 | 110 | defp reverse(s) do 111 | loop(s, stack().empty()) 112 | end 113 | 114 | defp loop(old, new) do 115 | if stack().empty?(old) do 116 | new 117 | else 118 | {:ok, top} = stack().top(old) 119 | {:ok, tail} = stack().pop(old) 120 | 121 | loop(tail, stack().push(new, top)) 122 | end 123 | end 124 | end 125 | 126 | definst(Queue, ListQueue, stack: ListStack) 127 | definst(Queue, ChurchQueue, stack: ChurchStack) 128 | 129 | test "ListQueue behaves as expected" do 130 | q = ListQueue.empty() 131 | 132 | assert ListQueue.empty?(q) 133 | assert q = ListQueue.enqueue(q, 5) 134 | assert q = ListQueue.enqueue(q, 3) 135 | assert {5, q} = ListQueue.dequeue(q) 136 | assert {3, q} = ListQueue.dequeue(q) 137 | assert :empty = ListQueue.dequeue(q) 138 | end 139 | 140 | property "ListQueue and ChurchQueue behave identically" do 141 | command = 142 | one_of([ 143 | constant(:dequeue), 144 | tuple({constant(:enqueue), term()}) 145 | ]) 146 | 147 | check all(commands <- list_of(command, min_length: 5)) do 148 | q1 = ListQueue.empty() 149 | q2 = ChurchQueue.empty() 150 | 151 | Enum.reduce(commands, {q1, q2}, fn command, {q1, q2} -> 152 | case command do 153 | {:enqueue, x} -> 154 | q1 = ListQueue.enqueue(q1, x) 155 | q2 = ChurchQueue.enqueue(q2, x) 156 | 157 | assert ListQueue.empty?(q1) == ChurchQueue.empty?(q2) 158 | 159 | {q1, q2} 160 | 161 | :dequeue -> 162 | case {ListQueue.dequeue(q1), ChurchQueue.dequeue(q2)} do 163 | {:empty, :empty} -> 164 | assert ListQueue.empty?(q1) 165 | assert ChurchQueue.empty?(q2) 166 | 167 | {q1, q2} 168 | 169 | {{x1, q1}, {x2, q2}} -> 170 | assert x1 == x2 171 | assert ListQueue.empty?(q1) == ChurchQueue.empty?(q2) 172 | 173 | {q1, q2} 174 | end 175 | end 176 | end) 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------