Plectrum: Lookup tables to Rust enums
Last year, I implemented
Plectrum, a
Rust crate that maps lookup tables (a.k.a. reference tables) in a
database to enum types in code. At the time I wasn't entirely sure if
such a library even needed to exist since it solves a very specific
problem. But it was the perfect excuse to get my hands dirty
brain fried implementing procedural macros in Rust. Probably for the
first time, I used generic type parameters in my own abstraction,
something I'd been side-stepping until that point. Eventually, I
published it on crates.io too.
So I saw it mostly as a learning exercise, until last week when I used it in another project and it fit perfectly. That felt like a nice bit of validation that the library does serve a real use case. The README and examples in the repo only scratch the surface and the problem it solves is tricky to grasp if you haven't run into it yourself. In this post, I’ll walk through Plectrum’s use case in more detail and show how it can help write robust and performant code when working with lookup tables.
Lookup tables
Lookup tables are perhaps more popularly known as reference tables because there's a Wikipedia entry for it. It says:
In the context of database design a reference table is a table into which an enumerated set of possible values of a certain field data type is divested. It is also called a domain table because it represents the domain for the columns that reference it. For example, in a relational database model of a warehouse the entity 'Item' may have a field called 'status' with a predefined set of values such as 'sold', 'reserved', 'out of stock'. In a purely designed database these values would be divested into an extra entity or Reference Table called 'status' in order to achieve database normalisation.
These tables contain key-value pairs, usually a numeric identifier mapped to a human readable name.
1 2 3 4 5 6 7 8 9 10 |
|
Typically, the names are tightly coupled with application code. For example, you might have specific branches for handling each of the possible item statuses in code. Hence such tables are essentially static i.e. insertions are performed only by developers and are usually accompanied by significant changes in code.
In Rust, the values are best represented using enums.
1 2 3 4 5 |
|
But why store them in the database at all? The answer is
normalization. Other
tables include status_id
columns that reference the id
of the
reference table via a foreign key constraint. This provides
database-level guarantees, such as ensuring that an item with an
invalid status can never be inserted.
Problem
Such schema design introduces an interesting problem in the
application layer. A query against the items
table will return only
the status_id
, and we'd ideally want to deserialize it into the
corresponding variant of the Status
enum. There is no way to get the
enum from the status id without either:
-
hard coding the status id as well, a classic footgun
-
looking up in the db, which means every such query has to join against the
statuses
table
1 2 3 4 5 6 7 |
|
Similarly, when inserting an item, we'd need to use a sub-query to get the status id from name:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
This is slightly better than hard coding status ids. We're at least consistently using only human readable status names everywhere in code. But that means status names are now hard coded in the application layer, which defeats the purpose of normalization. If there's a need to change any of the status names in future, we have to remember to find-replace all hard coded strings in the codebase. Simply relying on the RDBMS's guarantee of referential integrity is not sufficient.
Ideally, we should always be using the Status
enum in code, with the
provision to convert it to the correct status id and vice
versa. Because the lookup tables are static, we could fetch all rows
from the statuses
table when the application starts and hold them in
memory for the lifetime of the process. Anytime the enum/name has to
be converted to id or vice versa, we use the mapping. There
are clear advantages of this approach:
-
No need for the table join. While the performance cost of joins in this context may not be significant, the SQL queries will certainly end up a lot simpler.
-
The mappings are in memory, so lookups are fast.
-
By using enums instead of hard coded strings, we're leveraging rust's type checker to catch errors at compile time.
Solution
While it wouldn't take much code to implement the above behaviour, it helps to have an abstraction that provides this functionality, especially if there are multiple lookup tables. Well, that's what plectrum is.
Before we can construct the mapping, we need to have the Status
enum
implement methods in the
plectrum::Enum
trait. Thanks to the Plectrum
derive macro we don't have to do it
manually.
1 2 3 4 5 6 7 8 9 |
|
The macro supports a plectrum
attribute with a rename_all
argument. It controls how enum variant names are translated from code
to data. For e.g. an enum variant in UpperCamelCase (OutOfStock
)
can be mapped to its database value in snake_case
(out_of_stock
). If you’ve used the
serde crate before, this pattern
will feel familiar. To use the derive macro, make sure to add plectrum
as a dependency with the derive
feature enabled.
To construct the mapping that will be held in memory, plectrum provides a Mapping struct with the following definition:
1 |
|
The type parameter K
corresponds to the status id, which will
typically be a numeric value. And E
corresponds to our enum that
implements the plectrum::Enum
trait.
To initialize the mapping, we call the Mapping::load
method which
has the following signature:
1 |
|
This method sets up bi-directional mapping between the status id and its corresponding name. But it needs to know how and from where to load the data. For that we've to pass it a source that implements the plectrum::DataSource trait, which is the final piece of the puzzle.
Continuing with our sqlite example, let's use sqlx to load data from the db.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
With this code in place, we can initialize the mapping as follows:
1 2 3 |
|
And in the application code,
1 2 3 4 |
|
If the schema has multiple reference tables, it makes sense to define a struct that holds all the mappings:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Using plectrum with frameworks
In case you're using any framework, it will likely provide an elegant way to manage application state. In Tauri for example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Or in case of Axum:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Now let's see how it helps with robustness.
Inconsistencies between enum and lookup table
At the time of application setup, it's possible to have inconsistencies between lookup table entries in the db and enum variants. For example:
-
There could be entries in the table for which enum variants are not defined
-
There could enum variants for which entries don't exist in the lookup table
In such cases, the Mapping::load
method would return
plectrum::Error::NotDefinedInCode
and
plectrum::Error::NotFoundInDb
errors respectively.
Because the mapping happens only once during process initialization, it implies that if an entry is added to or removed from the lookup table, the enum definition would also have to be modified accordingly (followed by restarting the process). The above errors act as a safety net to catch any inconsistencies eagerly i.e. the process will fail to start, which in most cases is preferable to errors at run time.
So that's about all there is to plectrum. Using it in my current project motivated me to revisit the code as a result of which I pushed a few improvements. The new release 0.2.0 is cleaner and better aligned with Rust's best practices. Hope others will find this crate useful.
If you've read this far, thank you! Just want to leave a note that I am available for contract work (or a full-time position if there's alignment) from November 2025, with a strong preference for projects leveraging Rust and/or PostgreSQL. Here's my up-to-date resume.
- Tagged
- rust
- opensource
Please reach out to me at