Model
Malli Schemas
Models in Gungnir are defined using malli. These models define your Clojure data structures, and how they interact with your database.
- Only data you describe as valid will be saved to the database.
- Perform transformations to your data when reading / writing to the database.
- Create descriptive error messages for your end users.
;; Define a account model
(def account-model
[:map
{:has-many {:account/posts {:model :post :foreign-key :post/account-id}
:account/comments {:model :comment :foreign-key :comment/account-id}}}
[:account/id {:primary-key true} uuid?]
[:account/email {:before-save [:string/lower-case]
:before-read [:string/lower-case]}
[:re {:error/message "Invalid email"} #".+@.+\..+"]]
[:account/password {:before-save [:bcrypt]} [:string {:min 6}]]
[:account/password-confirmation {:virtual true} [:string {:min 6}]]
[:account/created-at {:auto true} inst?]
[:account/updated-at {:auto true} inst?]])
Registering Models
Models can be registered using the gungnir.model/register!
function. Generally you’d want your system state manager to manage this. E.g. Integrant, Component, Mount.
(gungnir.model/register!
{:account account-model
:post post-model
:comment comment-model})
Model properties
:table
Specify the table you’d like to use for this model. By default the model name will be used as the table. For example you might have a :account
model, but you want to target the “accounts” table.
{:account
[:map
{:table :accounts}
[:account/email string?]}
:has-many
Describe a :has-many
relation which can be queried through the current model. This relational query will return a vector of maps.
[:map
{:has-many {:account/posts {:model :post :foreign-key :post/account-id}}}
,,,]
:has-one
Describe a :has-one
relation which can be queried through the current model. This relational query will return a single map or nil
.
[:map
{:has-one {:account/reset-token {:model :reset-token :foreign-key :reset-token/account-id}}}
,,,]
:belongs-to
Describe a :belongs-to
relation which can be queried through the current model. This relational query will return a single map or nil
.
[:map
{:belongs-to {:post/account {:model :account :foreign-key :post/account-id}}}
,,,]
Model Field properties
Malli schemas support adding properties. Gungnir has a few custom properties that can be used.
:primary-key
(required)
Describe which key is the PRIMARY KEY
in your table. This is required for Gungnir to be able to make use of the querying API, as well as the relational mapping.
[:account/id {:primary-key true} uuid?]
:auto
Tell Gungnir that this key is automatically managed by the database, and Gungnir should never make an attempt to modify it. This is useful for e.g. TIMESTAMP
columns which might be updated automatically.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
[:account/created-at {:auto true} inst?]
:virtual
Tell Gungnir that you want this key to be part of your Model, but it does not exist as a column in the database. Gungnir will make no attempt to query, or save this key. Useful for things such as password-confirmation fields.
[:account/password-confirmation {:virtual true} string?]
:before-save
Add hooks to a column to be executed before you save them to the database. This is useful to keep data transformations consistent. For example you always want to encrypt passwords before saving them to the database. Passwords could be set during creating, updated on the profile page, changed through a password reset. In all of these cases you must encrypt the user’s inputted password before inserting it to the database.
Add a :bcrypt
key to the :before-save
vector for :account/password
.
[:account/password {:before-save [:bcrypt]} string?]
And define the :bcrypt
:before-save
handler. Handlers take the key and value of field in question. The resulting value of gungnir.model/before-save
will be saved in the database.
(defmethod gungnir.model/before-save :bcrypt [_k v]
(buddy.hashers/derive v))
:before-read
Add hooks to a column to be executed before you read it from database. This is useful if want to sanitize any query parameters before reading. For example you could save all emails as lowercase (with the gungnir.model/before-save
hook). Then add a :before-read
hook to lowercase the email field when you query the database. That way you’ll be able to deal with case sensitive data.
Add the :before-save
and :before-read
hooks.
[:account/email
{:before-save [:string/lower-case]
:before-read [:string/lower-case]}
[:re #".+@.+\..+"]]
Define the hooks to be used
(defmethod gungnir.model/before-save :string/lower-case [_k v]
(clojure.string/lower-case v))
(defmethod gungnir.model/before-read :string/lower-case [_k v]
(clojure.string/lower-case v))
Gungnir has the :string/lower-case
hooks built-in, so you don’t have to define them yourself.
:after-read
Add hooks to a column to be executed after you read it from database. You could encrypt data before saving it, and decrypt it after reading it for extra security. Another use case is saving keywords to the database as strings, and parsing it as EDN after reading it.
In this case, account has an :account/option
key, which is a qualified-keyword. You can’t store keywords in SQL, so they’re converted to strings.
[:account/option
{:after-read [:edn/read-string]}
[:enum :option/one :option/two :option/three]]
Define the :edn/read-string
hook to convert the keywords. :edn/read-string
is also built-in Gungnir, so you don’t have to defined it yourself.
(defmethod after-read :edn/read-string [_ v]
(if (vector? v)
(mapv edn/read-string v)
(edn/read-string v)))
Model Relation Definitions
Gungnir can handle relational mapping for you. This is done by adding relation definitions to the models properties. For more information regarding querying relations visit the query page.
Example
In the example below we define the following relations:
- account has_many comments, through
:account/comments
- account has_many posts, through
:account/posts
- post belongs_to account, through
:post/account
- post has_many comments, through
:post/comments
- comment belongs_to post, through
:comment/account
- comment belongs_to account, through
:comment/post
(def model-account
[:map
{:has-many {:account/posts {:model :post :foreign-key :post/account-id}
:account/comments {:model :comment :foreign-key :comment/account-id}}}
[:account/id {:primary-key true} uuid?]
,,,])
(def model-post
[:map
{:belongs-to {:post/account {:model :account :foreign-key :post/account-id}}
:has-many {:post/comments {:model :comment :foreign-key :comment/post-id}}}
[:post/id {:primary-key true} uuid?]
[:post/account-id uuid?]
,,,])
(def model-comment
[:map
{:belongs-to {:comment/account {:model :account :foreign-key :comment/account-id}
:comment/post {:model :post :foreign-key :comment/post-id}}}
[:comment/id {:primary-key true} uuid?]
[:comment/account-id uuid?]
[:comment/post-id uuid?]
,,,])
Model Validators
In some situations you will want to have extra validations. Visit the changeset page to learn how to use validators.
Validators are defined using the gungnir.model/validator
multimethod. Which is matched with a qualified-keyword
.
The gungnir.model/validator
multimethod should return the following map.
:validator/key
- The key this validator is related to. If the validator check fails it will mark this key as the failing key.:validator/fn
- The function to be run to check the validation. This function takes a single argument, which is the map that is being validated.:validator/message
- The error message to be displayed when the validation check fails. This will be assigned to the:validation/key
as its error.
Example
Check if the :account/password
and :account/password-validation
match during registration. Since :map
keys are isolated from each other this would be a good solution.
(defn password-match? [m]
(= (:account/password m)
(:account/password-confirmation m)))
(defmethod gungnir.model/validator :account/password-match? [_]
{:validator/key :account/password-confirmation
:validator/fn password-match?
:validator/message "Passwords don't match"})
Model Database Error Formatting
Sometimes even with the perfect model you can still gets errors from the database. This can happen when you try to insert a row which contains an existing key with a UNIQUE CONSTRAINT
. Normally JDBC would throw an exception for these cases. Gungnir will instead catch them and place them in your changesets :changeset/errors
key. This error will be identified with a unique keyword and can be modified per field using the gungnir.model/format-error
multimethod.
Example
During registration, you won’t know if an email exists until you hit the database. If an email exists (assuming you have a UNIQUE CONSTRAINT
on the email column) Gungnir will return a :duplicate-key
error. This error can transformed to make it more understandable for your end user.
(defmethod gungnir.model/format-error [:account/email :duplicate-key] [_ _]
"Email already exists")
Note: Gungnir is in its early stages, and only few errors are handled. If an unhandled error occurs gungnir will instead of keyword return a Postgresql exception code. Read more at the database section.