# C++26 ORM via Reflection

25 september 2026 | https://www.ribomation.se/blog/2026/c++26-orm-via-reflection/

En helt vanlig C++-struct in. `CREATE TABLE` ut — med tabellnamn, kolumnnamn, SQL-typer, nullability och foreign keys, allt härlett. Inga makron, ingen kodgenerator, inget registreringssteg, och ingenting av det kostar en enda instruktion vid körning. Förra veckan gick jag igenom syntaxen i C++26:s reflection. Den här veckan byggde jag ett ramverk som är betydligt mer än ett trivialt demo.

Resultatet heter **tiny-orm**: cirka 2 500 rader headers, två databasmotorer, 88 tester, och ett svar på frågan om en vanlig aggregat-struct verkligen bär tillräckligt med information för att ett bibliotek ska kunna härleda hela avbildningen mot en databas. Den gör det. Och det bästa resultatet var inte SQL:en, utan att _samma_ härledda beskrivning visade sig kunna driva tre andra saker också: CSV-import, materialisering av godtyckliga frågeresultat, och typkontrollerade frågor.

I den här artikeln bygger jag ramverket från grunden. Utmaningen ligger i att programmera för compile-time och skapa datastrukturer som överlever till runtime. Först fundamentet om varför en sträng inte självklart överlever konstantevaluering, och två oberoende hinder att övervinna.

Sedan schemalagret: `template for` över medlemmarna, annotationer som bara anger avvikelser, och kolumnbeskrivningen som allt annat läser. Därefter vad det köper i form av DDL, CRUD och typsäkra frågor, och hur övergången mellan kompileringstid och körtid blev mätbar.

Sist, en genomgång av hur du kör alltihop själv: installerar drivrutiner för databaserna, startar en PostgreSQL-databas i en Docker-container, laddar ned data från _OpenFlights_, bygger systemet, kör hela testsviten, och till sist kör demoprogrammet som laddar CSV-data till databasen, aggregerar och skriver ut. Koden finns på GitHub.

* * *

Frågan
------

Verktygslådan bestod av fyra delar: `^^` som reflekterar, `[: :]` som splicar tillbaka, `template for` som rullar ut en loop vid kompilering, och annotationer som hänger metadata på en deklaration. Bra verktyg. Men ett verktyg bevisar ingenting förrän man byggt något med det som är stort nog att göra ont.

Så jag byggde ett ORM-ramverk.

Valet är inte godtyckligt. Objekt-relationell mappning är precis den sortens problem där C++ traditionellt får betala, och man kan välja mellan tre valutor:

*   **Intrusiva makron.** `REGISTER_ENTITY(User); REGISTER_FIELD(User, name);` — en rad per fält, som måste hållas i synk med structen för hand. Glömmer du en rad försvinner kolumnen tyst.
*   **En kodgenerator.** Ett separat verktyg som läser annoterade headers och spottar ut C++. Fungerar utmärkt, men nu har bygget ett extra steg, ett eget filformat och en egen uppsättning felmeddelanden.
*   **Handskriven boilerplate.** En `to_row()` och en `from_row()` per entitet. Ärligt, men det är samma kod om och om igen, och den ruttnar i takt med schemat.

Alla tre duplicerar information kompilatorn redan har. Den _har_ hela structen framför sig — fältnamnen, typerna, ordningen. Frågan blir alltså:

> Räcker en helt vanlig aggregat-struct — utan makron, utan basklass, utan registreringssteg — för att ett bibliotek ska kunna härleda tabellnamn, kolumner, SQL-typer, nullability, nycklar och foreign keys, och utifrån det generera DDL, parametriserad CRUD och avbildningen rad ⇄ objekt? Allt vid kompilering.

Kortsvaret är ja, och längre än jag trodde. Resten av artikeln är det långa svaret: vad som gick, vad det kostade, och vad som fortfarande fattas.

> Ramverket heter **tiny-orm** och ligger på GitHub — cirka 2 500 rader över 13 headers, 83 enhetstester som inte rör en databas, plus en integrationssvit som körs mot PostgreSQL och SQLite om de finns installerade. Byggt och kört med GCC 16.1.0, som rapporterar `__cpp_impl_reflection 202603L` och `__cpp_expansion_statements 202506L`. Det andra makrot spelar lika stor roll som det första: `template for` är hur ramverket går igenom en entitets medlemmar.

* * *

Grunden: en sträng som överlever kompileringen
----------------------------------------------

_Det visar sig att skriva kod för "compile-time" drar med sig en hel del nya problem som man i vanliga fall inte behöver fundera över._

Innan något över huvud taget kunde byggas dök ett problem upp som jag inte hade förutsett, och som visade sig vara hela fundamentet: **en sträng måste kunna existera som en konstant**.

Det låter trivialt. Ett kolumnnamn är en sträng, ett tabellnamn är en sträng, och båda är kända vid kompilering. Men en sträng som utgör payload för en annotering, ett template-argument eller ett element i `std::define_static_array` måste klara **två helt oberoende hinder** — och nästan varje förvirrande felmeddelande i det här området är det ena eller det andra.

Kravet

Faller för

Lösningen

**Hinder 1**

_typen_ är strukturell

`std::string_view`, `std::span`

publika medlemmar

**Hinder 2**

_värdet_ är en tillåten konstant

pekare till strängliteraler

`std::define_static_string`

### Hinder 1: strukturella typer

En klass är _strukturell_ när den är en literal type och **varje basklass och icke-statisk datamedlem är `public` och icke-`mutable`**, rekursivt. Det handlar om åtkomstkontroll, inte om layout. Och libstdc++ deklarerar:

```cpp
class basic_string_view {
    private:
      size_t        _M_len;
      const _CharT* _M_str;
```

Alltså är `std::string_view` ute och kan inte användas i denna kontext. Tre typer med identisk storlek, som skiljer sig på ett nyckelord:

```text
sizeof(string_view) = 16, sizeof(sv_public) = 16, sizeof(sv_private) = 16

is_structural_v      = false
is_structural_v> = false
is_structural_v             = true     // struct { const char*; size_t; }
is_structural_v            = false    // samma medlemmar som ovan, men privata
```

Att ärva hjälper inte: `struct my_sv : std::string_view` är också `false`, eftersom en strukturell typs baser själva måste vara strukturella.

> Regeln är inte godtycklig. Kompilatorn avgör om `T<a>` och `T<b>` är samma typ genom _template-argument equivalence_, som jämför värden **medlem för medlem** — aldrig via `operator==`. För en strängtyp är de två begreppen oense (innehåll kontra pekare-och-längd), och att bygga typidentitet av privata medlemmar skulle göra manglade namn beroende av ett biblioteks interna representation. Kravet på publika medlemmar gör "värdet är exakt dess medlemmar" kontrollerbart.

### Hinder 2: tillåtna resultat av ett konstant uttryck

En pekare som används som konstant måste peka på ett objekt med statisk lagringstid som språket kan _namnge_. En stränglitteral är inte ett sådant objekt — den har ingen länkning, och implementationer får slå ihop eller duplicera literaler fritt. Så även en perfekt strukturell typ blir avvisad:

```cpp
struct sv { const char* data; unsigned long size; };   // strukturell, OK
template<sv S> struct T {};
using X = T<sv{"users", 5}>;
```

```text
error: '"users"' is not a valid template argument of type 'const char*'
       because '"users"' is not a variable or function
```

`std::define_static_string()` finns precis för att ta sig igenom det här hindret: den kopierar tecknen till en riktig array med statisk lagringstid och ger tillbaka en pekare som går att namnge. Sett genom reflection rapporteras samma överträdelse som `reflect_constant failed`.

Ramverkets bärare är därför `string_view` med locket av:

```cpp
struct compiletime_string {                 // hinder 1: bara publika medlemmar
    const char* data = nullptr;
    std::size_t size = 0;
    constexpr auto view() const -> std::string_view { return {data, size}; }
    constexpr bool empty() const { return size == 0; }
};
static_assert(std::is_structural_v<compiletime_string>);

consteval auto S(std::string_view s) -> compiletime_string {
    return { std::define_static_string(s), s.size() };   // hinder 2: länkbar sträng
}
```

Och så den detalj som visade sig vara bärande för hela ramverket: **`define_static_string` är innehållsadresserad** — samma text ger alltid samma pekare.

```cpp
static_assert(lower("USERS") == std::define_static_string("users"));
static_assert(std::is_same_v<T<lower("USERS")>,
                             T<std::define_static_string("users")>>);
```

Det betyder att ett kolumnnamn som härletts reflektivt ur `fullName` och ett handskrivet `"full_name"` namnger _samma_ instansiering, istället för att tyst generera två olika.

> Den ergonomiska baksidan: **varje** sträng måste promoveras (dvs göras länkbar), även literaler som returneras från egna `consteval`\-funktioner. Det kostar en felsökningsrunda varje gång man glömmer:

```cpp
consteval auto sql_type(std::meta::info t) -> const char* {
    if (t == ^^int) return "INTEGER";                            // reflect_constant failed
    if (t == ^^int) return std::define_static_string("INTEGER"); // kompilerar
```

* * *

Schemat: från struct till beskrivning
-------------------------------------

Med strängarna på plats kan det roliga börja. Här är en entitet:

```cpp
struct [[=Table{.name = "bank_accounts"}]] Account {
    [[=PrimaryKey{.auto_increment = true}]] long id{};
    [[=Column{.name = "owner", .nullable = Nullable::no}]] std::string ownerName{};
    double balance{};
    std::optional<std::string> nickName{};
};

struct Transaction {
    [[=PrimaryKey{.auto_increment = true}]] long id{};
    [[=ForeignKey{.target = ^^Account, .on_delete = FkAction::cascade}]] long accountId{};
    double amount{};
    std::optional<std::string> memo{};
};
```

Det är hela entitetsdefinitionen. Ingen basklass, inget makro, ingen registrering. Lägg märke till tre saker:

1.  **`Transaction` har ingen `[[=Table]]` alls** och blir ändå tabellen `transactions`. Namnet härleds: `snake_case` plus pluralisering. Annotationer anger **bara det som avviker** från konventionen.
2.  **`ForeignKey.target` är `^^Account`** — en reflektion av entitetstypen, inte ett tabellnamn som sträng. Den refererade databastabellen förblir väldefinierad, och byter du namn på `Account` följer referensen med.
3.  **`nickName` och `memo` är nullable** för att de är `std::optional`. Ingen konfiguration. Typen bär redan svaret.

Hjärtat i ramverket är funktionen som gör en medlem till en beskrivning:

```cpp
struct column_info {
    compiletime_string name{};
    type_kind kind = type_kind::text;        // härledd ur medlemmens C++-typ
    compiletime_string sql_type{};           // explicit override, tom = rendera `kind`
    bool nullable = false;
    compiletime_string default_value{};
    bool primary_key = false;
    bool auto_increment = false;
    compiletime_string references_table{};   // tom när det inte är en foreign key
    compiletime_string references_column{};
    FkAction on_delete = FkAction::none;
};
static_assert(std::is_structural_v<column_info>);
```

Och loopen som bygger dem, som är precis den `template for`\-sats vi gick igenom förra veckan:

```cpp
template<typename T>
consteval auto columns_of() -> std::span<const column_info> {
    auto v = std::vector<column_info>{};
    for (auto m : std::meta::nonstatic_data_members_of(
                      ^^T, std::meta::access_context::unprivileged()))
        v.push_back(detail::describe(m));
    return std::define_static_array(v);
}
```

`describe()` är där all policy bor, och den är läsbar rakt igenom — prioritetsordning för SQL-typen, nullability som härleds om inget annat sägs, primärnyckeln som aldrig är nullable:

```cpp
consteval auto describe(std::meta::info m) -> column_info {
    auto const type = std::meta::dealias(std::meta::remove_cvref(std::meta::type_of(m)));
    auto const col  = annotation_on<Column>(m);
    auto const pk   = annotation_on<PrimaryKey>(m);
    auto const fk   = annotation_on<ForeignKey>(m);

    auto out = column_info{};
    out.name = column_name(m);
    ...
    auto const stated = col ? col->nullable : Nullable::derive;
    switch (stated) {
        case Nullable::yes:    out.nullable = true;               break;
        case Nullable::no:     out.nullable = false;              break;
        case Nullable::derive: out.nullable = is_optional(type);  break;
    }
    if (pk) {
        out.primary_key   = true;
        out.auto_increment = pk->auto_increment;
        out.nullable       = false;   // en primärnyckel är aldrig nullable
    }
    if (fk) {
        out.references_table  = table_name(fk->target);
        out.references_column = fk->column.empty() ? primary_key_name(fk->target)
                                                   : fk->column;
        out.on_delete = fk->on_delete;
    }
    return out;
}
```

Notera `table_name(fk->target)`: den refererade tabellens namn härleds ur _samma_ funktion som härleder entitetens eget namn. Konventionen bor på ett ställe.

Och om en medlem har en typ som inte går att mappa, blir det ett kompileringsfel som faktiskt säger vad som är fel och vad du ska göra:

```cpp
throw std::meta::exception{"tiny-orm: no SQL type mapping for member '"
                               + std::string{std::meta::identifier_of(m)}
                               + "'; give it an explicit [[=Column{.sql_type = \"...\"}]]",
                           m};
```

* * *

Vad det köper, del 1: SQL ut ur tomma intet
-------------------------------------------

Från de två structarna ovan, och ingenting annat, faller det här ut — verbatim ur `sql-generation-example`, som inte rör en databas alls:

```text
================ postgresql ================

CREATE TABLE IF NOT EXISTS "bank_accounts" (
  "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
  "owner" TEXT NOT NULL,
  "balance" DOUBLE PRECISION NOT NULL,
  "nick_name" TEXT,
  PRIMARY KEY ("id")
);

CREATE TABLE IF NOT EXISTS "transactions" (
  "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
  "account_id" BIGINT NOT NULL,
  "amount" DOUBLE PRECISION NOT NULL,
  "memo" TEXT,
  PRIMARY KEY ("id"),
  CONSTRAINT "fk_transactions_account_id" FOREIGN KEY ("account_id")
      REFERENCES "bank_accounts" ("id") ON DELETE CASCADE
);

-- insert
INSERT INTO "bank_accounts" ("owner", "balance", "nick_name") VALUES ($1, $2, $3) RETURNING "id";
   params: ['Ada', '1234.5', NULL]

-- select many
SELECT "id", "owner", "balance", "nick_name" FROM "bank_accounts"
  WHERE ("balance" > $1 AND "owner" LIKE $2) ORDER BY "balance" DESC LIMIT $3;
   params: ['100', 'A%', '10']
```

`ownerName` blev `owner` för att annotationen sa det; `nickName` blev `nick_name` för att konventionen sa det; `nick_name` saknar `NOT NULL` för att typen sa det. Och `transactions` fick sitt namn helt på egen hand.

Samma entiteter, renderade av SQLite-dialekten istället:

```text
CREATE TABLE IF NOT EXISTS "bank_accounts" (
  "id" INTEGER NOT NULL,
  "owner" TEXT NOT NULL,
  "balance" REAL NOT NULL,
  "nick_name" TEXT,
  PRIMARY KEY ("id")
);

-- insert
INSERT INTO "bank_accounts" ("owner", "balance", "nick_name") VALUES (?, ?, ?) RETURNING "id";
```

`BIGINT` blev `INTEGER`, `DOUBLE PRECISION` blev `REAL`, `$1` blev `?`, och identity-klausulen försvann helt. Vi återkommer till varför den gjorde det.

Ovanpå det ligger ett DAO, där nyckeltypen återvinns genom att reflektera `PrimaryKey`\-medlemmen:

```cpp
auto accounts = DAO<Account>{*db};

accounts.create();                       // CREATE TABLE ur structen
auto ada = Account{.ownerName = "Ada Lovelace", .balance = 1000.0};
accounts.insert(ada);                    // ada.id håller nu den genererade nyckeln
auto found = accounts.load(ada.id);      // std::optional<Account>
accounts.insert_all(rows, {.batch_size = 500});
```

> Ingen persistence context: ingen identity map, ingen dirty checking, ingen lazy loading. Entiteter är värden — ladda en kopia, ändra den, lämna tillbaka den. Det passar C++ bättre, och plumbing-lagret hade inte demonstrerat någonting om reflection.

* * *

Vad det köper, del 2: `field<^^Account::balance>`
-------------------------------------------------

Det här är den klaraste "reflection ger typsäkerhet"-demon i hela projektet, och den enda platsen där jag efteråt blev genuint imponerad av vad man får gratis.

En kolumn namnges av **en reflektion av medlemmen**:

```cpp
constexpr auto balance = field<^^Account::balance>;
constexpr auto owner   = field<^^Account::ownerName>;

auto rich = accounts.select()
                .where(balance > 1000.0 && owner.like("A%"))
                .order_by(balance, sort::desc)
                .limit(10)
                .all();
```

Ingen sträng någonstans. Och ur det enda värdet `^^Account::balance` återvinns tre saker:

```cpp
template<std::meta::info M>
struct field_t {
    using entity     = typename[:std::meta::parent_of(M):];
    using value_type = typename[:detail::unwrap(
        std::meta::dealias(std::meta::remove_cvref(std::meta::type_of(M)))):];
    static constexpr auto column = detail::column_name(M);

    template<typename V>
    auto compare(std::string_view op, V const& v) const -> predicate<entity> {
        static_assert(std::convertible_to<V const&, value_type>,
                      "tiny-orm: the compared value is not convertible to the column's type");
        ...
    }
};
```

`parent_of(M)` ger **den ägande entiteten**, `type_of(M)` ger **medlemmens typ**, och `column_name(M)` ger **kolumnnamnet**. Konsekvenserna är trevligare än de ser ut:

*   `accounts.select().where(field<^^Order::id> > 1)` **kompilerar inte**. Predikatet är `predicate<Order>` och `where` vill ha `predicate<Account>` — vanlig överlagringsupplösning, ingen `static_assert` inblandad.
*   `field<^^Account::balance> > "abc"` kompilerar inte heller, för att operanden jämförs mot medlemmens egen typ.
*   `value_type` skalar av `std::optional`, så en jämförelse mot en nullable kolumn jämför ändå mot det underliggande värdet.

Det där är inte ett bibliotek som lagt till typkontroller. Det är typkontroller som _faller ut_ av att kolumnen namnges av sin medlem istället för av en sträng.

* * *

Avslöjandet: en beskrivning, flera konsumenter
----------------------------------------------

Så här långt hade jag byggt en SQL-generator som läser structar. Det var när jag behövde läsa in CSV-data i demon som det intressanta hände: **CSV-läsaren behövde inte något nytt**.

`column_info` — samma beskrivning, härledd en gång — läses av fyra ställen som inte har med varandra att göra:

Konsument

Vad den gör med den

`ddl.hxx`

renderar `CREATE TABLE`

`dml.hxx`

bygger parametriserad INSERT/UPDATE/DELETE/SELECT

`csv.hxx`

matchar CSV-headerns kolumner mot medlemmar

`mapping.hxx`

skriver resultatkolumner in i medlemmar via splice

CSV-matchningen är fjorton rader och använder exakt samma `describe(m)`:

```cpp
template for (constexpr auto m : members_of<T>()) {
    constexpr auto c = describe(m);
    for (auto i = 0uz; i < header.size(); ++i)
        if (normalise(header[i]) == normalise(c.name.view())) { ... }
}
```

Och avbildningen tillbaka, som är splicen från förra veckan i sin mest användbara form:

```cpp
template for (constexpr auto m : members_of<T>()) {
    constexpr auto c = describe(m);
    using V = typename[:std::meta::type_of(m):];
    e.[:m:] = from_cell<V>(rs.at(row, c.name.view()));
}
```

Ingenting deklareras två gånger. Och det är den starkaste enskilda slutsatsen i hela experimentet:

> **Reflection beskriver _typen_, inte databasavbildningen.** CSV-importen har ingenting med SQL att göra och läser ändå samma beskrivning. Det är beviset för att greppet generaliserar bortom det jag råkade bygga.

Samma sak gäller läsningen av godtycklig SQL. `select_into<Row>` tar vilken vanlig struct som helst och matchar kolumner mot medlemmar på namn — för joins, `GROUP BY` och aggregat som byggaren medvetet inte gör:

```cpp
struct OwnerTotal { std::string owner{}; long accounts{}; double totalBalance{}; };

auto totals = select_into<OwnerTotal>(*db, R"(
    SELECT "owner" AS owner, count(*) AS accounts, sum("balance") AS total_balance
    FROM bank_accounts GROUP BY "owner" HAVING count(*) > 1)");
```

`totalBalance` läser `total_balance`, för att medlemmar snake\_case:as med samma funktion som kolumner.

### Övergången går att mäta

Den fråga jag skulle ställa om någon annan visade mig det här är: _påverkar det hela kodbasen?_ Svaret är en siffra. Av tretton header-filer innehåller **de fem runtime-headerfilerna noll reflection** — `connection.hxx`, `dialect.hxx`, `psql.hxx`, `sqlite.hxx` och `connect.hxx` känner inte till att `std::meta` existerar. Reflection är koncentrerad till den del som utförs i compile-time, och övergången mellan delarna är synlig i filstrukturen.

* * *

Vad det kostar
--------------

Så här långt har det låtit lätt. Det var det inte. Sju överraskningar under bygget, varav **två inte ger något felmeddelande alls**, och resten ger diagnostik som inte nämner sin egen orsak.

De delar ett tema som är värt att säga rakt ut:

> **Reflection skiljer på saker som typsystemet annars låter dig blanda ihop** — ett alias från typen det namnger, ett `const`\-värde från ett muterbart, en deklaration från entiteten den introducerar — **och reflektionsvärden kan inte lämna konstantevalueringen.**

### De två tysta

**1\. `^^` på en typedef reflekterar _aliaset_, inte typen.**

```cpp
if (t == ^^std::string) return type_kind::text;   // matchar aldrig
```

```text
^^std::string == ^^basic_string            : false
dealias(^^std::string) == ^^basic_string   : true
```

`std::string` är ett alias för `std::__cxx11::basic_string<char>`, och `^^` reflekterar aliaset, som är en annan entitet än typen det namnger. Jämförelsen är helt enkelt falsk, och varenda `std::string`\-medlem föll igenom till "omappad typ"-grenen. Inget felmeddelande, bara fel resultat. Fix: `std::meta::dealias` före varje jämförelse mot en standard-typedef.

**2\. Annotationer kommer tillbaka `const`\-kvalificerade.**

```cpp
for (auto a : std::meta::annotations_of(^^User))
    if (std::meta::type_of(a) == ^^Table)      // matchar aldrig för en klasstyp
```

```text
annotation type   = const Table
== ^^Table        = false
== ^^const Table  = true
```

Och här är asymmetrin som gör den så lätt att missa: en `const char*`\-payload **jämför lika** mot `^^const char*`. Fällan slår alltså till först när du går från en pekar-payload till en struct-payload — vilket är precis den riktning det här projektet rörde sig. Fix: `annotations_of_with_type`, som normaliserar kvalificeringen.

### De fem som åtminstone säger ifrån

**3\. `^^` avvisar namn som införts med en using-deklaration.** `using tiny_orm::x;` följt av `^^x` ger `error: '^^' cannot be applied to a using-declaration`. En using-deklaration inför en _deklaration_, inte entiteten. `using namespace` är däremot helt OK. Fix: kvalificera operanden.

**4\. `std::meta::info` är consteval-only, och det smittar.** Den här ändringen såg oskyldig ut:

```cpp
struct column_info {
    ...
    std::meta::info member{};   // för att kunna peka tillbaka på datamedlemmen
};
```

Den kompilerade fint i `schema.hxx` och detonerade vid varje runtime-användning:

```text
error: consteval-only variable 'c' not declared 'constexpr' used outside a
       constant-evaluated context
```

**En typ som innehåller en `std::meta::info` kan inte existera vid körning över huvud taget.** Ett enda sådant fält hade gjort `column_info` oanvändbar i både DDL- och DML-generatorerna, som håller den i vanliga `constexpr`\-variabler i vanliga funktioner. Det är också därför beskrivningen ser ut som den gör: den bär namn och egenskaper, aldrig reflektioner.

**5\. En länkbar container måste vara `static`.** Det här är punkt 4 i förklädnad, och det tog mig en stund att se det:

```cpp
constexpr auto members = std::define_static_array(nonstatic_data_members_of(^^T, ctx));
template for (constexpr auto m : members) { ... }
```

```text
error: 'members' is not a constant expression
```

Ett `constexpr`\-lokalt objekt av consteval-only typ duger inte som intervall (range). Två saker fungerar däremot: att skriva anropet _i loopheadern_, eller att lägga till ett enda nyckelord.

```cpp
static constexpr auto members = ...;    // fungerar
```

Det är formen jag använde i `stringify()` i [förra artikeln](/blog/2026/c++26-reflection/) utan att tänka på saken, och den är väl värd att känna till innan man sitter och stirrar på felmeddelandet.

**6\. `consteval` läcker in i kod som runtime också behöver.** Man skriver `consteval` av vana när man håller på med reflection, och så råkar predikatet också anropas från dialektlagret. Tumregel: `consteval` bara för funktioner som rör `std::meta`; predikat över _resultaten_ av reflection ska vara `constexpr`.

**7\. `std::format` är inte `constexpr`** — vilket biter exakt där man önskar använda den mest, nämligen när man bygger ett läsbart meddelande till en `std::meta::exception` från en `consteval`\-funktion. Vanlig enkel sammanslagning av textsträngar får duga.

Inget av de sju problemen ovan visade sig vara show-stoppers. De kostade omvägar, inte funktionalitet. Och de är alla egenskaper hos GCC 16.1 och `<meta>`\-API:et som det ser ut hösten 2026 — inte hos språkdesignen.

* * *

När verkligheten slår tillbaka
------------------------------

De två mest användbara sakerna jag lärde mig handlar inte om C++26 alls. De är samma tes på två nivåer:

> Ett dialektlager skrivet mot en motor är en gissning om motorer. En parser skriven mot påhittad data är en gissning om filer.

### Den andra databasmotorn

SQLite lades till av ett enda skäl: **ett gränssnitt med en implementation är overifierat**. Man kan inte se, genom att titta, om en `dialect` abstraherar det som faktiskt skiljer motorerna åt eller bara det man råkade komma på.

Dialektgränssnittet designades genom att lista vad som skiljer motorer åt, innan någon av databaserna fanns på plats. Listan var i huvudsak rätt, och påtagligt ofullständig:

Skillnad

Hur den hittades

Platshållare — `$1` mot `?`

förutspådd

Identifierarcitering

förutspådd

Typrendering — `DOUBLE PRECISION` mot `REAL`

förutspådd

Identity-klausul

förutspådd

`RETURNING`\-stöd

förutspådd

`LIMIT` / `OFFSET`

förutspådd — och **fel**, de är identiska

**Bind-parametergräns — 65535 mot 32766**

**upptäckt, under bygget av bulk-insert**

**`DROP TABLE ... CASCADE`**

**upptäckt, när demon kördes**

**Hur en bunden boolean lagras**

**upptäckt, medan jag skrev den här artikeln**

Den andra av dem tog kål på demon redan vid första satsen:

```text
failed: tiny-orm: near "CASCADE": syntax error
        [sql: DROP TABLE IF EXISTS "routes" CASCADE;]
```

Den tredje raden förtjänar sin egen historia, för den hittades medan jag körde demon för att plocka fram siffror till den här artikeln — och den sa **ingenting alls**.

Demon skriver fyra rapportavsnitt. Mot PostgreSQL fylldes alla fyra. Mot SQLite kom _Airlines by route count_ tillbaka tom, utan ett felmeddelande någonstans. Frågan är odramatisk:

```sql
SELECT al.name, al.country, count(*) AS routes
FROM routes r JOIN airlines al ON al.id = r.airline_id
WHERE al.active
GROUP BY al.name, al.country
```

Parametrar binds som text, och `to_param(bool)` renderade `"true"`. Det är utmärkt för PostgreSQL, som konverterar texten till en `BOOLEAN`\-kolumn. SQLite konverterar inte: kolumnen har INTEGER-affinitet, `"true"` går inte att göra ett heltal av, och därför lagras den **som text** — varpå `WHERE al.active` läser en icke-numerisk sträng som falsk. För varje rad.

```sql
CREATE TABLE t (active INTEGER NOT NULL);
INSERT INTO t VALUES ('true'), ('1');

SELECT active, typeof(active) FROM t;   -- true|text
                                        -- 1|integer
SELECT count(*) FROM t WHERE active;    -- 1, inte 2
```

Det lömska är att resan _genom ramverket_ var korrekt hela tiden. `from_cell<bool>` accepterar `"true"`, så `load()` gav rätt värde tillbaka och samtliga tester var gröna. Det var bara motorns egen läsning av kolumnen som var fel. Fixen är en rad — bind `"1"` och `"0"`, som båda motorerna läser som en boolean — och regressionstestet får kontrollera det som faktiskt gick sönder, `WHERE "active"` i rå SQL, istället för rundturen som aldrig gjorde det.

Och notera vad kolumnaffinitet _är_: den står inte på någons lista över dialektskillnader, för den är ingen skillnad i **syntax**. Den är en skillnad i vad motorn gör med ett värde efter att den accepterat det.

Tre av tio missade, alltså, och en förutspådd skillnad som inte var någon. Vad som skiljer kolumnerna åt är inte svårighetsgrad. De förutspådda hittades genom att jämföra **syntax** i dokumentation; de upptäckta hittades genom att **göra något nytt** — skicka fler parametrar än en sats får bära, droppa tabeller som är sammanlänkade av foreign keys, och be motorn läsa tillbaka ett värde som en boolean. Ingendera dyker upp när man sätter sig ner och listar hur två dialekter skiljer sig.

> Identity-klausulen är värd en fotnot, för "SQLites nyckelord är `AUTOINCREMENT`" är det uppenbara och felaktiga svaret. En kolumn deklarerad exakt `INTEGER PRIMARY KEY` är ett alias för rowid och genererar redan nycklar. `AUTOINCREMENT` gör bara att rowid inte återanvänds, kostar en extra intern tabell, och avvisas överallt annars — inklusive på ett `PRIMARY KEY (...)`\-tabellconstraint, vilket är formen det här ramverket genererar. Att inte skriva ut någonting alls är både enklare och mer allmängiltigt.

### Den riktiga datan

Demon läser OpenFlights referensdata — riktiga filer, producerade av någon annan. CSV är inte ett format; det är en familj av konventioner som råkar dela komma. Sju saker gick sönder. De tre bästa:

**En post är inte en rad.** Ett citerat fält får innehålla avgränsaren, dubblerade citattecken **och radbrytningar**. `std::getline` är alltså fel primitiv — den delar ett tvåradigt citerat värde i två trasiga poster, och gör det tyst. Riktiga OpenFlights-namn innehåller både inbäddade komman och `"Winnipeg / St. Andrews Airport"`.

**Booleans är inte överens om någonting.** `from_cell<bool>` accepterade `t`, `true` och `1` — exakt den mängd man kommer fram till genom att tänka på vad en _databas_ returnerar. Sedan visade det sig att `airlines.dat` skriver `Y` och `N`, och samtliga 6 162 flygbolag laddades tyst som inaktiva. Inget fel: `"Y"` är helt enkelt inte `"t"`. Accepterade mängden är nu `t/true/1/y/yes`, skiftlägesokänsligt — och filen innehåller faktiskt både `N` och ett ensamt `n`, vilket avgör saken. Det finns ingen principiell plats att sluta: det är en lista över observerade stavningar, inte en specifikation.

Och lägg märke till att det är _samma_ boolean som ställde till det i dialektlagret, fast från andra hållet. Där handlade det om hur ett värde skrivs ut till motorn; här om hur det läses in från en fil. Booleans har ingen kanonisk stavning någonstans, och varje gräns ett sådant värde passerar är en gräns där någon har gissat.

**Riktig data är inte referentiellt ren.** Att deklarera foreign keys på `routes` och ladda filen misslyckas. Datan innehåller 109 käll- och 112 destinationsflygplats-id:n som `airports.dat` inte definierar, plus 479 rutter helt utan flygbolags-id — **1 347 rutter, 2,0 %**. Den frestande lösningen är att droppa constraints så att laddningen går igenom. Loadern filtrerar istället bort raderna och **rapporterar antalet**, för att 2 % är en egenskap hos datan värd att känna till snarare än ett hinder värt att dölja:

```text
  read  airports.dat     7698 rows    151 ms
  read  routes.dat      67663 rows    534 ms

  load  airports         7698 rows    180 ms
  skip                   1347 routes with dangling references (2.0%)
  load  routes          66316 rows   1105 ms

tables: 7698 airports, 6162 airlines, 246 planes, 66316 routes
```

Och som grädde på moset: OpenFlights egen sida säger "over 10,000" flygplatser. Filen har 7 698. Sidan ljuger inte — den är daterad, och filerna ändras utan förvarning och bär ingen egen version. Därför skriver hämtningsskriptet en `MANIFEST` med rader, bytes och tidpunkt. Den siffra du kan citera är den du mätte.

> Gemensamt för alla sju: **var och en överlevde en grön testsvit** och hittades först när koden pekades mot en fil någon annan producerat. En importerare skriven mot data du hittat på klarar varje test du kommer på att skriva. Den har bara testats mot din fantasi.

* * *

Sammanfattning
--------------

Frågan var om en vanlig struct räcker. Den gör det.

Ramverket härleder tabellnamn, kolumnnamn, SQL-typer, nullability, primärnycklar och foreign keys ur ingenting annat än medlemmarnas namn och typer, plus annotationer som bara anger avvikelser. Samma härledda beskrivning driver DDL, parametriserad CRUD, CSV-import och materialisering av godtyckliga frågeresultat. Fel — ett fält från fel entitet, ett värde av fel typ, en entitet utan primärnyckel, `auto_increment` på en icke-heltalskolumn — är kompileringsfel, för det mesta genom vanlig överlagringsupplösning.

Det som gick sämre var inte språket. Klumpigheten sitter i `<meta>`\-API:ets kanter: alias som måste dealiasas för hand, annotationer som kommer tillbaka const-kvalificerade, `template for` som är kinkig med sin range, strängar som måste promoveras en och en. Det är sådant som slipas bort — av en biblioteksversion, eller av en ny kompilatorutgåva. Kvarvarande gap är **verktygsmognad, inte uttryckskraft**.

Och det jag medvetet lät bli att bygga säger kanske mest om var gränsen går:

*   **Persistence context** — identity map, dirty checking, lazy loading. Värdesemantik passar C++ bättre, och plumbing-lagret hade inte visat något om reflection.
*   **`GROUP BY` i frågebyggaren** — aggregat bryter kontraktet att `DAO<T>` returnerar `T`. `select_into` täcker samma mark.
*   **Joins i byggaren**, arvsmappning, en tredje backend.
*   Och den intressantaste ogjorda saken: **finder-namn parsade vid kompilering**. `findByOwnerAndBalanceGreaterThan` som en sträng-NTTP, parsad och typkontrollerad medan kompilatorn kör. Det är fullt möjligt med det som finns idag. Jag lät bli för att experimentet redan hade svarat på sin fråga — men det är där jag skulle börja nästa gång.

Spring Framework respektive Hibernate tog år av arbete och en runtime-motor för att komma dit. Det här är 2500 rader header-filer och ingen runtime-motor alls, för att C++26 slutligen låter kompilatorn berätta vad den redan vet.

* * *

Appendix: bygga och köra demon själv
------------------------------------

Allt nedan är kört på riktigt, i den ordningen, på en Ubuntu-maskin.

### 1\. Kompilator

Det enda verkliga hindret. Reflection kräver **GCC 16**, som ännu inte paketeras av de flesta distributioner — det blir ett källkodsbygge eller en nightly-toolchain. Jag kör ett eget bygge i `/opt/gcc-16.1`, som inte ligger i `PATH`, så det pekas ut explicit till CMake längre ner.

```bash
$ /opt/gcc-16.1/bin/g++ --version
g++ (GCC) 16.1.0
```

CMake behöver vara minst 4.1, vilket är vad `CMakeLists.txt` kräver. Distributionens egen duger gott:

```bash
$ cmake --version
cmake version 4.2.3
```

### 2\. Databasdrivrutiner

Båda backendarna är valfria. Utan någon av dem byggs biblioteket ändå, och hela enhetstestsviten körs, eftersom de testerna går mot en fejkad connection. CMake säger till om vad som saknas och hur man installerar det.

```bash
# Debian / Ubuntu
sudo apt install libpq-dev libsqlite3-dev

# Fedora / RHEL
sudo yum install libpq-devel sqlite-devel

# Alpine
sudo apk add libpq-dev sqlite-dev
```

Catch2 hämtas automatiskt av CMake — ingenting att installera.

### 3\. PostgreSQL i en container

SQLite behöver ingen server alls, så vill du bara se demon köra kan du hoppa över det här steget helt. För PostgreSQL finns en färdig compose-fil:

```bash
$ docker compose -f docker/docker-compose.yml up -d
$ docker compose -f docker/docker-compose.yml ps
NAME          IMAGE                ...   STATUS                PORTS
tiny-orm-db   postgres:18-alpine   ...   Up 4 days (healthy)   0.0.0.0:5432->5432/tcp
```

Den startar `postgres:18-alpine` på `localhost:5432` med användaren `orm` och lösenordet `orm` — utvecklingsuppgifter, medvetet triviala — och skapar två databaser: `tiny_orm_demo` för demon och `tiny_orm_test` för integrationstesterna. Två stycken, för att både demon och testerna skapar och droppar tabeller, och de ska inte trampa på varandra.

Städa upp efteråt med `down` (behåller datan) eller `down -v` (slänger volymen).

### 4\. Klona och bygg

```bash
git clone https://github.com/ribomation/cxx26-tiny-orm.git
cd cxx26-tiny-orm

cmake -S . -B build -DCMAKE_CXX_COMPILER=/opt/gcc-16.1/bin/g++
cmake --build build -j 8
```

Konfigureringen talar om vad den hittade:

```text
-- tiny-orm: libpq 18.6 found -- PostgreSQL backend enabled.
-- tiny-orm: SQLite 3.46.1 found -- SQLite backend enabled.
```

Och testerna ska vara gröna innan du går vidare:

```bash
$ ctest --test-dir build
100% tests passed, 0 tests failed out of 88

Total Test time (real) =   3.04 sec
```

Integrationstesterna hoppar över sig själva, med instruktioner, om ingen motor är igång.

### 5\. Hämta data

OpenFlights-filerna är inte incheckade. De ligger under Open Database License, vars share-alike-villkor gäller för härledda databaser som publiceras — att hämta dem vid behov undviker vidaredistribution helt, och håller 4 MB data utanför repot.

```bash
$ ./scripts/fetch-openflights.sh
OpenFlights data -> /home/jensr/GitHubProjects/cxx26-tiny-orm/data/openflights
  airports      7698 rows   1127225 bytes
  airlines      6162 rows    396896 bytes
  routes       67663 rows   2377148 bytes
  planes         246 rows      8331 bytes
  countries      261 rows      5989 bytes
```

Skriptet skriver också en `MANIFEST` med rader, bytes och tidpunkt — eftersom filerna uppströms ändras utan förvarning och inte bär någon egen version. Den siffra du kan citera är den du mätte.

### 6\. Hur datan faktiskt ser ut

Det är värt att titta på filerna innan man läser koden som läser dem, för de illustrerar precis det som avsnittet om riktig data handlade om.

`airports.dat` — fjorton kolumner, ingen header, `\N` för null, och citerade fält som innehåller både komman och snedstreck:

```text
1,"Goroka Airport","Goroka","Papua New Guinea","GKA","AYGA",-6.081689834590001,145.391998291,5282,10,"U","Pacific/Port_Moresby","airport","OurAirports"
22,"Winnipeg / St. Andrews Airport","Winnipeg","Canada",\N,"CYAV",50.0564002991,-97.03250122070001,760,-6,"A","America/Winnipeg","airport","OurAirports"
```

`airlines.dat` — och där är den, sista kolumnen, den som kostade mig alla 6 162 flygbolag:

```text
1,"Private flight",\N,"-","N/A","","","Y"
2,"135 Airways",\N,"","GNL","GENERAL","United States","N"
```

Så här läses de, och det är hela konfigurationen — resten kommer ur structen:

```cpp
auto const airports = read_csv_file<Airport>(dir / "airports.dat",
                                             {.has_header = false, .null_marker = "\\N"});
```

### 7\. Kör demon

Samma binär, två databaser, enda skillnaden är URL:en:

```bash
./build/openflights-app                          # PostgreSQL, enligt compose-filen
./build/openflights-app "sqlite::memory:"        # ingen server behövs
./build/openflights-app "sqlite:/tmp/flights.db" # eller en fil
```

Tabellerna skapas ur structarna, filerna läses in, och sedan besvaras några frågor genom både `select_into` och den typade frågebyggaren:

```text
connected to sqlite::memory: (sqlite)

schema created from the structs

  read  airports.dat     7698 rows    151 ms
  read  airlines.dat     6162 rows     54 ms
  read  planes.dat        246 rows      1 ms
  read  routes.dat      67663 rows    534 ms

  load  airports         7698 rows    180 ms
  load  airlines         6162 rows     72 ms
  load  planes            246 rows      1 ms
  skip                   1347 routes with dangling references (2.0%)
  load  routes          66316 rows   1105 ms

tables: 7698 airports, 6162 airlines, 246 planes, 66316 routes

Busiest airports by departing routes
  Hartsfield Jackson Atlanta International Airport United States      915 routes to  217 airports
  Chicago O'Hare International Airport   United States      558 routes to  206 airports
  Beijing Capital International Airport  China              531 routes to  204 airports
  London Heathrow Airport                United Kingdom     525 routes to  170 airports

Airlines by route count
  Ryanair                                Ireland            2484
  American Airlines                      United States      2352
  United Airlines                        United States      2178

Highest airports, via the typed query builder
  Daocheng Yading Airport                China             14472 ft
  Qamdo Bangda Airport                   China             14219 ft
  Kangding Airport                       China             14042 ft
```

Kör samma sak mot PostgreSQL och siffrorna är identiska — vilket är hela poängen med att ha två backends, och, som avsnittet om dialekter beskrev, inte något man ska ta för givet förrän man provat.

### 8\. Bara SQL, ingen databas

Vill du se vad reflection genererar utan att installera någonting alls utöver kompilatorn, finns ett program som bara skriver ut SQL för båda dialekterna:

```bash
./build/sql-generation-example
```

Det är utdatan från det som ligger tidigare i artikeln, och det är den snabbaste vägen till att se `CREATE TABLE`\-satsen falla ut ur en struct.
