Skip to content

Commit 03c6335

Browse files
authored
Add onconflict upsert DSL with conditional update support (#82)
* Add onconflict upsert DSL with conditional update support * Disambiguate upsert DO UPDATE WHERE columns for Postgres
1 parent 2706d62 commit 03c6335

4 files changed

Lines changed: 200 additions & 24 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ let db {.global.} = open("localhost", "user", "password", "dbname")
7272

7373
## Query DSL
7474

75-
`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.
75+
`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.
7676

7777
Example snippets:
7878

@@ -88,6 +88,27 @@ let payload = %*{"dt2": %*"2023-10-01T00:00:00Z"}
8888
query:
8989
insert tb_timestamp(dt1 = ?dt1, dt2 = %payload["dt2"])
9090
91+
# Upsert on conflict (SQLite/PostgreSQL)
92+
query:
93+
insert tb_nullable(id = ?id, note = ?note)
94+
onconflict(id)
95+
doupdate(note = ?note)
96+
97+
# Conditional upsert update
98+
query:
99+
insert tb_nullable(id = ?id, note = ?note)
100+
onconflict(id)
101+
doupdate(note = ?note)
102+
where note != ?note
103+
104+
# Ignore duplicates
105+
query:
106+
insert tb_nullable(id = ?id, note = ?note)
107+
onconflict(id)
108+
donothing()
109+
110+
# Note: plain INSERT ... VALUES does not support `where`; use `onconflict(...)+doupdate(...)+where ...`
111+
91112
# Explicit join with filter
92113
let rows = query:
93114
select Post(author)

ormin.nimble

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Package
22

3-
version = "0.8.0"
3+
version = "0.8.1"
44
author = "Araq"
55
description = "Prepared SQL statement generator. A lightweight ORM."
66
license = "MIT"

ormin/queries.nim

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ type
143143
qkInsertReturning
144144
QueryBuilder = ref object
145145
head, fromm, join, values, where, groupby, having, orderby: string
146-
limit, offset, returning: string
146+
limit, offset, returning, onConflict, onConflictWhere: string
147147
env: Env
148148
ctes: seq[CteDef]
149149
cteBase: int
@@ -158,6 +158,7 @@ type
158158
insertedValues: seq[(string, NimNode)]
159159
# For SQLite: expression to return instead of last_insert_rowid()
160160
retExpr: NimNode
161+
onConflictTargetSet, onConflictActionSet, onConflictIsDoUpdate, onConflictWhereSet: bool
161162

162163
# Execute a non-row SQL statement strictly (errors on failure)
163164
template execNoRowsStrict*(sqlStmt: string) =
@@ -183,12 +184,14 @@ template execNoRowsLoose(sqlStmt: string) =
183184
proc newQueryBuilder(): QueryBuilder {.compileTime.} =
184185
QueryBuilder(head: "", fromm: "", join: "", values: "", where: "",
185186
groupby: "", having: "", orderby: "", limit: "", offset: "",
186-
returning: "",
187+
returning: "", onConflict: "", onConflictWhere: "",
187188
env: @[], ctes: @[], cteBase: 0, kind: qkNone, params: @[],
188189
retType: newNimNode(nnkTupleTy), singleRow: false,
189190
retTypeIsJson: false, retNames: @[],
190191
coln: 0, qmark: 0, aliasGen: 1, colAliases: @[],
191-
insertedValues: @[], retExpr: newEmptyNode())
192+
insertedValues: @[], retExpr: newEmptyNode(),
193+
onConflictTargetSet: false, onConflictActionSet: false,
194+
onConflictIsDoUpdate: false, onConflictWhereSet: false)
192195

193196
proc getAlias(q: QueryBuilder; tabIndex: int): string =
194197
result = tableNames[tabIndex][0] & $q.aliasGen
@@ -361,7 +364,8 @@ proc isQueryClause(name: string): bool {.compileTime.} =
361364
of "with", "select", "distinct", "insert", "update", "replace", "delete",
362365
"where", "join", "innerjoin", "outerjoin", "leftjoin", "leftouterjoin",
363366
"rightjoin", "rightouterjoin", "fulljoin", "fullouterjoin", "crossjoin",
364-
"groupby", "orderby", "having", "limit", "offset", "returning", "produce":
367+
"groupby", "orderby", "having", "limit", "offset", "returning", "produce",
368+
"onconflict", "donothing", "doupdate":
365369
result = true
366370
else:
367371
result = false
@@ -1019,8 +1023,14 @@ proc tableSel(n: NimNode; q: QueryBuilder) =
10191023

10201024

10211025
proc queryh(n: NimNode; q: QueryBuilder) =
1026+
var n = n
1027+
if n.kind == nnkCall:
1028+
let c = newNimNode(nnkCommand)
1029+
for i in 0..<n.len:
1030+
c.add n[i]
1031+
n = c
10221032
expectKind n, nnkCommand
1023-
let kind = nodeName(n[0])
1033+
let kind = nodeName(n[0]).toLowerAscii()
10241034
case kind
10251035
of "with":
10261036
expectLen n, 2
@@ -1090,20 +1100,41 @@ proc queryh(n: NimNode; q: QueryBuilder) =
10901100
tableSel(n[1], q)
10911101
of "where":
10921102
expectLen n, 2
1093-
let t = cond(n[1], q.where, q.params, DbType(kind: dbBool), q)
1094-
checkBool(t, n)
1103+
if q.kind in {qkInsert, qkInsertReturning}:
1104+
if not q.onConflictTargetSet or not q.onConflictActionSet or not q.onConflictIsDoUpdate:
1105+
macros.error "'where' for insert is only supported after 'onconflict(...)' and 'doupdate(...)'", n
1106+
if q.onConflictWhereSet:
1107+
macros.error "conflict update 'where' can only be specified once", n
1108+
var conflictWhere = ""
1109+
# In PostgreSQL upsert WHERE, bare column names are ambiguous between
1110+
# target table and EXCLUDED. Resolve bare identifiers against target table.
1111+
let oldKind = q.kind
1112+
let oldEnv = q.env
1113+
if q.env.len > 0:
1114+
let source = q.env[^1][0]
1115+
q.kind = qkSelect
1116+
q.env = @[(source, sourceName(q, source))]
1117+
let t = cond(n[1], conflictWhere, q.params, DbType(kind: dbBool), q)
1118+
q.kind = oldKind
1119+
q.env = oldEnv
1120+
checkBool(t, n)
1121+
q.onConflictWhere = " where " & conflictWhere
1122+
q.onConflictWhereSet = true
1123+
else:
1124+
let t = cond(n[1], q.where, q.params, DbType(kind: dbBool), q)
1125+
checkBool(t, n)
10951126
of "join", "innerjoin", "outerjoin", "leftjoin", "leftouterjoin",
10961127
"rightjoin", "rightouterjoin", "fulljoin", "fullouterjoin", "crossjoin":
10971128
q.join.add "\L" & joinKeyword(kind)
10981129
expectLen n, 2
1099-
let cmd = n[1]
1100-
if kind == "crossjoin" and cmd.kind == nnkCommand and cmd.len == 2 and
1101-
cmd[1].kind == nnkCommand and cmd[1].len == 2 and $cmd[1][0] == "on":
1130+
let joinClause = n[1]
1131+
if kind == "crossjoin" and joinClause.kind == nnkCommand and joinClause.len == 2 and
1132+
joinClause[1].kind == nnkCommand and joinClause[1].len == 2 and $joinClause[1][0] == "on":
11021133
macros.error "crossjoin does not support an on clause", n
1103-
if cmd.kind == nnkCommand and cmd.len == 2 and
1104-
cmd[1].kind == nnkCommand and cmd[1].len == 2 and $cmd[1][0] == "on" and
1105-
cmd[0].kind == nnkCall:
1106-
let tab = $cmd[0][0]
1134+
if joinClause.kind == nnkCommand and joinClause.len == 2 and
1135+
joinClause[1].kind == nnkCommand and joinClause[1].len == 2 and $joinClause[1][0] == "on" and
1136+
joinClause[0].kind == nnkCall:
1137+
let tab = $joinClause[0][0]
11071138
let tabIndex = sourceLookup(q, tab)
11081139
if tabIndex < 0:
11091140
macros.error "unknown table name: " & tab & " from: " & fmtTableList(tableNames), n
@@ -1114,17 +1145,17 @@ proc queryh(n: NimNode; q: QueryBuilder) =
11141145
var oldEnv = q.env
11151146
q.env = @[(tabIndex, alias)]
11161147
q.kind = qkJoin
1117-
tableSel(cmd[0], q)
1148+
tableSel(joinClause[0], q)
11181149
swap q.env, oldEnv
1119-
let onn = cmd[1][1]
1150+
let onn = joinClause[1][1]
11201151
q.join.add " on "
11211152
oldEnv = q.env
11221153
q.env.add((tabIndex, alias))
11231154
let t = cond(onn, q.join, q.params, DbType(kind: dbBool), q)
11241155
swap q.env, oldEnv
11251156
checkBool(t, onn)
1126-
elif cmd.kind == nnkCall:
1127-
let tab = $cmd[0]
1157+
elif joinClause.kind == nnkCall:
1158+
let tab = $joinClause[0]
11281159
let tabIndex = sourceLookup(q, tab)
11291160
if tabIndex < 0:
11301161
macros.error "unknown table name: " & tab & " from: " & fmtTableList(tableNames), n[1][0]
@@ -1175,6 +1206,64 @@ proc queryh(n: NimNode; q: QueryBuilder) =
11751206
expectLen n, 2
11761207
let t = cond(n[1], q.offset, q.params, DbType(kind: dbInt), q)
11771208
checkInt(t, n[1])
1209+
of "onconflict":
1210+
if q.kind notin {qkInsert, qkInsertReturning}:
1211+
macros.error "'onconflict' only possible within 'insert'", n
1212+
if q.onConflictTargetSet:
1213+
macros.error "'onconflict' can only be specified once", n
1214+
if n.len < 2:
1215+
macros.error "'onconflict' expects one or more columns", n
1216+
q.onConflict = "\Lon conflict ("
1217+
for i in 1..<n.len:
1218+
let col = n[i]
1219+
let colname = nodeName(col)
1220+
if colname.len == 0:
1221+
macros.error "'onconflict' columns must be identifiers", col
1222+
if lookup("", colname, q).kind == dbUnknown:
1223+
macros.error "unknown column name: " & colname, col
1224+
if i > 1:
1225+
q.onConflict.add ", "
1226+
escIdent(q.onConflict, colname)
1227+
q.onConflict.add ")"
1228+
q.onConflictTargetSet = true
1229+
of "donothing":
1230+
if q.kind notin {qkInsert, qkInsertReturning}:
1231+
macros.error "'donothing' only possible within 'insert'", n
1232+
if not q.onConflictTargetSet:
1233+
macros.error "'donothing' requires a preceding 'onconflict' clause", n
1234+
if q.onConflictActionSet:
1235+
macros.error "conflict action already set; choose only one of 'donothing' or 'doupdate'", n
1236+
expectLen n, 1
1237+
q.onConflict.add " do nothing"
1238+
q.onConflictActionSet = true
1239+
q.onConflictIsDoUpdate = false
1240+
of "doupdate":
1241+
if q.kind notin {qkInsert, qkInsertReturning}:
1242+
macros.error "'doupdate' only possible within 'insert'", n
1243+
if not q.onConflictTargetSet:
1244+
macros.error "'doupdate' requires a preceding 'onconflict' clause", n
1245+
if q.onConflictActionSet:
1246+
macros.error "conflict action already set; choose only one of 'donothing' or 'doupdate'", n
1247+
if n.len < 2:
1248+
macros.error "'doupdate' expects assignments like doupdate(col = value)", n
1249+
q.onConflict.add " do update set "
1250+
for i in 1..<n.len:
1251+
let assignment = n[i]
1252+
if assignment.kind != nnkExprEqExpr:
1253+
macros.error "'doupdate' expects assignments like doupdate(col = value)", assignment
1254+
let colname = nodeName(assignment[0])
1255+
if colname.len == 0:
1256+
macros.error "'doupdate' assignments must target a column identifier", assignment[0]
1257+
let coltype = lookup("", colname, q)
1258+
if coltype.kind == dbUnknown:
1259+
macros.error "unknown column name: " & colname, assignment[0]
1260+
if i > 1:
1261+
q.onConflict.add ", "
1262+
escIdent(q.onConflict, colname)
1263+
q.onConflict.add " = "
1264+
discard cond(assignment[1], q.onConflict, q.params, coltype, q)
1265+
q.onConflictActionSet = true
1266+
q.onConflictIsDoUpdate = true
11781267
of "returning":
11791268
if q.kind != qkInsert:
11801269
macros.error "'returning' only possible within 'insert'"
@@ -1217,6 +1306,10 @@ proc queryh(n: NimNode; q: QueryBuilder) =
12171306
macros.error "unknown query component " & repr(n), n
12181307

12191308
proc queryAsString(q: QueryBuilder, n: NimNode): string =
1309+
if q.onConflictTargetSet and not q.onConflictActionSet:
1310+
macros.error "'onconflict' requires either 'donothing' or 'doupdate'", n
1311+
if q.onConflictWhereSet and not q.onConflictIsDoUpdate:
1312+
macros.error "conflict update 'where' requires 'doupdate(...)'", n
12201313
if q.cteBase < q.ctes.len:
12211314
result.add "with "
12221315
for i in q.cteBase..<q.ctes.len:
@@ -1237,9 +1330,16 @@ proc queryAsString(q: QueryBuilder, n: NimNode): string =
12371330
result.add "\Lvalues ("
12381331
result.add q.values
12391332
result.add ")"
1333+
if q.onConflict.len > 0:
1334+
result.add q.onConflict
1335+
if q.onConflictWhere.len > 0:
1336+
result.add q.onConflictWhere
12401337
if q.where.len > 0:
1241-
result.add "\Lwhere "
1242-
result.add q.where
1338+
if q.kind in {qkSelect, qkJoin, qkUpdate, qkDelete}:
1339+
result.add "\Lwhere "
1340+
result.add q.where
1341+
else:
1342+
macros.error "'where' is not supported for this query kind", n
12431343
if q.groupby.len > 0:
12441344
result.add "\Lgroup by "
12451345
result.add q.groupby
@@ -1353,7 +1453,7 @@ proc applyQueryNode(n: NimNode; q: QueryBuilder) =
13531453
macros.error "mixed infix set operations are not supported; use nesting for precedence", part
13541454
flushBranch(part)
13551455
else:
1356-
if part.kind == nnkCommand or isSetOpCall(part):
1456+
if part.kind in {nnkCommand, nnkCall} or isSetOpCall(part):
13571457
currentBranch.add part
13581458
else:
13591459
macros.error "illformed query", part
@@ -1368,7 +1468,7 @@ proc applyQueryNode(n: NimNode; q: QueryBuilder) =
13681468
for part in flattened:
13691469
if isSetOpCall(part):
13701470
buildSetOpQuery(part, q)
1371-
elif part.kind == nnkCommand:
1471+
elif part.kind in {nnkCommand, nnkCall}:
13721472
queryh(part, q)
13731473
else:
13741474
macros.error "illformed query", part

tests/tcommon.nim

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,3 +451,58 @@ suite "nullable":
451451
produce json
452452
check res[0]["note"].kind == JNull
453453
check res[1]["note"].getStr == "hello"
454+
455+
suite "upsert":
456+
setup:
457+
db.dropTable(sqlFile, "tb_nullable")
458+
db.createTable(sqlFile, "tb_nullable")
459+
460+
test "onconflict do nothing":
461+
let first = "first value"
462+
query:
463+
insert tb_nullable(id = 1, note = ?first)
464+
465+
let ignored = "should not overwrite"
466+
query:
467+
insert tb_nullable(id = 1, note = ?ignored)
468+
onconflict(id)
469+
donothing()
470+
471+
let note = db.getValue(sql"select note from tb_nullable where id = 1")
472+
check note == first
473+
474+
test "onconflict do update":
475+
let first = "old note"
476+
query:
477+
insert tb_nullable(id = 2, note = ?first)
478+
479+
let replacement = "new note"
480+
query:
481+
insert tb_nullable(id = 2, note = ?replacement)
482+
onconflict(id)
483+
doupdate(note = ?replacement)
484+
485+
let note = db.getValue(sql"select note from tb_nullable where id = 2")
486+
check note == replacement
487+
488+
test "onconflict do update where":
489+
query:
490+
insert tb_nullable(id = 3, note = "keep-me")
491+
492+
query:
493+
insert tb_nullable(id = 3, note = "replace-me")
494+
onconflict(id)
495+
doupdate(note = "replace-me")
496+
where note != "keep-me"
497+
498+
let note = db.getValue(sql"select note from tb_nullable where id = 3")
499+
check note == "keep-me"
500+
501+
static:
502+
doAssert not compiles(
503+
(block:
504+
query:
505+
insert tb_nullable(id = 100, note = "x")
506+
where id == 100
507+
)
508+
)

0 commit comments

Comments
 (0)