Database
The crate arminius-lib-db contains our basic interface to execute queries and manage transactions
Queries are meant to be defined in their own crates. The name of such a crate should adhere to the pattern arminius-lib-db-<table-or-partition-name>. As a rule of thumb, one such crate should be created per table. However, there may be situations where it makes sense to have one crate being responsible for multiple tables, if those are so closely related to each other that it makes no sense to use them in isolation.
Application code may only depend on arminius-lib-db / arminius-lib-db-* to interface with the database, but must not use sqlx directly.
See the module-level documentation in arminius-lib-db/src/lib.rs for more details and examples.
Query Functions
-
Functions should use the statically checked query functions from sqlx -
sqlx::query!,sqlx::query_as!,sqlx::query_file!, and family -
Function names must be declarative and indicate the type of operation. For example:
- Select statements must start with
fetch_ - Insert statements with
insert_ - Delete statements with
delete_ - Update statements with
update_ - Conditional insert or update statements with
upsert_
- Select statements must start with
-
Be explicit about which columns you select.
SELECT *might be tempting, but will possibly select more columns than expected and will silently change the columns returned when the schema changes. -
Query functions must return SQL-related errors, not API errors. This enforces a clear separation between database logic and API layers.
-
Functions must not accept or return any complex Ruma-api-specific structs (e.g. Ruma Responses). Only primitive types or simple domain types that don’t implement complex functionality should be used for input.
-
Functions should only implement database operations, not entire endpoints.
-
Functions should use strong types for their parameters where possible. Wrappers around primitive types that just validate the format of the input are explicitly encouraged.
- Example:
ruma::OwnedUserIdis a wrapper around String that verifies the string is a correct MXID
- Example:
-
Function must not return tuples. Tuples force the caller to remember (or look up) the meaning of each position, which hurts readability and maintainability. Structs also provide better forward compatibility. If the function needs to return an additional field, this is easier to maintain with structs than with tuples.
-
The sqlx method
fetch_optionalis generally preferred tofetch_one.- Whereas
fetch_onereturns aResult<_, sqlx::Error>where the error may besqlx::Error::RowNotFound,fetch_optionalreturns aResult<Option<_>, sqlx::Error>, wheresqlx::Error::RowNotFoundis ruled out. This allows us to explicitly handle the case of a missing row, and consider all other database errors unrecoverable. Withfetch_oneit is far too easy to write code that returns a 500 when it should return a 404
- Whereas
-
sqlxis not perfect at telling when a column may or may not be null. If you know a query can’t be null, prefer forcing non-null by writingSELECT column as "column!" FROM function()as your query, instead of handling it in Rust.
Tip
The
sqlx::query!macro returns anonymous structs, so their type cannot be described. If you find yourself in this situation:async fn fetch_all_users(conn: &mut PgConnection) -> Result</* WTF DO I PUT HERE */, sqlx::Error> { sqlx::query!("SELECT column FROM users").fetch_all(conn).await }you should use the
query_as!macro in cases where it is easily possible to map records into their respective representation without doing any sophisticated kind of transformation. Alternatively your function might usequery!internally as long as it immediately converts the return type to a named type, butquery_as!is preferred where possible.
- query functions should be traced using the
tracing::instrumentmacro to give us insights into the database usage. Long term we will refine the rules around that or move to automated instrumentation for those queries to limit what data is collected in those traces, but until then all query functions should generally be instrumented.
Disclaimer: This project is still under active development. If better conventions are identified in the future, this documentation will be updated.
Related Pages
- Database Architecture – schema conventions, table rules, crate structure
- Storing JSON in PostgreSQL –
jsonvsjsonbdecisions