├── .credo.exs ├── .gitignore ├── .travis.yml ├── README.md ├── config └── config.exs ├── examples └── example_app │ ├── .gitignore │ ├── README.md │ ├── config │ ├── .gitignore │ └── config.exs │ ├── lib │ ├── example_app.ex │ └── example_app │ │ ├── application.ex │ │ ├── models │ │ ├── problem.ex │ │ └── user.ex │ │ └── repo.ex │ ├── mix.exs │ ├── mix.lock │ ├── priv │ └── repo │ │ └── migrations │ │ ├── 20171108033453_add_example_table.exs │ │ ├── 20171109173855_add_problems.exs │ │ └── 20171110134706_add_problem_severity.exs │ ├── spec │ ├── base_model_spec.exs │ └── spec_helper.exs │ └── test │ ├── example_app_test.exs │ └── test_helper.exs ├── lib ├── base_model.ex └── base_model │ └── functions.ex ├── mix.exs ├── mix.lock └── testing └── example_specs.sh /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | {Credo.Check.Consistency.ExceptionNames}, 52 | {Credo.Check.Consistency.LineEndings}, 53 | {Credo.Check.Consistency.ParameterPatternMatching}, 54 | {Credo.Check.Consistency.SpaceAroundOperators}, 55 | {Credo.Check.Consistency.SpaceInParentheses}, 56 | {Credo.Check.Consistency.TabsOrSpaces}, 57 | 58 | # For some checks, like AliasUsage, you can only customize the priority 59 | # Priority values are: `low, normal, high, higher` 60 | # 61 | {Credo.Check.Design.AliasUsage, priority: :low}, 62 | 63 | # For others you can set parameters 64 | 65 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 66 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 67 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 68 | # 69 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 70 | 71 | # You can also customize the exit_status of each check. 72 | # If you don't want TODO comments to cause `mix credo` to fail, just 73 | # set this value to 0 (zero). 74 | # 75 | {Credo.Check.Design.TagTODO, exit_status: 2}, 76 | {Credo.Check.Design.TagFIXME}, 77 | 78 | {Credo.Check.Readability.FunctionNames}, 79 | {Credo.Check.Readability.LargeNumbers}, 80 | {Credo.Check.Readability.MaxLineLength, priority: :normal, max_length: 150}, 81 | {Credo.Check.Readability.ModuleAttributeNames}, 82 | {Credo.Check.Readability.ModuleDoc}, 83 | {Credo.Check.Readability.ModuleNames}, 84 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 85 | {Credo.Check.Readability.ParenthesesInCondition}, 86 | {Credo.Check.Readability.PredicateFunctionNames}, 87 | {Credo.Check.Readability.PreferImplicitTry}, 88 | {Credo.Check.Readability.RedundantBlankLines}, 89 | {Credo.Check.Readability.StringSigils}, 90 | {Credo.Check.Readability.TrailingBlankLine}, 91 | {Credo.Check.Readability.TrailingWhiteSpace}, 92 | {Credo.Check.Readability.VariableNames}, 93 | {Credo.Check.Readability.Semicolons}, 94 | {Credo.Check.Readability.SpaceAfterCommas}, 95 | 96 | {Credo.Check.Refactor.DoubleBooleanNegation}, 97 | {Credo.Check.Refactor.CondStatements}, 98 | {Credo.Check.Refactor.CyclomaticComplexity}, 99 | {Credo.Check.Refactor.FunctionArity}, 100 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 101 | {Credo.Check.Refactor.MatchInCondition}, 102 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 103 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 104 | {Credo.Check.Refactor.Nesting}, 105 | {Credo.Check.Refactor.PipeChainStart, false}, 106 | {Credo.Check.Refactor.UnlessWithElse}, 107 | 108 | {Credo.Check.Warning.BoolOperationOnSameValues}, 109 | {Credo.Check.Warning.IExPry}, 110 | {Credo.Check.Warning.IoInspect}, 111 | {Credo.Check.Warning.LazyLogging}, 112 | {Credo.Check.Warning.OperationOnSameValues}, 113 | {Credo.Check.Warning.OperationWithConstantResult}, 114 | {Credo.Check.Warning.UnusedEnumOperation}, 115 | {Credo.Check.Warning.UnusedFileOperation}, 116 | {Credo.Check.Warning.UnusedKeywordOperation}, 117 | {Credo.Check.Warning.UnusedListOperation}, 118 | {Credo.Check.Warning.UnusedPathOperation}, 119 | {Credo.Check.Warning.UnusedRegexOperation}, 120 | {Credo.Check.Warning.UnusedStringOperation}, 121 | {Credo.Check.Warning.UnusedTupleOperation}, 122 | {Credo.Check.Warning.RaiseInsideRescue}, 123 | 124 | # Controversial and experimental checks (opt-in, just remove `, false`) 125 | # 126 | {Credo.Check.Refactor.ABCSize}, 127 | {Credo.Check.Refactor.AppendSingleItem, false}, 128 | {Credo.Check.Refactor.VariableRebinding, false}, 129 | {Credo.Check.Warning.MapGetUnsafePass, false}, 130 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 131 | 132 | # Deprecated checks (these will be deleted after a grace period) 133 | # 134 | {Credo.Check.Readability.Specs, false}, 135 | {Credo.Check.Warning.NameRedeclarationByAssignment, false}, 136 | {Credo.Check.Warning.NameRedeclarationByCase, false}, 137 | {Credo.Check.Warning.NameRedeclarationByDef, false}, 138 | {Credo.Check.Warning.NameRedeclarationByFn, false}, 139 | 140 | # Custom checks can be created using `mix credo.gen.check`. 141 | # 142 | ] 143 | } 144 | ] 145 | } 146 | -------------------------------------------------------------------------------- /.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 3rd-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 | # Language Server directory 23 | /.elixir_ls 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: 2 | elixir 3 | 4 | services: 5 | - postgresql 6 | 7 | matrix: 8 | include: 9 | - otp_release: 20.1 10 | elixir: 1.6.1 11 | 12 | script: 13 | - bash ./testing/example_specs.sh 14 | - mix credo 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## BaseModel 2 | 3 | ActiveRecord for Ecto. 4 | 5 | [![Build Status](https://travis-ci.org/meyercm/base_model.svg?branch=master)](https://travis-ci.org/meyercm/base_model) 6 | 7 | `{:base_model, "~> 0.2"},` 8 | 9 | `BaseModel` provides a straightforward `__using__` macro to include common CRUD 10 | functions in your models: 11 | 12 | * `create(params)` 13 | * `all()` 14 | * `count(where_clause \\ :anything)` 15 | * `find(id)` 16 | * `first(where_clause)` 17 | * `first_or_create(where_clause)` 18 | * `where(where_clause)` 19 | * `update(model, params)` 20 | * `update_where(where_clause, params)` 21 | * `delete(id_or_struct)` 22 | * `delete_where(where_clause)` 23 | * `delete_all` 24 | 25 | All of these are overridable, and where appropriate, support options including 26 | `:limit`, `:preload`, and `:order_by`. Custom create and update validation is 27 | possible by overriding `create_changeset/1` or `update_changeset/1` in the 28 | model. 29 | 30 | ### Example 31 | 32 | A model taken from the [example app][example-app]: 33 | 34 | ```elixir 35 | defmodule ExampleApp.Models.User do 36 | use BaseModel, repo: ExampleApp.Repo 37 | alias ExampleApp.Models.Problem 38 | 39 | schema "users" do 40 | field :name, :string 41 | field :age, :integer 42 | has_many :problems, Problem 43 | timestamps() 44 | end 45 | end 46 | ``` 47 | 48 | Because ExampleApp.Repo has been specified in the `use` directive, BaseModel 49 | methods can omit it: 50 | 51 | ```elixir 52 | iex> alias ExampleApp.Models.User 53 | ...> {:ok, chris} = User.create(name: "chris") 54 | {:ok, %User{name: "chris", age: nil}} 55 | ...> User.update(chris, age: -1) 56 | {:ok, %User{name: "chris", age: -1}} 57 | ...> User.count 58 | 1 59 | ...> User.where(name: "chris") 60 | [%User{name: "chris", age: -1}] 61 | ``` 62 | 63 | ### Getting Started 64 | 65 | 1. Setup your repo as you normally would, and create your models as usual. 66 | 2. To each model, add `use BaseModel, repo: YourApp.Repo` 67 | 3. Profit! 68 | 69 | ### Associations 70 | 71 | `:belongs_to` associations can be specified during `create`, and can be used in 72 | any query or params list, e.g.: 73 | 74 | ```elixir 75 | iex> {:ok, chris} = User.create(name: "chris") 76 | 77 | # BaseModel will do the field mapping for you if you pass a struct to the association 78 | ...> Problem.create(user: chris, description: "...so I used regular expressions.") 79 | # Or you could do it yourself: 80 | ...> Problem.create(user_id: chris.id, description: "now I have 100 problems.") 81 | 82 | # In query-mode: (also works for `where`, `count`, `update_where`) 83 | ...> Problem.delete_where(user: chris) 84 | {:ok, 2} 85 | ``` 86 | 87 | ### Opts 88 | 89 | BaseModel methods support an optional `opts` parameter, which accepts 3 values: 90 | 91 | * `:preload` 92 | * `:limit` 93 | * `:order_by` 94 | 95 | Each of these operates as a direct pass-thru to `Ecto`, so see their 96 | documentation on available use. Note that these opts are sensibly applied, e.g. 97 | passing `:limit` to `count` is ignored, etc. 98 | 99 | ```elixir 100 | iex> User.find(1, preload: :problems) 101 | %User{name: "chris", problems: []} 102 | ``` 103 | 104 | ### Overriding `*_changeset` methods 105 | 106 | From the `Problem` model in [ExampleApp][example-app] 107 | 108 | ```elixir 109 | # in models/problem.ex: 110 | schema "problems" do 111 | field :description, :string 112 | field :severity, :integer 113 | belongs_to :user, User 114 | timestamps() 115 | end 116 | 117 | @severities 1..5 118 | 119 | @impl BaseModel 120 | def create_changeset(params) do 121 | %__MODULE__{} 122 | |> cast(params, [:description, :severity, :user_id]) 123 | |> validate_inclusion(:severity, @severities) 124 | end 125 | 126 | @impl BaseModel 127 | def update_changeset(model, params) do 128 | model 129 | |> cast(params, [:description, :severity, :user_id]) 130 | |> validate_inclusion(:severity, @severities) 131 | end 132 | ``` 133 | 134 | The BaseModel method `create` will first extract association fields from your 135 | params, then pass them to `create_changeset/1`. By overriding it as we have 136 | here, custom validations can be applied, e.g. here, we've restricted severity 137 | to be in 1..5. 138 | 139 | Likewise, `update` will call `update_changeset`, and use the resulting changeset 140 | in it's call to `Repo.update.` 141 | 142 | ### Closing comments 143 | 144 | I wrote the first version of `BaseModel` back when Elixir 0.13 was the new 145 | hotness and I was missing my old friend, ActiveRecord. I've found this query 146 | interface suitable for many use-cases, but as soon as I have a need for a more 147 | complicated query, I simply add it as a new method on the model. This way, all 148 | of my Ecto code lives in the models, and in the models only. The sanity gained 149 | from not spreading Ecto calls directly into the business logic cannot be 150 | overstated. 151 | 152 | Please drop me a note if you end up using BaseModel in something cool, or file 153 | an issue if you have difficulty, bugs, or ideas for a better API. 154 | 155 | [example-app]: https://github.com/meyercm/base_model/tree/master/examples/example_app 156 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | -------------------------------------------------------------------------------- /examples/example_app/.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 3rd-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 | -------------------------------------------------------------------------------- /examples/example_app/README.md: -------------------------------------------------------------------------------- 1 | # ExampleApp 2 | 3 | This is a simple example of incorporating BaseModel into a small Ecto-backed app 4 | 5 | 6 | Of particular node is that this example application holds the specs for 7 | BaseModel, as testing here makes much more sense. 8 | -------------------------------------------------------------------------------- /examples/example_app/config/.gitignore: -------------------------------------------------------------------------------- 1 | secrets.exs 2 | -------------------------------------------------------------------------------- /examples/example_app/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :example_app, ecto_repos: [ExampleApp.Repo] 6 | 7 | config :example_app, ExampleApp.Repo, 8 | database: "basemodel_example_1", 9 | hostname: "localhost", 10 | pool: Ecto.Adapters.SQL.Sandbox, 11 | port: "5432", 12 | loggers: false, 13 | garbage_key: :ok 14 | 15 | 16 | import_config "secrets.exs" 17 | -------------------------------------------------------------------------------- /examples/example_app/lib/example_app.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp do 2 | @moduledoc """ 3 | Documentation for ExampleApp. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> ExampleApp.hello 12 | :world 13 | 14 | """ 15 | defmacro __using__(_opts) do 16 | quote do 17 | alias ExampleApp.Models.{User, Problem} 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /examples/example_app/lib/example_app/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | # List all child processes to be supervised 10 | children = [ 11 | ExampleApp.Repo, 12 | ] 13 | 14 | # See https://hexdocs.pm/elixir/Supervisor.html 15 | # for other strategies and supported options 16 | opts = [strategy: :one_for_one, name: ExampleApp.Supervisor] 17 | Supervisor.start_link(children, opts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /examples/example_app/lib/example_app/models/problem.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Models.Problem do 2 | use BaseModel, repo: ExampleApp.Repo 3 | import Ecto.Changeset 4 | alias ExampleApp.Models.User 5 | 6 | schema "problems" do 7 | field :description, :string 8 | field :severity, :integer 9 | belongs_to :user, User 10 | timestamps() 11 | end 12 | 13 | @allowed_severities 1..5 14 | 15 | # These override the defaults provided in BaseModel, allowing us to specify 16 | # custom validation or param scubbing. 17 | 18 | # Both of these do the same thing, but are separate so that updates can have 19 | # logic separate from create: for instance, it might not make sense to allow 20 | # updates on a foreign key. 21 | def create_changeset(params) do 22 | %__MODULE__{} 23 | |> cast(params, [:description, :severity, :user_id]) 24 | |> validate_inclusion(:severity, @allowed_severities) 25 | end 26 | 27 | def update_changeset(model, params) do 28 | model 29 | |> cast(params, [:description, :severity, :user_id]) 30 | |> validate_inclusion(:severity, @allowed_severities) 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /examples/example_app/lib/example_app/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Models.User do 2 | use BaseModel, repo: ExampleApp.Repo 3 | alias ExampleApp.Models.Problem 4 | 5 | schema "users" do 6 | field :name, :string 7 | field :age, :integer 8 | has_many :problems, Problem 9 | timestamps() 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/example_app/lib/example_app/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Repo do 2 | use Ecto.Repo, 3 | otp_app: :example_app, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | end 7 | -------------------------------------------------------------------------------- /examples/example_app/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example_app, 7 | version: "0.1.0", 8 | elixir: "~> 1.4", 9 | start_permanent: Mix.env == :prod, 10 | deps: deps(), 11 | preferred_cli_env: [espec: :test], 12 | ] 13 | end 14 | 15 | # Run "mix help compile.app" to learn about applications. 16 | def application do 17 | [ 18 | extra_applications: [:logger], 19 | mod: {ExampleApp.Application, []} 20 | ] 21 | end 22 | 23 | # Run "mix help deps" to learn about dependencies. 24 | defp deps do 25 | [ 26 | {:postgrex, ">= 0.0.0"}, 27 | {:ecto, "~> 3.0"}, 28 | {:base_model, path: "../.."}, 29 | {:shorter_maps, "~> 2.1"}, 30 | #{:espec, "~> 1.4"}, 31 | {:espec, github: "antonmi/espec", branch: "master"}, 32 | ] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /examples/example_app/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 3 | "db_connection": {:hex, :db_connection, "2.0.3", "b4e8aa43c100e16f122ccd6798cd51c48c79fd391c39d411f42b3cd765daccb0", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "decimal": {:hex, :decimal, "1.6.0", "bfd84d90ff966e1f5d4370bdd3943432d8f65f07d3bab48001aebd7030590dcc", [:mix], [], "hexpm"}, 5 | "ecto": {:hex, :ecto, "3.0.6", "d33ab5b3f7553a41507d4b0ad5bf192d533119c4ad08f3a5d63d85aa12117dc9", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 6 | "ecto_sql": {:hex, :ecto_sql, "3.0.4", "e7a0feb0b2484b90981c56d5cd03c52122c1c31ded0b95ed213b7c5c07ae6737", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "espec": {:git, "https://github.com/antonmi/espec.git", "28fbad10cd4fbbd5c28664209b0d49564cbc0f3f", [branch: "master"]}, 8 | "meck": {:hex, :meck, "0.8.8", "eeb3efe811d4346e1a7f65b2738abc2ad73cbe1a2c91b5dd909bac2ea0414fa6", [:rebar3], [], "hexpm"}, 9 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [], [], "hexpm"}, 10 | "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 11 | "shorter_maps": {:hex, :shorter_maps, "2.2.4", "69b54de2f5a744964b4c1748d2d111a6fea34fa7440af03f8161be6655e703f0", [:mix], [], "hexpm"}, 12 | "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm"}, 13 | } 14 | -------------------------------------------------------------------------------- /examples/example_app/priv/repo/migrations/20171108033453_add_example_table.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Repo.Migrations.AddExampleTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table :users do 6 | add :name, :string 7 | add :age, :integer 8 | timestamps() 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /examples/example_app/priv/repo/migrations/20171109173855_add_problems.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Repo.Migrations.AddProblems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table :problems do 6 | add :description, :text 7 | add :severity, :integer 8 | add :user_id, references(:users), null: false 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /examples/example_app/priv/repo/migrations/20171110134706_add_problem_severity.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleApp.Repo.Migrations.AddProblemSeverity do 2 | use Ecto.Migration 3 | 4 | def change do 5 | 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /examples/example_app/spec/base_model_spec.exs: -------------------------------------------------------------------------------- 1 | defmodule BaseModelSpec do 2 | use ESpec 3 | import ShorterMaps 4 | use ExampleApp 5 | 6 | before do 7 | # set up a sandbox DB connection for each test. 8 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(ExampleApp.Repo) 9 | Ecto.Adapters.SQL.Sandbox.mode(ExampleApp.Repo, {:shared, self()}) 10 | end 11 | finally do 12 | # reset the database 13 | Ecto.Adapters.SQL.Sandbox.checkin(ExampleApp.Repo) 14 | end 15 | 16 | describe "all" do 17 | it "returns an empty list when there are none" do 18 | User.all |> should(eq []) 19 | end 20 | 21 | it "returns all records in the db if there are any" do 22 | User.create(name: "a") 23 | User.create(name: "b") 24 | User.all |> should(match_pattern [%User{name: "a"}, %User{name: "b"}]) 25 | end 26 | 27 | it "supports :preload" do 28 | {:ok, ~M{id} = a} = User.create(name: "a") 29 | Problem.create(description: "b", user: a) 30 | User.all(preload: :problems) 31 | |> should(match_pattern [%User{name: "a", problems: [%Problem{description: "b"}]}]) 32 | end 33 | 34 | it "allows specifying sort order" do 35 | User.create(name: "b") 36 | User.create(name: "a") 37 | User.create(name: "c") 38 | User.all(order_by: :name) 39 | |> should(match_pattern [%User{name: "a"}, %User{name: "b"}, %User{name: "c"}]) 40 | 41 | User.all(order_by: [desc: :name]) 42 | |> should(match_pattern [%User{name: "c"}, %User{name: "b"}, %User{name: "a"}]) 43 | end 44 | end 45 | 46 | describe "create" do 47 | it "returns {:ok, model} if it succeeds" do 48 | User.create(name: "Chris", age: -1) |> should(match_pattern {:ok, %User{name: "Chris", age: -1}}) 49 | end 50 | 51 | it "accepts a map" do 52 | {name, age} = {"test_map_create", 1999} 53 | User.create(~M{name, age}) |> should(match_pattern {:ok, %User{name: ^name, age: ^age}}) 54 | end 55 | 56 | it "allows setting an association" do 57 | {:ok, ~M{id} = bob} = User.create(name: "bob") 58 | {:ok, problem} = Problem.create(user: bob, desciption: "a problem") 59 | User.find(id, preload: :problems) |> should(match_pattern %User{name: "bob", problems: [problem]}) 60 | end 61 | 62 | it "allows overriding validation" do 63 | {:ok, a} = User.create(name: "a") #required 64 | Problem.create(severity: 1, user: a) |> should(match_pattern {:ok, %Problem{}}) 65 | Problem.create(severity: -1, user: a) |> should(match_pattern {:error, _reason}) 66 | end 67 | 68 | it "returns {:error, reason} if validation fails" do 69 | {:ok, a} = User.create(name: "a") #required 70 | Problem.create(severity: 1, user: a) |> should(match_pattern {:ok, %Problem{}}) 71 | Problem.create(severity: -1, user: a) |> should(match_pattern {:error, _reason}) 72 | end 73 | end 74 | 75 | describe "find" do 76 | it "returns nil if the id is not in the table" do 77 | User.find(1) |> should(eq nil) 78 | end 79 | 80 | it "returns the record if it exists" do 81 | {:ok, ~M{id}} = User.create(name: "test_find") 82 | User.find(id) |> should(match_pattern(%User{name: "test_find"})) 83 | end 84 | 85 | # Maybe don't add this behavior, as it would completely screw anyone with 86 | # compound keys. Though, maybe we don't care? how common is a compound PK? 87 | 88 | # it "can accept a list of pks" do 89 | # {:ok, ~M{id}} = User.create(name: "test_find") 90 | # {:ok, %{id: id2}} = User.create(name: "test_find2") 91 | # User.find([id, id2]) |> should(match_pattern([%User{name: "test_find"}, %User{name: "test_find2"}])) 92 | # end 93 | end 94 | 95 | describe "where(where_clause, opts)" do 96 | before do 97 | {:ok, a} = User.create(name: "a", age: 1) 98 | User.create(name: "b", age: 2) 99 | User.create(name: "c", age: 2) 100 | Problem.create(description: "problem for a", user: a) 101 | end 102 | 103 | it "supports nil fields" do 104 | User.create(name: "d") 105 | User.where(age: nil) 106 | |> should(match_pattern [%User{name: "d"}]) 107 | end 108 | 109 | it "returns a list of matching records" do 110 | User.where(age: 1) |> should(match_pattern [%User{name: "a", age: 1}]) 111 | end 112 | 113 | it "allows specifying order" do 114 | User.where([age: 2], order_by: :name) |> should(match_pattern [%User{name: "b", age: 2}, %User{name: "c", age: 2}]) 115 | User.where([age: 2], order_by: [desc: :name]) |> should(match_pattern [%User{name: "c", age: 2}, %User{name: "b", age: 2}]) 116 | end 117 | 118 | it "allows specifying limit" do 119 | User.where([age: 2], limit: 1) |> should(match_pattern [%User{age: 2}]) 120 | end 121 | 122 | it "supports order and limit together" do 123 | User.where([age: 2], order_by: :name, limit: 1) |> should(match_pattern [%User{age: 2, name: "b"}]) 124 | end 125 | 126 | it "supports :preload" do 127 | User.where([age: 1], preload: :problems) 128 | |> should(match_pattern [%User{name: "a", problems: [%Problem{description: "problem for a"}]}]) 129 | end 130 | 131 | it "ignores other opts" do 132 | User.where([age: 2], asdfasdf: 1, limit: 1) |> should(match_pattern [%User{age: 2}]) 133 | end 134 | 135 | it "allows querying by association" do 136 | [a] = User.where(name: "a") 137 | Problem.where(user: a) 138 | |> should(match_pattern [%Problem{description: "problem for a"}]) 139 | end 140 | end 141 | 142 | describe "count(where_clause)" do 143 | it "returns the count of all records with no query" do 144 | User.count |> should(eq 0) 145 | User.create(name: "a") 146 | User.count |> should(eq 1) 147 | end 148 | 149 | it "accepts associations in the query" do 150 | {:ok, a} = User.create(name: "a") 151 | {:ok, _b} = Problem.create(user: a, description: "b") 152 | Problem.count(user: a) |> should(eq 1) 153 | end 154 | 155 | it "returns the count of matching records" do 156 | User.create(name: "a") 157 | User.create(name: "a") 158 | User.create(name: "b") 159 | User.count(name: "a") |> should(eq 2) 160 | User.count(name: "b") |> should(eq 1) 161 | end 162 | 163 | end 164 | 165 | describe "update(model, params)" do 166 | it "updates the model" do 167 | {:ok, ~M{id} = model} = User.create(name: "a") 168 | User.update(model, name: "b") 169 | User.find(id) |> should(match_pattern %User{name: "b"}) 170 | end 171 | 172 | it "returns {:ok, updated_model} when successful" do 173 | {:ok, ~M{id} = model} = User.create(name: "a") 174 | User.update(model, name: "b", age: 12) 175 | |> should(match_pattern {:ok, %User{name: "b", age: 12}}) 176 | User.find(id) |> should(match_pattern %User{name: "b", age: 12}) 177 | end 178 | 179 | it "supports updating association" do 180 | {:ok, a} = User.create(name: "a") 181 | {:ok, b} = User.create(name: "b") 182 | {:ok, c} = Problem.create(user: a, description: "c") 183 | Problem.update(c, user: b) 184 | Problem.count(user: b) |> should(eq 1) 185 | end 186 | 187 | it "allows overriding validation" do 188 | {:ok, a} = User.create(name: "a") #required 189 | {:ok, problem} = Problem.create(severity: 1, user: a) 190 | Problem.update(problem, severity: -1) |> should(match_pattern {:error, reason}) 191 | end 192 | end 193 | 194 | describe "update_where(where_clause, update_map)" do 195 | before do 196 | User.create(name: "a") 197 | User.create(name: "a") 198 | User.create(name: "b") 199 | end 200 | 201 | it "updates all the matching records" do 202 | User.update_where([name: "a"], name: "c") 203 | User.count(name: "c") |> should(eq 2) 204 | User.count(name: "a") |> should(eq 0) 205 | end 206 | 207 | it "returns {:ok, count} if successful" do 208 | User.update_where([name: "a"], name: "c") 209 | |> should(eq {:ok, 2}) 210 | end 211 | 212 | it "allows using assoc in where and in update" do 213 | [user1, user2] = User.where(name: "a") 214 | Problem.create(user: user1, description: "first problem") 215 | Problem.create(user: user1, description: "second problem") 216 | Problem.create(user: user2, description: "third problem") 217 | Problem.update_where([user: user1], [user: user2]) 218 | Problem.count(user: user2) |> should(eq 3) 219 | end 220 | 221 | end 222 | 223 | 224 | describe "delete(id)" do 225 | it "deletes a record" do 226 | {:ok, ~M{id}} = User.create(age: 1, name: "a") 227 | User.count |> should(eq 1) 228 | User.delete(id) 229 | User.count |> should(eq 0) 230 | end 231 | 232 | it "returns :ok if the record was deleted" do 233 | {:ok, ~M{id}} = User.create(age: 1, name: "a") 234 | User.delete(id) |> should(eq :ok) 235 | end 236 | 237 | it "returns {:error, reason} if there was a problem" do 238 | User.delete(1) |> should(eq {:error, :not_found}) 239 | end 240 | 241 | it "accepts a model" do 242 | {:ok, user} = User.create(name: "a") 243 | User.delete(user) |> should(eq :ok) 244 | end 245 | end 246 | 247 | 248 | describe "delete_where(where_clause)" do 249 | before do 250 | User.create(name: "a") 251 | User.create(name: "a") 252 | User.create(name: "b") 253 | 254 | end 255 | 256 | it "deletes all records that match" do 257 | User.delete_where(name: "a") 258 | User.count |> should(eq 1) 259 | end 260 | 261 | it "returns {:ok, count} if successful" do 262 | User.delete_where(name: "a") |> should(eq {:ok, 2}) 263 | User.delete_where(name: "b") |> should(eq {:ok, 1}) 264 | User.delete_where(name: "c") |> should(eq {:ok, 0}) 265 | end 266 | 267 | it "allows using assoc" do 268 | [b] = User.where(name: "b") 269 | Problem.create(user: b, description: "c") 270 | Problem.delete_where(user: b) 271 | Problem.count |> should(eq 0) 272 | end 273 | end 274 | 275 | describe "delete_all()" do 276 | before do 277 | User.create(name: "a") 278 | User.create(name: "a") 279 | User.create(name: "b") 280 | end 281 | 282 | it "deletes all records in the table" do 283 | User.delete_all() 284 | User.count |> should(eq 0) 285 | end 286 | 287 | it "returns {:ok, count}" do 288 | User.delete_all() |> should(eq {:ok, 3}) 289 | end 290 | end 291 | 292 | describe "first(where_clause, opts)" do 293 | before do 294 | {:ok, a} = User.create(name: "a", age: 1) 295 | {:ok, _c} = User.create(name: "c", age: 2) 296 | {:ok, b} = User.create(name: "b", age: 2) 297 | Problem.create(user: a, description: "a_p") 298 | Problem.create(user: b, description: "b_p1") 299 | Problem.create(user: b, description: "b_p2") 300 | end 301 | 302 | it "returns one matching row in the table" do 303 | User.first(age: 1) 304 | |> should(match_pattern(%User{name: "a"})) 305 | end 306 | 307 | it "supports :preload" do 308 | User.first([age: 1], preload: :problems) 309 | |> should(match_pattern(%User{name: "a", problems: [%Problem{}]})) 310 | end 311 | 312 | it "supports :order_by" do 313 | User.first([age: 2], order_by: :name) 314 | |> should(match_pattern(%User{name: "b"})) 315 | User.first([age: 2], order_by: [desc: :name]) 316 | |> should(match_pattern(%User{name: "c"})) 317 | end 318 | 319 | it "supports associations" do 320 | [b] = User.where(name: "b") 321 | Problem.first(user: b) 322 | |> should(match_pattern(%Problem{})) 323 | end 324 | 325 | it "returns nil when no matches" do 326 | Problem.first(description: "does not exist") 327 | |> should(eq(nil)) 328 | end 329 | end 330 | 331 | describe "first_or_create(where_clause)" do 332 | before do 333 | {:ok, a} = User.create(name: "a", age: 1) 334 | Problem.create(description: "a_p", user: a) 335 | end 336 | 337 | it "returns the first matching item if one exists" do 338 | User.first_or_create(name: "a") 339 | |> should(match_pattern(%User{name: "a"})) 340 | User.count |> should(eq 1) 341 | end 342 | 343 | it "supports :preload" do 344 | User.first_or_create([name: "a"], preload: :problems) 345 | |> should(match_pattern %User{problems: [%Problem{}]}) 346 | end 347 | 348 | it "supports :order_by" do 349 | User.create(name: "a", age: 2) 350 | User.first_or_create([name: "a"], order_by: :age) 351 | |> should(match_pattern %User{age: 1}) 352 | User.first_or_create([name: "a"], order_by: [desc: :age]) 353 | |> should(match_pattern %User{age: 2}) 354 | end 355 | it "supports associations" do 356 | 357 | end 358 | 359 | it "creates a new object if one does not exist" do 360 | User.first_or_create(name: "does not exist") 361 | |> should(match_pattern(%User{name: "does not exist"})) 362 | end 363 | end 364 | 365 | end 366 | -------------------------------------------------------------------------------- /examples/example_app/spec/spec_helper.exs: -------------------------------------------------------------------------------- 1 | ESpec.configure fn(config) -> 2 | config.before fn(tags) -> 3 | {:shared, hello: :world, tags: tags} 4 | end 5 | 6 | config.finally fn(_shared) -> 7 | :ok 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /examples/example_app/test/example_app_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleAppTest do 2 | use ExUnit.Case 3 | doctest ExampleApp 4 | 5 | test "greets the world" do 6 | assert ExampleApp.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /examples/example_app/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/base_model.ex: -------------------------------------------------------------------------------- 1 | defmodule BaseModel do 2 | @moduledoc ~S""" 3 | BaseModel provides a simple set of CRUD functions to an Ecto Model via a 4 | `__using__` macro. For Example: 5 | 6 | ```elixir 7 | defmodule Db.Models.Person do 8 | use BaseModel, repo: Db.Repo 9 | 10 | schema "persons" do 11 | field :name, :string 12 | field :age, :integer 13 | end 14 | end 15 | ``` 16 | 17 | The `use BaseModel` line adds the following methods to the Person Model: 18 | 19 | - `Person.create(params)` 20 | - `Person.all(opts \\ [order_by: :none, preload: []])` 21 | - `Person.find(id, opts \\ [preload: []])` 22 | - `Person.first(where, opts \\ [preload: []])` 23 | - `Person.first(where_clause, opts \\ [order_by: :none, preload []])` 24 | - `Person.first_or_create(where_clause, opts \\ [order_by: :none, preload []])` 25 | - `Person.where(where_clause, opts \\ [order_by: :none, limit: :none, preload: []])` 26 | - `Person.count(where_clause \\ [])` 27 | - `Person.delete(id_or_struct)` 28 | - `Person.delete_all()` 29 | - `Person.delete_where(where_clause)` 30 | - `Person.update(model, params)` 31 | - `Person.update_where(where_clause, params)` 32 | 33 | This model can now be interacted with in a more fluent manner than raw ecto: 34 | ```elixir 35 | iex> {:ok, model} = Person.create(name: "chris", age: 99) 36 | {:ok, %Person{name: "chris", age: 99}} 37 | iex> Person.update(model, age: 18) 38 | {:ok, %Person{name: "chris", age: 18}} 39 | iex> Person.where(age: 18) 40 | [%Person{name: "chris", age: 18}] 41 | ``` 42 | """ 43 | 44 | @type params :: map | Keyword.t() 45 | @type model :: map 46 | @callback create_changeset(params) :: Ecto.Changeset.t() 47 | @callback update_changeset(model, params) :: Ecto.Changeset.t() 48 | 49 | @doc """ 50 | 51 | """ 52 | defmacro __using__(repo: q_repo) do 53 | quote do 54 | use Ecto.Schema 55 | # the injected functions call the functions in BaseModel.Functions to 56 | # keep this short and sweet. 57 | alias BaseModel.Functions, as: BMF 58 | @__repo unquote(q_repo) 59 | @__doc_module Module.split(__MODULE__) |> Enum.reverse() |> hd 60 | @__repo_mod {@__repo, __MODULE__} 61 | 62 | @behaviour BaseModel 63 | @type t :: %__MODULE__{} 64 | @type opts :: Keyword.t() 65 | @type params :: map | Keyword.t() 66 | # NOTE: find a way to make this dynamic, if possible. 67 | @type pk :: any 68 | @type where_clause :: Keyword.t() 69 | ########################### 70 | # Inserted API Functions 71 | ########################### 72 | 73 | @doc """ 74 | Returns a list of all #{@__doc_module}s stored in the database 75 | 76 | valid `opts`: 77 | - `:order_by` - accepts the same arguments as Ecto's `order_by` 78 | - `:preload` - accepts the same arguments as Ecto's `preload` 79 | 80 | ### Examples 81 | ```elixir 82 | iex> #{@__doc_module}.all() 83 | ...> #{@__doc_module}.all(order_by: field) 84 | ...> #{@__doc_module}.all(order_by: [desc: field]) 85 | ``` 86 | """ 87 | @spec all(opts) :: [t] 88 | def all(opts \\ []), do: BMF.all(@__repo_mod, opts) 89 | 90 | @doc """ 91 | Creates a #{@__doc_module}, using either a Keyword list or a Map of keys 92 | and values. 93 | 94 | The `create` method calls `create_changeset/1`, which by default only 95 | strips fields from the Primary Key, but can be overridden by this module. 96 | If `create_changeset/1` is overridden, it must accept a map of keys/values 97 | and must return an `Ecto.Changeset`. 98 | 99 | Returns `{:ok, model}` or `{:error, reason}` 100 | 101 | ```elixir 102 | iex> #{@__doc_module}.create(%{field => value}) 103 | ``` 104 | """ 105 | @spec create(params) :: {:ok, t} | {:error, any} 106 | def create(args), do: BMF.create(@__repo_mod, args) 107 | 108 | @doc """ 109 | Finds a #{@__doc_module} based on primary key. 110 | 111 | Returns the record struct if the key exists in the database, otherwise 112 | `nil` 113 | 114 | valid `opts`: 115 | - `:preload` - accepts the same arguments as Ecto's `preload` 116 | """ 117 | @spec find(pk, opts) :: t | nil 118 | def find(id, opts \\ []), do: BMF.find(@__repo_mod, id, opts) 119 | 120 | @doc """ 121 | Returns a list of #{@__doc_module}s that match the where clause, subject 122 | to the limitations of `opts` 123 | 124 | valid `opts`: 125 | - `:order_by` - accepts the same arguments as Ecto's `order_by` 126 | - `:limit` - max number of records to return 127 | - `:preload` - accepts the same arguments as Ecto's `preload` 128 | """ 129 | @spec where(where_clause) :: [t] 130 | @spec where(where_clause, opts) :: [t] 131 | def where(where_clause, opts \\ []), do: BMF.where(@__repo_mod, where_clause, opts) 132 | 133 | @doc """ 134 | Counts the number of #{@__doc_module}s that match the where clause 135 | (default is all). Where clause may be a Keyword list or a Map. 136 | 137 | ### Examples 138 | ```elixir 139 | iex> #{@__doc_module}.count() 140 | ...> #{@__doc_module}.count(%{field => value}) 141 | ``` 142 | """ 143 | @spec count() :: non_neg_integer 144 | @spec count(where_clause) :: non_neg_integer 145 | def count(where_clause \\ []), do: BMF.count(@__repo_mod, where_clause) 146 | 147 | @doc """ 148 | Deletes a #{@__doc_module} by primary key or by passing the model. 149 | returns 150 | 151 | ### Examples 152 | ```elixir 153 | iex> #{@__doc_module}.delete(1) 154 | ...> {:ok, struct} = #{@__doc_module}.create(params) 155 | ...> #{@__doc_module}.delete(struct) 156 | ``` 157 | """ 158 | @spec delete(pk | t) :: :ok | {:error, any} 159 | def delete(id_or_struct), do: BMF.delete(@__repo_mod, id_or_struct) 160 | 161 | @doc """ 162 | Deletes all #{@__doc_module}s matching the where clause. Where clause may 163 | be a Keyword list or a Map. 164 | 165 | ### Examples 166 | ```elixir 167 | iex> #{@__doc_module}.delete_where(%{field => value}) 168 | """ 169 | @spec delete_where(where_clause) :: {:ok, non_neg_integer} 170 | def delete_where(where_clause), do: BMF.delete_where(@__repo_mod, where_clause) 171 | 172 | @doc """ 173 | Delete all records from the table. 174 | 175 | Returns {:ok, count} if successful. 176 | """ 177 | @spec delete_all() :: {:ok, non_neg_integer} 178 | def delete_all, do: BMF.delete_all(@__repo_mod) 179 | 180 | @doc """ 181 | Updates a model's fields as set in `params`. Accepts a #{@__doc_module} 182 | struct and either a Keyword list or Map of params. 183 | 184 | Like `create/1`, update will call the models `update_changeset/2` method 185 | to validate and clean the params passed in. By default, update changeset 186 | allows updating any field that is not part of the table's primary key. 187 | 188 | Returns {:ok, updated_model} when successful. 189 | """ 190 | @spec update(t, params) :: {:ok, t} | {:error, any} 191 | def update(model, params), do: BMF.update(@__repo_mod, model, params) 192 | 193 | @doc """ 194 | Updates all #{@__doc_module}s that match the where clause with the given 195 | params. 196 | 197 | **Important**: This method does not call `update_changeset/2`, and should 198 | not be used with untrusted inputs. 199 | """ 200 | @spec update_where(where_clause, params) :: {:ok, non_neg_integer} 201 | def update_where(where_clause, params), 202 | do: BMF.update_where(@__repo_mod, where_clause, params) 203 | 204 | @doc """ 205 | Returns the first record to match the where clause, or nil if no match 206 | 207 | valid opts: 208 | - order_by 209 | - preload 210 | """ 211 | @spec first(where_clause) :: nil | t 212 | @spec first(where_clause, opts) :: nil | t 213 | def first(where_clause, opts \\ []), do: BMF.first(@__repo_mod, where_clause, opts) 214 | 215 | @doc """ 216 | Queries for a record, and creates it if nothing matches the query 217 | 218 | Returns the first record to match the where clause, or the newly created record 219 | 220 | valid opts: 221 | - order_by 222 | - preload 223 | """ 224 | @spec first_or_create(where_clause) :: t 225 | @spec first_or_create(where_clause, opts) :: t 226 | def first_or_create(where_clause, opts \\ []), 227 | do: BMF.first_or_create(@__repo_mod, where_clause, opts) 228 | 229 | #################################### 230 | # Overrideable Callback Functions 231 | #################################### 232 | @impl BaseModel 233 | def create_changeset(params), do: BMF.create_changeset(@__repo_mod, params) 234 | 235 | @impl BaseModel 236 | def update_changeset(model, params), do: BMF.update_changeset(@__repo_mod, model, params) 237 | 238 | defoverridable create_changeset: 1, 239 | # override these to provide custom validation / data hygiene 240 | update_changeset: 2, 241 | 242 | # overriding these is not common, but supported. Good luck. 243 | all: 0, 244 | all: 1, 245 | create: 1, 246 | find: 1, 247 | find: 2, 248 | first: 1, 249 | first: 2, 250 | first_or_create: 1, 251 | first_or_create: 2, 252 | where: 1, 253 | where: 2, 254 | count: 0, 255 | count: 1, 256 | delete: 1, 257 | delete_where: 1, 258 | update: 2, 259 | update_where: 2 260 | end 261 | end 262 | end 263 | -------------------------------------------------------------------------------- /lib/base_model/functions.ex: -------------------------------------------------------------------------------- 1 | defmodule BaseModel.Functions do 2 | @moduledoc false 3 | import Ecto.Query, only: [from: 2] 4 | import ShorterMaps 5 | alias Ecto.{Query, Changeset, Association.BelongsTo} 6 | 7 | def all({repo, model}, opts) do 8 | model 9 | |> add_opts(opts, [:order_by, :preload]) 10 | |> repo.all 11 | end 12 | 13 | def create({repo, model}, params) do 14 | params 15 | |> fix_params_assoc(model) 16 | |> model.create_changeset 17 | |> repo.insert 18 | end 19 | 20 | def find({repo, model}, id, opts) do 21 | [pk] = model.__schema__(:primary_key) 22 | 23 | from(x in model, where: field(x, ^pk) == ^id) 24 | |> add_opts(opts, [:preload]) 25 | |> repo.one 26 | end 27 | 28 | def where({repo, model}, where_clause, opts) do 29 | where_clause = fix_where_assoc(where_clause, model) 30 | 31 | model 32 | |> add_where(where_clause) 33 | |> add_opts(opts, [:order_by, :limit, :preload]) 34 | |> repo.all 35 | end 36 | 37 | def count({repo, model}, where_clause) do 38 | where_clause = fix_where_assoc(where_clause, model) 39 | 40 | model 41 | |> add_where(where_clause) 42 | |> Query.select([], count(1)) 43 | |> repo.one 44 | end 45 | 46 | def delete({_repo, model} = rm, %{__struct__: model} = struct) do 47 | [pk] = model.__schema__(:primary_key) 48 | delete(rm, Map.fetch!(struct, pk)) 49 | end 50 | 51 | def delete({repo, model}, id) do 52 | [pk] = model.__schema__(:primary_key) 53 | 54 | case repo.delete_all(from(x in model, where: field(x, ^pk) == ^id)) do 55 | {1, _} -> :ok 56 | {0, _} -> {:error, :not_found} 57 | end 58 | end 59 | 60 | def delete_where({repo, model}, where_clause) do 61 | where_clause = fix_where_assoc(where_clause, model) 62 | 63 | model 64 | |> add_where(where_clause) 65 | |> repo.delete_all 66 | |> case do 67 | {n, _} -> {:ok, n} 68 | end 69 | end 70 | 71 | def delete_all({repo, model}) do 72 | model 73 | |> repo.delete_all 74 | |> case do 75 | {n, _} -> {:ok, n} 76 | end 77 | end 78 | 79 | def update({repo, model}, model_struct, params) do 80 | params = fix_params_assoc(params, model) 81 | 82 | model.update_changeset(model_struct, params) 83 | |> repo.update() 84 | end 85 | 86 | def update_where({repo, model}, where_clause, params) do 87 | where_clause = fix_where_assoc(where_clause, model) 88 | params = fix_params_assoc(params, model) |> Map.to_list() 89 | 90 | model 91 | |> add_where(where_clause) 92 | |> repo.update_all(set: params) 93 | |> case do 94 | {n, _} -> {:ok, n} 95 | end 96 | end 97 | 98 | def first(repo_mod, where_clause, opts) do 99 | case where(repo_mod, where_clause, Keyword.merge(opts, limit: 1)) do 100 | [] -> nil 101 | [result] -> result 102 | end 103 | end 104 | 105 | def first_or_create(repo_mod, where_clause, opts) do 106 | case where(repo_mod, where_clause, [{:limit, 1} | opts]) do 107 | [result] -> 108 | result 109 | 110 | [] -> 111 | {:ok, result} = create(repo_mod, where_clause) 112 | result 113 | end 114 | end 115 | 116 | # The default `*_changeset` functions accept any fields except those in the pk. 117 | def create_changeset({_repo, model}, params) do 118 | pk = model.__schema__(:primary_key) 119 | fields = model.__schema__(:fields) |> Enum.reject(fn f -> Enum.member?(pk, f) end) 120 | struct = model.__struct__ 121 | Changeset.cast(struct, params, fields) 122 | end 123 | 124 | def update_changeset({_repo, model}, model_struct, params) do 125 | pk = model.__schema__(:primary_key) 126 | fields = model.__schema__(:fields) |> Enum.reject(fn f -> Enum.member?(pk, f) end) 127 | Changeset.cast(model_struct, params, fields) 128 | end 129 | 130 | # Internal functions 131 | def add_opts(query, [], _allowed_opts), do: query 132 | 133 | def add_opts(query, [{opt, opt_val} | rest], allowed_opts) do 134 | if opt in allowed_opts do 135 | apply_opt(query, opt, opt_val) 136 | else 137 | query 138 | end 139 | |> add_opts(rest, allowed_opts) 140 | end 141 | 142 | def apply_opt(query, :order_by, order_by), do: Query.order_by(query, ^order_by) 143 | def apply_opt(query, :limit, limit), do: Query.limit(query, ^limit) 144 | def apply_opt(query, :preload, preload), do: Query.preload(query, ^preload) 145 | 146 | def fix_where_assoc(where_clause, model) do 147 | for {field, value} <- where_clause do 148 | case model.__schema__(:association, field) do 149 | # only create keys for `belongs_to`, because this table has the foreign key 150 | ~M{%BelongsTo owner_key, related_key} -> 151 | {owner_key, Map.get(value, related_key)} 152 | 153 | _ -> 154 | {field, value} 155 | end 156 | end 157 | end 158 | 159 | # params needs to be a map 160 | def fix_params_assoc(params, model) do 161 | fix_where_assoc(params, model) 162 | |> Map.new() 163 | end 164 | 165 | def add_where(query, []), do: query 166 | 167 | def add_where(query, [{f, nil} | rest]) do 168 | from(x in query, where: is_nil(field(x, ^f))) 169 | |> add_where(rest) 170 | end 171 | 172 | def add_where(query, [{f, v} | rest]) do 173 | query 174 | |> Query.where(^[{f, v}]) 175 | |> add_where(rest) 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BaseModel.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.3.0" 5 | @repo_url "https://github.com/meyercm/base_model" 6 | 7 | def project do 8 | [ 9 | app: :base_model, 10 | version: @version, 11 | elixir: "~> 1.4", 12 | start_permanent: Mix.env == :prod, 13 | deps: deps(), 14 | # Hex 15 | package: hex_package(), 16 | description: "ActiveRecord for Ecto", 17 | # Docs 18 | name: "BaseModel", 19 | docs: [extras: ["README.md"], 20 | main: "readme"], 21 | # Testing 22 | preferred_cli_env: [espec: :test], 23 | ] 24 | end 25 | 26 | def application do 27 | [ 28 | extra_applications: [:logger] 29 | ] 30 | end 31 | 32 | defp hex_package do 33 | [maintainers: ["Chris Meyer"], 34 | licenses: ["MIT"], 35 | links: %{"GitHub" => @repo_url}] 36 | end 37 | 38 | defp deps do 39 | [ 40 | {:espec, "~> 1.4", only: :test}, 41 | {:ecto_sql, "~> 3.0"}, 42 | {:shorter_maps, "~> 2.0"}, 43 | {:ex_doc, ">= 0.0.0", only: :dev}, 44 | {:credo, "~> 0.8", only: [:dev, :test], runtime: false}, 45 | ] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 4 | "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "db_connection": {:hex, :db_connection, "2.0.3", "b4e8aa43c100e16f122ccd6798cd51c48c79fd391c39d411f42b3cd765daccb0", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "decimal": {:hex, :decimal, "1.6.0", "bfd84d90ff966e1f5d4370bdd3943432d8f65f07d3bab48001aebd7030590dcc", [:mix], [], "hexpm"}, 7 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [], [], "hexpm"}, 8 | "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm"}, 9 | "ecto": {:hex, :ecto, "3.0.6", "d33ab5b3f7553a41507d4b0ad5bf192d533119c4ad08f3a5d63d85aa12117dc9", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 10 | "ecto_sql": {:hex, :ecto_sql, "3.0.4", "e7a0feb0b2484b90981c56d5cd03c52122c1c31ded0b95ed213b7c5c07ae6737", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "espec": {:hex, :espec, "1.4.6", "9ac809d2a7ce64b9dbb468a517fe0c00c0464e4aeb918709ad2ba68a0a0d6536", [:mix], [{:meck, "0.8.7", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "meck": {:hex, :meck, "0.8.7", "ebad16ca23f685b07aed3bc011efff65fbaf28881a8adf925428ef5472d390ee", [:rebar3], [], "hexpm"}, 14 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 15 | "shorter_maps": {:hex, :shorter_maps, "2.2.4", "69b54de2f5a744964b4c1748d2d111a6fea34fa7440af03f8161be6655e703f0", [:mix], [], "hexpm"}, 16 | "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm"}, 17 | } 18 | -------------------------------------------------------------------------------- /testing/example_specs.sh: -------------------------------------------------------------------------------- 1 | cd examples/example_app 2 | 3 | echo " 4 | use Mix.Config 5 | config :example_app, ExampleApp.Repo, 6 | username: \"postgres\", 7 | password: \"\" 8 | " > config/secrets.exs 9 | 10 | mix deps.get 11 | mix do ecto.create, ecto.migrate 12 | mix espec 13 | --------------------------------------------------------------------------------