Guide / Reinhardt Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9

How to make an ORM in your favorite language?

In this guide

We'll study how to make an ORM in your favorite language. In this video, we'll use MoonBit as an example to reproduce Reinhardt's mapping and query core. We build a small working core; the data structures and behavior checks below describe what to preserve when adapting it to your language.

Download the Rust and MoonBit experiments. Extract the ZIP and follow framework-internals/README.md. Tested with Rust 1.96.0, Moon CLI 0.1.20260827, moonc 0.10.11+6ff76a5f9, Node 22.22.1, Python 3.12 and a native C toolchain. Initial builds fetch dependencies. Both SQLite examples use separate in-memory databases; no server is needed.

python3 verify.py

One name, through both implementations

You want a stored user to come back as a typed value, even when the name looks like SQL. Reinhardt returns that name as literal data, and our MoonBit reproduction returns the same row. We will trace the real Rust model and query, then rebuild the path that makes this possible.

The reference is Reinhardt version zero point four, alpha thirteen, with its dependency lockfile preserved. The target is one model's mapping and execution path, not the framework's entire database feature set.

Start with the real model macro

Begin with User, which has an integer ID and a name. The Rust sample uses Reinhardt's model attribute, so the framework generates the model contract and field information. That metadata tells the ORM which table and columns represent this application value. See the pinned Model contract.

A Manager uses this mapping when creating users, while QuerySet describes which rows to retrieve. Describing a query and executing it are separate steps; adding a filter does not itself fetch the rows. See Manager::create_with_conn and QuerySet::filter. Follow one User through those steps before adding relationships or automatic schema generation.

A macro can remove repetitive declarations, but the generated information still has to agree with the table and the values being decoded.

Excerpt from the executable source; the complete program is in the download.

#[model(app_label = "internals", table_name = "users")]
#[derive(Serialize, Deserialize)]
pub struct User {
    #[field(primary_key = true)]
    pub id: i64,
    #[field(max_length = 100)]
    pub name: String,
}

Follow the value into a bound parameter

Our filter compares the name column with text supplied by the application. Reinhardt builds a query for the selected database and passes the value separately from the SQL structure. A placeholder stands for the value; the driver binds the complete name at execution. QuerySet::all_with_db passes the SQL and parameter values separately to the executor.

That is why quotes and a fake drop-table command inside the name remain data. Placeholders do not represent table or column names, so our schema identifiers come from trusted model metadata. The row then crosses back through field decoding to become a User, or a decoding error. See the row-to-model conversion in all_with_db and the DatabaseField::decode_database contract.

Excerpt from the executable source; the complete program is in the download.

let rows = QuerySet::<User>::new()
    .filter(Filter::new(
        "name",
        FilterOperator::Eq,
        FilterValue::String(literal.into()),
    ))
    .all_with_db(&mut db)
    .await?;
assert_eq!(rows.len(), 1);

Observe the real database boundary

Run the Rust program and the MoonBit runner using the supplied comparison command. Both versions insert Alice and a second user whose name contains SQL-looking text. Filtering by that second name returns exactly the user with ID two and the original name.

Searching for a missing ID gives no user; absence is different from an execution failure. A later transaction inserts a third user, then deliberately writes to a missing table. The operation fails, the third user is absent, and the original two remain.

These are database observations checked by assertions, not a performance comparison.

Rust / Reinhardt

model=generated manager=Reinhardt queryset=Reinhardt
quoted_input=literal matching_id=2 missing_id=None
failed_transaction_rolled_back=true rows=2
PASS reinhardt-web=0.4.0-alpha.13; real SQLite executor

MoonBit

model=descriptor manager=MoonBit queryset=MoonBit
quoted_input=literal matching_id=2 missing_id=None
failed_transaction_rolled_back=true rows=2
PASS MoonBit descriptors, lazy filters, bound values and typed rows

Represent generated metadata in MoonBit

To reproduce this path, separate the generated contract from the Rust syntax that produces it. Our MoonBit Model is a descriptor holding table and column names plus encode and decode functions. A QuerySet keeps that descriptor and an array of filters; adding a filter returns a new description.

Only all builds the statement, binds values, executes it, and decodes the returned rows. An enum limits selectable fields, and the decoder checks that the row contains the expected types. A malformed row in the MoonBit decoder check raises an error, rather than silently converting a string into an integer identifier.

This reproduces the mapping contract by hand; it does not implement a MoonBit model macro.

Excerpt from the executable source; the complete program is in the download.

priv struct Model[T] {
  table : String
  columns : Array[String]
  encode : (T) -> Array[Json]
  decode : (Json) -> T raise
}

Keep transaction ownership visible

The query layer must use the connection or transaction supplied by its caller. In Reinhardt, create_with_conn accepts the executor used by the atomic callback; its pinned implementation and transaction example show that caller-owned connection boundary. Our MoonBit functions similarly receive one database handle, so the failed insert belongs to the same transaction.

The small JavaScript adapter exposes Node's SQLite operations; the query and rollback policy stay in MoonBit. The MoonBit FFI documentation describes the host-language boundary. The two implementations use different database adapters, so matching behavior does not establish matching speed or allocation cost. Keep that boundary explicit when investigating another backend.

The host transports rows as JSON for this experiment. That extra representation boundary must preserve the types our decoder expects, and it is not Reinhardt's native row format.

Extend one verified contract

The download contains both implementations and the source-to-reproduction map. Start with the passing comparison, then add one nullable field and decide how absence differs from a null value. Trace the corresponding Reinhardt codec before changing the MoonBit decoder.

Our current subset has two columns, bounded integer IDs and equality filters; relationships and pooling are outside it. Preserve the version, command and smallest failing assertion when reporting a difference to the framework. Reinhardt Press is the project's own outreach publication, and these examples make its implementation open to investigation.

Take the mechanism to your language

In your favorite language, represent a model with a record and conversion functions, keep filters in a list, and call a database driver that binds values separately.

Keep the same checks: the complete quoted name returns unchanged, an absent ID returns no user, and a failed transaction leaves the original rows.

Source map and limits

PORTING.md maps the pinned framework functions to the executable MoonBit implementation and names the omitted features. The reproduction uses different runtime representations and is a bounded investigation, not a complete port. Node 22 emits an experimental warning for its SQLite API.

Reinhardt contribution guide.