Usage
Generate language clients from one dbspec document set. config/parity.json declares Go, PHP, PHP extension, TypeScript, Python and Rust as the six implementations; the PHP extension shares the PHP-facing API surface and its unfinished native scope is tracked separately. Supported databases are MySQL 8 by default, PostgreSQL 12+, and SQLite 3.46+.
A query starts with a generated model and receives an opened database connection through connect. Inside a transaction callback, a model without connect uses the active transaction.
The complete syntax is in dsl.md, the schema language is in dbspec.md, schema tools are in schema.md, and the IR/Plan format is in protocol.md. Dialect differences are in dialects.md, configuration is in config.md, and error codes are in errors.yaml.
1. Requirements
| Requirement | Notes |
|---|---|
| Go 1.27+ | Engine, generator, and Go client |
| MySQL 8.0.2+ / MariaDB 10.2+ | Primary target; PostgreSQL 12+ and SQLite 3.46+ use the same plan |
PHP 8.4+ (pdo_mysql) | Required for PHP; add pdo_pgsql and ext-pgsql for PostgreSQL, and pdo_sqlite for SQLite. PostgreSQL cancellation uses ext-pgsql. |
| Rust 1.98+ | Required for Rust |
| Node.js 22.16+ | Required for TypeScript |
git clone https://github.com/polyspec/orm && cd orm
go build ./packages/go/... ./tests/... ./examples/...2. Schema
The human-maintained definition is a set of dbspec documents (dbspec.md); contracts/fixtures/bench/bench.dbs is an example.
dbspec 1 example
table users {
id i64 identity
name varchar(191)
email bytes null
key_version i32
created_at datetime(6) default now
updated_at datetime(6) default now
primary key (id)
settings {
updated updated_at
codec email aes
aes_version key_version
}
}
table authors {
id i64 identity
user_id i64
closed bool default false
primary key (id)
index ix_authors_user (user_id, closed)
foreign key fk_authors_user (user_id) references users (id) on delete restrict on update restrict
}- Columns state their type,
null,identityanddefault; keys, indexes, foreign keys and checks are named lines of the table. - Settings give the ORM and the database their behavior:
updated,soft_delete,aes_version,blind_index,codec,select explicit,immutableandaudit(dbspec.md, codec.md). A column name never selects behavior. - A foreign key is named; queries name relation keys with
match<L>With<R>.
mermaid.md exports a document to a Mermaid erDiagram and imports a diagram into a document with the list of what the diagram cannot express.
2.1 Create tables and migrate
Each client renders the CREATE statements of a document set for one dialect, introspects a database into a document, and applies a chain of migration plans with a lock, history, verification and MySQL recovery. schema.md lists the functions of each language, dialects.md the rendered statements, and plans.md the plan documents, the diff and the apply. A rename and the permission to drop are declared in the plan; the diff never infers them.
3. Code generation
Each language generates its models with its own build tool. The generator reads the dbspec document set, embeds its manifest text and manifestHash, and writes one model per entity with typed column getters and setters.
Every generator takes the document set as its set file, here contracts/fixtures/bench/dbspec-set.json beside the documents it lists, and no other form:
{"schema": 1, "documents": ["example.dbs"]}go run github.com/polyspec/orm/packages/go/cmd/orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --lang go --out model --scan ./...
vendor/bin/orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --out src/Model --namespace 'Example\Model' --scan src
npx orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --out src/models --scan src
orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --out model --scan src// build.rs
fn main() {
polyspec_orm_build::Builder::set("contracts/fixtures/bench/dbspec-set.json").scan("src").generate();
}| Language | Tool | When it runs |
|---|---|---|
| Go | orm-gen gen --lang go in a //go:generate line | go generate before go build |
| PHP | vendor/bin/orm-gen gen | a Composer script after the schema changes |
| TypeScript | orm-gen gen of @polyspec/orm | the build script before tsc |
| Python | orm-gen gen (Python package) | after the schema or a scanned source changes |
| Rust | the polyspec-orm-build crate | build.rs on every cargo build; polyspec_orm::models!() includes the models as the module model |
- Every generator declares a typed method for every prefix of every key of a model (the primary key, the unique keys and the indexes), from the schema, whether or not the source named by
--scancalls it: a single column is its column method, such asauditSeqof the index onaudit_seq, and several columns are the tuple method, such astupleAccountSeqWithProjectSeq. Go, Rust, TypeScript, PHP and Python declare these methods in the generated models; Python and PHP also declare the condition method of every column and the column methods, each typed to return the model, and resolve every other chain name at run time. The scan adds the chain methods that the source calls, and a name that the chain grammar does not accept stops the build. PHP declares in each generated model the methods that the schema enumerates, so that a static analyser such as PHPStan finds them: the condition method of every column, the tuple method of every key prefix of two or more columns,addColumn<Col>,removeColumn<Col>,groupBy<Col>,keyName<Col>,orderBy<Col>AscandorderBy<Col>Descof every column,plus<Col>,minus<Col>,sum<Col>andavg<Col>of every numeric column, andforceIndex<Index>of every index. Each runs the chain of its name, so it builds the query that the chain builds. With--scan, PHP also declares as@methodtags the chain methods that the scanned sources call, and a name that the chain grammar does not accept stops the build (dsl.md). Without--scan, PHP resolves every other chain name at call time through__call, because the schema does not enumerate it: an operator chain such aslbNameorgtSeq, a chain of several columns such asseqAndName, a condition withandoror, a findergetBy<Chain>,getsBy<Chain>orgetCountBy<Chain>, an order of several columns,join<Pair>,leftJoin<Pair>,match<Pair>,alias<Name>,new<Name>,possible<Col>,addColumn<Name>of an added column, andget<Name>of an added value or relation. - The generated models depend only on the documents and the scanned sources, not on how a path is written: a scan or output path written relative or absolute, with a
./component or with a trailing/gives the same files, and so does a PHP set file path written relative, absolute or with./. Rustinclude_str!names the manifest file by the canonical path of the output directory. - The TypeScript scan declares a method only for a call whose receiver resolves to a generated model, and declares it on that model. A receiver resolves to a model when it is
new C()of a generated classC(also through an import alias or a namespace import), a variable or parameter bound to a model or typed with a model class, a function or arrow function whose declared return type is a model or, without a declared type, whose expression body or onlyreturnstatement is a model, a chain method of a model, the untyped first parameter of a callback given toand,or,on,fetchKey,fetchValue, oraddColumn…of a model, an expression assertedas C, a conditional expressionc ? a : bor a nullish defaulta ?? bwhose operands are models (a variable reassigned from one holds every model its operands may be), or a row of a model: the awaited result ofgetorgetBy…, or an element of a collection of models (the awaited result ofgetsorgetsBy…, itsvalues()orfilter(…), a spread copy, or a value typedCollection<C>orC[]) reached throughfor … of, an index,first,get,at,find, or the callback ofmap,forEach,filter,find,some, orevery. A call on any other receiver, such asthis.enabled()of a class that is not a model, adds no method; give such a receiver a model type when its calls must be typed. orm-gen gen --lang gowrites each scan round into a temporary directory beside--outand replaces the generated files of--outonly after the scan converges; files without the generated header stay. A generation failure leaves--outunchanged, andorm-genexits with status 1; the failures include an invalid chain call, a scan that does not converge, a scanned package that cannot be loaded, and generated code that does not compile. When the scan converges but the scanned packages do not compile for another reason,--outholds the complete models, andorm-genexits with status 3.--checkcompares the output with the existing files without writing:genof theorm-genof each language that has one. The command printsdiffers: <path>for a file whose content differs,missing: <path>for a file that does not exist, andextra: <path>for a file of the output directory that holds the generated-code comment and that the generation no longer writes, ordered by path, and exits with status 1 when it prints a line. Current files print nothing and exit with status 0. Python writes one file,models.py, so its check printsmissingordiffersfor it and has noextraline. Gogen --checkruns the same scan asgenin a directory under the system temporary directory, so the model package keeps the name and import path of--out; its generation failures and status 3 are the same as those ofgen. Rust generates its models inbuild.rsand has nogencommand.
go run github.com/polyspec/orm/packages/go/cmd/orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --lang go --out model --scan ./... --check- The scan generates a called method when an argument of the call has an unresolved type, such as a value computed with a method that another model package does not have yet; a join or relation argument must resolve to a model of the generated package. One
go generaterun over several model packages therefore writes the final models of each package. A generation that runs before another package's methods exist still exits with status 3 for the calls of that package. - After changing the schema or adding a chain call, regenerate and deploy the models with the schema. A request whose
manifest_hashdiffers from the model the client loaded fails withSCHEMA_HASH_MISMATCH.
4. Connections
A connection takes one DSN URI as an argument; the client reads no environment or secret store of its own. The URI scheme selects the database; callers do not pass a second driver value.
mysql://user:password@host:3306/orm_example
postgres://user:password@host:5432/orm_example?sslmode=disable
sqlite:///var/lib/orm_example.sqliteEach client accepts the DSN and creates the matching native driver and pool. The client plans every statement in the calling process from the loaded schema; no service runs beside it. One connection is opened per database, such as master and slave1, and connect gives each model the selected connection.
Go
master, err := model.Connect(masterDSN, orm.Config{AESKey: aesKey})orm-gen gen --set <dbspec-set.json> reads the dbspec document set from its set file with dbspec.ReadSet and writes ManifestText, ManifestHash and the schema value Schema into the generated orm.go; each path of the set file's external names an external document, whose used tables the generated code carries as ExternalText and Install checks in the database, without generating their models. model.Connect(dsn, config) calls orm.ConnectSchema(dsn, model.Schema, config): it opens the connection and registers the set of the models on it, and a text whose hash differs from ManifestHash fails with CONFIG. A request of a set that is not registered on its connection fails with SCHEMA_HASH_MISMATCH (protocol); orm.Connect(dsn, config) opens a connection without any set. master.Utils().Schema().Install(model.Schema) registers the set on the connection and renders the document set with the dialect of the connection and applies the statements, triggers included: it creates every table when none of the set exists, changes nothing when all exist, and fails with CONFIG when only some exist; then it fails with CONFIG naming each difference when the tables of the set in the database differ from the set.
PHP
$master = \Polyspec\Orm\Tests\Model\connect($masterDsn, new Config(aesKey: $aesKey));
$master->utils()->schema()->register(\Module\Orm\schema());bootstrap.php of the generated models defines MANIFEST_TEXT, MANIFEST_HASH, the schema value schema() and the connect helper connect(), which opens the connection with Orm::connectSchema and registers the set of the models on it; every request carries manifestHash. Orm::connect opens a connection without any set. $db->utils()->schema()->register(\Module\Orm\schema()) registers the set of other generated models, here a module set, on the same connection without a statement, so a server that opens a connection per request registers every set it uses on it. $db->utils()->schema()->install(\Polyspec\Orm\Tests\Model\schema()) installs the set at deployment, verifies the database and registers the set on the connection. new Config(poolSize: 1) keeps one connection per php-fpm worker across its requests instead of connecting in every request (config); the connection keeps its session settings, and a transaction that an ended request left open is rolled back with its locks and local values.
Rust
let master = model::connect(&master_dsn, pool_size, polyspec_orm::Config { aes_key, ..Default::default() }).await?;
master.utils().schema().install(&model::SCHEMA).await?;polyspec_orm_build::Builder::set(path) reads the dbspec document set from its set file with dbspec::read_set and writes the models and the manifest text into OUT_DIR. The generated module embeds the manifest text with include_str! as model::SCHEMA and its manifestHash as model::MANIFEST_HASH; its model::connect calls polyspec_orm::Db::connect_schema(dsn, &model::SCHEMA, pool_size, config), which opens the connection and registers the set on it. polyspec_orm::Db::connect opens a connection without any set. The runtime model is built from the embedded text on first use, and every request carries manifestHash. utils().schema().install(&model::SCHEMA) registers the set on the connection and renders the document set for the connection's database and applies the statements. It creates nothing when every table of the set exists and returns CONFIG when only some exist, and then returns CONFIG naming each difference when the tables of the set differ from the set; MySQL applies the statements outside a transaction and returns CONFIG inside one.
TypeScript
import { Item, SCHEMA, connect } from './models/models.js';
const master = await connect(masterDsn, { aesKey });
await master.utils().schema().install(SCHEMA);orm-gen gen --set <dbspec-set.json> reads the dbspec document set from its set file with readDbspecSet, and the generated models.ts exports the manifest text as MANIFEST_TEXT, its hash as MANIFEST_HASH, the schema value SCHEMA and the connect helper connect(dsn, options), which opens the connection with Db.connectSchema and registers the set of the models on it. Db.connect(dsn, options) opens a connection without any set, and a request of a set that is not registered on its connection fails with SCHEMA_HASH_MISMATCH. utils().schema().install(SCHEMA) takes the schema value of one document set, registers it on the connection and applies their rendered statements when none of their tables exists; when every table exists it changes nothing, and when only some exist it fails with CONFIG; then it fails with CONFIG naming each difference when the tables of the set differ from the set.
Each client caches plans by request shape. connection.utils().schema().install(schema) installs the document set (schema.md), and connection.utils().schema().addTablesAndColumns(schema) upgrades an installed set to a version that only adds: it creates the tables the database lacks, adds the missing columns of its existing tables that are null or have a default, creates their missing indexes, and returns SCHEMA_DIFFERS before any change for every other difference, a missing unique key included, because it can fail on the existing rows and belongs to a plan (schema.md). Both verify the database when they run; connection.utils().schema().register(schema) and the connect helper register a set on each connection without reading the database (schema.md).
5. Reading
Method names are shared; only spelling differs (PHP and TypeScript camelCase / Go PascalCase / Rust and Python snake_case).
$rows = (new Author)->connect($slave1)
->serviceSeq(7)->andIsClose(false)
->and(fn (Author $q) => $q->isDisplay(true)->or()->isAllday(true))
->andSeq([6, 106, 206])
->orderBySeqDesc()->limit(0, 20)
->gets();rows, err := model.Author().Connect(slave1).
ServiceSeq(7).AndIsClose(false).
And(func(q *model.AuthorModel) { q.IsDisplay(true).Or().IsAllday(true) }).
AndSeq([]int{6, 106, 206}).
OrderBySeqDesc().Limit(0, 20).
Gets()let rows = Author::new().connect(&slave1)
.service_seq(7).and_is_close(false)
.and(|q| q.is_display(true).or().is_allday(true))
.and_seq(vec![6, 106, 206])
.order_by_seq_desc().limit(0, 20)
.gets().await?;const rows = await new Author().connect(slave1)
.serviceSeq(7).andIsClose(false)
.and(q => q.isDisplay(true).or().isAllday(true))
.andSeq([6, 106, 206])
.orderBySeqDesc().limit(0, 20)
.gets();- Conditions: the first condition has no prefix, following conditions use
and<Chain>,or<Chain>, orand()andor(), groups useand(fn)andor(fn), and negated groupsnot(fn),andNot(fn)andorNot(fn). Operator prefixes, value shapes, and chain rules are in dsl.md. - Finders:
getBy<Chain>,getsBy<Chain>, andgetCountBy<Chain>apply a column chain, such asgetsByServiceSeqAndIsClose(7, false). Go, Rust and TypeScript declare the chains that model generation finds in the scanned code, PHP declares them as@methodtags when--scanis given and otherwise resolves a chain when the call runs, and Python resolves a chain when the call runs; the rule is in finder chains. - Columns:
addColumn<Col>(),removeColumn<Col>(),removeAllColumns(), andaddAllColumns().text,blob, and styled columns are excluded from the default SELECT and added withaddColumn<Col>(). - Rust generated model fields are private. Reading a column that was neither selected nor assigned returns
COLUMN_UNSELECTED; an omitted value is never presented as SQL NULL or a default. - PHP generated model getters also return
COLUMN_UNSELECTEDwhen a non-styled column was neither selected nor assigned; selected SQL NULL and explicitly assigned values remain readable. - Terminals:
getreturns one row and null when no row matches; no read raises an error for an empty result.getsreturns a collection, which is empty when no row matches.getCountreturns a count. - A collection is an ordered map keyed by PK or
keyName<Col>():first(),count(), andtoArray()are available, and iteration yieldskey => row. toArray()returns the rows as a list of maps in collection order: Gorows.ToArray(), Rustrows.to_array(), PHP$rows->toArray(), TypeScriptrows.toArray(), and Pythonrows.to_array(). Iteration keeps the keys and their types.
Aggregates and groups
(new Author)->connect($slave1)->serviceSeq(7)->groupByUserSeq()->getsCount(); // rows per user with row_count
(new Author)->connect($slave1)->serviceSeq(7)->sumLikeCount()->getSum(); // sum of like_count
(new Author)->connect($slave1)->serviceSeq(7)->avgPrice()->getAvg(); // average priceNegated groups, column function outputs, subquery columns, and ORM function values are specified in dsl.md. Rust gets_count() returns GroupRows with only selected grouping values and a checked row_count, instead of a partially populated model. Rust GroupRow::value(name) returns Result<&Val>: an unselected name reports COLUMN_UNSELECTED, while a selected SQL NULL remains Val::Null. Go GetsCount() returns *orm.GroupRows. Each GroupRow exposes its selected values through Value(name) and its checked count through Count(). Value(name) returns COLUMN_UNSELECTED for an unselected name and returns nil without an error for a selected SQL NULL. PHP and TypeScript getsCount() also return GroupRows with GroupRow entries. Use value(name) for a selected grouping value and count() in PHP or count in TypeScript for the checked row count. An unselected name fails with COLUMN_UNSELECTED; SQL NULL remains null.
6. Relations and joins
A relation uses a separate statement. It batches parent values into IN and attaches the child rows. A join is part of the same statement.
$rows = (new Author)->connect($slave1)->serviceSeq(7)->limit(0, 20)
->relation((new User)->matchUserSeqWithSeq()) // $b->getUser()
->relation((new Service)->matchServiceSeqWithSeq()
->relations((new ServiceMember)->matchSeqWithServiceSeq() // ->getServiceMembers()
->orderBySeqDesc()->groupLimit(3)
->keyNameUserSeq()))
->relation((new User)->connect($userReplica)->matchUserSeqWithSeq()->aliasWriter())
->joinServiceSeqWithSeq((new Service)->on(fn (Service $s) => $s->name('service-7')))
->gets();| Child option | Meaning |
|---|---|
match<L>With<R>() | Parent column L equals child column R |
alias<Name>() | Result name of the relation |
keyName<Col>() | Key column for a many collection; the last duplicate wins |
parentNode() | Merge one-relation columns into the parent row; parent values win (protocol) |
groupLimit(n) | n rows per parent using ROW_NUMBER() OVER (PARTITION BY …) |
possible<Col>(v) | Load only when the parent row matches |
deleteLock() | Stop point for delete(true) |
A relation child with connect runs its statement on that connection. A join child sets ON conditions with on(fn) and WHERE conditions directly; and(child) or or(child) places the child conditions in a group. connect on a join child returns CONFIG. Column comparisons with a joined model use <ColA><Op><ColB>(model), such as priceGtMinPrice($brand).
7. Writes
$row = (new Author)->connect($master)->setName('x')->setUserSeq(1)->…->create(); // returns the row with its generated key
$row->setName('y')->update(); // updates changed columns only
$row->setName('z')->update(true); // updated_ts mismatch → OPTIMISTIC_LOCK
$row->delete();
$row->delete(true); // loaded relations first, except deleteLock()
$replicaRow->connect($master)->plusReadCount(1)->update(); // read on a replica, write on the primary
(new Author)->connect($master)->setUuid($u)->setName('x')->…
->duplication((new Author)->setName('x')->plusReadCount(1))->create(); // UPSERT
$row->newIsMember(true); // attached value, not part of SQLupdatealways writesupdated_tsexplicitly so the value is consistent across dialects.minus<Col>never stores a negative value.save()updates when the primary key is set and creates the row otherwise.creates(rows, batch)inserts the rows in batches ofbatchrows, each batch one multi-row INSERT, in one transaction, and returns the inserted row count; the caller choosesbatch, and the client never splits a batch.batchis a required positive integer, otherwiseCONFIG; a batch whose binds (rows times columns) exceed the bind limit, 999 on SQLite and 65535 on MySQL and PostgreSQL, isBATCH_TOO_LARGE, whose message names the batch, its bind count and the limit; both fail before any statement.- Transactions use
connection.transaction(fn). A callback error or exception rolls back the transaction, and deadlocks are retried. - Calling
transactionon the same connection inside an active transaction creates a savepoint. An inner failure rolls back only the inner work unless the outer callback returns it. - Two transactions that must be open at the same time need two connections, because a second transaction on one connection becomes a savepoint of the first. Lock contention is written that way: one connection holds the row and another waits for it.
- Transaction options select
isolation,readOnly, andtimeoutMs. The supported isolation names aredefault,read_uncommitted,read_committed,repeatable_read, andserializable. PostgreSQL applies transaction settings afterBEGIN; MySQL applies them beforeSTART TRANSACTIONon the retained connection. SQLite appliesreadOnlythroughPRAGMA query_onlyand maps portable isolation modes to its transaction connection; the ORM restores connection state before commit or rollback. A positivetimeoutMsapplies PostgreSQLstatement_timeout; MySQL and SQLite returnCAPABILITY_UNSUPPORTED. forUpdate(),forShare(),forUpdateNoWait(), andforShareNoWait()are allowed only inside a transaction. MySQL and PostgreSQL execute the selected row lock;NoWaitfails immediately when the row is unavailable. SQLite emits no lock suffix and uses an ORM transaction-scoped database lock row for all four modes. A SQLite write transaction takes the database write lock when it begins, so a lock request inside it succeeds without waiting, and the lock wait happens when the transaction begins (runtime connection).Db::withCancellation($source)derives a PHP cancellation handle. PostgreSQL connections require bothpdo_pgsqlandext-pgsql; the latter sends cancellation through the same connection.timeoutMsalso limits PostgreSQL statements.- A test makes the rollback of a transaction fail on every database with the test fault of its client, which a production build does not contain or load (protocol §3.1): Go
orm.FailNextRollback(db)with-tags ormtest, Rustpolyspec_orm::testing::fail_next_rollback(&db)with the featuretest-faults, TypeScriptfailNextRollback(db)of@polyspec/orm/testingundernode --conditions=orm-test, and PHPPolyspec\Orm\Testing\Faults::failNextRollback($db)afterrequire 'vendor/polyspec/orm/testing/Faults.php'. The next transaction whose callback fails is rolled back and returnsROLLBACKwith the callback error and aFAULTerror. - A test makes the next statement of a connection fail with a given code and message on every database with the statement fault of its client (protocol §3.2): Go
orm.FailNextStatement(db, code, message)with-tags ormtest, Rustpolyspec_orm::testing::fail_next_statement(&db, code, message)with the featuretest-faults, TypeScriptfailNextStatement(db, code, message)of@polyspec/orm/testingundernode --conditions=orm-test, PHPPolyspec\Orm\Testing\Faults::failNextStatement($db, $code, $message), and Pythonpolyspec.orm.testing.fail_next_statement(db, code, message). The statement is not sent; the client reports it asOrmExceptionwith that code and message, and the fault is consumed by that statement.
err := master.Transaction(func() error {
row, err := model.Author().SetName("x").….Create()
if err != nil {
return err
}
authorSeq, err := row.GetSeq()
if err != nil {
return err
}
_, err = model.AuthorLog().SetAuthorSeq(authorSeq).Create()
return err
})$row = $master->transaction(fn () => (new Author)->setName('x')->…->create());let row = master.transaction(async || Author::new().set_name("x")./*…*/.create().await).await?;Audited writes
A table with an audit setting (audit) is written only inside a transaction that records its unit of work in the audit record table, the table that the setting names with references. Two places give the record its values:
- The connection configuration takes
auditSourceonce per process (configuration): a function that returns the audit values of the current request, a map from column name to value of the audit record table, such as the account and the request id. The ORM writes no audit value of its own. - The
auditoption of a transaction holds the values of this change, a map from column name to value (Goorm.Audit(map[string]any), PHPaudit:array, Rust.audit(pairs)ontransaction,transaction_sendandtransaction_once, TypeScript{ audit: {...} }). An empty map records the source's values alone.
master, err := model.Connect(dsn, orm.Config{AuditSource: func(ctx context.Context) (map[string]any, error) {
r := requestOf(ctx) // the request context of the calling code
return map[string]any{"account_seq": r.AccountSeq, "request_id": r.ID}, nil
}})
err = master.WithContext(ctx).Transaction(func() error {
_, err := model.Service().SetName("renamed").Create()
return err
}, orm.Audit(map[string]any{"action": "service.rename", "reason": reason}))$master = \Polyspec\Orm\Tests\Model\connect($dsn, new Config(auditSource: fn (): array => ['account_seq' => $request->accountSeq(), 'request_id' => $request->id()]));
$master->transaction(function (): void {
(new Service)->setName('renamed')->create();
}, audit: ['action' => 'service.rename', 'reason' => $reason]);let master = model::connect(&dsn, pool_size, polyspec_orm::Config {
audit_source: Some(Arc::new(|| Ok(vec![("account_seq".into(), Param::I64(current_account()?)), ("request_id".into(), Param::from(current_request_id()?))]))),
..Default::default()
}).await?;
master.transaction(async || Service::new().set_name("renamed").create().await.map(|_| ()))
.audit([("action", "service.rename"), ("reason", reason.as_str())])
.await?;const master = await connect(dsn, { auditSource: () => ({ account_seq: currentRequest().accountSeq, request_id: currentRequest().id }) });
await master.transaction(async () => {
await new Service().setName('renamed').create();
}, { audit: { action: 'service.rename', reason } });- A transaction with
auditcalls the source once, before it begins, and right before its first write of an audited table, in every attempt of a retried transaction, inserts one row into the audit record table with the source's values and its own, where a value of the transaction wins over the source's value of the same column. Every insert, update, soft delete and restore of an audited table in the transaction writes that row's primary key (the generated identity, or the given value) into the table's audit column, and the database triggers copy each version into the history table. A transaction that writes no audited row inserts no audit row: reads leave none, and a first update, delete or restore that matches no row rolls its audit row back with a savepoint. A failed callback, or a savepoint that rolls back the first audited write, rolls the audit row back with the changes, and the next audited write inserts it again. - The audit record table is the one table that the
referencesof the audited tables of the sets registered on the connection name, and it is a table of a set registered on the connection; no such table, several of them, or a primary key of more than one column fail withCONFIG. The transaction fails withCONFIGbefore it begins when the connection has no audit source, a value of the source or the transaction names a column that the audit record table does not have, or no value is given at all; an error of the source fails the transaction with that error. - A write of an audited table outside a transaction with
auditfails withCONFIG. A nested transaction uses the audit of the outer one and does not takeaudit; a request that assigns the audit column itself fails withIR_INVALID. - With
tableson the setting (audit), the triggers also write one row for each audited table that the transaction changed into the tables table, such asaudit_table (audit_seq, table_name). It is an ordinary table of the audit set, so its generated model reads it, and a join from the audit record filters and pages the audits of a table:(new Audit)->connect($db)->ltSeq($last)->joinSeqWithAuditSeq((new AuditTable)->tableName('service'))->orderBySeqDesc()->limit(0, 20)->gets(), andgetCount()of the same chain counts them; the primary key(audit_seq, table_name)gives one joined row per audit. A table that no audit changed matches no row: an empty list, a count of 0, and null for a single row, as any read.
Restore a soft-deleted row
A delete of a table with a soft_delete setting keeps the row and its unique key values, and reads never return it, so inserting the same key again fails with DUPLICATE_KEY. restore() brings such a row back: set the values of the primary key or of one unique key on a new model, and any new values of other columns, and call restore(). It sends the set values as the request's set; the planner picks the key after the request hook, which may add conditions to the restore as to an update. It writes the new values and clears the soft delete column with one UPDATE (protocol), reads the row by the values of that key with the default select set and returns it as a loaded model.
row, err := model.Membership().Connect(master).SetTeamId(1).SetMemberId(2).SetRole("owner").Restore()$row = (new Membership)->connect($master)->setTeamId(1)->setMemberId(2)->setRole('owner')->restore();let row = Membership::new().connect(&master).set_team_id(1).set_member_id(2).set_role("owner").restore().await?;const row = await new Membership().connect(master).setTeamId(1).setMemberId(2).setRole('owner').restore();- The key is the primary key when the set values and the
eqconditions of a column of the request after the hook, without a function, value function, ref or subquery, give every primary key column a value, otherwise the first unique key, in name order, whose columns they all give a value; a key value is a plain value, not null, raw, plus or minus. A set value of a key column is a condition; every other set value is a new value, assigned as inupdate. A condition the hook adds narrows the restore further. Without the values of a key restore fails withIR_INVALIDbefore any statement, and so does a new value of a primary key or soft delete column, or a table withoutsoft_delete. - A row that is not deleted is returned unchanged and nothing is written, the new values included. A key that no row has fails with
NO_ROWS. - On a table with an
auditsetting the restore is an update like any other: it runs in a transaction with audit values, writes the audit record's key into the audit column, and the triggers record the version in the history table. Without an audit it fails withCONFIG. - Like a soft delete, restore does not assign the
updatedcolumn. The update and the read are two statements; inside a transaction they see one state.
Restore or create a row
restoreOrCreate() gives a key of a table with a soft_delete setting its row whatever state the key is in: set the values of the primary key or of one unique key on a new model, and the other values of the row, as for restore().
row, err := model.Membership().Connect(master).SetTeamId(1).SetMemberId(2).SetRole("owner").RestoreOrCreate()$row = (new Membership)->connect($master)->setTeamId(1)->setMemberId(2)->setRole('owner')->restoreOrCreate();row = (
Membership()
.connect(master)
.set_team_id(1)
.set_member_id(2)
.set_role("owner")
.restore_or_create()
)let row = Membership::new().connect(&master).set_team_id(1).set_member_id(2).set_role("owner").restore_or_create().await?;const row = await new Membership().connect(master).setTeamId(1).setMemberId(2).setRole('owner').restoreOrCreate();- It runs in one transaction, and its key is the one
restore()picks. A soft-deleted row that holds the key is restored with the set values, asrestore()restores it, audit included, and returned. - A key that no row holds is created with the set values, as
create()creates a row, and the created row is returned. Another unique key that an existing row already holds fails withDUPLICATE_KEY, as forcreate(). - A row that holds the key and is not deleted fails with
ROW_NOT_DELETED, and nothing is written, the set values included: the caller decides whether to update it. - Its statements are the restore step, the read of the key and, for a key that no row holds, the insert; the conformance vector
restore_or_createfixes them and their results on MySQL, PostgreSQL and SQLite. A table withoutsoft_deletefails withIR_INVALID, as forrestore().
8. Styled columns
gz_*, json_*, jsons_*, base64_*, and serialize_* use decoded values, not stored bytes, at the client output (Go any · Rust serde_json::Value · PHP mixed). MySQL handles aes_hex_* and ip with SQL functions, while PostgreSQL and SQLite executors produce the same bytes (codec.md).
$b->setJsonSetting(['a' => 1])->connect($master)->update();
$b = (new Author)->connect($slave1)->addColumnJsonSetting()->getBySeq(42); // excluded from the default SELECT
$b->getJsonSetting()['a'];9. Multiple databases
The same statement produces the same result on all three databases, although statement text differs by dialect. Rules and exceptions are in dialects.md.
connection.utils().schema().install(...) renders the document set with the dialect of the connection (schema.md).
- Go:
model.Connect(url, config); the DSN scheme selects the driver. - PHP:
\Polyspec\Orm\Tests\Model\connect(url, config); the DSN scheme selects the PDO driver. - Rust:
model::connect(url, pool_size, config).await?; the DSN scheme selects the sqlx driver. - TypeScript:
connect(url, options)of the generated module; the DSN scheme selects the driver package. - Several connections can be open at once, and
connectselects one per model, for example reads onslave1and writes onmaster. - SQLite rejects
Lband fulltext operators (OPERATOR_NOT_ALLOWED).
10. Operations
| Operation | Command |
|---|---|
| Compare schema with the live database | introspect the database and compare its schema text with the document set (schema.md) |
| Check generated files | git diff --exit-code after the language generator runs |
| Statement events | subscribe on the connection; every statement the client sends (statement events) |
| Execute a compiled statement, such as a load with its keys | execute on the connection (execute a compiled statement) |
| Change every request of a handle before planning | withRequestHook on the connection (request hook) |
| View SQL without executing | getQuery() on a connected model (dsl.md) |
| Error constants | orm-gen errors --lang go|php|rust|typescript|python --out … (errors.yaml) |
| Install a schema | connection.utils().schema().install(...) (schema.md) |
| Add the missing tables, columns and indexes of an installed set | connection.utils().schema().addTablesAndColumns(...) (schema.md) |
Statement events
Every client publishes one event for each statement it sends to the database: the statements of models, transaction control, the statements of schema utilities and every other statement of a utility. Subscribers register on the connection. The shared vector tests/events/vectors.json runs one sequence on MySQL, PostgreSQL and SQLite and states the events every client publishes.
| Client | Subscribe and unsubscribe | Event |
|---|---|---|
| Go | unsubscribe, err := db.Subscribe(func(e orm.StatementEvent) error { …; return nil }); unsubscribe() | orm.StatementEvent{SQL, Binds, Kind, Tables, Elapsed, Transaction, Err}, Elapsed a time.Duration, Transaction 0 outside a transaction |
| PHP | $unsubscribe = $db->subscribe(function (Polyspec\Orm\StatementEvent $e): void { … }); $unsubscribe() | Polyspec\Orm\StatementEvent with sql, binds, kind, tables, elapsed (seconds), transaction (?int), error (?OrmException) |
| Rust | let subscription = db.subscribe(|e: &polyspec_orm::StatementEvent<'_>| Ok(()))?;; subscription.unsubscribe() | polyspec_orm::StatementEvent { sql, binds, kind, tables, elapsed, transaction, error }, elapsed a Duration, transaction an Option<u64>, error an Option<&polyspec_orm::Error> |
| TypeScript | const unsubscribe = db.subscribe(e => { … }); unsubscribe() | StatementEvent with sql, binds, kind, tables, elapsed (seconds), transaction (number | null), error (OrmError | null) |
sqlis the statement as sent; a relation step carries its expandedINlist.bindsare the bound values in order; a secret is$SECRETand an executor clock value$NOW.kindcomes from the origin of the statement:select,insert,updateordeletefor a model statement, the verb that its plan step writes (a soft delete isupdate);begin,commit,rollback,savepoint,releaseandrollback_tofor transaction control;schemafor the statements of schema utilities (the catalog reads and the statements ofinstallandaddTablesAndColumns, and the reads ofexists,installedandempty);utilityfor every other statement: named locks and row locks, transaction-local values, transaction settings, privileges, AES status and rotation, and the SQLite row-lock table.tablesare sorted and without duplicates. A model statement has thetablesof its plan step (protocol); a schema statement that creates or changes a table names that table; a utility statement on a table names it (orm__row_lockfor the SQLite row lock, the table of a privilege, the table of an AES status or rotation); transaction control, catalog reads and the other statements name none.elapsedruns from sending the statement until the client has read its result.transactionis the number of the transaction on the connection: every outermost begin, including a retry after a deadlock and the SQLite transaction that rebuilds tables with foreign keys off, takes the next number from 1, and every statement from its begin to its commit or rollback carries it; a statement outside a transaction has none.erroris the error the statement ended with, with the code the operation reports; none on success. A failure to decode returned rows is not an error of the statement.
The client calls the subscribers synchronously, in registration order, after the statement ends and before the operation continues. Every handle of the connection (Go WithContext, TypeScript withSignal, a Rust clone, Python with_request_hook) shares them. With no subscriber the client only finds the list empty. A subscriber must not fail: when one returns an error (Go, Rust) or throws (PHP, TypeScript, Python), the remaining subscribers do not run and the operation fails with SUBSCRIBER, whose cause is that error; the statement keeps its effect, and inside a transaction the callback receives the error.
Every client sends the same transaction control statements:
| Dialect | Begin | End |
|---|---|---|
| MySQL | SET TRANSACTION ISOLATION LEVEL <level> (utility, with an isolation), then START TRANSACTION or START TRANSACTION READ ONLY | COMMIT or ROLLBACK |
| PostgreSQL | BEGIN, with ISOLATION LEVEL <level> and READ ONLY as the options ask, then SET LOCAL statement_timeout = <ms> (utility, with timeoutMs) | COMMIT or ROLLBACK |
| SQLite | BEGIN IMMEDIATE, or BEGIN for a read-only transaction, then PRAGMA read_uncommitted = 1 and PRAGMA query_only = 1 (utility, as the options ask) | the same PRAGMA with 0, then COMMIT or ROLLBACK |
<level> is READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ or SERIALIZABLE. Before the first transaction of a SQLite connection the client creates the row-lock table with CREATE TABLE IF NOT EXISTS "orm__row_lock" ("id" INTEGER PRIMARY KEY CHECK ("id" = 1)) outside the transaction. A nested transaction sends SAVEPOINT orm_sp_<depth> and RELEASE SAVEPOINT orm_sp_<depth>, or after a failed callback ROLLBACK TO SAVEPOINT orm_sp_<depth> and then the RELEASE. Before the end of a MySQL transaction the client releases its named locks with SELECT RELEASE_LOCK(?) and clears its local values with SET @`orm.<key>` = NULL in key order. When the context or signal of a MySQL or PostgreSQL transaction is cancelled, the client closes the session instead of sending ROLLBACK; a cancelled SQLite transaction still resets its PRAGMA modes and ends with ROLLBACK.
The statements that open a physical connection (session settings such as the time zone, the SQLite version read), the preparation of a statement and protocol-level cancellation are not events.
Execute a compiled statement
A program that compiles requests itself, such as a load that writes rows with their identity keys and then restarts each identity (protocol), runs each statement with execute on the connection: one step of a plan that the client's planner compiled, and the request parameters that its bind slots read.
| Client | Compile | Execute |
|---|---|---|
| Go | engine.New(model, dialect) then Compile(requestJSON), or (&planner.Planner{M: model, D: dialect}).Compile(request) | err := db.Execute(&plan.Steps[0], params) |
| PHP | (new Polyspec\Orm\Engine($model, $dialect, $cacheSize))->compile($request) | $db->execute($plan['steps'][0], $params) |
| Rust | polyspec_orm::engine::compile(&manifest, dialect, &request) | db.execute(&plan.steps[0], ¶ms).await? |
| TypeScript | new Engine(model, dialect).compile(request) | await db.execute(plan.steps[0], params) |
| Python | Engine(model, dialect).compile(request) of polyspec.orm.engine | db.execute(plan['steps'][0], params) |
- The statement runs where a model write runs: on the active transaction of the connection, or on the connection. Its bind slots take their values as a model statement's do: the request parameter with its host styles, a decimal, a SQLite date or time, the AES key and key version from the configuration, the client clock, and the audit of the transaction.
- It publishes its statement event with the kind of its verb,
insert,update,deleteorselect, andschemafor the MySQLALTER TABLEof an identity restart (statement events); a driver error has the code a model statement would report. - A step that reads rows (it has an
assemble) fails withIR_INVALIDexecute runs a statement without rows; the statement is a read, and aparamslot past the parameters fails withIR_INVALIDbind slot <i> reads parameter <p>, but execute has <n> parameters; neither sends a statement. executereturns nothing. A load reads back what it needs with a model or a query.- The PHP extension
polyspec_ormimplements only the dbspec surface and holds no query connection, so it has noexecute; the PHP client runs both.
Request hook
Python SQL connections can derive a cancellation handle with db.with_future(future), where future is an asyncio.Future. Cancelling that future interrupts the statement in flight on the derived handle and reports CANCELED; completing it normally does not affect the connection. Python database calls are synchronous, so run a cancellable call with asyncio.to_thread. A cancelled transaction still runs its rollback before the pooled connection is reused. The derived handle remains cancelled; use the root db for later work. The Python markdown executor does not support with_future.
withRequestHook returns a handle on the same connection whose hook receives each request (protocol) of the handle right before the client plans it. The hook may add predicates, assignments and parameters; the client gives the hook no meaning of its own.
| Client | Handle | Hook |
|---|---|---|
| Go | h := db.WithRequestHook(hook) | func(r *ir.Request, addParam func(value any) int, params []any) error of orm.RequestHook, which changes r in place and reads the bound values in params, a copy; ir is github.com/polyspec/orm/packages/go/engine/ir |
| PHP | $h = $db->withRequestHook($hook) | \Closure(Polyspec\Orm\Request $r): void, which changes $r->ir in place, adds a parameter with $r->param($value) and reads the bound values in $r->params |
| Rust | let h = db.with_request_hook(hook); | Fn(&mut polyspec_orm::ir::Request, &mut polyspec_orm::RequestParams) -> polyspec_orm::Result<()>, which changes the request in place, adds a parameter with params.add(value) and reads a bound value with params.get(index) |
| TypeScript | const h = db.withRequestHook(hook) | (request: Request, addParam: (value: unknown) => number, params: readonly unknown[]) => Request of RequestHook, which returns the request to plan and reads the bound values in params, a frozen copy |
| Python | h = db.with_request_hook(hook) | Callable[[IRRequest, Callable[[object], int], tuple], IRRequest] of RequestHook, which returns the request to plan and reads the bound values in the tuple; IRRequest is of polyspec.orm.ir |
- The handle shares everything of the connection: the pool (the PDO connection in PHP), the plan and statement caches, the statement event subscribers and the active transactions. It keeps the other properties of the handle it was derived from (the Go context, the TypeScript signal), and a handle derived from it with
WithContextorwithSignalkeeps its hook. A handle carries one hook:withRequestHookon a hooked handle returns a handle with the new hook.Root()(Go) androot()(Python, TypeScript) return the connection. - The client calls the hook once for each request of a model terminal that runs through the handle:
oneandallreads,count,group_count,sumandavg,paginate(one call for its page and count statements),insertwith one row or withrows,update,delete, soft delete,restore, an insert withon_duplicate, andgetQuery. It also calls it for the requests the client builds for the model: the child deletes of a recursive delete and, in a transaction with audit values that began on the handle, the insert of the audit record. A relation whose child has its own connection runs through that connection.execute, the schema utilities and the other utilities send no request and do not call it. - The hook receives the request after the model built it, the parameters that it binds, read-only, so a hook can check a value that the caller gave, and the means to add a parameter:
addParam(value)appends value to the request parameters and returns its index. It may change the request in any way the protocol permits, such as a predicate in thewhereof the root, of a join or of a relation, an assignment insetwith one more parameter in each item ofrows, or an item ofon_duplicate. The client then setsn_paramsto the number of parameters and plans the request as any other: the planner validates it, so a parameter index outside the parameters fails withIR_INVALIDand an unknown column withCOLUMN_UNKNOWN, before any statement. The plan cache keys on the request, never on values, so two requests that the hook changes alike share one plan whatever values it adds. - An error of the hook (thrown in PHP, Python and TypeScript, returned in Go and Rust) fails the operation unchanged, before any statement.
- A model runs through the handle it is connected to; inside a transaction, a model without a connection runs through the handle that began the transaction. A row read or created through a hooked handle keeps that handle, so its later
update,deleteandrestorepass the hook. A transaction that begins on a hooked handle keeps the hook for the models of its callback, and a model connected to another handle of the connection inside it runs with the hook of that handle. - A created row holds the values its caller assigned; a value the hook assigned is written but not read back into the row.
- A handle without a hook plans each request as the model built it, without a copy or a call.
- The shared cases
contracts/fixtures/request_hook.dbsrun in every client on SQLite: the hook reads the value of an INSERT assignment without changing the value written; a predicate the hook adds filters a read, a count and a page; an assignment it adds is written by a single and a bulk insert; the update and the delete of a loaded row pass the hook; a parameter index outside the parameters fails withIR_INVALIDbefore any statement; a handle without a hook reads every row; a restore takes its key from its set values and the condition the hook adds, a condition the hook adds keeps a restore from a row outside it, and a restore whose values and conditions cover no key fails withIR_INVALIDbefore any statement.
11. Common errors
| Code | Cause and action |
|---|---|
SCHEMA_HASH_MISMATCH | The generated models carry another manifestHash than the model the client loaded; regenerate and deploy together |
OPERATOR_NOT_ALLOWED | Operator is unavailable for the type or style, such as styled columns or SQLite fulltext |
COLUMN_UNKNOWN / RELATION_UNKNOWN | Name is absent from the schema; correct the document and regenerate |
EMPTY_IN | A condition received an empty list; validate before calling |
CONFIG | Missing connect outside a transaction, a missing or misplaced connector, connect on a join child, or a configuration, path, or driver mismatch |
LIMIT_IN_RELATION | Relation child uses limit; use groupLimit(n) |
OPTIMISTIC_LOCK | update(true) found a newer updated_ts; read again and retry |
LOCK_NOT_AVAILABLE | A *_nowait row-lock request could not acquire the lock immediately; do not retry it as a transaction conflict |
DUPLICATE_KEY / DEADLOCK | Mapped driver error with original text retained; deadlock follows the transaction retries |
WRITE_CONFLICT | An IR executor found a target changed since it was read, or, at the commit, a change since the read that makes the transaction's writes other writes; it names the file and line. It is never retried, even with retry options, unlike DEADLOCK |
CANCELED | The statement stopped before it finished: a cancellation, a timeout bound, or a SQLite lock that another connection still held when busy_timeout ended; it is not retried |
READ_ONLY | A write reached a read-only server or connection: a replica, a read-only transaction, or a SQLite database opened read-only; send the write through the primary connection |
CONSTRAINT | A CHECK constraint refused the row; the error keeps the driver message and the driver error |
DRIVER | Any other driver error, such as a write that an audit or immutable trigger refuses; the error keeps the driver message and the driver error as its cause |
CONNECTION_LOST | The server ended the connection (a killed session, a restart, an idle limit) or it broke; the statement returns this error and is not sent again (lost connections) |
ROLLBACK | The callback of a transaction or savepoint failed and its rollback failed too, for example because the server ended the session; the error keeps both errors and is not retried |
FAULT | A test fault armed through the test entry point of the client reported the rollback of a transaction as failed after the rollback ran; it is the rollback error of a ROLLBACK error (protocol §3.1) |
CODEC_DECODE | Stored bytes do not match the declared column styles |
12. Verification
make orm-test-servers # MySQL, PostgreSQL, their replicas, ProxySQL, PgBouncer, and the seeded bench databases
. .runtime/servers/env # the DSN variables of the tests
go test ./packages/go/... ./tests/... ./examples/... # engine, generator, Go client, Go runners and examples
npm run typescript:test # TypeScript client
(cd packages/rust && cargo test --workspace) # Rust client
node tests/conformance/check/main.mts run --out "$(mktemp -d)/out" --dsn "$BENCH_MYSQL_DSN" # configured implementations, identical results on MySQL
node tests/conformance/check/main.mts run --out "$(mktemp -d)/out" --driver postgres --dsn "$BENCH_POSTGRES_DSN"Examples: examples/thin-slice (Go · PHP · Rust, the same JSON) and examples/complex (joins, groups, two-level relations, and aggregates).
Rust source analysis separates a fallible model setter result from its model. Error mapping and checked extraction preserve the model; success mapping can return another type. Result operations do not create column methods. Unknown calls on a known model still fail generation.