From cd620dff66e798a64476bae339cdff387ba88c5a Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Fri, 17 Apr 2026 18:28:52 +0300 Subject: [PATCH 1/2] Add onconflict upsert DSL with conditional update support --- README.md | 23 +++++++- ormin.nimble | 2 +- ormin/queries.nim | 134 ++++++++++++++++++++++++++++++++++++++-------- tests/tcommon.nim | 55 +++++++++++++++++++ 4 files changed, 190 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 5dd3b1f..fb79293 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ let db {.global.} = open("localhost", "user", "password", "dbname") ## Query DSL -`query:` blocks are turned into prepared statements at compile time. Placeholders use `?` for Nim values and `%` for JSON values; Ormin chooses JSON instead of an ad-hoc variant type so your data can flow straight from/into `JsonNode` trees. `!!` splices vendor-specific SQL fragments. Typical clauses such as `with`, `where`, joins, `orderby`, `groupby`, `limit`, `offset`, `exists`, `distinct`, window expressions, `union`/`intersect`/`except` and `returning` are supported. Referring to columns from related tables can trigger **automatic join generation** based on foreign keys, reducing boilerplate joins. +`query:` blocks are turned into prepared statements at compile time. Placeholders use `?` for Nim values and `%` for JSON values; Ormin chooses JSON instead of an ad-hoc variant type so your data can flow straight from/into `JsonNode` trees. `!!` splices vendor-specific SQL fragments. Typical clauses such as `with`, `where`, joins, `orderby`, `groupby`, `limit`, `offset`, `exists`, `distinct`, window expressions, `union`/`intersect`/`except`, `returning`, and insert upserts via `onconflict` + (`donothing` or `doupdate`) are supported. Referring to columns from related tables can trigger **automatic join generation** based on foreign keys, reducing boilerplate joins. Example snippets: @@ -88,6 +88,27 @@ let payload = %*{"dt2": %*"2023-10-01T00:00:00Z"} query: insert tb_timestamp(dt1 = ?dt1, dt2 = %payload["dt2"]) +# Upsert on conflict (SQLite/PostgreSQL) +query: + insert tb_nullable(id = ?id, note = ?note) + onconflict(id) + doupdate(note = ?note) + +# Conditional upsert update +query: + insert tb_nullable(id = ?id, note = ?note) + onconflict(id) + doupdate(note = ?note) + where note != ?note + +# Ignore duplicates +query: + insert tb_nullable(id = ?id, note = ?note) + onconflict(id) + donothing() + +# Note: plain INSERT ... VALUES does not support `where`; use `onconflict(...)+doupdate(...)+where ...` + # Explicit join with filter let rows = query: select Post(author) diff --git a/ormin.nimble b/ormin.nimble index 521174f..d1db178 100644 --- a/ormin.nimble +++ b/ormin.nimble @@ -1,6 +1,6 @@ # Package -version = "0.8.0" +version = "0.8.1" author = "Araq" description = "Prepared SQL statement generator. A lightweight ORM." license = "MIT" diff --git a/ormin/queries.nim b/ormin/queries.nim index c35fb75..7c54bd0 100644 --- a/ormin/queries.nim +++ b/ormin/queries.nim @@ -143,7 +143,7 @@ type qkInsertReturning QueryBuilder = ref object head, fromm, join, values, where, groupby, having, orderby: string - limit, offset, returning: string + limit, offset, returning, onConflict, onConflictWhere: string env: Env ctes: seq[CteDef] cteBase: int @@ -158,6 +158,7 @@ type insertedValues: seq[(string, NimNode)] # For SQLite: expression to return instead of last_insert_rowid() retExpr: NimNode + onConflictTargetSet, onConflictActionSet, onConflictIsDoUpdate, onConflictWhereSet: bool # Execute a non-row SQL statement strictly (errors on failure) template execNoRowsStrict*(sqlStmt: string) = @@ -183,12 +184,14 @@ template execNoRowsLoose(sqlStmt: string) = proc newQueryBuilder(): QueryBuilder {.compileTime.} = QueryBuilder(head: "", fromm: "", join: "", values: "", where: "", groupby: "", having: "", orderby: "", limit: "", offset: "", - returning: "", + returning: "", onConflict: "", onConflictWhere: "", env: @[], ctes: @[], cteBase: 0, kind: qkNone, params: @[], retType: newNimNode(nnkTupleTy), singleRow: false, retTypeIsJson: false, retNames: @[], coln: 0, qmark: 0, aliasGen: 1, colAliases: @[], - insertedValues: @[], retExpr: newEmptyNode()) + insertedValues: @[], retExpr: newEmptyNode(), + onConflictTargetSet: false, onConflictActionSet: false, + onConflictIsDoUpdate: false, onConflictWhereSet: false) proc getAlias(q: QueryBuilder; tabIndex: int): string = result = tableNames[tabIndex][0] & $q.aliasGen @@ -361,7 +364,8 @@ proc isQueryClause(name: string): bool {.compileTime.} = of "with", "select", "distinct", "insert", "update", "replace", "delete", "where", "join", "innerjoin", "outerjoin", "leftjoin", "leftouterjoin", "rightjoin", "rightouterjoin", "fulljoin", "fullouterjoin", "crossjoin", - "groupby", "orderby", "having", "limit", "offset", "returning", "produce": + "groupby", "orderby", "having", "limit", "offset", "returning", "produce", + "onconflict", "donothing", "doupdate": result = true else: result = false @@ -1019,8 +1023,14 @@ proc tableSel(n: NimNode; q: QueryBuilder) = proc queryh(n: NimNode; q: QueryBuilder) = + var n = n + if n.kind == nnkCall: + let c = newNimNode(nnkCommand) + for i in 0.. 1: + q.onConflict.add ", " + escIdent(q.onConflict, colname) + q.onConflict.add ")" + q.onConflictTargetSet = true + of "donothing": + if q.kind notin {qkInsert, qkInsertReturning}: + macros.error "'donothing' only possible within 'insert'", n + if not q.onConflictTargetSet: + macros.error "'donothing' requires a preceding 'onconflict' clause", n + if q.onConflictActionSet: + macros.error "conflict action already set; choose only one of 'donothing' or 'doupdate'", n + expectLen n, 1 + q.onConflict.add " do nothing" + q.onConflictActionSet = true + q.onConflictIsDoUpdate = false + of "doupdate": + if q.kind notin {qkInsert, qkInsertReturning}: + macros.error "'doupdate' only possible within 'insert'", n + if not q.onConflictTargetSet: + macros.error "'doupdate' requires a preceding 'onconflict' clause", n + if q.onConflictActionSet: + macros.error "conflict action already set; choose only one of 'donothing' or 'doupdate'", n + if n.len < 2: + macros.error "'doupdate' expects assignments like doupdate(col = value)", n + q.onConflict.add " do update set " + for i in 1.. 1: + q.onConflict.add ", " + escIdent(q.onConflict, colname) + q.onConflict.add " = " + discard cond(assignment[1], q.onConflict, q.params, coltype, q) + q.onConflictActionSet = true + q.onConflictIsDoUpdate = true of "returning": if q.kind != qkInsert: macros.error "'returning' only possible within 'insert'" @@ -1217,6 +1296,10 @@ proc queryh(n: NimNode; q: QueryBuilder) = macros.error "unknown query component " & repr(n), n proc queryAsString(q: QueryBuilder, n: NimNode): string = + if q.onConflictTargetSet and not q.onConflictActionSet: + macros.error "'onconflict' requires either 'donothing' or 'doupdate'", n + if q.onConflictWhereSet and not q.onConflictIsDoUpdate: + macros.error "conflict update 'where' requires 'doupdate(...)'", n if q.cteBase < q.ctes.len: result.add "with " for i in q.cteBase.. 0: + result.add q.onConflict + if q.onConflictWhere.len > 0: + result.add q.onConflictWhere if q.where.len > 0: - result.add "\Lwhere " - result.add q.where + if q.kind in {qkSelect, qkJoin, qkUpdate, qkDelete}: + result.add "\Lwhere " + result.add q.where + else: + macros.error "'where' is not supported for this query kind", n if q.groupby.len > 0: result.add "\Lgroup by " result.add q.groupby @@ -1353,7 +1443,7 @@ proc applyQueryNode(n: NimNode; q: QueryBuilder) = macros.error "mixed infix set operations are not supported; use nesting for precedence", part flushBranch(part) else: - if part.kind == nnkCommand or isSetOpCall(part): + if part.kind in {nnkCommand, nnkCall} or isSetOpCall(part): currentBranch.add part else: macros.error "illformed query", part @@ -1368,7 +1458,7 @@ proc applyQueryNode(n: NimNode; q: QueryBuilder) = for part in flattened: if isSetOpCall(part): buildSetOpQuery(part, q) - elif part.kind == nnkCommand: + elif part.kind in {nnkCommand, nnkCall}: queryh(part, q) else: macros.error "illformed query", part diff --git a/tests/tcommon.nim b/tests/tcommon.nim index 629fd35..ac72aaf 100644 --- a/tests/tcommon.nim +++ b/tests/tcommon.nim @@ -451,3 +451,58 @@ suite "nullable": produce json check res[0]["note"].kind == JNull check res[1]["note"].getStr == "hello" + +suite "upsert": + setup: + db.dropTable(sqlFile, "tb_nullable") + db.createTable(sqlFile, "tb_nullable") + + test "onconflict do nothing": + let first = "first value" + query: + insert tb_nullable(id = 1, note = ?first) + + let ignored = "should not overwrite" + query: + insert tb_nullable(id = 1, note = ?ignored) + onconflict(id) + donothing() + + let note = db.getValue(sql"select note from tb_nullable where id = 1") + check note == first + + test "onconflict do update": + let first = "old note" + query: + insert tb_nullable(id = 2, note = ?first) + + let replacement = "new note" + query: + insert tb_nullable(id = 2, note = ?replacement) + onconflict(id) + doupdate(note = ?replacement) + + let note = db.getValue(sql"select note from tb_nullable where id = 2") + check note == replacement + + test "onconflict do update where": + query: + insert tb_nullable(id = 3, note = "keep-me") + + query: + insert tb_nullable(id = 3, note = "replace-me") + onconflict(id) + doupdate(note = "replace-me") + where note != "keep-me" + + let note = db.getValue(sql"select note from tb_nullable where id = 3") + check note == "keep-me" + +static: + doAssert not compiles( + (block: + query: + insert tb_nullable(id = 100, note = "x") + where id == 100 + ) + ) From 370b5847d77693cdf6bff45b792121948de7ba91 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Fri, 17 Apr 2026 18:51:21 +0300 Subject: [PATCH 2/2] Disambiguate upsert DO UPDATE WHERE columns for Postgres --- ormin/queries.nim | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ormin/queries.nim b/ormin/queries.nim index 7c54bd0..88cdd79 100644 --- a/ormin/queries.nim +++ b/ormin/queries.nim @@ -1106,7 +1106,17 @@ proc queryh(n: NimNode; q: QueryBuilder) = if q.onConflictWhereSet: macros.error "conflict update 'where' can only be specified once", n var conflictWhere = "" + # In PostgreSQL upsert WHERE, bare column names are ambiguous between + # target table and EXCLUDED. Resolve bare identifiers against target table. + let oldKind = q.kind + let oldEnv = q.env + if q.env.len > 0: + let source = q.env[^1][0] + q.kind = qkSelect + q.env = @[(source, sourceName(q, source))] let t = cond(n[1], conflictWhere, q.params, DbType(kind: dbBool), q) + q.kind = oldKind + q.env = oldEnv checkBool(t, n) q.onConflictWhere = " where " & conflictWhere q.onConflictWhereSet = true