Sökresultat för

C++26 ORM via Reflection

72 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
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:

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:

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:

struct sv { const char* data; unsigned long size; };   // strukturell, OK
template<sv S> struct T {};
using X = T<sv{"users", 5}>;
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:

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.

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:

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:

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:

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:

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:

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:

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:

================ 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:

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:

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:

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:

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):

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:

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:

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.

if (t == ^^std::string) return type_kind::text;   // matchar aldrig
^^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.

for (auto a : std::meta::annotations_of(^^User))
    if (std::meta::type_of(a) == ^^Table)      // matchar aldrig för en klasstyp
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:

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:

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:

constexpr auto members = std::define_static_array(nonstatic_data_members_of(^^T, ctx));
template for (constexpr auto m : members) { ... }
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.

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

Det är formen jag använde i stringify() i förra artikeln 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:

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:

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.

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:

  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.

$ /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:

$ 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.

# 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:

$ 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

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:

-- 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:

$ 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.

$ ./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:

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:

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:

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:

./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:

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:

./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.