DSL
This page specifies the query syntax defined by the design plan. Client status is recorded in the implementation matrix; a client that does not yet match this page is incomplete.
1. Model and connection
DSL-1 A query starts with a model. A model is created in the language form and receives a database connection through
connect.DSL-2 PHP — Creation:
new Product; Connected model:(new Product)->connect($slave1); Connected loaded row:$row->connect($master)DSL-3 Go — Creation:
model.Product(); Connected model:model.Product().Connect(slave1); Connected loaded row:row.Connect(master)DSL-4 Python — Creation:
Product(); Connected model:Product().connect(slave1); Connected loaded row:row.connect(master)DSL-5 Rust — Creation:
Product::new(); Connected model:Product::new().connect(&slave1); Connected loaded row:row.connect(&master)DSL-6 TypeScript — Creation:
new Product(); Connected model:new Product().connect(slave1); Connected loaded row:row.connect(master)DSL-7
connectis the only connection method. A loaded row or collection uses the same method before a write, so rows read from a replica are written through the primary connection.DSL-8 PHP accepts
(new Product)($slave1)and$row($master)as the short form ofconnect.DSL-9 The
github.com/polyspec/orm/packages/goclient uses functions in the generatedmodelpackage.model.Product()returns*model.ProductModel.DSL-10 Inside a transaction callback, a model without
connectuses the active transaction of the current execution flow. Outside a transaction, a terminal or write on a model withoutconnectreturnsCONFIG.
2. Conditions
2.1 Connectors and groups
- DSL-11 The first condition has no prefix; a prefix written on it is dropped. Each following condition starts with
andoror, either inside the method name or as a separate connector.
(new Product)->connect($slave1)
->serviceSeq($serviceSeq)
->andIsClose(0)
->or()->isSale(2)
->and(fn (Product $q) => $q->isSale(1)->andLtSaleStartDt($now))
->gets();DSL-12
<Chain>(…)— Meaning: first condition of the model or groupDSL-13
and<Chain>(…),or<Chain>(…)— Meaning: condition joined withANDorORDSL-14
and(),or()followed by<Chain>(…)— Meaning: the same connection written as a separate callDSL-15
and(fn),or(fn)— Meaning: parenthesized group joined withANDorORDSL-16
not(fn),andNot(fn),orNot(fn)— Meaning: negated groupNOT (…)as the first condition or joined withANDorORDSL-17
and(model),or(model)— Meaning: parenthesized group of the conditions set on a joined modelDSL-18 A connector at the start of a model or a group has nothing to join, so it is dropped:
and(fn),or(fn),and(),or(),and<Chain>(…), andor<Chain>(…)all read as the first condition, and the statement starts with that condition or group.DSL-19 A missing connector between two conditions and a connector without a following condition return
CONFIG.DSL-20 A group callback receives an empty model of the same type. The callback accepts condition methods only; a terminal, write, or
connectinside the callback returnsCONFIG.DSL-21 A group is the only way to write parentheses.
DSL-22
not(fn)negates its group as a whole; a negated group always has parentheses. Rust names the two joined formsand_notandor_not. A callback that adds no condition returnsCONFIG.
2.1.1 Query expression grammar
DSL-23 A condition is one expression of a neutral grammar: it renders and means the same on MySQL, PostgreSQL and SQLite. Its element set is the predicate set of a dbspec
check(dbspec): a comparison (=,<>,<,<=,>,>=),in,is [not] null,and,orand grouping, whose operands are columns and values. A value is a typed bind: it takes the dbspec type of the column it meets, or the result type of the column function it is compared with (dialects), never a literal written into the SQL. A query adds three extensions that acheckdoes not have, because introspection cannot restore them from a database and a query is never introspected:notthroughnot(fn),andNot(fn)andorNot(fn);betweenthrough theBetweenoperator, which every database renders asBETWEEN;- the column functions
year,month,dayOfWeekanddateand the value functionsnow,today,<unit>Agoand<unit>Later(section 10), each with one rendering per database.
DSL-24 The existing
Lk,Lb,Fulltext,FulltextBooleanand tuple operators and column comparisons keep their rules. Arithmetic is not part of the grammar: integer overflow and division differ between the three databases. The DSL has no SQL text form: a condition, column, order, grouping or assignment is written only with the methods of this document, so every value reaches the database as a typed bind.
2.2 Chains
- DSL-25 A chain lists one or more keys separated by
AndorOr. Each key consumes one argument in order.
Chain = Key { ("And" | "Or") Key }
Key = [Operator] Column
| Column Operator Column
| ("Fulltext" | "FulltextBoolean") Column { "With" Column }
| ["Ne"] "Tuple" Column "With" Column { "With" Column }DSL-26 none,
Eq— SQL:=DSL-27
Ne— SQL:!=DSL-28
Gt,Lt,Ge,Le— SQL:>,<,>=,<=DSL-29
Lk— SQL:LIKEwith the value between wildcardsDSL-30
Lb— SQL: binaryLIKEwith the value between wildcardsDSL-31
Between— SQL:BETWEENwith a fixed two-value arrayDSL-32
Fulltext— SQL: full-text match in natural-language mode; IR:fulltext(PRT-75)DSL-33
FulltextBoolean— SQL: full-text match in boolean mode; IR:fulltext_boolean(PRT-75)DSL-34
<ColA><Op><ColB>(model)compares columnColAof the calling model with columnColBofmodelin the same statement.(new Product)->priceGtMinPrice($brand)rendersa.price > b.min_price.OpisEq,Ne,Gt,Lt,Ge, orLe. Insideon(fn)the calling model is the join child.DSL-35
tuple<ColA>With<ColB>(list)compares several columns with a list of value groups, such astupleTenantIdWithAccountId([[1, 10], [2, 10]])for(tenant_id, account_id) IN ((1, 10), (2, 10)).NeproducesNOT IN. Go uses the generated value group structmodel.<Model><ColA>With<ColB>with one field per column, Rust uses tuples, TypeScript uses typed tuples, and Python uses tuples. SQLite renders the list asIN (VALUES …).DSL-36
getsByServiceSeqAndIsClose(7, 0)andserviceSeq(7)->andIsClose(0)->gets()produce the same condition.DSL-37 A chain can combine any columns. PHP and Python resolve chains at call time, while Go, Rust, and TypeScript generate the chain methods that the scanned source code calls and reject an unknown column, operator, or argument count during generation, and PHP declares them as
@methodtags when--scanis given and rejects the same errors during generation; the scan of Python reports a model method that no column, chain, or operator matches during generation.DSL-38 Each language generates with its own build tool:
- DSL-39 The
github.com/polyspec/orm/packages/gogenerator, in Go, scans the packages named by--scanand repeats until the calls type-check:go run github.com/polyspec/orm/packages/go/cmd/orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --lang go --out model --scan ./...in a//go:generateline. Files that the default build excludes with a//go:buildconstraint are loaded with the tags, GOOS, and GOARCH their constraint needs, so a tagged test is covered withoutGOFLAGS=-tags. The default build loads every scanned package once; such a configuration loads only the packages that hold its files and takes the type information of every other package from the default load. - DSL-40 The
@polyspec/ormgenerator, in TypeScript, scans the files named by--scanwith oxc-parser and writes exact method signatures:orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --out src/models --scan srcin thebuildscript beforetsc. - DSL-41 The
polyspec-orm-buildcrate, in Rust, scans the sources named byscanwithsyninbuild.rs:polyspec_orm_build::Builder::set("contracts/fixtures/bench/dbspec-set.json").scan("src").generate(), andpolyspec_orm::models!()includes the result as the modulemodel. - DSL-42 The
polyspec/ormgenerator, in PHP, writes the model classes with column metadata and typed getters and setters:vendor/bin/orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --out src/Model --namespace Example\Model --scan src.--scantakes a file or a directory and may repeat; a directory is read without itsvendorandnode_modulesdirectories, and a symlink stops generation with status 1, since the scan never follows one. It reads the PHP files withnikic/php-parserand declares, in the class comment of each model, one typed@methodtag for every chain method the sources call on that model (for exampleltSeq,andIsClose,getsBySeqAndName), so that PHPStan finds the call. The call still runs through__call. A receiver counts as a model when it isnew Cof a generated classC(also through ausealias), a variable, parameter, or return type declared with that class, a chain method of a model, or the untyped first parameter of a closure given toand,or,not,andNot,orNot,on,fetchKey, orfetchValue. A call on any other receiver adds no tag. A called name that no column, chain, or operator accepts, or a call whose argument count the chain does not take, stops generation with status 1 and names the model and the method. - DSL-43 The
polyspec-ormgenerator, in Python, writes the model classes with typed getters and setters and reports the model methods of the scanned sources that no rule matches:orm-gen gen --set contracts/fixtures/bench/dbspec-set.json --out model --scan src, the console script the package installs. - DSL-44 The getters of the scan are one rule in the five generators. A scanned call
get<Name>()(get_<name>()in Python and Rust) on a receiver that resolves to a model is a getter, unless it is a column getter, a fixed method, or agetBy,getsByorgetCountBychain. It is attached by analias<Name>()call of a relation child, anew<Name>()call, or anaddColumncall that names<Name>, or it is the relation resultget<Model>Modelorget<Model>Models. The alias of a relation nested in a relation attaches the getter to the model the nested relation is called on:new Product().relation(new Store().matchSeqWithStoreSeq().aliasStore().relation(new Match().matchStoreSeqWithSeq().aliasMatch()))givesgetStoreto Product andgetMatchto Store. A getter returns no declared model, so a receiver read through one names its model with the getter's type argument in TypeScript (row.getStore<Store | null>()), the type of its binding in Go, Rust (Option<Store>) and Python (store: Store | None = row.get_store()), or a/** @var Store|null $store */tag on its assignment in PHP; a parameter typedIterable<Store>(TypeScript),Iterable[Store](Python) or as a model collection yields models to its loop. A scanned getter call that nothing attaches stops generation and names the model and the method, so a missing getter is never dropped without a message. The scan refuses a model method call only when no class the receiver may hold declares it. The PHP generator also checks the argument count against those classes; every other client leaves undeclared chain methods and counts to its compiler, or, in Python, to the call itself. A used table of an external document is an entity of the set (T209), so a relation to it attaches its getter as a relation to any other entity does. - DSL-151 A scanned call belongs to the set whose generated models the class is imported from, resolved from the import binding as the language's checker resolves it; a class of the same name imported from another set's generated models is another class, and its calls are neither declared nor refused by this set's generation. Go takes the package of the receiver's type. TypeScript resolves a relative or absolute specifier against the importing file: the set's
models.tsis the set's, and a module that orm-gen generated from another manifest (itsMANIFEST_HASH) is another set's; a bare specifier (a package or a path alias), which names no file of the scan, keeps the class name. PHP takes the fully qualified name thatuseand the namespace give the class. Python resolves a relative import from the importing file to the set'smodels.py, and an absolute import names it when its dotted name is the tail of that file's path. Rust takes the module the path or theusereaches the class through: a module thatinclude!s the set'sorm_model.rs(orpolyspec_orm::models!()forOUT_DIR) is the set's, and a module that includes anotherorm_model.rsis another set's. A receiver held in a variable resolves through the variable's initializer and every reassignment of it to its class and that class's import binding, as the checker types the variable, not by the variable's name: a variable initialized from a chain on an imported class and reassigned from its own chain, in a conditional expression (c ? a : b,a if c else b,if c { a } else { b }) or in a branch, holds that class, so the calls on it after each reassignment are that class's calls. A class that no import names keeps its name, as before. - DSL-152 A source file that orm-gen cannot parse while it scans is an error in every generator: an unparsed file would hide its calls, so the generation stops and writes nothing, and the error names the file with the parser's position (its line, and its column where the parser gives one) and message. No generator skips the file or reports it only as a warning.
- DSL-153 A
$that is not followed by a digit is text, not a placeholder, so the parent-slot expansion writes it as it is: only$nis renumbered (DIA-2, DIA-19), and a$in a literal or an identifier never gets a number. A statement that holds such a$expands to the same text with the$unchanged. - DSL-154 Declared query methods. A generated model class declares the chain methods of its schema under their names: the condition method of each column, the tuple method of each key prefix of two or more columns, the order methods (
orderBy<Col>Asc), theaddColumn<Name>methods, theplus<Col>methods and theforceIndex<Name>method of each index. Each declared method is typed to return the model itself (typing.Selfin Python), so a type checker reads a chain as the model.
- DSL-39 The
2.3 Value shapes
- DSL-45 one value — Without operator or
Eq:= ?;Ne:!= ? - DSL-46 list — Without operator or
Eq:IN (…);Ne:NOT IN (…) - DSL-47 empty list — Without operator or
Eq:EMPTY_INbefore SQL execution;Ne:EMPTY_INbefore SQL execution - DSL-48 null — Without operator or
Eq:IS NULL;Ne:IS NOT NULL
->andPhotoUrl('a.png')->andPhotoUrl(['a', 'b'])->andPhotoUrl(null)->andNeSeq([1, 2]).AndPhotoUrl("a.png").AndPhotoUrl([]string{"a", "b"}).AndPhotoUrl(orm.Null).AndNeSeq([]int{1, 2}).and_photo_url("a.png").and_photo_url(vec!["a", "b"]).and_photo_url(Null).and_ne_seq(vec![1, 2]).andPhotoUrl('a.png').andPhotoUrl(['a', 'b']).andPhotoUrl(null).andNeSeq([1, 2]).and_photo_url('a.png').and_photo_url(['a', 'b']).and_photo_url(None).and_ne_seq([1, 2])- DSL-49 Null is accepted only for nullable columns. Go, Rust, and TypeScript reject null for a non-null column at compile time; PHP and Python return
CONFIG. - DSL-50 Lists are accepted only without an operator, with
Eq, and withNe. Other operators reject lists and null. - DSL-51 PHP, TypeScript and Python use their own null value, Go uses
orm.Null, and Rust usesNull. - DSL-52 Go methods use generic type parameters with the allowed value types of each column. Integer columns accept
int,int32,int64, and slices of those types. - DSL-53 An unexecuted model as the value renders
IN (SELECT …), orNOT INwithNe. The model must add exactly one column withaddColumn<Col>(); otherwiseCONFIGis returned. - DSL-54 An ORM function value applies a function from section 10. A column function wraps the column, and the compared value is the second method argument, for example
andEqCreatedTs(Orm::year(), 2026). A value function is the compared value, for exampleandGtCreatedTs(Orm::daysAgo(7)). - DSL-55
Betweentakes a fixed two-value array: PHP[1, 10], Go[2]int{1, 10}, Rust[1, 10], TypeScript[1, 10]typed as[number, number], and Python[1, 10]. Go, Rust, and TypeScript reject another length at compile time; PHP and Python returnCONFIG.
3. Reads
DSL-56
get()— Result: one row; null when no row matchesDSL-57
gets()— Result: collection of rowsDSL-58
getBy<Chain>(…)— Result:get()with the chain as conditionDSL-59
getsBy<Chain>(…)— Result:gets()with the chain as conditionDSL-60
getCount(),getCountBy<Chain>(…)— Result: row count; 0 when no row matchesDSL-61
getsCount()— Result: grouped rows withrow_countDSL-62
sum<Col>()withgetSum()— Result: sum of the column; 0 when there is no valueDSL-63
avg<Col>()withgetAvg()— Result: average of the column; null when there is no valueDSL-64
getsPage(page, perPage)— Result: one page withitems,totalCount,totalPages,page, andperPageDSL-65
getQuery()— Result: statement text and binds ofgets()without executionDSL-66
getSum()andgetAvg()convert a numeric database scalar to the nearest finite IEEE 754 binary64 value, with ties rounded to even. SQLSUMandAVGskip null values and give a null scalar when no value is left, whether no row matches or every value is null: thengetSum()returns 0, the sum of no values, andgetAvg()returns null, since the average of no values is undefined and 0 would be an invented average. A nonnumeric, nonfinite or overflowing scalar returnsCODEC_DECODE. Row column decoding retains its lossless conversion rules.DSL-67
getsPagecounts rows with the same conditions and joins as the page query.pageandperPagemust be positive, and a model withlimitreturnsCONFIG. A page after the last page returns an empty collection. orm does no rendering, request reading or redirects. A grouped model is not valid forgetsPage; see DSL-99.DSL-68
getQueryrequires a connected model or an active transaction because the dialect decides the statement text; otherwise it returnsCONFIG. Secret values are shown as$SECRET.DSL-69 Terminals take no arguments except chain values. A terminal does not change the model, so running a second terminal produces the same statement.
DSL-70
keyName<Col>()sets the collection key column.fetchKey(fn)computes the collection key.fetchValue(fn)replaces each loaded row value with the callback result.DSL-71 A row exposes
get<Col>()getters. A relation result is read through the generated relation getter.
4. Columns
- DSL-72
addColumn<Col>()— Effect: add one column - DSL-73
addColumn<Col>Alias<Name>(fn)— Effect: add a column function of the column (section 10) with an output name - DSL-74
addColumn<Name>(fn)— Effect: add a scalar subquery column; the callback receives the calling model and returns an unexecuted model; a scalar column subquery haslimit(0, 1)and an order that includes every primary key column, else the request isIR_INVALID - DSL-75
removeColumn<Col>()— Effect: remove one column - DSL-76
removeAllColumns()— Effect: keep only primary and foreign keys - DSL-77
addAllColumns()— Effect: select every column - DSL-78
forceIndex<Name>()— Effect: add an index hint for the model table
(new User)->connect($slave1)
->addColumnCash(fn (User $u) => (new Point)->sumAmount()->toUserSeqEqSeq($u)->andStatus(1))
->gets();
// (SELECT COALESCE(SUM(b.amount), 0) FROM point b WHERE b.to_user_seq = a.seq AND b.status = ?) AS cash5. Relations and joins
5.1 Relations
- DSL-79 A relation runs a separate query for the parent keys and attaches the result to each parent row.
$orders = (new Order)->connect($slave1)
->relation((new Status)->matchStatusSeqWithSeq()->aliasOrderStatus())
->relations((new OrderItem)->matchSeqWithOrderSeq()->groupLimit(5))
->orderBySeqDesc()
->gets();DSL-80
relation(child)— Effect: attach one related rowDSL-81
relations(child)— Effect: attach a collection of related rowsDSL-82
match<L>With<R>()— Effect: parent columnLequals child columnR; a composite key calls one per component, in key order, e.g.matchTenantIdWithTenantId().matchAccountIdWithAccountId()DSL-83
alias<Name>()— Effect: result name of the relationDSL-84
parentNode()— Effect: merge child columns into the parent rowDSL-85
possible<Col>(value)— Effect: load the child only when the parent column equals the valueDSL-86
groupLimit(count)— Effect: limit child rows per parent keyDSL-87
deleteLock()— Effect: exclude the relation from recursive deleteDSL-88 A child without
connectuses the parent connection. A child withconnectruns its query on that connection.DSL-89 A relation result is named
<table>_modelforrelationand<table>_modelsforrelations, and is read withget<Table>Model()orget<Table>Models().alias<Name>()replaces the result name, and the result is read withget<Name>().
$env = (new ServiceEnvironment)->connect($slave1)
->relations((new ServiceUrl)->matchSeqWithServiceEnvironmentSeq()->aliasUrls())
->relation((new User)->matchWriterSeqWithSeq()->aliasWriter())
->relation((new User)->matchEditorSeqWithSeq()->aliasEditor())
->getBySeq($seq);
$env->getUrls(); $env->getWriter(); $env->getEditor();5.2 Joins
DSL-90 A join adds the child table to the same statement.
DSL-91
join<L>With<R>(child)— SQL:INNER JOINon parent columnLand child columnRDSL-92
leftJoin<L>With<R>(child)— SQL:LEFT JOINon parent columnLand child columnRDSL-93
on(fn)on the child — SQL:ONconditions written with the condition grammar
$brandLang = (new ProductBrandLang)
->on(fn (ProductBrandLang $b) => $b->langId($langId))
->lkName($keyword);
(new Product)->connect($slave1)
->leftJoinProductBrandSeqWithProductBrandSeq($brandLang)
->serviceSeq($serviceSeq)
->and(fn (Product $q) => $q->lkName($keyword)->or($brandLang))
->gets();brandLang := model.ProductBrandLang().
On(func(b *model.ProductBrandLangModel) { b.LangId(langId) }).
LkName(keyword)
rows, err := model.Product().Connect(slave1).
LeftJoinProductBrandSeqWithProductBrandSeq(brandLang).
ServiceSeq(serviceSeq).
And(func(q *model.ProductModel) { q.LkName(keyword).Or(brandLang) }).
Gets()- DSL-94 The child is configured before it is passed to the join method.
on(fn)sets theONconditions; the callback receives the child and uses the condition grammar. - DSL-95 Conditions set directly on the child are
WHEREconditions.and(model)oror(model)places them as a group at that position. When the child is not passed to a group, its conditions are appended withAND. - DSL-96 Passing the same child to two groups, or passing a model that is not joined in the statement, returns
CONFIG. - DSL-97 Child columns are returned on the child result of each row.
- DSL-98 A join child with
connectreturnsCONFIG.
6. Order and range
- DSL-99
orderBy<Col>Asc(),orderBy<Col>Desc(), and chains such asorderBySeqDescAndNameAsc()set the order.orderByRandom()orders rows randomly with the dialect function.groupBy<Col>()sets grouping ongetsCount()and on group counts; on any other read it isIR_INVALID.limit(offset, count)sets the range.
7. Writes
DSL-100
set<Col>(value)— Effect: stored column value; the column must existDSL-101
new<Name>(value)— Effect: attach a value under a name that is not a column; see the rules belowDSL-102
plus<Col>(n),minus<Col>(n)— Effect: bound increment and decrement;minusnever stores a negative valueDSL-103
create()— Effect: insert and return the row with its generated keyDSL-104
creates(models, batch)— Effect: insert models created withset<Col>in batches ofbatchrows, each batch one multi-row INSERT, all in one transaction (a savepoint inside an active transaction), and return the inserted row count; every model must set the same columns.batchis required and a positive integer, otherwiseCONFIG; a batch whose binds exceed the dialect bind limit isBATCH_TOO_LARGE; both fail before any statementDSL-105
duplication(model)withcreate()— Effect: insert with duplicate-key update from the changes ofmodelDSL-106
update()— Effect: update changed columns of the rowDSL-107
update(true)— Effect: update only when the storedupdated_tsequals the value that was read; otherwiseOPTIMISTIC_LOCKDSL-155
update(true)on a row without a fresh update time — Result:CONFIG. A row has no fresh update time when it was not read with its update time column, or when an earlierupdateof the same model cleared that time; the row must be read again beforeupdate(true).DSL-108
expectRevision(column, revision)withupdate()ordelete()— Effect: write only while the row'scolumnstill holdsrevision;update()also storesrevision + 1; otherwiseREVISION_CHANGEDand nothing changes; see the rules belowDSL-109
save()— Effect:update()when the primary key is set, otherwisecreate()DSL-110
delete()— Effect: delete the row or every row of a collectionDSL-111
delete(true)— Effect: delete loaded relation rows recursively, except relations marked withdeleteLock()DSL-112 Attribute changes are cleared after a successful write.
DSL-113
create()andcreates()reject a value for the identity column, andupdate()and the changes of aduplicationmodel reject a primary key or identity column, withIR_INVALIDbefore any statement runs. The database generates every identity key, because PostgreSQL identity does not advance past an explicit key and its next generated key would collide. Only a load writes identity keys: aninsertrequest withloadand then arestart_identityrequest, run withexecuteon the connection (protocol).DSL-114
expectRevision(column, revision)makes the nextupdate()ordelete()of the model a write with an expected revision. The chain names the revision column, rather than a dbspec setting: a revision column is an ordinary integer column that the chain already names (asplus<Col>does), the expected revision is a value of one write that only the chain can carry, and a setting would change the manifest, and so the manifest hash, of every document set that declares it. The column is ani16,i32ori64column of the entity that is not a primary key column; another column, a value of the column set in the same write, orupdate(true)with the expectation fails withCONFIGbefore any statement runs. The write builds on the request the model already sends, with no second mechanism: the key predicate ofupdate()anddelete()gainscolumn = revision, andupdate()assigns the bound incrementcolumn = column + 1(asplus<Col>(1)) with its changed columns, also when no other column changed. The one conditional statement locks the row it matches in the connection's transaction, when one is open, and compares and writes it with no window between the two; SQLite, which has noFOR UPDATE, holds its write lock for the statement. A statement that matches no row, because the row holds another revision or no longer exists, fails withREVISION_CHANGEDand writes nothing; adelete(true)runs in its transaction, so the root'sREVISION_CHANGEDrolls back the related rows it deleted. A soft delete compares the same way. The model is not read again: after a successfulupdate()the row's revision isrevision + 1. The expectation is cleared with the other changes after a successfulupdate()and applies to that model only, not to a collection's rows. Theoptimisticrequest slot ofupdate(true)compares theupdated_tsthat the executor writes; the revision is a value that the caller holds, such as the revision a form was rendered with, and it guardsdelete()too.DSL-115
new<Name>attaches data that the row carries to its output, such as a computed amount or a flag for a view. The value is not used inINSERT,UPDATE, orSELECTstatements.get<Name>(),toArray(), and JSON output include it after reads and writes; a model returned bycreate()keeps the attached values.DSL-116 A real column name in
new<Name>is rejected; stored column values useset<Col>.DSL-117 The
github.com/polyspec/orm/packages/gogenerator generatesNew<Name>andGet<Name>for the names that the scanned source code calls; their value type isany.DSL-156 The
polyspec-orm-buildcrate generatesnew_<name>andget_<name>for the names that the scanned source code calls; their value type is the common value type.
$item = (new CartItem)->connect($master)
->setProductSeq(5)->setQuantity(2)
->newAmount(24000) // not part of the INSERT statement
->create();
$item->getAmount(); // 240008. Transactions
$order = $master->transaction(function () use ($data) {
$order = (new Order)->setServiceSeq($data['service_seq'])->create();
(new OrderItem)->setOrderSeq($order->getSeq())->setAmount($data['amount'])->create();
return $order;
});err := master.Transaction(func() error {
order, err := model.Order().SetServiceSeq(serviceSeq).Create()
if err != nil {
return err
}
orderSeq, err := order.GetSeq()
if err != nil {
return err
}
_, err = model.OrderItem().SetOrderSeq(orderSeq).SetAmount(amount).Create()
return err
})master.transaction(async || {
let order = Order::new().set_service_seq(service_seq).create().await?;
OrderItem::new().set_order_seq(order.seq).set_amount(amount).create().await?;
Ok(())
}).await?;await master.transaction(async () => {
const order = await new Order().setServiceSeq(serviceSeq).create()
await new OrderItem().setOrderSeq(order.getSeq()).setAmount(amount).create()
})DSL-118 A callback error or exception rolls back the transaction. Deadlocks are retried.
DSL-119 A transaction on the same connection inside an active transaction creates a savepoint.
DSL-121 Concurrent use of one transaction connection returns an error.
DSL-122 The execution flow is the goroutine in Go, the request in PHP, the tokio task in Rust, the
AsyncLocalStoragecontext in TypeScript, and the thread of the request in Python: a transaction is active on the connection that opened it, and every execution flow holds its own connection. A goroutine or spawned Rust task started inside the callback has no active transaction; a Go subtest is such a goroutine, so a transaction opened in one is a separate transaction that waits for the connection held outside it. In TypeScript, asynchronous work started inside the callback shares the callback's context and therefore the transaction; a statement that overlaps another statement on it returnsCONFIG.DSL-123
forUpdate(),forShare(),forUpdateNoWait(), andforShareNoWait()are allowed only inside a transaction.DSL-124 Begin, commit, and rollback are not public.
DSL-125 The options are
isolation,readOnly,timeoutMs, andretry(default3,0disables retry). An omitted option keeps its default.
$master->transaction(fn () => …, isolation: 'serializable', readOnly: true, timeoutMs: 500, retry: 0);err := master.Transaction(func() error { … },
orm.Isolation(orm.Serializable), orm.ReadOnly(), orm.TimeoutMs(500), orm.Retry(0))master.transaction(async || { … })
.isolation(Isolation::Serializable).read_only().timeout_ms(500).retry(0).await?;await master.transaction(async () => { … }, { isolation: 'serializable', readOnly: true, timeoutMs: 500, retry: 0 })- DSL-126 A transaction of the same connection inside an active one accepts only
retry, which it ignores; the savepoint joins the outer transaction.
9. Reserved names
DSL-127 The generator rejects a column whose method name matches a reserved method (
and,or,get,gets,getsPage,getQuery,limit,alias,connect,create,creates,update,delete,restore,save,on) or starts with a reserved prefix (and,or,get,set,new,plus,minus,orderBy,groupBy,tuple, or an operator). A column namedrandomis rejected becauseorderByRandom()is reserved.DSL-128 The names attached to a row form one name space: real columns, columns added with
addColumn<Name>(fn)oraddColumn<Col>Alias<Name>(fn), relation result names, andnew<Name>names. A duplicate name in this space is rejected. Go and Rust reject it during generation; PHP and TypeScript returnCONFIG. Two relations to the same table therefore need different aliases, and a subquery column needs a name that is not a real column.DSL-129 A column whose method name equals another generated method of the model, such as
sum_amountnext toamount(sumAmount()), is rejected.DSL-130 A column name also must not contain any of these underscore-separated segments:
and,or,with,gt,lt,ge,le,eq,ne,lk,lb,between,fulltext,tuple. For example,price_gt_limitandwith_taxare rejected, andmin_priceis accepted.
10. ORM functions
DSL-131 An ORM function value carries a function kind and its arguments. The model method that receives it records the model alias, column, and operator, and the client planner renders SQL for the connection dialect.
DSL-132 column function in a condition — Form:
and<Op><Col>(Orm::year(), 2026)DSL-133 value function in a condition — Form:
and<Op><Col>(Orm::daysAgo(7))DSL-134 column function as a column — Form:
addColumn<Col>Alias<Name>(Orm::year())DSL-135 column function in ordering — Form:
orderBy<Col>Asc(Orm::year())DSL-136 Go uses
orm.Year(), Rust usespolyspec_orm::year(), TypeScript usesorm.year(), and Python usesorm.year().
10.1 Value functions
DSL-137
now()— MySQL:NOW(6); PostgreSQL:now(); SQLite: value computed by the client in UTC and boundDSL-138
today()— MySQL:CURDATE(); PostgreSQL:CURRENT_DATE; SQLite: value computed by the client and boundDSL-139
secondsAgo(n),minutesAgo(n),hoursAgo(n),daysAgo(n),monthsAgo(n)— MySQL:DATE_SUB(NOW(6), INTERVAL ? unit); PostgreSQL:now() - make_interval(unit => ?); SQLite:datetime(clock, '-n units')over the bound client clock, followed by the six fraction digits of the clockDSL-140
secondsLater(n),minutesLater(n),hoursLater(n),daysLater(n),monthsLater(n)— MySQL:DATE_ADD(NOW(6), INTERVAL ? unit); PostgreSQL:now() + make_interval(unit => ?); SQLite:datetime(clock, '+n units')over the bound client clock, followed by the six fraction digits of the clockDSL-141 Every connection reads and writes datetime values in UTC (config); MySQL and PostgreSQL connections set the session time zone to UTC.
DSL-142 Month arithmetic keeps the last valid day of the target month, as MySQL and PostgreSQL do.
DSL-143 A relative time function outside 0001-01-01 to 9999-12-31 23:59:59.999999, or with an interval past that span, is NULL, and each dialect guards the forms above for it (dialects).
10.2 Column functions
DSL-144
dayOfWeek()(1 = Sunday … 7) — Columns: date and time; MySQL:DAYOFWEEK(c); PostgreSQL:(EXTRACT(DOW FROM c)::int + 1); SQLite:(CAST(strftime('%w', c) AS INTEGER) + 1)DSL-145
year()— Columns: date and time; MySQL:YEAR(c); PostgreSQL:EXTRACT(YEAR FROM c)::int; SQLite:CAST(strftime('%Y', c) AS INTEGER)DSL-146
month()— Columns: date and time; MySQL:MONTH(c); PostgreSQL:EXTRACT(MONTH FROM c)::int; SQLite:CAST(strftime('%m', c) AS INTEGER)DSL-147
date()— Columns: date and time; MySQL:DATE(c); PostgreSQL:CAST(c AS date); SQLite:date(c)DSL-148 A column function on a column type that it does not list returns
OPERATOR_NOT_ALLOWED.DSL-150 The minimum SQLite version is 3.46. A client reads the version of the SQLite library its driver links (Go
RegisterSQLiteVersionfrommodernc.org/sqlite, PHPPDO::ATTR_SERVER_VERSION, TypeScriptprocess.versions.sqlite, Rustlibsqlite3_sys::SQLITE_VERSION, Pythonsqlite3.sqlite_version) and connects without a statement that asks for it or checks the connection.