Add Ormin ORM support for BaraDB (Nim client)
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
This commit is contained in:
@@ -141,6 +141,23 @@ nim c -r tests/test_integration.nim
|
||||
|
||||
Same as async but blocking. Available on `SyncClient`.
|
||||
|
||||
## Ormin Integration
|
||||
|
||||
The Nim client ships with an [Ormin](https://github.com/Araq/ormin) backend for compile-time checked SQL queries.
|
||||
|
||||
```nim
|
||||
import ormin
|
||||
|
||||
importModel(DbBackend.baradb, "my_model")
|
||||
let db {.global.} = open("127.0.0.1:9472", "admin", "", "default")
|
||||
|
||||
let rows = query:
|
||||
select users(id, name)
|
||||
where name == ?"alice"
|
||||
```
|
||||
|
||||
See `examples/ormin_basic.nim` for a full sample.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_DB: test_ormin
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
nim:
|
||||
- stable
|
||||
name: Test on nim ${{ matrix.nim }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache choosenim
|
||||
id: cache-choosenim
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.choosenim
|
||||
key: ${{ runner.os }}-choosenim-stable
|
||||
|
||||
- name: Cache nimble
|
||||
id: cache-nimble
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: ${{ runner.os }}-nimble-stable
|
||||
|
||||
- name: Setup nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
with:
|
||||
nim-version: ${{ matrix.nim }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
nimble --useSystemNim install -d
|
||||
nimble --useSystemNim install karax -y
|
||||
nimble --useSystemNim install jester -y
|
||||
nimble --useSystemNim install websocket -y
|
||||
|
||||
- name: Run test sqlite
|
||||
run: nimble --useSystemNim test
|
||||
|
||||
- name: Run test postgres
|
||||
run: nimble --useSystemNim test_postgres
|
||||
env:
|
||||
PGPASSWORD: test
|
||||
|
||||
- name: Build examples
|
||||
run: nimble buildexamples
|
||||
@@ -0,0 +1,29 @@
|
||||
*
|
||||
!*/
|
||||
!*.*
|
||||
*.db
|
||||
*.js
|
||||
nimcache
|
||||
tools/ormin_importer
|
||||
tests/forum_model_sqlite.nim
|
||||
tests/forum_model_postgres.nim
|
||||
tests/model_postgre.nim
|
||||
tests/model_sqlite.nim
|
||||
tests/tfeature
|
||||
tests/tcommon
|
||||
tests/tpostgre
|
||||
tests/tsqlite
|
||||
examples/chat/chat_model.nim
|
||||
examples/chat/chatclient.nim
|
||||
examples/chat/createdb
|
||||
examples/chat/server
|
||||
examples/forum/forum_model.nim
|
||||
examples/forum/forumclient.nim
|
||||
examples/forum/forum
|
||||
examples/forum/forumproto
|
||||
examples/tweeter/src/createdatabase
|
||||
examples/tweeter/src/tweeterdeps/
|
||||
deps/
|
||||
nim.cfg
|
||||
.nimcache
|
||||
tests/tdb_utils
|
||||
@@ -0,0 +1,28 @@
|
||||
sudo: false
|
||||
language: c
|
||||
os: linux
|
||||
install:
|
||||
- git clone https://github.com/nim-lang/nim
|
||||
- cd nim
|
||||
- git clone --depth 1 https://github.com/nim-lang/csources.git
|
||||
- cd csources
|
||||
- sh build.sh
|
||||
- cd ../
|
||||
- bin/nim c koch
|
||||
- ./koch boot -d:release
|
||||
- ./koch tools
|
||||
- cd ..
|
||||
before_script:
|
||||
- set -e
|
||||
- export PATH=$(pwd)/nim/bin:$(pwd):$PATH
|
||||
script:
|
||||
- nimble develop -y
|
||||
- nim c tools/ormin_importer
|
||||
- cd examples/forum
|
||||
- ../../tools/ormin_importer forum_model.sql
|
||||
- nim c forum.nim
|
||||
- nim c forumproto.nim
|
||||
- cd ../..
|
||||
- cd examples/chat
|
||||
- ../../tools/ormin_importer chat_model.sql
|
||||
- nim c server.nim
|
||||
@@ -0,0 +1,135 @@
|
||||
# Ormin + BaraDB
|
||||
|
||||
This is a fork of [Ormin](https://github.com/Araq/ormin) (prepared SQL statement generator for Nim) with added support for **BaraDB** — a multimodal database engine with a binary wire protocol.
|
||||
|
||||
## What changed?
|
||||
|
||||
- Added `DbBackend.baradb` to the backend enum.
|
||||
- Added `ImportTarget.baradb` for schema import.
|
||||
- New backend file `ormin/ormin_baradb.nim` that bridges Ormin's compile-time query DSL to the BaraDB Nim client (`baradb/client`).
|
||||
|
||||
## Installation
|
||||
|
||||
Install both the BaraDB client and this Ormin fork:
|
||||
|
||||
```bash
|
||||
# Install BaraDB client (from barabadb/clients/nim)
|
||||
cd clients/nim
|
||||
nimble install
|
||||
|
||||
# Install Ormin with BaraDB support
|
||||
cd ormin
|
||||
nimble install
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Write your schema
|
||||
|
||||
Create a file named `model.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
age INT
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Use Ormin DSL in Nim
|
||||
|
||||
```nim
|
||||
import json
|
||||
import ormin
|
||||
|
||||
importModel(DbBackend.baradb, "model")
|
||||
|
||||
let db {.global.} = open("127.0.0.1:9472", "admin", "", "default")
|
||||
|
||||
# Select tuples
|
||||
proc listUsers() =
|
||||
let rows = query:
|
||||
select users(id, name, email)
|
||||
orderby id
|
||||
for r in rows:
|
||||
echo "User #", r.id, ": ", r.name
|
||||
|
||||
# Parameterized query
|
||||
proc findUser(name: string) =
|
||||
let row = query:
|
||||
select users(id, name, email)
|
||||
where name == ?name
|
||||
limit 1
|
||||
echo row
|
||||
|
||||
# Insert
|
||||
proc addUser(name, email: string; age: int) =
|
||||
query:
|
||||
insert users(name = ?name, email = ?email, age = ?age)
|
||||
|
||||
# JSON output
|
||||
proc usersAsJson(): seq[JsonNode] =
|
||||
result = query:
|
||||
select users(id, name)
|
||||
produce json
|
||||
|
||||
when isMainModule:
|
||||
listUsers()
|
||||
```
|
||||
|
||||
Compile with:
|
||||
|
||||
```bash
|
||||
nim c -r myapp.nim
|
||||
```
|
||||
|
||||
## Connection string format
|
||||
|
||||
`open(host:port, username, password, database)`
|
||||
|
||||
```nim
|
||||
let db = open("127.0.0.1:9472", "admin", "", "default")
|
||||
```
|
||||
|
||||
If you omit the port, the default `9472` is used.
|
||||
|
||||
## Supported Ormin features
|
||||
|
||||
| Feature | Status |
|
||||
|---------------------|--------|
|
||||
| `select` | ✅ |
|
||||
| `where` | ✅ |
|
||||
| `join` / `leftjoin` | ✅ |
|
||||
| `insert` | ✅ |
|
||||
| `update` | ✅ |
|
||||
| `delete` | ✅ |
|
||||
| `orderby` | ✅ |
|
||||
| `limit` / `offset` | ✅ |
|
||||
| `?` placeholders | ✅ |
|
||||
| `%` JSON params | ✅ |
|
||||
| `produce json` | ✅ |
|
||||
| `query(T)` typed | ✅ |
|
||||
| `createProc` | ✅ |
|
||||
| `createIter` | ✅ |
|
||||
| `transaction` | ⚠️ (relies on server support) |
|
||||
| `returning` | ⚠️ (single-row limit works) |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Wire protocol strings**: BaraDB returns all column values as strings over the wire. The backend parses them at runtime. This is slightly less efficient than native SQLite/PostgreSQL bindings but keeps the implementation simple and portable.
|
||||
- **`getLastId`**: Currently returns `0`. If BaraDB supports `RETURNING id`, use `limit 1` queries instead.
|
||||
- **Async**: Ormin's DSL is synchronous by design. This backend uses `SyncClient` under the hood. If you need async, use the raw `baradb/client` async API directly.
|
||||
|
||||
## Running the example
|
||||
|
||||
```bash
|
||||
cd clients/nim/ormin/examples
|
||||
nim c -r baradb_basic.nim
|
||||
```
|
||||
|
||||
*(Requires a BaraDB server on `localhost:9472`.)*
|
||||
|
||||
## License
|
||||
|
||||
Same as upstream Ormin — MIT.
|
||||
@@ -0,0 +1,473 @@
|
||||
# ormin
|
||||
|
||||
Prepared SQL statement generator for Nim. A lightweight ORM.
|
||||
|
||||
Features:
|
||||
|
||||
- Compile time query checking: Types as well as table
|
||||
and column names are checked, no surprises at runtime!
|
||||
- Automatic join generation: Ormin knows the table
|
||||
relations and can compute the "natural" join for you!
|
||||
- Nim based DSL for queries: No syntax errors at runtime,
|
||||
no SQL injections possible.
|
||||
- Generated prepared statements: As fast as low level
|
||||
hand written API calls!
|
||||
- First class JSON support: No explicit conversions
|
||||
from rows to JSON objects required.
|
||||
|
||||
TODO:
|
||||
|
||||
- Better support for complex nested queries.
|
||||
- Write mysql backend.
|
||||
|
||||
|
||||
## Schema and Database Setup
|
||||
|
||||
1. **Generate a model from SQL** – Place your schema in an `.sql` file and import it using `importModel`. By default this runs `ormin_importer` and includes the generated Nim code. Pass `includeStatic = true` to generate the model directly at compile time from the SQL file instead.
|
||||
2. **Create a database connection** – Ormin expects a global connection named `db` when issuing queries. The library ships drivers for SQLite and PostgreSQL; pick the matching backend in `importModel` and open a connection with Nim's database modules.
|
||||
|
||||
### Static Schema
|
||||
|
||||
The SQL file can be easily embedded at compile time. Import `ormin/db_utils` and use `const mySql = staticLoad("schema.sql")`, which returns a distinct string type `DbSql` after running sanity-checks the SQL. Pass that const `DbSql` to `createTable` / `dropTable` overloads:
|
||||
|
||||
```nim
|
||||
import ormin/db_utils
|
||||
|
||||
const schema = staticLoad("model.sql")
|
||||
|
||||
db.createTable(schema)
|
||||
db.createTable(schema, "quoted table")
|
||||
db.dropTable(schema)
|
||||
db.dropTable(schema, "quoted table")
|
||||
```
|
||||
|
||||
If you already use `importModel`, you can opt into the same static path directly there. `includeStatic = true` skips the generated `.nim` file, builds the model metadata from the `.sql` file at compile time, and exposes `sqlSchema` automatically:
|
||||
|
||||
```nim
|
||||
import ../ormin
|
||||
importModel(DbBackend.sqlite, "model_sqlite", includeStatic = true)
|
||||
|
||||
db.createTable(sqlSchema)
|
||||
db.dropTable(sqlSchema, "tb_timestamp")
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```nim
|
||||
import ../ormin
|
||||
importModel(DbBackend.sqlite, "model_sqlite")
|
||||
let db {.global.} = open(":memory:", "", "", "")
|
||||
```
|
||||
|
||||
Note: Ormin now properly handles quoted table names in `dropTable`. The compile flag `-d:orminLegacySqliteDropNames` restores that older drop-table behavior by using the normalized lookup name instead of the preserved SQL identifier. The old behavior only worked in SQlite, not Postgres.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```nim
|
||||
import postgres
|
||||
import ../ormin
|
||||
importModel(DbBackend.postgre, "model_postgre")
|
||||
let db {.global.} = open("localhost", "user", "password", "dbname")
|
||||
```
|
||||
|
||||
### BaraDB
|
||||
|
||||
```nim
|
||||
import ormin
|
||||
importModel(DbBackend.baradb, "model_baradb")
|
||||
let db {.global.} = open("127.0.0.1:9472", "admin", "", "default")
|
||||
```
|
||||
|
||||
## 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`, `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:
|
||||
|
||||
```nim
|
||||
# Select recent rows from a Messages table with a Nim parameter
|
||||
let recentMessages = query:
|
||||
select Messages(content, creation, author)
|
||||
orderby desc(creation)
|
||||
limit ?maxMessages
|
||||
|
||||
# Insert using Nim and JSON parameters
|
||||
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)
|
||||
leftjoin Person(name) on author == id
|
||||
where id == ?postId
|
||||
|
||||
# Automatic join generated from foreign keys
|
||||
let postsWithAuthors = query:
|
||||
select Post(title)
|
||||
join Author(name)
|
||||
where author.name == ?userName
|
||||
|
||||
# DISTINCT queries and COUNT(DISTINCT ...)
|
||||
let authorIds = query:
|
||||
select `distinct` Post(author)
|
||||
let authorCount = query:
|
||||
select Post(count(distinct author))
|
||||
|
||||
# NULL predicates use `nil` or `null`
|
||||
let unassigned = query:
|
||||
select Ticket(id)
|
||||
where assignee == nil
|
||||
|
||||
# Pattern matching uses backticked infix operators
|
||||
let matchingPeople = query:
|
||||
select Person(id, name)
|
||||
where name `like` ?"john%"
|
||||
|
||||
# EXISTS / NOT EXISTS subqueries
|
||||
let peopleWithPosts = query:
|
||||
select Person(id)
|
||||
where exists(select Post(id) where author == ?personId)
|
||||
|
||||
# CTEs use with cteName(select ...)
|
||||
let recentAuthors = query:
|
||||
with recent(select Post(id, author) where id <= 3)
|
||||
select recent(author)
|
||||
|
||||
# Window functions use over(expr, ...)
|
||||
let rankedPosts = query:
|
||||
select Post(author, id, over(row_number(), partitionby(author), orderby(id)) as rn)
|
||||
|
||||
# Set operations can be written inline between select queries
|
||||
let mergedIds = query:
|
||||
select Person(id) where id <= 2
|
||||
union
|
||||
select Person(id) where id >= 4
|
||||
|
||||
# Multiple joins with pagination
|
||||
let page = query:
|
||||
select Post(title)
|
||||
join Person(name) on author == id
|
||||
join Category(title) on category == id
|
||||
orderby desc(post.creation)
|
||||
limit 5 offset 10
|
||||
|
||||
# Vendor-specific function via raw SQL splice
|
||||
query:
|
||||
update Users(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?userId
|
||||
```
|
||||
|
||||
Compile with `-d:debugOrminSql` to see the produced SQL at build time, which helps when experimenting with the DSL.
|
||||
|
||||
`tryQuery` executes a query but ignores database errors. `createProc` and `createIter` wrap a `query` block into a callable method on `db` for reuse.
|
||||
|
||||
### Select and Joins
|
||||
|
||||
Selecting columns for the primary table is done using the syntax `select Post(title, author, ...)` where `Post` is the table and `title`, `author`, etc are columns of that table. This will return a tuple containing `(title, author, ...)`. Only one table can be selected and columns must be from that table. Unlike in SQL, columns for joined tables are selected directly in the `join` syntax.
|
||||
|
||||
Joins use the syntax `join Person(name, city) on author == id` where `Person` is the table and the columns `name`, and `city` are columns of that table. Often the join condition can be inferred from foreign keys and can be left out: `join Author(name, city)`. The columns listed in the joined tabled will be appended to the results tuple, i.e. `(title, author, name, city)`. Supported joins are: `join`, `innerjoin`, `leftjoin`, `leftouterjoin`, `rightjoin`, `rightouterjoin`, `fulljoin`, `fullouterjoin`, `crossjoin`, and the legacy `outerjoin`. Runtime support for `rightjoin` / `fulljoin` still depends on the SQL backend.
|
||||
|
||||
The join syntax differs from SQL but simplifies selecting fields from multiple tables by making them more explicit while still maintaing SQL's full query capabilities.
|
||||
|
||||
### Return Types
|
||||
|
||||
The core return type for queries is a sequence of tuples where the tuples fields are the types of the columns. Some queries with `returning` or `limit` clauses will return singular values or raise a DbError.
|
||||
|
||||
- Selecting multiple columns returns a sequence of tuples of the inferred Nim types.
|
||||
- Selecting a single column produces a sequence of that Nim type, e.g. `let names: seq[string] = query: select person(name)`.
|
||||
- `produce json` emits `JsonNode` objects instead of Nim tuples; `produce nim` forces standard Nim results.
|
||||
- `returning` or `limit 1` make the query return a single value or tuple instead of a sequence.
|
||||
- Generated procedures/iterators return the same types as the underlying query (see `createProc`/`createIter` tests).
|
||||
|
||||
Examples:
|
||||
|
||||
```nim
|
||||
# Sequence of tuples
|
||||
let threads = query:
|
||||
select thread(id, name)
|
||||
|
||||
# Sequence of simple Nim types
|
||||
let ids = query:
|
||||
select thread(id)
|
||||
|
||||
# JSON result
|
||||
let threadJson = query:
|
||||
select thread(id, name)
|
||||
produce json
|
||||
|
||||
# Force Nim tuple even if `produce json` was used earlier
|
||||
let threadNim = query:
|
||||
select thread(id, name)
|
||||
produce nim
|
||||
```
|
||||
|
||||
Single tuples or values can be returned in some cases:
|
||||
|
||||
```nim
|
||||
# Single value returning
|
||||
let newId = query:
|
||||
insert thread(name = ?"topic")
|
||||
returning id
|
||||
|
||||
# Single row value returning when limit is a const `1`
|
||||
let newId = query:
|
||||
select thread(name = ?"topic")
|
||||
orderby desc(trhead.id)
|
||||
limit 1
|
||||
```
|
||||
|
||||
**Note**: use an integer arg to limit to return a sequence instead!
|
||||
|
||||
```nim
|
||||
let n = 1
|
||||
let newId = query:
|
||||
select thread(name = ?"topic")
|
||||
orderby desc(trhead.id)
|
||||
limit ?n
|
||||
|
||||
```
|
||||
|
||||
### Typed Queries
|
||||
|
||||
Use `query(T):` when you want Ormin to deserialize selected columns directly into a named Nim type instead of returning the default tuple shape. This is useful at module boundaries where a named object, ref object, or scalar domain type is clearer than a tuple.
|
||||
|
||||
For object results, selected column names must match fields on the destination type. Use `as` aliases when the database column name differs from the Nim field name:
|
||||
|
||||
```nim
|
||||
type
|
||||
ThreadSummary = object
|
||||
id: int
|
||||
title: string
|
||||
|
||||
let threads = query(ThreadSummary):
|
||||
select thread(id, name as title)
|
||||
orderby id
|
||||
```
|
||||
|
||||
Selecting one column can map directly to a scalar type:
|
||||
|
||||
```nim
|
||||
let names = query(string):
|
||||
select thread(name)
|
||||
```
|
||||
|
||||
Queries that return a single row, such as a `limit 1` query, return one `T` instead of `seq[T]`:
|
||||
|
||||
```nim
|
||||
let thread = query(ThreadSummary):
|
||||
select thread(id, name as title)
|
||||
where id == ?threadId
|
||||
limit 1
|
||||
```
|
||||
|
||||
#### `fromQueryHook` Column Hooks
|
||||
|
||||
Typed queries deserialize each selected column through `fromQueryHook`. You can overload this hook for your own field or scalar destination types:
|
||||
|
||||
```nim
|
||||
import ormin/query_hooks
|
||||
|
||||
type
|
||||
TitleLength = distinct int
|
||||
|
||||
ThreadTitleSize = object
|
||||
id: int
|
||||
title: TitleLength
|
||||
|
||||
proc fromQueryHook*(val: var TitleLength, value: string) =
|
||||
val = TitleLength(value.len)
|
||||
|
||||
let rows = query(ThreadTitleSize):
|
||||
select thread(id, name as title)
|
||||
```
|
||||
|
||||
If a hook needs to handle SQL `NULL` itself, accept a `DbValue[SourceType]`:
|
||||
|
||||
```nim
|
||||
type
|
||||
NullableTitle = distinct string
|
||||
|
||||
proc fromQueryHook*(val: var NullableTitle, value: DbValue[string]) =
|
||||
if value.isNull:
|
||||
val = NullableTitle("<untitled>")
|
||||
else:
|
||||
val = NullableTitle(value.value)
|
||||
```
|
||||
|
||||
These are column deserialization hooks. In object typed queries, Ormin calls `fromQueryHook` separately for each selected column that maps to a destination field; it does not currently call a hook for the entire row object. For whole-row transformations, query into an intermediate typed result and convert it in regular Nim code.
|
||||
|
||||
### JSON and Raw SQL
|
||||
|
||||
JSON values can be spliced directly using `%` expressions. The `%` prefix tells Ormin to treat the following Nim expression as a `JsonNode` without conversion:
|
||||
|
||||
```nim
|
||||
import json
|
||||
let payload = %*{"id": %*1, "meta": %*{"tags": %*["nim", "orm"]}}
|
||||
|
||||
# Use JSON in WHERE clause
|
||||
let rows = query:
|
||||
select post(id, title)
|
||||
where id == %payload["id"]
|
||||
produce json
|
||||
|
||||
# Insert a row using JSON fields
|
||||
query:
|
||||
insert post(id = %payload["id"], title = %payload["title"], info = %payload["meta"])
|
||||
```
|
||||
|
||||
`!!"RAW"` injects a literal SQL fragment for vendor-specific functions or clauses that Ormin does not know about:
|
||||
|
||||
```nim
|
||||
query:
|
||||
update users(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?userId
|
||||
```
|
||||
|
||||
The tests include additional samples of JSON parameters and raw SQL expressions.
|
||||
|
||||
### Custom SQL Functions
|
||||
|
||||
Use the `{.importSql.}` pragma to tell Ormin about additional SQL functions that your database provides. Declare a Nim proc or func that mirrors the SQL signature and mark it with the pragma; the declaration does not need an implementation because Ormin only uses it to register the function for the query DSL.
|
||||
|
||||
```nim
|
||||
proc substr(s: string; start, length: int): string {.importSql.}
|
||||
|
||||
let name = "foo"
|
||||
let rows = query:
|
||||
select tb_string(substr(typstring, 1, 5))
|
||||
where substr(typstring, 1, 5) == ?name
|
||||
```
|
||||
|
||||
Imported functions participate in compile-time checking for arity and return type so they can be composed with regular Ormin expressions.
|
||||
|
||||
**Limitation:** argument types are currently not validated, so using mismatched parameter types still compiles—ensure the arguments you pass match what the underlying SQL function expects.
|
||||
|
||||
## Transactions and Batching
|
||||
|
||||
Use `transaction:` to run multiple queries atomically. The block commits on success and rolls back on any exception. Nesting is supported via savepoints. `tryTransaction:` behaves the same but returns `bool` (false on database errors) without raising.
|
||||
|
||||
Examples:
|
||||
|
||||
```nim
|
||||
# Commit on success
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(1), name = ?"alice", password = ?"p", email = ?"a@x", salt = ?"s", status = ?"ok")
|
||||
query:
|
||||
update thread(views = views + 1)
|
||||
where id == ?(42)
|
||||
|
||||
# Rollback on error
|
||||
let ok = tryTransaction:
|
||||
query:
|
||||
insert person(id = ?(2), name = ?"bob", password = ?"p", email = ?"b@x", salt = ?"s", status = ?"ok")
|
||||
# Primary key violation => entire block is rolled back, ok = false
|
||||
query:
|
||||
insert person(id = ?(2), name = ?"duplicate", password = ?"p", email = ?"d@x", salt = ?"s", status = ?"x")
|
||||
|
||||
# Nested transactions via savepoints
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(3), name = ?"carol", password = ?"p", email = ?"c@x", salt = ?"s", status = ?"ok")
|
||||
let innerOk = tryTransaction:
|
||||
# This will fail and roll back to the savepoint
|
||||
query:
|
||||
insert person(id = ?(3), name = ?"duplicate", password = ?"p", email = ?"d@x", salt = ?"s", status = ?"x")
|
||||
doAssert innerOk == false
|
||||
# Continue outer transaction normally
|
||||
```
|
||||
|
||||
PostgreSQL and SQLite are supported. The macros use `BEGIN/COMMIT/ROLLBACK` for the outermost transaction and `SAVEPOINT/RELEASE/ROLLBACK TO` for nested scopes.
|
||||
|
||||
## Reusable Procedures and Iterators
|
||||
|
||||
`createProc` turns a query into a procedure that returns all rows at once:
|
||||
|
||||
```nim
|
||||
createProc postsByAuthor:
|
||||
select post(id, title)
|
||||
where author == ?userId
|
||||
|
||||
let posts = db.postsByAuthor(userId)
|
||||
```
|
||||
|
||||
`createIter` emits an iterator that yields rows lazily:
|
||||
|
||||
```nim
|
||||
createIter postsIter:
|
||||
select post(id, title)
|
||||
where author == ?userId
|
||||
|
||||
for row in db.postsIter(userId):
|
||||
echo row.title
|
||||
```
|
||||
|
||||
Both forms accept parameters matching the `?`/`%` placeholders and produce the same return types as an inline `query` block.
|
||||
|
||||
Inline `query` blocks resolve `db` from the current lexical scope, so a proc parameter or local `db` binding can override the global connection when needed. This is useful for making procs which need to do complex handling:
|
||||
|
||||
```nim
|
||||
proc loadUser(db: DbConn; userId: int): User =
|
||||
let row = query:
|
||||
select user(id, name, email)
|
||||
where id == ?userId
|
||||
limit 1
|
||||
|
||||
User(id: row.id, name: row.name, email: row.email)
|
||||
```
|
||||
|
||||
## Running Arbitrary SQL
|
||||
|
||||
The standard `db_connector` APIs can be imported and used. For example:
|
||||
|
||||
```nim
|
||||
discard db.getValue(sql"select setval('antibot_id_seq', 10, false)")
|
||||
```
|
||||
|
||||
## Additional Facilities
|
||||
|
||||
- **Protocol DSL** – The `protocol` macro lets you describe paired server/client handlers that communicate via JSON messages. Sections use keywords like `recv`, `broadcast` and `send`, and every server block must be mirrored by a client block. The chat example demonstrates this code generation.
|
||||
- **JSON Dispatcher** – `createDispatcher` constructs a dispatcher for textual commands mapped to Nim procedures.
|
||||
- **WebSocket Server** – `serverws` provides a small WebSocket server that can broadcast messages to selected receivers via the `serve` proc.
|
||||
|
||||
## Tooling
|
||||
|
||||
The repository ships with `tools/ormin_importer`, used by the default `importModel` path, to parse SQL schema files into Nim type information and write the generated `.nim` model file.
|
||||
|
||||
## Examples
|
||||
|
||||
The `examples/` directory contains small applications (chat, forum, tweeter) demonstrating schema import, query blocks and the protocol/WebSocket features.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the full test suite via Nimble:
|
||||
|
||||
```bash
|
||||
nimble test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Ormin is released under the MIT license.
|
||||
@@ -0,0 +1,47 @@
|
||||
switch("nimcache", ".nimcache")
|
||||
|
||||
task buildimporter, "Build ormin_importer":
|
||||
exec "nim c -o:./tools/ormin_importer tools/ormin_importer"
|
||||
|
||||
task clean, "Clean generated files":
|
||||
rmFile("tests/forum_model_sqlite.nim")
|
||||
rmFile("tests/model_sqlite.nim")
|
||||
|
||||
rmFile("tests/forum_model_postgres.nim")
|
||||
rmFile("tests/model_postgre.nim")
|
||||
|
||||
task test, "Run all test suite":
|
||||
buildimporterTask()
|
||||
cleanTask()
|
||||
|
||||
exec "nim c -f -r tests/tfeature"
|
||||
exec "nim c -f -r tests/tcommon"
|
||||
exec "nim c -f -r -d:release tests/tquery_types"
|
||||
exec "nim c -f -r tests/tsqlite"
|
||||
exec "nim c -f -r tests/tdb_utils"
|
||||
exec "nim c -f -r tests/timportstatic"
|
||||
|
||||
task setup_postgres, "Ensure local Postgres has test DB/user":
|
||||
# Use a simple script to avoid Nim/psql quoting pitfalls
|
||||
exec "bash -lc 'bash tools/setup_postgres.sh'"
|
||||
|
||||
task test_postgres, "Run PostgreSQL test suite":
|
||||
cleanTask()
|
||||
buildimporterTask()
|
||||
# setup_postgresTask()
|
||||
# Pre-generate Postgres models to avoid include timing issues
|
||||
exec "./tools/ormin_importer tests/forum_model_postgres.sql"
|
||||
exec "./tools/ormin_importer tests/model_postgre.sql"
|
||||
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:postgre tests/tfeature"
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:postgre tests/tcommon"
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:release -d:postgre tests/tquery_types"
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:postgre tests/tpostgre"
|
||||
|
||||
task buildexamples, "Build examples: chat and forum":
|
||||
buildimporterTask()
|
||||
selfExec "c examples/chat/server"
|
||||
selfExec "js examples/chat/frontend"
|
||||
selfExec "c examples/forum/forum"
|
||||
selfExec "c examples/forum/forumproto"
|
||||
selfExec "c examples/tweeter/src/tweeter"
|
||||
@@ -0,0 +1,35 @@
|
||||
## Basic Ormin + BaraDB example
|
||||
##
|
||||
## Run with:
|
||||
## nim c -r examples/baradb_basic.nim
|
||||
##
|
||||
## Requires a BaraDB server on localhost:9472.
|
||||
|
||||
import ormin
|
||||
|
||||
importModel(DbBackend.baradb, "baradb_model")
|
||||
|
||||
let db {.global.} = open("127.0.0.1:9472", "admin", "", "default")
|
||||
|
||||
proc listUsers() =
|
||||
let rows = query:
|
||||
select users(id, name, email)
|
||||
orderby id
|
||||
for r in rows:
|
||||
echo "User #", r.id, ": ", r.name, " <", r.email, ">"
|
||||
|
||||
proc findUserByName(name: string) =
|
||||
let row = query:
|
||||
select users(id, name, email)
|
||||
where name == ?name
|
||||
limit 1
|
||||
echo "Found: ", row
|
||||
|
||||
proc insertUser(name, email: string; age: int) =
|
||||
query:
|
||||
insert users(name = ?name, email = ?email, age = ?age)
|
||||
|
||||
when isMainModule:
|
||||
listUsers()
|
||||
findUserByName("alice")
|
||||
insertUser("bob", "bob@example.com", 30)
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
age INT
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
create table if not exists users(
|
||||
id integer primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
lastOnline timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
/* Names need to be unique: */
|
||||
create unique index if not exists UserNameIx on users(name);
|
||||
|
||||
create table if not exists messages(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
content varchar(1000) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
|
||||
foreign key (author) references users(id)
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import strutils, db_sqlite
|
||||
|
||||
var db = open(connection="chat.db", user="araq", password="",
|
||||
database="chat")
|
||||
let model = readFile("chat_model.sql")
|
||||
for m in model.split(';'):
|
||||
if m.strip != "":
|
||||
db.exec(sql(m), [])
|
||||
db.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
## Frontend for our chat application example.
|
||||
## You need to have 'karax' installed in order for this to work.
|
||||
|
||||
import karax / [kbase, karax, vdom, karaxdsl, kajax, jwebsockets, jjson,
|
||||
jstrutils, errors]
|
||||
|
||||
# Custom UI element with input validation:
|
||||
|
||||
proc validateNotEmpty(field: kstring): proc () =
|
||||
result = proc () =
|
||||
let x = getVNodeById(field)
|
||||
if x.text.isNil or x.text == "":
|
||||
setError(field, field & " must not be empty")
|
||||
else:
|
||||
setError(field, "")
|
||||
|
||||
proc loginField(desc, field, class: kstring;
|
||||
validator: proc (field: kstring): proc ()): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
label(`for` = field):
|
||||
text desc
|
||||
input(`type` = class, id = field, onchange = validator(field))
|
||||
|
||||
# here we setup the connection to the server:
|
||||
let conn = newWebSocket("ws://localhost:8080", "orminchat")
|
||||
|
||||
proc send(msg: JsonNode) =
|
||||
# The Ormin "protocol" requires us to have 'send' implementation.
|
||||
conn.send(toJson(msg))
|
||||
|
||||
type User* = ref object
|
||||
name*, password: kstring
|
||||
|
||||
const
|
||||
username = kstring"username"
|
||||
password = kstring"password"
|
||||
message = kstring"message"
|
||||
|
||||
# Include the 'chatclient' helper that Ormin produced for us:
|
||||
include chatclient
|
||||
|
||||
template loggedIn(): bool = userId > 0
|
||||
|
||||
proc doLogin() =
|
||||
registerUser(User(name: getVNodeById(username).text,
|
||||
password: getVNodeById(password).text))
|
||||
|
||||
proc doSendMessage() =
|
||||
let inputField = getVNodeById(message)
|
||||
sendMessage(TextMessage(author: userId, content: inputField.text))
|
||||
inputField.setInputText ""
|
||||
|
||||
proc registerOnUpdate() =
|
||||
conn.onmessage =
|
||||
proc (e: MessageEvent) =
|
||||
let msg = fromJson[JsonNode](e.data)
|
||||
# 'recvMsg' was generated for us:
|
||||
recvMsg(msg)
|
||||
karax.redraw()
|
||||
|
||||
proc main(): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
tdiv:
|
||||
if not loggedIn:
|
||||
loginField("Name :", username, "input", validateNotEmpty)
|
||||
loginField("Password: ", password, "password", validateNotEmpty)
|
||||
button(onclick = doLogin, disabled = disableOnError()):
|
||||
text "Login"
|
||||
p:
|
||||
text getError(username)
|
||||
p:
|
||||
text getError(password)
|
||||
tdiv:
|
||||
table:
|
||||
for m in allMessages:
|
||||
tr:
|
||||
td:
|
||||
bold:
|
||||
text m.name
|
||||
td:
|
||||
text m.content
|
||||
tdiv:
|
||||
if loggedIn:
|
||||
label(`for` = message):
|
||||
text "Message: "
|
||||
input(class = "input", id = message, onkeyupenter = doSendMessage)
|
||||
|
||||
registerOnUpdate()
|
||||
runLater proc() =
|
||||
getRecentMessages()
|
||||
|
||||
setRenderer(main)
|
||||
@@ -0,0 +1,96 @@
|
||||
import ../../ormin / [serverws]
|
||||
import ../../ormin
|
||||
|
||||
import json
|
||||
|
||||
# Ormin needs to know about our SQL model:
|
||||
importModel(DbBackend.sqlite, "chat_model")
|
||||
|
||||
# Currently Ormin assumes a global database connection that needs to be
|
||||
# annotated as '.global' for technical reasons. Later versions will improve.
|
||||
var db {.global.} = open("chat.db", "", "", "")
|
||||
|
||||
# Ormin produces the file "chatclient.nim" for our frontend:
|
||||
protocol "chatclient.nim":
|
||||
# A 'common' code section is shared by both the client and the server:
|
||||
common:
|
||||
when not defined(js):
|
||||
type kstring = string
|
||||
type
|
||||
inetType = kstring
|
||||
varcharType = kstring
|
||||
timestampType = kstring
|
||||
intType = int
|
||||
|
||||
|
||||
server "get recent messages":
|
||||
let lastMessages = query:
|
||||
select messages(content, creation, author)
|
||||
join users(name)
|
||||
orderby desc(creation)
|
||||
limit 100
|
||||
send(lastMessages)
|
||||
|
||||
client "get recent messages":
|
||||
type TextMessage* = ref object
|
||||
# Ormin fills in the fields of 'TextMessage' for us, based on the
|
||||
# query in the 'server' part.
|
||||
var allMessages*: seq[TextMessage] = @[]
|
||||
proc getRecentMessages*()
|
||||
allMessages = recv()
|
||||
|
||||
|
||||
server "send chat message":
|
||||
let userId = arg["author"].num
|
||||
# unregistered users cannot send anything:
|
||||
if userId == 0: return
|
||||
query:
|
||||
insert messages(content = %arg["content"], author = ?userId)
|
||||
query:
|
||||
update users(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?userId
|
||||
|
||||
let lastMessage = query:
|
||||
select messages(content, creation, author)
|
||||
join users(name)
|
||||
orderby desc(creation)
|
||||
limit 1
|
||||
|
||||
broadcast(lastMessage)
|
||||
|
||||
client "send chat message":
|
||||
proc sendMessage*(m: TextMessage)
|
||||
allMessages.add recv(TextMessage)
|
||||
|
||||
|
||||
# This is the request to register/login a new user:
|
||||
server "register/login a new user":
|
||||
var candidates = query:
|
||||
produce nim
|
||||
select users(id, password)
|
||||
where name == %arg["name"]
|
||||
if candidates.len == 0:
|
||||
let userId = query:
|
||||
produce nim
|
||||
insert users(name = %arg["name"], password = %arg["password"])
|
||||
returning id
|
||||
send(%userId)
|
||||
else:
|
||||
block search:
|
||||
for c in candidates:
|
||||
if c[1] == arg["password"].str:
|
||||
send(%c[0])
|
||||
echo "login found!"
|
||||
break search
|
||||
# invalid login:
|
||||
send(%0)
|
||||
echo "no such user!"
|
||||
|
||||
client "register/login a new user":
|
||||
proc registerUser*(u: User)
|
||||
var userId: int
|
||||
userId = recv()
|
||||
if userId == 0: setError(username, "invalid login!")
|
||||
|
||||
|
||||
serve "orminchat", dispatch
|
||||
@@ -0,0 +1,2 @@
|
||||
--define: ssl
|
||||
--path:"$projectDir/../../../ormin"
|
||||
@@ -0,0 +1,2 @@
|
||||
--define: ssl
|
||||
--path:"$projectDir/../../../ormin"
|
||||
@@ -0,0 +1,160 @@
|
||||
import ../../ormin, json
|
||||
|
||||
importModel(DbBackend.sqlite, "forum_model")
|
||||
|
||||
var db {.global.} = open("stuff", "", "", "")
|
||||
|
||||
#var db: DbConn
|
||||
#proc getPrepStmt(idx: int): PStmt
|
||||
|
||||
#var gPrepStmts: array[N, cstring]
|
||||
|
||||
type inetType = string
|
||||
|
||||
const
|
||||
id = 90
|
||||
ip = "moo"
|
||||
answer = "dunno"
|
||||
pw = "mypw"
|
||||
email = "some@body.com"
|
||||
salt = "pepper"
|
||||
name = "me"
|
||||
limit = 10
|
||||
offset = 5
|
||||
|
||||
let threads = query:
|
||||
select thread(id, name, views, modified)
|
||||
where id in (select post(thread) where author in
|
||||
(select person(id) where status notin ("Spammer") or id == ?id))
|
||||
orderby desc(modified)
|
||||
limit ?limit
|
||||
offset ?offset
|
||||
|
||||
let thisThread = tryQuery:
|
||||
select thread(id)
|
||||
where id == ?id
|
||||
|
||||
createIter allThreadIds:
|
||||
select thread(id)
|
||||
where id == ?id
|
||||
|
||||
query:
|
||||
delete antibot
|
||||
where ip == ?ip
|
||||
|
||||
query:
|
||||
insert antibot(?ip, ?answer)
|
||||
|
||||
let something = query:
|
||||
select antibot(answer & answer, (if ip == "hi": 0 else: 1))
|
||||
where ip == ?ip and answer =~ "%things%"
|
||||
orderby desc(ip)
|
||||
limit 1
|
||||
|
||||
let myNewPersonId: int = query:
|
||||
insert person(?name, password = ?pw, ?email, ?salt, status = !!"'EmailUnconfirmed'",
|
||||
lastOnline = !!"DATETIME('now')")
|
||||
returning id
|
||||
|
||||
query:
|
||||
delete session
|
||||
where ip == ?ip and password == ?pw
|
||||
|
||||
query:
|
||||
update session(lastModified = !!"DATETIME('now')")
|
||||
where ip == ?ip and password == ?pw
|
||||
|
||||
let myj = %*{"pw": "stuff here"}
|
||||
|
||||
let userId1 = query:
|
||||
select session(userId)
|
||||
where ip == ?ip and password == %myj["pw"]
|
||||
|
||||
let (name9, email9, status, ban) = query:
|
||||
select person(name, email, status, ban)
|
||||
where id == ?id
|
||||
limit 1
|
||||
|
||||
let (idg, nameg, pwg, emailg, creationg, saltg, statusg, lastOnlineg, bang) = query:
|
||||
select person(_)
|
||||
where id == ?id
|
||||
limit 1
|
||||
|
||||
query:
|
||||
update person(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?id
|
||||
|
||||
query:
|
||||
update thread(views = views + 1, modified = !!"DATETIME('now')")
|
||||
where id == ?id
|
||||
|
||||
query:
|
||||
delete thread
|
||||
where id notin (select post(thread))
|
||||
|
||||
let (author, creation) = query:
|
||||
select post(author)
|
||||
join person(creation)
|
||||
limit 1
|
||||
|
||||
let (authorB, creationB) = query:
|
||||
select post(author)
|
||||
join person(creation) on author == id
|
||||
limit 1
|
||||
|
||||
let allPosts = query:
|
||||
select post(count(_) as cnt)
|
||||
where cnt > 0
|
||||
produce json
|
||||
limit 1
|
||||
|
||||
createProc getAllThreadIds:
|
||||
select thread(id)
|
||||
where id == ?id
|
||||
produce json
|
||||
|
||||
let totalThreads = query:
|
||||
select thread(count(_))
|
||||
where id in (select post(thread) where author == ?id and id in (
|
||||
select post(min(id)) groupby thread))
|
||||
limit 1
|
||||
|
||||
#query:
|
||||
# update thread(modified = (select post(creation) where post.thread == ?thread
|
||||
# orderby creation desc limit 1 ))
|
||||
|
||||
#[
|
||||
# Check if post is the first post of the thread.
|
||||
let rows = db.getAllRows(sql("select id, thread, creation from post " &
|
||||
"where thread = ? order by creation asc"), $c.threadId)
|
||||
|
||||
proc rateLimitCheck(c: var TForumData): bool =
|
||||
sql("SELECT count(*) FROM post where author = ? and " &
|
||||
"(strftime('%s', 'now') - strftime('%s', creation)) < 40")
|
||||
sql("SELECT count(*) FROM post where author = ? and " &
|
||||
"(strftime('%s', 'now') - strftime('%s', creation)) < 90")
|
||||
sql("SELECT count(*) FROM post where author = ? and " &
|
||||
"(strftime('%s', 'now') - strftime('%s', creation)) < 300")
|
||||
|
||||
sql "insert into thread(name, views, modified) values (?, 0, DATETIME('now'))"
|
||||
sql "select id, name, password, email, salt, status, ban from person where name = ?"
|
||||
sql "insert into session (ip, password, userid) values (?, ?, ?)",
|
||||
sql"select password, salt, strftime('%s', lastOnline) from person where name = ?"
|
||||
sql("delete from post where author = (select id from person where name = ?)")
|
||||
sql("update person set status = ?, ban = ? where name = ?")
|
||||
sql("update person set password = ?, salt = ? where name = ?")
|
||||
sql"select count(*) from person"
|
||||
sql"select count(*) from post"
|
||||
sql"select count(*) from thread"
|
||||
sql"select id, name, strftime('%s', lastOnline), strftime('%s', creation) from person"
|
||||
|
||||
sql"select count(*) from post where thread = ?"
|
||||
sql"select count(*) from post p, person u where u.id = p.author and p.thread = ?"
|
||||
sql"select name from thread where id = ?"
|
||||
sql"select id from person where name = ?"
|
||||
sql"select count(*) from post where author = ?"
|
||||
sql("select count(*) from thread where id in (select thread from post where" &
|
||||
" author = ? and post.id in (select min(id) from post group by thread))")
|
||||
sql"""select strftime('%s', lastOnline), email, ban, status
|
||||
from person where id = ?"""
|
||||
]#
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
create table if not exists thread(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create unique index if not exists ThreadNameIx on thread (name);
|
||||
|
||||
create table if not exists person(
|
||||
id integer primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
email varchar(30) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
salt varchar(128) not null,
|
||||
status varchar(30) not null,
|
||||
lastOnline timestamp not null default (DATETIME('now')),
|
||||
ban varchar(128) not null default ''
|
||||
);
|
||||
|
||||
create unique index if not exists UserNameIx on person (name);
|
||||
|
||||
create table if not exists post(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
ip inet not null,
|
||||
header varchar(100) not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists session(
|
||||
id integer primary key,
|
||||
ip inet not null,
|
||||
password varchar(32) not null,
|
||||
userid integer not null,
|
||||
lastModified timestamp not null default (DATETIME('now')),
|
||||
foreign key (userid) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists antibot(
|
||||
id integer primary key,
|
||||
ip inet not null,
|
||||
answer varchar(30) not null,
|
||||
created timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create index PersonStatusIdx on person(status);
|
||||
create index PostByAuthorIdx on post(thread, author);
|
||||
@@ -0,0 +1,37 @@
|
||||
import ../../ormin, ../../ormin/serverws, json
|
||||
|
||||
importModel(DbBackend.sqlite, "forum_model")
|
||||
|
||||
var db {.global.} = open("stuff", "", "", "")
|
||||
|
||||
protocol "forumclient.nim":
|
||||
common:
|
||||
when defined(js):
|
||||
type kstring = cstring
|
||||
else:
|
||||
type kstring = string
|
||||
type
|
||||
inetType = kstring
|
||||
varcharType = kstring
|
||||
timestampType = kstring
|
||||
server:
|
||||
query:
|
||||
delete antibot
|
||||
where ip == %arg
|
||||
client:
|
||||
proc deleteAntibot(ip: string)
|
||||
server:
|
||||
query:
|
||||
update session(lastModified = !!"DATETIME('now')")
|
||||
where ip == %arg["ip"] and password == %arg["pw"]
|
||||
client:
|
||||
proc updateSession(arg: Session)
|
||||
server:
|
||||
let allSessions = query:
|
||||
select session(_)
|
||||
send(allSessions)
|
||||
client:
|
||||
type Session = ref object
|
||||
var gSessions: seq[Session]
|
||||
gSessions = recv()
|
||||
proc getAllSessions()
|
||||
@@ -0,0 +1,117 @@
|
||||
body {
|
||||
background-color: #f1f9ea;
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
}
|
||||
|
||||
div#main {
|
||||
width: 80%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
div#user {
|
||||
background-color: #66ac32;
|
||||
width: 100%;
|
||||
color: #c7f0aa;
|
||||
padding: 5pt;
|
||||
}
|
||||
|
||||
div#user > h1 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
padding-left: 10pt;
|
||||
padding-right: 10pt;
|
||||
}
|
||||
|
||||
div#user > form {
|
||||
float: right;
|
||||
margin-right: 10pt;
|
||||
}
|
||||
|
||||
div#user > form > input[type="submit"] {
|
||||
border: 0px none;
|
||||
padding: 5pt;
|
||||
font-size: 108%;
|
||||
color: #ffffff;
|
||||
background-color: #515d47;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div#user > form > input[type="submit"]:hover {
|
||||
background-color: #538c29;
|
||||
}
|
||||
|
||||
|
||||
div#messages {
|
||||
background-color: #a2dc78;
|
||||
width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
div#messages > div {
|
||||
border-left: 1px solid #869979;
|
||||
border-right: 1px solid #869979;
|
||||
border-bottom: 1px solid #869979;
|
||||
padding: 5pt;
|
||||
}
|
||||
|
||||
div#messages > div > a, div#messages > div > span {
|
||||
color: #475340;
|
||||
}
|
||||
|
||||
div#messages > div > a:hover {
|
||||
text-decoration: none;
|
||||
color: #c13746;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
div#login {
|
||||
width: 200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 20%;
|
||||
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
div#login span.small {
|
||||
display: block;
|
||||
font-size: 56%;
|
||||
}
|
||||
|
||||
div#newMessage {
|
||||
background-color: #538c29;
|
||||
width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
color: #ffffff;
|
||||
padding: 5pt;
|
||||
}
|
||||
|
||||
div#newMessage span {
|
||||
padding-right: 5pt;
|
||||
}
|
||||
|
||||
div#newMessage form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
div#newMessage > form > input[type="text"] {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
div#newMessage > form > input[type="submit"] {
|
||||
font-size: 80%;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Tweeter example
|
||||
|
||||
Modified from [tweeter example](https://github.com/dom96/nim-in-action-code/tree/master/Chapter7/Tweeter) in the "nim in action" book, replace the database layer with ormin.
|
||||
@@ -0,0 +1,2 @@
|
||||
--define: ssl
|
||||
--path:"$projectDir/../../../../ormin"
|
||||
@@ -0,0 +1,11 @@
|
||||
import db_sqlite, os, strutils
|
||||
|
||||
let db {.global.} = open("tweeter.db", "", "", "")
|
||||
|
||||
let sqlFile = readFile(currentSourcePath.parentDir() / "tweeter_model.sql")
|
||||
for t in sqlFile.split(';'):
|
||||
if t.strip() != "":
|
||||
db.exec(sql(t))
|
||||
|
||||
echo("Database created successfully!")
|
||||
db.close()
|
||||
@@ -0,0 +1,51 @@
|
||||
import times, strutils, strformat, sequtils
|
||||
from db_connector/db_sqlite import instantRows, `[]`
|
||||
import ../../../ormin
|
||||
import model
|
||||
|
||||
importModel(DbBackend.sqlite, "tweeter_model")
|
||||
var db {.global.} = open("tweeter.db", "", "", "")
|
||||
|
||||
proc findUser*(username: string, user: var User): bool =
|
||||
let res = query:
|
||||
select user(username)
|
||||
where username == ?username
|
||||
echo res
|
||||
if res.len == 0: return false
|
||||
else: user.username = res[0]
|
||||
|
||||
let following = query:
|
||||
select following(followed_user)
|
||||
where follower == ?username
|
||||
user.following = following.filterIt(it.len != 0)
|
||||
|
||||
return true
|
||||
|
||||
proc create*(user: User) =
|
||||
query:
|
||||
insert user(username = ?user.username)
|
||||
|
||||
proc post*(message: Message) =
|
||||
if message.msg.len > 140:
|
||||
raise newException(ValueError, "Message has to be less than 140 characters.")
|
||||
query:
|
||||
insert message(username = ?message.username,
|
||||
time = ?message.time,
|
||||
msg = ?message.msg)
|
||||
|
||||
proc follow*(follower, user: User) =
|
||||
query:
|
||||
insert following(follower = ?follower.username, followed_user = ?user.username)
|
||||
|
||||
proc findMessage*(usernames: openArray[string], limit = 10): seq[Message] =
|
||||
result = @[]
|
||||
if usernames.len == 0: return
|
||||
var whereClause = "WHERE "
|
||||
for i in 0 ..< usernames.len:
|
||||
whereClause.add("trim(username) = ?")
|
||||
if i < usernames.len - 1:
|
||||
whereClause.add(" or ")
|
||||
|
||||
let s = &"SELECT username, time, msg FROM Message {whereClause} ORDER BY time DESC LIMIT {limit}"
|
||||
for row in db.instantRows(sql(s), usernames):
|
||||
result.add((username: row[0], time: row[1].parseInt, msg: row[2]))
|
||||
@@ -0,0 +1,11 @@
|
||||
type
|
||||
User* = tuple[
|
||||
username: string,
|
||||
following: seq[string]
|
||||
]
|
||||
|
||||
Message* = tuple[
|
||||
username: string,
|
||||
time: int,
|
||||
msg: string
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncdispatch, times
|
||||
import jester
|
||||
import database, model, views/[user, general]
|
||||
|
||||
proc userLogin(request: Request, user: var User): bool =
|
||||
if request.cookies.hasKey("username"):
|
||||
let username = request.cookies["username"]
|
||||
if not findUser(username, user):
|
||||
user = (username: username, following: @[])
|
||||
create(user)
|
||||
return true
|
||||
else:
|
||||
return false
|
||||
|
||||
routes:
|
||||
get "/":
|
||||
var user: User
|
||||
if userLogin(request, user):
|
||||
let messages = findMessage(user.following & user.username)
|
||||
resp renderMain(renderTimeline(user.username, messages))
|
||||
else:
|
||||
resp renderMain(renderLogin())
|
||||
|
||||
get "/@name":
|
||||
cond '.' notin @"name"
|
||||
var user: User
|
||||
if not findUser(@"name", user):
|
||||
halt "User not found"
|
||||
let messages = findMessage([user.username])
|
||||
|
||||
var currentUser: User
|
||||
if userLogin(request, currentUser):
|
||||
resp renderMain(renderUser(user, currentUser) & renderMessages(messages))
|
||||
else:
|
||||
resp renderMain(renderUser(user) & renderMessages(messages))
|
||||
|
||||
post "/follow":
|
||||
var follower, target: User
|
||||
if not findUser(@"follower", follower):
|
||||
halt "Follower not found"
|
||||
if not findUser(@"target", target):
|
||||
halt "Follow target not found"
|
||||
follow(follower, target)
|
||||
redirect uri("/" & @"target")
|
||||
|
||||
post "/login":
|
||||
setCookie("username", @"username", getTime().utc() + 2.hours)
|
||||
redirect "/"
|
||||
|
||||
post "/createMessage":
|
||||
let message = (
|
||||
username: @"username",
|
||||
time: getTime().toUnix().int,
|
||||
msg: @"message"
|
||||
)
|
||||
post(message)
|
||||
redirect "/"
|
||||
|
||||
runForever()
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by ormin_importer. DO NOT EDIT.
|
||||
|
||||
type
|
||||
Attr = object
|
||||
name: string
|
||||
tabIndex: int
|
||||
typ: DbTypekind
|
||||
key: int # 0 nothing special,
|
||||
# +1 -- primary key
|
||||
# -N -- references attribute N
|
||||
const tableNames = [
|
||||
"user",
|
||||
"following",
|
||||
"message"
|
||||
]
|
||||
|
||||
const attributes = [
|
||||
Attr(name: "username", tabIndex: 0, typ: dbVarchar, key: 1),
|
||||
Attr(name: "follower", tabIndex: 1, typ: dbVarchar, key: -1),
|
||||
Attr(name: "followed_user", tabIndex: 1, typ: dbVarchar, key: -1),
|
||||
Attr(name: "username", tabIndex: 2, typ: dbVarchar, key: -1),
|
||||
Attr(name: "time", tabIndex: 2, typ: dbInt, key: 0),
|
||||
Attr(name: "msg", tabIndex: 2, typ: dbVarchar, key: 0)
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS User(
|
||||
username text PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Following(
|
||||
follower text,
|
||||
followed_user text,
|
||||
PRIMARY KEY (follower, followed_user),
|
||||
FOREIGN KEY (follower) REFERENCES User(username),
|
||||
FOREIGN KEY (followed_user) REFERENCES User(username)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Message(
|
||||
username text,
|
||||
time integer,
|
||||
msg text NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES User(username)
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
#import xmltree
|
||||
#import ../model
|
||||
#import user
|
||||
#
|
||||
#proc `$!`(text: string): string = escape(text)
|
||||
#end proc
|
||||
#
|
||||
#proc renderMain*(body: string): string =
|
||||
# result = ""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tweeter written in Nim</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
#end proc
|
||||
#
|
||||
#proc renderLogin*(): string =
|
||||
# result = ""
|
||||
<div id="login">
|
||||
<span>Login</span>
|
||||
<span class="small">Please type in your username...</span>
|
||||
<form action="login" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderTimeline*(username: string, messages: openArray[Message]): string =
|
||||
# result = ""
|
||||
<div id="user">
|
||||
<h1>${$!username} timeline</h1>
|
||||
</div>
|
||||
<div id="newMessage">
|
||||
<span>New message</span>
|
||||
<form action="createMessage" method="post">
|
||||
<input type="text" name="message">
|
||||
<input type="hidden" name="username" value="${$!username}">
|
||||
<input type="submit" value="Tweet">
|
||||
</form>
|
||||
</div>
|
||||
${renderMessages(messages)}
|
||||
#end proc
|
||||
@@ -0,0 +1,41 @@
|
||||
#? stdtmpl(subsChar = '$', metaChar = '#', toString = "xmltree.escape")
|
||||
#import xmltree
|
||||
#import times
|
||||
#import "../model"
|
||||
#
|
||||
#proc renderUser*(user: User): string =
|
||||
# result = ""
|
||||
<div id="user">
|
||||
<h1>${user.username}</h1>
|
||||
<span>Following: ${$user.following.len}</span>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderUser*(user, currentUser: User): string =
|
||||
# result = ""
|
||||
<div id="user">
|
||||
<h1>${user.username}</h1>
|
||||
<span>Following: ${$user.following.len}</span>
|
||||
#if user.username notin currentUser.following and user.username != currentUser.username:
|
||||
<form action="follow" method="post">
|
||||
<input type="hidden" name="follower" value="${currentUser.username}">
|
||||
<input type="hidden" name="target" value="${user.username}">
|
||||
<input type="submit" value="Follow">
|
||||
</form>
|
||||
#end if
|
||||
</div>
|
||||
#
|
||||
#end proc
|
||||
#
|
||||
#proc renderMessages*(messages: openArray[Message]): string =
|
||||
# result = ""
|
||||
<div id="messages">
|
||||
#for message in messages:
|
||||
<div>
|
||||
<a href="/${message.username}">${message.username}</a>
|
||||
<span>${message.time.fromUnix().format("HH:mm MMMM d',' yyyy")}</span>
|
||||
<h3>${message.msg}</h3>
|
||||
</div>
|
||||
#end for
|
||||
</div>
|
||||
#end proc
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 Andreas Rumpf
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,49 @@
|
||||
type
|
||||
DbBackend* {.pure.} = enum
|
||||
postgre, sqlite, mysql, baradb
|
||||
|
||||
import os
|
||||
from ormin/importer_core import generateModelCode, ImportTarget
|
||||
|
||||
template importModel*(backend: DbBackend, filename: string, includeStatic: static[bool] = false) {.dirty.} =
|
||||
## imports a model from an SQL file.
|
||||
bind fileExists, parentDir, `/`
|
||||
bind generateModelCode, ImportTarget
|
||||
const file = static:
|
||||
let path = parentDir(instantiationInfo(-1, true)[0])
|
||||
path / filename & ".sql"
|
||||
const importedFile = static:
|
||||
if not includeStatic:
|
||||
let res =
|
||||
if fileExists("./tools/ormin_importer"):
|
||||
gorgeEx("./tools/ormin_importer " & file)
|
||||
else:
|
||||
# run ormin_importer from the PATH
|
||||
gorgeEx("ormin_importer " & file)
|
||||
if res.exitCode != 0:
|
||||
raise newException(Exception, "Failed to generate model: " & res.output)
|
||||
file
|
||||
{.warning: "Imported SQL Model: " & importedFile.}
|
||||
|
||||
const dbBackend = backend
|
||||
|
||||
import db_connector/db_common
|
||||
when includeStatic:
|
||||
import ormin/db_utils
|
||||
|
||||
when dbBackend == DbBackend.sqlite:
|
||||
import ormin/ormin_sqlite
|
||||
elif dbBackend == DbBackend.postgre:
|
||||
import ormin/ormin_postgre
|
||||
elif dbBackend == DbBackend.mysql:
|
||||
import ormin/ormin_mysql
|
||||
elif dbBackend == DbBackend.baradb:
|
||||
import ormin/ormin_baradb
|
||||
else:
|
||||
{.error: "unknown database backend".}
|
||||
|
||||
when includeStatic:
|
||||
generateModelCode(staticRead(file), file, ImportTarget(ord(backend)), true)
|
||||
else:
|
||||
include filename
|
||||
include "ormin/queries"
|
||||
@@ -0,0 +1,23 @@
|
||||
# Package
|
||||
|
||||
version = "0.9.0"
|
||||
author = "Araq"
|
||||
description = "Prepared SQL statement generator. A lightweight ORM."
|
||||
license = "MIT"
|
||||
bin = @["tools/ormin_importer"]
|
||||
skipDirs = @["examples"]
|
||||
installExt = @["nim"]
|
||||
|
||||
# Dependencies
|
||||
requires "nim >= 2.0.0"
|
||||
requires "db_connector >= 0.1.0"
|
||||
|
||||
feature "examples":
|
||||
requires "websocket >= 0.2.2"
|
||||
requires "karax"
|
||||
requires "jester"
|
||||
|
||||
import std/os
|
||||
when fileExists("config.nims"):
|
||||
include "config.nims"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import std/strutils
|
||||
import db_connector/db_common
|
||||
|
||||
proc dbTypFromName*(name: string): DbTypeKind =
|
||||
var k = dbUnknown
|
||||
|
||||
case name.toLowerAscii
|
||||
of "int", "integer", "int8", "smallint", "int16",
|
||||
"longint", "int32", "int64", "tinyint", "hugeint": k = dbInt
|
||||
of "uint", "uint8", "uint16", "uint32", "uint64": k = dbUInt
|
||||
of "serial": k = dbSerial
|
||||
of "bit": k = dbBit
|
||||
of "bool", "boolean": k = dbBool
|
||||
of "blob": k = dbBlob
|
||||
of "fixedchar": k = dbFixedChar
|
||||
of "varchar", "text", "string": k = dbVarchar
|
||||
of "json": k = dbJson
|
||||
of "xml": k = dbXml
|
||||
of "decimal": k = dbDecimal
|
||||
of "float", "double", "longdouble", "real": k = dbFloat
|
||||
of "date", "day": k = dbDate
|
||||
of "time": k = dbTime
|
||||
of "datetime": k = dbDateTime
|
||||
of "timestamp", "timestamptz": k = dbTimestamp
|
||||
of "timeinterval": k = dbTimeInterval
|
||||
of "set": k = dbSet
|
||||
of "array": k = dbArray
|
||||
of "composite": k = dbComposite
|
||||
of "url", "uri": k = dbUrl
|
||||
of "uuid": k = dbUuid
|
||||
of "inet", "ip", "tcpip": k = dbInet
|
||||
of "mac", "macaddress": k = dbMacAddress
|
||||
of "geometry": k = dbGeometry
|
||||
of "point": k = dbPoint
|
||||
of "line": k = dbLine
|
||||
of "lseg": k = dbLseg
|
||||
of "box": k = dbBox
|
||||
of "path": k = dbPath
|
||||
of "polygon": k = dbPolygon
|
||||
of "circle": k = dbCircle
|
||||
else: discard
|
||||
|
||||
return k
|
||||
@@ -0,0 +1,179 @@
|
||||
import std/[os, paths]
|
||||
import db_connector/db_common, strutils, strformat
|
||||
import db_connector/db_postgres as db_postgres
|
||||
import db_connector/db_sqlite as db_sqlite
|
||||
|
||||
import parsesql_tmp # import std/parsesql
|
||||
|
||||
export paths
|
||||
|
||||
type
|
||||
DbConn = db_postgres.DbConn | db_sqlite.DbConn
|
||||
DbId* = distinct string
|
||||
DbSql* = distinct string
|
||||
|
||||
proc `$`*(dbId: DbId): string {.borrow.}
|
||||
proc `$`*(dbSql: DbSql): string {.borrow.}
|
||||
|
||||
template staticLoad*(filename: string): DbSql =
|
||||
bind isAbsolute, parentDir, `/`, instantiationInfo
|
||||
block:
|
||||
const schemaPath {.gensym.} = static:
|
||||
if isAbsolute(filename):
|
||||
filename
|
||||
else:
|
||||
parentDir(instantiationInfo(-1, true)[0]) / filename
|
||||
const schemaSql {.gensym.} = staticRead(schemaPath)
|
||||
static:
|
||||
try:
|
||||
discard parseSql(schemaSql)
|
||||
except SqlParseError as e:
|
||||
doAssert false, "Invalid SQL in " & schemaPath & ": " & e.msg
|
||||
DbSql(schemaSql)
|
||||
|
||||
proc parseQualifiedIdentifier(name: string): seq[(string, bool)] =
|
||||
var current = ""
|
||||
var quoteChar = '\0'
|
||||
var partWasQuoted = false
|
||||
var i = 0
|
||||
while i < name.len:
|
||||
let c = name[i]
|
||||
if quoteChar == '\0':
|
||||
case c
|
||||
of '.':
|
||||
result.add((current, partWasQuoted))
|
||||
current = ""
|
||||
partWasQuoted = false
|
||||
of '"', '\'', '`':
|
||||
if current.len == 0:
|
||||
quoteChar = c
|
||||
partWasQuoted = true
|
||||
else:
|
||||
current.add(c)
|
||||
else:
|
||||
current.add(c)
|
||||
else:
|
||||
if c == quoteChar:
|
||||
if i + 1 < name.len and name[i + 1] == quoteChar:
|
||||
current.add(c)
|
||||
inc(i)
|
||||
else:
|
||||
quoteChar = '\0'
|
||||
else:
|
||||
current.add(c)
|
||||
inc(i)
|
||||
|
||||
result.add((current, partWasQuoted))
|
||||
|
||||
proc canUseBareIdentifier(part: string): bool =
|
||||
if part.len == 0:
|
||||
return false
|
||||
if part[0] notin {'a'..'z', 'A'..'Z', '_'}:
|
||||
return false
|
||||
for c in part[1..^1]:
|
||||
if c notin {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||
return false
|
||||
true
|
||||
|
||||
proc quoteDbIdentifier*(name: string): DbId =
|
||||
## Quote a database identifier for direct interpolation into SQL text.
|
||||
## Dot-separated names are treated as qualified identifiers.
|
||||
var partsSql: seq[string] = @[]
|
||||
for (part, wasQuoted) in parseQualifiedIdentifier(name):
|
||||
let normalizedPart = if wasQuoted: part else: part.toLowerAscii()
|
||||
if wasQuoted or not canUseBareIdentifier(normalizedPart):
|
||||
partsSql.add("\"" & normalizedPart.replace("\"", "\"\"") & "\"")
|
||||
else:
|
||||
partsSql.add(normalizedPart)
|
||||
DbId(partsSql.join("."))
|
||||
|
||||
proc dropTableName(db: DbConn; tableName, lookupName: string) =
|
||||
# SQL parameters bind values, not identifiers, so DROP TABLE needs the
|
||||
# identifier rendered into the statement text with correct quoting.
|
||||
when defined(sqlite) and defined(orminLegacySqliteDropNames):
|
||||
db.exec(sql("drop table if exists " & lookupName))
|
||||
else:
|
||||
let tableIdentSql = quoteDbIdentifier(tableName)
|
||||
when defined(postgre):
|
||||
db.exec(sql("drop table if exists " & $tableIdentSql & " cascade"))
|
||||
else:
|
||||
db.exec(sql("drop table if exists " & $tableIdentSql))
|
||||
|
||||
iterator tableDefs(sql: DbSql): tuple[name, tableName, model: string] =
|
||||
# Parse the entire SQL file and iterate statements via the SQL parser
|
||||
var ast: SqlNode
|
||||
try:
|
||||
ast = parseSql($sql)
|
||||
except SqlParseError as e:
|
||||
echo "SQL Parse Error:\n", sql
|
||||
raise e
|
||||
|
||||
if ast.len > 0:
|
||||
# ast is a statement list; iterate each statement node
|
||||
for i in 0 ..< ast.len:
|
||||
let node = ast[i]
|
||||
if node.kind in {nkCreateTable, nkCreateTableIfNotExists}:
|
||||
yield (node[0].strVal.toLowerAscii(), $node[0], $node)
|
||||
else:
|
||||
# Fallback: ast might be a single statement (not a list)
|
||||
let node = ast
|
||||
if node.kind in {nkCreateTable, nkCreateTableIfNotExists}:
|
||||
yield (node[0].strVal.toLowerAscii(), $node[0], $node)
|
||||
|
||||
iterator tablePairs*(sql: string): tuple[name, model: string] =
|
||||
for name, _, model in tableDefs(DbSql(sql)):
|
||||
yield (name, model)
|
||||
|
||||
iterator tablePairs*(sql: static[DbSql]): tuple[name, model: string] =
|
||||
for name, _, model in tableDefs(sql):
|
||||
yield (name, model)
|
||||
|
||||
|
||||
iterator tablePairs*(sql: Path): tuple[name, model: string] =
|
||||
let f = readFile($sql)
|
||||
for n, m in tablePairs(f):
|
||||
yield (n, m)
|
||||
|
||||
proc createTable*(db: DbConn; sqlFile: Path) =
|
||||
for _, m in tablePairs(sqlFile):
|
||||
db.exec(sql(m))
|
||||
|
||||
proc createTable*(db: DbConn; sqlFile: Path, name: string) =
|
||||
for n, m in tablePairs(sqlFile):
|
||||
if n == name:
|
||||
db.exec(sql(m))
|
||||
return
|
||||
raiseAssert &"table: {name} not found in: {sqlFile}"
|
||||
|
||||
proc createTable*(db: DbConn; schemaSql: static[DbSql]) =
|
||||
for _, m in tablePairs(schemaSql):
|
||||
db.exec(sql(m))
|
||||
|
||||
proc createTable*(db: DbConn; schemaSql: static[DbSql], name: string) =
|
||||
for n, m in tablePairs(schemaSql):
|
||||
if n == name:
|
||||
db.exec(sql(m))
|
||||
return
|
||||
raiseAssert &"table: {name} not found in static schema"
|
||||
|
||||
proc dropTable*(db: DbConn; sqlFile: Path) =
|
||||
for lookupName, tableName, _ in tableDefs(DbSql(readFile($sqlFile))):
|
||||
db.dropTableName(tableName, lookupName)
|
||||
|
||||
proc dropTable*(db: DbConn; sqlFile: Path, name: string) =
|
||||
for lookupName, tableName, _ in tableDefs(DbSql(readFile($sqlFile))):
|
||||
if lookupName == name:
|
||||
db.dropTableName(tableName, lookupName)
|
||||
return
|
||||
raiseAssert &"table: {name} not found in: {sqlFile}"
|
||||
|
||||
proc dropTable*(db: DbConn; schemaSql: static[DbSql]) =
|
||||
for lookupName, tableName, _ in tableDefs(schemaSql):
|
||||
db.dropTableName(tableName, lookupName)
|
||||
|
||||
proc dropTable*(db: DbConn; schemaSql: static[DbSql], name: string) =
|
||||
for lookupName, tableName, _ in tableDefs(schemaSql):
|
||||
if lookupName == name:
|
||||
db.dropTableName(tableName, lookupName)
|
||||
return
|
||||
raiseAssert &"table: {name} not found in static schema"
|
||||
@@ -0,0 +1,71 @@
|
||||
## This module implements a little helper macro to ease
|
||||
## writing the dispatching logic for JSON based servers.
|
||||
|
||||
import macros
|
||||
|
||||
proc tbody(n: NimNode): NimNode =
|
||||
# transforms::
|
||||
# f fields(a, "b")
|
||||
# into::
|
||||
# f a = %args["a"], b = %args["b"]
|
||||
case n.kind
|
||||
of nnkCallKinds:
|
||||
result = copyNimNode(n)
|
||||
result.add tbody(n[0])
|
||||
for i in 1..<n.len:
|
||||
let it = n[i]
|
||||
if it.kind == nnkCall and $it[0] == "fields":
|
||||
for j in 1..<it.len:
|
||||
let field = it[j]
|
||||
let s = if field.kind in {nnkStrLit..nnkTripleStrLit}: field.strVal
|
||||
else: $field
|
||||
let arg = newTree(nnkPrefix, ident"%",
|
||||
newTree(nnkBracketExpr, ident"arg", newLit(s)))
|
||||
result.add newTree(nnkExprEqExpr, ident(s), arg)
|
||||
else:
|
||||
result.add tbody(it)
|
||||
else:
|
||||
result = copyNimNode(n)
|
||||
for x in n: result.add tbody(x)
|
||||
|
||||
macro createDispatcher*(name, n: untyped): untyped =
|
||||
expectKind n, nnkStmtList
|
||||
result = newStmtList()
|
||||
let disp = newTree(nnkCaseStmt, ident"cmd")
|
||||
template cmdProc(name, body) {.dirty.} =
|
||||
proc name(arg: JsonNode): JsonNode = body
|
||||
template callCmd(name) {.dirty.} =
|
||||
result = name(arg)
|
||||
|
||||
for x in n:
|
||||
if x.kind == nnkCall and x.len == 2 and
|
||||
x[0].kind == nnkIdent and x[1].kind == nnkStmtList:
|
||||
result.add getAst(cmdProc(x[0], tbody x[1]))
|
||||
disp.add newTree(nnkOfBranch, newLit($x[0]),
|
||||
newStmtList(getAst(callCmd(x[0]))))
|
||||
else:
|
||||
# do not touch:
|
||||
result.add n
|
||||
disp.add newTree(nnkElse, newStmtList(newTree(nnkDiscardStmt, newEmptyNode())))
|
||||
|
||||
template dispatchProc(name, body) {.dirty.} =
|
||||
proc dispatch(inp: JsonNode): JsonNode =
|
||||
let arg = inp["arg"]
|
||||
let cmd = inp["cmd"].getStr("")
|
||||
body
|
||||
result.add getAst(dispatchProc(name, disp))
|
||||
when defined(debugDispatcherDsl):
|
||||
echo repr result
|
||||
|
||||
when isMainModule:
|
||||
import json, db_sqlite
|
||||
|
||||
createDispatcher(dispatch):
|
||||
insertCustomer:
|
||||
echo "insert customer", fields(a, b, c)
|
||||
result = arg
|
||||
selectCustomers:
|
||||
echo "select customer"
|
||||
result = arg
|
||||
|
||||
echo dispatch(nil, %*{"cmd": "insertCustomer", "arg": [1, 2, 3]})
|
||||
@@ -0,0 +1,178 @@
|
||||
import std/[macros, strutils, tables]
|
||||
import db_connector/db_common
|
||||
|
||||
import ./db_types
|
||||
import ./parsesql_tmp
|
||||
|
||||
const
|
||||
FileHeader* = """
|
||||
# Generated by ormin_importer. DO NOT EDIT.
|
||||
|
||||
type
|
||||
Attr = object
|
||||
name: string
|
||||
tabIndex: int
|
||||
typ: DbTypekind
|
||||
key: int # 0 nothing special,
|
||||
# +1 -- primary key
|
||||
# -N -- references attribute N
|
||||
"""
|
||||
|
||||
type
|
||||
DbColumn* = object
|
||||
name*: string
|
||||
tableName*: string
|
||||
typ*: DbType
|
||||
primaryKey*: bool
|
||||
refs*: (string, string)
|
||||
DbColumns* = seq[DbColumn]
|
||||
|
||||
KnownTables* = OrderedTable[string, DbColumns]
|
||||
ImportTarget* = enum
|
||||
postgre, sqlite, mysql, baradb
|
||||
|
||||
proc hasAttribute(colDesc: SqlNode; k: set[SqlNodeKind]): bool =
|
||||
for i in 2 ..< colDesc.len:
|
||||
if colDesc[i].kind in k:
|
||||
return true
|
||||
|
||||
proc hasRefs(colDesc: SqlNode): (string, string) =
|
||||
for i in 2 ..< colDesc.len:
|
||||
let c = colDesc[i]
|
||||
if c.kind == nkReferences:
|
||||
if c[0].kind == nkCall:
|
||||
return (c[0][0].strVal, c[0][1].strVal)
|
||||
elif c[0].kind == nkIdent:
|
||||
return ($c[0], "id")
|
||||
("", "")
|
||||
|
||||
proc getType(n: SqlNode): DbType =
|
||||
var it = n
|
||||
if it.kind == nkCall:
|
||||
it = it[0]
|
||||
if it.kind == nkEnumDef:
|
||||
result.kind = dbEnum
|
||||
result.validValues = @[]
|
||||
for i in 0 ..< it.len:
|
||||
assert it[i].kind == nkStringLit
|
||||
result.validValues.add it[i].strVal
|
||||
elif it.kind in {nkIdent, nkStringLit}:
|
||||
result.kind = dbTypFromName(it.strVal)
|
||||
result.name = it.strVal
|
||||
|
||||
proc collectTables*(n: SqlNode; t: var KnownTables) =
|
||||
if n.isNil:
|
||||
return
|
||||
case n.kind
|
||||
of nkCreateTable, nkCreateTableIfNotExists:
|
||||
let tableName = n[0].strVal
|
||||
var cols: DbColumns = @[]
|
||||
for i in 1 ..< n.len:
|
||||
let it = n[i]
|
||||
if it.kind == nkColumnDef:
|
||||
var typ = getType(it[1])
|
||||
if hasAttribute(it, {nkNotNull}):
|
||||
typ.notNull = true
|
||||
cols.add DbColumn(
|
||||
name: it[0].strVal,
|
||||
tableName: tableName,
|
||||
typ: typ,
|
||||
primaryKey: hasAttribute(it, {nkPrimaryKey}),
|
||||
refs: hasRefs(it)
|
||||
)
|
||||
for i in 1 ..< n.len:
|
||||
let it = n[i]
|
||||
if it.kind == nkForeignKey:
|
||||
var refNode: SqlNode = nil
|
||||
var localCols: seq[string] = @[]
|
||||
for j in 0 ..< it.len:
|
||||
let child = it[j]
|
||||
if child.kind == nkReferences:
|
||||
refNode = child
|
||||
elif child.kind == nkIdent:
|
||||
localCols.add(child.strVal)
|
||||
if refNode.isNil:
|
||||
continue
|
||||
var r = refNode[0]
|
||||
var refTable = ""
|
||||
var refCols: seq[string] = @[]
|
||||
if r.kind == nkColumnReference or r.kind == nkCall:
|
||||
refTable = r[0].strVal
|
||||
for k in 1 ..< r.len:
|
||||
refCols.add(r[k].strVal)
|
||||
let pairCount = min(localCols.len, refCols.len)
|
||||
for k in 0 ..< pairCount:
|
||||
let localName = localCols[k]
|
||||
let targetCol = refCols[k]
|
||||
for c in mitems(cols):
|
||||
if cmpIgnoreCase(c.name, localName) == 0:
|
||||
c.refs = (refTable, targetCol)
|
||||
break
|
||||
t[tableName] = cols
|
||||
else:
|
||||
for i in 0 ..< n.len:
|
||||
collectTables(n[i], t)
|
||||
|
||||
proc attrToKey(a: DbColumn; t: KnownTables): int =
|
||||
if a.primaryKey:
|
||||
return 1
|
||||
if a.refs[0].len > 0:
|
||||
var i = 0
|
||||
for k, v in pairs(t):
|
||||
for b in v:
|
||||
if cmpIgnoreCase(k, a.refs[0]) == 0 and cmpIgnoreCase(b.name, a.refs[1]) == 0:
|
||||
return -i - 1
|
||||
inc i
|
||||
0
|
||||
|
||||
proc renderModelCode(schemaSql, schemaPath: string; target: ImportTarget; includeStatic = false): string =
|
||||
discard target
|
||||
let sql = parseSql(schemaSql, schemaPath)
|
||||
var knownTables = initOrderedTable[string, DbColumns]()
|
||||
collectTables(sql, knownTables)
|
||||
|
||||
result.add FileHeader
|
||||
result.add "const tableNames = ["
|
||||
var i = 0
|
||||
for k in keys(knownTables):
|
||||
if i > 0:
|
||||
result.add ",\n "
|
||||
else:
|
||||
result.add "\n "
|
||||
result.add escape(k.toLowerAscii)
|
||||
inc i
|
||||
result.add "\n]\n"
|
||||
|
||||
i = 0
|
||||
var j = 0
|
||||
result.add "\nconst attributes = ["
|
||||
for _, v in mpairs(knownTables):
|
||||
for a in v:
|
||||
if j > 0:
|
||||
result.add ",\n "
|
||||
else:
|
||||
result.add "\n "
|
||||
result.add "Attr(name: "
|
||||
result.add escape(a.name.toLowerAscii)
|
||||
result.add ", tabIndex: "
|
||||
result.add $i
|
||||
result.add ", typ: "
|
||||
result.add $a.typ.kind
|
||||
result.add ", key: "
|
||||
result.add $attrToKey(a, knownTables)
|
||||
result.add ")"
|
||||
inc j
|
||||
inc i
|
||||
result.add "\n]\n"
|
||||
|
||||
if includeStatic:
|
||||
result.add "\nconst sqlSchema* = staticLoad("
|
||||
result.add escape(schemaPath)
|
||||
result.add ")\n"
|
||||
|
||||
proc generateModelCode*(schemaSql, schemaPath: string; target: ImportTarget; includeStatic = false): string =
|
||||
renderModelCode(schemaSql, schemaPath, target, includeStatic)
|
||||
|
||||
macro generateModelCode*(schemaSql: static[string], schemaPath: static[string],
|
||||
target: static[ImportTarget], includeStatic: static[bool] = false): untyped =
|
||||
parseStmt(renderModelCode(schemaSql, schemaPath, target, includeStatic))
|
||||
@@ -0,0 +1,231 @@
|
||||
import std/[strutils, json, times, parseutils]
|
||||
import db_connector/db_common
|
||||
import query_hooks
|
||||
export db_common
|
||||
|
||||
import baradb/client
|
||||
export client.WireValue, client.FieldKind
|
||||
|
||||
type
|
||||
DbConn* = SyncClient
|
||||
PStmt = object
|
||||
sql: string
|
||||
|
||||
varcharType* = string
|
||||
intType* = int
|
||||
floatType* = float
|
||||
boolType* = bool
|
||||
timestampType* = DateTime
|
||||
serialType* = int
|
||||
jsonType* = JsonNode
|
||||
|
||||
var jsonTimeFormat* = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
proc dbError*(db: DbConn) {.noreturn.} =
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = "BaraDB query failed"
|
||||
raise e
|
||||
|
||||
proc prepareStmt*(db: DbConn; q: string): PStmt =
|
||||
when defined(debugOrminTrace):
|
||||
echo "[[Ormin Executing]]: ", q
|
||||
result.sql = q
|
||||
|
||||
template startBindings*(s: PStmt; n: int) {.dirty.} =
|
||||
var pparams: seq[WireValue] = newSeq[WireValue](n)
|
||||
|
||||
template bindParam*(db: DbConn; s: PStmt; idx: int; x: untyped; t: untyped) =
|
||||
when t is DateTime:
|
||||
let xx = x.format("yyyy-MM-dd HH:mm:ss")
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: $xx)
|
||||
elif t is int or t is int64:
|
||||
pparams[idx-1] = WireValue(kind: fkInt64, int64Val: int64(x))
|
||||
elif t is float or t is float64:
|
||||
pparams[idx-1] = WireValue(kind: fkFloat64, float64Val: float64(x))
|
||||
elif t is bool:
|
||||
pparams[idx-1] = WireValue(kind: fkBool, boolVal: bool(x))
|
||||
elif t is string:
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: string(x))
|
||||
elif t is JsonNode:
|
||||
pparams[idx-1] = WireValue(kind: fkJson, jsonVal: $x)
|
||||
else:
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: $x)
|
||||
|
||||
template bindNullParam*(db: DbConn; s: PStmt; idx: int) =
|
||||
pparams[idx-1] = WireValue(kind: fkNull)
|
||||
|
||||
template bindParamJson*(db: DbConn; s: PStmt; idx: int; xx: JsonNode;
|
||||
t: typedesc) =
|
||||
let x = xx
|
||||
if x.kind == JNull:
|
||||
pparams[idx-1] = WireValue(kind: fkNull)
|
||||
else:
|
||||
bindFromJson(db, s, idx, x, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[string]) =
|
||||
doAssert x.kind == JString
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: x.str)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[int|int64]) =
|
||||
doAssert x.kind == JInt
|
||||
pparams[idx-1] = WireValue(kind: fkInt64, int64Val: x.num)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[float64]) =
|
||||
doAssert x.kind == JFloat
|
||||
pparams[idx-1] = WireValue(kind: fkFloat64, float64Val: x.fnum)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[bool]) =
|
||||
doAssert x.kind == JBool
|
||||
pparams[idx-1] = WireValue(kind: fkBool, boolVal: x.bval)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[DateTime]) =
|
||||
doAssert x.kind == JString
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: x.str)
|
||||
|
||||
template startQuery*(db: DbConn; s: PStmt) =
|
||||
when declared(pparams):
|
||||
var queryResult {.inject.} = db.query(s.sql, pparams)
|
||||
else:
|
||||
var queryResult {.inject.} = db.query(s.sql)
|
||||
var queryI {.inject.} = -1
|
||||
var queryLen {.inject.} = queryResult.rowCount
|
||||
|
||||
template stopQuery*(db: DbConn; s: PStmt) =
|
||||
discard
|
||||
|
||||
template stepQuery*(db: DbConn; s: PStmt; returnsData: bool): bool =
|
||||
inc queryI
|
||||
queryI < queryLen
|
||||
|
||||
template getLastId*(db: DbConn; s: PStmt): int =
|
||||
0
|
||||
|
||||
template getAffectedRows*(db: DbConn; s: PStmt): int =
|
||||
queryResult.affectedRows
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
client.close(db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
let colonPos = connection.find(':')
|
||||
let host = if colonPos < 0: connection else: connection[0..<colonPos]
|
||||
let portStr = if colonPos < 0: "9472" else: connection[colonPos+1..^1]
|
||||
let port = parseInt(portStr)
|
||||
let cfg = ClientConfig(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: user,
|
||||
password: password
|
||||
)
|
||||
result = newSyncClient(cfg)
|
||||
result.connect()
|
||||
|
||||
# --- Result binding helpers ---
|
||||
|
||||
template currentValue(s: PStmt; idx: int): string =
|
||||
queryResult.rows[queryI][idx-1]
|
||||
|
||||
template columnIsNull*(db: DbConn; s: PStmt; idx: int): bool =
|
||||
currentValue(s, idx).len == 0
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int;
|
||||
t: typedesc; name: string) =
|
||||
dest = int(parseBiggestInt(currentValue(s, idx)))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int64;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseBiggestInt(currentValue(s, idx))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: bool;
|
||||
t: typedesc; name: string) =
|
||||
let v = currentValue(s, idx)
|
||||
dest = v == "true" or v == "1" or v == "t"
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var string;
|
||||
t: typedesc; name: string) =
|
||||
dest = currentValue(s, idx)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: float64;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseFloat(currentValue(s, idx))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var DateTime;
|
||||
t: typedesc; name: string) =
|
||||
let src = currentValue(s, idx)
|
||||
if src.len > 0:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss")
|
||||
else:
|
||||
dest = initDateTime(1, 1, 1, 0, 0, 0, utc())
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseJson(currentValue(s, idx))
|
||||
|
||||
template bindResult*[T](db: DbConn; s: PStmt; idx: int; dest: var DbValue[T];
|
||||
t: typedesc; name: string) =
|
||||
let v = currentValue(s, idx)
|
||||
if v.len == 0:
|
||||
dest.isNull = true
|
||||
else:
|
||||
dest.isNull = false
|
||||
when T is string:
|
||||
dest.value = v
|
||||
else:
|
||||
bindResult(db, s, idx, dest.value, t, name)
|
||||
|
||||
template createJObject*(): untyped = newJObject()
|
||||
template createJArray*(): untyped = newJArray()
|
||||
|
||||
template bindResultJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let x = obj
|
||||
doAssert x.kind == JObject
|
||||
let v = currentValue(s, idx)
|
||||
if v.len == 0:
|
||||
x[name] = newJNull()
|
||||
else:
|
||||
bindToJson(db, s, idx, x, t, name)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[string]; name: string) =
|
||||
obj[name] = newJString(currentValue(s, idx))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[int|int64]; name: string) =
|
||||
var v: int64
|
||||
discard parseBiggestInt(currentValue(s, idx), v)
|
||||
obj[name] = newJInt(v)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[float64]; name: string) =
|
||||
obj[name] = newJFloat(parseFloat(currentValue(s, idx)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[bool]; name: string) =
|
||||
let v = currentValue(s, idx)
|
||||
obj[name] = newJBool(v == "true" or v == "1" or v == "t")
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[DateTime]; name: string) =
|
||||
var dt: DateTime
|
||||
bindResult(db, s, idx, dt, t, name)
|
||||
obj[name] = newJString(format(dt, jsonTimeFormat))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[JsonNode]; name: string) =
|
||||
obj[name] = parseJson(currentValue(s, idx))
|
||||
@@ -0,0 +1,274 @@
|
||||
import strutils, db_connector/postgres, json, times
|
||||
|
||||
import db_connector/db_common
|
||||
import query_hooks
|
||||
export db_common
|
||||
|
||||
type
|
||||
DbConn* = PPGconn ## encapsulates a database connection
|
||||
PStmt = string ## a identifier for the prepared queries
|
||||
|
||||
varcharType* = string
|
||||
intType* = int
|
||||
floatType* = float
|
||||
boolType* = bool
|
||||
timestampType* = Datetime
|
||||
serialType* = int
|
||||
jsonType* = JsonNode
|
||||
|
||||
var jsonTimeFormat* = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
proc dbError*(db: DbConn) {.noreturn.} =
|
||||
## raises a DbError exception.
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = $pqErrorMessage(db)
|
||||
raise e
|
||||
|
||||
proc c_strtod(buf: cstring, endptr: ptr cstring = nil): float64 {.
|
||||
importc: "strtod", header: "<stdlib.h>", noSideEffect.}
|
||||
|
||||
proc c_strtol(buf: cstring, endptr: ptr cstring = nil, base: cint = 10): int {.
|
||||
importc: "strtol", header: "<stdlib.h>", noSideEffect.}
|
||||
|
||||
var sid {.compileTime.}: int
|
||||
|
||||
proc prepareStmt*(db: DbConn; q: string): PStmt =
|
||||
when defined(debugOrminTrace):
|
||||
echo "[[Ormin Executing]]: ", q
|
||||
inc sid
|
||||
result = "ormin" & $sid
|
||||
var res = pqprepare(db, result, q, 0, nil)
|
||||
if pqResultStatus(res) != PGRES_COMMAND_OK: dbError(db)
|
||||
|
||||
template startBindings*(s: PStmt; n: int) {.dirty.} =
|
||||
# pparams is a duplicated array to keep the Nim string alive
|
||||
# for the duration of the query. This is safer than relying
|
||||
# on the conservative stack marking:
|
||||
var pparams: array[n, string]
|
||||
var parr: array[n, cstring]
|
||||
|
||||
template bindParam*(db: DbConn; s: PStmt; idx: int; x: untyped; t: untyped) =
|
||||
# when not (x is t):
|
||||
# {.error: "type mismatch for query argument at position " & $idx.}
|
||||
when t is DateTime:
|
||||
let xx = x.format("yyyy-MM-dd HH:mm:ss\'.\'ffffffzzzz")
|
||||
pparams[idx-1] = $xx
|
||||
else:
|
||||
pparams[idx-1] = $x
|
||||
|
||||
parr[idx-1] = cstring(pparams[idx-1])
|
||||
|
||||
template bindParamUnchecked(db: DbConn; s: PStmt; idx: int; x: untyped; t: untyped) =
|
||||
pparams[idx-1] = $x
|
||||
parr[idx-1] = cstring(pparams[idx-1])
|
||||
|
||||
template bindNullParam*(db: DbConn; s: PStmt; idx: int) =
|
||||
parr[idx-1] = cstring(nil)
|
||||
|
||||
template bindParamJson*(db: DbConn; s: PStmt; idx: int; xx: JsonNode;
|
||||
t: typedesc) =
|
||||
let x = xx
|
||||
if x.kind == JNull:
|
||||
# a NULL entry is not reflected in the 'pparams' array:
|
||||
parr[idx-1] = cstring(nil)
|
||||
else:
|
||||
bindFromJson(db, s, idx, x, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[string]) =
|
||||
doAssert x.kind == JString
|
||||
let xs = x.str
|
||||
bindParamUnchecked(db, s, idx, xs, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[int|int64]) =
|
||||
doAssert x.kind == JInt
|
||||
let xi = x.num
|
||||
bindParamUnchecked(db, s, idx, xi, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[float64]) =
|
||||
doAssert x.kind == JFloat
|
||||
let xf = x.fnum
|
||||
bindParamUnchecked(db, s, idx, xf, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[bool]) =
|
||||
doAssert x.kind == JBool
|
||||
let xb = x.bval
|
||||
bindParamUnchecked(db, s, idx, xb, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[DateTime]) =
|
||||
doAssert x.kind == JString
|
||||
let dt = x.str
|
||||
bindParamUnchecked(db, s, idx, dt, t)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int;
|
||||
t: typedesc; name: string) =
|
||||
dest = c_strtol(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int64;
|
||||
t: typedesc; name: string) =
|
||||
dest = c_strtol(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
# XXX This needs testing:
|
||||
template isTrue(x): untyped = x[0] == 't'
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: bool;
|
||||
t: typedesc; name: string) =
|
||||
dest = isTrue(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
proc fillString(dest: var string; src: cstring; srcLen: int) {.inline.} =
|
||||
dest = ""
|
||||
var i = 0
|
||||
while src[i] != '\0':
|
||||
dest.add src[i]
|
||||
inc i
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var string;
|
||||
t: typedesc; name: string) =
|
||||
if pqgetisnull(queryResult, queryI, idx.cint) != 0:
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, 0)
|
||||
else:
|
||||
dest = nil
|
||||
else:
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
let srcLen = int(pqgetlength(queryResult, queryI, idx.cint))
|
||||
fillString(dest, src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: float64;
|
||||
t: typedesc; name: string) =
|
||||
dest = c_strtod(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var DateTime;
|
||||
t: typedesc; name: string) =
|
||||
let
|
||||
src = $pqgetvalue(queryResult, queryI, idx.cint)
|
||||
i = src.find('.')
|
||||
tzflag = src[src.len-3]
|
||||
if i < 0:
|
||||
if tzflag in {'+', '-'}:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:sszz")
|
||||
else:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss")
|
||||
else:
|
||||
if tzflag in {'+', '-'}:
|
||||
let itz = src.len - 3
|
||||
let dtstr = src[0..<itz] & '0'.repeat(10-src.len+i) & src[src.len-3 .. ^1]
|
||||
dest = parse(dtstr, "yyyy-MM-dd HH:mm:ss\'.\'ffffffzz")
|
||||
else:
|
||||
let dtstr = src & '0'.repeat(7-src.len+i)
|
||||
dest = parse(dtstr, "yyyy-MM-dd HH:mm:ss\'.\'ffffff")
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseJson($pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
template bindResult*[T](db: DbConn; s: PStmt; idx: int; dest: var DbValue[T];
|
||||
t: typedesc; name: string) =
|
||||
if pqgetisnull(queryResult, queryI, idx.cint) != 0:
|
||||
dest.isNull = true
|
||||
else:
|
||||
dest.isNull = false
|
||||
when T is string:
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
let srcLen = int(pqgetlength(queryResult, queryI, idx.cint))
|
||||
fillString(dest.value, src, srcLen)
|
||||
else:
|
||||
bindResult(db, s, idx, dest.value, t, name)
|
||||
|
||||
template createJObject*(): untyped = newJObject()
|
||||
template createJArray*(): untyped = newJArray()
|
||||
|
||||
template bindResultJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let x = obj
|
||||
doAssert x.kind == JObject
|
||||
if pqgetisnull(queryResult, queryI, idx.cint) != 0:
|
||||
x[name] = newJNull()
|
||||
else:
|
||||
bindToJson(db, s, idx, x, t, name)
|
||||
|
||||
template columnIsNull*(db: DbConn; s: PStmt; idx: int): bool =
|
||||
pqgetisnull(queryResult, queryI, idx.cint) != 0
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[string]; name: string) =
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
obj[name] = newJString($src)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[int|int64]; name: string) =
|
||||
obj[name] = newJInt(c_strtol(pqgetvalue(queryResult, queryI, idx.cint)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[float64]; name: string) =
|
||||
obj[name] = newJFloat(c_strtod(pqgetvalue(queryResult, queryI, idx.cint)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[bool]; name: string) =
|
||||
obj[name] = newJBool(isTrue(pqgetvalue(queryResult, queryI, idx.cint)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[DateTime]; name: string) =
|
||||
var dt: DateTime
|
||||
bindResult(db, s, idx, dt, t, name)
|
||||
obj[name] = newJString(format(dt, jsonTimeFormat))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[JsonNode]; name: string) =
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
obj[name] = parseJson($src)
|
||||
|
||||
template startQuery*(db: DbConn; s: PStmt) =
|
||||
when declared(pparams):
|
||||
var queryResult {.inject.} = pqexecPrepared(db, s, int32(parr.len),
|
||||
cast[cstringArray](addr parr), nil, nil, 0)
|
||||
else:
|
||||
var queryResult {.inject.} = pqexecPrepared(db, s, int32(0),
|
||||
nil, nil, nil, 0)
|
||||
if pqResultStatus(queryResult) == PGRES_COMMAND_OK:
|
||||
discard # insert does not returns data in pg
|
||||
elif pqResultStatus(queryResult) != PGRES_TUPLES_OK: ormin_postgre.dbError(db)
|
||||
var queryI {.inject.} = cint(-1)
|
||||
var queryLen {.inject.} = pqntuples(queryResult)
|
||||
|
||||
template stopQuery*(db: DbConn; s: PStmt) =
|
||||
pqclear(queryResult)
|
||||
|
||||
template stepQuery*(db: DbConn; s: PStmt; returnsData: bool): bool =
|
||||
inc queryI
|
||||
queryI < queryLen
|
||||
|
||||
template getLastId*(db: DbConn; s: PStmt): int = 0 # XXX to implement
|
||||
|
||||
template getAffectedRows*(db: DbConn; s: PStmt): int =
|
||||
c_strtol(pqcmdTuples(queryResult))
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
## closes the database connection.
|
||||
if db != nil: pqfinish(db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
## opens a database connection. Raises `DbError` if the connection could not
|
||||
## be established.
|
||||
let
|
||||
colonPos = connection.find(':')
|
||||
host = if colonPos < 0: connection
|
||||
else: substr(connection, 0, colonPos-1)
|
||||
port = if colonPos < 0: ""
|
||||
else: substr(connection, colonPos+1)
|
||||
result = pqsetdbLogin(host, port, nil, nil, database, user, password)
|
||||
if pqStatus(result) != CONNECTION_OK: dbError(result) # result = nil
|
||||
@@ -0,0 +1,295 @@
|
||||
|
||||
{.deadCodeElim: on.}
|
||||
|
||||
import json, times
|
||||
|
||||
import db_connector/db_common
|
||||
import db_connector/sqlite3
|
||||
import query_hooks
|
||||
export db_common
|
||||
|
||||
type
|
||||
DbConn* = PSqlite3 ## encapsulates a database connection
|
||||
|
||||
varcharType* = string
|
||||
blobType* = seq[byte]
|
||||
intType* = int
|
||||
floatType* = float
|
||||
boolType* = bool
|
||||
timestampType* = DateTime
|
||||
jsonType* = JsonNode
|
||||
|
||||
var jsonTimeFormat* = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
proc dbError*(db: DbConn) {.noreturn.} =
|
||||
## raises a DbError exception.
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = $sqlite3.errmsg(db)
|
||||
raise e
|
||||
|
||||
proc prepareStmt*(db: DbConn; q: string): PStmt =
|
||||
when defined(debugOrminTrace):
|
||||
echo "[[Ormin Executing]]: ", q
|
||||
if prepare_v2(db, q, q.len.cint, result, nil) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template startBindings*(s: PStmt; n: int) =
|
||||
if clear_bindings(s) != SQLITE_OK: dbError(db)
|
||||
|
||||
template bindParam*(db: DbConn; s: PStmt; idx: int; x, t: untyped) =
|
||||
#when not (x is t):
|
||||
# {.error: "type mismatch for query argument at position " & $idx.}
|
||||
when t is blobType:
|
||||
let xs = x
|
||||
let blobPtr = if xs.len == 0:
|
||||
cast[pointer](nil)
|
||||
else:
|
||||
cast[pointer](unsafeAddr(xs[0]))
|
||||
if bind_blob(s, idx.cint, blobPtr, xs.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is int or t is int64 or t is bool:
|
||||
if bind_int64(s, idx.cint, x.int64) != SQLITE_OK: dbError(db)
|
||||
elif t is string:
|
||||
if bind_text(s, idx.cint, cstring(x), x.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is float64:
|
||||
if bind_double(s, idx.cint, x) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is DateTime:
|
||||
let xx = if x.nanosecond > 0:
|
||||
x.utc().format("yyyy-MM-dd HH:mm:ss\'.\'fff")
|
||||
else:
|
||||
x.utc().format("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
if bind_text(s, idx.cint, cstring(xx), xx.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is JsonNode:
|
||||
let xx = $x
|
||||
if bind_text(s, idx.cint, cstring(xx), xx.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
else:
|
||||
{.error: "type mismatch for query argument at position " & $idx.}
|
||||
|
||||
template bindNullParam*(db: DbConn; s: PStmt; idx: int) =
|
||||
if bind_null(s, idx.cint) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindParamJson*(db: DbConn; s: PStmt; idx: int; xx: JsonNode;
|
||||
t: typedesc) =
|
||||
let x = xx
|
||||
if x.kind == JNull:
|
||||
if bind_null(s, idx.cint) != SQLITE_OK: dbError(db)
|
||||
else:
|
||||
bindFromJson(db, s, idx, x, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[string]) =
|
||||
doAssert x.kind == JString
|
||||
let xs = x.str
|
||||
if bind_text(s, idx.cint, cstring(xs), xs.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[int|int64]) =
|
||||
doAssert x.kind == JInt
|
||||
let xi = x.num
|
||||
if bind_int64(s, idx.cint, xi.int64) != SQLITE_OK: dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[float64]) =
|
||||
doAssert x.kind == JFloat
|
||||
let xf = x.fnum
|
||||
if bind_double(s, idx.cint, xf) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[bool]) =
|
||||
doAssert x.kind == JBool
|
||||
let xb = x.bval
|
||||
if bind_int64(s, idx.cint, xb.int64) != SQLITE_OK: dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[DateTime]) =
|
||||
doAssert x.kind == JString
|
||||
let
|
||||
dtStr = x.str
|
||||
i = dtStr.find('.')
|
||||
dt = if i > 0 and dtStr[i+1 .. ^1] == "000":
|
||||
dtStr[0 ..< i]
|
||||
else:
|
||||
dtStr
|
||||
if bind_text(s, idx.cint, cstring(dt), dt.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int;
|
||||
t: typedesc; name: string) =
|
||||
dest = int column_int64(s, idx.cint)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int64;
|
||||
t: typedesc; name: string) =
|
||||
dest = column_int64(s, idx.cint)
|
||||
|
||||
proc fillString(dest: var string; src: cstring; srcLen: int) =
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, srcLen)
|
||||
else:
|
||||
if dest.isNil: dest = newString(srcLen)
|
||||
else: setLen(dest, srcLen)
|
||||
if srcLen > 0:
|
||||
copyMem(unsafeAddr(dest[0]), src, srcLen)
|
||||
|
||||
proc fillBytes(dest: var seq[byte]; src: pointer; srcLen: int) =
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, srcLen)
|
||||
else:
|
||||
if dest.isNil: dest = newSeq[byte](srcLen)
|
||||
else: setLen(dest, srcLen)
|
||||
if srcLen > 0:
|
||||
copyMem(unsafeAddr(dest[0]), src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var string;
|
||||
t: typedesc; name: string) =
|
||||
if column_type(s, idx.cint) == SQLITE_NULL:
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, 0)
|
||||
else:
|
||||
dest = nil
|
||||
else:
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_text(s, idx.cint)
|
||||
fillString(dest, src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var blobType;
|
||||
t: typedesc; name: string) =
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_blob(s, idx.cint)
|
||||
fillBytes(dest, src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: float64;
|
||||
t: typedesc; name: string) =
|
||||
dest = column_double(s, idx.cint)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: bool;
|
||||
t: typedesc; name: string) =
|
||||
dest = column_int64(s, idx.cint) != 0
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: DateTime;
|
||||
t: typedesc; name: string) =
|
||||
let
|
||||
src = $column_text(s, idx.cint)
|
||||
i = src.find('.')
|
||||
if i < 0:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss", utc())
|
||||
else:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss\'.\'fff", utc())
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let src = column_text(s, idx.cint)
|
||||
dest = parseJson($src)
|
||||
|
||||
template bindResult*[T](db: DbConn; s: PStmt; idx: int; dest: var DbValue[T];
|
||||
t: typedesc; name: string) =
|
||||
if column_type(s, idx.cint) == SQLITE_NULL:
|
||||
dest.isNull = true
|
||||
else:
|
||||
dest.isNull = false
|
||||
when T is string:
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_text(s, idx.cint)
|
||||
fillString(dest.value, src, srcLen)
|
||||
else:
|
||||
bindResult(db, s, idx, dest.value, t, name)
|
||||
|
||||
template createJObject*(): untyped = newJObject()
|
||||
template createJArray*(): untyped = newJArray()
|
||||
|
||||
template bindResultJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let x = obj
|
||||
doAssert x.kind == JObject
|
||||
if column_type(s, idx.cint) == SQLITE_NULL:
|
||||
x[name] = newJNull()
|
||||
else:
|
||||
bindToJson(db, s, idx, x, t, name)
|
||||
|
||||
template columnIsNull*(db: DbConn; s: PStmt; idx: int): bool =
|
||||
column_type(s, idx.cint) == SQLITE_NULL
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[string]; name: string) =
|
||||
let dest = newJString("")
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_text(s, idx.cint)
|
||||
fillString(dest.str, src, srcLen)
|
||||
obj[name] = dest
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[int|int64]; name: string) =
|
||||
obj[name] = newJInt(column_int64(s, idx.cint))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[blobType]; name: string) =
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_blob(s, idx.cint)
|
||||
var data: blobType
|
||||
fillBytes(data, src, srcLen)
|
||||
let arr = newJArray()
|
||||
for b in data:
|
||||
arr.add newJInt(int(b))
|
||||
obj[name] = arr
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[float64]; name: string) =
|
||||
obj[name] = newJFloat(column_double(s, idx.cint))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[bool]; name: string) =
|
||||
obj[name] = newJBool(column_int64(s, idx.cint) != 0)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[DateTime]; name: string) =
|
||||
var dt: DateTime
|
||||
bindResult(db, s, idx, dt, t, name)
|
||||
obj[name] = newJString(format(dt, jsonTimeFormat))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[JsonNode]; name: string) =
|
||||
let src = column_text(s, idx.cint)
|
||||
obj[name] = parseJson($src)
|
||||
|
||||
template startQuery*(db: DbConn; s: PStmt) = discard "nothing to do"
|
||||
|
||||
template stopQuery*(db: DbConn; s: PStmt) =
|
||||
if sqlite3.reset(s) != SQLITE_OK: dbError(db)
|
||||
|
||||
template stepQuery*(db: DbConn; s: PStmt; returnsData: bool): bool =
|
||||
when returnsData:
|
||||
step(s) == SQLITE_ROW
|
||||
else:
|
||||
step(s) == SQLITE_DONE
|
||||
|
||||
template getLastId*(db: DbConn; s: PStmt): int =
|
||||
int(last_insert_rowid(db))
|
||||
|
||||
template getAffectedRows*(db: DbConn; s: PStmt): int =
|
||||
int(changes(db))
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
## closes the database connection.
|
||||
if sqlite3.close(db) != SQLITE_OK: dbError(db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
## opens a database connection. Raises `EDb` if the connection could not
|
||||
## be established. Only the ``connection`` parameter is used for ``sqlite``.
|
||||
if sqlite3.open(connection, result) != SQLITE_OK:
|
||||
dbError(result)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import options, json
|
||||
|
||||
type
|
||||
DbValue*[T] = object
|
||||
isNull*: bool
|
||||
value*: T
|
||||
|
||||
template fromQueryHook*[T, S](val: var T, x: S) =
|
||||
## Default conversion hook used by `query(T): ...`.
|
||||
## Users can overload this proc to customize field/type conversions.
|
||||
val = x
|
||||
|
||||
template toQueryHook*[T, S](val: var T, x: S) =
|
||||
## Default conversion hook used for query parameters.
|
||||
## Users can overload this proc to customize parameter conversions.
|
||||
val = x
|
||||
|
||||
proc nullQueryValueError() {.noreturn.} =
|
||||
raise newException(ValueError, "cannot map NULL query result")
|
||||
|
||||
proc fromQueryHook*[T, S](val: var Option[T], x: var DbValue[S]) =
|
||||
if x.isNull:
|
||||
val = none(T)
|
||||
else:
|
||||
var converted: T
|
||||
fromQueryHook(converted, move x.value)
|
||||
val = some(converted)
|
||||
|
||||
proc fromQueryHook*[T, S](val: var T, x: var DbValue[S]) =
|
||||
if x.isNull:
|
||||
when T is string:
|
||||
val = ""
|
||||
elif T is JsonNode:
|
||||
val = newJNull()
|
||||
else:
|
||||
nullQueryValueError()
|
||||
else:
|
||||
fromQueryHook(val, move x.value)
|
||||
|
||||
proc bindFromQueryHook*[T, S](dest: var T, x: var DbValue[S]) =
|
||||
fromQueryHook(dest, x)
|
||||
|
||||
proc toQueryHook*[S, T](val: var DbValue[S], x: Option[T]) =
|
||||
if x.isSome:
|
||||
val.isNull = false
|
||||
toQueryHook(val.value, x.get)
|
||||
else:
|
||||
val.isNull = true
|
||||
when compiles(val.value = default(S)):
|
||||
val.value = default(S)
|
||||
|
||||
proc toQueryHook*[S, T](val: var DbValue[S], x: T) =
|
||||
val.isNull = false
|
||||
toQueryHook(val.value, x)
|
||||
@@ -0,0 +1,139 @@
|
||||
|
||||
import asynchttpserver, asyncdispatch, asyncnet, json,
|
||||
strutils, times
|
||||
|
||||
import websocket
|
||||
|
||||
type
|
||||
Receivers* {.pure} = enum
|
||||
sender, allExceptSender, all
|
||||
ReqHandler* = proc (msg: JsonNode; receivers: var Receivers): JsonNode {.
|
||||
closure, gcsafe.}
|
||||
|
||||
proc error(msg: string) = echo "[Error] ", msg
|
||||
proc warn(msg: string) = echo "[Warning] ", msg
|
||||
proc hint(msg: string) = echo "[Hint] ", msg
|
||||
|
||||
type
|
||||
Client = ref object
|
||||
socket: AsyncWebSocket
|
||||
connected: bool
|
||||
hostname: string
|
||||
id: int
|
||||
lastMessage: float
|
||||
rapidMessageCount: int
|
||||
|
||||
Server = ref object
|
||||
clients: seq[Client]
|
||||
needsUpdate: bool
|
||||
receivers: Receivers
|
||||
msgs: seq[(string, int)]
|
||||
gid: int
|
||||
handler: ReqHandler
|
||||
|
||||
proc newClient(s: Server; socket: AsyncWebSocket, hostname: string): Client =
|
||||
inc s.gid
|
||||
result = Client(socket: socket, connected: true, hostname: hostname, id: s.gid)
|
||||
|
||||
proc `$`(client: Client): string =
|
||||
"Client(ip: $1)" % [client.hostname]
|
||||
|
||||
proc updateClients(server: Server) {.async.} =
|
||||
while true:
|
||||
var needsUpdate = false
|
||||
for client in server.clients:
|
||||
if not client.connected:
|
||||
needsUpdate = true
|
||||
break
|
||||
|
||||
server.needsUpdate = server.needsUpdate or needsUpdate
|
||||
if server.needsUpdate and server.msgs.len != 0:
|
||||
var someDead = false
|
||||
# perform a copy to prevent the race condition:
|
||||
var msgs = server.msgs
|
||||
setLen(server.msgs, 0)
|
||||
for m in msgs:
|
||||
for c in server.clients:
|
||||
if c.connected:
|
||||
# do not send a broadcast message to the one which sent it:
|
||||
if server.receivers != Receivers.allExceptSender or c.id != m[1]:
|
||||
await c.socket.sendText(m[0]) #, false)
|
||||
else:
|
||||
someDead = true
|
||||
if someDead:
|
||||
var i = 0
|
||||
while i < server.clients.len:
|
||||
if not server.clients[i].connected: del(server.clients, i)
|
||||
else: inc i
|
||||
server.needsUpdate = false
|
||||
# let other stuff in the main loop run:
|
||||
await sleepAsync(10)
|
||||
|
||||
proc processMessage(server: Server, client: Client, data: string) {.async.} =
|
||||
# Check if last message was relatively recent. If so, kick the user.
|
||||
echo "processMessage ", data
|
||||
let now = epochTime()
|
||||
if now - client.lastMessage < 0.1: # 100ms
|
||||
client.rapidMessageCount.inc
|
||||
else:
|
||||
client.rapidMessageCount = 0
|
||||
|
||||
client.lastMessage = now
|
||||
if client.rapidMessageCount > 50:
|
||||
warn("Client ($1) is firing messages too rapidly. Killing." % $client)
|
||||
client.connected = false
|
||||
let msgj = parseJson(data)
|
||||
server.receivers = Receivers.sender
|
||||
if msgj.hasKey("msg") and msgj["msg"].getStr("") == "disconnect":
|
||||
client.connected = false
|
||||
server.needsUpdate = true
|
||||
else:
|
||||
let resp = server.handler(msgj, server.receivers)
|
||||
if not resp.isNil:
|
||||
if server.receivers == sender:
|
||||
await client.socket.sendText($resp) #, false)
|
||||
else:
|
||||
server.msgs.add(($resp, client.id))
|
||||
server.needsUpdate = true
|
||||
|
||||
proc processClient(server: Server, client: Client) {.async.} =
|
||||
while client.connected:
|
||||
var frameFut = client.socket.readData()
|
||||
yield frameFut
|
||||
if frameFut.failed:
|
||||
error("Error occurred handling client messages.\n" &
|
||||
frameFut.error.msg)
|
||||
client.connected = false
|
||||
break
|
||||
|
||||
let frame = frameFut.read()
|
||||
if frame.opcode == Opcode.Text:
|
||||
let processFut = processMessage(server, client, frame.data)
|
||||
if processFut.failed:
|
||||
echo processFut.error.getStackTrace()
|
||||
error("Client ($1) attempted to send bad JSON? " % $client & "\n" &
|
||||
processFut.error.msg)
|
||||
#client.connected = false
|
||||
await client.socket.close()
|
||||
|
||||
proc onRequest(server: Server, req: Request; key: string) {.async.} =
|
||||
let (ws, error) = await verifyWebsocketRequest(req, key)
|
||||
if ws != nil:
|
||||
hint("Client connected from " & req.hostname)
|
||||
ws.protocol = key
|
||||
server.clients.add(newClient(server, ws, req.hostname))
|
||||
asyncCheck processClient(server, server.clients[^1])
|
||||
else:
|
||||
warn("WS negotiation failed: " & $error)
|
||||
await req.respond(Http400, "WebSocket negotiation failed: " & $error)
|
||||
req.client.close()
|
||||
|
||||
proc serve*(key: string; handler: ReqHandler) =
|
||||
let httpServer = newAsyncHttpServer()
|
||||
let server = Server(clients: @[], msgs: @[], handler: handler, gid: 0)
|
||||
|
||||
proc cb(req: Request): Future[void] {.async, gcsafe.} =
|
||||
await onRequest(server, req, key)
|
||||
|
||||
asyncCheck updateClients(server)
|
||||
waitFor httpServer.serve(Port(8080), cb)
|
||||
@@ -0,0 +1,55 @@
|
||||
include system/inclrtl
|
||||
import macros
|
||||
|
||||
proc transLastStmt(n, res, bracketExpr: NimNode): (NimNode, NimNode, NimNode) =
|
||||
case n.kind
|
||||
of nnkIfExpr, nnkIfStmt, nnkTryStmt, nnkCaseStmt:
|
||||
result[0] = copyNimTree(n)
|
||||
result[1] = copyNimTree(n)
|
||||
result[2] = copyNimTree(n)
|
||||
for i in ord(n.kind == nnkCaseStmt)..<n.len:
|
||||
(result[0][i], result[1][^1], result[2][^1]) = transLastStmt(n[i], res, bracketExpr)
|
||||
of nnkStmtList, nnkStmtListExpr, nnkBlockStmt, nnkBlockExpr, nnkWhileStmt,
|
||||
nnkForStmt, nnkElifBranch, nnkElse, nnkElifExpr, nnkOfBranch, nnkExceptBranch:
|
||||
result[0] = copyNimTree(n)
|
||||
result[1] = copyNimTree(n)
|
||||
result[2] = copyNimTree(n)
|
||||
if n.len >= 1:
|
||||
(result[0][^1], result[1][^1], result[2][^1]) = transLastStmt(n[^1], res, bracketExpr)
|
||||
of nnkTableConstr:
|
||||
result[1] = n[0][0]
|
||||
result[2] = n[0][1]
|
||||
if bracketExpr.len == 1:
|
||||
bracketExpr.add([newCall(bindSym"typeof", newEmptyNode()), newCall(
|
||||
bindSym"typeof", newEmptyNode())])
|
||||
template adder(res, k, v) = res[k] = v
|
||||
result[0] = getAst(adder(res, n[0][0], n[0][1]))
|
||||
of nnkCurly:
|
||||
result[2] = n[0]
|
||||
if bracketExpr.len == 1:
|
||||
bracketExpr.add(newCall(bindSym"typeof", newEmptyNode()))
|
||||
template adder(res, v) = res.incl(v)
|
||||
result[0] = getAst(adder(res, n[0]))
|
||||
else:
|
||||
result[2] = n
|
||||
if bracketExpr.len == 1:
|
||||
bracketExpr.add(newCall(bindSym"typeof", newEmptyNode()))
|
||||
template adder(res, v) = res.add(v)
|
||||
result[0] = getAst(adder(res, n))
|
||||
|
||||
macro collect*(init, body: untyped): untyped =
|
||||
let res = genSym(nskVar, "collectResult")
|
||||
expectKind init, {nnkCall, nnkIdent, nnkSym}
|
||||
let bracketExpr = newTree(nnkBracketExpr,
|
||||
if init.kind == nnkCall: init[0] else: init)
|
||||
let (resBody, keyType, valueType) = transLastStmt(body, res, bracketExpr)
|
||||
if bracketExpr.len == 3:
|
||||
bracketExpr[1][1] = keyType
|
||||
bracketExpr[2][1] = valueType
|
||||
else:
|
||||
bracketExpr[1][1] = valueType
|
||||
let call = newTree(nnkCall, bracketExpr)
|
||||
if init.kind == nnkCall:
|
||||
for i in 1 ..< init.len:
|
||||
call.add init[i]
|
||||
result = newTree(nnkStmtListExpr, newVarStmt(res, call), resBody, res)
|
||||
@@ -0,0 +1 @@
|
||||
--path:"$projectDir/../../ormin"
|
||||
@@ -0,0 +1,26 @@
|
||||
-- lower case, upper case, and quoted table names
|
||||
create table lower_table (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
CREATE TABLE UPPER_TABLE (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- std sql quoted table name
|
||||
create table "Quoted Table" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- sqlite quoted table name
|
||||
create table `Quoted Table2` (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "UPPER_QUOTED" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "A""B" (
|
||||
id integer primary key
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
create table if not exists thread(
|
||||
id serial primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified timestamp not null default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create unique index if not exists ThreadNameIx on thread (name);
|
||||
|
||||
create table if not exists person(
|
||||
id serial primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
email varchar(30) not null,
|
||||
creation timestamp not null default CURRENT_TIMESTAMP,
|
||||
salt varchar(128) not null,
|
||||
status varchar(30) not null,
|
||||
lastOnline timestamp not null default CURRENT_TIMESTAMP,
|
||||
ban varchar(128) not null default ''
|
||||
);
|
||||
|
||||
create unique index if not exists UserNameIx on person (name);
|
||||
|
||||
create table if not exists post(
|
||||
id serial primary key,
|
||||
author integer not null,
|
||||
ip varchar(20) not null,
|
||||
header varchar(100) not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation timestamp not null default CURRENT_TIMESTAMP,
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists session(
|
||||
id serial primary key,
|
||||
ip varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
userid integer not null,
|
||||
lastModified timestamp not null default CURRENT_TIMESTAMP,
|
||||
foreign key (userid) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists antibot(
|
||||
id serial primary key,
|
||||
ip varchar(20) not null,
|
||||
answer varchar(30) not null,
|
||||
created timestamp not null default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create table if not exists error(
|
||||
uuid varchar(36) primary key,
|
||||
message varchar(1000) not null,
|
||||
created timestamp not null default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create index PersonStatusIdx on person(status);
|
||||
create index PostByAuthorIdx on post(thread, author);
|
||||
@@ -0,0 +1,62 @@
|
||||
pragma foreign_keys = on;
|
||||
|
||||
create table if not exists thread(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create unique index if not exists ThreadNameIx on thread (name);
|
||||
|
||||
create table if not exists person(
|
||||
id integer primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
email varchar(30) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
salt varchar(128) not null,
|
||||
status varchar(30) not null,
|
||||
lastOnline timestamp not null default (DATETIME('now')),
|
||||
ban varchar(128) not null default ''
|
||||
);
|
||||
|
||||
create unique index if not exists UserNameIx on person (name);
|
||||
|
||||
create table if not exists post(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
ip varchar(20) not null,
|
||||
header varchar(100) not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists session(
|
||||
id integer primary key,
|
||||
ip varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
userid integer not null,
|
||||
lastModified timestamp not null default (DATETIME('now')),
|
||||
foreign key (userid) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists antibot(
|
||||
id integer primary key,
|
||||
ip varchar(20) not null,
|
||||
answer varchar(30) not null,
|
||||
created timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create table if not exists error(
|
||||
uuid varchar(36) primary key,
|
||||
message varchar(1000) not null,
|
||||
created timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create index PersonStatusIdx on person(status);
|
||||
create index PostByAuthorIdx on post(thread, author);
|
||||
@@ -0,0 +1,45 @@
|
||||
create table if not exists tb_serial(
|
||||
typserial serial primary key,
|
||||
typinteger integer not null
|
||||
);
|
||||
|
||||
create table if not exists tb_boolean(
|
||||
typboolean boolean not null
|
||||
);
|
||||
|
||||
create table if not exists tb_float(
|
||||
typfloat real not null
|
||||
);
|
||||
|
||||
create table if not exists tb_string(
|
||||
typstring text not null
|
||||
);
|
||||
|
||||
create table if not exists tb_timestamp(
|
||||
dt timestamp not null,
|
||||
dtn timestamptz not null,
|
||||
dtz timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists tb_json(
|
||||
typjson json not null
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_pk(
|
||||
pk1 integer not null,
|
||||
pk2 integer not null,
|
||||
message text not null,
|
||||
primary key (pk1, pk2)
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_fk(
|
||||
id integer not null,
|
||||
fk1 integer not null,
|
||||
fk2 integer not null,
|
||||
foreign key (fk1, fk2) references tb_composite_pk(pk1, pk2)
|
||||
);
|
||||
|
||||
create table if not exists tb_nullable(
|
||||
id integer primary key,
|
||||
note text null
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
create table if not exists tb_serial(
|
||||
typserial integer primary key,
|
||||
typinteger integer not null
|
||||
);
|
||||
|
||||
create table if not exists tb_boolean(
|
||||
typboolean boolean not null
|
||||
);
|
||||
|
||||
create table if not exists tb_float(
|
||||
typfloat real not null
|
||||
);
|
||||
|
||||
create table if not exists tb_string(
|
||||
typstring text not null
|
||||
);
|
||||
|
||||
create table if not exists tb_timestamp(
|
||||
dt1 timestamp not null,
|
||||
dt2 timestamp not null
|
||||
);
|
||||
|
||||
create table if not exists tb_json(
|
||||
typjson json not null
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_pk(
|
||||
pk1 integer not null,
|
||||
pk2 integer not null,
|
||||
message text not null,
|
||||
primary key (pk1, pk2)
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_fk(
|
||||
id integer not null,
|
||||
fk1 integer not null,
|
||||
fk2 integer not null,
|
||||
foreign key (fk1, fk2) references tb_composite_pk(pk1, pk2)
|
||||
);
|
||||
create table if not exists tb_blob(
|
||||
id integer primary key,
|
||||
typblob blob not null
|
||||
);
|
||||
|
||||
create table if not exists tb_nullable(
|
||||
id integer primary key,
|
||||
note text null
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
create table user_static (
|
||||
id integer primary key,
|
||||
name varchar(255) not null
|
||||
);
|
||||
@@ -0,0 +1,508 @@
|
||||
import unittest, json, strutils, strformat, sequtils, macros, times, os, math, unicode
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
|
||||
when defined(postgre):
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
import db_connector/db_postgres as db_postgres
|
||||
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "model_postgre")
|
||||
const sqlFileName = "model_postgre.sql"
|
||||
let db {.global.} = db_postgres.open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "model_sqlite")
|
||||
const sqlFileName = "model_sqlite.sql"
|
||||
# let db {.global.} = open(":memory:", "", "", "")
|
||||
let db {.global.} = open("test.db", "", "", "")
|
||||
|
||||
let
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / sqlFileName)
|
||||
|
||||
proc substr(s: string; start, length: int): string {.importSql.}
|
||||
|
||||
let
|
||||
min = -3
|
||||
max = 3
|
||||
serial_int_seqs = [(1, min), (2, 1), (3, 2), (4, max)]
|
||||
|
||||
suite &"Test common database types and functions of {backend}":
|
||||
discard
|
||||
|
||||
suite "serial_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_serial")
|
||||
db.createTable(sqlFile, "tb_serial")
|
||||
|
||||
test "insert":
|
||||
for i in serial_int_seqs:
|
||||
query:
|
||||
insert tb_serial(typinteger = ?i[1])
|
||||
check db.getValue(sql"select count(*) from tb_serial") == $serial_int_seqs.len
|
||||
|
||||
test "json":
|
||||
for i in serial_int_seqs:
|
||||
let v = %*{"typinteger": i[1]}
|
||||
query:
|
||||
insert tb_serial(typinteger = %v["typinteger"])
|
||||
check db.getValue(sql"select count(*) from tb_serial") == $serial_int_seqs.len
|
||||
|
||||
|
||||
suite "serial":
|
||||
db.dropTable(sqlFile, "tb_serial")
|
||||
db.createTable(sqlFile, "tb_serial")
|
||||
|
||||
let insertSql = sql"insert into tb_serial(typinteger) values (?)"
|
||||
for (_, v) in serial_int_seqs:
|
||||
db.exec(insertSql, v)
|
||||
doAssert db.getValue(sql"select count(*) from tb_serial") == $serial_int_seqs.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_serial(typserial, typinteger)
|
||||
check res == serial_int_seqs
|
||||
|
||||
test "where":
|
||||
let
|
||||
id = 1
|
||||
res = query:
|
||||
select tb_serial(typserial, typinteger)
|
||||
where typserial == ?id
|
||||
check res == serial_int_seqs.filterIt(it[0] == id)
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_serial(typserial, typinteger)
|
||||
produce json
|
||||
check res == %*serial_int_seqs.mapIt(%*{"typserial": it[0], "typinteger": it[1]})
|
||||
|
||||
test "count":
|
||||
let res = query:
|
||||
select tb_serial(count(_))
|
||||
check res[0] == serial_int_seqs.len
|
||||
|
||||
test "sum":
|
||||
let res = query:
|
||||
select tb_serial(sum(typinteger))
|
||||
check res[0] == serial_int_seqs.mapIt(it[1]).sum()
|
||||
|
||||
test "avg":
|
||||
let res = query:
|
||||
select tb_serial(avg(typinteger))
|
||||
check res[0] == serial_int_seqs.mapIt(it[1]).sum() / serial_int_seqs.len()
|
||||
|
||||
test "min":
|
||||
let res = query:
|
||||
select tb_serial(min(typinteger))
|
||||
check res[0] == min
|
||||
|
||||
test "max":
|
||||
let res = query:
|
||||
select tb_serial(max(typinteger))
|
||||
check res[0] == max
|
||||
|
||||
test "abs":
|
||||
let res = query:
|
||||
select tb_serial(abs(typinteger))
|
||||
check res == serial_int_seqs.mapIt(abs(it[1]))
|
||||
|
||||
|
||||
let seqs = toSeq(1..3)
|
||||
|
||||
suite "boolean_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_boolean")
|
||||
db.createTable(sqlFile, "tb_boolean")
|
||||
|
||||
test "insert":
|
||||
for i in seqs:
|
||||
let b = (i mod 2 == 0)
|
||||
query:
|
||||
insert tb_boolean(typboolean = ?b)
|
||||
check db.getValue(sql"select count(*) from tb_boolean") == $seqs.len
|
||||
|
||||
test "json":
|
||||
for i in seqs:
|
||||
let b = %*{"typboolean": (i mod 2 == 0)}
|
||||
query:
|
||||
insert tb_boolean(typboolean = %b["typboolean"])
|
||||
check db.getValue(sql"select count(*) from tb_boolean") == $seqs.len
|
||||
|
||||
|
||||
suite "boolean":
|
||||
db.dropTable(sqlFile, "tb_boolean")
|
||||
db.createTable(sqlFile, "tb_boolean")
|
||||
|
||||
proc toBool(x: int): bool =
|
||||
if x mod 2 == 0: true else: false
|
||||
|
||||
proc toDbValue(x: int): auto =
|
||||
when defined(postgre):
|
||||
if x mod 2 == 0: "t" else: "f"
|
||||
else:
|
||||
if x mod 2 == 0: 1 else: 0
|
||||
|
||||
let insertSql = sql"insert into tb_boolean(typboolean) values (?)"
|
||||
for i in seqs:
|
||||
db.exec(insertSql, toDbValue(i))
|
||||
doAssert db.getValue(sql"select count(*) from tb_boolean") == $seqs.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_boolean(typboolean)
|
||||
check res == seqs.mapIt(it mod 2 == 0)
|
||||
|
||||
test "where":
|
||||
let
|
||||
b = true
|
||||
res = query:
|
||||
select tb_boolean(typboolean)
|
||||
where typboolean == ?b
|
||||
check res == seqs.map(toBool).filterIt(it == b)
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_boolean(typboolean)
|
||||
produce json
|
||||
check res == %*seqs.mapIt(%*{"typboolean": toBool(it)})
|
||||
|
||||
when defined(sqlite):
|
||||
test "importSql substr wrong type":
|
||||
## TODO: should this be allowed?
|
||||
let res = query:
|
||||
select tb_boolean(substr(typboolean, 1, 2))
|
||||
echo "res: ", res
|
||||
|
||||
let fs = [-3.14, 2.56, 10.45]
|
||||
|
||||
suite "float_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_float")
|
||||
db.createTable(sqlFile, "tb_float")
|
||||
|
||||
test "insert":
|
||||
for f in fs:
|
||||
query:
|
||||
insert tb_float(typfloat = ?f)
|
||||
check db.getValue(sql"select count(*) from tb_float") == $fs.len
|
||||
|
||||
test "json":
|
||||
for f in fs:
|
||||
let v = %*{"typfloat": f}
|
||||
query:
|
||||
insert tb_float(typfloat = %v["typfloat"])
|
||||
check db.getValue(sql"select count(*) from tb_float") == $fs.len
|
||||
|
||||
|
||||
suite "float":
|
||||
db.dropTable(sqlFile, "tb_float")
|
||||
db.createTable(sqlFile, "tb_float")
|
||||
|
||||
let insertSql = sql"insert into tb_float(typfloat) values (?)"
|
||||
for f in fs:
|
||||
db.exec(insertSql, f)
|
||||
doAssert db.getValue(sql"select count(*) from tb_float") == $fs.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_float(typfloat)
|
||||
check res == fs
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_float(typfloat)
|
||||
where typfloat == ?fs[0]
|
||||
check res == [fs[0]]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_float(typfloat)
|
||||
produce json
|
||||
check res == %*fs.mapIt(%*{"typfloat": it})
|
||||
|
||||
test "abs":
|
||||
let res = query:
|
||||
select tb_float(abs(typfloat))
|
||||
check res == fs.mapIt(abs(it))
|
||||
|
||||
let ss = ["one", "Two", "three", "第四", "four'th"]
|
||||
|
||||
suite "string_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_string")
|
||||
db.createTable(sqlFile, "tb_string")
|
||||
|
||||
test "insert":
|
||||
for v in ss:
|
||||
query:
|
||||
insert tb_string(typstring = ?v)
|
||||
check db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
test "insert":
|
||||
for v in ss:
|
||||
query:
|
||||
insert tb_string(typstring = "can't touch this")
|
||||
check db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
test "json":
|
||||
for v in ss:
|
||||
let j = %*{"typstring": v}
|
||||
query:
|
||||
insert tb_string(typstring = %j["typstring"])
|
||||
check db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
suite "string":
|
||||
db.dropTable(sqlFile, "tb_string")
|
||||
db.createTable(sqlFile, "tb_string")
|
||||
|
||||
let insertsql = sql"insert into tb_string(typstring) values (?)"
|
||||
for v in ss:
|
||||
db.exec(insertsql, v)
|
||||
doAssert db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_string(typstring)
|
||||
check res == ss
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_string(typstring)
|
||||
where typstring == ?ss[0]
|
||||
check res[0] == ss[0]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_string(typstring)
|
||||
produce json
|
||||
check res == %*ss.mapIt(%*{"typstring": it})
|
||||
|
||||
test "concat_op":
|
||||
let
|
||||
s = "typstring: "
|
||||
let res = query:
|
||||
select tb_string(?s & typstring)
|
||||
check res == ss.mapIt(s & it)
|
||||
|
||||
test "length":
|
||||
let res = query:
|
||||
select tb_string(length(typstring))
|
||||
check res == ss.mapIt(it.runeLen)
|
||||
|
||||
test "lower":
|
||||
let res = query:
|
||||
select tb_string(lower(typstring))
|
||||
check res == ss.mapIt(it.toLowerAscii)
|
||||
|
||||
test "upper":
|
||||
let res = query:
|
||||
select tb_string(upper(typstring))
|
||||
check res == ss.mapIt(it.toUpperAscii)
|
||||
|
||||
test "replace":
|
||||
let res = query:
|
||||
select tb_string(replace(typstring, "e", "o"))
|
||||
check res == ss.mapIt(it.replace("e", "o"))
|
||||
|
||||
test "importSql substr length 0":
|
||||
let res = query:
|
||||
select tb_string(substr(typstring, 1, 0))
|
||||
let expected = ss.mapIt($(toRunes(it)[0..<0]))
|
||||
check res == expected
|
||||
|
||||
test "importSql substr length no arg types checked":
|
||||
# TODO: fixme!
|
||||
# the `functions` array only contains the types of the return
|
||||
# but sqlite doesn't really care...
|
||||
let res = query:
|
||||
select tb_string(substr(typstring, "1", 2))
|
||||
let expected = ss.mapIt($(toRunes(it)[0..<2]))
|
||||
check res == expected
|
||||
|
||||
let js = [
|
||||
%*{"name": "tom", "age": 30},
|
||||
%*{"name": "bob", "age": 35},
|
||||
%*{"name": "jack", "age": 23}
|
||||
]
|
||||
|
||||
suite "insert_json":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_json")
|
||||
db.createTable(sqlFile, "tb_json")
|
||||
|
||||
test "insert":
|
||||
for j in js:
|
||||
query:
|
||||
insert tb_json(typjson = ?j)
|
||||
check db.getValue(sql"select count(*) from tb_json") == $js.len
|
||||
|
||||
suite "json":
|
||||
db.dropTable(sqlFile, "tb_json")
|
||||
db.createTable(sqlFile, "tb_json")
|
||||
|
||||
let insertSql = sql"insert into tb_json(typjson) values (?)"
|
||||
for j in js:
|
||||
db.exec(insertSql, j)
|
||||
doAssert db.getValue(sql"select count(*) from tb_json") == $js.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_json(typjson)
|
||||
check res == js
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_json(typjson)
|
||||
produce json
|
||||
check res == %*js.mapIt(%*{"typjson": it})
|
||||
|
||||
|
||||
let cps = [
|
||||
(1, 1, "one-one"),
|
||||
(1, 2, "one-two"),
|
||||
(2, 1, "two-one")
|
||||
]
|
||||
|
||||
suite "composite_pk_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
|
||||
test "insert":
|
||||
for r in cps:
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = ?r[0], pk2 = ?r[1], message = ?r[2])
|
||||
check db.getValue(sql"select count(*) from tb_composite_pk") == $cps.len
|
||||
|
||||
test "json":
|
||||
for r in cps:
|
||||
let v = %*{"pk1": r[0], "pk2": r[1], "message": r[2]}
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = %v["pk1"], pk2 = %v["pk2"], message = %v["message"])
|
||||
check db.getValue(sql"select count(*) from tb_composite_pk") == $cps.len
|
||||
|
||||
|
||||
suite "composite_pk":
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
|
||||
let insertSql = sql"insert into tb_composite_pk(pk1, pk2, message) values (?, ?, ?)"
|
||||
for r in cps:
|
||||
db.exec(insertSql, r[0], r[1], r[2])
|
||||
doAssert db.getValue(sql"select count(*) from tb_composite_pk") == $cps.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_composite_pk(pk1, pk2, message)
|
||||
check res == cps
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_composite_pk(pk1, pk2, message)
|
||||
where pk1 == ?cps[0][0]
|
||||
check res == cps.filterIt(it[0] == cps[0][0])
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_composite_pk(pk1, pk2, message)
|
||||
produce json
|
||||
check res == %*cps.mapIt(%*{"pk1": it[0], "pk2": it[1], "message": it[2]})
|
||||
|
||||
|
||||
suite "nullable":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_nullable")
|
||||
db.createTable(sqlFile, "tb_nullable")
|
||||
query:
|
||||
insert tb_nullable(id = 1, note = nil)
|
||||
query:
|
||||
insert tb_nullable(id = 2, note = "hello")
|
||||
|
||||
test "where_null":
|
||||
let res = query:
|
||||
select tb_nullable(id)
|
||||
where note == nil
|
||||
check res == @[1]
|
||||
|
||||
test "where_not_null":
|
||||
let res = query:
|
||||
select tb_nullable(id)
|
||||
where note != null
|
||||
check res == @[2]
|
||||
|
||||
test "update_to_null":
|
||||
query:
|
||||
update tb_nullable(note = null)
|
||||
where id == 2
|
||||
let res = query:
|
||||
select tb_nullable(id)
|
||||
where note == nil
|
||||
check res == @[1, 2]
|
||||
|
||||
test "json_null":
|
||||
let res = query:
|
||||
select tb_nullable(id, note)
|
||||
orderby id
|
||||
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
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import unittest, os, sequtils
|
||||
import db_connector/db_common
|
||||
from db_connector/db_sqlite import open, exec, getValue
|
||||
import ormin/db_utils
|
||||
|
||||
const usesLegacySqliteDropNames = defined(sqlite) and defined(orminLegacySqliteDropNames)
|
||||
|
||||
let
|
||||
db {.global.} = open(":memory:", "", "", "")
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / "db_utils_case_quoted.sql")
|
||||
|
||||
let sqlContent = """
|
||||
-- lower case, upper case, and quoted table names
|
||||
create table lower_table (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
CREATE TABLE UPPER_TABLE (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- std sql quoted table name
|
||||
create table "Quoted Table" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- sqlite quoted table name
|
||||
create table `Quoted Table2` (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "UPPER_QUOTED" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "A""B" (
|
||||
id integer primary key
|
||||
);
|
||||
"""
|
||||
|
||||
const staticSqlContent = staticLoad("db_utils_case_quoted.sql")
|
||||
|
||||
writeFile($sqlFile, sqlContent)
|
||||
|
||||
suite "db_utils: case and quoted names":
|
||||
test "staticLoad returns typed compile-time SQL":
|
||||
let loaded: DbSql = staticSqlContent
|
||||
check $loaded == sqlContent
|
||||
|
||||
test "quoteDbIdentifier quotes and escapes identifiers":
|
||||
check $quoteDbIdentifier("lower_table") == "lower_table"
|
||||
check $quoteDbIdentifier("UPPER_TABLE") == "upper_table"
|
||||
check $quoteDbIdentifier("\"Quoted Table\"") == "\"Quoted Table\""
|
||||
check $quoteDbIdentifier("`Quoted Table2`") == "\"Quoted Table2\""
|
||||
check $quoteDbIdentifier("'Quoted Table3'") == "\"Quoted Table3\""
|
||||
check $quoteDbIdentifier("\"UPPER_TABLE\"") == "\"UPPER_TABLE\""
|
||||
check $quoteDbIdentifier("\"A\"\"B\"") == "\"A\"\"B\""
|
||||
check $quoteDbIdentifier("schema.table") == "schema.table"
|
||||
check $quoteDbIdentifier("Schema.\"Mixed Table\"") == "schema.\"Mixed Table\""
|
||||
check $quoteDbIdentifier("weird\"name") == "\"weird\"\"name\""
|
||||
|
||||
test "check tables names":
|
||||
let pairs = tablePairs(sqlContent).toSeq()
|
||||
check pairs.len == 6
|
||||
check pairs[0][0] == ("lower_table")
|
||||
check pairs[1][0] == ("upper_table")
|
||||
check pairs[2][0] == ("quoted table")
|
||||
check pairs[3][0] == ("quoted table2")
|
||||
check pairs[4][0] == ("upper_quoted")
|
||||
check pairs[5][0] == ("a\"b")
|
||||
|
||||
check pairs[0][1] == "create table lower_table(id integer primary key );"
|
||||
check pairs[1][1] == "create table UPPER_TABLE(id integer primary key );"
|
||||
check pairs[2][1] == "create table \"Quoted Table\"(id integer primary key );"
|
||||
check pairs[4][1] == "create table \"UPPER_QUOTED\"(id integer primary key );"
|
||||
check pairs[5][1] == "create table \"A\"\"B\"(id integer primary key );"
|
||||
|
||||
test "tablePairs parses compile-time SQL":
|
||||
let pairs = tablePairs(staticSqlContent).toSeq()
|
||||
check pairs.len == 6
|
||||
check pairs[0][0] == "lower_table"
|
||||
check pairs[1][0] == "upper_table"
|
||||
check pairs[2][0] == "quoted table"
|
||||
check pairs[3][0] == "quoted table2"
|
||||
check pairs[4][0] == "upper_quoted"
|
||||
check pairs[5][0] == "a\"b"
|
||||
|
||||
test "createTable creates all tables from SQL file":
|
||||
db.createTable(sqlFile)
|
||||
let countAll = db.getValue(sql"select count(*) from sqlite_master where type='table' and name in ('lower_table','UPPER_TABLE','Quoted Table')")
|
||||
check countAll == "3"
|
||||
|
||||
test "createTable with specific lowercased name matches quoted":
|
||||
# Use a new in-memory DB for isolation
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(sqlFile, "quoted table")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table'")
|
||||
check countQuoted == "1"
|
||||
|
||||
test "createTable creates all tables from compile-time SQL":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(staticSqlContent)
|
||||
let countAll = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name in ('lower_table','UPPER_TABLE','Quoted Table','Quoted Table2','UPPER_QUOTED','A\"B')"))
|
||||
check countAll == "6"
|
||||
|
||||
test "createTable with specific lowercased name matches quoted from compile-time SQL":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(staticSqlContent, "quoted table")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table'")
|
||||
check countQuoted == "1"
|
||||
|
||||
test "dropTable handles quoted names from SQL file":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(sqlFile)
|
||||
db2.dropTable(sqlFile, "quoted table2")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table2'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countQuoted == "1"
|
||||
else:
|
||||
check countQuoted == "0"
|
||||
|
||||
db2.dropTable(sqlFile, "upper_quoted")
|
||||
let countUpperQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'UPPER_QUOTED'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countUpperQuoted == "1"
|
||||
else:
|
||||
check countUpperQuoted == "0"
|
||||
|
||||
db2.dropTable(sqlFile, "a\"b")
|
||||
let countEscapedQuoted = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name = 'A\"B'"))
|
||||
when usesLegacySqliteDropNames:
|
||||
check countEscapedQuoted == "1"
|
||||
else:
|
||||
check countEscapedQuoted == "0"
|
||||
|
||||
test "dropTable removes tables from compile-time SQL":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(staticSqlContent)
|
||||
db2.dropTable(staticSqlContent, "quoted table2")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table2'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countQuoted == "1"
|
||||
else:
|
||||
check countQuoted == "0"
|
||||
|
||||
db2.dropTable(staticSqlContent, "upper_quoted")
|
||||
let countUpperQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'UPPER_QUOTED'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countUpperQuoted == "1"
|
||||
else:
|
||||
check countUpperQuoted == "0"
|
||||
|
||||
db2.dropTable(staticSqlContent, "a\"b")
|
||||
let countEscapedQuoted = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name = 'A\"B'"))
|
||||
when usesLegacySqliteDropNames:
|
||||
check countEscapedQuoted == "1"
|
||||
else:
|
||||
check countEscapedQuoted == "0"
|
||||
|
||||
db2.dropTable(staticSqlContent)
|
||||
let countAll = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name in ('lower_table','UPPER_TABLE','Quoted Table','Quoted Table2','UPPER_QUOTED','A\"B')"))
|
||||
when usesLegacySqliteDropNames:
|
||||
check countAll == "3"
|
||||
else:
|
||||
check countAll == "0"
|
||||
@@ -0,0 +1,822 @@
|
||||
import unittest, strformat, sequtils, algorithm, sugar, json, tables, random
|
||||
import os
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
when NimVersion < "1.2.0": import ./compat
|
||||
|
||||
|
||||
let testDir = currentSourcePath.parentDir()
|
||||
|
||||
when defined postgre:
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
from db_connector/db_postgres import exec, getValue
|
||||
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "forum_model_postgres")
|
||||
const sqlFileName = "forum_model_postgres.sql"
|
||||
let db {.global.} = open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "forum_model_sqlite")
|
||||
const sqlFileName = "forum_model_sqlite.sql"
|
||||
var memoryPath = testDir & "/" & ":memory:"
|
||||
let db {.global.} = open(memoryPath, "", "", "")
|
||||
|
||||
var sqlFilePath = Path(testDir & "/" & sqlFileName)
|
||||
|
||||
type
|
||||
Person = tuple[id: int,
|
||||
name: string,
|
||||
password: string,
|
||||
email: string,
|
||||
salt: string,
|
||||
status: string]
|
||||
Thread = tuple[id: int,
|
||||
name: string,
|
||||
views: int]
|
||||
Post = tuple[id: int,
|
||||
author: int,
|
||||
ip: string,
|
||||
header: string,
|
||||
content: string,
|
||||
thread: int]
|
||||
Antibot = tuple[id: int,
|
||||
ip: string,
|
||||
answer: string]
|
||||
|
||||
const
|
||||
personcount = 5
|
||||
threadcount = 5
|
||||
postcount = 10
|
||||
antibotcount = 5
|
||||
var
|
||||
persondata: seq[Person]
|
||||
threaddata: seq[Thread]
|
||||
postdata: seq[Post]
|
||||
antibotdata: seq[Antibot]
|
||||
|
||||
|
||||
suite &"Test ormin features of {backend}":
|
||||
discard
|
||||
|
||||
suite "query":
|
||||
db.dropTable(sqlFilePath)
|
||||
db.createTable(sqlFilePath)
|
||||
|
||||
static:
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
crossjoin person(name)
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
outerjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
leftjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
leftouterjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
rightjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
rightouterjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
fulljoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
fullouterjoin person(name) on author == id
|
||||
)
|
||||
|
||||
# prepare data to insert
|
||||
for i in 1..personcount:
|
||||
persondata.add((id: i,
|
||||
name: fmt"john{i}",
|
||||
password: fmt"pass{i}",
|
||||
email: fmt"john{i}@mail.com",
|
||||
salt: fmt"abcd{i}",
|
||||
status: fmt"ok{i}"))
|
||||
|
||||
for i in 1..threadcount:
|
||||
threaddata.add((id: i,
|
||||
name: fmt"thread{i}",
|
||||
views: i))
|
||||
|
||||
for i in 1..postcount:
|
||||
postdata.add((id: i,
|
||||
author: sample({1..personcount}),
|
||||
ip: "",
|
||||
header: fmt"title{i}",
|
||||
content: fmt"content{i}",
|
||||
thread: sample({1..threadcount})))
|
||||
|
||||
for i in 1..antibotcount:
|
||||
antibotdata.add((id: i,
|
||||
ip: "",
|
||||
answer: fmt"answer{i}"))
|
||||
|
||||
# insert data into database
|
||||
let
|
||||
insertperson = sql"insert into person (id, name, password, email, salt, status) values (?, ?, ?, ?, ?, ?)"
|
||||
insertthread = sql"insert into thread (id, name, views) values (?, ?, ?)"
|
||||
insertpost = sql"insert into post (id, author, ip, header, content, thread) values (?, ?, ?, ?, ?, ?)"
|
||||
insertantibot = sql"insert into antibot (id, ip, answer) values (?, ?, ?)"
|
||||
|
||||
for p in persondata:
|
||||
db.exec(insertperson, p.id, p.name, p.password, p.email, p.salt, p.status)
|
||||
|
||||
for t in threaddata:
|
||||
db.exec(insertthread, t.id, t.name, t.views)
|
||||
|
||||
for p in postdata:
|
||||
db.exec(insertpost, p.id, p.author, p.ip, p.header, p.content, p.thread)
|
||||
|
||||
for a in antibotdata:
|
||||
db.exec(insertantibot, a.id, a.ip, a.answer)
|
||||
|
||||
# check data in database
|
||||
let personexpected = db.getValue(sql"select count(*) from person")
|
||||
assert personexpected == $personcount
|
||||
|
||||
let threadexpected = db.getValue(sql"select count(*) from thread")
|
||||
assert threadexpected == $threadcount
|
||||
|
||||
let postexpected = db.getValue(sql"select count(*) from post")
|
||||
assert postexpected == $postcount
|
||||
|
||||
let antibotexpected = db.getValue(sql"select count(*) from antibot")
|
||||
assert antibotexpected == $antibotcount
|
||||
|
||||
test "table":
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
check res == persondata
|
||||
|
||||
test "all_column":
|
||||
let res = query:
|
||||
select person(_)
|
||||
check res.mapIt((it.id, it.name, it.password, it.email, it.salt, it.status)) == persondata
|
||||
|
||||
test "column_alias":
|
||||
let res = query:
|
||||
select person(id as personid, name as personname)
|
||||
check res == persondata.mapIt((personid: it.id, personname: it.name))
|
||||
|
||||
test "arithmetic":
|
||||
let res = query:
|
||||
select person(id * 4 / 2 + 2 - 1 as id)
|
||||
check res == persondata.mapIt(int(it.id * 4 / 2 + 2 - 1))
|
||||
|
||||
test "comparison":
|
||||
let id = 1
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id == ?id
|
||||
check res == [persondata[id - 1]]
|
||||
|
||||
test "comparison_ne":
|
||||
let id = 1
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id != ?id
|
||||
check res == persondata.filterIt(it.id != id)
|
||||
|
||||
test "comparison_ge":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id >= ?id
|
||||
check res == persondata.filterIt(it.id >= id)
|
||||
|
||||
test "comparison_le":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id <= ?id
|
||||
check res == persondata.filterIt(it.id <= id)
|
||||
|
||||
test "comparison_gt":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id > ?id
|
||||
check res == persondata.filterIt(it.id > id)
|
||||
|
||||
test "comparison_lt":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id < ?id
|
||||
check res == persondata.filterIt(it.id < id)
|
||||
|
||||
test "logical_not":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where not (id > ?id)
|
||||
check res == persondata.filterIt(it.id <= id)
|
||||
|
||||
test "logical_and":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id >= ?id1 and id <= ?id2
|
||||
check res == persondata.filterIt(it.id >= id1 and it.id <= id2)
|
||||
|
||||
test "logical_or":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id == ?id1 or id == ?id2
|
||||
check res == persondata.filterIt(it.id == id1 or it.id == id2)
|
||||
|
||||
test "logical_complex":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 3
|
||||
id3 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id > ?id2 and (id == ?id1 or id == ?id3)
|
||||
check res == persondata.filterIt(it.id == id3)
|
||||
|
||||
test "predicate_in":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id in ?id1 .. ?id2
|
||||
check res == persondata.filterIt(it.id in id1..id2)
|
||||
|
||||
test "predicate_not_in":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id notin ?id1 .. ?id2
|
||||
check res == persondata.filterIt(it.id < id1 or it.id > id2)
|
||||
|
||||
test "predicate_like":
|
||||
let pattern = "john1%"
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
where name `like` ?pattern
|
||||
check res == persondata.filterIt(it.name.startsWith("john1")).mapIt((it.id, it.name))
|
||||
|
||||
test "predicate_ilike":
|
||||
let pattern = "JOHN1%"
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
where name `ilike` ?pattern
|
||||
check res == persondata.filterIt(it.name.startsWith("john1")).mapIt((it.id, it.name))
|
||||
|
||||
test "predicate_not_like":
|
||||
let pattern = "john1%"
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
where not (name `like` ?pattern)
|
||||
check res == persondata.filterIt(not it.name.startsWith("john1")).mapIt((it.id, it.name))
|
||||
|
||||
test "distinct":
|
||||
var res = query:
|
||||
select `distinct` post(author)
|
||||
res.sort()
|
||||
let expected = postdata.mapIt(it.author).deduplicate().sortedByIt(it)
|
||||
check res == expected
|
||||
|
||||
test "count_distinct":
|
||||
let res = query:
|
||||
select post(count(distinct author))
|
||||
check res == @[postdata.mapIt(it.author).deduplicate().len]
|
||||
|
||||
test "window_row_number":
|
||||
let res = query:
|
||||
select post(author, id, over(row_number(), partitionby(author), orderby(id)) as rn)
|
||||
orderby author, id
|
||||
var expected: seq[(int, int, int)] = @[]
|
||||
var counters = initCountTable[int]()
|
||||
for p in postdata.sortedByIt((it.author, it.id)):
|
||||
counters.inc(p.author)
|
||||
expected.add((p.author, p.id, counters[p.author]))
|
||||
check res == expected
|
||||
|
||||
test "window_running_sum":
|
||||
let res = query:
|
||||
select post(author, id, over(sum(id), partitionby(author), orderby(id)) as running)
|
||||
orderby author, id
|
||||
var expected: seq[(int, int, int)] = @[]
|
||||
var sums = initTable[int, int]()
|
||||
for p in postdata.sortedByIt((it.author, it.id)):
|
||||
sums[p.author] = sums.getOrDefault(p.author) + p.id
|
||||
expected.add((p.author, p.id, sums[p.author]))
|
||||
check res == expected
|
||||
|
||||
test "limit":
|
||||
let id = 1
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
limit 1
|
||||
static:
|
||||
echo "LIMIT TEST: ", typeof(res)
|
||||
check res == persondata[id - 1]
|
||||
|
||||
test "offset":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
limit 2
|
||||
offset 2
|
||||
check res == persondata[2..3].mapIt((it.id, it.name))
|
||||
|
||||
test "match_assignment":
|
||||
let id = 1
|
||||
let (name, password, email, salt, status) = query:
|
||||
select person(name, password, email, salt, status)
|
||||
where id == ?id
|
||||
limit 1
|
||||
check:
|
||||
name == persondata[id - 1].name
|
||||
password == persondata[id - 1].password
|
||||
email == persondata[id - 1].email
|
||||
salt == persondata[id - 1].salt
|
||||
status == persondata[id - 1].status
|
||||
|
||||
test "groupby":
|
||||
let res = query:
|
||||
select post(author, count(id))
|
||||
groupby author
|
||||
let counttable = postdata.mapIt(it.author).toCountTable()
|
||||
for (author, c) in res:
|
||||
check counttable[author] == c
|
||||
|
||||
test "groupby_aggregate":
|
||||
let author = 3
|
||||
let res = query:
|
||||
select post(count(id))
|
||||
where author == ?author
|
||||
groupby author
|
||||
let c = postdata.mapIt(it.author).toCountTable()[author]
|
||||
check res == [c]
|
||||
|
||||
test "orderby":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
orderby id
|
||||
check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])
|
||||
|
||||
test "orderby_asc":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
orderby asc(id)
|
||||
check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])
|
||||
|
||||
test "orderby_desc":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
orderby desc(id)
|
||||
let expected = persondata.mapIt((it.id, it.name))
|
||||
.sorted((x, y) => system.cmp(x[0], y[0]), Descending)
|
||||
check res == expected
|
||||
|
||||
test "orderby_key_select":
|
||||
let res = query:
|
||||
select person(id as personid, name)
|
||||
orderby personid
|
||||
check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])
|
||||
|
||||
test "orderby_key_not_select":
|
||||
let res = query:
|
||||
select person(name)
|
||||
orderby id
|
||||
check res == persondata.sortedByIt(it.id).mapIt(it.name)
|
||||
|
||||
test "orderby_mulitple":
|
||||
# test fix #30 Incorrect handling of multiple sort keys in orderby
|
||||
let res = query:
|
||||
select post(author, id)
|
||||
orderby author, desc(id)
|
||||
check res == postdata.mapIt((it.author, it.id))
|
||||
.sorted((x, y) => system.cmp(x[1], y[1]), Descending)
|
||||
.sortedByIt(it[0])
|
||||
|
||||
test "having":
|
||||
let id = 4
|
||||
let res = query:
|
||||
select post(author, count(_) as count)
|
||||
groupby author
|
||||
having author == ?id
|
||||
let expected = collect(newSeq):
|
||||
for p in postdata.mapIt(it.author).toCountTable().pairs():
|
||||
if p[0] == id: p
|
||||
check res == expected
|
||||
|
||||
test "having_aggregate":
|
||||
let countvalue = 2
|
||||
let res = query:
|
||||
select post(author, count(id) as count)
|
||||
groupby author
|
||||
having count(id) >= ?countvalue
|
||||
let expected = collect(newSeq):
|
||||
for p in postdata.mapIt(it.author).toCountTable().pairs():
|
||||
if p[1] >= countvalue: p
|
||||
check res.sortedByIt(it[0]) == expected.sortedByIt(it[0])
|
||||
|
||||
test "having_complex":
|
||||
let
|
||||
authorid1 = 1
|
||||
authorid2 = 3
|
||||
countvalue = 2
|
||||
let res = query:
|
||||
select post(author, count(id) as count)
|
||||
groupby author
|
||||
having count(id) >= ?countvalue and (author == ?authorid1 or author == ?authorid2)
|
||||
let expected = collect(newSeq):
|
||||
for p in postdata.mapIt(it.author).toCountTable().pairs():
|
||||
if p[0] in [authorid1, authorid2] and p[1] >= countvalue: p
|
||||
check res == expected
|
||||
|
||||
test "subquery":
|
||||
let res = query:
|
||||
select post(id)
|
||||
where author in
|
||||
(select person(id))
|
||||
check res.sortedByIt(it) == postdata.mapIt(it.id)
|
||||
|
||||
test "subquery_nest2":
|
||||
let
|
||||
personid1 = 1
|
||||
personid2 = 2
|
||||
expectedpost = postdata.filterIt(it.author in [personid1, personid2])
|
||||
.mapIt(it.id)
|
||||
let res = query:
|
||||
select post(id)
|
||||
where author in
|
||||
(select person(id) where id == ?personid1 or id == ?personid2)
|
||||
check res.sortedByIt(it) == expectedpost.sortedByIt(it)
|
||||
|
||||
test "subquery_nest3":
|
||||
var res = query:
|
||||
select thread(id)
|
||||
where id in (select post(thread) where author in
|
||||
(select person(id) where id in {1, 2}))
|
||||
res.sort()
|
||||
check res == postdata.filterIt(it.author in [1, 2])
|
||||
.mapIt(it.thread)
|
||||
.sortedByIt(it)
|
||||
|
||||
test "subquery_having":
|
||||
# feature for having in subquery
|
||||
let id = 4
|
||||
let res = query:
|
||||
select person(id)
|
||||
where id in (
|
||||
select post(author) groupby author having author == ?id)
|
||||
check res == @[id]
|
||||
|
||||
test "exists":
|
||||
let authorid = postdata[0].author
|
||||
let res = query:
|
||||
select person(id)
|
||||
where exists(select post(id) where author == ?authorid)
|
||||
check res.sortedByIt(it) == persondata.mapIt(it.id)
|
||||
|
||||
test "not_exists":
|
||||
let missingAuthor = personcount + postcount
|
||||
let res = query:
|
||||
select person(id)
|
||||
where not exists(select post(id) where author == ?missingAuthor)
|
||||
check res.sortedByIt(it) == persondata.mapIt(it.id)
|
||||
|
||||
test "with_cte":
|
||||
let res = query:
|
||||
with recent(select post(id, author) where id <= 3)
|
||||
select recent(author)
|
||||
orderby id
|
||||
check res == postdata.filterIt(it.id <= 3).sortedByIt(it.id).mapIt(it.author)
|
||||
|
||||
test "with_cte_chain":
|
||||
let res = query:
|
||||
with recent(select post(id, author) where id <= 3)
|
||||
with authors(select recent(author))
|
||||
select authors(author)
|
||||
check res.sortedByIt(it) == postdata.filterIt(it.id <= 3).mapIt(it.author).sortedByIt(it)
|
||||
|
||||
test "with_cte_subquery":
|
||||
let res = query:
|
||||
with recent(select post(id, author) where id <= 3)
|
||||
select person(id)
|
||||
where id in (select recent(author))
|
||||
check res.sortedByIt(it) == postdata.filterIt(it.id <= 3).mapIt(it.author).deduplicate().sortedByIt(it)
|
||||
|
||||
test "union":
|
||||
var res = query:
|
||||
select person(id) where id <= 2
|
||||
union
|
||||
select person(id) where id >= 4
|
||||
res.sort()
|
||||
check res == @[1, 2, 4, 5]
|
||||
|
||||
res = query:
|
||||
union(
|
||||
select person(id) where id <= 2,
|
||||
select person(id) where id >= 4
|
||||
)
|
||||
res.sort()
|
||||
check res == @[1, 2, 4, 5]
|
||||
|
||||
test "intersect":
|
||||
var res = query:
|
||||
select person(id) where id <= 3
|
||||
intersect
|
||||
select person(id) where id >= 2
|
||||
res.sort()
|
||||
check res == @[2, 3]
|
||||
|
||||
res = query:
|
||||
intersect(
|
||||
select person(id) where id <= 3,
|
||||
select person(id) where id >= 2
|
||||
)
|
||||
res.sort()
|
||||
check res == @[2, 3]
|
||||
|
||||
test "except":
|
||||
var res = query:
|
||||
select person(id) where id <= 4
|
||||
`except`
|
||||
select person(id) where id in {2, 3}
|
||||
res.sort()
|
||||
check res == @[1, 4]
|
||||
|
||||
res = query:
|
||||
`except`(
|
||||
select person(id) where id <= 4,
|
||||
select person(id) where id in {2, 3}
|
||||
)
|
||||
res.sort()
|
||||
check res == @[1, 4]
|
||||
|
||||
test "complex_query_subquery_having":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 3
|
||||
let res = query:
|
||||
select thread(count(_))
|
||||
where id in (
|
||||
select post(thread) where (author == ?id1 or author == ?id2) and id in (
|
||||
select post(min(id)) groupby thread having min(id) > 3))
|
||||
limit 1
|
||||
|
||||
let threadinpost = postdata.mapIt(it.thread).deduplicate().sortedByIt(it)
|
||||
var postminids: seq[int]
|
||||
for id in threadinpost:
|
||||
let group = collect(newSeq):
|
||||
for p in postdata:
|
||||
if p.thread == id: p.id
|
||||
postminids.add(group.min())
|
||||
let threadids = postdata.filterIt(it.author in [id1, id2] and
|
||||
it.id in postminids.filterIt(it > 3))
|
||||
.mapIt(it.thread)
|
||||
check res == threadids.len()
|
||||
|
||||
test "auto_join":
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
join person(name)
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "join_on":
|
||||
# test fix #29 Cannot handle join on condition correctly
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
join person(name) on author == id
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "leftjoin_on":
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
leftjoin person(name) on author == id
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "leftouterjoin_on":
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
leftouterjoin person(name) on author == id
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "casewhen":
|
||||
# need more test, only number
|
||||
# has problem with string or expression in condition
|
||||
let res = query:
|
||||
select person(name, (if id == 1: 10 elif id == 2: 20 else: 30) as pid)
|
||||
check res == persondata.mapIt((
|
||||
name: it.name,
|
||||
pid: if it.id == 1: 10 elif it.id == 2: 20 else: 30))
|
||||
|
||||
test "produce_json":
|
||||
# test fix #27 Error: type mismatch: got <typeof(nil)>
|
||||
let threadjson = query:
|
||||
select thread(id, name)
|
||||
produce json
|
||||
let expected = threaddata.map do (it: tuple) -> JsonNode:
|
||||
result = newJObject()
|
||||
result["id"] = %it.id
|
||||
result["name"] = %it.name
|
||||
check threadjson == %expected
|
||||
|
||||
test "produce_nim":
|
||||
let threadnim = query:
|
||||
select thread(id, name)
|
||||
produce nim
|
||||
check threadnim == threaddata.mapIt((it.id, it.name))
|
||||
|
||||
test "tryquery":
|
||||
# unknow use, only not raise dbError?
|
||||
let res = tryQuery:
|
||||
select thread(id)
|
||||
check res == threaddata.mapIt(it.id)
|
||||
|
||||
test "createproc":
|
||||
createProc getAllThreadIds:
|
||||
select thread(id)
|
||||
check db.getAllThreadIds() == threaddata.mapIt(it.id)
|
||||
|
||||
test "createiter":
|
||||
createIter allThreadIdsIter:
|
||||
select thread(id)
|
||||
let res = collect(newSeq):
|
||||
for id in db.allThreadIdsIter:
|
||||
id
|
||||
check res.sortedByIt(it) == threaddata.mapIt(it.id)
|
||||
|
||||
test "update_concat_op":
|
||||
let
|
||||
id = 3
|
||||
name = persondata[id - 1].name
|
||||
appendstr = "updated"
|
||||
nameupdated = name & appendstr
|
||||
query:
|
||||
update person(name = name & ?appendstr)
|
||||
where id == ?id
|
||||
let res = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
check res[0] == nameupdated
|
||||
|
||||
test "delete_one":
|
||||
# test fix #14: delete not return value
|
||||
let id = 1
|
||||
query:
|
||||
delete antibot
|
||||
where id == ?id
|
||||
let res = query:
|
||||
select antibot(_)
|
||||
where id == ?id
|
||||
check res == []
|
||||
|
||||
test "delete_all":
|
||||
query:
|
||||
delete antibot
|
||||
let res = query:
|
||||
select antibot(_)
|
||||
check res == []
|
||||
|
||||
test "insert_return_id":
|
||||
# test fix #28 returning id fail under postgresql
|
||||
let expectedid = 6
|
||||
let id = query:
|
||||
insert antibot(id = ?expectedid, ip = "", answer = "just insert")
|
||||
returning id
|
||||
check id == expectedid
|
||||
|
||||
test "insert_return_answer":
|
||||
# test returning non-id parameter
|
||||
let expectedanswer = "just insert another"
|
||||
let answer = query:
|
||||
insert antibot(id = 9, ip = "", answer = ?expectedanswer)
|
||||
returning answer
|
||||
check answer == expectedanswer
|
||||
|
||||
test "insert_return_id_auto":
|
||||
# test returning id column
|
||||
when defined(postgre):
|
||||
# fix postgres sequence so next nextval returns 10
|
||||
discard db.getValue(sql"select setval('antibot_id_seq', ?, ?)", 10, false)
|
||||
let answer = query:
|
||||
insert antibot(ip = "", answer = "just auto insert")
|
||||
returning id
|
||||
check answer == 10
|
||||
|
||||
test "insert_returning_uuid":
|
||||
# test returning uuid column
|
||||
let expecteduuid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
let uuid = query:
|
||||
insert error(uuid = ?expecteduuid, message = "just insert")
|
||||
returning uuid
|
||||
check uuid == expecteduuid
|
||||
|
||||
test "where_json":
|
||||
let
|
||||
id = 1
|
||||
p = %*{"id": id}
|
||||
let res = query:
|
||||
select person(id)
|
||||
where id == %p["id"]
|
||||
check res == [id]
|
||||
|
||||
test "insert_json":
|
||||
let
|
||||
id = 7
|
||||
answer = "json answer"
|
||||
a = %*{"answer": answer}
|
||||
query:
|
||||
insert antibot(id = ?id, ip = "", answer = %a["answer"])
|
||||
let res = query:
|
||||
select antibot(answer)
|
||||
where id == ?id
|
||||
check res[0] == answer
|
||||
|
||||
test "update_json":
|
||||
let
|
||||
id = 3
|
||||
name = "json"
|
||||
p = %*{"name": name}
|
||||
query:
|
||||
update person(name = %p["name"])
|
||||
where id == ?id
|
||||
let res = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
check res[0] == name
|
||||
|
||||
test "insert_rawsql":
|
||||
let id = 8
|
||||
query:
|
||||
insert antibot(id = ?id, ip = "", answer = !!"'raw sql'",
|
||||
created = !!"CURRENT_TIMESTAMP")
|
||||
let res = query:
|
||||
select antibot(_)
|
||||
where id == ?id
|
||||
check res[0].answer == "raw sql"
|
||||
|
||||
test "update_rawsql":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
query:
|
||||
update person(name = !!"UPPER(name)")
|
||||
where id == ?id
|
||||
let res2 = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
check res[0] != res2[0]
|
||||
check res[0].toUpper() == res2[0]
|
||||
test "empty_string":
|
||||
db.exec(insertthread, 6, "", 77)
|
||||
|
||||
createProc getAllThreads:
|
||||
select thread(id, name, views)
|
||||
where id >= 5
|
||||
|
||||
let rows = db.getAllThreads
|
||||
check rows[1].id == 6
|
||||
check rows[1].views == 77
|
||||
check rows[1].name == ""
|
||||
@@ -0,0 +1,29 @@
|
||||
import unittest
|
||||
import ormin/ormin_sqlite as orm_sqlite
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
|
||||
import ormin
|
||||
|
||||
importModel(DbBackend.sqlite, "static_only_model", includeStatic = true)
|
||||
|
||||
let db {.global.} = orm_sqlite.open(":memory:", "", "", "")
|
||||
|
||||
suite "importModel includeStatic":
|
||||
test "sqlSchema is generated and usable without a generated Nim model":
|
||||
let schema: DbSql = sqlSchema
|
||||
check ($schema).len > 0
|
||||
|
||||
db.createTable(sqlSchema)
|
||||
db.exec(sql"insert into user_static (id, name) values (?, ?)", 1, "Ada")
|
||||
|
||||
let row = query:
|
||||
select user_static(id, name)
|
||||
where id == 1
|
||||
limit 1
|
||||
|
||||
check row.id == 1
|
||||
check row.name == "Ada"
|
||||
check db.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'user_static'") == "1"
|
||||
|
||||
db.dropTable(sqlSchema, "user_static")
|
||||
check db.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'user_static'") == "0"
|
||||
@@ -0,0 +1,130 @@
|
||||
import unittest, json, strutils, macros, times, os, sequtils
|
||||
# Postgres connection handled through ormin_postgre backend
|
||||
from db_connector/db_postgres import exec, getValue
|
||||
import ormin
|
||||
import ormin/ormin_postgre as ormin_postgre
|
||||
# import db_connector/db_postgres as db_postgres
|
||||
import ormin/db_utils
|
||||
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
|
||||
|
||||
importModel(DbBackend.postgre, "model_postgre")
|
||||
|
||||
let
|
||||
db {.global.} = ormin_postgre.open("localhost", "test", "test", "test_ormin")
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / "model_postgre.sql")
|
||||
|
||||
|
||||
suite "Test special database types and functions of postgre":
|
||||
discard
|
||||
|
||||
let
|
||||
dtStr1 = "2018-03-30 01:01:01"
|
||||
dt1 = parse(dtStr1, "yyyy-MM-dd HH:mm:ss")
|
||||
dtnStr1 = "2019-03-30 11:02:02+08"
|
||||
dtn1 = parse(dtnStr1, "yyyy-MM-dd HH:mm:sszz")
|
||||
dtzStr1 = "2020-03-30 09:03:03Z"
|
||||
dtz1 = parse(dtzStr1, "yyyy-MM-dd HH:mm:sszz")
|
||||
dtStr2 = "2018-03-30 01:01:01.123"
|
||||
dt2 = parse(dtStr2, "yyyy-MM-dd HH:mm:ss\'.\'fff")
|
||||
dtnStr2 = "2019-03-30 11:02:02.123+08"
|
||||
dtn2 = parse(dtnStr2, "yyyy-MM-dd HH:mm:ss\'.\'fffzz")
|
||||
dtzStr2 = "2020-03-30 09:03:03.123Z"
|
||||
dtz2 = parse(dtzStr2, "yyyy-MM-dd HH:mm:ss\'.\'fffzz")
|
||||
dtStr3 = "2018-03-30 01:01:01.123456"
|
||||
dt3 = parse(dtStr3, "yyyy-MM-dd HH:mm:ss\'.\'ffffff")
|
||||
dtnStr3 = "2019-03-30 11:02:02.123456+08"
|
||||
dtn3 = parse(dtnStr3, "yyyy-MM-dd HH:mm:ss\'.\'ffffffzz")
|
||||
dtzStr3 = "2020-03-30 09:03:03.123456Z"
|
||||
dtz3 = parse(dtzStr3, "yyyy-MM-dd HH:mm:ss\'.\'ffffffzz")
|
||||
dtjson1 = %*{"dt": dt1.format(jsonTimeFormat),
|
||||
"dtn": dtn1.format(jsonTimeFormat),
|
||||
"dtz": dtz1.format(jsonTimeFormat)}
|
||||
dtjson2 = %*{"dt": dt2.format(jsonTimeFormat),
|
||||
"dtn": dtn2.format(jsonTimeFormat),
|
||||
"dtz": dtz2.format(jsonTimeFormat)}
|
||||
dtjson3 = %*{"dt": dt3.format(jsonTimeFormat),
|
||||
"dtn": dtn3.format(jsonTimeFormat),
|
||||
"dtz": dtz3.format(jsonTimeFormat)}
|
||||
|
||||
let insertSql = sql"insert into tb_timestamp(dt, dtn, dtz) values (?, ?, ?)"
|
||||
|
||||
suite "timestamp_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
test "insert":
|
||||
query:
|
||||
insert tb_timestamp(dt = ?dt1, dtn = ?dtn1, dtz = ?dtz1)
|
||||
check db_postgres.getValue(db, sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
test "json":
|
||||
query:
|
||||
insert tb_timestamp(dt = %dtjson1["dt"],
|
||||
dtn = %dtjson1["dtn"],
|
||||
dtz = %dtjson1["dtz"])
|
||||
check db_postgres.getValue(db, sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
suite "timestamp":
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
db_postgres.exec(db, insertSql, dtStr1, dtnStr1, dtzStr1)
|
||||
db_postgres.exec(db, insertSql, dtStr2, dtnStr2, dtzStr2)
|
||||
db_postgres.exec(db, insertSql, dtStr3, dtnStr3, dtzStr3)
|
||||
doAssert db_postgres.getValue(db, sql"select count(*) from tb_timestamp") == "3"
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_timestamp(dt, dtn, dtz)
|
||||
check res == [(dt1, dtn1, dtz1),
|
||||
(dt2, dtn2, dtz2),
|
||||
(dt3, dtn3, dtz3)]
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt)
|
||||
where dt == ?dt1
|
||||
check res == [dt1]
|
||||
|
||||
test "in":
|
||||
let
|
||||
duration = initDuration(hours = 1)
|
||||
dtStart = dt1 - duration
|
||||
dtEnd = dt1 + duration
|
||||
res2 = query:
|
||||
select tb_timestamp(dt)
|
||||
where dt in ?dtStart .. ?dtEnd
|
||||
check res2 == [dt1, dt2, dt3]
|
||||
|
||||
test "iter":
|
||||
createIter iter:
|
||||
select tb_timestamp(dt)
|
||||
where dt == ?dt
|
||||
var res: seq[DateTime]
|
||||
for it in db.iter(dt1):
|
||||
res.add(it)
|
||||
check res == [dt1]
|
||||
|
||||
test "proc":
|
||||
createProc aproc:
|
||||
select tb_timestamp(dt)
|
||||
where dt == ?dt
|
||||
check db.aproc(dt1) == [dt1]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_timestamp(dt, dtn, dtz)
|
||||
produce json
|
||||
check res == %*[dtjson1, dtjson2, dtjson3]
|
||||
|
||||
test "json_where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt, dtn, dtz)
|
||||
where dt == %dtjson1["dt"]
|
||||
produce json
|
||||
check res[0] == dtjson1
|
||||
@@ -0,0 +1,216 @@
|
||||
import unittest, strformat, os, times, std/monotimes
|
||||
import std/options
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
import ormin/query_hooks
|
||||
|
||||
when defined(postgre):
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "model_postgre")
|
||||
const sqlFileName = "model_postgre.sql"
|
||||
let db {.global.} = open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "model_sqlite")
|
||||
const sqlFileName = "model_sqlite.sql"
|
||||
let db {.global.} = open("test.db", "", "", "")
|
||||
|
||||
let
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / sqlFileName)
|
||||
|
||||
type
|
||||
CompositeRow = object
|
||||
id: int
|
||||
message: string
|
||||
|
||||
RefCompositeRow = ref object
|
||||
id: int
|
||||
message: string
|
||||
|
||||
BenchmarkCompositeRow = object
|
||||
pk1: int
|
||||
message: string
|
||||
|
||||
NullableNoteOptionRow = object
|
||||
id: int
|
||||
note: Option[string]
|
||||
|
||||
MessageSize = distinct int
|
||||
|
||||
HookedMessageRow = object
|
||||
id: int
|
||||
message: MessageSize
|
||||
|
||||
NullFallbackNote = distinct string
|
||||
|
||||
HookedNullableNoteRow = object
|
||||
id: int
|
||||
note: NullFallbackNote
|
||||
|
||||
const
|
||||
benchmarkRowCount = 256
|
||||
benchmarkWarmupIterations = 75
|
||||
benchmarkIterations = 250
|
||||
benchmarkRounds = 5
|
||||
maxTypedQuerySlowdown = 1.20
|
||||
|
||||
proc fromQueryHook*(val: var MessageSize, value: string) =
|
||||
val = MessageSize(value.len)
|
||||
|
||||
proc fromQueryHook*(val: var NullFallbackNote, value: DbValue[string]) =
|
||||
if value.isNull:
|
||||
val = NullFallbackNote("<missing>")
|
||||
else:
|
||||
val = NullFallbackNote("note:" & value.value)
|
||||
|
||||
proc loadBenchmarkRows() =
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
for i in 1 .. benchmarkRowCount:
|
||||
let message = &"message-{i}"
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = ?i, pk2 = ?i, message = ?message)
|
||||
|
||||
proc benchmarkCurrentQuery(iterations: int): float =
|
||||
var checksum = 0
|
||||
let started = getMonoTime()
|
||||
for _ in 0 ..< iterations:
|
||||
let rows = query:
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
checksum += rows.len + rows[^1][0] + rows[^1][1].len
|
||||
doAssert checksum > 0
|
||||
result = (getMonoTime() - started).inNanoseconds.float / 1_000_000_000.0
|
||||
|
||||
proc benchmarkTypedQuery(iterations: int): float =
|
||||
var checksum = 0
|
||||
let started = getMonoTime()
|
||||
for _ in 0 ..< iterations:
|
||||
let rows = query(BenchmarkCompositeRow):
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
checksum += rows.len + rows[^1].pk1 + rows[^1].message.len
|
||||
doAssert checksum > 0
|
||||
result = (getMonoTime() - started).inNanoseconds.float / 1_000_000_000.0
|
||||
|
||||
suite &"query(T) mapping on {backend}":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
db.dropTable(sqlFile, "tb_nullable")
|
||||
db.createTable(sqlFile, "tb_nullable")
|
||||
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = 1, pk2 = 1, message = "hello")
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = 2, pk2 = 2, message = "world")
|
||||
|
||||
query:
|
||||
insert tb_nullable(id = 1, note = nil)
|
||||
query:
|
||||
insert tb_nullable(id = 2, note = "hello")
|
||||
|
||||
test "maps selected rows to objects":
|
||||
let rows = query(CompositeRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
orderby pk1
|
||||
|
||||
check rows == @[
|
||||
CompositeRow(id: 1, message: "hello"),
|
||||
CompositeRow(id: 2, message: "world")
|
||||
]
|
||||
|
||||
test "maps selected rows to ref objects":
|
||||
let rows = query(RefCompositeRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
orderby pk1
|
||||
|
||||
check rows.len == 2
|
||||
check rows[0] != nil
|
||||
check rows[0].id == 1
|
||||
check rows[0].message == "hello"
|
||||
check rows[1] != nil
|
||||
check rows[1].id == 2
|
||||
check rows[1].message == "world"
|
||||
|
||||
test "single-row query(T) returns a single object":
|
||||
let row = query(CompositeRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
where pk1 == 1 and pk2 == 1
|
||||
limit 1
|
||||
|
||||
check row == CompositeRow(id: 1, message: "hello")
|
||||
|
||||
test "maps nullable column to Option":
|
||||
let rows = query(NullableNoteOptionRow):
|
||||
select tb_nullable(id, note)
|
||||
orderby id
|
||||
|
||||
check rows[0].id == 1
|
||||
check rows[0].note.isNone
|
||||
check rows[1].id == 2
|
||||
check rows[1].note.isSome
|
||||
check rows[1].note.get == "hello"
|
||||
|
||||
test "maps object fields through user fromQueryHook overloads":
|
||||
let rows = query(HookedMessageRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
orderby pk1
|
||||
|
||||
check rows.len == 2
|
||||
check rows[0].id == 1
|
||||
check int(rows[0].message) == "hello".len
|
||||
check rows[1].id == 2
|
||||
check int(rows[1].message) == "world".len
|
||||
|
||||
test "maps scalar query(T) through user fromQueryHook overloads":
|
||||
let values = query(MessageSize):
|
||||
select tb_composite_pk(message)
|
||||
orderby pk1
|
||||
|
||||
check values.len == 2
|
||||
check int(values[0]) == "hello".len
|
||||
check int(values[1]) == "world".len
|
||||
|
||||
test "allows user fromQueryHook overloads to handle NULL values":
|
||||
let rows = query(HookedNullableNoteRow):
|
||||
select tb_nullable(id, note)
|
||||
orderby id
|
||||
|
||||
check rows.len == 2
|
||||
check rows[0].id == 1
|
||||
check string(rows[0].note) == "<missing>"
|
||||
check rows[1].id == 2
|
||||
check string(rows[1].note) == "note:hello"
|
||||
|
||||
test "sqlite benchmark for query and query(T)":
|
||||
loadBenchmarkRows()
|
||||
|
||||
let untypedRows = query:
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
let typedRows = query(BenchmarkCompositeRow):
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
check untypedRows.len == typedRows.len
|
||||
check typedRows[0] == BenchmarkCompositeRow(pk1: untypedRows[0][0], message: untypedRows[0][1])
|
||||
check typedRows[^1] == BenchmarkCompositeRow(pk1: untypedRows[^1][0], message: untypedRows[^1][1])
|
||||
|
||||
discard benchmarkCurrentQuery(benchmarkWarmupIterations)
|
||||
discard benchmarkTypedQuery(benchmarkWarmupIterations)
|
||||
|
||||
var currentBest = high(float)
|
||||
var typedBest = high(float)
|
||||
for _ in 0 ..< benchmarkRounds:
|
||||
currentBest = min(currentBest, benchmarkCurrentQuery(benchmarkIterations))
|
||||
typedBest = min(typedBest, benchmarkTypedQuery(benchmarkIterations))
|
||||
|
||||
let ratio = typedBest / currentBest
|
||||
echo &"sqlite benchmark query={currentBest:.6f}s query(T)={typedBest:.6f}s ratio={ratio:.3f}x; 20% budget={(ratio <= maxTypedQuerySlowdown)}"
|
||||
check currentBest > 0.0
|
||||
check typedBest > 0.0
|
||||
when defined(release):
|
||||
check ratio <= maxTypedQuerySlowdown
|
||||
@@ -0,0 +1,153 @@
|
||||
import unittest, db_connector/sqlite3, json, times, sequtils
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
|
||||
importModel(DbBackend.sqlite, "model_sqlite")
|
||||
|
||||
let
|
||||
db {.global.} = open(":memory:", "", "", "")
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / "model_sqlite.sql")
|
||||
|
||||
|
||||
suite "Test special database types and functions of sqlite":
|
||||
discard
|
||||
|
||||
jsonTimeFormat = "yyyy-MM-dd HH:mm:ss\'.\'fff"
|
||||
let
|
||||
dtStr1 = "2018-02-20 02:02:02"
|
||||
dt1 = parse(dtStr1, "yyyy-MM-dd HH:mm:ss", utc())
|
||||
dtStr2 = "2019-03-30 03:03:03.123"
|
||||
dt2 = parse(dtStr2, "yyyy-MM-dd HH:mm:ss\'.\'fff", utc())
|
||||
dtjson = %*{"dt1": dt1.format(jsonTimeFormat),
|
||||
"dt2": dt2.format(jsonTimeFormat)}
|
||||
let insertSql = sql"insert into tb_timestamp(dt1, dt2) values (?, ?)"
|
||||
|
||||
proc blobFromBytes(bytes: openArray[int]): seq[byte] =
|
||||
result = newSeq[byte](bytes.len)
|
||||
for i, b in bytes:
|
||||
doAssert b >= 0 and b <= 255
|
||||
result[i] = byte(b)
|
||||
|
||||
let blobFixtures = [
|
||||
blobFromBytes(@[0, 1, 2, 3, 4, 5, 6, 7]),
|
||||
blobFromBytes(@[255, 128, 64, 32, 16, 8, 4, 2, 1]),
|
||||
blobFromBytes(@[ord('O'), ord('R'), ord('M'), ord('I'), ord('N'), 0, 1, 2])
|
||||
]
|
||||
|
||||
suite "timestamp_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
test "insert":
|
||||
query:
|
||||
insert tb_timestamp(dt1 = ?dt1, dt2 = ?dt2)
|
||||
check db.getValue(sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
test "json":
|
||||
query:
|
||||
insert tb_timestamp(dt1 = %dtjson["dt1"], dt2 = %dtjson["dt2"])
|
||||
check db.getValue(sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
suite "timestamp":
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
db.exec(insertSql, dtStr1, dtStr2)
|
||||
doAssert db.getValue(sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
check res == [(dt1, dt2)]
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == ?dt1
|
||||
check res == [(dt1, dt2)]
|
||||
|
||||
test "in":
|
||||
let
|
||||
duration = initDuration(hours = 1)
|
||||
dtStart = dt1 - duration
|
||||
dtEnd = dt1 + duration
|
||||
res2 = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 in ?dtStart .. ?dtEnd
|
||||
check res2 == [(dt1, dt2)]
|
||||
|
||||
test "iter":
|
||||
createIter iter:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == ?dt1
|
||||
var res: seq[tuple[dt1: DateTime, dt2: DateTime]]
|
||||
for it in db.iter(dt1):
|
||||
res.add(it)
|
||||
check res == [(dt1, dt2)]
|
||||
|
||||
test "proc":
|
||||
createProc aproc:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == ?dt1
|
||||
check db.aproc(dt1) == [(dt1, dt2)]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
produce json
|
||||
check res == %*[dtjson]
|
||||
|
||||
test "json_where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == %dtjson["dt1"]
|
||||
produce json
|
||||
check res == %*[dtjson]
|
||||
|
||||
suite "blob_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_blob")
|
||||
db.createTable(sqlFile, "tb_blob")
|
||||
|
||||
test "insert parameters":
|
||||
for blob in blobFixtures:
|
||||
query:
|
||||
insert tb_blob(typblob = ?blob)
|
||||
check db.getValue(sql"select count(*) from tb_blob") == $blobFixtures.len
|
||||
|
||||
suite "blob":
|
||||
db.dropTable(sqlFile, "tb_blob")
|
||||
db.createTable(sqlFile, "tb_blob")
|
||||
|
||||
for blob in blobFixtures:
|
||||
query:
|
||||
insert tb_blob(typblob = ?blob)
|
||||
doAssert db.getValue(sql"select count(*) from tb_blob") == $blobFixtures.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_blob(id, typblob)
|
||||
check res.mapIt(it.typblob) == blobFixtures
|
||||
|
||||
test "where":
|
||||
let target = blobFixtures[1]
|
||||
let res = query:
|
||||
select tb_blob(id, typblob)
|
||||
where typblob == ?target
|
||||
check res.len == 1
|
||||
check res[0].typblob == target
|
||||
|
||||
createProc selectAllBlob:
|
||||
select tb_blob(id, typblob)
|
||||
|
||||
test "createProc":
|
||||
let res = db.selectAllBlob
|
||||
check res.mapIt(it.typblob) == blobFixtures
|
||||
|
||||
test "createProc with empty blob in result":
|
||||
db.exec(sql"insert into tb_blob(id, typblob) values (?, ?)", 9, "")
|
||||
let res = db.selectAllBlob
|
||||
check res[^1].typblob == blobFromBytes(@[])
|
||||
@@ -0,0 +1,132 @@
|
||||
import unittest, os, strformat
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
when NimVersion < "1.2.0": import ./compat
|
||||
|
||||
let testDir = currentSourcePath.parentDir()
|
||||
|
||||
when defined postgre:
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
from db_connector/db_postgres import exec, getValue
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "forum_model_postgres")
|
||||
const sqlFileName = "forum_model_postgres.sql"
|
||||
let db {.global.} = open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "forum_model_sqlite")
|
||||
const sqlFileName = "forum_model_sqlite.sql"
|
||||
var memoryPath = testDir & "/" & ":memory:"
|
||||
let db {.global.} = open(memoryPath, "", "", "")
|
||||
|
||||
var sqlFilePath = Path(testDir & "/" & sqlFileName)
|
||||
|
||||
# Fresh schema
|
||||
db.dropTable(sqlFilePath)
|
||||
db.createTable(sqlFilePath)
|
||||
|
||||
suite &"Transactions ({backend})":
|
||||
|
||||
test "commit on success":
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(101), name = ?"john101", password = ?"p101", email = ?"john101@mail.com", salt = ?"s101", status = ?"ok")
|
||||
check db.getValue(sql"select count(*) from person where id = 101") == "1"
|
||||
|
||||
test "rollback on error with manual try except":
|
||||
# prepare one row
|
||||
query:
|
||||
insert person(id = ?(201), name = ?"john201", password = ?"p201", email = ?"john201@mail.com", salt = ?"s201", status = ?"ok")
|
||||
# in transaction insert a new row and then violate PK
|
||||
try:
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(202), name = ?"john202", password = ?"p202", email = ?"john202@mail.com", salt = ?"s202", status = ?"ok")
|
||||
# duplicate key error
|
||||
query:
|
||||
insert person(id = ?(201), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
check false # should not reach
|
||||
except DbError as e:
|
||||
discard
|
||||
# both inserts inside the transaction should be rolled back
|
||||
check db.getValue(sql"select count(*) from person where id = 202") == "0"
|
||||
check db.getValue(sql"select count(*) from person where id = 201 and name = 'dup'") == "0"
|
||||
|
||||
test "rollback on error with else":
|
||||
# prepare one row
|
||||
var failed = false
|
||||
query:
|
||||
insert person(id = ?(501), name = ?"john501", password = ?"p501", email = ?"john501@mail.com", salt = ?"s501", status = ?"ok")
|
||||
# in transaction insert a new row and then violate PK
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(502), name = ?"john502", password = ?"p502", email = ?"john502@mail.com", salt = ?"s502", status = ?"ok")
|
||||
# duplicate key error
|
||||
query:
|
||||
insert person(id = ?(501), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
check false # should not reach
|
||||
else:
|
||||
echo "do something else..."
|
||||
failed = true
|
||||
|
||||
check failed
|
||||
# both inserts inside the transaction should be rolled back
|
||||
check db.getValue(sql"select count(*) from person where id = 502") == "0"
|
||||
check db.getValue(sql"select count(*) from person where id = 501 and name = 'dup'") == "0"
|
||||
|
||||
test "commit normally with else":
|
||||
# prepare one row
|
||||
var failed = false
|
||||
query:
|
||||
insert person(id = ?(601), name = ?"john601", password = ?"p601", email = ?"john601@mail.com", salt = ?"s601", status = ?"ok")
|
||||
# in transaction insert a new row and then violate PK
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(602), name = ?"john602", password = ?"p602", email = ?"john602@mail.com", salt = ?"s602", status = ?"ok")
|
||||
query:
|
||||
insert person(id = ?(603), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
else:
|
||||
failed = true
|
||||
|
||||
check not failed
|
||||
# both inserts inside the transaction should be rolled back
|
||||
check db.getValue(sql"select count(*) from person where id = 603") == "1"
|
||||
check db.getValue(sql"select count(*) from person where id = 602") == "1"
|
||||
check db.getValue(sql"select count(*) from person where id = 601") == "1"
|
||||
|
||||
test "transaction set false on DbError":
|
||||
var failed = false
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(301), name = ?"john301", password = ?"p301", email = ?"john301@mail.com", salt = ?"s301", status = ?"ok")
|
||||
query:
|
||||
insert person(id = ?(301), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
else:
|
||||
failed = true
|
||||
check failed
|
||||
check db.getValue(sql"select count(*) from person where id = 301") == "0"
|
||||
|
||||
test "nested savepoints":
|
||||
var failed = false
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(401), name = ?"john401", password = ?"p401", email = ?"john401@mail.com", salt = ?"s401", status = ?"ok")
|
||||
var innerOk = true
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(402), name = ?"john402", password = ?"p402", email = ?"john402@mail.com", salt = ?"s402", status = ?"ok")
|
||||
query:
|
||||
insert person(id = ?(401), name = ?"dup401", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
else:
|
||||
innerOk = false
|
||||
|
||||
check innerOk == false
|
||||
|
||||
# after inner rollback, we can still insert another row and commit outer
|
||||
query:
|
||||
insert person(id = ?(403), name = ?"john403", password = ?"p403", email = ?"john403@mail.com", salt = ?"s403", status = ?"ok")
|
||||
else:
|
||||
failed = true
|
||||
check db.getValue(sql"select count(*) from person where id in (401,402,403)") == "2"
|
||||
@@ -0,0 +1,38 @@
|
||||
## Ormin -- ORM for Nim.
|
||||
## (c) 2017 Andreas Rumpf
|
||||
## MIT License.
|
||||
|
||||
import strutils, os, parseopt
|
||||
|
||||
import ../ormin/importer_core
|
||||
|
||||
proc writeHelp() =
|
||||
echo """
|
||||
ormin <schema.sql> --out:<file.nim> --db:postgre|sqlite|mysql
|
||||
"""
|
||||
|
||||
proc writeVersion() = echo "v1.0"
|
||||
|
||||
var p = initOptParser()
|
||||
var infile = ""
|
||||
var outfile = ""
|
||||
var target: ImportTarget
|
||||
for kind, key, val in p.getopt():
|
||||
case kind
|
||||
of cmdArgument:
|
||||
infile = key
|
||||
of cmdLongOption, cmdShortOption:
|
||||
case key
|
||||
of "help", "h": writeHelp()
|
||||
of "version", "v": writeVersion()
|
||||
of "out", "o": outfile = val
|
||||
of "db": target = parseEnum[ImportTarget](val)
|
||||
else: discard
|
||||
of cmdEnd: assert(false) # cannot happen
|
||||
if infile == "":
|
||||
# no filename has been given, so we show the help:
|
||||
writeHelp()
|
||||
else:
|
||||
if outfile == "":
|
||||
outfile = changeFileExt(infile, "nim")
|
||||
writeFile(outfile, generateModelCode(readFile(infile), absolutePath(infile), target))
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Try default connection first, then fallback to -U postgres
|
||||
run_psql_file() {
|
||||
local db="$1"; shift
|
||||
local file="$1"; shift
|
||||
if ! psql -v ON_ERROR_STOP=1 -d "$db" -f "$file" "$@" >/dev/null 2>&1; then
|
||||
psql -v ON_ERROR_STOP=1 -U postgres -d "$db" -f "$file" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_psql_cmd() {
|
||||
local db="$1"; shift
|
||||
local cmd="$1"; shift
|
||||
if ! psql -v ON_ERROR_STOP=1 -d "$db" -c "$cmd" "$@" >/dev/null 2>&1; then
|
||||
psql -v ON_ERROR_STOP=1 -U postgres -d "$db" -c "$cmd" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create role 'test' if needed
|
||||
run_psql_file postgres tools/setup_postgres_role.sql
|
||||
|
||||
# Create database 'test' if needed (must be outside DO block)
|
||||
if ! psql -v ON_ERROR_STOP=1 -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'test_ormin'" | grep -q 1; then
|
||||
run_psql_cmd postgres "CREATE DATABASE test_ormin OWNER test"
|
||||
fi
|
||||
|
||||
# Grant privileges on public schema
|
||||
run_psql_cmd test_ormin "GRANT ALL PRIVILEGES ON SCHEMA public TO test"
|
||||
|
||||
echo "Postgres test DB/user ensured (role 'test', db 'test_ormin')."
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'test') THEN
|
||||
CREATE ROLE test LOGIN PASSWORD 'test';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'test') THEN
|
||||
CREATE DATABASE test OWNER test;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
\connect test
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO test;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'test') THEN
|
||||
CREATE ROLE test LOGIN PASSWORD 'test';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
Reference in New Issue
Block a user