diff --git a/book/models.nim b/book/models.nim index 3fc43c22..e88675e6 100644 --- a/book/models.nim +++ b/book/models.nim @@ -86,6 +86,24 @@ nbText """ This will result in this schema: CREATE TABLE IF NOT EXISTS "ThingTable"(attr TEXT NOT NULL, id INTEGER NOT NULL PRIMARY KEY) + + +## Read-only Models + +To slim down DB queries when you don't need to fetch the full model, use read-only models. + +A read-only model is a model that defines a subset of fields of another model. Another important property of read-only model is that you can't use it to insert, update, or delete data; just select. + +To define a read-only model, annotate your `Model` subtype with `ro` pragma and point it to an existing table with `tableName`: +""" + +nbCode: + type + ClientName {.ro, tableName: "Client".} = ref object of Model + name: string + +nbText """ +Now you can query data from the DB into `ClientName` instances just like you do with any other model. """ nbSave diff --git a/changelog.md b/changelog.md index 472efd27..d74a173a 100644 --- a/changelog.md +++ b/changelog.md @@ -7,9 +7,14 @@ - [t]—test suite improvement - [d]—docs improvement +## 2.3.4 (WIP) + +- [+] Add the ability to define read-only models (see [#125](https://github.com/moigagoo/norm/issues/125)). + + ## 2.3.3 (December 26, 2021) -- [+] SQLite: Add `conflictPolicy` param to `insert` procs. It determines how insertion conflicts should be handled: `cpRaise` (default) means raise an `DbError`, `cpIgnore` means keep the old row and ignore the new one, and `cpReplace` means replace the old row. +- [+] SQLite: Add `conflictPolicy` param to `insert` procs. It determines how insertion conflicts should be handled: `cpRaise` (default) means raise an `DbError`, `cpIgnore` means keep the old row and ignore the new one, and `cpReplace` means replace the old row (see [#120](https://github.com/moigagoo/norm/issues/120)). ## 2.3.2 (November 19, 2021) diff --git a/src/norm/model.nim b/src/norm/model.nim index e3fcf7dd..615bea55 100644 --- a/src/norm/model.nim +++ b/src/norm/model.nim @@ -108,3 +108,9 @@ func joinGroups*[T: Model](obj: T, flds: seq[string] = @[]): seq[tuple[tbl, tAls result.add grp & subMod.joinGroups(flds & fld) +proc checkRo*(T: typedesc[Model]) = + ## Stop compilation if an object has `ro`_ pragma. + + when T.hasCustomPragma(ro): + {.error: "can't use mutating procs with read-only models".} + diff --git a/src/norm/postgres.nim b/src/norm/postgres.nim index 8e1a9033..17046610 100644 --- a/src/norm/postgres.nim +++ b/src/norm/postgres.nim @@ -8,7 +8,7 @@ import private/[dot, log] import model import pragmas -export dbtypes +export dbtypes, macros type @@ -127,6 +127,8 @@ proc insert*[T: Model](dbConn; obj: var T, force = false) = By default, if the inserted object's ``id`` is not 0, the object is considered already inserted and is not inserted again. You can force new insertion with ``force = true``. ]## + checkRo(T) + if obj.id != 0 and not force: log("Object ID is not 0, skipping insertion. Type: $#, ID: $#" % [$T, $obj.id]) @@ -235,6 +237,8 @@ proc count*(dbConn; T: typedesc[Model], col = "*", dist = false, cond = "TRUE", proc update*[T: Model](dbConn; obj: var T) = ## Update rows for `Model`_ instance and its `Model`_ fields. + checkRo(T) + for fld, val in obj[].fieldPairs: if val.model.isSome: var subMod = get val.model @@ -260,6 +264,8 @@ proc update*[T: Model](dbConn; objs: var openArray[T]) = proc delete*[T: Model](dbConn; obj: var T) = ## Delete rows for `Model`_ instance and its `Model`_ fields. + checkRo(T) + let qry = "DELETE FROM $# WHERE id = $#" % [T.table, $obj.id] log(qry) diff --git a/src/norm/pragmas.nim b/src/norm/pragmas.nim index f82e2392..0220813c 100755 --- a/src/norm/pragmas.nim +++ b/src/norm/pragmas.nim @@ -8,7 +8,9 @@ template pk* {.pragma.} ]## template ro* {.pragma.} - ##[ Mark field as read-only. + ##[ Mark model or field as read-only. + + Read-only models can't be mutated, i.e. you can't call ``insert``, ``update``, or ``delete`` on their instances. Read-only fields are ignored in ``insert`` and ``update`` procs unless ``force`` is passed. diff --git a/src/norm/sqlite.nim b/src/norm/sqlite.nim index 48a3c40d..b4b3081f 100755 --- a/src/norm/sqlite.nim +++ b/src/norm/sqlite.nim @@ -8,7 +8,7 @@ import private/[dot, log] import model import pragmas -export dbtypes +export dbtypes, macros type @@ -125,6 +125,8 @@ proc insert*[T: Model](dbConn; obj: var T, force = false, conflictPolicy = cpRai ``conflictPolicy`` determines how the proc reacts to insertion conflicts. ``cpRaise`` means raise a ``DbError``, ``cpIgnore`` means ignore the conflict and do not insert the conflicting row, ``cpReplace`` means overwrite the older row with the newer one. ]## + checkRo(T) + if obj.id != 0 and not force: log("Object ID is not 0, skipping insertion. Type: $#, ID: $#" % [$T, $obj.id]) @@ -235,6 +237,8 @@ proc count*(dbConn; T: typedesc[Model], col = "*", dist = false, cond = "1", par proc update*[T: Model](dbConn; obj: var T) = ## Update rows for `Model`_ instance and its `Model`_ fields. + checkRo(T) + for fld, val in obj[].fieldPairs: if val.model.isSome: var subMod = get val.model @@ -260,6 +264,8 @@ proc update*[T: Model](dbConn; objs: var openArray[T]) = proc delete*[T: Model](dbConn; obj: var T) = ## Delete rows for `Model`_ instance and its `Model`_ fields. + checkRo(T) + let qry = "DELETE FROM $# WHERE id = $#" % [T.table, $obj.id] log(qry) diff --git a/src/norm/types.nim b/src/norm/types.nim index 15d4af5c..bcd97a89 100644 --- a/src/norm/types.nim +++ b/src/norm/types.nim @@ -25,3 +25,4 @@ func `$`*[_](s: StringOfCap[_]): string = func `$`*[_](s: PaddedStringOfCap[_]): string = string(s) + diff --git a/tests/models.nim b/tests/models.nim index 485b4ba9..fe961995 100644 --- a/tests/models.nim +++ b/tests/models.nim @@ -15,6 +15,9 @@ type name* {.unique.}: string pet* {.onDelete: "CASCADE"}: Option[Pet] + PersonName* {.ro, tableName: "Person".} = ref object of Model + name*: string + PetPerson* = ref object of Model pet*: Pet person*: Person @@ -152,3 +155,5 @@ func `===`*(a, b: String): bool = func newTable*(legCount: Positive = 4): Table = Table(legCount: legCount) +func newPersonName*: PersonName = PersonName(name: "") + diff --git a/tests/postgres/tro.nim b/tests/postgres/tro.nim new file mode 100644 index 00000000..44d1362e --- /dev/null +++ b/tests/postgres/tro.nim @@ -0,0 +1,46 @@ +import std/[unittest, strutils, sugar, options] + +import norm/[model, postgres] + +import ../models + + +const + dbHost = "postgres" + dbUser = "postgres" + dbPassword = "postgres" + dbDatabase = "postgres" + + +suite "Read-only models, non-mutating procs": + proc resetDb = + let dbConn = open(dbHost, dbUser, dbPassword, "template1") + dbConn.exec(sql "DROP DATABASE IF EXISTS $#" % dbDatabase) + dbConn.exec(sql "CREATE DATABASE $#" % dbDatabase) + close dbConn + + setup: + resetDb() + let dbConn = open(dbHost, dbUser, dbPassword, dbDatabase) + + var + alice = newPerson("Alice", none Pet) + bob = newPerson("Bob", none Pet) + + dbConn.createTables(newPerson()) + dbConn.insert(alice) + dbConn.insert(bob) + + teardown: + close dbConn + resetDb() + + test "Select rows": + var personNames = @[newPersonName()] + + dbConn.selectAll(personNames) + + assert len(personNames) == 2 + assert personNames[0].name == "Alice" + assert personNames[1].name == "Bob" + diff --git a/tests/postgres/tronocomp.nim b/tests/postgres/tronocomp.nim new file mode 100644 index 00000000..8a01815d --- /dev/null +++ b/tests/postgres/tronocomp.nim @@ -0,0 +1,42 @@ +discard """ + action: "reject" + errormsg: "can't use mutating procs with read-only models" + file: "model.nim" +""" + +import std/[unittest, strutils, sugar] + +import norm/[model, postgres] + +import ../models + + +const + dbHost = "postgres" + dbUser = "postgres" + dbPassword = "postgres" + dbDatabase = "postgres" + + +suite "Read-only models, mutating procs": + proc resetDb = + let dbConn = open(dbHost, dbUser, dbPassword, "template1") + dbConn.exec(sql "DROP DATABASE IF EXISTS $#" % dbDatabase) + dbConn.exec(sql "CREATE DATABASE $#" % dbDatabase) + close dbConn + + setup: + resetDb() + let dbConn = open(dbHost, dbUser, dbPassword, dbDatabase) + + dbConn.createTables(newPerson()) + + teardown: + close dbConn + resetDb() + + test "Insert row": + var personName = PersonName(name: "Name") + + dbConn.insert(personName) + diff --git a/tests/sqlite/tcount.nim b/tests/sqlite/tcount.nim index 30c6e8c7..e35f37fd 100644 --- a/tests/sqlite/tcount.nim +++ b/tests/sqlite/tcount.nim @@ -41,3 +41,4 @@ suite "Count": test "Conditions": check dbConn.count(Person, "*", dist = false, "name LIKE ?", "alice") == 1 check dbConn.count(Person, "pet", dist = true, "pet = ?", 1) == 1 + diff --git a/tests/sqlite/tdb.nim b/tests/sqlite/tdb.nim index 944a411a..8bf75dd8 100644 --- a/tests/sqlite/tdb.nim +++ b/tests/sqlite/tdb.nim @@ -25,3 +25,4 @@ suite "Database manipulation": dropDb() check not fileExists(dbFile) + diff --git a/tests/sqlite/tro.nim b/tests/sqlite/tro.nim new file mode 100644 index 00000000..6e1ddd53 --- /dev/null +++ b/tests/sqlite/tro.nim @@ -0,0 +1,39 @@ +import std/[unittest, os, strutils, options] + +import norm/[model, sqlite] + +import ../models + + +const dbFile = "test.db" + + +suite "Read-only models, non-mutating procs": + setup: + removeFile dbFile + + putEnv(dbHostEnv, dbFile) + + var + alice = newPerson("Alice", none Pet) + bob = newPerson("Bob", none Pet) + + withDb: + db.createTables(newPerson()) + db.insert(alice) + db.insert(bob) + + teardown: + delEnv(dbHostEnv) + removeFile dbFile + + test "Select rows": + var personNames = @[newPersonName()] + + withDb: + db.selectAll(personNames) + + assert len(personNames) == 2 + assert personNames[0].name == "Alice" + assert personNames[1].name == "Bob" + diff --git a/tests/sqlite/tronocomp.nim b/tests/sqlite/tronocomp.nim new file mode 100644 index 00000000..36bfc24e --- /dev/null +++ b/tests/sqlite/tronocomp.nim @@ -0,0 +1,35 @@ +discard """ + action: "reject" + errormsg: "can't use mutating procs with read-only models" + file: "model.nim" +""" + +import std/[unittest, os, strutils] + +import norm/[model, sqlite] + +import ../models + + +const dbFile = "test.db" + + +suite "Read-only models, mutating procs": + setup: + removeFile dbFile + + putEnv(dbHostEnv, dbFile) + + withDb: + db.createTables(newPerson()) + + teardown: + delEnv(dbHostEnv) + removeFile dbFile + + test "Insert row": + var personName = PersonName(name: "Name") + + withDb: + db.insert(personName) +