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 enfrom_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 202603Loch__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>ochT<b>är samma typ genom template-argument equivalence, som jämför värden medlem för medlem — aldrig viaoperator==. 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:
Transactionhar ingen[[=Table]]alls och blir ändå tabellentransactions. Namnet härleds:snake_caseplus pluralisering. Annotationer anger bara det som avviker från konventionen.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åAccountföljer referensen med.nickNameochmemoär nullable för att de ärstd::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 ärpredicate<Order>ochwherevill hapredicate<Account>— vanlig överlagringsupplösning, ingenstatic_assertinblandad.field<^^Account::balance> > "abc"kompilerar inte heller, för att operanden jämförs mot medlemmens egen typ.value_typeskalar avstd::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 exaktINTEGER PRIMARY KEYär ett alias för rowid och genererar redan nycklar.AUTOINCREMENTgör bara att rowid inte återanvänds, kostar en extra intern tabell, och avvisas överallt annars — inklusive på ettPRIMARY 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 BYi frågebyggaren — aggregat bryter kontraktet attDAO<T>returnerarT.select_intotäcker samma mark.- Joins i byggaren, arvsmappning, en tredje backend.
- Och den intressantaste ogjorda saken: finder-namn parsade vid kompilering.
findByOwnerAndBalanceGreaterThansom 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.
Länkar
Senaste Artiklarna
-
C++26 ORM via Reflection
25 september 2026 -
C++26 Reflection
18 september 2026 -
C++26 Contracts
11 september 2026 -
Cargo – konvention framför konfiguration
4 september 2026 -
Referenser & pekare i Rust jämfört med C++
28 augusti 2026 -
När Rust får panik gör C++ ett undantag
21 augusti 2026 -
Rust syntax jämfört med C++
14 augusti 2026 -
Fem skäl att välja Rust över C++
10 augusti 2026