From 4e540750780ca72ee6d1f37e2177417ebf971a04 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Fri, 15 May 2026 21:49:08 +0300 Subject: [PATCH] Add Ormin ORM support for BaraDB (Nim client) --- clients/nim/README.md | 17 + clients/nim/ormin/.github/workflows/test.yml | 68 + clients/nim/ormin/.gitignore | 29 + clients/nim/ormin/.travis.yml | 28 + clients/nim/ormin/README-baradb.md | 135 ++ clients/nim/ormin/README.md | 473 ++++ clients/nim/ormin/config.nims | 47 + clients/nim/ormin/examples/baradb_basic.nim | 35 + clients/nim/ormin/examples/baradb_model.sql | 6 + .../nim/ormin/examples/chat/chat_model.sql | 19 + clients/nim/ormin/examples/chat/createdb.nim | 9 + clients/nim/ormin/examples/chat/frontend.nim | 92 + clients/nim/ormin/examples/chat/server.nim | 96 + clients/nim/ormin/examples/chat/server.nims | 2 + clients/nim/ormin/examples/forum/config.nims | 2 + clients/nim/ormin/examples/forum/forum.nim | 160 ++ .../nim/ormin/examples/forum/forum_model.sql | 55 + .../nim/ormin/examples/forum/forumproto.nim | 37 + .../ormin/examples/tweeter/public/style.css | 117 + clients/nim/ormin/examples/tweeter/readme.md | 3 + .../ormin/examples/tweeter/src/config.nims | 2 + .../examples/tweeter/src/createdatabase.nim | 11 + .../ormin/examples/tweeter/src/database.nim | 51 + .../nim/ormin/examples/tweeter/src/model.nim | 11 + .../ormin/examples/tweeter/src/tweeter.nim | 59 + .../examples/tweeter/src/tweeter_model.nim | 24 + .../examples/tweeter/src/tweeter_model.sql | 18 + .../examples/tweeter/src/views/general.nim | 51 + .../ormin/examples/tweeter/src/views/user.nim | 41 + clients/nim/ormin/license.txt | 21 + clients/nim/ormin/ormin.nim | 49 + clients/nim/ormin/ormin.nimble | 23 + clients/nim/ormin/ormin/db_types.nim | 43 + clients/nim/ormin/ormin/db_utils.nim | 179 ++ clients/nim/ormin/ormin/dispatcher.nim | 71 + clients/nim/ormin/ormin/importer_core.nim | 178 ++ clients/nim/ormin/ormin/ormin_baradb.nim | 231 ++ clients/nim/ormin/ormin/ormin_postgre.nim | 274 +++ clients/nim/ormin/ormin/ormin_sqlite.nim | 295 +++ clients/nim/ormin/ormin/parsesql_tmp.nim | 1858 +++++++++++++++ clients/nim/ormin/ormin/queries.nim | 2093 +++++++++++++++++ clients/nim/ormin/ormin/query_hooks.nim | 54 + clients/nim/ormin/ormin/serverws.nim | 139 ++ clients/nim/ormin/tests/compat.nim | 55 + clients/nim/ormin/tests/config.nims | 1 + .../nim/ormin/tests/db_utils_case_quoted.sql | 26 + .../nim/ormin/tests/forum_model_postgres.sql | 60 + .../nim/ormin/tests/forum_model_sqlite.sql | 62 + clients/nim/ormin/tests/model_postgre.sql | 45 + clients/nim/ormin/tests/model_sqlite.sql | 48 + clients/nim/ormin/tests/static_only_model.sql | 4 + clients/nim/ormin/tests/tcommon.nim | 508 ++++ clients/nim/ormin/tests/tdb_utils.nim | 166 ++ clients/nim/ormin/tests/tfeature.nim | 822 +++++++ clients/nim/ormin/tests/timportstatic.nim | 29 + clients/nim/ormin/tests/tpostgre.nim | 130 + clients/nim/ormin/tests/tquery_types.nim | 216 ++ clients/nim/ormin/tests/tsqlite.nim | 153 ++ clients/nim/ormin/tests/ttransactions.nim | 132 ++ clients/nim/ormin/tools/ormin_importer.nim | 38 + clients/nim/ormin/tools/setup_postgres.sh | 33 + clients/nim/ormin/tools/setup_postgres.sql | 21 + .../nim/ormin/tools/setup_postgres_role.sql | 9 + 63 files changed, 9764 insertions(+) create mode 100644 clients/nim/ormin/.github/workflows/test.yml create mode 100644 clients/nim/ormin/.gitignore create mode 100644 clients/nim/ormin/.travis.yml create mode 100644 clients/nim/ormin/README-baradb.md create mode 100644 clients/nim/ormin/README.md create mode 100644 clients/nim/ormin/config.nims create mode 100644 clients/nim/ormin/examples/baradb_basic.nim create mode 100644 clients/nim/ormin/examples/baradb_model.sql create mode 100644 clients/nim/ormin/examples/chat/chat_model.sql create mode 100644 clients/nim/ormin/examples/chat/createdb.nim create mode 100644 clients/nim/ormin/examples/chat/frontend.nim create mode 100644 clients/nim/ormin/examples/chat/server.nim create mode 100644 clients/nim/ormin/examples/chat/server.nims create mode 100644 clients/nim/ormin/examples/forum/config.nims create mode 100644 clients/nim/ormin/examples/forum/forum.nim create mode 100644 clients/nim/ormin/examples/forum/forum_model.sql create mode 100644 clients/nim/ormin/examples/forum/forumproto.nim create mode 100644 clients/nim/ormin/examples/tweeter/public/style.css create mode 100644 clients/nim/ormin/examples/tweeter/readme.md create mode 100644 clients/nim/ormin/examples/tweeter/src/config.nims create mode 100644 clients/nim/ormin/examples/tweeter/src/createdatabase.nim create mode 100644 clients/nim/ormin/examples/tweeter/src/database.nim create mode 100644 clients/nim/ormin/examples/tweeter/src/model.nim create mode 100644 clients/nim/ormin/examples/tweeter/src/tweeter.nim create mode 100644 clients/nim/ormin/examples/tweeter/src/tweeter_model.nim create mode 100644 clients/nim/ormin/examples/tweeter/src/tweeter_model.sql create mode 100644 clients/nim/ormin/examples/tweeter/src/views/general.nim create mode 100644 clients/nim/ormin/examples/tweeter/src/views/user.nim create mode 100644 clients/nim/ormin/license.txt create mode 100644 clients/nim/ormin/ormin.nim create mode 100644 clients/nim/ormin/ormin.nimble create mode 100644 clients/nim/ormin/ormin/db_types.nim create mode 100644 clients/nim/ormin/ormin/db_utils.nim create mode 100644 clients/nim/ormin/ormin/dispatcher.nim create mode 100644 clients/nim/ormin/ormin/importer_core.nim create mode 100644 clients/nim/ormin/ormin/ormin_baradb.nim create mode 100644 clients/nim/ormin/ormin/ormin_postgre.nim create mode 100644 clients/nim/ormin/ormin/ormin_sqlite.nim create mode 100644 clients/nim/ormin/ormin/parsesql_tmp.nim create mode 100644 clients/nim/ormin/ormin/queries.nim create mode 100644 clients/nim/ormin/ormin/query_hooks.nim create mode 100644 clients/nim/ormin/ormin/serverws.nim create mode 100644 clients/nim/ormin/tests/compat.nim create mode 100644 clients/nim/ormin/tests/config.nims create mode 100644 clients/nim/ormin/tests/db_utils_case_quoted.sql create mode 100644 clients/nim/ormin/tests/forum_model_postgres.sql create mode 100644 clients/nim/ormin/tests/forum_model_sqlite.sql create mode 100644 clients/nim/ormin/tests/model_postgre.sql create mode 100644 clients/nim/ormin/tests/model_sqlite.sql create mode 100644 clients/nim/ormin/tests/static_only_model.sql create mode 100644 clients/nim/ormin/tests/tcommon.nim create mode 100644 clients/nim/ormin/tests/tdb_utils.nim create mode 100644 clients/nim/ormin/tests/tfeature.nim create mode 100644 clients/nim/ormin/tests/timportstatic.nim create mode 100644 clients/nim/ormin/tests/tpostgre.nim create mode 100644 clients/nim/ormin/tests/tquery_types.nim create mode 100644 clients/nim/ormin/tests/tsqlite.nim create mode 100644 clients/nim/ormin/tests/ttransactions.nim create mode 100644 clients/nim/ormin/tools/ormin_importer.nim create mode 100644 clients/nim/ormin/tools/setup_postgres.sh create mode 100644 clients/nim/ormin/tools/setup_postgres.sql create mode 100644 clients/nim/ormin/tools/setup_postgres_role.sql diff --git a/clients/nim/README.md b/clients/nim/README.md index 4ff6d90..256adaa 100644 --- a/clients/nim/README.md +++ b/clients/nim/README.md @@ -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 diff --git a/clients/nim/ormin/.github/workflows/test.yml b/clients/nim/ormin/.github/workflows/test.yml new file mode 100644 index 0000000..46ee496 --- /dev/null +++ b/clients/nim/ormin/.github/workflows/test.yml @@ -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 diff --git a/clients/nim/ormin/.gitignore b/clients/nim/ormin/.gitignore new file mode 100644 index 0000000..2e1b178 --- /dev/null +++ b/clients/nim/ormin/.gitignore @@ -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 diff --git a/clients/nim/ormin/.travis.yml b/clients/nim/ormin/.travis.yml new file mode 100644 index 0000000..980d866 --- /dev/null +++ b/clients/nim/ormin/.travis.yml @@ -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 diff --git a/clients/nim/ormin/README-baradb.md b/clients/nim/ormin/README-baradb.md new file mode 100644 index 0000000..27c097e --- /dev/null +++ b/clients/nim/ormin/README-baradb.md @@ -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. diff --git a/clients/nim/ormin/README.md b/clients/nim/ormin/README.md new file mode 100644 index 0000000..a082448 --- /dev/null +++ b/clients/nim/ormin/README.md @@ -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("") + 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. diff --git a/clients/nim/ormin/config.nims b/clients/nim/ormin/config.nims new file mode 100644 index 0000000..9eb46ad --- /dev/null +++ b/clients/nim/ormin/config.nims @@ -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" diff --git a/clients/nim/ormin/examples/baradb_basic.nim b/clients/nim/ormin/examples/baradb_basic.nim new file mode 100644 index 0000000..eae4ce9 --- /dev/null +++ b/clients/nim/ormin/examples/baradb_basic.nim @@ -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) diff --git a/clients/nim/ormin/examples/baradb_model.sql b/clients/nim/ormin/examples/baradb_model.sql new file mode 100644 index 0000000..133c176 --- /dev/null +++ b/clients/nim/ormin/examples/baradb_model.sql @@ -0,0 +1,6 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + age INT +); diff --git a/clients/nim/ormin/examples/chat/chat_model.sql b/clients/nim/ormin/examples/chat/chat_model.sql new file mode 100644 index 0000000..23fae79 --- /dev/null +++ b/clients/nim/ormin/examples/chat/chat_model.sql @@ -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) +); diff --git a/clients/nim/ormin/examples/chat/createdb.nim b/clients/nim/ormin/examples/chat/createdb.nim new file mode 100644 index 0000000..03cf6d9 --- /dev/null +++ b/clients/nim/ormin/examples/chat/createdb.nim @@ -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() diff --git a/clients/nim/ormin/examples/chat/frontend.nim b/clients/nim/ormin/examples/chat/frontend.nim new file mode 100644 index 0000000..e187d64 --- /dev/null +++ b/clients/nim/ormin/examples/chat/frontend.nim @@ -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) diff --git a/clients/nim/ormin/examples/chat/server.nim b/clients/nim/ormin/examples/chat/server.nim new file mode 100644 index 0000000..922a4a0 --- /dev/null +++ b/clients/nim/ormin/examples/chat/server.nim @@ -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 diff --git a/clients/nim/ormin/examples/chat/server.nims b/clients/nim/ormin/examples/chat/server.nims new file mode 100644 index 0000000..9ff8690 --- /dev/null +++ b/clients/nim/ormin/examples/chat/server.nims @@ -0,0 +1,2 @@ +--define: ssl +--path:"$projectDir/../../../ormin" \ No newline at end of file diff --git a/clients/nim/ormin/examples/forum/config.nims b/clients/nim/ormin/examples/forum/config.nims new file mode 100644 index 0000000..9ff8690 --- /dev/null +++ b/clients/nim/ormin/examples/forum/config.nims @@ -0,0 +1,2 @@ +--define: ssl +--path:"$projectDir/../../../ormin" \ No newline at end of file diff --git a/clients/nim/ormin/examples/forum/forum.nim b/clients/nim/ormin/examples/forum/forum.nim new file mode 100644 index 0000000..415285c --- /dev/null +++ b/clients/nim/ormin/examples/forum/forum.nim @@ -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 = ?""" +]# diff --git a/clients/nim/ormin/examples/forum/forum_model.sql b/clients/nim/ormin/examples/forum/forum_model.sql new file mode 100644 index 0000000..0f899f4 --- /dev/null +++ b/clients/nim/ormin/examples/forum/forum_model.sql @@ -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); diff --git a/clients/nim/ormin/examples/forum/forumproto.nim b/clients/nim/ormin/examples/forum/forumproto.nim new file mode 100644 index 0000000..d5af1d2 --- /dev/null +++ b/clients/nim/ormin/examples/forum/forumproto.nim @@ -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() diff --git a/clients/nim/ormin/examples/tweeter/public/style.css b/clients/nim/ormin/examples/tweeter/public/style.css new file mode 100644 index 0000000..4957c57 --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/public/style.css @@ -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%; + } \ No newline at end of file diff --git a/clients/nim/ormin/examples/tweeter/readme.md b/clients/nim/ormin/examples/tweeter/readme.md new file mode 100644 index 0000000..027d77d --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/readme.md @@ -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. diff --git a/clients/nim/ormin/examples/tweeter/src/config.nims b/clients/nim/ormin/examples/tweeter/src/config.nims new file mode 100644 index 0000000..ead9ee0 --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/config.nims @@ -0,0 +1,2 @@ +--define: ssl +--path:"$projectDir/../../../../ormin" \ No newline at end of file diff --git a/clients/nim/ormin/examples/tweeter/src/createdatabase.nim b/clients/nim/ormin/examples/tweeter/src/createdatabase.nim new file mode 100644 index 0000000..0f1656e --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/createdatabase.nim @@ -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() \ No newline at end of file diff --git a/clients/nim/ormin/examples/tweeter/src/database.nim b/clients/nim/ormin/examples/tweeter/src/database.nim new file mode 100644 index 0000000..95699bc --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/database.nim @@ -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])) diff --git a/clients/nim/ormin/examples/tweeter/src/model.nim b/clients/nim/ormin/examples/tweeter/src/model.nim new file mode 100644 index 0000000..81f0dcc --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/model.nim @@ -0,0 +1,11 @@ +type + User* = tuple[ + username: string, + following: seq[string] + ] + + Message* = tuple[ + username: string, + time: int, + msg: string + ] \ No newline at end of file diff --git a/clients/nim/ormin/examples/tweeter/src/tweeter.nim b/clients/nim/ormin/examples/tweeter/src/tweeter.nim new file mode 100644 index 0000000..d67809c --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/tweeter.nim @@ -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() \ No newline at end of file diff --git a/clients/nim/ormin/examples/tweeter/src/tweeter_model.nim b/clients/nim/ormin/examples/tweeter/src/tweeter_model.nim new file mode 100644 index 0000000..2f999da --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/tweeter_model.nim @@ -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) +] diff --git a/clients/nim/ormin/examples/tweeter/src/tweeter_model.sql b/clients/nim/ormin/examples/tweeter/src/tweeter_model.sql new file mode 100644 index 0000000..6103c76 --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/tweeter_model.sql @@ -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) +); \ No newline at end of file diff --git a/clients/nim/ormin/examples/tweeter/src/views/general.nim b/clients/nim/ormin/examples/tweeter/src/views/general.nim new file mode 100644 index 0000000..31bae30 --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/views/general.nim @@ -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 = "" + + + + Tweeter written in Nim + + + + + ${body} + + + +#end proc +# +#proc renderLogin*(): string = +# result = "" +
+ Login + Please type in your username... +
+ + +
+
+#end proc +# +#proc renderTimeline*(username: string, messages: openArray[Message]): string = +# result = "" +
+

${$!username} timeline

+
+
+ New message +
+ + + +
+
+${renderMessages(messages)} +#end proc \ No newline at end of file diff --git a/clients/nim/ormin/examples/tweeter/src/views/user.nim b/clients/nim/ormin/examples/tweeter/src/views/user.nim new file mode 100644 index 0000000..8ef66a6 --- /dev/null +++ b/clients/nim/ormin/examples/tweeter/src/views/user.nim @@ -0,0 +1,41 @@ +#? stdtmpl(subsChar = '$', metaChar = '#', toString = "xmltree.escape") +#import xmltree +#import times +#import "../model" +# +#proc renderUser*(user: User): string = +# result = "" +
+

${user.username}

+ Following: ${$user.following.len} +
+#end proc +# +#proc renderUser*(user, currentUser: User): string = +# result = "" +
+

${user.username}

+ Following: ${$user.following.len} + #if user.username notin currentUser.following and user.username != currentUser.username: +
+ + + +
+ #end if +
+# +#end proc +# +#proc renderMessages*(messages: openArray[Message]): string = +# result = "" +
+ #for message in messages: +
+ ${message.username} + ${message.time.fromUnix().format("HH:mm MMMM d',' yyyy")} +

${message.msg}

+
+ #end for +
+#end proc \ No newline at end of file diff --git a/clients/nim/ormin/license.txt b/clients/nim/ormin/license.txt new file mode 100644 index 0000000..28f54bc --- /dev/null +++ b/clients/nim/ormin/license.txt @@ -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. diff --git a/clients/nim/ormin/ormin.nim b/clients/nim/ormin/ormin.nim new file mode 100644 index 0000000..75d9073 --- /dev/null +++ b/clients/nim/ormin/ormin.nim @@ -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" diff --git a/clients/nim/ormin/ormin.nimble b/clients/nim/ormin/ormin.nimble new file mode 100644 index 0000000..1de2b6e --- /dev/null +++ b/clients/nim/ormin/ormin.nimble @@ -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" + diff --git a/clients/nim/ormin/ormin/db_types.nim b/clients/nim/ormin/ormin/db_types.nim new file mode 100644 index 0000000..29cc10f --- /dev/null +++ b/clients/nim/ormin/ormin/db_types.nim @@ -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 diff --git a/clients/nim/ormin/ormin/db_utils.nim b/clients/nim/ormin/ormin/db_utils.nim new file mode 100644 index 0000000..0d0398b --- /dev/null +++ b/clients/nim/ormin/ormin/db_utils.nim @@ -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" diff --git a/clients/nim/ormin/ormin/dispatcher.nim b/clients/nim/ormin/ormin/dispatcher.nim new file mode 100644 index 0000000..12b25d5 --- /dev/null +++ b/clients/nim/ormin/ormin/dispatcher.nim @@ -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.. 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)) diff --git a/clients/nim/ormin/ormin/ormin_baradb.nim b/clients/nim/ormin/ormin/ormin_baradb.nim new file mode 100644 index 0000000..49491ca --- /dev/null +++ b/clients/nim/ormin/ormin/ormin_baradb.nim @@ -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.. 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)) diff --git a/clients/nim/ormin/ormin/ormin_postgre.nim b/clients/nim/ormin/ormin/ormin_postgre.nim new file mode 100644 index 0000000..e3f1beb --- /dev/null +++ b/clients/nim/ormin/ormin/ormin_postgre.nim @@ -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: "", noSideEffect.} + +proc c_strtol(buf: cstring, endptr: ptr cstring = nil, base: cint = 10): int {. + importc: "strtol", header: "", 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.. 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) diff --git a/clients/nim/ormin/ormin/parsesql_tmp.nim b/clients/nim/ormin/ormin/parsesql_tmp.nim new file mode 100644 index 0000000..b2b4eb9 --- /dev/null +++ b/clients/nim/ormin/ormin/parsesql_tmp.nim @@ -0,0 +1,1858 @@ +# +# +# Nim's Runtime Library +# (c) Copyright 2009 Andreas Rumpf +# +# See the file "copying.txt", included in this +# distribution, for details about the copyright. +# + +## The `parsesql` module implements a high performance SQL file +## parser. It parses PostgreSQL syntax and the SQL ANSI standard. +## +## Unstable API. + +import std/[strutils, lexbase] +import std/private/decode_helpers + +when defined(nimPreviewSlimSystem): + import std/assertions + +# ------------------- scanner ------------------------------------------------- + +type + TokKind = enum ## enumeration of all SQL tokens + tkInvalid, ## invalid token + tkEof, ## end of file reached + tkIdentifier, ## abc + tkQuotedIdentifier, ## "abc" + tkStringConstant, ## 'abc' + tkEscapeConstant, ## e'abc' + tkDollarQuotedConstant, ## $tag$abc$tag$ + tkBitStringConstant, ## B'00011' + tkHexStringConstant, ## x'00011' + tkInteger, + tkNumeric, + tkOperator, ## + - * / < > = ~ ! @ # % ^ & | ` ? + tkSemicolon, ## ';' + tkColon, ## ':' + tkComma, ## ',' + tkParLe, ## '(' + tkParRi, ## ')' + tkBracketLe, ## '[' + tkBracketRi, ## ']' + tkDot ## '.' + + Token = object # a token + kind: TokKind # the type of the token + literal: string # the parsed (string) literal + + SqlLexer* = object of BaseLexer ## the parser object. + filename: string + +const + tokKindToStr: array[TokKind, string] = [ + "invalid", "[EOF]", "identifier", "quoted identifier", "string constant", + "escape string constant", "dollar quoted constant", "bit string constant", + "hex string constant", "integer constant", "numeric constant", "operator", + ";", ":", ",", "(", ")", "[", "]", "." + ] + + reservedKeywords = @[ + # statements + "select", "from", "where", "group", "limit", "offset", "having", + # functions + "count", + ] + +proc close(L: var SqlLexer) = + lexbase.close(L) + +proc getColumn(L: SqlLexer): int = + ## get the current column the parser has arrived at. + result = getColNumber(L, L.bufpos) + +proc getLine(L: SqlLexer): int = + result = L.lineNumber + +proc handleOctChar(c: var SqlLexer, xi: var int) = + if c.buf[c.bufpos] in {'0'..'7'}: + xi = (xi shl 3) or (ord(c.buf[c.bufpos]) - ord('0')) + inc(c.bufpos) + +proc getEscapedChar(c: var SqlLexer, tok: var Token) = + inc(c.bufpos) + case c.buf[c.bufpos] + of 'n', 'N': + add(tok.literal, '\L') + inc(c.bufpos) + of 'r', 'R', 'c', 'C': + add(tok.literal, '\c') + inc(c.bufpos) + of 'l', 'L': + add(tok.literal, '\L') + inc(c.bufpos) + of 'f', 'F': + add(tok.literal, '\f') + inc(c.bufpos) + of 'e', 'E': + add(tok.literal, '\e') + inc(c.bufpos) + of 'a', 'A': + add(tok.literal, '\a') + inc(c.bufpos) + of 'b', 'B': + add(tok.literal, '\b') + inc(c.bufpos) + of 'v', 'V': + add(tok.literal, '\v') + inc(c.bufpos) + of 't', 'T': + add(tok.literal, '\t') + inc(c.bufpos) + of '\'', '\"': + add(tok.literal, c.buf[c.bufpos]) + inc(c.bufpos) + of '\\': + add(tok.literal, '\\') + inc(c.bufpos) + of 'x', 'X': + inc(c.bufpos) + var xi = 0 + if handleHexChar(c.buf[c.bufpos], xi): + inc(c.bufpos) + if handleHexChar(c.buf[c.bufpos], xi): + inc(c.bufpos) + add(tok.literal, chr(xi)) + of '0'..'7': + var xi = 0 + handleOctChar(c, xi) + handleOctChar(c, xi) + handleOctChar(c, xi) + if (xi <= 255): add(tok.literal, chr(xi)) + else: tok.kind = tkInvalid + else: tok.kind = tkInvalid + +proc handleCRLF(c: var SqlLexer, pos: int): int = + case c.buf[pos] + of '\c': result = lexbase.handleCR(c, pos) + of '\L': result = lexbase.handleLF(c, pos) + else: result = pos + +proc skip(c: var SqlLexer) = + var pos = c.bufpos + var nested = 0 + while true: + case c.buf[pos] + of ' ', '\t': + inc(pos) + of '-': + if c.buf[pos+1] == '-': + while not (c.buf[pos] in {'\c', '\L', lexbase.EndOfFile}): inc(pos) + else: + break + of '/': + if c.buf[pos+1] == '*': + inc(pos, 2) + while true: + case c.buf[pos] + of '\0': break + of '\c', '\L': + pos = handleCRLF(c, pos) + of '*': + if c.buf[pos+1] == '/': + inc(pos, 2) + if nested <= 0: break + dec(nested) + else: + inc(pos) + of '/': + if c.buf[pos+1] == '*': + inc(pos, 2) + inc(nested) + else: + inc(pos) + else: inc(pos) + else: break + of '\c', '\L': + pos = handleCRLF(c, pos) + else: + break # EndOfFile also leaves the loop + c.bufpos = pos + +proc getString(c: var SqlLexer, tok: var Token, kind: TokKind) = + var pos = c.bufpos + 1 + tok.kind = kind + block parseLoop: + while true: + while true: + var ch = c.buf[pos] + if ch == '\'': + if c.buf[pos+1] == '\'': + inc(pos, 2) + add(tok.literal, '\'') + else: + inc(pos) + break + elif ch in {'\c', '\L', lexbase.EndOfFile}: + tok.kind = tkInvalid + break parseLoop + elif (ch == '\\') and kind == tkEscapeConstant: + c.bufpos = pos + getEscapedChar(c, tok) + pos = c.bufpos + else: + add(tok.literal, ch) + inc(pos) + c.bufpos = pos + var line = c.lineNumber + skip(c) + if c.lineNumber > line: + # a new line whitespace has been parsed, so we check if the string + # continues after the whitespace: + pos = c.bufpos + if c.buf[pos] == '\'': inc(pos) + else: break parseLoop + else: break parseLoop + c.bufpos = pos + +proc getDollarString(c: var SqlLexer, tok: var Token) = + var pos = c.bufpos + 1 + tok.kind = tkDollarQuotedConstant + var tag = "$" + while c.buf[pos] in IdentChars: + add(tag, c.buf[pos]) + inc(pos) + if c.buf[pos] == '$': inc(pos) + else: + tok.kind = tkInvalid + return + while true: + case c.buf[pos] + of '\c', '\L': + pos = handleCRLF(c, pos) + add(tok.literal, "\L") + of '\0': + tok.kind = tkInvalid + break + of '$': + inc(pos) + var tag2 = "$" + while c.buf[pos] in IdentChars: + add(tag2, c.buf[pos]) + inc(pos) + if c.buf[pos] == '$': inc(pos) + if tag2 == tag: break + add(tok.literal, tag2) + add(tok.literal, '$') + else: + add(tok.literal, c.buf[pos]) + inc(pos) + c.bufpos = pos + +proc getSymbol(c: var SqlLexer, tok: var Token) = + var pos = c.bufpos + while true: + add(tok.literal, c.buf[pos]) + inc(pos) + if c.buf[pos] notin {'a'..'z', 'A'..'Z', '0'..'9', '_', '$', + '\128'..'\255'}: + break + c.bufpos = pos + tok.kind = tkIdentifier + +proc getQuotedIdentifier(c: var SqlLexer, tok: var Token, quote = '\"') = + var pos = c.bufpos + 1 + tok.kind = tkQuotedIdentifier + while true: + var ch = c.buf[pos] + if ch == quote: + if c.buf[pos+1] == quote: + inc(pos, 2) + add(tok.literal, quote) + else: + inc(pos) + break + elif ch in {'\c', '\L', lexbase.EndOfFile}: + tok.kind = tkInvalid + break + else: + add(tok.literal, ch) + inc(pos) + c.bufpos = pos + +proc getBitHexString(c: var SqlLexer, tok: var Token, validChars: set[char]) = + var pos = c.bufpos + 1 + block parseLoop: + while true: + while true: + var ch = c.buf[pos] + if ch in validChars: + add(tok.literal, ch) + inc(pos) + elif ch == '\'': + inc(pos) + break + else: + tok.kind = tkInvalid + break parseLoop + c.bufpos = pos + var line = c.lineNumber + skip(c) + if c.lineNumber > line: + # a new line whitespace has been parsed, so we check if the string + # continues after the whitespace: + pos = c.bufpos + if c.buf[pos] == '\'': inc(pos) + else: break parseLoop + else: break parseLoop + c.bufpos = pos + +proc getNumeric(c: var SqlLexer, tok: var Token) = + tok.kind = tkInteger + var pos = c.bufpos + while c.buf[pos] in Digits: + add(tok.literal, c.buf[pos]) + inc(pos) + if c.buf[pos] == '.': + tok.kind = tkNumeric + add(tok.literal, c.buf[pos]) + inc(pos) + while c.buf[pos] in Digits: + add(tok.literal, c.buf[pos]) + inc(pos) + if c.buf[pos] in {'E', 'e'}: + tok.kind = tkNumeric + add(tok.literal, c.buf[pos]) + inc(pos) + if c.buf[pos] == '+': + inc(pos) + elif c.buf[pos] == '-': + add(tok.literal, c.buf[pos]) + inc(pos) + if c.buf[pos] in Digits: + while c.buf[pos] in Digits: + add(tok.literal, c.buf[pos]) + inc(pos) + else: + tok.kind = tkInvalid + c.bufpos = pos + +proc getOperator(c: var SqlLexer, tok: var Token) = + const operators = {'+', '-', '*', '/', '<', '>', '=', '~', '!', '@', '#', '%', + '^', '&', '|', '`', '?'} + tok.kind = tkOperator + var pos = c.bufpos + var trailingPlusMinus = false + while true: + case c.buf[pos] + of '-': + if c.buf[pos] == '-': break + if not trailingPlusMinus and c.buf[pos+1] notin operators and + tok.literal.len > 0: break + of '/': + if c.buf[pos] == '*': break + of '~', '!', '@', '#', '%', '^', '&', '|', '`', '?': + trailingPlusMinus = true + of '+': + if not trailingPlusMinus and c.buf[pos+1] notin operators and + tok.literal.len > 0: break + of '*', '<', '>', '=': discard + else: break + add(tok.literal, c.buf[pos]) + inc(pos) + c.bufpos = pos + +proc getTok(c: var SqlLexer, tok: var Token) = + tok.kind = tkInvalid + setLen(tok.literal, 0) + skip(c) + case c.buf[c.bufpos] + of ';': + tok.kind = tkSemicolon + inc(c.bufpos) + add(tok.literal, ';') + of ',': + tok.kind = tkComma + inc(c.bufpos) + add(tok.literal, ',') + of ':': + tok.kind = tkColon + inc(c.bufpos) + add(tok.literal, ':') + of 'e', 'E': + if c.buf[c.bufpos + 1] == '\'': + inc(c.bufpos) + getString(c, tok, tkEscapeConstant) + else: + getSymbol(c, tok) + of 'b', 'B': + if c.buf[c.bufpos + 1] == '\'': + tok.kind = tkBitStringConstant + getBitHexString(c, tok, {'0'..'1'}) + else: + getSymbol(c, tok) + of 'x', 'X': + if c.buf[c.bufpos + 1] == '\'': + tok.kind = tkHexStringConstant + getBitHexString(c, tok, {'a'..'f', 'A'..'F', '0'..'9'}) + else: + getSymbol(c, tok) + of '$': getDollarString(c, tok) + of '[': + tok.kind = tkBracketLe + inc(c.bufpos) + add(tok.literal, '[') + of ']': + tok.kind = tkBracketRi + inc(c.bufpos) + add(tok.literal, ']') + of '(': + tok.kind = tkParLe + inc(c.bufpos) + add(tok.literal, '(') + of ')': + tok.kind = tkParRi + inc(c.bufpos) + add(tok.literal, ')') + of '.': + if c.buf[c.bufpos + 1] in Digits: + getNumeric(c, tok) + else: + tok.kind = tkDot + inc(c.bufpos) + add(tok.literal, '.') + of '0'..'9': getNumeric(c, tok) + of '\'': getString(c, tok, tkStringConstant) + of '"': getQuotedIdentifier(c, tok, '"') + of '`': getQuotedIdentifier(c, tok, '`') + of lexbase.EndOfFile: + tok.kind = tkEof + tok.literal = "[EOF]" + of 'a', 'c', 'd', 'f'..'w', 'y', 'z', 'A', 'C', 'D', 'F'..'W', 'Y', 'Z', '_', + '\128'..'\255': + getSymbol(c, tok) + of '+', '-', '*', '/', '<', '>', '=', '~', '!', '@', '#', '%', + '^', '&', '|', '?': + getOperator(c, tok) + else: + add(tok.literal, c.buf[c.bufpos]) + inc(c.bufpos) + +proc errorStr(L: SqlLexer, msg: string): string = + result = "$1($2, $3) Error: $4" % [L.filename, $getLine(L), $getColumn(L), msg] + + +# ----------------------------- parser ---------------------------------------- + +# Operator/Element Associativity Description +# . left table/column name separator +# :: left PostgreSQL-style typecast +# [ ] left array element selection +# - right unary minus +# ^ left exponentiation +# * / % left multiplication, division, modulo +# + - left addition, subtraction +# IS IS TRUE, IS FALSE, IS UNKNOWN, IS NULL +# ISNULL test for null +# NOTNULL test for not null +# (any other) left all other native and user-defined oprs +# IN set membership +# BETWEEN range containment +# OVERLAPS time interval overlap +# LIKE ILIKE SIMILAR string pattern matching +# < > less than, greater than +# = right equality, assignment +# NOT right logical negation +# AND left logical conjunction +# OR left logical disjunction + +type + SqlNodeKind* = enum ## kind of SQL abstract syntax tree + nkNone, + nkIdent, + nkQuotedIdent, + nkStringLit, + nkBitStringLit, + nkHexStringLit, + nkIntegerLit, + nkNumericLit, + nkPrimaryKey, + nkForeignKey, + nkNotNull, + nkNull, + + nkStmtList, + nkDot, + nkDotDot, + nkPrefix, + nkInfix, + nkCall, + nkPrGroup, + nkColumnReference, + nkReferences, + nkDefault, + nkCheck, + nkConstraint, + nkUnique, + nkIdentity, + nkColumnDef, ## name, datatype, constraints + nkInsert, + nkUpdate, + nkDelete, + nkSelect, + nkSelectDistinct, + nkSelectColumns, + nkSelectPair, + nkAsgn, + nkFrom, + nkFromItemPair, + nkJoin, + nkNaturalJoin, + nkUsing, + nkGroup, + nkLimit, + nkOffset, + nkHaving, + nkOrder, + nkDesc, + nkUnion, + nkIntersect, + nkExcept, + nkColumnList, + nkValueList, + nkWhere, + nkCreateTable, + nkCreateTableIfNotExists, + nkCreateType, + nkCreateTypeIfNotExists, + nkCreateIndex, + nkCreateIndexIfNotExists, + nkEnumDef, + nkPragma, + nkAutoIncrement, + nkOnDelete, + nkOnUpdate, + nkDeferrable, + nkInitially, + nkCollate + +const + LiteralNodes = { + nkIdent, nkQuotedIdent, nkStringLit, nkBitStringLit, nkHexStringLit, + nkIntegerLit, nkNumericLit + } + +type + SqlParseError* = object of ValueError ## Invalid SQL encountered + SqlNode* = ref SqlNodeObj ## an SQL abstract syntax tree node + SqlNodeObj* = object ## an SQL abstract syntax tree node + case kind*: SqlNodeKind ## kind of syntax tree + of LiteralNodes: + strVal*: string ## AST leaf: the identifier, numeric literal + ## string literal, etc. + else: + sons*: seq[SqlNode] ## the node's children + + SqlParser* = object of SqlLexer ## SQL parser object + tok: Token + considerTypeParams: bool ## Determines whether type parameters (e.g., sizes in types like VARCHAR(255)) are included in the SQL AST. + +proc newNode*(k: SqlNodeKind): SqlNode = + when defined(js): # bug #14117 + case k + of LiteralNodes: + result = SqlNode(kind: k, strVal: "") + else: + result = SqlNode(kind: k, sons: @[]) + else: + result = SqlNode(kind: k) + +proc newNode*(k: SqlNodeKind, s: string): SqlNode = + result = SqlNode(kind: k) + result.strVal = s + +proc newNode*(k: SqlNodeKind, sons: seq[SqlNode]): SqlNode = + result = SqlNode(kind: k) + result.sons = sons + +proc len*(n: SqlNode): int = + if n.kind in LiteralNodes: + result = 0 + else: + result = n.sons.len + +proc `[]`*(n: SqlNode; i: int): SqlNode = n.sons[i] +proc `[]`*(n: SqlNode; i: BackwardsIndex): SqlNode = n.sons[n.len - int(i)] + +proc add*(father, n: SqlNode) = + add(father.sons, n) + +proc getTok(p: var SqlParser) = + getTok(p, p.tok) + +proc sqlError(p: SqlParser, msg: string) {.noreturn.} = + var e: ref SqlParseError + new(e) + e.msg = errorStr(p, msg) + raise e + +proc isKeyw(p: SqlParser, keyw: string): bool = + result = p.tok.kind == tkIdentifier and + cmpIgnoreCase(p.tok.literal, keyw) == 0 + +proc isOpr(p: SqlParser, opr: string): bool = + result = p.tok.kind == tkOperator and + cmpIgnoreCase(p.tok.literal, opr) == 0 + +proc optKeyw(p: var SqlParser, keyw: string) = + if p.tok.kind == tkIdentifier and cmpIgnoreCase(p.tok.literal, keyw) == 0: + getTok(p) + +proc expectIdent(p: SqlParser) = + if p.tok.kind != tkIdentifier and p.tok.kind != tkQuotedIdentifier: + sqlError(p, "identifier expected") + +proc expect(p: SqlParser, kind: TokKind) = + if p.tok.kind != kind: + sqlError(p, tokKindToStr[kind] & " expected") + +proc eat(p: var SqlParser, kind: TokKind) = + if p.tok.kind == kind: + getTok(p) + else: + sqlError(p, tokKindToStr[kind] & " expected") + +proc eat(p: var SqlParser, keyw: string) = + if isKeyw(p, keyw): + getTok(p) + else: + sqlError(p, keyw.toUpperAscii() & " expected") + +proc opt(p: var SqlParser, kind: TokKind) = + if p.tok.kind == kind: getTok(p) + +proc parseDataType(p: var SqlParser): SqlNode = + if isKeyw(p, "enum"): + result = newNode(nkEnumDef) + getTok(p) + if p.tok.kind == tkParLe: + getTok(p) + result.add(newNode(nkStringLit, p.tok.literal)) + getTok(p) + while p.tok.kind == tkComma: + getTok(p) + result.add(newNode(nkStringLit, p.tok.literal)) + getTok(p) + eat(p, tkParRi) + else: + expectIdent(p) + result = newNode(nkIdent, p.tok.literal) + getTok(p) + if p.tok.kind == tkParLe: + var complexType = newNode(nkCall) + complexType.add(result) + getTok(p) + complexType.add(newNode(nkIntegerLit, p.tok.literal)) + expect(p, tkInteger) + getTok(p) + while p.tok.kind == tkComma: + getTok(p) + complexType.add(newNode(nkIntegerLit, p.tok.literal)) + expect(p, tkInteger) + getTok(p) + eat(p, tkParRi) + if p.considerTypeParams: + result = complexType + +proc getPrecedence(p: SqlParser): int = + if isOpr(p, "*") or isOpr(p, "/") or isOpr(p, "%"): + result = 6 + elif isOpr(p, "+") or isOpr(p, "-"): + result = 5 + elif isOpr(p, "=") or isOpr(p, "<") or isOpr(p, ">") or isOpr(p, ">=") or + isOpr(p, "<=") or isOpr(p, "<>") or isOpr(p, "!=") or isKeyw(p, "is") or + isKeyw(p, "like") or isKeyw(p, "in"): + result = 4 + elif isKeyw(p, "and"): + result = 3 + elif isKeyw(p, "or"): + result = 2 + elif isKeyw(p, "between"): + result = 1 + elif p.tok.kind == tkOperator: + # user-defined operator: + result = 0 + else: + result = - 1 + +proc parseExpr(p: var SqlParser): SqlNode {.gcsafe.} +proc parseSelect(p: var SqlParser): SqlNode {.gcsafe.} + +proc identOrLiteral(p: var SqlParser): SqlNode = + result = nil + case p.tok.kind + of tkQuotedIdentifier: + result = newNode(nkQuotedIdent, p.tok.literal) + getTok(p) + of tkIdentifier: + result = newNode(nkIdent, p.tok.literal) + getTok(p) + of tkStringConstant, tkEscapeConstant, tkDollarQuotedConstant: + result = newNode(nkStringLit, p.tok.literal) + getTok(p) + of tkBitStringConstant: + result = newNode(nkBitStringLit, p.tok.literal) + getTok(p) + of tkHexStringConstant: + result = newNode(nkHexStringLit, p.tok.literal) + getTok(p) + of tkInteger: + result = newNode(nkIntegerLit, p.tok.literal) + getTok(p) + of tkNumeric: + result = newNode(nkNumericLit, p.tok.literal) + getTok(p) + of tkParLe: + getTok(p) + result = newNode(nkPrGroup) + while true: + result.add(parseExpr(p)) + if p.tok.kind != tkComma: break + getTok(p) + eat(p, tkParRi) + else: + if p.tok.literal == "*": + result = newNode(nkIdent, p.tok.literal) + getTok(p) + else: + sqlError(p, "expression expected") + # getTok(p) # we must consume a token here to prevent endless loops! + +proc primary(p: var SqlParser): SqlNode = + if (p.tok.kind == tkOperator and (p.tok.literal == "+" or p.tok.literal == + "-")) or isKeyw(p, "not"): + result = newNode(nkPrefix) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + result.add(primary(p)) + return + result = identOrLiteral(p) + while true: + case p.tok.kind + of tkParLe: + var a = result + result = newNode(nkCall) + result.add(a) + getTok(p) + while p.tok.kind != tkParRi: + result.add(parseExpr(p)) + if p.tok.kind == tkComma: getTok(p) + else: break + eat(p, tkParRi) + of tkDot: + getTok(p) + var a = result + if p.tok.kind == tkDot: + getTok(p) + result = newNode(nkDotDot) + else: + result = newNode(nkDot) + result.add(a) + if isOpr(p, "*"): + result.add(newNode(nkIdent, "*")) + elif p.tok.kind in {tkIdentifier, tkQuotedIdentifier}: + result.add(newNode(nkIdent, p.tok.literal)) + else: + sqlError(p, "identifier expected") + getTok(p) + else: break + +proc lowestExprAux(p: var SqlParser, v: out SqlNode, limit: int): int = + var + v2, node, opNode: SqlNode + v = primary(p) # expand while operators have priorities higher than 'limit' + var opPred = getPrecedence(p) + result = opPred + while opPred > limit: + node = newNode(nkInfix) + opNode = newNode(nkIdent, p.tok.literal.toLowerAscii()) + getTok(p) + result = lowestExprAux(p, v2, opPred) + node.add(opNode) + node.add(v) + node.add(v2) + v = node + opPred = getPrecedence(p) + +proc parseExpr(p: var SqlParser): SqlNode = + discard lowestExprAux(p, result, - 1) + +proc parseTableName(p: var SqlParser): SqlNode = + expectIdent(p) + result = primary(p) + +proc parseColumnReference(p: var SqlParser): SqlNode = + result = parseTableName(p) + if p.tok.kind == tkParLe: + getTok(p) + var a = result + result = newNode(nkColumnReference) + result.add(a) + result.add(parseTableName(p)) + while p.tok.kind == tkComma: + getTok(p) + result.add(parseTableName(p)) + eat(p, tkParRi) + +proc parseCheck(p: var SqlParser): SqlNode = + getTok(p) + result = newNode(nkCheck) + result.add(parseExpr(p)) + +proc parseConstraint(p: var SqlParser): SqlNode = + getTok(p) + result = newNode(nkConstraint) + expectIdent(p) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + optKeyw(p, "check") + result.add(parseExpr(p)) + +proc parseParIdentList(p: var SqlParser, father: SqlNode) = + eat(p, tkParLe) + while true: + expectIdent(p) + father.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + if p.tok.kind != tkComma: break + getTok(p) + eat(p, tkParRi) + +proc parseColumnConstraints(p: var SqlParser, result: SqlNode) = + while true: + if isKeyw(p, "default"): + getTok(p) + var n = newNode(nkDefault) + n.add(parseExpr(p)) + result.add(n) + elif isKeyw(p, "references"): + getTok(p) + var n = newNode(nkReferences) + n.add(parseColumnReference(p)) + # Optional reference actions and attributes + # ON DELETE | ON UPDATE + # DEFERRABLE [INITIALLY {DEFERRED|IMMEDIATE}] | NOT DEFERRABLE + # MATCH + while true: + if isKeyw(p, "on"): + getTok(p) + if isKeyw(p, "delete"): + getTok(p) + var act = newNode(nkOnDelete) + # Parse action: cascade | restrict | no action | set null | set default + if isKeyw(p, "cascade") or isKeyw(p, "restrict"): + act.add(newNode(nkIdent, p.tok.literal.toLowerAscii())) + getTok(p) + elif isKeyw(p, "no"): + getTok(p); eat(p, "action") + act.add(newNode(nkIdent, "no action")) + elif isKeyw(p, "set"): + getTok(p) + if isKeyw(p, "null"): + getTok(p) + act.add(newNode(nkIdent, "set null")) + elif isKeyw(p, "default"): + getTok(p) + act.add(newNode(nkIdent, "set default")) + else: + # Unknown, stop parsing ON ... + discard + else: + discard + n.add(act) + elif isKeyw(p, "update"): + getTok(p) + var act = newNode(nkOnUpdate) + if isKeyw(p, "cascade") or isKeyw(p, "restrict"): + act.add(newNode(nkIdent, p.tok.literal.toLowerAscii())) + getTok(p) + elif isKeyw(p, "no"): + getTok(p); eat(p, "action") + act.add(newNode(nkIdent, "no action")) + elif isKeyw(p, "set"): + getTok(p) + if isKeyw(p, "null"): + getTok(p) + act.add(newNode(nkIdent, "set null")) + elif isKeyw(p, "default"): + getTok(p) + act.add(newNode(nkIdent, "set default")) + else: + discard + else: + discard + n.add(act) + else: + # Unknown ON ... clause + discard + elif isKeyw(p, "deferrable"): + getTok(p) + var d = newNode(nkDeferrable) + if isKeyw(p, "initially"): + getTok(p) + if isKeyw(p, "deferred") or isKeyw(p, "immediate"): + d.add(newNode(nkInitially, p.tok.literal.toLowerAscii())) + getTok(p) + n.add(d) + elif isKeyw(p, "not"): + getTok(p) + if isKeyw(p, "deferrable"): + getTok(p) + var d = newNode(nkDeferrable) + d.add(newNode(nkIdent, "not")) + n.add(d) + else: + # Unknown NOT ... clause + discard + elif isKeyw(p, "match"): + # Consume match + getTok(p) + if p.tok.kind in {tkIdentifier, tkQuotedIdentifier}: + # store as an identifier child under references + n.add(newNode(nkIdent, p.tok.literal.toLowerAscii())) + getTok(p) + else: + discard + else: + break + result.add(n) + elif isKeyw(p, "not"): + getTok(p) + eat(p, "null") + result.add(newNode(nkNotNull)) + elif isKeyw(p, "null"): + getTok(p) + result.add(newNode(nkNull)) + elif isKeyw(p, "identity"): + getTok(p) + result.add(newNode(nkIdentity)) + elif isKeyw(p, "primary"): + getTok(p) + eat(p, "key") + result.add(newNode(nkPrimaryKey)) + elif isKeyw(p, "check"): + result.add(parseCheck(p)) + elif isKeyw(p, "constraint"): + result.add(parseConstraint(p)) + elif isKeyw(p, "unique"): + getTok(p) + result.add(newNode(nkUnique)) + elif isKeyw(p, "autoincrement"): + getTok(p) + result.add(newNode(nkAutoIncrement)) + elif isKeyw(p, "collate"): + getTok(p) + # collate + if p.tok.kind in {tkIdentifier, tkQuotedIdentifier}: + var c = newNode(nkCollate) + c.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + result.add(c) + else: + discard + else: + break + +proc parseColumnDef(p: var SqlParser): SqlNode = + expectIdent(p) + result = newNode(nkColumnDef) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + result.add(parseDataType(p)) + parseColumnConstraints(p, result) + +proc parseIfNotExists(p: var SqlParser, k: SqlNodeKind): SqlNode = + getTok(p) + if isKeyw(p, "if"): + getTok(p) + eat(p, "not") + eat(p, "exists") + result = newNode(succ(k)) + else: + result = newNode(k) + +proc parseTableConstraint(p: var SqlParser): SqlNode = + if isKeyw(p, "primary"): + getTok(p) + eat(p, "key") + result = newNode(nkPrimaryKey) + parseParIdentList(p, result) + elif isKeyw(p, "foreign"): + getTok(p) + eat(p, "key") + result = newNode(nkForeignKey) + parseParIdentList(p, result) + eat(p, "references") + var m = newNode(nkReferences) + m.add(parseColumnReference(p)) + # Optional reference actions and attributes for table-level FK + while true: + if isKeyw(p, "on"): + getTok(p) + if isKeyw(p, "delete"): + getTok(p) + var act = newNode(nkOnDelete) + if isKeyw(p, "cascade") or isKeyw(p, "restrict"): + act.add(newNode(nkIdent, p.tok.literal.toLowerAscii())) + getTok(p) + elif isKeyw(p, "no"): + getTok(p); eat(p, "action") + act.add(newNode(nkIdent, "no action")) + elif isKeyw(p, "set"): + getTok(p) + if isKeyw(p, "null"): + getTok(p) + act.add(newNode(nkIdent, "set null")) + elif isKeyw(p, "default"): + getTok(p) + act.add(newNode(nkIdent, "set default")) + else: + discard + else: + discard + m.add(act) + elif isKeyw(p, "update"): + getTok(p) + var act = newNode(nkOnUpdate) + if isKeyw(p, "cascade") or isKeyw(p, "restrict"): + act.add(newNode(nkIdent, p.tok.literal.toLowerAscii())) + getTok(p) + elif isKeyw(p, "no"): + getTok(p); eat(p, "action") + act.add(newNode(nkIdent, "no action")) + elif isKeyw(p, "set"): + getTok(p) + if isKeyw(p, "null"): + getTok(p) + act.add(newNode(nkIdent, "set null")) + elif isKeyw(p, "default"): + getTok(p) + act.add(newNode(nkIdent, "set default")) + else: + discard + else: + discard + m.add(act) + else: + discard + elif isKeyw(p, "deferrable"): + getTok(p) + var d = newNode(nkDeferrable) + if isKeyw(p, "initially"): + getTok(p) + if isKeyw(p, "deferred") or isKeyw(p, "immediate"): + d.add(newNode(nkInitially, p.tok.literal.toLowerAscii())) + getTok(p) + m.add(d) + elif isKeyw(p, "not"): + getTok(p) + if isKeyw(p, "deferrable"): + getTok(p) + var d = newNode(nkDeferrable) + d.add(newNode(nkIdent, "not")) + m.add(d) + else: + discard + elif isKeyw(p, "match"): + getTok(p) + if p.tok.kind in {tkIdentifier, tkQuotedIdentifier}: + m.add(newNode(nkIdent, p.tok.literal.toLowerAscii())) + getTok(p) + else: + discard + else: + break + result.add(m) + elif isKeyw(p, "unique"): + getTok(p) + eat(p, "key") + result = newNode(nkUnique) + parseParIdentList(p, result) + elif isKeyw(p, "check"): + result = parseCheck(p) + elif isKeyw(p, "constraint"): + result = parseConstraint(p) + else: + sqlError(p, "column definition expected") + +proc parseUnique(p: var SqlParser): SqlNode = + result = parseExpr(p) + if result.kind == nkCall: result.kind = nkUnique + +proc parseTableDef(p: var SqlParser): SqlNode = + result = parseIfNotExists(p, nkCreateTable) + expectIdent(p) + if p.tok.kind == tkQuotedIdentifier: + result.add(newNode(nkQuotedIdent, p.tok.literal)) + else: + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + if p.tok.kind == tkParLe: + getTok(p) + while p.tok.kind != tkParRi: + if isKeyw(p, "constraint"): + result.add parseConstraint(p) + elif isKeyw(p, "primary") or isKeyw(p, "foreign"): + result.add parseTableConstraint(p) + elif isKeyw(p, "unique"): + result.add parseUnique(p) + elif p.tok.kind == tkIdentifier or p.tok.kind == tkQuotedIdentifier: + result.add(parseColumnDef(p)) + else: + result.add(parseTableConstraint(p)) + if p.tok.kind != tkComma: break + getTok(p) + eat(p, tkParRi) + # skip additional crap after 'create table (...) crap;' + while p.tok.kind notin {tkSemicolon, tkEof}: + getTok(p) + +proc parseTypeDef(p: var SqlParser): SqlNode = + result = parseIfNotExists(p, nkCreateType) + expectIdent(p) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + eat(p, "as") + result.add(parseDataType(p)) + +proc parseWhere(p: var SqlParser): SqlNode = + getTok(p) + result = newNode(nkWhere) + result.add(parseExpr(p)) + +proc parseJoinType(p: var SqlParser): SqlNode = + ## parse [ INNER ] JOIN | ( LEFT | RIGHT | FULL ) [ OUTER ] JOIN + if isKeyw(p, "inner"): + getTok(p) + eat(p, "join") + return newNode(nkIdent, "inner") + elif isKeyw(p, "join"): + getTok(p) + return newNode(nkIdent, "") + elif isKeyw(p, "left") or isKeyw(p, "full") or isKeyw(p, "right"): + var joinType = newNode(nkIdent, p.tok.literal.toLowerAscii()) + getTok(p) + optKeyw(p, "outer") + eat(p, "join") + return joinType + else: + sqlError(p, "join type expected") + +proc parseFromItem(p: var SqlParser): SqlNode = + result = newNode(nkFromItemPair) + var expectAs = true + if p.tok.kind == tkParLe: + getTok(p) + if isKeyw(p, "select"): + result.add(parseSelect(p)) + else: + result = parseFromItem(p) + expectAs = false + eat(p, tkParRi) + else: + result.add(parseExpr(p)) + if expectAs and isKeyw(p, "as"): + getTok(p) + result.add(parseExpr(p)) + while true: + if isKeyw(p, "cross"): + var join = newNode(nkJoin) + join.add(newNode(nkIdent, "cross")) + join.add(result) + getTok(p) + eat(p, "join") + join.add(parseFromItem(p)) + result = join + elif isKeyw(p, "natural"): + var join = newNode(nkNaturalJoin) + getTok(p) + join.add(parseJoinType(p)) + join.add(result) + join.add(parseFromItem(p)) + result = join + elif isKeyw(p, "inner") or isKeyw(p, "join") or isKeyw(p, "left") or + iskeyw(p, "full") or isKeyw(p, "right"): + var join = newNode(nkJoin) + join.add(parseJoinType(p)) + join.add(result) + join.add(parseFromItem(p)) + if isKeyw(p, "on"): + getTok(p) + join.add(parseExpr(p)) + elif isKeyw(p, "using"): + getTok(p) + var n = newNode(nkUsing) + parseParIdentList(p, n) + join.add n + else: + sqlError(p, "ON or USING expected") + result = join + else: + break + +proc parseIndexDef(p: var SqlParser): SqlNode = + result = parseIfNotExists(p, nkCreateIndex) + if isKeyw(p, "primary"): + getTok(p) + eat(p, "key") + result.add(newNode(nkPrimaryKey)) + else: + expectIdent(p) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + eat(p, "on") + expectIdent(p) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + eat(p, tkParLe) + expectIdent(p) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + while p.tok.kind == tkComma: + getTok(p) + expectIdent(p) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + eat(p, tkParRi) + +proc parseInsert(p: var SqlParser): SqlNode = + getTok(p) + eat(p, "into") + expectIdent(p) + result = newNode(nkInsert) + result.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + if p.tok.kind == tkParLe: + var n = newNode(nkColumnList) + parseParIdentList(p, n) + result.add n + else: + result.add(newNode(nkNone)) + if isKeyw(p, "default"): + getTok(p) + eat(p, "values") + result.add(newNode(nkDefault)) + else: + eat(p, "values") + eat(p, tkParLe) + var n = newNode(nkValueList) + while true: + n.add(parseExpr(p)) + if p.tok.kind != tkComma: break + getTok(p) + result.add(n) + eat(p, tkParRi) + +proc parseUpdate(p: var SqlParser): SqlNode = + getTok(p) + result = newNode(nkUpdate) + result.add(primary(p)) + eat(p, "set") + while true: + var a = newNode(nkAsgn) + expectIdent(p) + a.add(newNode(nkIdent, p.tok.literal)) + getTok(p) + if isOpr(p, "="): getTok(p) + else: sqlError(p, "= expected") + a.add(parseExpr(p)) + result.add(a) + if p.tok.kind != tkComma: break + getTok(p) + if isKeyw(p, "where"): + result.add(parseWhere(p)) + else: + result.add(newNode(nkNone)) + +proc parseDelete(p: var SqlParser): SqlNode = + getTok(p) + if isOpr(p, "*"): + getTok(p) + result = newNode(nkDelete) + eat(p, "from") + result.add(primary(p)) + if isKeyw(p, "where"): + result.add(parseWhere(p)) + else: + result.add(newNode(nkNone)) + +proc parsePragma(p: var SqlParser): SqlNode = + ## Parses SQLite PRAGMA statements. + ## Supports forms: + ## PRAGMA name; + ## PRAGMA name = expr; + ## PRAGMA name(expr); + getTok(p) # consume 'pragma' + # name can be dotted identifier or general primary expression + # Use primary so dotted names like schema.table.pragma work. + result = newNode(nkPragma) + result.add(primary(p)) + # Optional assignment or parenthesized argument + if isOpr(p, "="): + getTok(p) + result.add(parseExpr(p)) + elif p.tok.kind == tkParLe: + # Parenthesized argument is parsed as a group expression + getTok(p) + var grp = newNode(nkPrGroup) + grp.add(parseExpr(p)) + while p.tok.kind == tkComma: + getTok(p) + grp.add(parseExpr(p)) + eat(p, tkParRi) + result.add(grp) + +proc parseSelect(p: var SqlParser): SqlNode = + getTok(p) + if isKeyw(p, "distinct"): + getTok(p) + result = newNode(nkSelectDistinct) + elif isKeyw(p, "all"): + getTok(p) + result = newNode(nkSelect) + var a = newNode(nkSelectColumns) + while true: + if isOpr(p, "*"): + a.add(newNode(nkIdent, "*")) + getTok(p) + else: + var pair = newNode(nkSelectPair) + pair.add(parseExpr(p)) + a.add(pair) + if isKeyw(p, "as"): + getTok(p) + pair.add(parseExpr(p)) + if p.tok.kind != tkComma: break + getTok(p) + result.add(a) + if isKeyw(p, "from"): + var f = newNode(nkFrom) + while true: + getTok(p) + f.add(parseFromItem(p)) + if p.tok.kind != tkComma: break + result.add(f) + if isKeyw(p, "where"): + result.add(parseWhere(p)) + if isKeyw(p, "group"): + getTok(p) + eat(p, "by") + var g = newNode(nkGroup) + while true: + g.add(parseExpr(p)) + if p.tok.kind != tkComma: break + getTok(p) + result.add(g) + if isKeyw(p, "order"): + getTok(p) + eat(p, "by") + var n = newNode(nkOrder) + while true: + var e = parseExpr(p) + if isKeyw(p, "asc"): + getTok(p) # is default + elif isKeyw(p, "desc"): + getTok(p) + var x = newNode(nkDesc) + x.add(e) + e = x + n.add(e) + if p.tok.kind != tkComma: break + getTok(p) + result.add(n) + if isKeyw(p, "having"): + var h = newNode(nkHaving) + while true: + getTok(p) + h.add(parseExpr(p)) + if p.tok.kind != tkComma: break + result.add(h) + if isKeyw(p, "union"): + result.add(newNode(nkUnion)) + getTok(p) + elif isKeyw(p, "intersect"): + result.add(newNode(nkIntersect)) + getTok(p) + elif isKeyw(p, "except"): + result.add(newNode(nkExcept)) + getTok(p) + if isKeyw(p, "limit"): + getTok(p) + var l = newNode(nkLimit) + l.add(parseExpr(p)) + result.add(l) + if isKeyw(p, "offset"): + getTok(p) + var o = newNode(nkOffset) + o.add(parseExpr(p)) + result.add(o) + +proc parseStmt(p: var SqlParser; parent: SqlNode) = + if isKeyw(p, "create"): + getTok(p) + optKeyw(p, "cached") + optKeyw(p, "memory") + optKeyw(p, "temp") + optKeyw(p, "global") + optKeyw(p, "local") + optKeyw(p, "temporary") + optKeyw(p, "unique") + optKeyw(p, "hash") + if isKeyw(p, "table"): + parent.add parseTableDef(p) + elif isKeyw(p, "type"): + parent.add parseTypeDef(p) + elif isKeyw(p, "index"): + parent.add parseIndexDef(p) + else: + sqlError(p, "TABLE expected") + elif isKeyw(p, "insert"): + parent.add parseInsert(p) + elif isKeyw(p, "update"): + parent.add parseUpdate(p) + elif isKeyw(p, "delete"): + parent.add parseDelete(p) + elif isKeyw(p, "select"): + parent.add parseSelect(p) + elif isKeyw(p, "pragma"): + # SQLite specific: treat as standalone statement + parent.add parsePragma(p) + elif isKeyw(p, "begin"): + getTok(p) + else: + sqlError(p, "SELECT, CREATE, UPDATE or DELETE expected") + +proc parse(p: var SqlParser): SqlNode = + ## parses the content of `p`'s input stream and returns the SQL AST. + ## Syntax errors raise an `SqlParseError` exception. + result = newNode(nkStmtList) + while p.tok.kind != tkEof: + parseStmt(p, result) + if p.tok.kind == tkEof: + break + eat(p, tkSemicolon) + +proc close(p: var SqlParser) = + ## closes the parser `p`. The associated input stream is closed too. + close(SqlLexer(p)) + +type + SqlWriter = object + indent: int + upperCase: bool + buffer: string + +proc add(s: var SqlWriter, thing: char) = + s.buffer.add(thing) + +proc prepareAdd(s: var SqlWriter) {.inline.} = + if s.buffer.len > 0 and s.buffer[^1] notin {' ', '\L', '(', '.'}: + s.buffer.add(" ") + +proc add(s: var SqlWriter, thing: string) = + s.prepareAdd + s.buffer.add(thing) + +proc addKeyw(s: var SqlWriter, thing: string) = + var keyw = thing + if s.upperCase: + keyw = keyw.toUpperAscii() + s.add(keyw) + +proc addIden(s: var SqlWriter, thing: string) = + var iden = thing + if iden.toLowerAscii() in reservedKeywords: + iden = '"' & iden & '"' + s.add(iden) + +proc ra(n: SqlNode, s: var SqlWriter) {.gcsafe.} + +proc rs(n: SqlNode, s: var SqlWriter, prefix = "(", suffix = ")", sep = ", ") = + if n.len > 0: + s.add(prefix) + for i in 0 .. n.len-1: + if i > 0: s.add(sep) + ra(n.sons[i], s) + s.add(suffix) + +proc addMulti(s: var SqlWriter, n: SqlNode, sep = ',') = + if n.len > 0: + for i in 0 .. n.len-1: + if i > 0: s.add(sep) + ra(n.sons[i], s) + +proc addMulti(s: var SqlWriter, n: SqlNode, sep = ',', prefix, suffix: char) = + if n.len > 0: + s.add(prefix) + for i in 0 .. n.len-1: + if i > 0: s.add(sep) + ra(n.sons[i], s) + s.add(suffix) + +proc quoted(s: string): string = + "\"" & replace(s, "\"", "\"\"") & "\"" + +func escape(result: var string; s: string) = + result.add('\'') + for c in items(s): + case c + of '\0'..'\31': + result.add("\\x") + result.add(toHex(ord(c), 2)) + of '\'': result.add("''") + else: result.add(c) + result.add('\'') + +proc ra(n: SqlNode, s: var SqlWriter) = + if n == nil: return + case n.kind + of nkNone: discard + of nkIdent: + if allCharsInSet(n.strVal, {'\33'..'\127'}): + s.add(n.strVal) + else: + s.add(quoted(n.strVal)) + of nkQuotedIdent: + s.add(quoted(n.strVal)) + of nkStringLit: + s.prepareAdd + s.buffer.escape(n.strVal) + of nkBitStringLit: + s.add("b'" & n.strVal & "'") + of nkHexStringLit: + s.add("x'" & n.strVal & "'") + of nkIntegerLit, nkNumericLit: + s.add(n.strVal) + of nkPrimaryKey: + s.addKeyw("primary key") + rs(n, s) + of nkForeignKey: + s.addKeyw("foreign key") + # Render only the column identifiers inside parentheses, + # then append the REFERENCES clause (and options) after. + var i = 0 + s.add('(') + while i < n.len and n.sons[i].kind != nkReferences: + if i > 0: s.add(", ") + ra(n.sons[i], s) + inc i + s.add(')') + # If a REFERENCES node exists, render it after the column list + while i < n.len: + s.add(' ') + ra(n.sons[i], s) + inc i + of nkNotNull: + s.addKeyw("not null") + of nkNull: + s.addKeyw("null") + of nkDot: + ra(n.sons[0], s) + s.add('.') + ra(n.sons[1], s) + of nkDotDot: + ra(n.sons[0], s) + s.add(". .") + ra(n.sons[1], s) + of nkPrefix: + ra(n.sons[0], s) + s.add(' ') + ra(n.sons[1], s) + of nkInfix: + ra(n.sons[1], s) + s.add(' ') + ra(n.sons[0], s) + s.add(' ') + ra(n.sons[2], s) + of nkCall, nkColumnReference: + ra(n.sons[0], s) + s.add('(') + for i in 1..n.len-1: + if i > 1: s.add(',') + ra(n.sons[i], s) + s.add(')') + of nkPrGroup: + s.add('(') + s.addMulti(n) + s.add(')') + of nkReferences: + s.addKeyw("references") + ra(n.sons[0], s) + of nkDefault: + s.addKeyw("default") + ra(n.sons[0], s) + of nkAutoIncrement: + s.addKeyw("autoincrement") + of nkCheck: + s.addKeyw("check") + ra(n.sons[0], s) + of nkConstraint: + s.addKeyw("constraint") + ra(n.sons[0], s) + s.addKeyw("check") + ra(n.sons[1], s) + of nkUnique: + s.addKeyw("unique") + rs(n, s) + of nkIdentity: + s.addKeyw("identity") + of nkColumnDef: + rs(n, s, "", "", " ") + of nkStmtList: + for i in 0..n.len-1: + ra(n.sons[i], s) + s.add(';') + of nkInsert: + assert n.len == 3 + s.addKeyw("insert into") + ra(n.sons[0], s) + s.add(' ') + ra(n.sons[1], s) + if n.sons[2].kind == nkDefault: + s.addKeyw("default values") + else: + ra(n.sons[2], s) + of nkUpdate: + s.addKeyw("update") + ra(n.sons[0], s) + s.addKeyw("set") + var L = n.len + for i in 1 .. L-2: + if i > 1: s.add(", ") + var it = n.sons[i] + assert it.kind == nkAsgn + ra(it, s) + ra(n.sons[L-1], s) + of nkDelete: + s.addKeyw("delete from") + ra(n.sons[0], s) + ra(n.sons[1], s) + of nkSelect, nkSelectDistinct: + s.addKeyw("select") + if n.kind == nkSelectDistinct: + s.addKeyw("distinct") + for i in 0 ..< n.len: + ra(n.sons[i], s) + of nkSelectColumns: + for i, column in n.sons: + if i > 0: s.add(',') + ra(column, s) + of nkSelectPair: + ra(n.sons[0], s) + if n.sons.len == 2: + s.addKeyw("as") + ra(n.sons[1], s) + of nkFromItemPair: + if n.sons[0].kind in {nkIdent, nkQuotedIdent}: + ra(n.sons[0], s) + else: + assert n.sons[0].kind == nkSelect + s.add('(') + ra(n.sons[0], s) + s.add(')') + if n.sons.len == 2: + s.addKeyw("as") + ra(n.sons[1], s) + of nkAsgn: + ra(n.sons[0], s) + s.add(" = ") + ra(n.sons[1], s) + of nkFrom: + s.addKeyw("from") + s.addMulti(n) + of nkJoin, nkNaturalJoin: + var joinType = n.sons[0].strVal + if joinType == "": + joinType = "join" + else: + joinType &= " " & "join" + if n.kind == nkNaturalJoin: + joinType = "natural " & joinType + ra(n.sons[1], s) + s.addKeyw(joinType) + # If the right part of the join is not leaf, parenthesize it + if n.sons[2].kind != nkFromItemPair: + s.add('(') + ra(n.sons[2], s) + s.add(')') + else: + ra(n.sons[2], s) + if n.sons.len > 3: + if n.sons[3].kind != nkUsing: + s.addKeyw("on") + ra(n.sons[3], s) + of nkUsing: + s.addKeyw("using") + rs(n, s) + of nkOnDelete: + s.addKeyw("on delete") + s.add(' ') + if n.len > 0: ra(n.sons[0], s) + of nkOnUpdate: + s.addKeyw("on update") + s.add(' ') + if n.len > 0: ra(n.sons[0], s) + of nkDeferrable: + if n.len > 0 and n.sons[0].kind == nkIdent and n.sons[0].strVal == "not": + s.addKeyw("not deferrable") + else: + s.addKeyw("deferrable") + if n.len > 0: + for i in 0 ..< n.len: + if n.sons[i].kind == nkInitially: + s.addKeyw("initially") + s.add(' ') + ra(n.sons[i], s) + of nkInitially: + if n.len == 0: + s.add(n.strVal) + else: + ra(n.sons[0], s) + of nkCollate: + s.addKeyw("collate") + s.add(' ') + if n.len > 0: ra(n.sons[0], s) + of nkGroup: + s.addKeyw("group by") + s.addMulti(n) + of nkLimit: + s.addKeyw("limit") + s.addMulti(n) + of nkOffset: + s.addKeyw("offset") + s.addMulti(n) + of nkHaving: + s.addKeyw("having") + s.addMulti(n) + of nkOrder: + s.addKeyw("order by") + s.addMulti(n) + of nkDesc: + ra(n.sons[0], s) + s.addKeyw("desc") + of nkUnion: + s.addKeyw("union") + of nkIntersect: + s.addKeyw("intersect") + of nkExcept: + s.addKeyw("except") + of nkColumnList: + rs(n, s) + of nkValueList: + s.addKeyw("values") + rs(n, s) + of nkWhere: + s.addKeyw("where") + ra(n.sons[0], s) + of nkCreateTable, nkCreateTableIfNotExists: + s.addKeyw("create table") + if n.kind == nkCreateTableIfNotExists: + s.addKeyw("if not exists") + ra(n.sons[0], s) + s.add('(') + for i in 1..n.len-1: + if i > 1: s.add(',') + ra(n.sons[i], s) + s.add(");") + of nkCreateType, nkCreateTypeIfNotExists: + s.addKeyw("create type") + if n.kind == nkCreateTypeIfNotExists: + s.addKeyw("if not exists") + ra(n.sons[0], s) + s.addKeyw("as") + ra(n.sons[1], s) + of nkCreateIndex, nkCreateIndexIfNotExists: + s.addKeyw("create index") + if n.kind == nkCreateIndexIfNotExists: + s.addKeyw("if not exists") + ra(n.sons[0], s) + s.addKeyw("on") + ra(n.sons[1], s) + s.add('(') + for i in 2..n.len-1: + if i > 2: s.add(", ") + ra(n.sons[i], s) + s.add(");") + of nkEnumDef: + s.addKeyw("enum") + rs(n, s) + of nkPragma: + s.addKeyw("pragma") + if n.len >= 1: + s.add(' ') + ra(n.sons[0], s) + if n.len == 2: + # If value is a parenthesized group, render without '=' + if n.sons[1].kind == nkPrGroup: + ra(n.sons[1], s) + else: + s.add(" = ") + ra(n.sons[1], s) + +proc renderSql*(n: SqlNode, upperCase = false): string = + ## Converts an SQL abstract syntax tree to its string representation. + var s = SqlWriter(buffer: "", upperCase: upperCase) + ra(n, s) + return s.buffer + +proc `$`*(n: SqlNode): string = + ## an alias for `renderSql`. + renderSql(n) + +proc treeReprAux(s: SqlNode, level: int, result: var string) = + result.add('\n') + for i in 0 ..< level: result.add(" ") + + result.add($s.kind) + if s.kind in LiteralNodes: + result.add(' ') + result.add(s.strVal) + else: + for son in s.sons: + treeReprAux(son, level + 1, result) + +proc treeRepr*(s: SqlNode): string = + result = newStringOfCap(128) + treeReprAux(s, 0, result) + +import std/streams + +proc open(L: var SqlLexer, input: Stream, filename: string) = + lexbase.open(L, input) + L.filename = filename + +proc open(p: var SqlParser, input: Stream, filename: string) = + ## opens the parser `p` and assigns the input stream `input` to it. + ## `filename` is only used for error messages. + open(SqlLexer(p), input, filename) + p.tok.kind = tkInvalid + p.tok.literal = "" + getTok(p) + +proc parseSql*(input: Stream, filename: string, considerTypeParams = false): SqlNode = + ## parses the SQL from `input` into an AST and returns the AST. + ## `filename` is only used for error messages. + ## Syntax errors raise an `SqlParseError` exception. + var p: SqlParser = SqlParser(considerTypeParams: considerTypeParams) + open(p, input, filename) + try: + result = parse(p) + finally: + close(p) + +proc parseSql*(input: string, filename = "", considerTypeParams = false): SqlNode = + ## parses the SQL from `input` into an AST and returns the AST. + ## `filename` is only used for error messages. + ## Syntax errors raise an `SqlParseError` exception. + parseSql(newStringStream(input), "", considerTypeParams) diff --git a/clients/nim/ormin/ormin/queries.nim b/clients/nim/ormin/ormin/queries.nim new file mode 100644 index 0000000..8848126 --- /dev/null +++ b/clients/nim/ormin/ormin/queries.nim @@ -0,0 +1,2093 @@ + +when not declared(tableNames): + {.macros.error: "The query DSL requires a tableNames const.".} + +when not declared(attributes): + {.macros.error: "The query DSL requires a attributes const.".} + +import macros, strutils +import db_connector/db_common +from os import parentDir, `/` + +import db_types +import query_hooks + +# SQL dialect specific things: +const + equals = "=" + nequals = "<>" + +type + Function* = object + name: string + arity: int # -1 for 'varargs' + typ: DbTypeKind # if dbUnknown, use type of the last argument + + SourceColumn = object + name: string + typ: DbType + + CteDef = object + name: string + sql: string + cols: seq[SourceColumn] + +proc buildHookedParamBinding(prepStmt: NimNode; idx: int; ex, typ: NimNode; isJson: bool): NimNode + +var + functions {.compileTime.} = @[ + Function(name: "count", arity: 1, typ: dbInt), + Function(name: "coalesce", arity: -1, typ: dbUnknown), + Function(name: "min", arity: 1, typ: dbUnknown), + Function(name: "max", arity: 1, typ: dbUnknown), + Function(name: "avg", arity: 1, typ: dbFloat), + Function(name: "sum", arity: 1, typ: dbUnknown), + Function(name: "row_number", arity: 0, typ: dbInt), + Function(name: "rank", arity: 0, typ: dbInt), + Function(name: "dense_rank", arity: 0, typ: dbInt), + Function(name: "percent_rank", arity: 0, typ: dbFloat), + Function(name: "cume_dist", arity: 0, typ: dbFloat), + Function(name: "ntile", arity: 1, typ: dbInt), + Function(name: "isnull", arity: 3, typ: dbUnknown), + Function(name: "concat", arity: -1, typ: dbVarchar), + Function(name: "abs", arity: 1, typ: dbUnknown), + Function(name: "length", arity: 1, typ: dbInt), + Function(name: "lower", arity: 1, typ: dbVarchar), + Function(name: "upper", arity: 1, typ: dbVarchar), + Function(name: "replace", arity: 3, typ: dbVarchar) + ] + +proc isVarargsType(n: NimNode): bool {.compileTime.} = + ## Checks if the provided type node encodes a varargs parameter. + case n.kind + of nnkBracketExpr: + if n.len > 0: + let head = n[0] + if head.kind in {nnkIdent, nnkSym} and head.strVal == "varargs": + return true + else: + discard + result = false + +proc typeNodeToDbKind(n: NimNode): DbTypeKind {.compileTime.} = + ## Maps the return type of an imported SQL function to DbTypeKind. + if n.kind == nnkEmpty: + return dbUnknown + if isVarargsType(n): + if n.len > 1: + return typeNodeToDbKind(n[1]) + return dbUnknown + let name = + case n.kind + of nnkSym, nnkIdent: + n.strVal + else: + $n + result = dbTypFromName(name) + if result == dbUnknown and name.len > 4 and name.endsWith("Type"): + result = dbTypFromName(name[0..^5]) + +proc registerImportSqlFunction(name: string; arity: int; typ: DbTypeKind) {.compileTime.} = + ## Adds or updates a Function descriptor for vendor specific SQL routines. + let lname = name.toLowerAscii() + for f in mitems(functions): + if f.name.toLowerAscii() == lname and f.arity == arity: + f.typ = typ + return + let f = Function(name: name, arity: arity, typ: typ) + when defined(debugOrminDsl): + echo "registerImportSqlFunction: ", f + functions.add f + +macro importSql*(n: typed): untyped = + ## Registers a Nim proc as callable SQL function within the query DSL. + if n.kind notin {nnkProcDef, nnkFuncDef}: + macros.error("{.importSql.} can only be applied to proc or func definitions", n) + let params = n[3] + expectKind(params, nnkFormalParams) + var paramCount = 0 + var hasVarargs = false + for i in 1.. 2: + macros.error("varargs parameters cannot be combined with fixed parameters", identDefs) + if identDefs.len - 2 != 1: + macros.error("varargs parameter must declare exactly one identifier", identDefs) + hasVarargs = true + else: + paramCount += identDefs.len - 2 + let arity = if hasVarargs: -1 else: paramCount + let fnName = n[0].strVal + let retKind = typeNodeToDbKind(params[0]) + registerImportSqlFunction(fnName, arity, retKind) + result = newStmtList() + +type + Env = seq[(int, string)] + Params = seq[tuple[ex, typ: NimNode; isJson: bool]] + QueryKind = enum + qkNone, + qkSelect, + qkJoin, + qkInsert, + qkReplace, + qkUpdate, + qkDelete, + qkInsertReturning + QueryBuilder = ref object + head, fromm, join, values, where, groupby, having, orderby: string + limit, offset, returning, onConflict, onConflictWhere: string + env: Env + ctes: seq[CteDef] + cteBase: int + kind: QueryKind + retType: NimNode + singleRow, retTypeIsJson: bool + retNames: seq[string] + params: Params + coln, qmark, aliasGen: int + colAliases: seq[(string, DbType)] + # Track inserted column values for potential SQLite RETURNING handling + insertedValues: seq[(string, NimNode)] + # For SQLite: expression to return instead of last_insert_rowid() + retExpr: NimNode + onConflictTargetSet, onConflictActionSet, onConflictIsDoUpdate, onConflictWhereSet: bool + +# Execute a non-row SQL statement strictly (errors on failure) +template execNoRowsStrict*(sqlStmt: string) = + when defined(debugOrminTrace): + echo "[[Ormin Executing]]: ", q + let s {.gensym.} = prepareStmt(db, sqlStmt) + startQuery(db, s) + if stepQuery(db, s, false): + stopQuery(db, s) + else: + stopQuery(db, s) + dbError(db) + +# Execute a non-row SQL statement, relying on startQuery to raise on failure +template execNoRowsLoose(sqlStmt: string) = + when defined(debugOrminTrace): + echo "[[Ormin Executing]]: ", sqlStmt + let s {.gensym.} = prepareStmt(db, sqlStmt) + startQuery(db, s) + discard stepQuery(db, s, false) + stopQuery(db, s) + +proc newQueryBuilder(): QueryBuilder {.compileTime.} = + QueryBuilder(head: "", fromm: "", join: "", values: "", where: "", + groupby: "", having: "", orderby: "", limit: "", offset: "", + returning: "", onConflict: "", onConflictWhere: "", + env: @[], ctes: @[], cteBase: 0, kind: qkNone, params: @[], + retType: newNimNode(nnkTupleTy), singleRow: false, + retTypeIsJson: false, retNames: @[], + coln: 0, qmark: 0, aliasGen: 1, colAliases: @[], + insertedValues: @[], retExpr: newEmptyNode(), + onConflictTargetSet: false, onConflictActionSet: false, + onConflictIsDoUpdate: false, onConflictWhereSet: false) + +proc getAlias(q: QueryBuilder; tabIndex: int): string = + result = tableNames[tabIndex][0] & $q.aliasGen + inc q.aliasGen + +proc placeholder(q: QueryBuilder): string = + when dbBackend == DbBackend.postgre: + inc q.qmark + result = "$" & $q.qmark + else: + result = "?" + +proc cteEnvIndex(i: int): int {.compileTime.} = tableNames.len + i +proc isCteEnvIndex(i: int): bool {.compileTime.} = i >= tableNames.len +proc fromCteEnvIndex(i: int): int {.compileTime.} = i - tableNames.len + +proc lookupCte(ctes: openArray[CteDef]; name: string): int {.compileTime.} = + result = -1 + for i, cte in ctes: + if cmpIgnoreCase(cte.name, name) == 0: + return i + +proc sourceName(q: QueryBuilder; source: int): string {.compileTime.} = + if isCteEnvIndex(source): + result = q.ctes[fromCteEnvIndex(source)].name + else: + result = tableNames[source] + +proc sourceColumns(q: QueryBuilder; source: int): seq[SourceColumn] {.compileTime.} = + if isCteEnvIndex(source): + result = q.ctes[fromCteEnvIndex(source)].cols + else: + for a in attributes: + if a.tabIndex == source: + result.add SourceColumn(name: a.name, typ: DbType(kind: a.typ)) + +proc sourceLookup(q: QueryBuilder; table: string): int {.compileTime.} = + for i, t in tableNames: + if cmpIgnoreCase(t, table) == 0: + return i + let cteIdx = lookupCte(q.ctes, table) + if cteIdx >= 0: + return cteEnvIndex(cteIdx) + result = -1 + +proc sourceAlias(q: QueryBuilder; source: int; sourceName: string): string {.compileTime.} = + if q.kind == qkJoin and q.env.len > 0 and q.env[^1][0] == source: + result = q.env[^1][1] + elif isCteEnvIndex(source): + result = sourceName.toLowerAscii() & $q.aliasGen + inc q.aliasGen + else: + result = q.getAlias(source) + +proc joinKeyword(kind: string): string {.compileTime.} = + case kind.toLowerAscii() + of "join", "innerjoin": + "inner join " + of "outerjoin": + "outer join " + of "leftjoin", "leftouterjoin": + "left outer join " + of "rightjoin", "rightouterjoin": + "right outer join " + of "fulljoin", "fullouterjoin": + "full outer join " + of "crossjoin": + "cross join " + else: + "" + +proc lookup(table, attr: string; qb: QueryBuilder; alias: var string): DbType = + var found = false + var foundSource = -1 + for e in qb.env: + if table.len == 0 or cmpIgnoreCase(sourceName(qb, e[0]), table) == 0: + for col in sourceColumns(qb, e[0]): + if cmpIgnoreCase(col.name, attr) == 0: + if found: + if foundSource != e[0] or alias != e[1]: + return DbType(kind: dbUnknown) + found = true + foundSource = e[0] + alias = e[1] + result = col.typ + +proc lookup(table, attr: string; qb: QueryBuilder): DbType = + var alias: string + result = lookup(table, attr, qb, alias) + +proc lookup(table: openArray[string], name: string): int = + result = -1 + for i, t in table: + if cmpIgnoreCase(t, name) == 0: + return i + +proc autoJoin(join: var string, src: (int, string), dest: int; + destAlias: string): bool = + var srcCol = -1 + var destCol = -1 + for i, a in attributes: + if a.tabIndex == src[0] and a.key < 0 and attributes[-a.key - 1].tabIndex == dest: + if srcCol < 0: + srcCol = i + destCol = -a.key - 1 + else: + return false + if srcCol >= 0: + join.add " on " + join.add destAlias + join.add "." + join.add attributes[destCol].name + join.add equals + join.add src[1] + join.add "." + join.add attributes[srcCol].name + result = true + +proc `$`(a: DbType): string = $a.kind + +proc checkBool(a: DbType; n: NimNode) = + if a.kind != dbBool: + macros.error "expected type 'bool', but got: " & $a, n + +proc checkInt(a: DbType; n: NimNode) = + if a.kind notin {dbInt, dbSerial}: + macros.error "expected type 'int', but got: " & $a, n + +proc checkCompatible(a, b: DbType; n: NimNode) = + # Treat serial and int as compatible + if not (a.kind == b.kind or (a.kind == dbSerial and b.kind == dbInt) or (a.kind == dbInt and b.kind == dbSerial)): + macros.error "incompatible types: " & $a & " and " & $b, n + +proc checkCompatibleSet(a, b: DbType; n: NimNode) = + discard "too implement; might require a richer type system" + +proc toNimType(t: DbTypeKind): NimNode {.compileTime.} = + let name = ($t).substr(2).toLowerAscii & "Type" + result = ident(name) + +proc toNimType(t: DbType): NimNode {.compileTime.} = toNimType(t.kind) + +proc escIdent(dest: var string; src: string) = + if allCharsInSet(src, {'\33'..'\127'}): + dest.add(src) + else: + dest.add("\"" & replace(src, "\"", "\"\"") & "\"") + +proc fmtTableList(tableNames: openArray[string]): string = + result = "" + for i, t in tableNames: + if i > 10: break + if i > 0: result.add ", " + result.add t + +proc nodeName(n: NimNode): string {.compileTime.} = + case n.kind + of nnkIdent, nnkSym: + result = n.strVal + of nnkAccQuoted: + if n.len == 1: + result = nodeName(n[0]) + else: + result = "" + else: + result = "" + +proc isQueryClause(name: string): bool {.compileTime.} = + case name.toLowerAscii() + of "with", "select", "distinct", "insert", "update", "replace", "delete", + "where", "join", "innerjoin", "outerjoin", "leftjoin", "leftouterjoin", + "rightjoin", "rightouterjoin", "fulljoin", "fullouterjoin", "crossjoin", + "groupby", "orderby", "having", "limit", "offset", "returning", "produce", + "onconflict", "donothing", "doupdate": + result = true + else: + result = false + +proc isSetOpName(name: string): bool {.compileTime.} = + case name.toLowerAscii() + of "union", "intersect", "except": + result = true + else: + result = false + +proc isSetOpCall(n: NimNode): bool {.compileTime.} = + n.kind == nnkCall and isSetOpName(nodeName(n[0])) + +proc isNullLiteral(n: NimNode): bool {.compileTime.} = + case n.kind + of nnkNilLit: + result = true + of nnkIdent, nnkSym: + result = cmpIgnoreCase(n.strVal, "null") == 0 + else: + result = false + +proc peelTrailingCommand(n: NimNode): tuple[core, tail: NimNode] {.compileTime.} = + if n.kind == nnkCommand and n.len == 2 and n[1].kind == nnkCommand and + nodeName(n[1][0]) == "on": + return (copyNimTree(n), newEmptyNode()) + + if n.kind == nnkCommand and n.len == 2 and n[1].kind == nnkCommand and + nodeName(n[1][0]).toLowerAscii() in ["like", "ilike"]: + return (copyNimTree(n), newEmptyNode()) + + if n.kind == nnkCommand and n.len == 2 and n[1].kind == nnkCommand and + not isQueryClause(nodeName(n[0])) and not isSetOpName(nodeName(n[0])): + return (copyNimTree(n[0]), copyNimTree(n[1])) + + if (n.kind == nnkCommand and (isQueryClause(nodeName(n[0])) or isSetOpName(nodeName(n[0])))) or + isSetOpCall(n): + return (copyNimTree(n), newEmptyNode()) + + if n.len > 0: + let idx = n.len - 1 + let peeled = peelTrailingCommand(n[idx]) + if peeled.tail.kind != nnkEmpty: + result.core = copyNimTree(n) + result.core[idx] = peeled.core + result.tail = peeled.tail + return result + + result = (copyNimTree(n), newEmptyNode()) + +proc flattenQueryCommands(n: NimNode; parts: var seq[NimNode]) {.compileTime.} = + case n.kind + of nnkStmtList: + for it in n: + flattenQueryCommands(it, parts) + of nnkCall: + let name = nodeName(n[0]) + if isQueryClause(name): + var cmd = newNimNode(nnkCommand) + for it in n: + cmd.add copyNimTree(it) + parts.add cmd + else: + parts.add copyNimTree(n) + of nnkCommand: + var cmd = copyNimTree(n) + if cmd.len >= 2: + let idx = cmd.len - 1 + let peeled = peelTrailingCommand(cmd[idx]) + cmd[idx] = peeled.core + parts.add cmd + if peeled.tail.kind != nnkEmpty: + flattenQueryCommands(peeled.tail, parts) + else: + parts.add cmd + else: + parts.add copyNimTree(n) + +proc queryh(n: NimNode; q: QueryBuilder) +proc queryAsString(q: QueryBuilder, n: NimNode): string +proc applyQueryNode(n: NimNode; q: QueryBuilder) +proc renderInlineQuery(n: NimNode; params: var Params; + qb: QueryBuilder): tuple[sql: string, typ: DbType] +proc cond(n: NimNode; q: var string; params: var Params; + expected: DbType, qb: QueryBuilder): DbType + +proc renderWindowClause(n: NimNode; q: var string; params: var Params; + qb: QueryBuilder) {.compileTime.} = + let op = nodeName(n[0]).toLowerAscii() + case op + of "partitionby": + q.add "partition by " + for i in 1..= 0 + q.add alias + q.add '.' + escIdent(q, name) + +proc cond(n: NimNode; q: var string; params: var Params; + expected: DbType, qb: QueryBuilder): DbType = + case n.kind + of nnkIdent: + let name = $n + if name == "_": + q.add "*" + result = DbType(kind: dbUnknown) + elif cmpIgnoreCase(name, "null") == 0: + q.add "NULL" + result = expected + else: + result = lookupColumnInEnv(n, q, params, expected, qb) + of nnkDotExpr: + let t = $n[0] + let a = $n[1] + escIdent(q, t) + q.add '.' + escIdent(q, a) + result = lookup(t, a, qb) + of nnkPar, nnkStmtListExpr: + if n.len == 1: + q.add "(" + result = cond(n[0], q, params, expected, qb) + q.add ")" + else: + macros.error "tuple construction not allowed here", n + of nnkCurly: + q.add "(" + let a = cond(n[0], q, params, expected, qb) + for i in 1..=", ">", "==", "!=", "=~": + if isNullLiteral(n[1]) or isNullLiteral(n[2]): + if op != "==" and op != "!=": + macros.error "NULL comparisons only support == and !=", n + if isNullLiteral(n[1]) and isNullLiteral(n[2]): + macros.error "NULL cannot be compared against NULL", n + let target = if isNullLiteral(n[1]): n[2] else: n[1] + discard cond(target, q, params, DbType(kind: dbUnknown), qb) + if op == "==": + q.add " is NULL" + else: + q.add " is not NULL" + else: + let env = qb.env + if env.len == 2: + qb.env = @[env[0]] + let a = cond(n[1], q, params, DbType(kind: dbUnknown), qb) + q.add ' ' + if op == "==": q.add equals + elif op == "!=": q.add nequals + elif op == "=~": q.add "like" + else: q.add op + q.add ' ' + if env.len == 2: + qb.env = @[env[1]] + let b = cond(n[2], q, params, a, qb) + checkCompatible a, b, n + result = DbType(kind: dbBool) + of "in", "notin": + let a = cond(n[1], q, params, DbType(kind: dbUnknown), qb) + if n[2].kind == nnkInfix and $n[2][0] == "..": + if op == "in": q.add " between " + else: q.add " not between " + let r = n[2] + let b = cond(r[1], q, params, a, qb) + checkCompatible a, b, n + q.add " and " + let c = cond(r[2], q, params, a, qb) + checkCompatible a, c, n + else: + if op == "in": q.add " in " + else: q.add " not in " + let b = cond(n[2], q, params, a, qb) + checkCompatibleSet a, b, n + result = DbType(kind: dbBool) + of "as": + result = cond(n[1], q, params, expected, qb) + q.add " as " + expectKind n[2], nnkIdent + let alias = $n[2] + escIdent(q, alias) + qb.colAliases.add((alias, result)) + if expected.kind != dbUnknown: + checkCompatible result, expected, n + of "&": + let a = cond(n[1], q, params, DbType(kind: dbVarchar), qb) + q.add " || " + let b = cond(n[2], q, params, a, qb) + checkCompatible a, b, n + result = DbType(kind: dbVarchar) + else: + # treat as arithmetic operator: + result = cond(n[1], q, params, DbType(kind: dbUnknown), qb) + q.add ' ' + q.add op + q.add ' ' + let b = cond(n[2], q, params, result, qb) + checkCompatible result, b, n + + of nnkPrefix: + let op = $n[0] + case op + of "?", "%": + q.add placeHolder(qb) + result = expected + if result.kind == dbUnknown: + macros.error "cannot infer the type of the placeholder", n + else: + params.add((ex: n[1], typ: toNimType(result), isJson: op == "%")) + of "not": + result = DbType(kind: dbBool) + q.add "not " + let a = cond(n[1], q, params, result, qb) + checkBool a, n[1] + of "!!": + result = expected + if result.kind == dbUnknown: + macros.error "cannot infer the type of the literal", n + let arg = n[1] + if arg.kind in {nnkStrLit..nnkTripleStrLit}: q.add arg.strVal + else: q.add repr(n[1]) + else: + # treat as arithmetic operator: + q.add ' ' + q.add op + q.add ' ' + result = cond(n[1], q, params, DbType(kind: dbUnknown), qb) + of nnkCall: + let op = nodeName(n[0]) + if isSetOpCall(n): + let subq = renderInlineQuery(n, params, qb) + q.add subq.sql + result = subq.typ + return + if op == "over": + if n.len < 2: + macros.error "over requires at least one expression", n + result = cond(n[1], q, params, expected, qb) + q.add " over (" + for i in 2.. 2: q.add " " + if n[i].kind notin nnkCallKinds: + macros.error "window clauses must be calls like partitionby(...) or orderby(...)", n[i] + renderWindowClause(n[i], q, params, qb) + q.add ")" + return + if op == "exists": + expectLen n, 2 + let subq = renderInlineQuery(n[1], params, qb) + q.add "exists (" + q.add subq.sql + q.add ")" + result = DbType(kind: dbBool) + return + if op == "asc" or op == "desc": + expectLen n, 2 + result = cond(n[1], q, params, DbType(kind: dbUnknown), qb) + q.add ' ' + q.add op + return + for f in functions: + if f.name.toLowerAscii() == op.toLowerAscii(): + if f.arity == n.len-1 or (f.arity == -1 and n.len > 1): + q.add op + q.add "(" + for i in 1.. 1: + q.retType + else: + q.retType[0][1] + body.add newTree(nnkVarSection, newIdentDefs(ident"res", rtyp)) + if k != nnkIteratorDef: + rtyp = nnkBracketExpr.newTree(ident"seq", rtyp) + finalParams.add rtyp + when dbBackend == DbBackend.postgre: + finalParams.add newIdentDefs(ident"db", newTree(nnkDotExpr, ident"ormin_postgre", ident"DbConn")) + elif dbBackend == DbBackend.sqlite: + finalParams.add newIdentDefs(ident"db", newTree(nnkDotExpr, ident"ormin_sqlite", ident"DbConn")) + else: + finalParams.add newIdentDefs(ident"db", ident("DbConn")) + var i = 1 + if q.params.len > 0: + body.add newCall(bindSym"startBindings", prepStmt, newLit(q.params.len)) + for p in q.params: + if p.isJson: + finalParams.add newIdentDefs(p.ex, ident"JsonNode") + body.add buildHookedParamBinding(prepStmt, i, p.ex, p.typ, true) + else: + finalParams.add newIdentDefs(p.ex, p.typ) + body.add buildHookedParamBinding(prepStmt, i, p.ex, p.typ, false) + inc i + body.add newCall(bindSym"startQuery", ident"db", prepStmt) + let yld = newStmtList() + if k != nnkIteratorDef: + if q.retTypeIsJson: + yld.add newVarStmt(ident"res", newCall(bindSym"createJObject")) + let fn = if q.retTypeIsJson: bindSym"bindResultJson" else: bindSym"bindResult" + if q.retType.len > 1: + var i = 0 + for r in q.retType: + template resAt(i) {.dirty.} = res[i] + let resx = if q.retTypeIsJson: ident"res" else: getAst(resAt(newLit(i))) + yld.add newCall(fn, ident"db", prepStmt, newLit(i), + resx, copyNimTree r[1], newLit q.retNames[i]) + inc i + else: + yld.add newCall(fn, ident"db", prepStmt, newLit(0), ident"res", + copyNimTree q.retType[0][1], newLit q.retNames[0]) + if k == nnkIteratorDef: + yld.add newTree(nnkYieldStmt, ident"res") + else: + yld.add newCall("add", ident"result", ident"res") + + let whileStmt = newTree(nnkWhileStmt, + newCall(bindSym"stepQuery", ident"db", prepStmt, newLit true), yld) + body.add whileStmt + body.add newCall(bindSym"stopQuery", ident"db", prepStmt) + + let iter = newTree(k, + name, + newEmptyNode(), + newEmptyNode(), + finalParams, + newEmptyNode(), # pragmas + newEmptyNode(), + body) + result = newStmtList(prepare, iter) + +proc getColumnName(n: NimNode): string = + case n.kind + of nnkPar: + if n.len == 1: result = getColumnName(n[0]) + of nnkInfix: + if $n[0] == "as": result = getColumnName(n[2]) + of nnkIdent, nnkSym: + result = $n + else: + # this is a little hacky, if the column name starts with + # a space, it means "could not extract the column name" + # but we need to emit this macros.error lazily: + result = " " & repr(n) + +proc retName(q: QueryBuilder; i: int; n: NimNode): string = + result = q.retNames[i] + if q.retTypeIsJson and result.startsWith(" "): + macros.error "cannot extract column name of:" & result, n + result = "" + +proc selectAll(q: QueryBuilder; tabIndex: int; arg, lineInfo: NimNode) = + proc fieldImpl(q: QueryBuilder; arg: NimNode; name: string): NimNode = + if q.retTypeIsJson: + result = newTree(nnkBracketExpr, arg, newLit(name)) + else: + result = newTree(nnkDotExpr, arg, ident(name)) + template field(): untyped = fieldImpl(q, arg, a.name) + case q.kind + of qkSelect, qkJoin: + for a in sourceColumns(q, tabIndex): + if q.coln > 0: q.head.add ", " + inc q.coln + q.retType.add nnkIdentDefs.newTree(newIdentNode(a.name), toNimType(a.typ), newEmptyNode()) + q.retNames.add a.name + doAssert q.env.len > 0 + q.head.add q.env[^1][1] + q.head.add '.' + escIdent(q.head, a.name) + of qkInsert, qkInsertReturning, qkReplace: + if isCteEnvIndex(tabIndex): + macros.error "cannot insert into a CTE", lineInfo + for a in attributes: + # we do not set the primary key: + if a.tabIndex == tabIndex and a.key != 1: + if q.coln > 0: q.head.add ", " + escIdent(q.head, a.name) + inc q.coln + q.params.add((ex: field(), typ: toNimType(a.typ), isJson: q.retTypeIsJson)) + if q.values.len > 0: q.values.add ", " + q.values.add placeholder(q) + of qkUpdate: + if isCteEnvIndex(tabIndex): + macros.error "cannot update a CTE", lineInfo + for a in attributes: + if a.tabIndex == tabIndex and a.key != 1: + if q.coln > 0: q.head.add ", " + escIdent(q.head, a.name) + inc q.coln + q.params.add((ex: field(), typ: toNimType(a.typ), isJson: q.retTypeIsJson)) + q.head.add " = " + q.head.add placeholder(q) + else: + macros.error "select '_' not supported for this construct", lineInfo + + +proc tableSel(n: NimNode; q: QueryBuilder) = + if n.kind == nnkCall and q.kind != qkDelete: + let call = n + let tab = $call[0] + let tabIndex = sourceLookup(q, tab) + if tabIndex < 0: + macros.error "unknown table name: " & tab & " from: " & fmtTableList(tableNames), n + return + let alias = sourceAlias(q, tabIndex, tab) + if q.kind == qkSelect: + escIdent(q.fromm, tab) + q.fromm.add " as " & alias + elif q.kind != qkJoin: + escIdent(q.head, tab) + if q.kind == qkUpdate: q.head.add " set " + elif q.kind notin {qkSelect, qkJoin}: q.head.add "(" + + if q.env.len == 0 or q.env[^1] != (tabIndex, alias): + q.env.add((tabindex, alias)) + for i in 1.. 0: q.head.add ", " + escIdent(q.head, colname) + #q.params.add newIdentDefs(col[1], toNimType(coltype)) + inc q.coln + if q.kind == qkUpdate: + q.head.add " = " + discard cond(col[1], q.head, q.params, coltype, q) + else: + if q.values.len > 0: q.values.add ", " + discard cond(col[1], q.values, q.params, coltype, q) + # Track inserted values for potential SQLite RETURNING support + if q.kind in {qkInsert, qkInsertReturning, qkReplace}: + var valNode = col[1] + if valNode.kind == nnkPrefix and (let opv = $valNode[0]; opv == "?" or opv == "%"): + q.insertedValues.add((colname, valNode[1])) + elif valNode.kind in {nnkStrLit, nnkRStrLit, nnkTripleStrLit, nnkIntLit..nnkInt64Lit, nnkFloatLit}: + q.insertedValues.add((colname, valNode)) + + elif col.kind == nnkPrefix and (let op = $col[0]; op == "?" or op == "%"): + let colname = $col[1] + let coltype = lookup("", colname, q) + if coltype.kind == dbUnknown: + macros.error "unkown column name: " & colname, col + else: + if q.coln > 0: q.head.add ", " + escIdent(q.head, colname) + inc q.coln + q.params.add((ex: col[1], typ: toNimType(coltype), isJson: op == "%")) + if q.kind == qkUpdate: + q.head.add " = " + q.head.add placeholder(q) + else: + if q.values.len > 0: q.values.add ", " + q.values.add placeholder(q) + elif col.kind == nnkIdent and $col == "_": + selectAll(q, tabIndex, ident"arg", col) + elif q.kind in {qkSelect, qkJoin}: + if q.coln > 0: q.head.add ", " + inc q.coln + let t = + if col.kind in {nnkIdent, nnkSym}: + let colname = $col + var typ = DbType(kind: dbUnknown) + for srcCol in sourceColumns(q, tabIndex): + if cmpIgnoreCase(srcCol.name, colname) == 0: + typ = srcCol.typ + break + if typ.kind == dbUnknown: + macros.error "unknown column name: " & colname, col + q.head.add q.env[^1][1] + q.head.add '.' + escIdent(q.head, colname) + typ + else: + cond(col, q.head, q.params, DbType(kind: dbUnknown), q) + q.retType.add nnkIdentDefs.newTree(newIdentNode(getColumnName(col)), toNimType(t), newEmptyNode()) + q.retNames.add getColumnName(col) + else: + macros.error "unknown selector: " & repr(n), n + if q.kind notin {qkUpdate, qkSelect, qkJoin}: q.head.add ")" + elif n.kind in {nnkIdent, nnkAccQuoted, nnkSym} and q.kind == qkDelete: + let tab = $n + let tabIndex = sourceLookup(q, tab) + if tabIndex < 0: + macros.error "unknown table name: " & tab & " from: " & fmtTableList(tableNames), n + return + if isCteEnvIndex(tabIndex): + macros.error "cannot delete from a CTE", n + escIdent(q.head, tab) + q.env.add((tabindex, q.getAlias(tabIndex))) + elif n.kind == nnkRStrLit: + q.head.add n.strVal + else: + macros.error "unknown selector: " & repr(n), n + + +proc queryh(n: NimNode; q: QueryBuilder) = + var n = n + if n.kind == nnkCall: + let c = newNimNode(nnkCommand) + for i in 0..= 0: + macros.error "duplicate CTE name: " & cteName, n[1][0] + var subq = newQueryBuilder() + subq.qmark = q.qmark + subq.aliasGen = q.aliasGen + subq.ctes = q.ctes + subq.cteBase = q.ctes.len + applyQueryNode(n[1][1], subq) + if subq.kind notin {qkSelect, qkJoin}: + macros.error "CTEs require a select-style query", n[1][1] + q.qmark = subq.qmark + q.aliasGen = subq.aliasGen + for p in subq.params: + q.params.add p + var cols: seq[SourceColumn] = @[] + for i, name in subq.retNames: + let typNode = + if i < subq.retType.len and subq.retType[i].kind == nnkIdentDefs and subq.retType[i].len > 1: + subq.retType[i][1] + else: + newEmptyNode() + cols.add SourceColumn(name: name, typ: DbType(kind: typeNodeToDbKind(typNode))) + q.ctes.add CteDef(name: cteName, sql: queryAsString(subq, n[1][1]), cols: cols) + of "select": + q.kind = qkSelect + q.head = "select " + expectLen n, 2 + if n[1].kind == nnkCommand and nodeName(n[1][0]) == "distinct": + expectLen n[1], 2 + q.head = "select distinct " + tableSel(n[1][1], q) + else: + tableSel(n[1], q) + of "distinct": + q.kind = qkSelect + q.head = "select distinct " + expectLen n, 2 + tableSel(n[1], q) + of "insert": + q.kind = qkInsert + q.head = "insert into " + expectLen n, 2 + tableSel(n[1], q) + of "update": + q.kind = qkUpdate + q.head = "update " + expectLen n, 2 + tableSel(n[1], q) + of "replace": + q.kind = qkReplace + q.head = "replace into " + expectLen n, 2 + tableSel(n[1], q) + of "delete": + q.kind = qkDelete + q.head = "delete from " + expectLen n, 2 + tableSel(n[1], q) + of "where": + expectLen n, 2 + if q.kind in {qkInsert, qkInsertReturning}: + if not q.onConflictTargetSet or not q.onConflictActionSet or not q.onConflictIsDoUpdate: + macros.error "'where' for insert is only supported after 'onconflict(...)' and 'doupdate(...)'", n + if q.onConflictWhereSet: + macros.error "conflict update 'where' can only be specified once", n + var conflictWhere = "" + # In PostgreSQL upsert WHERE, bare column names are ambiguous between + # target table and EXCLUDED. Resolve bare identifiers against target table. + let oldKind = q.kind + let oldEnv = q.env + if q.env.len > 0: + let source = q.env[^1][0] + q.kind = qkSelect + q.env = @[(source, sourceName(q, source))] + let t = cond(n[1], conflictWhere, q.params, DbType(kind: dbBool), q) + q.kind = oldKind + q.env = oldEnv + checkBool(t, n) + q.onConflictWhere = " where " & conflictWhere + q.onConflictWhereSet = true + else: + let t = cond(n[1], q.where, q.params, DbType(kind: dbBool), q) + checkBool(t, n) + of "join", "innerjoin", "outerjoin", "leftjoin", "leftouterjoin", + "rightjoin", "rightouterjoin", "fulljoin", "fullouterjoin", "crossjoin": + q.join.add "\L" & joinKeyword(kind) + expectLen n, 2 + let joinClause = n[1] + if kind == "crossjoin" and joinClause.kind == nnkCommand and joinClause.len == 2 and + joinClause[1].kind == nnkCommand and joinClause[1].len == 2 and $joinClause[1][0] == "on": + macros.error "crossjoin does not support an on clause", n + if joinClause.kind == nnkCommand and joinClause.len == 2 and + joinClause[1].kind == nnkCommand and joinClause[1].len == 2 and $joinClause[1][0] == "on" and + joinClause[0].kind == nnkCall: + let tab = $joinClause[0][0] + let tabIndex = sourceLookup(q, tab) + if tabIndex < 0: + macros.error "unknown table name: " & tab & " from: " & fmtTableList(tableNames), n + else: + escIdent(q.join, tab) + let alias = sourceAlias(q, tabIndex, tab) + q.join.add " as " & alias + var oldEnv = q.env + q.env = @[(tabIndex, alias)] + q.kind = qkJoin + tableSel(joinClause[0], q) + swap q.env, oldEnv + let onn = joinClause[1][1] + q.join.add " on " + oldEnv = q.env + q.env.add((tabIndex, alias)) + let t = cond(onn, q.join, q.params, DbType(kind: dbBool), q) + swap q.env, oldEnv + checkBool(t, onn) + elif joinClause.kind == nnkCall: + let tab = $joinClause[0] + let tabIndex = sourceLookup(q, tab) + if tabIndex < 0: + macros.error "unknown table name: " & tab & " from: " & fmtTableList(tableNames), n[1][0] + else: + if kind == "crossjoin": + let alias = sourceAlias(q, tabIndex, tab) + escIdent(q.join, tab) + q.join.add " as " & alias + var oldEnv = q.env + q.env = @[(tabIndex, alias)] + q.kind = qkJoin + tableSel(n[1], q) + swap q.env, oldEnv + else: + # auto join: + if isCteEnvIndex(tabIndex) or isCteEnvIndex(q.env[^1][0]): + macros.error "automatic joins are only supported for base tables", n + let alias = q.getAlias(tabIndex) + escIdent(q.join, tab) + q.join.add " as " & alias + if not autoJoin(q.join, q.env[^1], tabIndex, alias): + macros.error "cannot compute auto join from: " & tableNames[q.env[^1][0]] & " to: " & tab, n + var oldEnv = q.env + q.env = @[(tabIndex, alias)] + q.kind = qkJoin + tableSel(n[1], q) + swap q.env, oldEnv + else: + macros.error "unknown query component " & repr(n), n + of "groupby": + for i in 1.. 1: + q.onConflict.add ", " + escIdent(q.onConflict, colname) + q.onConflict.add ")" + q.onConflictTargetSet = true + of "donothing": + if q.kind notin {qkInsert, qkInsertReturning}: + macros.error "'donothing' only possible within 'insert'", n + if not q.onConflictTargetSet: + macros.error "'donothing' requires a preceding 'onconflict' clause", n + if q.onConflictActionSet: + macros.error "conflict action already set; choose only one of 'donothing' or 'doupdate'", n + expectLen n, 1 + q.onConflict.add " do nothing" + q.onConflictActionSet = true + q.onConflictIsDoUpdate = false + of "doupdate": + if q.kind notin {qkInsert, qkInsertReturning}: + macros.error "'doupdate' only possible within 'insert'", n + if not q.onConflictTargetSet: + macros.error "'doupdate' requires a preceding 'onconflict' clause", n + if q.onConflictActionSet: + macros.error "conflict action already set; choose only one of 'donothing' or 'doupdate'", n + if n.len < 2: + macros.error "'doupdate' expects assignments like doupdate(col = value)", n + q.onConflict.add " do update set " + for i in 1.. 1: + q.onConflict.add ", " + escIdent(q.onConflict, colname) + q.onConflict.add " = " + discard cond(assignment[1], q.onConflict, q.params, coltype, q) + q.onConflictActionSet = true + q.onConflictIsDoUpdate = true + of "returning": + if q.kind != qkInsert: + macros.error "'returning' only possible within 'insert'" + q.kind = qkInsertReturning + expectLen n, 2 + when dbBackend == DbBackend.sqlite: + q.returning = ";\nselect last_insert_rowid()" + elif dbBackend == DbBackend.mysql: + q.returning = ";\nselect LAST_INSERT_ID()" + else: + q.returning = " returning " + var colname = "" + let nimType = toNimType lookupColumnInEnv(n[1], colname, q.params, DbType(kind: dbUnknown), q) + q.singleRow = true + when dbBackend != DbBackend.sqlite: + q.retType.add newIdentDefs(ident(colname), nimType) + q.retNames.add colname + else: + discard nimType + + # check if the column is inserted, if so, use the inserted expression to return the value + var found = false + for p in q.insertedValues: + if cmpIgnoreCase(p[0], colname) == 0: + q.retExpr = p[1] + found = true + break + + when dbBackend == DbBackend.postgre: + q.returning.add colname + of "produce": + expectLen n, 2 + if eqIdent(n[1], "json"): + q.retTypeIsJson = true + elif eqIdent(n[1], "nim") or eqIdent(n[1], "tuple"): + q.retTypeIsJson = false + else: + macros.error "produce expects 'json' or 'nim', but got: " & repr(n[1]), n + else: + macros.error "unknown query component " & repr(n), n + +proc queryAsString(q: QueryBuilder, n: NimNode): string = + if q.onConflictTargetSet and not q.onConflictActionSet: + macros.error "'onconflict' requires either 'donothing' or 'doupdate'", n + if q.onConflictWhereSet and not q.onConflictIsDoUpdate: + macros.error "conflict update 'where' requires 'doupdate(...)'", n + if q.cteBase < q.ctes.len: + result.add "with " + for i in q.cteBase.. q.cteBase: + result.add ",\L" + escIdent(result, q.ctes[i].name) + result.add " as (\L" + result.add q.ctes[i].sql + result.add "\L)" + result.add "\L" + result.add q.head + if q.fromm.len > 0: + result.add "\Lfrom " + result.add q.fromm + if q.join.len > 0: + result.add q.join + if q.values.len > 0: + result.add "\Lvalues (" + result.add q.values + result.add ")" + if q.onConflict.len > 0: + result.add q.onConflict + if q.onConflictWhere.len > 0: + result.add q.onConflictWhere + if q.where.len > 0: + if q.kind in {qkSelect, qkJoin, qkUpdate, qkDelete}: + result.add "\Lwhere " + result.add q.where + else: + macros.error "'where' is not supported for this query kind", n + if q.groupby.len > 0: + result.add "\Lgroup by " + result.add q.groupby + if q.having.len > 0: + result.add "\Lhaving " + result.add q.having + if q.orderby.len > 0: + result.add "\Lorder by " + result.add q.orderby + if q.limit.len > 0: + result.add "\Llimit " + result.add q.limit + if q.offset.len > 0: + result.add "\Loffset " + result.add q.offset + when dbBackend != DbBackend.sqlite: + if q.returning.len > 0: + result.add q.returning + when defined(debugOrminSql): + macros.hint("Ormin SQL:\n" & $result, n) + +proc sameReturnShape(a, b: NimNode): bool {.compileTime.} = + if a.len != b.len: + return false + for i in 0.. 1: a[i][1] else: a[i] + let bt = if b[i].kind == nnkIdentDefs and b[i].len > 1: b[i][1] else: b[i] + if repr(at) != repr(bt): + return false + result = true + +proc buildSetOpQueryParts(op: string; branches: openArray[NimNode]; + q: QueryBuilder; lineInfo: NimNode) {.compileTime.} = + if q.kind != qkNone or q.head.len > 0 or q.params.len > 0: + macros.error "set operations must form the whole query", lineInfo + if branches.len < 2: + macros.error "set operations require at least two queries", lineInfo + + q.kind = qkSelect + q.singleRow = false + + for i, branchNode in branches: + var branch = newQueryBuilder() + branch.qmark = q.qmark + branch.aliasGen = q.aliasGen + branch.ctes = q.ctes + branch.cteBase = q.ctes.len + applyQueryNode(branchNode, branch) + if branch.kind notin {qkSelect, qkJoin}: + macros.error "set operations only support select-style queries", branchNode + + if i > 0: + q.head.add "\L" & op & "\L" + if isSetOpCall(branchNode): + q.head.add "(" + q.head.add queryAsString(branch, branchNode) + q.head.add ")" + else: + q.head.add queryAsString(branch, branchNode) + + q.qmark = branch.qmark + q.aliasGen = branch.aliasGen + for p in branch.params: + q.params.add p + + if i == 0: + q.retType = branch.retType + q.retNames = branch.retNames + q.retTypeIsJson = branch.retTypeIsJson + elif branch.retTypeIsJson != q.retTypeIsJson or + not sameReturnShape(branch.retType, q.retType): + macros.error "all set operation branches must return the same types", branchNode + +proc buildSetOpQuery(n: NimNode; q: QueryBuilder) {.compileTime.} = + var branches: seq[NimNode] = @[] + for i in 1.. 0: nnkStmtListExpr else: nnkStmtList, + newLetStmt(prepStmt, newCall(bindSym"prepareStmt", ident"db", newLit sql)) + ) + let rtyp = if q.retType.len > 1 or q.retType.len == 0: + q.retType + else: + q.retType[0][1] + if q.retType.len > 0: + if q.singleRow: + if q.retTypeIsJson: + result.add newVarStmt(res, newCall(bindSym"createJObject")) + else: + result.add newTree(nnkVarSection, newIdentDefs(res, rtyp)) + else: + if q.retTypeIsJson: + result.add newVarStmt(res, newCall(bindSym"createJArray")) + else: + result.add newTree(nnkVarSection, newIdentDefs(res, + newTree(nnkBracketExpr, bindSym"seq", rtyp), + newTree(nnkPrefix, bindSym"@", newTree(nnkBracket)))) + let blk = newStmtList() + var i = 1 + if q.params.len > 0: + blk.add newCall(bindSym"startBindings", prepStmt, newLit(q.params.len)) + for p in q.params: + blk.add buildHookedParamBinding(prepStmt, i, p.ex, p.typ, p.isJson) + inc i + blk.add newCall(bindSym"startQuery", ident"db", prepStmt) + var body = newStmtList() + let it = if q.singleRow: res else: genSym(nskVar) + if not q.singleRow and q.retType.len > 0: + if q.retTypeIsJson: + body.add newVarStmt(it, newCall(bindSym"createJObject")) + else: + body.add newTree(nnkVarSection, newIdentDefs(it, rtyp)) + + let fn = if q.retTypeIsJson: bindSym"bindResultJson" else: bindSym"bindResult" + if q.retType.len > 1: + var i = 0 + for r in q.retType: + template resAt(x, i) {.dirty.} = x[i] + let resx = if q.retTypeIsJson: it else: getAst(resAt(it, newLit(i))) + + body.add newCall(fn, ident"db", prepStmt, newLit(i), + resx, (if r.len > 0: r[1] else: r), newLit retName(q, i, body)) + inc i + elif q.retType.len > 0: + body.add newCall(fn, ident"db", prepStmt, newLit(0), + it, rtyp, newLit retName(q, 0, body)) + else: + body.add newTree(nnkDiscardStmt, newEmptyNode()) + + template ifStmt2(prepStmt, returnsData: bool; action) {.dirty.} = + bind stepQuery + bind stopQuery + bind dbError + if stepQuery(db, prepStmt, returnsData): + action + stopQuery(db, prepStmt) + else: + stopQuery(db, prepStmt) + dbError(db) + + template ifStmt1(prepStmt, returnsData: bool; action) {.dirty.} = + bind stepQuery + bind stopQuery + if stepQuery(db, prepStmt, returnsData): + action + stopQuery(db, prepStmt) + + template whileStmt(prepStmt, res, it, action) {.dirty.} = + bind stepQuery + bind stopQuery + while stepQuery(db, prepStmt, true): + action + add res, it + stopQuery(db, prepStmt) + + template insertQueryReturningId(prepStmt) {.dirty.} = + bind stepQuery + bind stopQuery + bind getLastId + bind dbError + var insertedId = -1 + if stepQuery(db, prepStmt, false): + insertedId = getLastId(db, prepStmt) + stopQuery(db, prepStmt) + else: + stopQuery(db, prepStmt) + dbError(db) + insertedId + + let returnsData = q.kind in {qkSelect, qkJoin, qkInsertReturning} + if not q.singleRow and q.retType.len > 0: + blk.add getAst(whileStmt(prepStmt, res, it, body)) + elif dbBackend == DbBackend.sqlite and q.kind == qkInsertReturning and q.retExpr.kind != nnkEmpty: + # For SQLite, emulate RETURNING by returning the inserted expression value. + # Execute the insert as a non-row statement, then yield the expression. + if attempt: + blk.add getAst(ifStmt1(prepStmt, false, newStmtList())) + else: + blk.add getAst(ifStmt2(prepStmt, false, newStmtList())) + blk.add q.retExpr + elif q.returning.len > 0 and dbBackend == DbBackend.sqlite: + blk.add getAst(insertQueryReturningId(prepStmt)) + # fix #14 delete not return value + elif (q.kind == qkInsert or q.kind == qkUpdate or q.kind == qkDelete) and dbBackend == DbBackend.postgre: + blk.add getAst(ifStmt1(prepStmt, returnsData, body)) + else: + if attempt: + blk.add getAst(ifStmt1(prepStmt, returnsData, body)) + else: + blk.add getAst(ifStmt2(prepStmt, returnsData, body)) + + result.add newTree(nnkBlockStmt, newEmptyNode(), blk) + if q.retType.len > 0: + result.add res + +proc queryHookImpl(q: QueryBuilder; body: NimNode; attempt: bool; retType: NimNode): NimNode = + expectKind body, nnkStmtList + expectMinLen body, 1 + + q.retTypeIsJson = false + applyQueryNode(body, q) + if q.kind notin {qkSelect, qkJoin}: + macros.error "query(T) currently supports select/join queries only", body + if q.retType.len == 0: + macros.error "query(T) requires a query that returns data", body + if q.retTypeIsJson: + macros.error "query(T) does not support 'produce json'", body + + let sql = queryAsString(q, body) + let prepStmt = genSym(nskLet) + let res = genSym(nskVar) + result = newTree( + nnkStmtListExpr, + newLetStmt(prepStmt, newCall(bindSym"prepareStmt", ident"db", newLit sql)) + ) + if q.singleRow: + result.add newTree(nnkVarSection, newIdentDefs(res, copyNimTree(retType), newEmptyNode())) + else: + result.add newTree(nnkVarSection, newIdentDefs(res, + newTree(nnkBracketExpr, bindSym"seq", copyNimTree(retType)), + newTree(nnkPrefix, bindSym"@", newTree(nnkBracket)))) + + let blk = newStmtList() + var i = 1 + if q.params.len > 0: + blk.add newCall(bindSym"startBindings", prepStmt, newLit(q.params.len)) + for p in q.params: + blk.add buildHookedParamBinding(prepStmt, i, p.ex, p.typ, p.isJson) + inc i + blk.add newCall(bindSym"startQuery", ident"db", prepStmt) + + let action = buildQueryHookAction(q, prepStmt, res, retType, q.singleRow) + + if q.singleRow: + if attempt: + blk.add newTree(nnkIfStmt, + newTree(nnkElifBranch, + newCall(bindSym"stepQuery", ident"db", prepStmt, newLit true), + action + ) + ) + blk.add newCall(bindSym"stopQuery", ident"db", prepStmt) + else: + blk.add newTree(nnkIfStmt, + newTree(nnkElifBranch, + newCall(bindSym"stepQuery", ident"db", prepStmt, newLit true), + newStmtList(action, newCall(bindSym"stopQuery", ident"db", prepStmt)) + ), + newTree(nnkElse, + newStmtList( + newCall(bindSym"stopQuery", ident"db", prepStmt), + newCall(bindSym"dbError", ident"db") + ) + ) + ) + else: + blk.add newTree(nnkWhileStmt, + newCall(bindSym"stepQuery", ident"db", prepStmt, newLit true), + action + ) + blk.add newCall(bindSym"stopQuery", ident"db", prepStmt) + + result.add newTree(nnkBlockStmt, newEmptyNode(), blk) + result.add res + +macro query*(args: varargs[untyped]): untyped = + if args.len == 1: + let body = args[0] + var q = newQueryBuilder() + result = queryImpl(q, body, false, false) + when defined(debugOrminDsl): + macros.hint("Ormin Query: " & repr(result), body) + return + + if args.len != 2: + macros.error("query expects either `query: ...` or `query(T): ...`", args) + + let retType = args[0] + let body = args[1] + var q = newQueryBuilder() + result = queryHookImpl(q, body, false, retType) + when defined(debugOrminDsl): + macros.hint("Ormin Query(T): " & repr(result), body) + +macro tryQuery*(args: varargs[untyped]): untyped = + if args.len == 1: + let body = args[0] + var q = newQueryBuilder() + result = queryImpl(q, body, true, false) + when defined(debugOrminDsl): + macros.hint("Ormin TryQuery: " & repr(result), body) + return + + if args.len != 2: + macros.error("tryQuery expects either `tryQuery: ...` or `tryQuery(T): ...`", args) + + let retType = args[0] + let body = args[1] + var q = newQueryBuilder() + result = queryHookImpl(q, body, true, retType) + when defined(debugOrminDsl): + macros.hint("Ormin TryQuery(T): " & repr(result), body) + +# ------------------------- +# Transactions DSL +# ------------------------- + +# Transaction state for nested transactions +var txDepth {.threadvar.}: int + +proc getTxDepth*(): int = + result = txDepth + +proc isTopTx*(): bool = + result = txDepth == 1 + +proc incTxDepth*() = + inc txDepth + +proc decTxDepth*() = + dec txDepth + +template txBegin*(sp: untyped) = + if isTopTx(): + execNoRowsLoose("begin transaction") + else: + execNoRowsLoose("savepoint " & sp) + +template txCommit*(sp: untyped) = + if isTopTx(): + execNoRowsLoose("commit") + else: + execNoRowsLoose("release savepoint " & sp) + +template txRollback*(sp: untyped) = + if isTopTx(): + execNoRowsLoose("rollback") + else: + execNoRowsLoose("rollback to savepoint " & sp) + +template transaction*(body: untyped) = + ## Runs the body inside a database transaction. Commits on success, + ## rolls back on any exception and rethrows. Supports nesting via savepoints. + block: + incTxDepth() + let sp = "ormin_tx_" & $txDepth + + try: + txBegin(sp) + `body` + txCommit(sp) + except DbError: + txRollback(sp) + raise + except CatchableError, Defect: + txRollback(sp) + raise + finally: + decTxDepth() + +macro getBlock(blk: untyped): untyped = + result = blk[0] + +template transaction*(body, other: untyped) = + ## Runs the body inside a database transaction. Commits on success, + ## rolls back on any exception and rethrows. Supports nesting via savepoints. + block: + incTxDepth() + let sp = "ormin_tx_" & $txDepth + + try: + txBegin(sp) + `body` + txCommit(sp) + except DbError: + txRollback(sp) + getBlock(`other`) + except CatchableError, Defect: + txRollback(sp) + raise + finally: + decTxDepth() + +proc createRoutine(name, query: NimNode; k: NimNodeKind): NimNode = + expectKind query, nnkStmtList + expectMinLen query, 1 + + var q = newQueryBuilder() + applyQueryNode(query, q) + if q.kind notin {qkSelect, qkJoin}: + macros.error "query must be a 'select' or 'join'", query + let sql = queryAsString(q, query) + result = generateRoutine(name, q, sql, k) + when defined(debugOrminDsl): + macros.hint("Ormin Query: " & repr(result), query) + +macro createIter*(name, query: untyped): untyped = + ## Creates an iterator of the given 'name' that iterates + ## over the result set as described in 'query'. + result = createRoutine(name, query, nnkIteratorDef) + +macro createProc*(name, query: untyped): untyped = + ## Creates an iterator of the given 'name' that iterates + ## over the result set as described in 'query'. + result = createRoutine(name, query, nnkProcDef) + +type + ProtoBuilder = ref object + msgId: int + dispClient, types, server, procs, retType: NimNode + retNames: seq[string] + foundObj, singleRow: bool + sectionName: string + +proc getTypename(n: NimNode): NimNode = + result = n[0][0] + if result.kind == nnkPostfix: + result = result[1] + +proc addFields(n: NimNode; b: ProtoBuilder): NimNode = + if n.kind == nnkObjectTy and not b.foundObj: + b.foundObj = true + expectLen n, 3 + var x = n[2] + if x.kind == nnkEmpty: + x = newTree(nnkRecList) + n[2] = x + expectKind x, nnkRecList + doAssert b.retType.len == b.retNames.len, "ormin: types and column names do not match" + for i in 0 ..< b.retType.len: + x.add newTree(nnkIdentDefs, newTree(nnkPostfix, ident"*", ident(b.retNames[i])), + b.retType[i][1], newEmptyNode()) + return n + result = copyNimNode(n) + for i in 0 ..< n.len: + result.add addFields(n[i], b) + +proc transformClient(n: NimNode; b: ProtoBuilder): NimNode = + template sendReqImpl(x, msgkind): untyped {.dirty.} = + let req = newJObject() + req["cmd"] = %msgkind + req["arg"] = cast[JsonNode](x) + send(req) + template sendReqImplNoArg(msgkind): untyped {.dirty.} = + let req = newJObject() + req["cmd"] = %msgkind + req["arg"] = newJNull() + send(req) + if n.kind in nnkCallKinds and n[0].kind == nnkIdent and $n[0] == "recv": + var castDest: NimNode + if n.len == 2: + castDest = n[1] + else: + expectLen n, 1 + # this can happen for the new 'returning' support: + let retType = if b.retType.len != 0 or b.retType.kind != nnkTupleTy: b.retType else: ident"int" + castDest = makeSeq(retType, b.singleRow) + return newTree(nnkCast, castDest, ident"data") + elif n.kind == nnkTypeSection: + b.foundObj = false + let t = addFields(n, b) + b.retType = getTypename(n) + b.types.add t + return newTree(nnkNone) + elif n.kind == nnkProcDef: + let p = n.params + if p.len == 1: + n.body = getAst(sendReqImplNoArg(b.msgId)) + else: + expectLen p, 2 + expectKind p[1], nnkIdentDefs + if p[1].len != 3: + macros.error "proc must have one or zero parameters", p + n.body = getAst(sendReqImpl(p[1][0], b.msgId)) + b.procs.add n + return newTree(nnkNone) + elif n.kind in {nnkLetSection, nnkVarSection}: + b.procs.add n + return newTree(nnkNone) + result = copyNimNode(n) + for i in 0 ..< n.len: + let x = transformClient(n[i], b) + if x.kind != nnkNone: result.add x + +proc transformServer(n: NimNode; b: ProtoBuilder): NimNode = + template sendImpl(x, msgkind): untyped {.dirty.} = + result = newJObject() + result["cmd"] = %msgkind + result["data"] = x + + template broadCastImpl(x, msgkind): untyped {.dirty.} = + receivers = Receivers.all + result = newJObject() + result["cmd"] = %msgkind + result["data"] = x + + if n.kind in nnkCallKinds and n[0].kind == nnkIdent: + case $n[0] + of "send": + expectLen n, 2 + return getAst(sendImpl(n[1], b.msgId+1)) + of "broadcast": + expectLen n, 2 + return getAst(broadCastImpl(n[1], b.msgId+1)) + of "query": + expectLen n, 2 + var qb = newQueryBuilder() + let q = queryImpl(qb, n[1], false, true) + b.retType = qb.retType + b.singleRow = qb.singleRow + b.retNames = qb.retNames + return q + of "tryQuery": + expectLen n, 2 + var qb = newQueryBuilder() + let q = queryImpl(qb, n[1], true, true) + b.retType = qb.retType + b.singleRow = qb.singleRow + b.retNames = qb.retNames + return q + + result = copyNimNode(n) + for i in 0 ..< n.len: + result.add transformServer(n[i], b) + +proc protoImpl(n: NimNode; b: ProtoBuilder): NimNode = + case n.kind + of nnkCallKinds: + if n[0].kind == nnkIdent: + let op = $n[0] + case op + of "server": + expectLen n, 2, 3 + if n.len == 3: + expectKind(n[1], nnkStrLit) + b.sectionName = n[1].strVal + return newTree(nnkOfBranch, newLit(b.msgId), transformServer(n[^1], b)) + of "client": + expectLen n, 2, 3 + if n.len == 3: + expectKind(n[1], nnkStrLit) + if b.sectionName != n[1].strVal: + macros.error "section names of client/server pair do not match", n[1] + var clientPart = transformClient(n[^1], b) + if clientPart.kind == nnkNone or (clientPart.kind == nnkStmtList and clientPart.len == 0): + clientPart = newStmtList(newTree(nnkDiscardStmt, newEmptyNode())) + b.dispClient.add newTree(nnkOfBranch, newLit(b.msgId+1), clientPart) + inc b.msgId, 2 + return newTree(nnkNone) + of "common": + expectLen n, 2 + expectKind n[1], nnkStmtList + for s in n[1]: + b.types.add copyNimTree(s) + b.server.add s + return newTree(nnkNone) + else: discard + result = copyNimNode(n) + for i in 0 ..< n.len: + let x = protoImpl(n[i], b) + if x.kind != nnkNone: result.add x + +macro protocol*(name: static[string]; body: untyped): untyped = + template serverProc(body) {.dirty.} = + proc dispatch(inp: JsonNode; receivers: var Receivers): JsonNode = + let arg = inp["arg"] + let cmd = inp["cmd"].getInt() + body + + template clientProc(body) {.dirty.} = + proc recvMsg*(inp: JsonNode) = + let data = inp["data"] + let cmd = inp["cmd"].getInt() + body + + var b = ProtoBuilder(msgId: 0, dispClient: newTree(nnkCaseStmt, ident"cmd"), + types: newStmtList(), procs: newStmtList(), server: newStmtList()) + let branches = protoImpl(body, b) + b.dispClient.add newTree(nnkElse, newStmtList(newTree(nnkDiscardStmt, newEmptyNode()))) + let disp = newTree(nnkCaseStmt, ident"cmd") + for branch in branches: disp.add branch + disp.add newTree(nnkElse, newStmtList(newTree(nnkDiscardStmt, newEmptyNode()))) + + let client = getAst(clientProc(b.dispClient)) + var clientBody = newStmtList(newCommentStmtNode"Generated by Ormin. DO NOT EDIT!") + for typ in b.types: clientBody.add typ + for prc in b.procs: clientBody.add prc + clientBody.add client + for xx in clientBody: + assert xx.kind != nnkStmtList + writeFile(parentDir(instantiationInfo(-1, true)[0]) / name, repr clientBody) + + b.server.add getAst(serverProc(disp)) + result = b.server + when defined(debugOrminDsl): + macros.hint("Ormin Query: " & repr(result), body) diff --git a/clients/nim/ormin/ormin/query_hooks.nim b/clients/nim/ormin/ormin/query_hooks.nim new file mode 100644 index 0000000..6933b26 --- /dev/null +++ b/clients/nim/ormin/ormin/query_hooks.nim @@ -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) diff --git a/clients/nim/ormin/ormin/serverws.nim b/clients/nim/ormin/ormin/serverws.nim new file mode 100644 index 0000000..df7c2c3 --- /dev/null +++ b/clients/nim/ormin/ormin/serverws.nim @@ -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) diff --git a/clients/nim/ormin/tests/compat.nim b/clients/nim/ormin/tests/compat.nim new file mode 100644 index 0000000..ca66b44 --- /dev/null +++ b/clients/nim/ormin/tests/compat.nim @@ -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)..= 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) \ No newline at end of file diff --git a/clients/nim/ormin/tests/config.nims b/clients/nim/ormin/tests/config.nims new file mode 100644 index 0000000..1bc058e --- /dev/null +++ b/clients/nim/ormin/tests/config.nims @@ -0,0 +1 @@ +--path:"$projectDir/../../ormin" \ No newline at end of file diff --git a/clients/nim/ormin/tests/db_utils_case_quoted.sql b/clients/nim/ormin/tests/db_utils_case_quoted.sql new file mode 100644 index 0000000..561288f --- /dev/null +++ b/clients/nim/ormin/tests/db_utils_case_quoted.sql @@ -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 + ); diff --git a/clients/nim/ormin/tests/forum_model_postgres.sql b/clients/nim/ormin/tests/forum_model_postgres.sql new file mode 100644 index 0000000..8ce68b0 --- /dev/null +++ b/clients/nim/ormin/tests/forum_model_postgres.sql @@ -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); diff --git a/clients/nim/ormin/tests/forum_model_sqlite.sql b/clients/nim/ormin/tests/forum_model_sqlite.sql new file mode 100644 index 0000000..5ba2179 --- /dev/null +++ b/clients/nim/ormin/tests/forum_model_sqlite.sql @@ -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); \ No newline at end of file diff --git a/clients/nim/ormin/tests/model_postgre.sql b/clients/nim/ormin/tests/model_postgre.sql new file mode 100644 index 0000000..bef59ba --- /dev/null +++ b/clients/nim/ormin/tests/model_postgre.sql @@ -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 +); diff --git a/clients/nim/ormin/tests/model_sqlite.sql b/clients/nim/ormin/tests/model_sqlite.sql new file mode 100644 index 0000000..b6f4c0f --- /dev/null +++ b/clients/nim/ormin/tests/model_sqlite.sql @@ -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 +); diff --git a/clients/nim/ormin/tests/static_only_model.sql b/clients/nim/ormin/tests/static_only_model.sql new file mode 100644 index 0000000..a6678d3 --- /dev/null +++ b/clients/nim/ormin/tests/static_only_model.sql @@ -0,0 +1,4 @@ +create table user_static ( + id integer primary key, + name varchar(255) not null +); diff --git a/clients/nim/ormin/tests/tcommon.nim b/clients/nim/ormin/tests/tcommon.nim new file mode 100644 index 0000000..ac72aaf --- /dev/null +++ b/clients/nim/ormin/tests/tcommon.nim @@ -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 + ) + ) diff --git a/clients/nim/ormin/tests/tdb_utils.nim b/clients/nim/ormin/tests/tdb_utils.nim new file mode 100644 index 0000000..525c1ef --- /dev/null +++ b/clients/nim/ormin/tests/tdb_utils.nim @@ -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" diff --git a/clients/nim/ormin/tests/tfeature.nim b/clients/nim/ormin/tests/tfeature.nim new file mode 100644 index 0000000..b8f7428 --- /dev/null +++ b/clients/nim/ormin/tests/tfeature.nim @@ -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 + 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 == "" diff --git a/clients/nim/ormin/tests/timportstatic.nim b/clients/nim/ormin/tests/timportstatic.nim new file mode 100644 index 0000000..6e16caf --- /dev/null +++ b/clients/nim/ormin/tests/timportstatic.nim @@ -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" diff --git a/clients/nim/ormin/tests/tpostgre.nim b/clients/nim/ormin/tests/tpostgre.nim new file mode 100644 index 0000000..1caee77 --- /dev/null +++ b/clients/nim/ormin/tests/tpostgre.nim @@ -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 diff --git a/clients/nim/ormin/tests/tquery_types.nim b/clients/nim/ormin/tests/tquery_types.nim new file mode 100644 index 0000000..e782c39 --- /dev/null +++ b/clients/nim/ormin/tests/tquery_types.nim @@ -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("") + 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) == "" + 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 diff --git a/clients/nim/ormin/tests/tsqlite.nim b/clients/nim/ormin/tests/tsqlite.nim new file mode 100644 index 0000000..dc18454 --- /dev/null +++ b/clients/nim/ormin/tests/tsqlite.nim @@ -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(@[]) diff --git a/clients/nim/ormin/tests/ttransactions.nim b/clients/nim/ormin/tests/ttransactions.nim new file mode 100644 index 0000000..35d0834 --- /dev/null +++ b/clients/nim/ormin/tests/ttransactions.nim @@ -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" diff --git a/clients/nim/ormin/tools/ormin_importer.nim b/clients/nim/ormin/tools/ormin_importer.nim new file mode 100644 index 0000000..0b804ce --- /dev/null +++ b/clients/nim/ormin/tools/ormin_importer.nim @@ -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 --out: --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)) diff --git a/clients/nim/ormin/tools/setup_postgres.sh b/clients/nim/ormin/tools/setup_postgres.sh new file mode 100644 index 0000000..ff7fff3 --- /dev/null +++ b/clients/nim/ormin/tools/setup_postgres.sh @@ -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')." + diff --git a/clients/nim/ormin/tools/setup_postgres.sql b/clients/nim/ormin/tools/setup_postgres.sql new file mode 100644 index 0000000..b0dc3c8 --- /dev/null +++ b/clients/nim/ormin/tools/setup_postgres.sql @@ -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; + diff --git a/clients/nim/ormin/tools/setup_postgres_role.sql b/clients/nim/ormin/tools/setup_postgres_role.sql new file mode 100644 index 0000000..6a19451 --- /dev/null +++ b/clients/nim/ormin/tools/setup_postgres_role.sql @@ -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 +$$; +