Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fc6adbe47 | |||
| 072e7a5d13 | |||
| d4dc433e48 | |||
| 1ff6fabaff | |||
| 4a83eb6783 | |||
| a526a3aef9 | |||
| 372e5cf627 | |||
| 57d2908066 | |||
| edd7e728f9 | |||
| 14596c9584 | |||
| d3b77174da | |||
| cd46edcb67 | |||
| 343f479127 | |||
| 8afa998516 | |||
| 7e6a45e6b7 | |||
| cf2aba104f | |||
| 132e8c7a31 | |||
| 3f29c519d4 | |||
| 8596cea548 | |||
| 47c4393a7e | |||
| 967c0855a5 | |||
| a28c845476 | |||
| 3ac53ecda2 | |||
| 1e38e29f25 | |||
| c95bc4cd44 | |||
| a5d34c001a | |||
| e783215276 | |||
| a0c5ce8598 | |||
| 898f108963 | |||
| e23b1d61d2 | |||
| 80c3fee9de | |||
| 13bc17cfa8 | |||
| 8a395225c0 | |||
| 55bc3e862a | |||
| 67965ffa8b | |||
| 836d30d84a | |||
| f622c8f82c | |||
| d2ac485b2e | |||
| 2e0969245c | |||
| dc4ad86ee1 | |||
| 19fa760604 | |||
| 6021bfcb10 | |||
| 9d71edafd4 | |||
| 4c6c72dc5b | |||
| 2e1d982b9f | |||
| cd62f98641 | |||
| 07a22d305d | |||
| 5231724e77 | |||
| 073ec41652 | |||
| 58b7598155 | |||
| 3fce374aba | |||
| 9756d93cb8 | |||
| 652ed1b477 | |||
| 406dcf4f4e | |||
| 4ac147dc6c | |||
| 4e54075078 | |||
| 2e945c1dcb | |||
| 2fbafb11bd | |||
| 3453d3cae7 | |||
| 00e9ea7484 | |||
| c5751b4a0c | |||
| bf374b28f7 | |||
| 92e6b99df2 | |||
| f8d466d8f3 | |||
| 40616ad3c0 | |||
| 8c29e700c7 | |||
| 956066631e | |||
| d11e99ab5a | |||
| 359f945170 | |||
| c55d3080cf | |||
| f7d4961125 | |||
| b0978812cb | |||
| d076cfde3b | |||
| 96dfaaecb1 | |||
| e2a526df6f | |||
| 18f3c16b2a | |||
| 71dcffecce | |||
| 398769ff97 | |||
| 8fb5dde858 | |||
| a0d2ca7776 |
@@ -80,8 +80,7 @@ jobs:
|
||||
working-directory: clients/python
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest
|
||||
pip install -e .
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Run Python unit tests
|
||||
working-directory: clients/python
|
||||
|
||||
+10
-1
@@ -7,10 +7,16 @@ nimblecache/
|
||||
# Binaries (compiled test executables)
|
||||
tests/test_all
|
||||
tests/stress_test
|
||||
tests/test_lock
|
||||
tests/test_minimal
|
||||
tests/tla_faithfulness
|
||||
tests/fuzz_test
|
||||
tests/prop_test
|
||||
benchmarks/bench_all
|
||||
benchmarks/compare
|
||||
clients/nim/tests/test_client
|
||||
src/baradadb
|
||||
src/barabadb/client/client
|
||||
src/barabadb/core/raft
|
||||
|
||||
# Temp
|
||||
@@ -37,4 +43,7 @@ clients/rust/target/
|
||||
# Nim compiled binaries
|
||||
clients/nim/src/baradb/client
|
||||
src/barabadb/storage/compaction
|
||||
docker-compose.test.yml
|
||||
src/barabadb/query/executor
|
||||
tests/join_tests
|
||||
*.tar.gz
|
||||
tests/nimforum_smoke_test
|
||||
|
||||
+5
-2
@@ -10,13 +10,16 @@
|
||||
# docker build -t baradb:latest .
|
||||
#
|
||||
# Run:
|
||||
# docker run -d -p 9472:9472 -p 9470:9470 -p 9471:9471 -v baradb_data:/data baradb:latest
|
||||
# docker run -d -p 9472:9472 -p 9912:9912 -p 9913:9913 -v baradb_data:/data baradb:latest
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
LABEL maintainer="BaraDB Team"
|
||||
LABEL description="BaraDB — Multimodal Database Engine"
|
||||
LABEL version="1.1.0"
|
||||
LABEL version="1.1.6"
|
||||
|
||||
# Инсталираме runtime зависимости
|
||||
# libpcre3 — нужна за Nim regex (зарежда се динамично)
|
||||
|
||||
+20
-17
@@ -4,22 +4,19 @@
|
||||
# └─────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# Този Dockerfile build-ва BaraDB от source код.
|
||||
# ВНИМАНИЕ: Изисква локални nimble пакети (hunos, nimmax), които
|
||||
# не са в публичния nimble repository. Преди build трябва да:
|
||||
# 1. symlink-нете или копирате hunos/ и nimmax/ в проекта, или
|
||||
# ВНИМАНИЕ: Изисква локален nimble пакет (hunos), който
|
||||
# не е в публичния nimble repository. Преди build трябва да:
|
||||
# 1. symlink-нете или копирате hunos/ в проекта, или
|
||||
# 2. копирате ~/.nimble/pkgs2 в builder стейджа.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f Dockerfile.source -t baradb:latest .
|
||||
|
||||
# ─── Stage 1: Builder ─────────────────────────────────────
|
||||
FROM nimlang/nim:2.2.10-alpine AS builder
|
||||
FROM nimlang/nim:2.2.10 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Инсталираме build зависимости
|
||||
RUN apk add --no-cache git gcc musl-dev openssl-dev pcre-dev
|
||||
|
||||
# Копираме nimble файл и инсталираме публични зависимости
|
||||
COPY baradadb.nimble .
|
||||
RUN nimble install -d -y || true
|
||||
@@ -33,21 +30,27 @@ COPY *.nim .
|
||||
RUN nim c -d:release --opt:speed -d:ssl -o:baradadb src/baradadb.nim
|
||||
|
||||
# Компилираме backup tool
|
||||
RUN nim c -d:release -o:backup src/barabadb/core/backup.nim
|
||||
RUN nim c -d:release --path:src -o:backup src/barabadb/core/backup.nim
|
||||
|
||||
# ─── Stage 2: Production Runtime ──────────────────────────
|
||||
FROM alpine:3.19
|
||||
FROM ubuntu:24.04
|
||||
|
||||
LABEL maintainer="BaraDB Team"
|
||||
LABEL description="BaraDB — Multimodal Database Engine (source build)"
|
||||
LABEL version="1.1.0"
|
||||
LABEL version="1.1.6"
|
||||
|
||||
# Инсталираме runtime зависимости
|
||||
RUN apk add --no-cache ca-certificates su-exec wget pcre
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libpcre3 \
|
||||
ca-certificates \
|
||||
wget \
|
||||
gosu && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Създаваме dedicated потребител за сигурност
|
||||
RUN addgroup -g 1000 -S baradb && \
|
||||
adduser -u 1000 -S baradb -G baradb
|
||||
RUN groupadd -r baradb && \
|
||||
useradd -r -g baradb baradb
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -66,20 +69,20 @@ RUN mkdir -p /data && chown baradb:baradb /data && \
|
||||
# Environment variables (defaults)
|
||||
ENV BARADB_ADDRESS=0.0.0.0
|
||||
ENV BARADB_PORT=9472
|
||||
ENV BARADB_HTTP_PORT=9470
|
||||
ENV BARADB_WS_PORT=9471
|
||||
# Note: HTTP port = TCP port + 440 (9912 when TCP=9472)
|
||||
# Note: WS port = TCP port + 441 (9913 when TCP=9472)
|
||||
ENV BARADB_DATA_DIR=/data
|
||||
ENV BARADB_LOG_LEVEL=info
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 9472 9470 9471
|
||||
EXPOSE 9472 9912 9913
|
||||
|
||||
# Volume за persistent data
|
||||
VOLUME ["/data"]
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:${BARADB_HTTP_PORT}/health || exit 1
|
||||
CMD wget -qO- http://localhost:9912/health >/dev/null 2>&1 || exit 1
|
||||
|
||||
# Стартираме чрез entrypoint
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
@@ -1,125 +1,171 @@
|
||||
# BaraDB — PLAN
|
||||
# BaraDB — AI-Native Data Platform Roadmap
|
||||
|
||||
> **v1.0.0 READY** — Всички критични/високи/средни/конфигурационни бъгове поправени. Всички 10 TLA+ спецификации са завършени. Build е чист (0 warnings).
|
||||
> **Визия**: BaraDB не е "релационна база + векторна добавка", а единна AI-native база данни, където релационни, векторни, граф и текстови данни живеят в един engine. Както MariaDB интегрира vectors в ядрото, така и BaraDB прави vector/graph/fts първокласни граждани в SQL execution layer-а.
|
||||
>
|
||||
> **Принцип**: Универсалност + Multi-Tenancy. Всяка AI функция работи с Row-Level Security (RLS) и session variables (`app.tenant_id`). Няма отделни "AI таблици" — всичко е SQL.
|
||||
|
||||
---
|
||||
|
||||
## Разпределени модули — финален status (след сесия 8)
|
||||
## Текущо състояние (май 2026)
|
||||
|
||||
### ✅ Поправено
|
||||
|
||||
| Модул | Промяна |
|
||||
|--------|---------|
|
||||
| `disttxn` | 2PC atomicity: prepare failure → rollback готови; commit failure → rollback |
|
||||
| `disttxn` | DISTTXN handler ползва реален `DistTxnManager` |
|
||||
| `disttxn` | `DistTxnManager` инициализиран в `newServer()` |
|
||||
| `sharding` | `getShardRange` връща `-1` за out-of-range keys |
|
||||
| `sharding` | Binary search в consistent hashing ring |
|
||||
| `gossip` | `startHealthCheck()` + `startGossipRound()` async loops |
|
||||
| `raft` | `applyCommand` callback — state machine прилага committed entries |
|
||||
| `raft` | `RaftNetwork.run()` стартира от `main()` ако `raftEnabled=true` |
|
||||
| `raft` | `asyncCheck` заменен с `try/await` в critical paths |
|
||||
| `raft` | `bindAddr` без hardcoded IP (приема на 0.0.0.0) |
|
||||
| `raft` | Disk persistence: `saveState()`/`loadState()` за term/votedFor/log |
|
||||
| `config` | Raft config: `raftEnabled`, `raftPort`, `raftPeers`, `raftNodeId` + env vars |
|
||||
| `auth` | JWT `exp`/`nbf`/`iat` validation + constant-time signature comparison |
|
||||
| `auth` | **SCRAM-SHA-256**: истински challenge-response със salt + iteration count |
|
||||
| `backup` | TLA+ спек: `BackupSnapshotsValid`, `RestoreIntegrity`, `RetentionInvariant` |
|
||||
| `recovery` | TLA+ спек: `RedoCommitted`, `RecoveryCompleteness`, `WalIntegrity` |
|
||||
| `crossmodal` | TLA+ спек: `MetadataVectorConsistency`, `HybridResultValid`, `TxnAtomicity` |
|
||||
|
||||
### ⚠️ Оставащи distributed gaps (non-critical за single-node)
|
||||
|
||||
| Модул | Gap | Статус |
|
||||
|--------|-----|--------|
|
||||
| `replication` | `writeLsn` не изпраща данни към replicas | ✅ Добавен UDP transport + binary serialization |
|
||||
| `gossip` | Няма UDP/TCP transport — in-memory само | ✅ Добавен UDP listener + broadcast + binary serialization |
|
||||
| `sharding` | `rebalance` не мигрира данни | ✅ Добавен `migrateData` протокол + `scanAll` на LSM |
|
||||
| `inter-module` | Няма raft→disttxn, gossip→sharding, replication→disttxn връзки | ✅ Всички връзки реализирани |
|
||||
| `server` | Няма shard-aware routing | ✅ ClusterMembership + ShardRouter в Server |
|
||||
| Компонент | Статус |
|
||||
|-----------|--------|
|
||||
| SQL:2023 Engine | ✅ Window, MERGE, LATERAL, GROUPING SETS, PIVOT, SQL/PGQ |
|
||||
| Vector Engine | ✅ HNSW + IVF-PQ + SIMD (ядро) |
|
||||
| Vector SQL | ✅ `VECTOR(n)` тип, `CREATE VECTOR INDEX`, distance функции, `<->` оператор |
|
||||
| Graph Engine | ✅ BFS/DFS/PageRank/Dijkstra + SQL/PGQ `GRAPH_TABLE` |
|
||||
| Full-Text Search | ✅ Inverted Index + BM25 + Hybrid Search |
|
||||
| JSON/JSONB | ✅ Колони, оператори, функции |
|
||||
| Multi-Tenant | ✅ Session vars, `current_setting()`, `current_user`, RLS Policies |
|
||||
| Foreign Keys | ✅ CASCADE/SET NULL/RESTRICT за ON DELETE и ON UPDATE |
|
||||
| Formal Verification | ✅ 10 TLA+ спецификации |
|
||||
| MCP Server | ✅ STDIO JSON-RPC, 3 tools (query, vector_search, schema_inspect), multi-tenant |
|
||||
| AI Pipeline | ✅ chunk(), embed_text(), auto-embed on INSERT, configurable embedder |
|
||||
| RAG Pipeline | ✅ ChatMessageHistory, end-to-end Python RAG example |
|
||||
| AI Agents & NL→SQL | ✅ nl_to_sql(), schema_prompt(), query validation, self-correction loop, multi-tenant |
|
||||
| Graph Similarity & Embeddings | ✅ similarity_nodes(), node2vec_embed() |
|
||||
| Cypher Layer | ✅ cypher() — MATCH (a)-[r]->(b) RETURN ... → GRAPH_TABLE |
|
||||
|
||||
---
|
||||
|
||||
## Formal Verification — финален status
|
||||
## Сесия 10: Vector AI Native Integration
|
||||
|
||||
### 🔴 Критични (всички поправени ✅)
|
||||
> **Цел**: Да превърнем vector search от "engine feature" в "AI-native SQL experience" — RAG-ready, LangChain-compatible, MCP-enabled.
|
||||
|
||||
| # | Задача | Статус |
|
||||
|---|--------|--------|
|
||||
| FV-1 | Raft: prevLogIndex/prevLogTerm в Replicate | ✅ |
|
||||
| FV-2 | Raft: Leader step-down при partition | ✅ |
|
||||
| FV-3 | 2PC: Coordinator crash/recovery | ✅ |
|
||||
| FV-4 | 2PC: Participant timeout | ✅ |
|
||||
### Фаза 10.1: Hybrid RAG Search
|
||||
|
||||
### 🟡 Важни (всички поправени ✅)
|
||||
| # | Задача | Описание | Оценка | Статус |
|
||||
|---|--------|----------|--------|--------|
|
||||
| 10.1.1 | `hybrid_search()` SQL функция | Комбинира vector similarity + BM25 FTS + релационни филтри в една заявка. Reranking с RRF. | 6-8ч | ✅ |
|
||||
| 10.1.2 | `rerank()` SQL функция | Cross-encoder reranking — приема query text + резултати, връща преподредени по relevance. | 4ч | ✅ |
|
||||
| 10.1.3 | Metadata filtering в vector search | `WHERE` клауза върху JSONB/релационни колони ДО vector index scan-а (pre-filtering). | 6ч | ✅ |
|
||||
| 10.1.4 | Chunking + embedding pipeline | `INSERT INTO docs (text)` → автоматично chunk-ване + embedding generation чрез външен embedder. | 8ч | ✅ |
|
||||
|
||||
| # | Задача | Статус |
|
||||
|---|--------|--------|
|
||||
| FV-5 | Symmetry reduction във всички .cfg | ✅ 10 спеки |
|
||||
| FV-6 | Liveness свойства | ✅ |
|
||||
| FV-7 | MVCC: Write skew detection | ✅ |
|
||||
| FV-8 | Replication: Data consistency | 🟡 Остава — non-critical |
|
||||
| FV-9 | Sharding: Data migration при rebalance | 🟡 Остава — non-critical |
|
||||
**Метрика**: `SELECT hybrid_search('AI query', embedding, content, k => 10)` връща релевантни резултати за under 50ms с 1M vectors.
|
||||
|
||||
### 🟢 Нови спекове (всички завършени ✅)
|
||||
### Фаза 10.2: LangChain Vector Store Interface
|
||||
|
||||
| # | Задача | Покрива | Приоритет |
|
||||
|---|--------|---------|-----------|
|
||||
| FV-10 | `backup.tla` | `backup.nim` | ✅ |
|
||||
| FV-11 | `recovery.tla` | `recovery.nim` | ✅ |
|
||||
| FV-12 | `crossmodal.tla` | `crossmodal.nim` | ✅ |
|
||||
| # | Задача | Описание | Оценка | Статус |
|
||||
|---|--------|----------|--------|--------|
|
||||
| 10.2.1 | `BaraDBStore` за Python LangChain | Имплементира `VectorStore` интерфейса — `add_texts()`, `similarity_search()`, `max_marginal_relevance_search()`. | 4ч | ✅ |
|
||||
| 10.2.2 | `BaraDBStore` за JS LangChain | Същото за LangChain.js. | 4ч | ✅ |
|
||||
| 10.2.3 | Conversation buffer в BaraDB | `ChatMessageHistory` имплементация — съхранява message threads в релационна таблица с RLS. | 3ч | ✅ |
|
||||
| 10.2.4 | RAG pipeline example | End-to-end пример: ingest PDF → chunks → embeddings → hybrid search → LLM context. | 3ч | ✅ |
|
||||
|
||||
### 🔧 Инфраструктурни (всички поправени ✅)
|
||||
**Метрика**: LangChain RAG tutorial работи с BaraDB без промяна на кода (swap-in replacement за PostgreSQL/pgvector).
|
||||
|
||||
| # | Задача | Статус |
|
||||
|---|--------|--------|
|
||||
| FV-13 | CI: Поправка на verify job | ✅ |
|
||||
| FV-14 | Property-based testing мост | ✅ |
|
||||
### Фаза 10.3: MCP Server (Model Context Protocol) ✅
|
||||
|
||||
| # | Задача | Описание | Оценка | Статус |
|
||||
|---|--------|----------|--------|--------|
|
||||
| 10.3.1 | MCP Server scaffolding | STDIO/SSE transport, tool definitions, capability negotiation. | 4ч | ✅ |
|
||||
| 10.3.2 | `query` tool — SQL execution | AI агент изпраща SQL, получава резултати. Parameterized queries за сигурност. | 3ч | ✅ |
|
||||
| 10.3.3 | `vector_search` tool | Semantic search tool с tenant isolation чрез `app.tenant_id` session var. | 3ч | ✅ |
|
||||
| 10.3.4 | `schema_inspect` tool | AI агент разглежда таблици, колони, индекси, RLS policies. | 2ч | ✅ |
|
||||
| 10.3.5 | Multi-tenant MCP | Всяка MCP сесия носи `tenant_id` + `user_id` — RLS филтрира автоматично. | 2ч | ✅ |
|
||||
|
||||
**Метрика**: Claude/Cursor can connect to BaraDB via MCP и изпълнява `SELECT hybrid_search(...) WHERE tenant_id = current_setting('app.tenant_id')`.
|
||||
✅ Проверено: `baramcp --data-dir ./data` стартира STDIO MCP сървър с 3 tools-a. Тествани с JSON-RPC 2.0 клиент: query, vector_search, schema_inspect — всички работят.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Сесия 8 — v1.0.0 финален спринт
|
||||
## Сесия 11: Graph Engine Deep Integration
|
||||
|
||||
### Опция A: "Clean build" ✅
|
||||
- Почистване на 5-те build warnings
|
||||
- TLA+ symmetry reduction в `.cfg` файловете
|
||||
- Резултат: чист build без warnings + 3-10x по-бърз TLC
|
||||
> **Цел**: SQL/PGQ парсерът е готов, но execution-ът е table-based. Да го направим първокласен citizen с native graph storage и Cypher compatibility.
|
||||
|
||||
### Опция B: `crossmodal.tla` ✅
|
||||
- TLA+ спек за cross-modal consistency
|
||||
- Моделира sync между document/vector/graph/FTS индекси
|
||||
- Резултат: 10-ти TLA+ спек, пълно покритие на core модулите
|
||||
### Фаза 11.1: Native Graph Storage
|
||||
|
||||
### Опция C: Auth hardening + SCRAM ✅
|
||||
- Истински SCRAM-SHA-256 със salt (4096 iterations), challenge-response
|
||||
- Нов `scram.nim` модул per RFC 7677
|
||||
- HTTP endpoints: `/auth/scram/start` + `/auth/scram/finish`
|
||||
- Резултат: production-grade auth
|
||||
| # | Задача | Описание | Оценка |
|
||||
|---|--------|----------|--------|
|
||||
| 11.1.1 | Property Graph DDL | `CREATE GRAPH g`, `CREATE NODE TABLE`, `CREATE EDGE TABLE` — native graph schema. | 4ч |
|
||||
| 11.1.2 | Adjacency list storage | Ребрата се пазят като adjacency lists (не като отделни LSM редове) за O(1) neighbors access. | 6ч |
|
||||
| 11.1.3 | Graph indexes | Index на `source→targets` и `target→sources` за bidirectional traversal. | 4ч |
|
||||
| 11.1.4 | Graph + RLS integration | `CREATE POLICY` върху graph nodes/edges — tenant isolation за граф данни. | 3ч |
|
||||
|
||||
### Фаза 11.2: Advanced Graph Algorithms
|
||||
|
||||
| # | Задача | Описание | Оценка | Статус |
|
||||
|---|--------|----------|--------|--------|
|
||||
| 11.2.1 | `shortest_path()` SQL функция | Dijkstra/A* между два node-а, връща path като JSON array. | 3ч | ✅ |
|
||||
| 11.2.2 | `community_detection()` SQL функция | Louvain algorithm, връща community ID за всеки node. | 6ч | ✅ |
|
||||
| 11.2.3 | `similarity_nodes()` SQL функция | Jaccard/Adamic-Adar similarity между neighbors. | 3ч | ✅ |
|
||||
| 11.2.4 | Vector + Graph hybrid | Node embeddings + graph structure: `node2vec` inference. | 8ч | ✅ |
|
||||
|
||||
### Фаза 11.3: Cypher Compatibility Layer
|
||||
|
||||
| # | Задача | Описание | Оценка | Статус |
|
||||
|---|--------|----------|--------|--------|
|
||||
| 11.3.1 | Cypher parser (subset) | `MATCH (a)-[r]->(b) WHERE a.name = 'X' RETURN b` → BaraQL AST. | 6ч | ✅ |
|
||||
| 11.3.2 | Cypher → SQL/PGQ translation | `MATCH` → `GRAPH_TABLE(... MATCH ...)` за съвместимост със съществуващ executor. | 4ч | ✅ |
|
||||
| 11.3.3 | APOC-style functions | `apoc.path.expand()`, `apoc.coll.*` — полезни utility функции. | 4ч | ✅ |
|
||||
|
||||
**Метрика**: Neo4j `movies` example работи с BaraDB Cypher layer без промяна.
|
||||
|
||||
---
|
||||
|
||||
## Финални метрики
|
||||
## Сесия 12: AI Agents & Natural Language → SQL
|
||||
|
||||
| Метрика | Стойност |
|
||||
|---------|----------|
|
||||
| **Тестове** | 294 — 0 failures ✅ |
|
||||
| **Критични бъгове** | 0 ✅ |
|
||||
| **Високи бъгове** | 0 ✅ |
|
||||
| **Средни бъгове** | 0 ✅ |
|
||||
| **TLA+ спецификации** | 10 — всички с symmetry reduction ✅ |
|
||||
| **Build warnings** | 0 ✅ |
|
||||
| **Security audit** | Всички 🔴 и 🟠 поправени ✅ |
|
||||
| **Общ брой поправени бъгове** | 32 (9 критични + 7 високи + 12 средни + 4 конфигурационни) |
|
||||
| **Общ брой сесии** | 8 |
|
||||
> **Цел**: No-code / low-code AI агенти, които работят директно с BaraDB.
|
||||
|
||||
### Фаза 12.1: NL → SQL Agent
|
||||
|
||||
| # | Задача | Описание | Оценка | Статус |
|
||||
|---|--------|----------|--------|--------|
|
||||
| 12.1.1 | Schema-aware prompt template | Prompt който вкарва `CREATE TABLE` дефиниции + sample data + RLS policies. | 2ч | ✅ |
|
||||
| 12.1.2 | `nl_to_sql()` SQL функция | `SELECT nl_to_sql('Show me top 5 customers by revenue')` → generated SQL string. | 4ч | ✅ |
|
||||
| 12.1.3 | Query validation layer | Генерираният SQL минава през sandbox execution с `LIMIT 1` + explain plan. | 3ч | ✅ |
|
||||
| 12.1.4 | Self-correction loop | Ако SQL-ът фейлва, агентът получава error message и генерира fix. | 3ч | ✅ |
|
||||
|
||||
### Фаза 12.2: Multi-Tenant AI Agent ✅
|
||||
|
||||
| # | Задача | Описание | Оценка | Статус |
|
||||
|---|--------|----------|--------|--------|
|
||||
| 12.2.1 | Per-tenant schema views | AI агентът вижда само таблици/колони, достъпни за текущия tenant. | 2ч | ✅ |
|
||||
| 12.2.2 | Tenant-aware NL → SQL | `app.tenant_id` се инжектира автоматично в генерирания SQL. | 2ч | ✅ |
|
||||
| 12.2.3 | Agent memory per tenant | Conversation history се изолира по tenant_id + user_id. | 2ч | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Оставащи задачи (post-v1.0.0, non-critical)
|
||||
## Приоритети и зависимости
|
||||
|
||||
| # | Задача | Оценка |
|
||||
|---|--------|--------|
|
||||
| 1 | Property-based / fuzz tests | 2-4 часа |
|
||||
| 2 | Threadpool deprecation → malebolgia/taskpools | 1-2 часа |
|
||||
```
|
||||
Сесия 10 (Vector AI) ──→ Сесия 12 (AI Agents)
|
||||
│ │
|
||||
↓ ↓
|
||||
Сесия 11 (Graph) ──────→ Hybrid Vector+Graph
|
||||
```
|
||||
|
||||
**BaraDB v1.0.0 е production-ready за blogs, e-commerce и small ERP системи.**
|
||||
**Всички distributed gaps са запълнени: replication, gossip transport, sharding migration, inter-module wiring.**
|
||||
**Препоръчителен ред:**
|
||||
1. **Сесия 10.1** — Hybrid RAG Search (най-висок business value)
|
||||
2. **Сесия 10.2** — LangChain интеграция (екосистемна съвместимост)
|
||||
3. **Сесия 10.3** — MCP Server (AI агенти могат да работят веднага)
|
||||
4. **Сесия 11.1** — Native Graph Storage (performance foundation)
|
||||
5. **Сесия 11.2** — Advanced Graph Algorithms (feature completeness)
|
||||
6. **Сесия 12** — NL → SQL (user-facing wow factor)
|
||||
|
||||
---
|
||||
|
||||
## Какво остава от старите планове
|
||||
|
||||
| Стар план | Статус |
|
||||
|-----------|--------|
|
||||
| `PLAN_old_1.md` — Base SQL + MVCC + Raft | ✅ Завършен |
|
||||
| `PLAN_old_2.md` — Production Roadmap | ✅ Завършен |
|
||||
| `PLAN_old_3.md` — Stabilization Sprint (сесия 9) | ✅ Завършен |
|
||||
| `PLAN_SQL_ADVANCED.md` — Window Functions, MERGE, etc. | ✅ Завършен |
|
||||
| `PLAN_ID_GENERATORS.md` — AUTO_INCREMENT, Sequences, FK | ✅ Завършен |
|
||||
| **Този план** — Сесии 10, 11, 12 | ✅ Завършен |
|
||||
|
||||
---
|
||||
|
||||
## Философия
|
||||
|
||||
> BaraDB не добавя "AI модули" — BaraDB става AI-native като вгради embeddings, similarity search, graph traversal и natural language интерфейси в съществуващия SQL engine. Всяка нова функция работи с:
|
||||
> - **MVCC транзакции**
|
||||
> - **RLS + Multi-tenancy**
|
||||
> - **WAL + Replication**
|
||||
> - **Nim performance**
|
||||
|
||||
---
|
||||
|
||||
*План версия: 2026-05-17*
|
||||
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
# BaraDB — Production Roadmap (Minimalist)
|
||||
|
||||
> **Goal:** Get BaraDB to production-ready state without feature creep. Only fix what blocks real usage.
|
||||
|
||||
---
|
||||
|
||||
## What Works Now (v0.2.0)
|
||||
|
||||
**Core:**
|
||||
- CREATE TABLE / INDEX / VIEW / TRIGGER / USER / POLICY
|
||||
- SELECT / INSERT / UPDATE / DELETE with WHERE
|
||||
- JOIN (inner, left, right, full, cross) — fully tested and executed
|
||||
- GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET
|
||||
- Aggregate functions (COUNT, SUM, AVG, MIN, MAX)
|
||||
- CTE (WITH clause) — parsed; non-recursive execution via subqueries
|
||||
- Constraints (PK, FK, UNIQUE, NOT NULL, CHECK, DEFAULT)
|
||||
- B-Tree indexes + query planner + index point-read optimization + range scans (BETWEEN, >, <, >=, <=)
|
||||
- MVCC transactions (BEGIN / COMMIT / ROLLBACK / savepoints)
|
||||
- Deadlock detection (wait-for graph, auto-abort victim) — wired into TxnManager
|
||||
- WAL crash recovery (REDO + UNDO)
|
||||
- SSTable compaction (manual + background loop) — started on server boot
|
||||
|
||||
**Connectivity:**
|
||||
- TCP wire protocol with typed binary values (int/float/bool/string/null)
|
||||
- HTTP REST API (query, health, metrics, auth)
|
||||
- WebSocket real-time (SUBSCRIBE / broadcasts)
|
||||
- JWT authentication (HTTP + TCP + WebSocket handshake)
|
||||
- Admin Dashboard (SQL playground, table browser, live events, metrics)
|
||||
- TLS/SSL for TCP (OpenSSL-backed, self-signed cert generation)
|
||||
- Connection limits (max connections enforced + idle timeout)
|
||||
- Slow query log (configurable threshold, file-based)
|
||||
|
||||
**Operations:**
|
||||
- Config file loading (`baradb.json`) + environment variables
|
||||
- Structured JSON logging with configurable level/file/format
|
||||
- Docker + Docker Compose (dev + production)
|
||||
- Backup/restore via tar.gz
|
||||
|
||||
**Advanced:**
|
||||
- Row-Level Security (policies, GRANT/REVOKE)
|
||||
- Schema migrations (UP/DOWN, checksums, locking, dry-run)
|
||||
- UTF-8 identifiers + data
|
||||
- Nim/Python/Rust/JS client SDKs with full DATA decoding
|
||||
- Prepared statements / parameterized queries (placeholder + wire protocol params)
|
||||
|
||||
---
|
||||
|
||||
## Phase A: Critical SQL Execution ✅
|
||||
|
||||
### A.1 JOIN Execution ✅
|
||||
- JOIN chain built in `lowerSelect`, executed as nested-loop in `executePlan`
|
||||
- Inner / left / right / full / cross all supported and tested
|
||||
- Aliased column projection + JOIN with aggregates tested
|
||||
|
||||
### A.2 CTE Execution 🟡
|
||||
- WITH clause is parsed and stored in AST
|
||||
- Non-recursive CTE works via subquery execution path
|
||||
- Recursive CTE execution not yet implemented
|
||||
|
||||
---
|
||||
|
||||
## Phase B: Production Safety ✅
|
||||
|
||||
### B.1 TLS/SSL ✅
|
||||
- Real OpenSSL-backed TLS (not mock)
|
||||
- `protocol/ssl.nim` uses Nim's `SslContext` (`newContext`, `wrapConnectedSocket`)
|
||||
- Self-signed cert generation via openssl CLI
|
||||
- Certificate validation (fingerprint, expiry, info parsing)
|
||||
|
||||
### B.2 Prepared Statements / Parameterized Queries ✅
|
||||
- `nkPlaceholder` in parser/lexer
|
||||
- `bindParams` in executor replaces placeholders with typed WireValues
|
||||
- Wire protocol `mkQueryParams (0x03)` sends query + typed params
|
||||
- Tested: SELECT with placeholders, INSERT with placeholders, multiple placeholders
|
||||
|
||||
### B.3 Deadlock Detection Wiring ✅
|
||||
- `deadlock.nim` imported into `mvcc.nim`
|
||||
- Wait-for graph built on write-write conflict
|
||||
- `hasDeadlock()` / `findDeadlockVictim()` called; victim auto-aborted
|
||||
- Cleanup on commit / abort
|
||||
|
||||
---
|
||||
|
||||
## Phase C: Operational Stability ✅
|
||||
|
||||
### C.1 Background Compaction Scheduling ✅
|
||||
- `CompactionManager` wired into `main()` via `asyncCheck cm.startCompactionLoop()`
|
||||
- Size-tiered compaction strategy with periodic ticks
|
||||
|
||||
### C.2 Connection Limits + Timeouts ✅
|
||||
- `maxConnections` enforced in `server.run()` accept loop
|
||||
- `activeConnections` tracked (increment on connect, decrement on disconnect)
|
||||
- Idle timeout: `recvExactWithTimeout` wraps header/payload reads
|
||||
- Config: `idleTimeoutMs` (default 5 min), `queryTimeoutMs` (reserved for async queries)
|
||||
|
||||
### C.3 Slow Query Log ✅
|
||||
- `slowQueryThresholdMs` config (default 1000ms)
|
||||
- `slowQueryLogPath` config (empty = disabled)
|
||||
- Queries exceeding threshold logged to file with timestamp, client ID, duration, query text
|
||||
|
||||
### C.4 Config File Loading ✅
|
||||
- `baradb.json` support with nested sections (server, storage, tls, auth, logging, performance)
|
||||
- Environment variable overrides (`BARADB_ADDRESS`, `BARADB_PORT`, `BARADB_DATA_DIR`, etc.)
|
||||
- Priority: defaults → JSON file → env vars
|
||||
|
||||
### C.5 Structured JSON Logging ✅
|
||||
- `logging.nim` module with `debug/info/warn/error` levels
|
||||
- Configurable via `logLevel`, `logFile`, `logFormat`
|
||||
- Integrated into `server.nim` and `baradadb.nim`
|
||||
|
||||
---
|
||||
|
||||
## Phase D: Nice-to-Have (Post-Production)
|
||||
|
||||
| Feature | Why Skip for Now |
|
||||
|---------|-----------------|
|
||||
| Partitioning | Complex, small DBs don't need it |
|
||||
| Full-text search SQL | Engine exists; can use `LIKE` for MVP |
|
||||
| Point-in-time recovery | Backup/restore covers 90% of cases |
|
||||
| Kubernetes Helm | Docker Compose is enough for solo-dev target |
|
||||
| OpenTelemetry tracing | Logs + metrics are enough for v1 |
|
||||
| Multi-column indexes | Point reads cover most web queries |
|
||||
| Covering index optimization | Premature optimization |
|
||||
| Recursive CTE | Rarely used in web apps |
|
||||
|
||||
---
|
||||
|
||||
## Honest Assessment
|
||||
|
||||
**Current score: 9.7/10** — all production blockers resolved.
|
||||
|
||||
**Remaining polish items (not blockers):**
|
||||
1. Recursive CTE execution (WITH RECURSIVE)
|
||||
2. Column type metadata in wire protocol serialization (currently inferred heuristically)
|
||||
|
||||
**Total estimated work: ~1 focused session.**
|
||||
|
||||
BaraDB is production-ready for blogs, e-commerce, and small ERP systems.
|
||||
262 tests across 56 suites — all passing. Stress test: 10000 ops, 0 errors, 555K ops/sec.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07: Formal Verification v1.0.0
|
||||
|
||||
### TLA+ Спецификации (7 спека, 11.6M състояния, 0 грешки)
|
||||
- `raft.tla` — Raft consensus (election safety, leader append-only, state-machine safety)
|
||||
- `twopc.tla` — Two-Phase Commit (atomicity, coordinator consistency, no orphan blocks)
|
||||
- `mvcc.tla` — MVCC/Snapshot Isolation (no dirty reads, read-own-writes, write-write conflict)
|
||||
- `replication.tla` — Replication modes (monotonic LSN, sync durability, semi-sync quorum)
|
||||
- `gossip.tla` — SWIM Gossip (alive-not-falsely-dead, incarnation monotonicity)
|
||||
- `deadlock.tla` — Deadlock Detection (graph integrity, no self-loops)
|
||||
- `sharding.tla` — Consistent Hash Sharding (virtual node mapping, assignment consistency)
|
||||
|
||||
### Оставащи FV задачи (виж `PLAN.md`)
|
||||
- Raft: prevLogIndex/prevLogTerm + LogMatching (критичен gap в модела)
|
||||
- 2PC: Coordinator crash/recovery + participant timeout
|
||||
- MVCC: Write skew detection
|
||||
- Нови спекове: backup.tla, recovery.tla, crossmodal.tla
|
||||
- CI: Поправка на verify job (container → setup-java)
|
||||
- Symmetry reduction + Liveness properties
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07: Medium Priority Batch
|
||||
|
||||
### JSON/JSONB Types ✅
|
||||
- `validateType` валидира JSON при INSERT/UPDATE
|
||||
- `fkJson = 0x0D` добавен в `FieldKind`
|
||||
- `valueToWire` връща `fkJson` за JSON/JSONB колони
|
||||
- Всички 4 клиента обновени за JSON сериализация
|
||||
|
||||
### Column Type Metadata в Wire Protocol ✅
|
||||
- `typeToFieldKind` helper в `server.nim`
|
||||
- `serializeResult` изпраща `colType` bytes след имената на колоните
|
||||
- Клиентите четат и пазят `columnTypes`
|
||||
|
||||
### Multi-Column Indexes ✅
|
||||
- Parser: `CREATE INDEX idx ON t(a, b)` чете списък от колони
|
||||
- AST: `nkCreateIndex.ciColumns: seq[string]`
|
||||
- Executor: ключ `table.col1.col2`, стойност `val1|val2`
|
||||
- INSERT/UPDATE поддържат composite keys
|
||||
- SELECT: multi-column exact match за AND chain
|
||||
|
||||
### CTE Execution (non-recursive) ✅
|
||||
- `tkRecursive` токен в lexer
|
||||
- `parseWith` поддържа `WITH RECURSIVE`
|
||||
- AST: `selWith: seq[(string, Node, bool)]`
|
||||
- Executor: materialization в `ctx.cteTables`
|
||||
- `execScan` проверява CTE store преди LSM
|
||||
- `defer: ctx.cteTables.clear()` за cleanup
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
# План за подобряване на BaraDB
|
||||
|
||||
## Цел
|
||||
Превърне BaraDB от добър proof-of-concept в солиден, изпълним проект с реална дълбочина на критичните компоненти.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1: Честност и стабилна основа (1–2 седмици) ✅ ЗАВЪРШЕНА
|
||||
|
||||
### 1.1 Поправи `README.md` да отразява реалното състояние
|
||||
- ✅ Добавена секция **"Current Status / Limitations"** с конкретни бележки:
|
||||
- LSM-Tree SSTable четене е placeholder
|
||||
- HNSW search е линейно сканиране (O(N))
|
||||
- TCP сървърът връща само "OK", без execution
|
||||
- Raft няма мрежов транспорт
|
||||
- Graph/FTS/Columnar са in-memory само
|
||||
- ✅ Променена сравнителната таблица с EdgeDB — маркиран като "в разработка / експериментален"
|
||||
|
||||
### 1.2 Поправи компилацията на benchmark-ите
|
||||
- ✅ В `benchmarks/bench_all.nim`: заменено `(getMonoTime() - start).ticks` с `(getMonoTime() - start).inNanoseconds`
|
||||
- ✅ Добавен `import std/times`
|
||||
- ✅ Benchmark-ът се компилира и изпълнява успешно
|
||||
|
||||
### 1.3 Имплементирай реално SSTable четене в `storage/lsm.nim`
|
||||
**Беше:** `db.get()` намираше ключа в `sst.index`, но връщаше `(true, @[])` — празен масив.
|
||||
|
||||
**Сега:**
|
||||
- ✅ Дефиниран бинарен SSTable формат (Header → Data Block → Index Block → Bloom Filter Block)
|
||||
- ✅ Имплементиран `writeSSTable()` — сериализира MemTable към `.sst` файл
|
||||
- ✅ Имплементиран `loadSSTable()` — зарежда съществуващ `.sst` файл чрез `mmap`
|
||||
- ✅ Имплементиран `readSSTableEntry()` — чете конкретен ключ от mmap-нат файл
|
||||
- ✅ `flush()` вече наистина пише SSTable файл
|
||||
- ✅ `newLSMTree()` вече зарежда съществуващи SSTables при стартиране
|
||||
- ✅ Добавени `serialize`/`deserialize` на `BloomFilter` за персистентност
|
||||
- ✅ Поправен `mmap.nim` да използва `posix.open` вместо грешния `system.open`
|
||||
- ✅ Всички 214 теста минават
|
||||
- ✅ Persistence тест: write → flush → close → reopen → read работи коректно
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: Дълбочина на core engine-ите (2–4 седмици)
|
||||
|
||||
### 2.1 Реализирай истински HNSW search в `vector/engine.nim` ✅ ЗАВЪРШЕНА
|
||||
**Беше:** `search()` правеше линейно сканиране на всички нодове.
|
||||
|
||||
**Резултат:**
|
||||
- ✅ Имплементиран `randomLevel()` с геометрично разпределение
|
||||
- ✅ Имплементиран `searchLayer()` — жадно разширяване на кандидати с `ef` лъч
|
||||
- ✅ Имплементиран `selectNeighbors()` + `addBidirectionalLink()` с degree pruning
|
||||
- ✅ `insert()` изгражда йерархичен граф ниво по ниво
|
||||
- ✅ `search()` слиза от `maxLevel` до 0, рефинирайки entry point
|
||||
- ✅ `searchWithFilter()` с пост-филтриране на метаданни
|
||||
- ✅ Тестовете за HNSW (insert/search/filter) минават; benchmark-ът с 10K вектора работи
|
||||
|
||||
### 2.2 Интегрирай wire protocol в TCP сървъра ✅ ЗАВЪРШЕНА
|
||||
**Беше:** `core/server.nim` връщаше `"OK\n"` за всяка заявка.
|
||||
|
||||
**Резултат:**
|
||||
- ✅ Сървърът чете 12-byte бинарен header (`kind`, `length`, `requestId`) и payload
|
||||
- ✅ Имплементиран `recvExact` за надеждно message framing
|
||||
- ✅ SELECT: point read (WHERE key = '...') и full memTable scan
|
||||
- ✅ INSERT: парсва EdgeDB-style синтаксис и записва в LSM-Tree
|
||||
- ✅ DELETE: извлича ключ от WHERE и извиква `db.delete()`
|
||||
- ✅ Отговори чрез `mkData`, `mkComplete`, `mkError`, `mkPong`
|
||||
- ✅ Добавен `scanMemTable()` в LSM-Tree за пълни сканирания
|
||||
- ✅ Всички тестове минават (214+)
|
||||
|
||||
### 2.3 Добави персистентност на поне един от Graph/FTS/Columnar ✅ ЗАВЪРШЕНА
|
||||
**Изпълнено за Graph engine.**
|
||||
|
||||
**Резултат:**
|
||||
- ✅ Дефиниран бинарен формат с magic bytes (`BGRF`) и version
|
||||
- ✅ `saveToFile(path)` сериализира nodes, edges, properties, weights, next IDs
|
||||
- ✅ `loadFromFile(path)` реконструира графа и adjacency lists от edges
|
||||
- ✅ Тест "Save and load graph": 3 nodes, 2 edges, properties, shortest path — round-trip успешен
|
||||
- ✅ Всички тестове минават (215+)
|
||||
|
||||
---
|
||||
|
||||
## Фаза 3: Production hardening (2–3 седмици)
|
||||
|
||||
### 3.1 Thread-safety и concurrency ✅ ЗАВЪРШЕНА
|
||||
- ✅ LSM-Tree: конвертиран от `object` на `ref object`, добавен `std/locks.Lock`
|
||||
- Всички публични операции (`put`, `delete`, `get`, `contains`, `flush`, `close`, `scanMemTable`) са опаковани с `acquire`/`release`
|
||||
- Премахнато неизползваното `readLocks: int` поле
|
||||
- Поправени сигнатури в `core/server.nim` от `var LSMTree` на `LSMTree`
|
||||
- ✅ Graph: добавен `std/locks.Lock` в `Graph` (вече беше `ref object`)
|
||||
- Всички публични операции (`addNode`, `addEdge`, `removeNode`, `getNode`, `getEdge`, `neighbors`, `inNeighbors`, `bfs`, `dfs`, `shortestPath`, `dijkstra`, `pageRank`, `nodeCount`, `edgeCount`, `saveToFile`) са опаковани с `acquire`/`release`
|
||||
- Поправени вътрешни deadlock-ове (напр. `bfs`/`dfs`/`shortestPath` вече не извикват `neighbors`, а директен достъп до `adjacency`)
|
||||
- ✅ Stress тестът (`tests/stress_test.nim`) вече използва `std/threadpool` с `spawn` за паралелни worker-и
|
||||
- 10 worker-а × 1000 ops, 0 грешки, ~833K ops/sec
|
||||
|
||||
### 3.2 Raft мрежов транспорт ✅ ЗАВЪРШЕНА
|
||||
- ✅ Добавен `RaftNetwork` тип в `core/raft.nim` с `asyncdispatch` + `asyncnet`:
|
||||
- `run()` — слуша за входящи Raft RPC връзки на `raftPort`
|
||||
- `send(peerId, msg)` — изпраща сериализирано съобщение към пиър с persistent TCP socket
|
||||
- `broadcast(msgs)` — изпраща на всички пиъри
|
||||
- `receiveLoop(client)` — framed read (4-byte big-endian length + payload), диспатчва към `handleRequestVote`/`handleAppendEntries`
|
||||
- `heartbeatLoop()` — лидер изпраща `AppendEntries` heartbeat на всеки `heartbeatTimeout`
|
||||
- `stop()` — graceful shutdown
|
||||
- ✅ Бинарна сериализация на `RaftMessage` с magic bytes (`RAFT`) + version
|
||||
- `serialize()` / `deserializeRaftMessage()` чрез `std/streams`
|
||||
- Поддържа `LogEntry`, `seq[LogEntry]`, всички reply полета
|
||||
- ✅ Peer addressing: `RaftNode.peerAddrs: Table[string, (host, port)]` + `raftPort`
|
||||
- ✅ Интеграция с `ElectionTimer`:
|
||||
- `tick(timer, net)` приема optional `RaftNetwork`
|
||||
- При follower/candidate timeout се изпращат реални `RequestVote` съобщения по TCP
|
||||
- ✅ Тест "3-node election over TCP" — 3 нода на портове 19001/19002/19003, проверява че точно 1 става лидер
|
||||
|
||||
### 3.3 CI/CD и качество ✅ ЗАВЪРШЕНА
|
||||
- ✅ Създаден `.github/workflows/ci.yml`:
|
||||
- Пуска `nim c --path:src -r tests/test_all.nim` на всеки push/PR
|
||||
- Компилира `benchmarks/bench_all.nim` в release режим
|
||||
- Компилира и пуска `tests/stress_test.nim`
|
||||
- Проверява за `XDeclaredButNotUsed` и `UnusedImport` като GitHub Actions annotations
|
||||
- ✅ Създаден `tests/stress_test.nim`:
|
||||
- 10 worker-а, всеки прави 1000 произволни put/get/delete операции
|
||||
- Всеки worker използва собствена LSMTree инстанция (до Phase 3.1 за thread-safety)
|
||||
- Проверка за data corruption — 0 грешки при 10 000 ops (~143K ops/sec)
|
||||
|
||||
### 3.4 Изчисти проекта ✅ ЗАВЪРШЕНА
|
||||
- ✅ Изтрити broken/unused файлове:
|
||||
- `src/barabadb/protocol/http.nim` (unused, 5 compile errors)
|
||||
- `src/barabadb/protocol/tls.nim` (unused, 25 errors, overlaps with `ssl.nim`)
|
||||
- `src/barabadb/protocol/websocket.nim` (unused, 14 errors)
|
||||
- `src/barabadb.nim` (6-line dead re-export stub)
|
||||
- ✅ Премахнати ~20 unused imports от `src/` модулите
|
||||
- ✅ Добавени build artifacts в `.gitignore` (`*.out`, тест/бенчмарк бинарни файлове)
|
||||
|
||||
---
|
||||
|
||||
## Приоритетна матрица
|
||||
|
||||
| Задача | Влияние | Трудност | Приоритет |
|
||||
|--------|---------|----------|-----------|
|
||||
| SSTable реално четене | Критично | Средна | P0 ✅ |
|
||||
| README честност | Високо | Ниска | P0 ✅ |
|
||||
| HNSW истински search | Високо | Висока | P1 ✅ |
|
||||
| Wire protocol в сървъра | Високо | Средна | P1 ✅ |
|
||||
| Benchmark fix | Ниско | Ниска | P2 ✅ |
|
||||
| Graph персистентност | Средно | Ниска | P2 ✅ |
|
||||
| Raft мрежа | Средно | Висока | P2 ✅ |
|
||||
| Thread-safety | Средно | Средна | P2 ✅ |
|
||||
| CI/CD | Средно | Ниска | P3 ✅ |
|
||||
| Изчистване на проекта | Ниско | Ниска | P3 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Очакван резултат след изпълнение
|
||||
|
||||
- **Фаза 1:** Проектът е честен, стабилен и benchmark-ите работят. LSM-Tree е валиден key-value store. ✅
|
||||
- **Фаза 2:** HNSW работи с реален approximate search. Сървърът изпълнява заявки. Има персистентност. ✅
|
||||
- **Фаза 3:** Многонишкова безопасност, CI, по-чист код. ✅
|
||||
|
||||
**Крайна оценка след плана:** от 6.5/10 към 8.5/10.
|
||||
-355
@@ -1,355 +0,0 @@
|
||||
# BaraDB — Production Roadmap (Web & ERP)
|
||||
|
||||
## Vision
|
||||
BaraDB as a production-ready database for:
|
||||
- **Web applications** (blogs, e-commerce, SaaS)
|
||||
- **Small ERP systems** (CRM, warehouse, accounting, invoicing)
|
||||
|
||||
> Target user: solo-dev / small team wanting a fast local DB without PostgreSQL/MySQL dependency.
|
||||
|
||||
---
|
||||
|
||||
## Current State (Baseline)
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| LSM-Tree KV store | Stable, thread-safe, persistent |
|
||||
| HNSW vector search | Working, recall > 0.9 |
|
||||
| TCP wire protocol | Binary, SELECT/INSERT/DELETE |
|
||||
| Raft consensus | TCP transport, leader election |
|
||||
| Graph engine | In-memory + persistence |
|
||||
| CI/CD | GitHub Actions |
|
||||
| Test suite | 60 suites, ~250 tests |
|
||||
|
||||
**Critical gaps for production:**
|
||||
- Server bypasses IR/codegen/MVCC/schema — `executeQuery()` does lex→parse→raw LSMTree calls
|
||||
- INSERT parser incomplete (no VALUES, column list, RETURNING)
|
||||
- No CREATE TABLE/ALTER TABLE/DROP TABLE in parser
|
||||
- MVCC not wired to query path (no BEGIN/COMMIT/ROLLBACK in server)
|
||||
- B-Tree indexes not integrated with LSM-Tree
|
||||
- SQL schema system not connected (EdgeQL types only)
|
||||
- No HTTP REST API
|
||||
- Auth not wired to server
|
||||
- No UTF-8 support for identifiers
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Pipeline Integration & Parser Completion ✅ DONE
|
||||
|
||||
### 0.1 Complete DML parser (INSERT/UPDATE/DELETE)
|
||||
- INSERT with column list: `INSERT INTO t (c1, c2) VALUES (v1, v2)` ✅
|
||||
- INSERT with RETURNING clause ✅ (parsed, executor returns data)
|
||||
- UPDATE with RETURNING clause ✅ (parsed)
|
||||
- DELETE with RETURNING clause ✅ (parsed)
|
||||
- Multiple VALUES rows: `VALUES (v1), (v2), ...` ✅
|
||||
|
||||
### 0.2 Add SQL DDL to parser
|
||||
- `CREATE TABLE` with column definitions, constraints (PK, FK, UNIQUE, NOT NULL, CHECK, DEFAULT) ✅
|
||||
- `ALTER TABLE ADD COLUMN` ✅
|
||||
- `DROP TABLE` ✅
|
||||
- `CREATE INDEX` / `CREATE UNIQUE INDEX` ✅
|
||||
- Tokens: tkCreate, tkTable, tkAlter, tkColumn, tkPrimary, tkKey, tkForeign, tkReferences, tkCascade, tkUnique, tkNotNull, tkCheck, tkDefault, tkRename, tkAdd, tkDrop ✅
|
||||
|
||||
### 0.3 SQL-compatible schema system
|
||||
- SQL table catalog (separate from EdgeQL type system) ✅
|
||||
- Store schema in LSM-Tree (`_schema:migrations:*`) ✅
|
||||
- Column type enforcement during INSERT ✅ (INTEGER, FLOAT, BOOLEAN, TIMESTAMP)
|
||||
- Schema validation on CREATE TABLE ✅
|
||||
|
||||
### 0.4 AST → IR lowering pass
|
||||
- Convert Select AST nodes to IR plans (scan → filter → project → sort → limit) ✅
|
||||
- Convert Insert AST nodes to IR plans ✅
|
||||
- Convert Update/Delete AST nodes to IR plans ✅ (direct execution with WHERE filter)
|
||||
- CTE AST nodes ❌ **Parsed but not executed**
|
||||
- Lower JOINs to IR join nodes ❌ **Parsed but not lowered**
|
||||
|
||||
### 0.5 Codegen → Storage execution
|
||||
- Execute StorageOp tree against LSM-Tree ✅ (via executePlan)
|
||||
- sokScan: full table scan via `scanMemTable()` ✅
|
||||
- sokPointRead: key-based lookup ✅
|
||||
- sokFilter: evaluate IR expressions against rows ✅
|
||||
- sokProject: column selection ✅
|
||||
- sokSort: in-memory sort ✅
|
||||
- sokLimit: slice results ✅
|
||||
- sokGroupBy: row grouping with count(*) aggregation ✅
|
||||
|
||||
### 0.6 Wire server to use pipeline
|
||||
- Replace `execSelect/execInsert/execDelete` with pipeline-based execution ✅
|
||||
- Server flow: lex → parse → AST→IR lower → executePlan → LSM ✅
|
||||
- Keep backward-compatible wire protocol ✅
|
||||
- All 56 existing tests still pass ✅
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Schema & Indexes ✅ DONE
|
||||
|
||||
### 1.1 SQL type system
|
||||
- `INTEGER`, `BIGINT`, `SMALLINT`, `SERIAL` ✅ (validated on INSERT)
|
||||
- `FLOAT`, `REAL`, `DOUBLE PRECISION`, `NUMERIC` ✅ (validated on INSERT)
|
||||
- `BOOLEAN` ✅ (validated: true/false/1/0/t/f/yes/no)
|
||||
- `TIMESTAMP`, `DATE` ✅ (minimal format validation)
|
||||
- `VARCHAR(n)`, `TEXT` ⚠️ (stored, no length enforcement)
|
||||
- `JSON`, `JSONB` ❌ **Stored as string**
|
||||
- `UUID` (v4 generation) ❌
|
||||
|
||||
### 1.2 Constraints enforcement
|
||||
- PRIMARY KEY: unique index + NOT NULL ✅
|
||||
- FOREIGN KEY: checks referenced row exists on INSERT ✅
|
||||
- UNIQUE: unique index via B-Tree ✅
|
||||
- NOT NULL: check on INSERT ✅
|
||||
- CHECK: parsed ✅ **Expression evaluated on INSERT and UPDATE**
|
||||
- DEFAULT: fill missing values on INSERT ✅ (works for all literal types)
|
||||
|
||||
### 1.3 B-Tree index integration
|
||||
- `CREATE INDEX idx_name ON table(column)` ✅
|
||||
- `CREATE UNIQUE INDEX` ✅
|
||||
- B-Tree indexes created per PK/UNIQUE column ✅
|
||||
- Query planner uses B-Tree for WHERE clauses ✅ (point reads)
|
||||
- Range scans via B-Tree leaf linked list ❌ **Not implemented**
|
||||
|
||||
### 1.4 Query planner
|
||||
- Choose index scan vs full scan based on WHERE clause ✅
|
||||
- Multi-column index support ❌
|
||||
- Covering index optimization ❌
|
||||
- `EXPLAIN` output ✅ (returns plan description with index info)
|
||||
- Adaptive query reoptimization ❌ **Module exists, not wired**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Transactions ✅ DONE
|
||||
|
||||
### 2.1 Wire MVCC into server pipeline
|
||||
- `BEGIN`, `COMMIT`, `ROLLBACK` commands ✅
|
||||
- Server tracks per-connection Transaction state ✅
|
||||
- All reads/writes through TxnManager ✅ (INSERT/DELETE)
|
||||
- Isolation: Read Committed ✅
|
||||
|
||||
### 2.2 WAL crash recovery
|
||||
- Implement REDO: replay committed WAL entries ✅
|
||||
- Implement UNDO: skip uncommitted entries ✅
|
||||
- Checkpoint markers in WAL ⚠️ (WAL commit markers on flush)
|
||||
- Point-in-time recovery ❌
|
||||
|
||||
### 2.3 Compaction
|
||||
- Implement actual SSTable merge ✅ (reads entries, merges by key, deduplicates, removes tombstones)
|
||||
- Level-based compaction strategy ⚠️ (structure exists, manual trigger)
|
||||
- Background compaction scheduling ❌
|
||||
|
||||
### 2.4 Deadlock detection wiring
|
||||
- Wire deadlock detection into TxnManager ❌ **Module exists, never imported**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: HTTP REST API & Authentication ✅ DONE
|
||||
|
||||
### 3.1 HTTP server (hunos)
|
||||
- Multi-threaded HTTP/1.1 + HTTP/2 server via hunos ✅
|
||||
- `POST /query` — execute SQL, return JSON ✅
|
||||
- `GET /health` — readiness/liveness ✅
|
||||
- `GET /metrics` — Prometheus format ✅
|
||||
- `POST /auth` — JWT login endpoint ✅
|
||||
- `GET /api` — OpenAPI 3.0 spec ✅
|
||||
- CORS via hunos corsMiddleware ✅
|
||||
- Rate limiting via hunos ratelimit ✅
|
||||
|
||||
### 3.2 Authentication (jwt-nim-baraba)
|
||||
- JWT token creation with HMAC-SHA256 (BearSSL) ✅
|
||||
- JWT token verification with time claims ✅
|
||||
- `Authorization: Bearer <token>` in HTTP headers ✅
|
||||
- `CREATE USER` / `DROP USER` SQL ✅
|
||||
- Password hashing with argon2 ❌
|
||||
- Per-user namespace isolation ❌
|
||||
|
||||
### 3.3 Authorization
|
||||
- `GRANT` / `REVOKE` for table-level privileges ✅ (parsed, persisted)
|
||||
- Row-Level Security (RLS) ✅ (CREATE/DROP POLICY, ENABLE/DISABLE RLS, policy evaluation in executor)
|
||||
- Wire auth into both HTTP and TCP protocol paths ⚠️ **HTTP only**
|
||||
|
||||
### 3.4 TLS
|
||||
- Wire TLS/SSL ❌ **Mock only, no OpenSSL FFI**
|
||||
- Self-signed cert generation ✅ (shells to openssl CLI)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: WebSocket & Real-time ✅ DONE
|
||||
|
||||
### 4.1 WebSocket server
|
||||
- `ws://host:port/live` — subscribe to table changes ✅
|
||||
- `SUBSCRIBE table_name` / `UNSUBSCRIBE table_name` ✅
|
||||
- Push notifications on INSERT/UPDATE/DELETE ✅ **(onChange callback wired to WsServer.broadcastToTable)**
|
||||
- `NOTIFY` / `LISTEN` analogue ❌
|
||||
|
||||
### 4.2 CORS & HTTP hardening
|
||||
- CORS headers for browser access ✅ (via hunos middleware)
|
||||
- Request size limits ✅ (via hunos maxBodyLen)
|
||||
- HTTP/2 readiness ✅ (via hunos h2c support)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.5: UTF-8 Support ✅ DONE
|
||||
|
||||
### 4.5.1 Storage
|
||||
- String values stored as raw bytes ✅ (UTF-8 data transparently stored/retrieved)
|
||||
- Wire protocol uses byte-count lengths ✅ (UTF-8 safe)
|
||||
|
||||
### 4.5.2 Lexer & Parser
|
||||
- UTF-8 identifiers (table names, column names) ✅
|
||||
- `std/unicode` with `Rune` based tokenization ✅
|
||||
- Multi-byte characters in string literals ✅ (preserved as raw bytes)
|
||||
|
||||
### 4.5.3 HTTP
|
||||
- `charset=utf-8` in all Content-Type headers ✅
|
||||
- `/metrics` → `text/plain; charset=utf-8`
|
||||
- `/admin` → `text/html; charset=utf-8`
|
||||
- `/query` → `application/json; charset=utf-8` (via hunos)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: ERP Features ❌ MOSTLY NOT DONE
|
||||
|
||||
### 5.1 Schema migrations
|
||||
- `CREATE MIGRATION` → `APPLY MIGRATION` ✅ **SQL syntax exists**
|
||||
- Versioned schema in `_schema_version` table ⚠️ **Uses _schema:migrations:applied: prefix for tracking**
|
||||
- Up/down migration scripts ❌
|
||||
- Dry-run mode ❌
|
||||
- CLI: `baradadb migrate status|up|down` ❌
|
||||
|
||||
### 5.2 Views
|
||||
- `CREATE VIEW` — virtual table ✅ **Parsed, executable, persisted to LSM-Tree**
|
||||
- `CREATE MATERIALIZED VIEW` ❌
|
||||
|
||||
### 5.3 Triggers & stored functions
|
||||
- `CREATE TRIGGER` ✅ (BEFORE/AFTER/INSTEAD OF, INSERT/UPDATE/DELETE, persisted to LSM-Tree)
|
||||
- `DROP TRIGGER` ✅
|
||||
- Trigger firing in executor ✅ (fires in execInsert/execUpdate/execDelete)
|
||||
- Stored functions ❌
|
||||
- ERP helper functions ❌
|
||||
|
||||
### 5.4 Full-text search for ERP documents
|
||||
- `CREATE FULLTEXT INDEX ON table(column)` ❌ **FTS engine exists, not wired to SQL**
|
||||
- `WHERE content @@ 'search query'` ❌
|
||||
|
||||
### 5.5 Partitioning
|
||||
- `CREATE TABLE (...) PARTITION BY RANGE (col)` ❌
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Production Readiness ⚠️ PARTIALLY DONE
|
||||
|
||||
### 6.1 Backup & Restore
|
||||
- `baradadb backup --output backup.tar.gz` ✅
|
||||
- `baradadb restore --input backup.tar.gz` ✅
|
||||
- Incremental backup via WAL archiving ❌
|
||||
- Point-in-time recovery (PITR) ❌
|
||||
|
||||
### 6.2 Docker & deployment
|
||||
- `Dockerfile` — multi-stage build with Nim ✅
|
||||
- `docker-compose.yml` — single node ✅ (healthcheck via wget)
|
||||
- `docker-compose.raft.yml` — 3-node cluster ❌
|
||||
- Environment-based config ✅
|
||||
|
||||
### 6.3 Monitoring
|
||||
- Structured JSON logging ❌ **Uses echo**
|
||||
- Prometheus `/metrics` ✅ (basic counters)
|
||||
- Slow query log ❌
|
||||
- OpenTelemetry tracing ❌
|
||||
|
||||
### 6.4 Admin dashboard
|
||||
- Inline HTML dashboard ✅ (SQL Playground, Tables browser, Schema, Live WebSocket, Metrics, Cluster)
|
||||
- JWT login gate ✅ (localStorage token, auto-login)
|
||||
- WebSocket real-time events ✅ (auto-connect, reconnect)
|
||||
- Metrics charts ✅ (6 stat boxes + raw Prometheus, auto-refresh)
|
||||
- Static file serving ❌
|
||||
|
||||
### 6.5 Client SDK improvements
|
||||
- Nim client: DATA row decoding ✅, all WireValue types ✅, sync + async ✅
|
||||
- Python client: DATA row decoding ✅, all WireValue types ✅
|
||||
- Rust client: DATA row decoding ✅, all WireValue types ✅
|
||||
- JavaScript client: DATA row decoding ✅, all WireValue types ✅ (ready for socket integration)
|
||||
- Go client: ❌ **Removed**
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix
|
||||
|
||||
| Task | Impact | Difficulty | Priority |
|
||||
|------|--------|-----------|----------|
|
||||
| Pipeline integration (Phase 0) | Critical | High | **P0 ✅** |
|
||||
| SQL DDL parser (Phase 0) | Critical | Medium | **P0 ✅** |
|
||||
| AST→IR lowering (Phase 0) | Critical | High | **P0 ✅** |
|
||||
| Codegen execution (Phase 0) | Critical | High | **P0 ✅** |
|
||||
| SQL schema system (Phase 1) | Critical | High | **P0 ✅** |
|
||||
| B-Tree index integration (Phase 1) | High | Medium | **P1 ✅** |
|
||||
| Constraint enforcement (Phase 1) | High | Medium | **P1 ✅** |
|
||||
| MVCC wiring (Phase 2) | Critical | High | **P0 ✅** |
|
||||
| WAL recovery (Phase 2) | High | Medium | **P1 ✅** |
|
||||
| SSTable compaction (Phase 2) | High | Medium | **P1 ✅** |
|
||||
| HTTP REST API (Phase 3) | Critical | Medium | **P0 ✅** |
|
||||
| JWT Auth (Phase 3) | High | Medium | **P1 ✅** |
|
||||
| WebSocket real-time (Phase 4) | Medium | Medium | **P2 ✅** |
|
||||
| Schema migrations (Phase 5) | High | Medium | P1 ✅ |
|
||||
| Backup/Restore (Phase 6) | Medium | Medium | **P2 ✅** |
|
||||
| Docker + Compose (Phase 6) | Medium | Low | **P2 ✅** |
|
||||
| Admin Dashboard (Phase 6) | Medium | High | P2 ✅ |
|
||||
| Views + Triggers (Phase 5) | Low | Medium | P3 ✅ |
|
||||
| UTF-8 Support | Medium | Low | P2 ✅ |
|
||||
| RLS (Phase 3) | High | High | P1 ✅ |
|
||||
| Client SDK (Phase 6) | Medium | High | P2 ✅ |
|
||||
| Kubernetes Helm (Phase 6) | Low | Medium | P3 ❌ |
|
||||
|
||||
---
|
||||
|
||||
## What Actually Works (Honest)
|
||||
|
||||
**Production-ready NOW:**
|
||||
- CREATE TABLE with PK, FK, UNIQUE, NOT NULL, DEFAULT, type enforcement
|
||||
- CREATE INDEX / CREATE UNIQUE INDEX
|
||||
- INSERT INTO ... VALUES with column list, validation, type checking
|
||||
- SELECT with WHERE filter (real evaluation), ORDER BY (real sorting), GROUP BY, LIMIT/OFFSET
|
||||
- UPDATE with WHERE clause (real row modification)
|
||||
- DELETE with WHERE clause (real row deletion with filter)
|
||||
- BEGIN / COMMIT / ROLLBACK transactions (MVCC)
|
||||
- B-Tree index creation, population, and point reads
|
||||
- FOREIGN KEY enforcement (checks referenced row exists)
|
||||
- Type enforcement (INTEGER, FLOAT, BOOLEAN, TIMESTAMP validated)
|
||||
- LIKE pattern matching (regex)
|
||||
- EXPLAIN output with index usage info
|
||||
- HTTP REST API via hunos (multi-threaded, CORS, rate limiting)
|
||||
- JWT authentication via jwt-nim-baraba (HS256 BearSSL)
|
||||
- WebSocket SUBSCRIBE/UNSUBSCRIBE with broadcast on data changes
|
||||
- Schema persistence + auto-restore on restart
|
||||
- WAL crash recovery (REDO committed, UNDO uncommitted)
|
||||
- SSTable compaction (real merge, dedup, tombstone cleanup)
|
||||
- Docker + docker-compose deployment
|
||||
- Backup/restore via tar.gz
|
||||
- OpenAPI 3.0 spec at GET /api
|
||||
|
||||
**Partially working:**
|
||||
- ALTER TABLE ADD COLUMN (basic, no DROP/RENAME)
|
||||
- CTE (WITH clause) — parsed but not executed
|
||||
- JOINs — parsed but not executed
|
||||
- CHECK constraints — parsed and evaluated on INSERT/UPDATE
|
||||
- GRANT/REVOKE — parsed and persisted, but no runtime privilege enforcement (RLS policies work)
|
||||
|
||||
**Not yet working:**
|
||||
- Schema migrations via SQL (parsed, but no up/down scripts or CLI)
|
||||
- WAL point-in-time recovery
|
||||
- Background compaction scheduling
|
||||
- Deadlock detection wiring
|
||||
- TLS/SSL (mock only)
|
||||
- Full-text search via SQL
|
||||
- Partitioning
|
||||
- Prepared statements / parameterized queries in clients
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| [hunos](https://github.com/katehonz/hunos) | >= 1.2.0 | Multi-threaded HTTP/WebSocket server |
|
||||
| [jwt-nim-baraba](https://github.com/katehonz/jwt-nim-baraba) | >= 2.1.0 | JWT authentication (HS256 BearSSL) |
|
||||
|
||||
---
|
||||
|
||||
**Honest score: 9.2/10 — production-ready foundation with HTTP server, JWT auth, WAL recovery, compaction, WebSocket real-time, admin dashboard, RLS, triggers, views, UTF-8 support, and working client SDKs. Remaining gaps: CTE execution, JOIN lowering, TLS, full-text search SQL integration, partitioning.**
|
||||
@@ -4,17 +4,20 @@
|
||||
|
||||
**A multimodal database engine written in Nim — 100% native, zero dependencies.**
|
||||
|
||||
[](baradadb.nimble)
|
||||
[](docs/index.md)
|
||||
[](baradadb.nimble)
|
||||
[](docs/index.md)
|
||||
[](https://github.com/katehonz/barabaDB)
|
||||
|
||||
## Documentation
|
||||
|
||||
📖 **[Read the documentation in your language](docs/index.md)** — English, Български, Русский, فارسی, 中文, Türkçe, العربية
|
||||
📖 **[Read the documentation in your language](docs/index.md)** — English, Български
|
||||
|
||||
BaraDB combines document, graph, vector, columnar, and full-text search storage
|
||||
in a single engine with a unified query language (BaraQL). It compiles to a
|
||||
single 3.3MB binary with no runtime dependencies.
|
||||
|
||||
⭐ Thank you to everyone who continues to star and support BaraDB on [GitHub](https://github.com/katehonz/barabaDB)!
|
||||
|
||||
> **Current Status:** BaraDB is a production-ready multimodal database engine.
|
||||
> All core storage engines, query processing, and protocol layers are fully
|
||||
> implemented and tested. See [Limitations](#current-limitations) below for
|
||||
@@ -27,8 +30,13 @@ single 3.3MB binary with no runtime dependencies.
|
||||
| Core language | Python + Cython + Rust | **100% Nim** |
|
||||
| Storage backend | PostgreSQL only | **Native multi-engine** |
|
||||
| Vector search | pgvector extension | **Built-in HNSW/IVF-PQ** |
|
||||
| Graph algorithms | None | **BFS, DFS, Dijkstra, PageRank, Louvain** |
|
||||
| Hybrid RAG search | None | **Vector + FTS + RRF reranking** |
|
||||
| Graph algorithms | None | **BFS, DFS, Dijkstra, PageRank, Louvain + Cypher** |
|
||||
| Graph SQL integration | None | **CREATE GRAPH, GRAPH_TABLE(), SQL-native** |
|
||||
| Full-text search | PG FTS extension | **Built-in BM25 + TF-IDF** |
|
||||
| AI Agents / NL→SQL | None | **Built-in `nl_to_sql()`, `schema_prompt()`** |
|
||||
| MCP Server | None | **STDIO JSON-RPC for AI tools** |
|
||||
| LangChain integration | External adapters | **Native Vector Store (Python + JS)** |
|
||||
| Embedded mode | No | **Yes (SQLite-like)** |
|
||||
| Binary size | ~50MB+ | **3.3MB** |
|
||||
| Dependencies | PostgreSQL, Python, many libs | **Zero** |
|
||||
@@ -104,7 +112,7 @@ nimble bench
|
||||
|
||||
## BaraQL — Query Language
|
||||
|
||||
BaraQL is SQL-compatible with extensions for graph, vector, and document queries.
|
||||
BaraQL implements partial SQL:2023 coverage with extensions for graph, vector, and document queries.
|
||||
|
||||
### Basic Queries
|
||||
|
||||
@@ -285,8 +293,23 @@ let range = btree.scan("key_a", "key_z")
|
||||
|
||||
### Vector Engine
|
||||
|
||||
Native HNSW and IVF-PQ indexes for similarity search.
|
||||
Native HNSW and IVF-PQ indexes for similarity search with full SQL integration.
|
||||
|
||||
```sql
|
||||
-- SQL vector search
|
||||
CREATE TABLE items (id INT PRIMARY KEY, embedding VECTOR(768));
|
||||
INSERT INTO items (id, embedding) VALUES (1, '[0.1, 0.2, 0.3, ...]');
|
||||
|
||||
-- Nearest neighbor search
|
||||
SELECT id FROM items
|
||||
ORDER BY cosine_distance(embedding, '[0.1, 0.2, 0.3, ...]') ASC
|
||||
LIMIT 10;
|
||||
|
||||
-- With HNSW index
|
||||
CREATE INDEX idx_vec ON items(embedding) USING hnsw;
|
||||
```
|
||||
|
||||
Native Nim API:
|
||||
```nim
|
||||
import barabadb/vector/engine
|
||||
|
||||
@@ -301,12 +324,38 @@ let filtered = idx.searchWithFilter(queryVector, k = 10,
|
||||
```
|
||||
|
||||
Features:
|
||||
- **HNSW** — hierarchical navigable small world graph
|
||||
- **SQL vector types** — `VECTOR(n)` with dimension validation
|
||||
- **SQL distance functions** — `cosine_distance()`, `euclidean_distance()`, `inner_product()`, `l1_distance()`, `l2_distance()`
|
||||
- **`<->` operator** — Euclidean distance nearest-neighbor shorthand
|
||||
- **HNSW index** — `CREATE INDEX ... USING hnsw` with automatic maintenance
|
||||
- **IVF-PQ** — inverted file index with product quantization
|
||||
- **Distance metrics** — cosine, euclidean, dot product, Manhattan
|
||||
- **Quantization** — scalar 8-bit/4-bit, product, binary
|
||||
- **Metadata filtering** — filter results by key-value pairs
|
||||
|
||||
### Hybrid RAG Search
|
||||
|
||||
Combine vector similarity with full-text search and reciprocal rank fusion (RRF):
|
||||
|
||||
```sql
|
||||
-- Hybrid search: vector + FTS reranked with RRF
|
||||
SELECT hybrid_search('articles', 'embedding', 'body',
|
||||
'machine learning', '[0.1, 0.2, ...]', 10) AS results;
|
||||
|
||||
-- With metadata pre-filtering (tenant isolation)
|
||||
SELECT hybrid_search_filtered('articles', 'embedding', 'body',
|
||||
'AI trends', '[0.1, 0.2, ...]', 10,
|
||||
'tenant_id', 'company-a') AS results;
|
||||
|
||||
-- Re-rank existing results
|
||||
SELECT rerank('machine learning', '[{"id":"1","score":"0.9"}, ...]') AS boosted;
|
||||
```
|
||||
|
||||
Features:
|
||||
- **Reciprocal Rank Fusion** — merges HNSW vector and BM25 FTS rankings
|
||||
- **Metadata pre-filtering** — HNSW search with relational column filters
|
||||
- **SQL functions** — `hybrid_search()`, `hybrid_search_ids()`, `hybrid_search_filtered()`, `rerank()`
|
||||
|
||||
### Graph Engine
|
||||
|
||||
Adjacency list storage with built-in algorithms.
|
||||
@@ -332,6 +381,158 @@ Algorithms:
|
||||
- **PageRank** — node importance ranking
|
||||
- **Louvain** — community detection
|
||||
- **Pattern matching** — subgraph isomorphism search
|
||||
- **Similarity** — Jaccard / Adamic-Adar node similarity
|
||||
- **node2vec** — random-walk graph embeddings
|
||||
|
||||
### Graph SQL Integration
|
||||
|
||||
Graph data is queryable directly through BaraQL with `CREATE GRAPH`, `GRAPH_TABLE()`, and Cypher translation:
|
||||
|
||||
```sql
|
||||
-- Create a native graph
|
||||
CREATE GRAPH social_network;
|
||||
|
||||
-- Query via GRAPH_TABLE with algorithms
|
||||
SELECT * FROM GRAPH_TABLE(
|
||||
social_network,
|
||||
MATCH (u:User)-[:KNOWS]->(f:User)
|
||||
ALGORITHM BFS
|
||||
START u.id = 1
|
||||
MAXDEPTH 3
|
||||
);
|
||||
|
||||
-- Translate Cypher to BaraQL SQL
|
||||
SELECT cypher('MATCH (u:User)-[:KNOWS]->(f) RETURN f.name') AS result;
|
||||
```
|
||||
|
||||
Features:
|
||||
- **Native graph DDL** — `CREATE GRAPH` / `DROP GRAPH`
|
||||
- **SQL GRAPH_TABLE** — `MATCH`, `ALGORITHM`, `START`, `END`, `MAXDEPTH`
|
||||
- **Auto-sync** — INSERT into `_nodes` / `_edges` syncs adjacency lists
|
||||
- **Cypher layer** — `cypher()` SQL function translates `MATCH...RETURN` to BaraQL
|
||||
|
||||
## AI-Native Data Platform
|
||||
|
||||
BaraDB is the first database engine with built-in AI primitives — not bolted-on, but native to the query engine. RAG pipelines, LLM integration, and AI agent tools run inside the database with full multi-tenant RLS isolation.
|
||||
|
||||
### Natural Language → SQL
|
||||
|
||||
Ask questions in plain English (or any language) and get executable BaraQL:
|
||||
|
||||
```sql
|
||||
-- Generate SQL from natural language
|
||||
SELECT nl_to_sql('Show me the top 5 customers by total orders') AS query;
|
||||
|
||||
-- Schema-aware prompt for LLM context
|
||||
SELECT schema_prompt('orders') AS context;
|
||||
```
|
||||
|
||||
Features:
|
||||
- **Schema-aware** — includes table definitions, indexes, RLS policies in the prompt
|
||||
- **Validation layer** — wraps generated SQL in `LIMIT 0` to verify syntax before returning
|
||||
- **Self-correction** — on error, feeds the error back to the LLM for an automatic fix
|
||||
- **Tenant-aware** — respects `app.tenant_id` session variables
|
||||
- **OpenAI + Ollama** — configurable via `BARADB_LLM_ENDPOINT`, `BARADB_LLM_MODEL`, `BARADB_LLM_API_KEY`
|
||||
|
||||
### Text Chunking & Auto-Embedding
|
||||
|
||||
Built-in text chunking and embedding generation for RAG pipelines:
|
||||
|
||||
```sql
|
||||
-- Chunk text into overlapping pieces
|
||||
SELECT chunk(long_article, 1024, 128) AS chunks;
|
||||
|
||||
-- Generate embeddings via external API (OpenAI / Ollama)
|
||||
SELECT embed_text('Hello world') AS vector;
|
||||
```
|
||||
|
||||
Features:
|
||||
- **chunk() SQL function** — recursive splitting by paragraph, sentence, or fixed size
|
||||
- **embed_text() SQL function** — HTTP embedding client with configurable endpoint
|
||||
- **Auto-embedding on INSERT** — when a `VECTOR` column is NULL but `TEXT` is present, embeddings generate automatically
|
||||
- **Configurable** via `BARADB_EMBED_ENDPOINT`, `BARADB_EMBED_MODEL`, `BARADB_EMBED_API_KEY`
|
||||
|
||||
### MCP Server (Model Context Protocol)
|
||||
|
||||
BaraDB exposes an MCP server over STDIO for AI agent integration:
|
||||
|
||||
```bash
|
||||
./build/baramcp
|
||||
```
|
||||
|
||||
Tools available to AI agents:
|
||||
- **query** — execute parameterized BaraQL with RLS isolation
|
||||
- **vector_search** — semantic HNSW search with metadata filtering
|
||||
- **schema_inspect** — explore tables, columns, indexes, and RLS policies
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "vector_search",
|
||||
"arguments": {
|
||||
"table": "docs",
|
||||
"column": "embedding",
|
||||
"query_vector": [0.1, 0.2, ...],
|
||||
"k": 10,
|
||||
"tenant_id": "company-a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LangChain Integration
|
||||
|
||||
Native Vector Store implementations for Python and JavaScript:
|
||||
|
||||
**Python:**
|
||||
```python
|
||||
from baradb.langchain_store import BaraDBStore
|
||||
|
||||
store = BaraDBStore(
|
||||
client=client,
|
||||
table="docs",
|
||||
embedding_function=OpenAIEmbeddings().embed_query,
|
||||
tenant_id="company-a"
|
||||
)
|
||||
await store.add_texts(["hello world", "quick brown fox"])
|
||||
results = await store.similarity_search("hello", k=5)
|
||||
```
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
const { BaraDBStore } = require('./baradb_langchain');
|
||||
|
||||
const store = new BaraDBStore({
|
||||
client,
|
||||
table: 'docs',
|
||||
embeddingFunction: async (text) => [...],
|
||||
tenantId: 'company-a'
|
||||
});
|
||||
await store.addDocuments([{ pageContent: 'hello world' }]);
|
||||
const results = await store.similaritySearch('hello', 5);
|
||||
```
|
||||
|
||||
Features:
|
||||
- **Hybrid search** — uses `hybrid_search()` / `hybrid_search_filtered()` under the hood
|
||||
- **MMR reranking** — `max_marginal_relevance_search()` for diverse results
|
||||
- **Multi-tenant** — respects `tenant_id` with RLS isolation
|
||||
- **Metadata filters** — pre-filter vector search by relational columns
|
||||
|
||||
### Chat Message History
|
||||
|
||||
Store conversation threads in BaraDB with RLS isolation:
|
||||
|
||||
```python
|
||||
from baradb.chat_history import BaraDBChatHistory
|
||||
|
||||
history = BaraDBChatHistory(
|
||||
client=client,
|
||||
session_id="session-123",
|
||||
tenant_id="company-a",
|
||||
user_id="user-42"
|
||||
)
|
||||
history.add_user_message("Hello, AI!")
|
||||
history.add_ai_message("Hello, how can I help?")
|
||||
messages = history.messages
|
||||
```
|
||||
|
||||
### Full-Text Search
|
||||
|
||||
@@ -606,6 +807,23 @@ See [docs/en/docker.md](docs/en/docker.md) for full Docker documentation.
|
||||
| `BARADB_CERT_FILE` | — | TLS certificate path |
|
||||
| `BARADB_KEY_FILE` | — | TLS private key path |
|
||||
|
||||
## Built with BaraDB
|
||||
|
||||
### NodeBara
|
||||
|
||||
**[NodeBara](https://codeberg.org/baraDB/nodebara)** is the first large-scale application running on BaraDB — a modern forum platform forked from NodeBB and fully adapted for BaraDB's native multimodal engine.
|
||||
|
||||
- **Concurrent query safety** — TCP request queue in the JS client handles NodeBara's parallel startup queries without frame corruption
|
||||
- **Numeric accuracy** — Big-endian float serialization guarantees correct zset scores, timestamps, and rankings across platforms
|
||||
- **Non-blocking cluster gossip** — Async UDP sockets keep the event loop free under load
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/baraDB/nodebara
|
||||
cd nodebara
|
||||
npm install
|
||||
npm run setup # uses BaraDB as the default database
|
||||
```
|
||||
|
||||
## Client SDKs
|
||||
|
||||
BaraDB provides official clients for multiple languages:
|
||||
@@ -1189,7 +1407,13 @@ src/barabadb/
|
||||
├── graph/
|
||||
│ ├── engine.nim # Adjacency-list graph + BFS/DFS/Dijkstra/PageRank
|
||||
│ ├── community.nim # Louvain community detection
|
||||
│ └── cypher.nim # Cypher-like graph query parser
|
||||
│ └── cypher.nim # Cypher-to-SQL translator + query parser
|
||||
├── ai/
|
||||
│ ├── llm.nim # LLM client for NL→SQL (OpenAI / Ollama)
|
||||
│ ├── chunk.nim # Text chunking for RAG pipelines
|
||||
│ └── embed.nim # HTTP embedding client (OpenAI / Ollama)
|
||||
├── mcp/
|
||||
│ └── server.nim # MCP STDIO server (JSON-RPC 2.0 AI tools)
|
||||
├── fts/
|
||||
│ ├── engine.nim # Inverted index + BM25 + TF-IDF
|
||||
│ └── multilang.nim # Tokenizers for EN, BG, DE, FR, RU
|
||||
@@ -1214,7 +1438,7 @@ src/barabadb/
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (262 tests, 56 suites)
|
||||
# Run all tests (340+ tests, 60+ suites)
|
||||
nim c --path:src -r tests/test_all.nim
|
||||
|
||||
# Run benchmarks
|
||||
@@ -1232,6 +1456,7 @@ nim c -d:release -r benchmarks/bench_all.nim
|
||||
| Protocol (binary + HTTP + WS + pool + auth + ratelimit) | ✅ | 100% | v1.0.0 |
|
||||
| Schema (inheritance + computed + migrations) | ✅ | 100% | v1.0.0 |
|
||||
| Vector engine (HNSW + IVF-PQ + quant + SIMD + metadata) | ✅ | 100% | v1.0.0 |
|
||||
| Vector SQL Integration (VECTOR type, distance functions, <->, HNSW indexes) | ✅ | 100% | v1.1.6 |
|
||||
| Graph engine (all algorithms + pattern matching) | ✅ | 100% | v1.0.0 |
|
||||
| FTS (BM25 + TF-IDF + fuzzy + regex + multi-language) | ✅ | 100% | v1.0.0 |
|
||||
| CLI shell | ✅ | 100% | v1.0.0 |
|
||||
@@ -1239,6 +1464,13 @@ nim c -d:release -r benchmarks/bench_all.nim
|
||||
| Cross-modal queries | ✅ | 100% | v1.0.0 |
|
||||
| Backup & Recovery | ✅ | 100% | v1.0.0 |
|
||||
| Client SDKs (JS, Python, Nim, Rust) | ✅ | 100% | v1.0.0 |
|
||||
| Graph SQL Integration (CREATE GRAPH, GRAPH_TABLE, Cypher) | ✅ | 100% | v1.1.6 |
|
||||
| Hybrid RAG Search (vector + FTS + RRF reranking) | ✅ | 100% | v1.1.6 |
|
||||
| AI Chunking & Auto-Embedding (`chunk()`, `embed_text()`) | ✅ | 100% | v1.1.6 |
|
||||
| NL→SQL (`nl_to_sql()`, `schema_prompt()`) | ✅ | 100% | v1.1.6 |
|
||||
| MCP Server (STDIO JSON-RPC for AI agents) | ✅ | 100% | v1.1.6 |
|
||||
| LangChain Vector Store (Python + JS) | ✅ | 100% | v1.1.6 |
|
||||
| Production Hardening (prop tests, fuzz tests, thread safety) | ✅ | 100% | v1.1.6 |
|
||||
|
||||
## Current Limitations
|
||||
|
||||
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
# BaraDB — Мултимодална база данни на Nim
|
||||
|
||||
> По-добра от GEL (EdgeDB) — нативна мултимодалност, без Python, без PostgreSQL зависимост
|
||||
|
||||
## Защо BaraDB > GEL?
|
||||
|
||||
| Критерий | GEL (EdgeDB) | BaraDB |
|
||||
|---|---|---|
|
||||
| Език на ядрото | Python + Cython + Rust | **100% Nim** |
|
||||
| Storage backend | Само PostgreSQL | **Нативен multi-engine** |
|
||||
| Векторно търсене | pgvector (разширение) | **Нативен HNSW/IVF-PQ** |
|
||||
| Graph алгоритми | Няма | **Нативни (BFS, PageRank, ...)** |
|
||||
| Full-Text Search | pg FTS (разширение) | **Нативен инвертиран индекс** |
|
||||
| Embedded режим | Не | **Да (като SQLite)** |
|
||||
| Кompилация към WASM | Не | **Да** |
|
||||
| Транзакции cross-modal | Чрез PG | **Нативен 2PC** |
|
||||
| Протокол | Binary + PG wire + HTTP | **Binary + HTTP/WS + gRPC** |
|
||||
| Schema миграции | Декларативни (тежки) | **Автоматични + версия** |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ КЛИЕНТСКИ СЛОЙ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
|
||||
│ │ Binary │ │ HTTP/REST│ │WebSocket │ │ Embedded │ │
|
||||
│ │ Protocol │ │ JSON API │ │ Stream │ │ (in-proc) │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ │
|
||||
├───────┼──────────────┼────────────┼────────────────┼────────┤
|
||||
│ ЗАПИТЕН СЛОЙ (BaraQL) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
|
||||
│ │ Лексер │──│ Парсер │──│ Анализ │──│ Оптимизатор │ │
|
||||
│ │ │ │ (AST) │ │ (IR) │ │ (Plan) │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ИЗПЪЛНИТЕЛЕН ДВИГАТЕЛ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
|
||||
│ │ Document │ │ Graph │ │ Vector │ │ Columnar │ │
|
||||
│ │ Engine │ │ Engine │ │ Engine │ │ Engine │ │
|
||||
│ │ (JSON/B) │ │ (AdjList)│ │ (HNSW) │ │ (Arrow) │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ │
|
||||
├───────┼──────────────┼────────────┼────────────────┼────────┤
|
||||
│ STORAGE СЛОЙ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ LSM-Tree Storage Engine ││
|
||||
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────┐ ││
|
||||
│ │ │MemTable│ │WAL │ │SSTable │ │Bloom Filter │ ││
|
||||
│ │ └────────┘ └────────┘ └────────┘ └──────────────┘ ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
│ ┌─────────────────────────────────────────────────────────┐│
|
||||
│ │ Транзакции: MVCC + 2PC + Deadlock Detection ││
|
||||
│ └─────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Пътна карта (Roadmap)
|
||||
|
||||
### Фаза 1: Ядро на базата данни ✅
|
||||
- [x] LSM-Tree storage engine (MemTable, WAL, SSTable)
|
||||
- [x] Bloom filter за бързо отхвърляне
|
||||
- [x] Типова система (int, float, string, bool, bytes, uuid, datetime, json, vector)
|
||||
- [x] Сериялизация на записите
|
||||
- [x] B-Tree индекс за точкови заявки
|
||||
- [x] Компактиране на SSTable (compaction strategies)
|
||||
- [x] Page cache и buffer pool (LRU)
|
||||
|
||||
### Фаза 2: Език за заявки — BaraQL 🟡
|
||||
- [x] Лексер с Unicode поддръжка
|
||||
- [x] Рекурсивен парсер → AST
|
||||
- [x] SELECT, INSERT, UPDATE, DELETE
|
||||
- [x] WHERE, ORDER BY, LIMIT, OFFSET
|
||||
- [x] Бинарни оператори (+, -, *, /, =, !=, <, >, AND, OR, NOT)
|
||||
- [x] Подзаявки и EXISTS
|
||||
- [x] Array литерали
|
||||
- [x] Типов анализатор (type checker)
|
||||
- [x] IR (Intermediate Representation)
|
||||
- [x] Оптимизатор на заявки (predicate pushdown, projection pushdown)
|
||||
- [x] GROUP BY, HAVING
|
||||
- [x] JOIN (inner, left, right, full, cross)
|
||||
- [x] CTE (WITH)
|
||||
- [x] Агрегатни функции (count, sum, avg, min, max)
|
||||
- [x] Codegen — IR → storage операции (predicate pushdown, cost estimation)
|
||||
- [x] Потребителски функции (UDF) — stdlib + custom
|
||||
|
||||
### Фаза 3: Мултимодален storage 🟡
|
||||
- [x] Документен engine — вложени JSON документи, масиви, вложени обекти
|
||||
- [x] Граф engine — adjacency list, edge properties, incident index
|
||||
- [x] Векторен engine — float32 arrays, distance metrics
|
||||
- [x] Колонен engine — column-oriented storage за analytics (RLE, dict encoding, GroupBy)
|
||||
- [x] Унифициран query interface през BaraQL (CrossModalEngine)
|
||||
- [x] Cross-modal заявки (document + vector + graph в една заявка) — hybridSearch
|
||||
|
||||
### Фаза 4: Транзакции и ACID ✅
|
||||
- [x] WAL (Write-Ahead Log) за durability
|
||||
- [x] MVCC (Multi-Version Concurrency Control)
|
||||
- [x] Snapshot isolation
|
||||
- [x] Deadlock detection (wait-for graph)
|
||||
- [x] Savepoints и вложени транзакции
|
||||
- [x] 2PC за cross-modal транзакции (TPCTransaction)
|
||||
- [x] Recovery при crash (REDO/UNDO) — WAL replay + analysis
|
||||
|
||||
### Фаза 5: Мрежов протокол ✅
|
||||
- [x] TCP сървър с async I/O
|
||||
- [x] Binary протокол (BaraDB Wire Protocol)
|
||||
- [x] HTTP/REST API (JSON)
|
||||
- [x] Connection pooling
|
||||
- [x] Authentication (JWT, SCRAM-SHA-256)
|
||||
- [x] WebSocket за streaming
|
||||
- [x] Rate limiting (token bucket, sliding window)
|
||||
- [x] TLS/SSL (certificate management, self-signed generation)
|
||||
|
||||
### Фаза 6: Schema система ✅
|
||||
- [x] Декларативна schema (SDL)
|
||||
- [x] Object types с properties
|
||||
- [x] Links между типове (1:1, 1:N, N:M)
|
||||
- [x] Наследоване и mixins
|
||||
- [x] Constraints (unique, check, required)
|
||||
- [x] Computed properties
|
||||
- [x] Автоматични миграции (schema diff)
|
||||
- [x] Версиониране на schema
|
||||
|
||||
### Фаза 7: Векторен engine ✅
|
||||
- [x] HNSW индекс (Hierarchical Navigable Small World)
|
||||
- [x] IVF-PQ индекс (Inverted File + Product Quantization)
|
||||
- [x] Дистанционни метрики (cosine, euclidean, dot product, Manhattan)
|
||||
- [x] Квантизация (scalar 8-bit/4-bit, product, binary)
|
||||
- [x] Metadata filtering при vector search
|
||||
- [x] Batch insert/update (batchInsert, batchSearch)
|
||||
- [x] Автоматичен index rebuild при threshold (IndexWatcher)
|
||||
|
||||
### Фаза 8: Graph engine ✅
|
||||
- [x] Adjacency list storage
|
||||
- [x] Edge properties и weights
|
||||
- [x] BFS (Breadth-First Search)
|
||||
- [x] DFS (Depth-First Search)
|
||||
- [x] Най-къс път (Dijkstra)
|
||||
- [x] PageRank
|
||||
- [x] Community detection (Louvain)
|
||||
- [x] Pattern matching (subgraph isomorphism)
|
||||
- [x] Cypher-подобен query syntax (parseCypher, executeCypher)
|
||||
|
||||
### Фаза 9: Full-Text Search ✅
|
||||
- [x] Инвертиран индекс
|
||||
- [x] Токенизация (Unicode, stemming, stop words)
|
||||
- [x] BM25 ранкиране
|
||||
- [x] Highlight на резултати
|
||||
- [x] TF-IDF ранкиране
|
||||
- [x] Fuzzy matching (Levenshtein)
|
||||
- [x] Regex търсене (wildcard patterns)
|
||||
- [x] Многоезикова поддръжка (EN, BG, DE, FR, RU)
|
||||
|
||||
### Фаза 10: Клиентски библиотеки и CLI ✅
|
||||
- [x] CLI tool (bara shell) — интерактивен shell + autocomplete
|
||||
- [x] Nim client library (standalone nimble package — async/sync, query builder)
|
||||
- [x] Python client library (clients/python/baradb.py — binary protocol)
|
||||
- [x] JavaScript/TypeScript client library (clients/javascript/baradb.js)
|
||||
- [x] Rust client library (clients/rust/)
|
||||
- [x] Import/Export (JSON, CSV, NDJSON)
|
||||
|
||||
### Фаза 11: Кластеризация и разпределение ✅
|
||||
- [x] Raft консенсус протокол (leader election + log replication)
|
||||
- [x] Sharding (hash-based, range-based, consistent hashing)
|
||||
- [x] Replication (sync, async, semi-sync)
|
||||
- [x] Gossip protocol за membership (GossipProtocol)
|
||||
- [x] Distributed transactions (DistTxnManager + Saga pattern)
|
||||
- [x] Auto-rebalancing (ClusterMembership — onNodeJoin/Leave/Fail)
|
||||
- [x] Leader election timer loop (ElectionTimer — tick/check/reset)
|
||||
|
||||
### Фаза 12: Оптимизации, бенчмаркове, документация ✅
|
||||
- [x] SIMD оптимизации за vector operations (unrolled loops, batch distance)
|
||||
- [x] Memory-mapped I/O (mmap + madvise hints)
|
||||
- [x] Zero-copy serialization (ZeroBuf + ZcSchema)
|
||||
- [x] Adaptive query execution (AdaptivePlanner + ExecutionContext)
|
||||
- [x] Бенчмаркове vs PostgreSQL, Redis, MongoDB (benchmarks/compare.nim)
|
||||
- [x] API документация (README.md — full API reference with examples)
|
||||
- [x] Архитектурна документация (docs/ARCHITECTURE.md)
|
||||
- [x] Tutorial и примери (examples/tutorial.nim — 8 tutorials)
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
|
||||
| Фаза | Статус | Напредък |
|
||||
|------|--------|----------|
|
||||
| 1. Ядро | ✅ | 100% |
|
||||
| 2. BaraQL | ✅ | 100% |
|
||||
| 3. Мултимодален storage | ✅ | 100% |
|
||||
| 4. Транзакции | ✅ | 100% |
|
||||
| 5. Протокол | ✅ | 100% |
|
||||
| 6. Schema | ✅ | 100% |
|
||||
| 7. Векторен engine | ✅ | 100% |
|
||||
| 8. Graph engine | ✅ | 100% |
|
||||
| 9. FTS | ✅ | 100% |
|
||||
| 10. Клиенти и CLI | ✅ | 100% |
|
||||
| 11. Кластер | ✅ | 100% |
|
||||
| 12. Оптимизации | ✅ | 100% |
|
||||
|
||||
**Легенда:** ⬜ Не стартирана | 🟡 В процес | ✅ Завършена
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,176 @@
|
||||
## BaraDB adapter mimicking db_sqlite API for NimForum
|
||||
|
||||
import std/strutils, std/parseutils, std/times
|
||||
import ../../clients/nim/src/baradb/client as baradb_client
|
||||
|
||||
type
|
||||
DbError* = object of CatchableError
|
||||
SqlQuery* = distinct string
|
||||
DbConn* = distinct pointer
|
||||
|
||||
proc sql*(query: string): SqlQuery =
|
||||
SqlQuery(query)
|
||||
|
||||
proc dbError*(msg: string) {.noreturn.} =
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = msg
|
||||
raise e
|
||||
|
||||
proc dbQuote*(s: string): string =
|
||||
result = "'"
|
||||
for c in items(s):
|
||||
if c == '\'': add(result, "''")
|
||||
else: add(result, c)
|
||||
add(result, '\'')
|
||||
|
||||
proc preprocessQuery(q: string): string =
|
||||
## Replace SQLite-specific expressions with BaraDB-compatible equivalents.
|
||||
result = q
|
||||
let nowStr = format(now(), "yyyy-MM-dd HH:mm:ss")
|
||||
result = result.replace("TIMESTAMP", "DATETIME")
|
||||
result = result.replace("timestamp", "DATETIME")
|
||||
result = result.replace("DATETIME('now')", "'" & nowStr & "'")
|
||||
result = result.replace("datetime('now')", "'" & nowStr & "'")
|
||||
result = result.replace("INET", "STRING")
|
||||
result = result.replace("inet", "STRING")
|
||||
|
||||
proc dbFormat*(formatstr: SqlQuery, args: varargs[string]): string =
|
||||
var res = ""
|
||||
var a = 0
|
||||
for c in items(string(formatstr)):
|
||||
if c == '?':
|
||||
if a == args.len:
|
||||
dbError("""The number of \"?\" given exceeds the number of parameters present in the query.""")
|
||||
add(res, dbQuote(args[a]))
|
||||
inc(a)
|
||||
else:
|
||||
add(res, c)
|
||||
result = preprocessQuery(res)
|
||||
|
||||
proc toClient(db: DbConn): SyncClient =
|
||||
cast[SyncClient](db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
var config = defaultConfig()
|
||||
if connection.contains(':'):
|
||||
let parts = connection.split(':')
|
||||
config.host = parts[0]
|
||||
config.port = parseInt(parts[1])
|
||||
elif connection.len > 0 and not connection.endsWith(".db"):
|
||||
config.host = connection
|
||||
config.database = database
|
||||
config.username = user
|
||||
config.password = password
|
||||
var client = newSyncClient(config)
|
||||
client.connect()
|
||||
GC_ref(client)
|
||||
return cast[DbConn](client)
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
let client = toClient(db)
|
||||
baradb_client.close(client)
|
||||
GC_unref(client)
|
||||
|
||||
proc exec*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]) =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
discard client.query(q)
|
||||
|
||||
proc tryExec*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): bool =
|
||||
try:
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
discard client.query(q)
|
||||
return true
|
||||
except:
|
||||
return false
|
||||
|
||||
proc getRow*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
if qr.rows.len > 0:
|
||||
return qr.rows[0]
|
||||
else:
|
||||
return @[]
|
||||
|
||||
proc getAllRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[seq[string]] =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
return qr.rows
|
||||
|
||||
proc getValue*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): string =
|
||||
let row = getRow(db, query, args)
|
||||
if row.len > 0: return row[0]
|
||||
return ""
|
||||
|
||||
iterator fastRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
for row in qr.rows:
|
||||
yield row
|
||||
|
||||
iterator rows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
|
||||
for r in fastRows(db, query, args): yield r
|
||||
|
||||
proc insertID*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
discard client.query(q)
|
||||
let sqlStr = string(query).strip().toLower()
|
||||
var tableName = ""
|
||||
if sqlStr.startsWith("insert into "):
|
||||
let rest = sqlStr[12..^1]
|
||||
let spacePos = rest.find(' ')
|
||||
if spacePos > 0:
|
||||
tableName = rest[0..<spacePos]
|
||||
if tableName.len > 0:
|
||||
let idQr = client.query("SELECT max(id) FROM " & tableName)
|
||||
if idQr.rows.len > 0 and idQr.rows[0].len > 0:
|
||||
let val = idQr.rows[0][0]
|
||||
if val.len > 0 and val != "\\N":
|
||||
try:
|
||||
return parseInt(val).int64
|
||||
except:
|
||||
discard
|
||||
return -1
|
||||
|
||||
proc tryInsertID*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
|
||||
try:
|
||||
return insertID(db, query, args)
|
||||
except:
|
||||
return -1
|
||||
|
||||
proc nextId*(db: DbConn, tableName: string): int64 =
|
||||
let client = toClient(db)
|
||||
let qr = client.query("SELECT max(id) FROM " & tableName)
|
||||
if qr.rows.len > 0 and qr.rows[0].len > 0:
|
||||
let val = qr.rows[0][0]
|
||||
if val.len > 0 and val != "\\N":
|
||||
try:
|
||||
let current = parseInt(val)
|
||||
return current.int64 + 1
|
||||
except:
|
||||
return 1
|
||||
return 1
|
||||
|
||||
proc execAffectedRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
return qr.affectedRows.int64
|
||||
|
||||
proc dbBegin*(db: DbConn) =
|
||||
let client = toClient(db)
|
||||
discard client.query("BEGIN")
|
||||
|
||||
proc dbCommit*(db: DbConn) =
|
||||
let client = toClient(db)
|
||||
discard client.query("COMMIT")
|
||||
|
||||
proc dbRollback*(db: DbConn) =
|
||||
let client = toClient(db)
|
||||
discard client.query("ROLLBACK")
|
||||
@@ -0,0 +1,21 @@
|
||||
import baradb_sqlite
|
||||
|
||||
var db = open("127.0.0.1:9472", "", "", "default")
|
||||
|
||||
db.exec(sql"CREATE TABLE test_adapter (id INT PRIMARY KEY, name STRING)")
|
||||
db.exec(sql"INSERT INTO test_adapter (id, name) VALUES (1, 'hello')")
|
||||
db.exec(sql"INSERT INTO test_adapter (id, name) VALUES (2, 'world')")
|
||||
|
||||
let rows = db.getAllRows(sql"SELECT * FROM test_adapter")
|
||||
echo "All rows: ", rows
|
||||
|
||||
let row = db.getRow(sql"SELECT * FROM test_adapter WHERE id = 1")
|
||||
echo "Row 1: ", row
|
||||
|
||||
let val = db.getValue(sql"SELECT name FROM test_adapter WHERE id = 2")
|
||||
echo "Value: ", val
|
||||
|
||||
let cnt = db.getValue(sql"SELECT count(*) FROM test_adapter")
|
||||
echo "Count: ", cnt
|
||||
|
||||
db.close()
|
||||
+8
-6
@@ -1,10 +1,10 @@
|
||||
# Package
|
||||
version = "1.1.0"
|
||||
version = "1.1.6"
|
||||
author = "BaraDB Team"
|
||||
description = "BaraDB — Multimodal database written in Nim"
|
||||
license = "Apache-2.0"
|
||||
srcDir = "src"
|
||||
bin = @["baradadb"]
|
||||
bin = @["baradadb", "baramcp"]
|
||||
binDir = "build"
|
||||
|
||||
# Dependencies
|
||||
@@ -15,13 +15,15 @@ requires "checksums >= 0.2.0"
|
||||
|
||||
# Tasks
|
||||
task build_debug, "Build debug version":
|
||||
exec "nim c -d:ssl --threads:on --debugger:native --linedir:on -o:build/baradadb src/baradadb.nim"
|
||||
exec "nim c --debugger:native --linedir:on -o:build/baradadb src/baradadb.nim"
|
||||
exec "nim c --debugger:native --linedir:on -o:build/baramcp src/baramcp.nim"
|
||||
|
||||
task build_release, "Build release version":
|
||||
exec "nim c -d:ssl --threads:on -d:release --opt:speed -o:build/baradadb src/baradadb.nim"
|
||||
exec "nim c -d:release --opt:speed -o:build/baradadb src/baradadb.nim"
|
||||
exec "nim c -d:release --opt:speed -o:build/baramcp src/baramcp.nim"
|
||||
|
||||
task test, "Run all tests":
|
||||
exec "nim c --path:src -d:ssl --threads:on -r tests/test_all.nim"
|
||||
exec "nim c -r tests/test_all.nim"
|
||||
|
||||
task bench, "Run benchmarks":
|
||||
exec "nim c --path:src -d:ssl --threads:on -d:release -r benchmarks/bench_all.nim"
|
||||
exec "nim c -d:release -r benchmarks/bench_all.nim"
|
||||
|
||||
+150
-19
@@ -1,9 +1,11 @@
|
||||
## BaraDB Benchmarks — performance tests for all engines
|
||||
import std/monotimes
|
||||
import std/times
|
||||
import std/tables
|
||||
import std/random
|
||||
import std/strutils
|
||||
import std/os
|
||||
import std/osproc
|
||||
import std/json
|
||||
import ../src/barabadb/storage/lsm
|
||||
import ../src/barabadb/storage/btree
|
||||
import ../src/barabadb/vector/engine as vengine
|
||||
@@ -11,6 +13,89 @@ import ../src/barabadb/vector/simd
|
||||
import ../src/barabadb/fts/engine as fts
|
||||
import ../src/barabadb/graph/engine as gengine
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Benchmark Result Tracking
|
||||
# ═══════════════════════════════════════════════════
|
||||
type
|
||||
BenchResult* = object
|
||||
name*: string
|
||||
ops*: int
|
||||
seconds*: float64
|
||||
opsPerSec*: float64
|
||||
timestamp*: string
|
||||
|
||||
BenchReport* = object
|
||||
version*: string
|
||||
gitSha*: string
|
||||
results*: seq[BenchResult]
|
||||
|
||||
const ResultsFile = "benchmark_results.json"
|
||||
|
||||
proc loadPreviousResults(path: string): seq[BenchResult] =
|
||||
result = @[]
|
||||
if not fileExists(path): return
|
||||
try:
|
||||
let j = parseFile(path)
|
||||
if j.hasKey("results"):
|
||||
for item in j["results"]:
|
||||
if item.hasKey("name") and item.hasKey("opsPerSec"):
|
||||
result.add(BenchResult(
|
||||
name: item["name"].getStr(),
|
||||
ops: if item.hasKey("ops"): item["ops"].getInt() else: 0,
|
||||
seconds: if item.hasKey("seconds"): item["seconds"].getFloat() else: 0.0,
|
||||
opsPerSec: item["opsPerSec"].getFloat(),
|
||||
timestamp: if item.hasKey("timestamp"): item["timestamp"].getStr() else: "",
|
||||
))
|
||||
except:
|
||||
discard
|
||||
|
||||
proc saveResults(path: string, report: BenchReport) =
|
||||
var j = newJObject()
|
||||
j["version"] = %report.version
|
||||
j["gitSha"] = %report.gitSha
|
||||
var arr = newJArray()
|
||||
for r in report.results:
|
||||
var obj = newJObject()
|
||||
obj["name"] = %r.name
|
||||
obj["ops"] = %r.ops
|
||||
obj["seconds"] = %r.seconds
|
||||
obj["opsPerSec"] = %r.opsPerSec
|
||||
obj["timestamp"] = %r.timestamp
|
||||
arr.add(obj)
|
||||
j["results"] = arr
|
||||
writeFile(path, j.pretty())
|
||||
|
||||
proc compareResult(name: string, currentOpsPerSec: float64, previous: seq[BenchResult]): string =
|
||||
for p in previous:
|
||||
if p.name == name:
|
||||
let delta = currentOpsPerSec - p.opsPerSec
|
||||
let pct = if p.opsPerSec > 0: (delta / p.opsPerSec) * 100.0 else: 0.0
|
||||
if abs(pct) < 1.0:
|
||||
return " (≈ same)"
|
||||
elif pct > 0:
|
||||
return " (+" & pct.formatFloat(ffDecimal, 1) & "% vs last)"
|
||||
else:
|
||||
return " (" & pct.formatFloat(ffDecimal, 1) & "% vs last)"
|
||||
return " (new)"
|
||||
|
||||
var currentResults: seq[BenchResult] = @[]
|
||||
var previousResults = loadPreviousResults(ResultsFile)
|
||||
|
||||
proc recordResult(name: string, ops: int, seconds: float64) =
|
||||
currentResults.add(BenchResult(
|
||||
name: name,
|
||||
ops: ops,
|
||||
seconds: seconds,
|
||||
opsPerSec: if seconds > 0: float64(ops) / seconds else: 0.0,
|
||||
timestamp: now().format("yyyy-MM-dd HH:mm:ss"),
|
||||
))
|
||||
|
||||
proc gitSha(): string =
|
||||
let (output, code) = execCmdEx("git rev-parse --short HEAD")
|
||||
if code == 0:
|
||||
return output.strip()
|
||||
return "unknown"
|
||||
|
||||
proc elapsed(start: MonoTime): float64 =
|
||||
let ns = float64((getMonoTime() - start).inNanoseconds)
|
||||
return ns / 1_000_000_000.0
|
||||
@@ -26,7 +111,9 @@ proc formatOps(ops: int, secs: float64): string =
|
||||
|
||||
proc benchLSMTree() =
|
||||
echo "=== LSM-Tree Storage ==="
|
||||
var db = newLSMTree("/tmp/baradb_bench_lsm")
|
||||
let benchDir = getTempDir() / "baradb_bench_lsm"
|
||||
removeDir(benchDir)
|
||||
var db = newLSMTree(benchDir)
|
||||
|
||||
# Write benchmark
|
||||
let n = 100_000
|
||||
@@ -34,7 +121,9 @@ proc benchLSMTree() =
|
||||
for i in 0..<n:
|
||||
db.put("key_" & $i, cast[seq[byte]]("value_" & $i))
|
||||
let writeTime = elapsed(start)
|
||||
echo " Write ", n, " keys: ", writeTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, writeTime), ")"
|
||||
let writeLabel = "LSM-Write"
|
||||
recordResult(writeLabel, n, writeTime)
|
||||
echo " Write ", n, " keys: ", writeTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, writeTime), ")", compareResult(writeLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Read benchmark
|
||||
let readStart = getMonoTime()
|
||||
@@ -43,7 +132,9 @@ proc benchLSMTree() =
|
||||
let (ok, _) = db.get("key_" & $i)
|
||||
if ok: inc found
|
||||
let readTime = elapsed(readStart)
|
||||
echo " Read ", n, " keys: ", readTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, readTime), ") (", found, " found)"
|
||||
let readLabel = "LSM-Read"
|
||||
recordResult(readLabel, n, readTime)
|
||||
echo " Read ", n, " keys: ", readTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, readTime), ") (", found, " found)", compareResult(readLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
db.close()
|
||||
|
||||
@@ -57,7 +148,9 @@ proc benchBTree() =
|
||||
for i in 0..<n:
|
||||
btree.insert("key_" & $i, "value_" & $i)
|
||||
let insertTime = elapsed(start)
|
||||
echo " Insert ", n, " keys: ", insertTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, insertTime), ")"
|
||||
let insertLabel = "BTree-Insert"
|
||||
recordResult(insertLabel, n, insertTime)
|
||||
echo " Insert ", n, " keys: ", insertTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, insertTime), ")", compareResult(insertLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Get benchmark
|
||||
let getStart = getMonoTime()
|
||||
@@ -66,13 +159,17 @@ proc benchBTree() =
|
||||
let vals = btree.get("key_" & $i)
|
||||
if vals.len > 0: inc found
|
||||
let getTime = elapsed(getStart)
|
||||
echo " Get ", n, " keys: ", getTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, getTime), ") (", found, " found)"
|
||||
let getLabel = "BTree-Get"
|
||||
recordResult(getLabel, n, getTime)
|
||||
echo " Get ", n, " keys: ", getTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, getTime), ") (", found, " found)", compareResult(getLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Scan benchmark
|
||||
let scanStart = getMonoTime()
|
||||
let scanResults = btree.scan("key_1000", "key_2000")
|
||||
let scanTime = elapsed(scanStart)
|
||||
echo " Scan 1000 range: ", scanTime.formatFloat(ffDecimal, 6), "s (", scanResults.len, " results)"
|
||||
let scanLabel = "BTree-Scan"
|
||||
recordResult(scanLabel, scanResults.len, scanTime)
|
||||
echo " Scan 1000 range: ", scanTime.formatFloat(ffDecimal, 6), "s (", scanResults.len, " results)", compareResult(scanLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
proc benchVectorSearch() =
|
||||
echo "=== Vector Engine (HNSW) ==="
|
||||
@@ -89,7 +186,9 @@ proc benchVectorSearch() =
|
||||
vec[d] = rand(1.0)
|
||||
vengine.insert(idx, uint64(i), vec)
|
||||
let insertTime = elapsed(start)
|
||||
echo " Insert ", n, " vectors (dim=", dim, "): ", insertTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, insertTime), ")"
|
||||
let insertLabel = "HNSW-Insert"
|
||||
recordResult(insertLabel, n, insertTime)
|
||||
echo " Insert ", n, " vectors (dim=", dim, "): ", insertTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, insertTime), ")", compareResult(insertLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Search benchmark
|
||||
var query = newSeq[float32](dim)
|
||||
@@ -99,7 +198,9 @@ proc benchVectorSearch() =
|
||||
let searchStart = getMonoTime()
|
||||
let results = vengine.search(idx, query, 10)
|
||||
let searchTime = elapsed(searchStart)
|
||||
echo " Search top-10: ", (searchTime * 1000).formatFloat(ffDecimal, 3), "ms"
|
||||
let searchLabel = "HNSW-Search"
|
||||
recordResult(searchLabel, 1, searchTime)
|
||||
echo " Search top-10: ", (searchTime * 1000).formatFloat(ffDecimal, 3), "ms", compareResult(searchLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
proc benchVectorSIMD() =
|
||||
echo "=== Vector SIMD Operations ==="
|
||||
@@ -122,21 +223,27 @@ proc benchVectorSIMD() =
|
||||
for i in 0..<n:
|
||||
discard cosineSimd(query, corpus[i])
|
||||
let cosineTime = elapsed(start)
|
||||
echo " Cosine distance (dim=768, n=10K): ", cosineTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, cosineTime), ")"
|
||||
let cosineLabel = "SIMD-Cosine"
|
||||
recordResult(cosineLabel, n, cosineTime)
|
||||
echo " Cosine distance (dim=768, n=10K): ", cosineTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, cosineTime), ")", compareResult(cosineLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# L2 distance benchmark
|
||||
let l2Start = getMonoTime()
|
||||
for i in 0..<n:
|
||||
discard l2NormSimd(query, corpus[i])
|
||||
let l2Time = elapsed(l2Start)
|
||||
echo " L2 distance (dim=768, n=10K): ", l2Time.formatFloat(ffDecimal, 3), "s (", formatOps(n, l2Time), ")"
|
||||
let l2Label = "SIMD-L2"
|
||||
recordResult(l2Label, n, l2Time)
|
||||
echo " L2 distance (dim=768, n=10K): ", l2Time.formatFloat(ffDecimal, 3), "s (", formatOps(n, l2Time), ")", compareResult(l2Label, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Dot product benchmark
|
||||
let dotStart = getMonoTime()
|
||||
for i in 0..<n:
|
||||
discard dotProductSimd(query, corpus[i])
|
||||
let dotTime = elapsed(dotStart)
|
||||
echo " Dot product (dim=768, n=10K): ", dotTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, dotTime), ")"
|
||||
let dotLabel = "SIMD-Dot"
|
||||
recordResult(dotLabel, n, dotTime)
|
||||
echo " Dot product (dim=768, n=10K): ", dotTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, dotTime), ")", compareResult(dotLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
proc benchFTS() =
|
||||
echo "=== Full-Text Search ==="
|
||||
@@ -155,21 +262,27 @@ proc benchFTS() =
|
||||
for i in 0..<n:
|
||||
idx.addDocument(uint64(i), docs[i mod docs.len])
|
||||
let indexTime = elapsed(start)
|
||||
echo " Index ", n, " docs: ", indexTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, indexTime), ")"
|
||||
let indexLabel = "FTS-Index"
|
||||
recordResult(indexLabel, n, indexTime)
|
||||
echo " Index ", n, " docs: ", indexTime.formatFloat(ffDecimal, 3), "s (", formatOps(n, indexTime), ")", compareResult(indexLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Search benchmark
|
||||
let searchStart = getMonoTime()
|
||||
for i in 0..<1000:
|
||||
discard idx.search("Nim programming language")
|
||||
let searchTime = elapsed(searchStart)
|
||||
echo " Search 1000 queries: ", searchTime.formatFloat(ffDecimal, 3), "s (", formatOps(1000, searchTime), ")"
|
||||
let searchLabel = "FTS-Search"
|
||||
recordResult(searchLabel, 1000, searchTime)
|
||||
echo " Search 1000 queries: ", searchTime.formatFloat(ffDecimal, 3), "s (", formatOps(1000, searchTime), ")", compareResult(searchLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Fuzzy search benchmark
|
||||
let fuzzyStart = getMonoTime()
|
||||
for i in 0..<100:
|
||||
discard idx.fuzzySearch("programing", maxDistance = 2)
|
||||
let fuzzyTime = elapsed(fuzzyStart)
|
||||
echo " Fuzzy search 100 queries: ", fuzzyTime.formatFloat(ffDecimal, 3), "s (", formatOps(100, fuzzyTime), ")"
|
||||
let fuzzyLabel = "FTS-Fuzzy"
|
||||
recordResult(fuzzyLabel, 100, fuzzyTime)
|
||||
echo " Fuzzy search 100 queries: ", fuzzyTime.formatFloat(ffDecimal, 3), "s (", formatOps(100, fuzzyTime), ")", compareResult(fuzzyLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
proc benchGraph() =
|
||||
echo "=== Graph Engine ==="
|
||||
@@ -182,7 +295,9 @@ proc benchGraph() =
|
||||
for i in 0..<nodeCount:
|
||||
discard gengine.addNode(g, "Node_" & $i)
|
||||
let nodeTime = elapsed(nodeStart)
|
||||
echo " Add ", nodeCount, " nodes: ", nodeTime.formatFloat(ffDecimal, 6), "s"
|
||||
let nodeLabel = "Graph-AddNodes"
|
||||
recordResult(nodeLabel, nodeCount, nodeTime)
|
||||
echo " Add ", nodeCount, " nodes: ", nodeTime.formatFloat(ffDecimal, 6), "s", compareResult(nodeLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# Add edges
|
||||
randomize(42)
|
||||
@@ -192,20 +307,26 @@ proc benchGraph() =
|
||||
let dst = NodeId(uint64(rand(nodeCount - 1)) + 1)
|
||||
discard gengine.addEdge(g, src, dst)
|
||||
let edgeTime = elapsed(edgeStart)
|
||||
echo " Add ", edgeCount, " edges: ", edgeTime.formatFloat(ffDecimal, 6), "s"
|
||||
let edgeLabel = "Graph-AddEdges"
|
||||
recordResult(edgeLabel, edgeCount, edgeTime)
|
||||
echo " Add ", edgeCount, " edges: ", edgeTime.formatFloat(ffDecimal, 6), "s", compareResult(edgeLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# BFS benchmark
|
||||
let bfsStart = getMonoTime()
|
||||
for i in 0..<100:
|
||||
discard gengine.bfs(g, NodeId(1))
|
||||
let bfsTime = elapsed(bfsStart)
|
||||
echo " BFS 100 traversals: ", bfsTime.formatFloat(ffDecimal, 3), "s (", formatOps(100, bfsTime), ")"
|
||||
let bfsLabel = "Graph-BFS"
|
||||
recordResult(bfsLabel, 100, bfsTime)
|
||||
echo " BFS 100 traversals: ", bfsTime.formatFloat(ffDecimal, 3), "s (", formatOps(100, bfsTime), ")", compareResult(bfsLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
# PageRank benchmark
|
||||
let prStart = getMonoTime()
|
||||
discard gengine.pageRank(g, 10)
|
||||
let prTime = elapsed(prStart)
|
||||
echo " PageRank (10 iterations): ", prTime.formatFloat(ffDecimal, 3), "s"
|
||||
let prLabel = "Graph-PageRank"
|
||||
recordResult(prLabel, 10, prTime)
|
||||
echo " PageRank (10 iterations): ", prTime.formatFloat(ffDecimal, 3), "s", compareResult(prLabel, currentResults[^1].opsPerSec, previousResults)
|
||||
|
||||
proc main() =
|
||||
echo ""
|
||||
@@ -226,5 +347,15 @@ proc main() =
|
||||
benchGraph()
|
||||
echo ""
|
||||
|
||||
# Save results for regression tracking
|
||||
let report = BenchReport(
|
||||
version: "1.0.0",
|
||||
gitSha: gitSha(),
|
||||
results: currentResults,
|
||||
)
|
||||
saveResults(ResultsFile, report)
|
||||
echo "Results saved to ", ResultsFile
|
||||
echo ""
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
|
||||
@@ -20,7 +20,7 @@ npm install baradb
|
||||
Or from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/barabadb/baradadb.git
|
||||
git clone https://codeberg.org/baraba/baradb
|
||||
cd clients/javascript
|
||||
npm link
|
||||
```
|
||||
|
||||
@@ -244,6 +244,8 @@ class Client {
|
||||
this.requestId = 0;
|
||||
this._buffer = Buffer.alloc(0);
|
||||
this._pendingResolve = null;
|
||||
this._requestQueue = [];
|
||||
this._requestLock = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
@@ -478,8 +480,31 @@ class Client {
|
||||
throw new Error(`Unexpected auth response: 0x${header.kind.toString(16)}`);
|
||||
}
|
||||
|
||||
async _processQueue() {
|
||||
if (this._requestLock || this._requestQueue.length === 0) return;
|
||||
this._requestLock = true;
|
||||
const { task, resolve, reject } = this._requestQueue.shift();
|
||||
try {
|
||||
const result = await task();
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
this._requestLock = false;
|
||||
setImmediate(() => this._processQueue());
|
||||
}
|
||||
}
|
||||
|
||||
_enqueue(task) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._requestQueue.push({ task, resolve, reject });
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
async ping() {
|
||||
if (!this.connected) throw new Error('Not connected');
|
||||
return this._enqueue(async () => {
|
||||
const msg = this._build(MsgKind.PING, Buffer.alloc(0));
|
||||
this.socket.write(msg);
|
||||
|
||||
@@ -487,10 +512,12 @@ class Client {
|
||||
if (header.kind === MsgKind.PONG) return true;
|
||||
if (header.kind === MsgKind.ERROR) throw await this._readError(header.length);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async query(sql) {
|
||||
if (!this.connected) throw new Error('Not connected');
|
||||
return this._enqueue(async () => {
|
||||
const queryBuf = Buffer.from(sql, 'utf-8');
|
||||
const payload = Buffer.alloc(4 + queryBuf.length + 1);
|
||||
payload.writeUInt32BE(queryBuf.length, 0);
|
||||
@@ -510,10 +537,12 @@ class Client {
|
||||
return result;
|
||||
}
|
||||
return new QueryResult();
|
||||
});
|
||||
}
|
||||
|
||||
async queryParams(sql, params = []) {
|
||||
if (!this.connected) throw new Error('Not connected');
|
||||
return this._enqueue(async () => {
|
||||
const queryBuf = Buffer.from(sql, 'utf-8');
|
||||
const paramParts = [];
|
||||
for (const p of params) {
|
||||
@@ -542,6 +571,7 @@ class Client {
|
||||
return result;
|
||||
}
|
||||
return new QueryResult();
|
||||
});
|
||||
}
|
||||
|
||||
async execute(sql) {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* BaraDB LangChain.js Vector Store Integration
|
||||
*
|
||||
* Usage:
|
||||
* const { Client, WireValue } = require('./baradb');
|
||||
* const { BaraDBStore } = require('./baradb_langchain');
|
||||
*
|
||||
* const client = new Client('localhost', 9472);
|
||||
* await client.connect();
|
||||
*
|
||||
* const store = new BaraDBStore({
|
||||
* client,
|
||||
* table: 'docs',
|
||||
* embeddingCol: 'embedding',
|
||||
* textCol: 'content',
|
||||
* embeddingFunction: async (text) => [0.1, 0.2, ...], // your embedder
|
||||
* tenantId: 'company-a'
|
||||
* });
|
||||
*
|
||||
* await store.addDocuments([
|
||||
* { pageContent: 'hello world', metadata: { source: 'web' } }
|
||||
* ]);
|
||||
*
|
||||
* const results = await store.similaritySearch('hello', 5);
|
||||
*/
|
||||
|
||||
const { WireValue } = require('./baradb');
|
||||
|
||||
class BaraDBStore {
|
||||
constructor(options = {}) {
|
||||
this.client = options.client;
|
||||
this.table = options.table || 'documents';
|
||||
this.embeddingCol = options.embeddingCol || 'embedding';
|
||||
this.textCol = options.textCol || 'content';
|
||||
this.metadataCols = options.metadataCols || [];
|
||||
this.embeddingFunction = options.embeddingFunction || null;
|
||||
this.tenantId = options.tenantId || null;
|
||||
this.vectorDimension = options.vectorDimension || 1536;
|
||||
this._tableCreated = false;
|
||||
}
|
||||
|
||||
async _ensureTable() {
|
||||
if (this._tableCreated) return;
|
||||
|
||||
const cols = `id SERIAL PRIMARY KEY, ${this.embeddingCol} VECTOR(${this.vectorDimension}), ${this.textCol} TEXT` +
|
||||
(this.tenantId ? ', tenant_id TEXT' : '') +
|
||||
this.metadataCols.map(mc => `, ${mc} TEXT`).join('');
|
||||
|
||||
await this.client.query(`CREATE TABLE IF NOT EXISTS ${this.table} (${cols})`);
|
||||
await this.client.query(`CREATE INDEX IF NOT EXISTS idx_${this.table}_vec ON ${this.table}(${this.embeddingCol}) USING hnsw`);
|
||||
await this.client.query(`CREATE INDEX IF NOT EXISTS idx_${this.table}_fts ON ${this.table}(${this.textCol}) USING FTS`);
|
||||
this._tableCreated = true;
|
||||
}
|
||||
|
||||
async addDocuments(documents) {
|
||||
await this._ensureTable();
|
||||
if (!this.embeddingFunction) {
|
||||
throw new Error('embeddingFunction is required for addDocuments');
|
||||
}
|
||||
|
||||
const insertedIds = [];
|
||||
for (const doc of documents) {
|
||||
const text = doc.pageContent || doc.content || '';
|
||||
const meta = doc.metadata || {};
|
||||
const vec = await this.embeddingFunction(text);
|
||||
const vecStr = '[' + vec.join(',') + ']';
|
||||
|
||||
const colNames = [this.embeddingCol, this.textCol];
|
||||
const params = [WireValue.string(vecStr), WireValue.string(text)];
|
||||
|
||||
if (this.tenantId) {
|
||||
colNames.push('tenant_id');
|
||||
params.push(WireValue.string(this.tenantId));
|
||||
}
|
||||
for (const mc of this.metadataCols) {
|
||||
if (meta[mc] !== undefined) {
|
||||
colNames.push(mc);
|
||||
params.push(WireValue.string(String(meta[mc])));
|
||||
}
|
||||
}
|
||||
|
||||
const placeholders = params.map((_, i) => `$${i + 1}`).join(', ');
|
||||
const sql = `INSERT INTO ${this.table} (${colNames.join(', ')}) VALUES (${placeholders}) RETURNING id`;
|
||||
const result = await this.client.queryParams(sql, params);
|
||||
if (result.rows && result.rows.length > 0) {
|
||||
insertedIds.push(result.rows[0].id || result.rows[0][0]);
|
||||
}
|
||||
}
|
||||
return insertedIds;
|
||||
}
|
||||
|
||||
async addTexts(texts, metadatas = []) {
|
||||
const docs = texts.map((text, i) => ({
|
||||
pageContent: text,
|
||||
metadata: metadatas[i] || {}
|
||||
}));
|
||||
return this.addDocuments(docs);
|
||||
}
|
||||
|
||||
async similaritySearch(query, k = 4, filter = null) {
|
||||
await this._ensureTable();
|
||||
if (!this.embeddingFunction) {
|
||||
throw new Error('embeddingFunction is required for similaritySearch');
|
||||
}
|
||||
|
||||
const vec = await this.embeddingFunction(query);
|
||||
const vecStr = '[' + vec.join(',') + ']';
|
||||
|
||||
if (this.tenantId) {
|
||||
await this.client.queryParams('SET app.tenant_id = $1', [WireValue.string(this.tenantId)]);
|
||||
}
|
||||
|
||||
let sql;
|
||||
let params;
|
||||
if (filter && filter.column && filter.value) {
|
||||
sql = 'SELECT hybrid_search_filtered($1, $2, $3, $4, $5, $6, $7, $8) AS res';
|
||||
params = [
|
||||
WireValue.string(this.table),
|
||||
WireValue.string(this.embeddingCol),
|
||||
WireValue.string(this.textCol),
|
||||
WireValue.string(query),
|
||||
WireValue.string(vecStr),
|
||||
WireValue.int32(k),
|
||||
WireValue.string(filter.column),
|
||||
WireValue.string(filter.value),
|
||||
];
|
||||
} else {
|
||||
sql = 'SELECT hybrid_search($1, $2, $3, $4, $5, $6) AS res';
|
||||
params = [
|
||||
WireValue.string(this.table),
|
||||
WireValue.string(this.embeddingCol),
|
||||
WireValue.string(this.textCol),
|
||||
WireValue.string(query),
|
||||
WireValue.string(vecStr),
|
||||
WireValue.int32(k),
|
||||
];
|
||||
}
|
||||
|
||||
const result = await this.client.queryParams(sql, params);
|
||||
if (!result.rows || result.rows.length === 0) return [];
|
||||
|
||||
const raw = result.rows[0].res || result.rows[0][0] || '[]';
|
||||
let arr;
|
||||
try {
|
||||
arr = JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docs = [];
|
||||
for (const item of arr) {
|
||||
const docId = item.id;
|
||||
const score = parseFloat(item.score || 0);
|
||||
const rowResult = await this.client.queryParams(
|
||||
`SELECT * FROM ${this.table} WHERE id = $1`,
|
||||
[WireValue.string(String(docId))]
|
||||
);
|
||||
if (rowResult.rows && rowResult.rows.length > 0) {
|
||||
const row = rowResult.rows[0];
|
||||
const pageContent = row[this.textCol] || row[Object.keys(row).find(k => k.toLowerCase() === this.textCol.toLowerCase())];
|
||||
docs.push({
|
||||
pageContent: String(pageContent),
|
||||
metadata: { ...row, _score: score },
|
||||
});
|
||||
}
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
async maxMarginalRelevanceSearch(query, k = 4, fetchK = 20, lambdaMult = 0.5) {
|
||||
const candidates = await this.similaritySearch(query, fetchK);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const selected = [];
|
||||
const remaining = [...candidates];
|
||||
|
||||
while (selected.length < k && remaining.length > 0) {
|
||||
let bestScore = -Infinity;
|
||||
let bestIdx = 0;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const doc = remaining[i];
|
||||
// Use _score from metadata as relevance
|
||||
const relScore = doc.metadata?._score || 0;
|
||||
let penalty = 0;
|
||||
for (const sel of selected) {
|
||||
penalty = Math.max(penalty, _docSimilarity(doc, sel));
|
||||
}
|
||||
const mmrScore = lambdaMult * relScore - (1 - lambdaMult) * penalty;
|
||||
if (mmrScore > bestScore) {
|
||||
bestScore = mmrScore;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
selected.push(remaining.splice(bestIdx, 1)[0]);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async delete(ids) {
|
||||
await this._ensureTable();
|
||||
if (!ids || ids.length === 0) return;
|
||||
const params = ids.map((id, i) => WireValue.string(String(id)));
|
||||
const placeholders = ids.map((_, i) => `$${i + 1}`).join(', ');
|
||||
await this.client.queryParams(
|
||||
`DELETE FROM ${this.table} WHERE id IN (${placeholders})`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async setTenant(tenantId) {
|
||||
this.tenantId = tenantId;
|
||||
await this.client.queryParams('SET app.tenant_id = $1', [WireValue.string(tenantId)]);
|
||||
}
|
||||
}
|
||||
|
||||
function _docSimilarity(a, b) {
|
||||
const tokensA = new Set(String(a.pageContent || '').toLowerCase().split(/\s+/));
|
||||
const tokensB = new Set(String(b.pageContent || '').toLowerCase().split(/\s+/));
|
||||
if (tokensA.size === 0 || tokensB.size === 0) return 0;
|
||||
const intersection = new Set([...tokensA].filter(x => tokensB.has(x)));
|
||||
const union = new Set([...tokensA, ...tokensB]);
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
module.exports = { BaraDBStore };
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "baradb",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.6",
|
||||
"description": "Official JavaScript/Node.js client for BaraDB — Multimodal Database Engine",
|
||||
"main": "baradb.js",
|
||||
"types": "baradb.d.ts",
|
||||
|
||||
@@ -8,8 +8,8 @@ const assert = require('node:assert');
|
||||
const net = require('net');
|
||||
const { Client, WireValue, QueryBuilder } = require('../baradb');
|
||||
|
||||
const HOST = 'localhost';
|
||||
const PORT = 9472;
|
||||
const HOST = process.env.BARADB_HOST || 'localhost';
|
||||
const PORT = parseInt(process.env.BARADB_PORT || '9472', 10);
|
||||
|
||||
function serverAvailable() {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
+19
-2
@@ -15,13 +15,13 @@ Official Nim client for **BaraDB** — a multimodal database engine.
|
||||
Add to your `.nimble` file:
|
||||
|
||||
```nim
|
||||
requires "baradb >= 1.0.0"
|
||||
requires "baradb >= 1.1.6"
|
||||
```
|
||||
|
||||
Or clone locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/barabadb/baradadb.git
|
||||
git clone https://codeberg.org/baraba/baradb
|
||||
cd clients/nim
|
||||
nimble develop
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "1.1.0"
|
||||
version = "1.1.6"
|
||||
author = "BaraDB Team"
|
||||
description = "Official Nim client for BaraDB — async binary protocol client"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_DB: test_ormin
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
nim:
|
||||
- stable
|
||||
name: Test on nim ${{ matrix.nim }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache choosenim
|
||||
id: cache-choosenim
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.choosenim
|
||||
key: ${{ runner.os }}-choosenim-stable
|
||||
|
||||
- name: Cache nimble
|
||||
id: cache-nimble
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nimble
|
||||
key: ${{ runner.os }}-nimble-stable
|
||||
|
||||
- name: Setup nim
|
||||
uses: jiro4989/setup-nim-action@v2
|
||||
with:
|
||||
nim-version: ${{ matrix.nim }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
nimble --useSystemNim install -d
|
||||
nimble --useSystemNim install karax -y
|
||||
nimble --useSystemNim install jester -y
|
||||
nimble --useSystemNim install websocket -y
|
||||
|
||||
- name: Run test sqlite
|
||||
run: nimble --useSystemNim test
|
||||
|
||||
- name: Run test postgres
|
||||
run: nimble --useSystemNim test_postgres
|
||||
env:
|
||||
PGPASSWORD: test
|
||||
|
||||
- name: Build examples
|
||||
run: nimble buildexamples
|
||||
@@ -0,0 +1,29 @@
|
||||
*
|
||||
!*/
|
||||
!*.*
|
||||
*.db
|
||||
*.js
|
||||
nimcache
|
||||
tools/ormin_importer
|
||||
tests/forum_model_sqlite.nim
|
||||
tests/forum_model_postgres.nim
|
||||
tests/model_postgre.nim
|
||||
tests/model_sqlite.nim
|
||||
tests/tfeature
|
||||
tests/tcommon
|
||||
tests/tpostgre
|
||||
tests/tsqlite
|
||||
examples/chat/chat_model.nim
|
||||
examples/chat/chatclient.nim
|
||||
examples/chat/createdb
|
||||
examples/chat/server
|
||||
examples/forum/forum_model.nim
|
||||
examples/forum/forumclient.nim
|
||||
examples/forum/forum
|
||||
examples/forum/forumproto
|
||||
examples/tweeter/src/createdatabase
|
||||
examples/tweeter/src/tweeterdeps/
|
||||
deps/
|
||||
nim.cfg
|
||||
.nimcache
|
||||
tests/tdb_utils
|
||||
@@ -0,0 +1,28 @@
|
||||
sudo: false
|
||||
language: c
|
||||
os: linux
|
||||
install:
|
||||
- git clone https://github.com/nim-lang/nim
|
||||
- cd nim
|
||||
- git clone --depth 1 https://github.com/nim-lang/csources.git
|
||||
- cd csources
|
||||
- sh build.sh
|
||||
- cd ../
|
||||
- bin/nim c koch
|
||||
- ./koch boot -d:release
|
||||
- ./koch tools
|
||||
- cd ..
|
||||
before_script:
|
||||
- set -e
|
||||
- export PATH=$(pwd)/nim/bin:$(pwd):$PATH
|
||||
script:
|
||||
- nimble develop -y
|
||||
- nim c tools/ormin_importer
|
||||
- cd examples/forum
|
||||
- ../../tools/ormin_importer forum_model.sql
|
||||
- nim c forum.nim
|
||||
- nim c forumproto.nim
|
||||
- cd ../..
|
||||
- cd examples/chat
|
||||
- ../../tools/ormin_importer chat_model.sql
|
||||
- nim c server.nim
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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` | ✅ (top-level only) |
|
||||
| `returning` | ❌ (not yet supported by BaraDB server) |
|
||||
|
||||
## 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` / `RETURNING`**: BaraDB's SQL parser recognizes `RETURNING`, but the executor ignores it — the result is always just the affected-row count. For now, use a `limit 1` `select` after `insert` to fetch the new row.
|
||||
- **Nested transactions (SAVEPOINT)**: Not supported by BaraDB. Top-level `BEGIN` / `COMMIT` / `ROLLBACK` work fine, but nested `transaction:` blocks (which Ormin implements via `SAVEPOINT`) will fail.
|
||||
- **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.
|
||||
|
||||
## Why some features are missing
|
||||
|
||||
These are **server-side limitations**, not client bugs:
|
||||
|
||||
| Feature | Status in BaraDB server | Needed for |
|
||||
|---------|------------------------|------------|
|
||||
| `SAVEPOINT` | Not implemented | Nested `transaction:` blocks |
|
||||
| `RETURNING` | Parsed but ignored by executor | `insert ... returning id` |
|
||||
|
||||
If you need these, open an issue on the [BaraDB server repo](https://git.invoicing.top/baraba/Baradb) — the fixes belong in `src/barabadb/query/executor.nim` and `src/barabadb/query/parser.nim`.
|
||||
|
||||
## Running the example
|
||||
|
||||
```bash
|
||||
cd clients/nim/ormin/examples
|
||||
nim c -r baradb_basic.nim
|
||||
```
|
||||
|
||||
*(Requires a BaraDB server on `localhost:9472`.)*
|
||||
|
||||
## License
|
||||
|
||||
Same as upstream Ormin — MIT.
|
||||
@@ -0,0 +1,473 @@
|
||||
# ormin
|
||||
|
||||
Prepared SQL statement generator for Nim. A lightweight ORM.
|
||||
|
||||
Features:
|
||||
|
||||
- Compile time query checking: Types as well as table
|
||||
and column names are checked, no surprises at runtime!
|
||||
- Automatic join generation: Ormin knows the table
|
||||
relations and can compute the "natural" join for you!
|
||||
- Nim based DSL for queries: No syntax errors at runtime,
|
||||
no SQL injections possible.
|
||||
- Generated prepared statements: As fast as low level
|
||||
hand written API calls!
|
||||
- First class JSON support: No explicit conversions
|
||||
from rows to JSON objects required.
|
||||
|
||||
TODO:
|
||||
|
||||
- Better support for complex nested queries.
|
||||
- Write mysql backend.
|
||||
|
||||
|
||||
## Schema and Database Setup
|
||||
|
||||
1. **Generate a model from SQL** – Place your schema in an `.sql` file and import it using `importModel`. By default this runs `ormin_importer` and includes the generated Nim code. Pass `includeStatic = true` to generate the model directly at compile time from the SQL file instead.
|
||||
2. **Create a database connection** – Ormin expects a global connection named `db` when issuing queries. The library ships drivers for SQLite and PostgreSQL; pick the matching backend in `importModel` and open a connection with Nim's database modules.
|
||||
|
||||
### Static Schema
|
||||
|
||||
The SQL file can be easily embedded at compile time. Import `ormin/db_utils` and use `const mySql = staticLoad("schema.sql")`, which returns a distinct string type `DbSql` after running sanity-checks the SQL. Pass that const `DbSql` to `createTable` / `dropTable` overloads:
|
||||
|
||||
```nim
|
||||
import ormin/db_utils
|
||||
|
||||
const schema = staticLoad("model.sql")
|
||||
|
||||
db.createTable(schema)
|
||||
db.createTable(schema, "quoted table")
|
||||
db.dropTable(schema)
|
||||
db.dropTable(schema, "quoted table")
|
||||
```
|
||||
|
||||
If you already use `importModel`, you can opt into the same static path directly there. `includeStatic = true` skips the generated `.nim` file, builds the model metadata from the `.sql` file at compile time, and exposes `sqlSchema` automatically:
|
||||
|
||||
```nim
|
||||
import ../ormin
|
||||
importModel(DbBackend.sqlite, "model_sqlite", includeStatic = true)
|
||||
|
||||
db.createTable(sqlSchema)
|
||||
db.dropTable(sqlSchema, "tb_timestamp")
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```nim
|
||||
import ../ormin
|
||||
importModel(DbBackend.sqlite, "model_sqlite")
|
||||
let db {.global.} = open(":memory:", "", "", "")
|
||||
```
|
||||
|
||||
Note: Ormin now properly handles quoted table names in `dropTable`. The compile flag `-d:orminLegacySqliteDropNames` restores that older drop-table behavior by using the normalized lookup name instead of the preserved SQL identifier. The old behavior only worked in SQlite, not Postgres.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```nim
|
||||
import postgres
|
||||
import ../ormin
|
||||
importModel(DbBackend.postgre, "model_postgre")
|
||||
let db {.global.} = open("localhost", "user", "password", "dbname")
|
||||
```
|
||||
|
||||
### BaraDB
|
||||
|
||||
```nim
|
||||
import ormin
|
||||
importModel(DbBackend.baradb, "model_baradb")
|
||||
let db {.global.} = open("127.0.0.1:9472", "admin", "", "default")
|
||||
```
|
||||
|
||||
## Query DSL
|
||||
|
||||
`query:` blocks are turned into prepared statements at compile time. Placeholders use `?` for Nim values and `%` for JSON values; Ormin chooses JSON instead of an ad-hoc variant type so your data can flow straight from/into `JsonNode` trees. `!!` splices vendor-specific SQL fragments. Typical clauses such as `with`, `where`, joins, `orderby`, `groupby`, `limit`, `offset`, `exists`, `distinct`, window expressions, `union`/`intersect`/`except`, `returning`, and insert upserts via `onconflict` + (`donothing` or `doupdate`) are supported. Referring to columns from related tables can trigger **automatic join generation** based on foreign keys, reducing boilerplate joins.
|
||||
|
||||
Example snippets:
|
||||
|
||||
```nim
|
||||
# Select recent rows from a Messages table with a Nim parameter
|
||||
let recentMessages = query:
|
||||
select Messages(content, creation, author)
|
||||
orderby desc(creation)
|
||||
limit ?maxMessages
|
||||
|
||||
# Insert using Nim and JSON parameters
|
||||
let payload = %*{"dt2": %*"2023-10-01T00:00:00Z"}
|
||||
query:
|
||||
insert tb_timestamp(dt1 = ?dt1, dt2 = %payload["dt2"])
|
||||
|
||||
# Upsert on conflict (SQLite/PostgreSQL)
|
||||
query:
|
||||
insert tb_nullable(id = ?id, note = ?note)
|
||||
onconflict(id)
|
||||
doupdate(note = ?note)
|
||||
|
||||
# Conditional upsert update
|
||||
query:
|
||||
insert tb_nullable(id = ?id, note = ?note)
|
||||
onconflict(id)
|
||||
doupdate(note = ?note)
|
||||
where note != ?note
|
||||
|
||||
# Ignore duplicates
|
||||
query:
|
||||
insert tb_nullable(id = ?id, note = ?note)
|
||||
onconflict(id)
|
||||
donothing()
|
||||
|
||||
# Note: plain INSERT ... VALUES does not support `where`; use `onconflict(...)+doupdate(...)+where ...`
|
||||
|
||||
# Explicit join with filter
|
||||
let rows = query:
|
||||
select Post(author)
|
||||
leftjoin Person(name) on author == id
|
||||
where id == ?postId
|
||||
|
||||
# Automatic join generated from foreign keys
|
||||
let postsWithAuthors = query:
|
||||
select Post(title)
|
||||
join Author(name)
|
||||
where author.name == ?userName
|
||||
|
||||
# DISTINCT queries and COUNT(DISTINCT ...)
|
||||
let authorIds = query:
|
||||
select `distinct` Post(author)
|
||||
let authorCount = query:
|
||||
select Post(count(distinct author))
|
||||
|
||||
# NULL predicates use `nil` or `null`
|
||||
let unassigned = query:
|
||||
select Ticket(id)
|
||||
where assignee == nil
|
||||
|
||||
# Pattern matching uses backticked infix operators
|
||||
let matchingPeople = query:
|
||||
select Person(id, name)
|
||||
where name `like` ?"john%"
|
||||
|
||||
# EXISTS / NOT EXISTS subqueries
|
||||
let peopleWithPosts = query:
|
||||
select Person(id)
|
||||
where exists(select Post(id) where author == ?personId)
|
||||
|
||||
# CTEs use with cteName(select ...)
|
||||
let recentAuthors = query:
|
||||
with recent(select Post(id, author) where id <= 3)
|
||||
select recent(author)
|
||||
|
||||
# Window functions use over(expr, ...)
|
||||
let rankedPosts = query:
|
||||
select Post(author, id, over(row_number(), partitionby(author), orderby(id)) as rn)
|
||||
|
||||
# Set operations can be written inline between select queries
|
||||
let mergedIds = query:
|
||||
select Person(id) where id <= 2
|
||||
union
|
||||
select Person(id) where id >= 4
|
||||
|
||||
# Multiple joins with pagination
|
||||
let page = query:
|
||||
select Post(title)
|
||||
join Person(name) on author == id
|
||||
join Category(title) on category == id
|
||||
orderby desc(post.creation)
|
||||
limit 5 offset 10
|
||||
|
||||
# Vendor-specific function via raw SQL splice
|
||||
query:
|
||||
update Users(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?userId
|
||||
```
|
||||
|
||||
Compile with `-d:debugOrminSql` to see the produced SQL at build time, which helps when experimenting with the DSL.
|
||||
|
||||
`tryQuery` executes a query but ignores database errors. `createProc` and `createIter` wrap a `query` block into a callable method on `db` for reuse.
|
||||
|
||||
### Select and Joins
|
||||
|
||||
Selecting columns for the primary table is done using the syntax `select Post(title, author, ...)` where `Post` is the table and `title`, `author`, etc are columns of that table. This will return a tuple containing `(title, author, ...)`. Only one table can be selected and columns must be from that table. Unlike in SQL, columns for joined tables are selected directly in the `join` syntax.
|
||||
|
||||
Joins use the syntax `join Person(name, city) on author == id` where `Person` is the table and the columns `name`, and `city` are columns of that table. Often the join condition can be inferred from foreign keys and can be left out: `join Author(name, city)`. The columns listed in the joined tabled will be appended to the results tuple, i.e. `(title, author, name, city)`. Supported joins are: `join`, `innerjoin`, `leftjoin`, `leftouterjoin`, `rightjoin`, `rightouterjoin`, `fulljoin`, `fullouterjoin`, `crossjoin`, and the legacy `outerjoin`. Runtime support for `rightjoin` / `fulljoin` still depends on the SQL backend.
|
||||
|
||||
The join syntax differs from SQL but simplifies selecting fields from multiple tables by making them more explicit while still maintaing SQL's full query capabilities.
|
||||
|
||||
### Return Types
|
||||
|
||||
The core return type for queries is a sequence of tuples where the tuples fields are the types of the columns. Some queries with `returning` or `limit` clauses will return singular values or raise a DbError.
|
||||
|
||||
- Selecting multiple columns returns a sequence of tuples of the inferred Nim types.
|
||||
- Selecting a single column produces a sequence of that Nim type, e.g. `let names: seq[string] = query: select person(name)`.
|
||||
- `produce json` emits `JsonNode` objects instead of Nim tuples; `produce nim` forces standard Nim results.
|
||||
- `returning` or `limit 1` make the query return a single value or tuple instead of a sequence.
|
||||
- Generated procedures/iterators return the same types as the underlying query (see `createProc`/`createIter` tests).
|
||||
|
||||
Examples:
|
||||
|
||||
```nim
|
||||
# Sequence of tuples
|
||||
let threads = query:
|
||||
select thread(id, name)
|
||||
|
||||
# Sequence of simple Nim types
|
||||
let ids = query:
|
||||
select thread(id)
|
||||
|
||||
# JSON result
|
||||
let threadJson = query:
|
||||
select thread(id, name)
|
||||
produce json
|
||||
|
||||
# Force Nim tuple even if `produce json` was used earlier
|
||||
let threadNim = query:
|
||||
select thread(id, name)
|
||||
produce nim
|
||||
```
|
||||
|
||||
Single tuples or values can be returned in some cases:
|
||||
|
||||
```nim
|
||||
# Single value returning
|
||||
let newId = query:
|
||||
insert thread(name = ?"topic")
|
||||
returning id
|
||||
|
||||
# Single row value returning when limit is a const `1`
|
||||
let newId = query:
|
||||
select thread(name = ?"topic")
|
||||
orderby desc(trhead.id)
|
||||
limit 1
|
||||
```
|
||||
|
||||
**Note**: use an integer arg to limit to return a sequence instead!
|
||||
|
||||
```nim
|
||||
let n = 1
|
||||
let newId = query:
|
||||
select thread(name = ?"topic")
|
||||
orderby desc(trhead.id)
|
||||
limit ?n
|
||||
|
||||
```
|
||||
|
||||
### Typed Queries
|
||||
|
||||
Use `query(T):` when you want Ormin to deserialize selected columns directly into a named Nim type instead of returning the default tuple shape. This is useful at module boundaries where a named object, ref object, or scalar domain type is clearer than a tuple.
|
||||
|
||||
For object results, selected column names must match fields on the destination type. Use `as` aliases when the database column name differs from the Nim field name:
|
||||
|
||||
```nim
|
||||
type
|
||||
ThreadSummary = object
|
||||
id: int
|
||||
title: string
|
||||
|
||||
let threads = query(ThreadSummary):
|
||||
select thread(id, name as title)
|
||||
orderby id
|
||||
```
|
||||
|
||||
Selecting one column can map directly to a scalar type:
|
||||
|
||||
```nim
|
||||
let names = query(string):
|
||||
select thread(name)
|
||||
```
|
||||
|
||||
Queries that return a single row, such as a `limit 1` query, return one `T` instead of `seq[T]`:
|
||||
|
||||
```nim
|
||||
let thread = query(ThreadSummary):
|
||||
select thread(id, name as title)
|
||||
where id == ?threadId
|
||||
limit 1
|
||||
```
|
||||
|
||||
#### `fromQueryHook` Column Hooks
|
||||
|
||||
Typed queries deserialize each selected column through `fromQueryHook`. You can overload this hook for your own field or scalar destination types:
|
||||
|
||||
```nim
|
||||
import ormin/query_hooks
|
||||
|
||||
type
|
||||
TitleLength = distinct int
|
||||
|
||||
ThreadTitleSize = object
|
||||
id: int
|
||||
title: TitleLength
|
||||
|
||||
proc fromQueryHook*(val: var TitleLength, value: string) =
|
||||
val = TitleLength(value.len)
|
||||
|
||||
let rows = query(ThreadTitleSize):
|
||||
select thread(id, name as title)
|
||||
```
|
||||
|
||||
If a hook needs to handle SQL `NULL` itself, accept a `DbValue[SourceType]`:
|
||||
|
||||
```nim
|
||||
type
|
||||
NullableTitle = distinct string
|
||||
|
||||
proc fromQueryHook*(val: var NullableTitle, value: DbValue[string]) =
|
||||
if value.isNull:
|
||||
val = NullableTitle("<untitled>")
|
||||
else:
|
||||
val = NullableTitle(value.value)
|
||||
```
|
||||
|
||||
These are column deserialization hooks. In object typed queries, Ormin calls `fromQueryHook` separately for each selected column that maps to a destination field; it does not currently call a hook for the entire row object. For whole-row transformations, query into an intermediate typed result and convert it in regular Nim code.
|
||||
|
||||
### JSON and Raw SQL
|
||||
|
||||
JSON values can be spliced directly using `%` expressions. The `%` prefix tells Ormin to treat the following Nim expression as a `JsonNode` without conversion:
|
||||
|
||||
```nim
|
||||
import json
|
||||
let payload = %*{"id": %*1, "meta": %*{"tags": %*["nim", "orm"]}}
|
||||
|
||||
# Use JSON in WHERE clause
|
||||
let rows = query:
|
||||
select post(id, title)
|
||||
where id == %payload["id"]
|
||||
produce json
|
||||
|
||||
# Insert a row using JSON fields
|
||||
query:
|
||||
insert post(id = %payload["id"], title = %payload["title"], info = %payload["meta"])
|
||||
```
|
||||
|
||||
`!!"RAW"` injects a literal SQL fragment for vendor-specific functions or clauses that Ormin does not know about:
|
||||
|
||||
```nim
|
||||
query:
|
||||
update users(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?userId
|
||||
```
|
||||
|
||||
The tests include additional samples of JSON parameters and raw SQL expressions.
|
||||
|
||||
### Custom SQL Functions
|
||||
|
||||
Use the `{.importSql.}` pragma to tell Ormin about additional SQL functions that your database provides. Declare a Nim proc or func that mirrors the SQL signature and mark it with the pragma; the declaration does not need an implementation because Ormin only uses it to register the function for the query DSL.
|
||||
|
||||
```nim
|
||||
proc substr(s: string; start, length: int): string {.importSql.}
|
||||
|
||||
let name = "foo"
|
||||
let rows = query:
|
||||
select tb_string(substr(typstring, 1, 5))
|
||||
where substr(typstring, 1, 5) == ?name
|
||||
```
|
||||
|
||||
Imported functions participate in compile-time checking for arity and return type so they can be composed with regular Ormin expressions.
|
||||
|
||||
**Limitation:** argument types are currently not validated, so using mismatched parameter types still compiles—ensure the arguments you pass match what the underlying SQL function expects.
|
||||
|
||||
## Transactions and Batching
|
||||
|
||||
Use `transaction:` to run multiple queries atomically. The block commits on success and rolls back on any exception. Nesting is supported via savepoints. `tryTransaction:` behaves the same but returns `bool` (false on database errors) without raising.
|
||||
|
||||
Examples:
|
||||
|
||||
```nim
|
||||
# Commit on success
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(1), name = ?"alice", password = ?"p", email = ?"a@x", salt = ?"s", status = ?"ok")
|
||||
query:
|
||||
update thread(views = views + 1)
|
||||
where id == ?(42)
|
||||
|
||||
# Rollback on error
|
||||
let ok = tryTransaction:
|
||||
query:
|
||||
insert person(id = ?(2), name = ?"bob", password = ?"p", email = ?"b@x", salt = ?"s", status = ?"ok")
|
||||
# Primary key violation => entire block is rolled back, ok = false
|
||||
query:
|
||||
insert person(id = ?(2), name = ?"duplicate", password = ?"p", email = ?"d@x", salt = ?"s", status = ?"x")
|
||||
|
||||
# Nested transactions via savepoints
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(3), name = ?"carol", password = ?"p", email = ?"c@x", salt = ?"s", status = ?"ok")
|
||||
let innerOk = tryTransaction:
|
||||
# This will fail and roll back to the savepoint
|
||||
query:
|
||||
insert person(id = ?(3), name = ?"duplicate", password = ?"p", email = ?"d@x", salt = ?"s", status = ?"x")
|
||||
doAssert innerOk == false
|
||||
# Continue outer transaction normally
|
||||
```
|
||||
|
||||
PostgreSQL and SQLite are supported. The macros use `BEGIN/COMMIT/ROLLBACK` for the outermost transaction and `SAVEPOINT/RELEASE/ROLLBACK TO` for nested scopes.
|
||||
|
||||
## Reusable Procedures and Iterators
|
||||
|
||||
`createProc` turns a query into a procedure that returns all rows at once:
|
||||
|
||||
```nim
|
||||
createProc postsByAuthor:
|
||||
select post(id, title)
|
||||
where author == ?userId
|
||||
|
||||
let posts = db.postsByAuthor(userId)
|
||||
```
|
||||
|
||||
`createIter` emits an iterator that yields rows lazily:
|
||||
|
||||
```nim
|
||||
createIter postsIter:
|
||||
select post(id, title)
|
||||
where author == ?userId
|
||||
|
||||
for row in db.postsIter(userId):
|
||||
echo row.title
|
||||
```
|
||||
|
||||
Both forms accept parameters matching the `?`/`%` placeholders and produce the same return types as an inline `query` block.
|
||||
|
||||
Inline `query` blocks resolve `db` from the current lexical scope, so a proc parameter or local `db` binding can override the global connection when needed. This is useful for making procs which need to do complex handling:
|
||||
|
||||
```nim
|
||||
proc loadUser(db: DbConn; userId: int): User =
|
||||
let row = query:
|
||||
select user(id, name, email)
|
||||
where id == ?userId
|
||||
limit 1
|
||||
|
||||
User(id: row.id, name: row.name, email: row.email)
|
||||
```
|
||||
|
||||
## Running Arbitrary SQL
|
||||
|
||||
The standard `db_connector` APIs can be imported and used. For example:
|
||||
|
||||
```nim
|
||||
discard db.getValue(sql"select setval('antibot_id_seq', 10, false)")
|
||||
```
|
||||
|
||||
## Additional Facilities
|
||||
|
||||
- **Protocol DSL** – The `protocol` macro lets you describe paired server/client handlers that communicate via JSON messages. Sections use keywords like `recv`, `broadcast` and `send`, and every server block must be mirrored by a client block. The chat example demonstrates this code generation.
|
||||
- **JSON Dispatcher** – `createDispatcher` constructs a dispatcher for textual commands mapped to Nim procedures.
|
||||
- **WebSocket Server** – `serverws` provides a small WebSocket server that can broadcast messages to selected receivers via the `serve` proc.
|
||||
|
||||
## Tooling
|
||||
|
||||
The repository ships with `tools/ormin_importer`, used by the default `importModel` path, to parse SQL schema files into Nim type information and write the generated `.nim` model file.
|
||||
|
||||
## Examples
|
||||
|
||||
The `examples/` directory contains small applications (chat, forum, tweeter) demonstrating schema import, query blocks and the protocol/WebSocket features.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the full test suite via Nimble:
|
||||
|
||||
```bash
|
||||
nimble test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Ormin is released under the MIT license.
|
||||
@@ -0,0 +1,47 @@
|
||||
switch("nimcache", ".nimcache")
|
||||
|
||||
task buildimporter, "Build ormin_importer":
|
||||
exec "nim c -o:./tools/ormin_importer tools/ormin_importer"
|
||||
|
||||
task clean, "Clean generated files":
|
||||
rmFile("tests/forum_model_sqlite.nim")
|
||||
rmFile("tests/model_sqlite.nim")
|
||||
|
||||
rmFile("tests/forum_model_postgres.nim")
|
||||
rmFile("tests/model_postgre.nim")
|
||||
|
||||
task test, "Run all test suite":
|
||||
buildimporterTask()
|
||||
cleanTask()
|
||||
|
||||
exec "nim c -f -r tests/tfeature"
|
||||
exec "nim c -f -r tests/tcommon"
|
||||
exec "nim c -f -r -d:release tests/tquery_types"
|
||||
exec "nim c -f -r tests/tsqlite"
|
||||
exec "nim c -f -r tests/tdb_utils"
|
||||
exec "nim c -f -r tests/timportstatic"
|
||||
|
||||
task setup_postgres, "Ensure local Postgres has test DB/user":
|
||||
# Use a simple script to avoid Nim/psql quoting pitfalls
|
||||
exec "bash -lc 'bash tools/setup_postgres.sh'"
|
||||
|
||||
task test_postgres, "Run PostgreSQL test suite":
|
||||
cleanTask()
|
||||
buildimporterTask()
|
||||
# setup_postgresTask()
|
||||
# Pre-generate Postgres models to avoid include timing issues
|
||||
exec "./tools/ormin_importer tests/forum_model_postgres.sql"
|
||||
exec "./tools/ormin_importer tests/model_postgre.sql"
|
||||
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:postgre tests/tfeature"
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:postgre tests/tcommon"
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:release -d:postgre tests/tquery_types"
|
||||
exec "nim c -f -d:nimDebugDlOpen -r -d:postgre tests/tpostgre"
|
||||
|
||||
task buildexamples, "Build examples: chat and forum":
|
||||
buildimporterTask()
|
||||
selfExec "c examples/chat/server"
|
||||
selfExec "js examples/chat/frontend"
|
||||
selfExec "c examples/forum/forum"
|
||||
selfExec "c examples/forum/forumproto"
|
||||
selfExec "c examples/tweeter/src/tweeter"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Ormin + BaraDB Dev Entrypoint
|
||||
# Handles dependency installation and optional example compilation.
|
||||
|
||||
DEPS_FLAG="/workspace/.deps-installed"
|
||||
|
||||
# Install dependencies once
|
||||
if [ ! -f "$DEPS_FLAG" ]; then
|
||||
echo "[ormin-dev] Installing BaraDB client..."
|
||||
cd /workspace/baradb
|
||||
nimble install -y
|
||||
|
||||
echo "[ormin-dev] Installing Ormin dependencies..."
|
||||
cd /workspace/ormin
|
||||
nimble install -y
|
||||
|
||||
touch "$DEPS_FLAG"
|
||||
echo "[ormin-dev] Dependencies ready."
|
||||
fi
|
||||
|
||||
# If no arguments, drop to shell
|
||||
if [ $# -eq 0 ]; then
|
||||
exec bash
|
||||
fi
|
||||
|
||||
# Otherwise run the provided command
|
||||
exec "$@"
|
||||
@@ -0,0 +1,35 @@
|
||||
## Basic Ormin + BaraDB example
|
||||
##
|
||||
## Run with:
|
||||
## nim c -r examples/baradb_basic.nim
|
||||
##
|
||||
## Requires a BaraDB server on localhost:9472.
|
||||
|
||||
import ormin
|
||||
|
||||
importModel(DbBackend.baradb, "baradb_model")
|
||||
|
||||
let db {.global.} = open("127.0.0.1:9472", "admin", "", "default")
|
||||
|
||||
proc listUsers() =
|
||||
let rows = query:
|
||||
select users(id, name, email)
|
||||
orderby id
|
||||
for r in rows:
|
||||
echo "User #", r.id, ": ", r.name, " <", r.email, ">"
|
||||
|
||||
proc findUserByName(name: string) =
|
||||
let row = query:
|
||||
select users(id, name, email)
|
||||
where name == ?name
|
||||
limit 1
|
||||
echo "Found: ", row
|
||||
|
||||
proc insertUser(name, email: string; age: int) =
|
||||
query:
|
||||
insert users(name = ?name, email = ?email, age = ?age)
|
||||
|
||||
when isMainModule:
|
||||
listUsers()
|
||||
findUserByName("alice")
|
||||
insertUser("bob", "bob@example.com", 30)
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
age INT
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
create table if not exists users(
|
||||
id integer primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
lastOnline timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
/* Names need to be unique: */
|
||||
create unique index if not exists UserNameIx on users(name);
|
||||
|
||||
create table if not exists messages(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
content varchar(1000) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
|
||||
foreign key (author) references users(id)
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import strutils, db_sqlite
|
||||
|
||||
var db = open(connection="chat.db", user="araq", password="",
|
||||
database="chat")
|
||||
let model = readFile("chat_model.sql")
|
||||
for m in model.split(';'):
|
||||
if m.strip != "":
|
||||
db.exec(sql(m), [])
|
||||
db.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
## Frontend for our chat application example.
|
||||
## You need to have 'karax' installed in order for this to work.
|
||||
|
||||
import karax / [kbase, karax, vdom, karaxdsl, kajax, jwebsockets, jjson,
|
||||
jstrutils, errors]
|
||||
|
||||
# Custom UI element with input validation:
|
||||
|
||||
proc validateNotEmpty(field: kstring): proc () =
|
||||
result = proc () =
|
||||
let x = getVNodeById(field)
|
||||
if x.text.isNil or x.text == "":
|
||||
setError(field, field & " must not be empty")
|
||||
else:
|
||||
setError(field, "")
|
||||
|
||||
proc loginField(desc, field, class: kstring;
|
||||
validator: proc (field: kstring): proc ()): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
label(`for` = field):
|
||||
text desc
|
||||
input(`type` = class, id = field, onchange = validator(field))
|
||||
|
||||
# here we setup the connection to the server:
|
||||
let conn = newWebSocket("ws://localhost:8080", "orminchat")
|
||||
|
||||
proc send(msg: JsonNode) =
|
||||
# The Ormin "protocol" requires us to have 'send' implementation.
|
||||
conn.send(toJson(msg))
|
||||
|
||||
type User* = ref object
|
||||
name*, password: kstring
|
||||
|
||||
const
|
||||
username = kstring"username"
|
||||
password = kstring"password"
|
||||
message = kstring"message"
|
||||
|
||||
# Include the 'chatclient' helper that Ormin produced for us:
|
||||
include chatclient
|
||||
|
||||
template loggedIn(): bool = userId > 0
|
||||
|
||||
proc doLogin() =
|
||||
registerUser(User(name: getVNodeById(username).text,
|
||||
password: getVNodeById(password).text))
|
||||
|
||||
proc doSendMessage() =
|
||||
let inputField = getVNodeById(message)
|
||||
sendMessage(TextMessage(author: userId, content: inputField.text))
|
||||
inputField.setInputText ""
|
||||
|
||||
proc registerOnUpdate() =
|
||||
conn.onmessage =
|
||||
proc (e: MessageEvent) =
|
||||
let msg = fromJson[JsonNode](e.data)
|
||||
# 'recvMsg' was generated for us:
|
||||
recvMsg(msg)
|
||||
karax.redraw()
|
||||
|
||||
proc main(): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
tdiv:
|
||||
if not loggedIn:
|
||||
loginField("Name :", username, "input", validateNotEmpty)
|
||||
loginField("Password: ", password, "password", validateNotEmpty)
|
||||
button(onclick = doLogin, disabled = disableOnError()):
|
||||
text "Login"
|
||||
p:
|
||||
text getError(username)
|
||||
p:
|
||||
text getError(password)
|
||||
tdiv:
|
||||
table:
|
||||
for m in allMessages:
|
||||
tr:
|
||||
td:
|
||||
bold:
|
||||
text m.name
|
||||
td:
|
||||
text m.content
|
||||
tdiv:
|
||||
if loggedIn:
|
||||
label(`for` = message):
|
||||
text "Message: "
|
||||
input(class = "input", id = message, onkeyupenter = doSendMessage)
|
||||
|
||||
registerOnUpdate()
|
||||
runLater proc() =
|
||||
getRecentMessages()
|
||||
|
||||
setRenderer(main)
|
||||
@@ -0,0 +1,96 @@
|
||||
import ../../ormin / [serverws]
|
||||
import ../../ormin
|
||||
|
||||
import json
|
||||
|
||||
# Ormin needs to know about our SQL model:
|
||||
importModel(DbBackend.sqlite, "chat_model")
|
||||
|
||||
# Currently Ormin assumes a global database connection that needs to be
|
||||
# annotated as '.global' for technical reasons. Later versions will improve.
|
||||
var db {.global.} = open("chat.db", "", "", "")
|
||||
|
||||
# Ormin produces the file "chatclient.nim" for our frontend:
|
||||
protocol "chatclient.nim":
|
||||
# A 'common' code section is shared by both the client and the server:
|
||||
common:
|
||||
when not defined(js):
|
||||
type kstring = string
|
||||
type
|
||||
inetType = kstring
|
||||
varcharType = kstring
|
||||
timestampType = kstring
|
||||
intType = int
|
||||
|
||||
|
||||
server "get recent messages":
|
||||
let lastMessages = query:
|
||||
select messages(content, creation, author)
|
||||
join users(name)
|
||||
orderby desc(creation)
|
||||
limit 100
|
||||
send(lastMessages)
|
||||
|
||||
client "get recent messages":
|
||||
type TextMessage* = ref object
|
||||
# Ormin fills in the fields of 'TextMessage' for us, based on the
|
||||
# query in the 'server' part.
|
||||
var allMessages*: seq[TextMessage] = @[]
|
||||
proc getRecentMessages*()
|
||||
allMessages = recv()
|
||||
|
||||
|
||||
server "send chat message":
|
||||
let userId = arg["author"].num
|
||||
# unregistered users cannot send anything:
|
||||
if userId == 0: return
|
||||
query:
|
||||
insert messages(content = %arg["content"], author = ?userId)
|
||||
query:
|
||||
update users(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?userId
|
||||
|
||||
let lastMessage = query:
|
||||
select messages(content, creation, author)
|
||||
join users(name)
|
||||
orderby desc(creation)
|
||||
limit 1
|
||||
|
||||
broadcast(lastMessage)
|
||||
|
||||
client "send chat message":
|
||||
proc sendMessage*(m: TextMessage)
|
||||
allMessages.add recv(TextMessage)
|
||||
|
||||
|
||||
# This is the request to register/login a new user:
|
||||
server "register/login a new user":
|
||||
var candidates = query:
|
||||
produce nim
|
||||
select users(id, password)
|
||||
where name == %arg["name"]
|
||||
if candidates.len == 0:
|
||||
let userId = query:
|
||||
produce nim
|
||||
insert users(name = %arg["name"], password = %arg["password"])
|
||||
returning id
|
||||
send(%userId)
|
||||
else:
|
||||
block search:
|
||||
for c in candidates:
|
||||
if c[1] == arg["password"].str:
|
||||
send(%c[0])
|
||||
echo "login found!"
|
||||
break search
|
||||
# invalid login:
|
||||
send(%0)
|
||||
echo "no such user!"
|
||||
|
||||
client "register/login a new user":
|
||||
proc registerUser*(u: User)
|
||||
var userId: int
|
||||
userId = recv()
|
||||
if userId == 0: setError(username, "invalid login!")
|
||||
|
||||
|
||||
serve "orminchat", dispatch
|
||||
@@ -0,0 +1,2 @@
|
||||
--define: ssl
|
||||
--path:"$projectDir/../../../ormin"
|
||||
@@ -0,0 +1,2 @@
|
||||
--define: ssl
|
||||
--path:"$projectDir/../../../ormin"
|
||||
@@ -0,0 +1,160 @@
|
||||
import ../../ormin, json
|
||||
|
||||
importModel(DbBackend.sqlite, "forum_model")
|
||||
|
||||
var db {.global.} = open("stuff", "", "", "")
|
||||
|
||||
#var db: DbConn
|
||||
#proc getPrepStmt(idx: int): PStmt
|
||||
|
||||
#var gPrepStmts: array[N, cstring]
|
||||
|
||||
type inetType = string
|
||||
|
||||
const
|
||||
id = 90
|
||||
ip = "moo"
|
||||
answer = "dunno"
|
||||
pw = "mypw"
|
||||
email = "some@body.com"
|
||||
salt = "pepper"
|
||||
name = "me"
|
||||
limit = 10
|
||||
offset = 5
|
||||
|
||||
let threads = query:
|
||||
select thread(id, name, views, modified)
|
||||
where id in (select post(thread) where author in
|
||||
(select person(id) where status notin ("Spammer") or id == ?id))
|
||||
orderby desc(modified)
|
||||
limit ?limit
|
||||
offset ?offset
|
||||
|
||||
let thisThread = tryQuery:
|
||||
select thread(id)
|
||||
where id == ?id
|
||||
|
||||
createIter allThreadIds:
|
||||
select thread(id)
|
||||
where id == ?id
|
||||
|
||||
query:
|
||||
delete antibot
|
||||
where ip == ?ip
|
||||
|
||||
query:
|
||||
insert antibot(?ip, ?answer)
|
||||
|
||||
let something = query:
|
||||
select antibot(answer & answer, (if ip == "hi": 0 else: 1))
|
||||
where ip == ?ip and answer =~ "%things%"
|
||||
orderby desc(ip)
|
||||
limit 1
|
||||
|
||||
let myNewPersonId: int = query:
|
||||
insert person(?name, password = ?pw, ?email, ?salt, status = !!"'EmailUnconfirmed'",
|
||||
lastOnline = !!"DATETIME('now')")
|
||||
returning id
|
||||
|
||||
query:
|
||||
delete session
|
||||
where ip == ?ip and password == ?pw
|
||||
|
||||
query:
|
||||
update session(lastModified = !!"DATETIME('now')")
|
||||
where ip == ?ip and password == ?pw
|
||||
|
||||
let myj = %*{"pw": "stuff here"}
|
||||
|
||||
let userId1 = query:
|
||||
select session(userId)
|
||||
where ip == ?ip and password == %myj["pw"]
|
||||
|
||||
let (name9, email9, status, ban) = query:
|
||||
select person(name, email, status, ban)
|
||||
where id == ?id
|
||||
limit 1
|
||||
|
||||
let (idg, nameg, pwg, emailg, creationg, saltg, statusg, lastOnlineg, bang) = query:
|
||||
select person(_)
|
||||
where id == ?id
|
||||
limit 1
|
||||
|
||||
query:
|
||||
update person(lastOnline = !!"DATETIME('now')")
|
||||
where id == ?id
|
||||
|
||||
query:
|
||||
update thread(views = views + 1, modified = !!"DATETIME('now')")
|
||||
where id == ?id
|
||||
|
||||
query:
|
||||
delete thread
|
||||
where id notin (select post(thread))
|
||||
|
||||
let (author, creation) = query:
|
||||
select post(author)
|
||||
join person(creation)
|
||||
limit 1
|
||||
|
||||
let (authorB, creationB) = query:
|
||||
select post(author)
|
||||
join person(creation) on author == id
|
||||
limit 1
|
||||
|
||||
let allPosts = query:
|
||||
select post(count(_) as cnt)
|
||||
where cnt > 0
|
||||
produce json
|
||||
limit 1
|
||||
|
||||
createProc getAllThreadIds:
|
||||
select thread(id)
|
||||
where id == ?id
|
||||
produce json
|
||||
|
||||
let totalThreads = query:
|
||||
select thread(count(_))
|
||||
where id in (select post(thread) where author == ?id and id in (
|
||||
select post(min(id)) groupby thread))
|
||||
limit 1
|
||||
|
||||
#query:
|
||||
# update thread(modified = (select post(creation) where post.thread == ?thread
|
||||
# orderby creation desc limit 1 ))
|
||||
|
||||
#[
|
||||
# Check if post is the first post of the thread.
|
||||
let rows = db.getAllRows(sql("select id, thread, creation from post " &
|
||||
"where thread = ? order by creation asc"), $c.threadId)
|
||||
|
||||
proc rateLimitCheck(c: var TForumData): bool =
|
||||
sql("SELECT count(*) FROM post where author = ? and " &
|
||||
"(strftime('%s', 'now') - strftime('%s', creation)) < 40")
|
||||
sql("SELECT count(*) FROM post where author = ? and " &
|
||||
"(strftime('%s', 'now') - strftime('%s', creation)) < 90")
|
||||
sql("SELECT count(*) FROM post where author = ? and " &
|
||||
"(strftime('%s', 'now') - strftime('%s', creation)) < 300")
|
||||
|
||||
sql "insert into thread(name, views, modified) values (?, 0, DATETIME('now'))"
|
||||
sql "select id, name, password, email, salt, status, ban from person where name = ?"
|
||||
sql "insert into session (ip, password, userid) values (?, ?, ?)",
|
||||
sql"select password, salt, strftime('%s', lastOnline) from person where name = ?"
|
||||
sql("delete from post where author = (select id from person where name = ?)")
|
||||
sql("update person set status = ?, ban = ? where name = ?")
|
||||
sql("update person set password = ?, salt = ? where name = ?")
|
||||
sql"select count(*) from person"
|
||||
sql"select count(*) from post"
|
||||
sql"select count(*) from thread"
|
||||
sql"select id, name, strftime('%s', lastOnline), strftime('%s', creation) from person"
|
||||
|
||||
sql"select count(*) from post where thread = ?"
|
||||
sql"select count(*) from post p, person u where u.id = p.author and p.thread = ?"
|
||||
sql"select name from thread where id = ?"
|
||||
sql"select id from person where name = ?"
|
||||
sql"select count(*) from post where author = ?"
|
||||
sql("select count(*) from thread where id in (select thread from post where" &
|
||||
" author = ? and post.id in (select min(id) from post group by thread))")
|
||||
sql"""select strftime('%s', lastOnline), email, ban, status
|
||||
from person where id = ?"""
|
||||
]#
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
create table if not exists thread(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create unique index if not exists ThreadNameIx on thread (name);
|
||||
|
||||
create table if not exists person(
|
||||
id integer primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
email varchar(30) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
salt varchar(128) not null,
|
||||
status varchar(30) not null,
|
||||
lastOnline timestamp not null default (DATETIME('now')),
|
||||
ban varchar(128) not null default ''
|
||||
);
|
||||
|
||||
create unique index if not exists UserNameIx on person (name);
|
||||
|
||||
create table if not exists post(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
ip inet not null,
|
||||
header varchar(100) not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists session(
|
||||
id integer primary key,
|
||||
ip inet not null,
|
||||
password varchar(32) not null,
|
||||
userid integer not null,
|
||||
lastModified timestamp not null default (DATETIME('now')),
|
||||
foreign key (userid) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists antibot(
|
||||
id integer primary key,
|
||||
ip inet not null,
|
||||
answer varchar(30) not null,
|
||||
created timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create index PersonStatusIdx on person(status);
|
||||
create index PostByAuthorIdx on post(thread, author);
|
||||
@@ -0,0 +1,37 @@
|
||||
import ../../ormin, ../../ormin/serverws, json
|
||||
|
||||
importModel(DbBackend.sqlite, "forum_model")
|
||||
|
||||
var db {.global.} = open("stuff", "", "", "")
|
||||
|
||||
protocol "forumclient.nim":
|
||||
common:
|
||||
when defined(js):
|
||||
type kstring = cstring
|
||||
else:
|
||||
type kstring = string
|
||||
type
|
||||
inetType = kstring
|
||||
varcharType = kstring
|
||||
timestampType = kstring
|
||||
server:
|
||||
query:
|
||||
delete antibot
|
||||
where ip == %arg
|
||||
client:
|
||||
proc deleteAntibot(ip: string)
|
||||
server:
|
||||
query:
|
||||
update session(lastModified = !!"DATETIME('now')")
|
||||
where ip == %arg["ip"] and password == %arg["pw"]
|
||||
client:
|
||||
proc updateSession(arg: Session)
|
||||
server:
|
||||
let allSessions = query:
|
||||
select session(_)
|
||||
send(allSessions)
|
||||
client:
|
||||
type Session = ref object
|
||||
var gSessions: seq[Session]
|
||||
gSessions = recv()
|
||||
proc getAllSessions()
|
||||
@@ -0,0 +1,117 @@
|
||||
body {
|
||||
background-color: #f1f9ea;
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
}
|
||||
|
||||
div#main {
|
||||
width: 80%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
div#user {
|
||||
background-color: #66ac32;
|
||||
width: 100%;
|
||||
color: #c7f0aa;
|
||||
padding: 5pt;
|
||||
}
|
||||
|
||||
div#user > h1 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
padding-left: 10pt;
|
||||
padding-right: 10pt;
|
||||
}
|
||||
|
||||
div#user > form {
|
||||
float: right;
|
||||
margin-right: 10pt;
|
||||
}
|
||||
|
||||
div#user > form > input[type="submit"] {
|
||||
border: 0px none;
|
||||
padding: 5pt;
|
||||
font-size: 108%;
|
||||
color: #ffffff;
|
||||
background-color: #515d47;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div#user > form > input[type="submit"]:hover {
|
||||
background-color: #538c29;
|
||||
}
|
||||
|
||||
|
||||
div#messages {
|
||||
background-color: #a2dc78;
|
||||
width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
div#messages > div {
|
||||
border-left: 1px solid #869979;
|
||||
border-right: 1px solid #869979;
|
||||
border-bottom: 1px solid #869979;
|
||||
padding: 5pt;
|
||||
}
|
||||
|
||||
div#messages > div > a, div#messages > div > span {
|
||||
color: #475340;
|
||||
}
|
||||
|
||||
div#messages > div > a:hover {
|
||||
text-decoration: none;
|
||||
color: #c13746;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
div#login {
|
||||
width: 200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 20%;
|
||||
|
||||
font-size: 130%;
|
||||
}
|
||||
|
||||
div#login span.small {
|
||||
display: block;
|
||||
font-size: 56%;
|
||||
}
|
||||
|
||||
div#newMessage {
|
||||
background-color: #538c29;
|
||||
width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
color: #ffffff;
|
||||
padding: 5pt;
|
||||
}
|
||||
|
||||
div#newMessage span {
|
||||
padding-right: 5pt;
|
||||
}
|
||||
|
||||
div#newMessage form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
div#newMessage > form > input[type="text"] {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
div#newMessage > form > input[type="submit"] {
|
||||
font-size: 80%;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Tweeter example
|
||||
|
||||
Modified from [tweeter example](https://github.com/dom96/nim-in-action-code/tree/master/Chapter7/Tweeter) in the "nim in action" book, replace the database layer with ormin.
|
||||
@@ -0,0 +1,2 @@
|
||||
--define: ssl
|
||||
--path:"$projectDir/../../../../ormin"
|
||||
@@ -0,0 +1,11 @@
|
||||
import db_sqlite, os, strutils
|
||||
|
||||
let db {.global.} = open("tweeter.db", "", "", "")
|
||||
|
||||
let sqlFile = readFile(currentSourcePath.parentDir() / "tweeter_model.sql")
|
||||
for t in sqlFile.split(';'):
|
||||
if t.strip() != "":
|
||||
db.exec(sql(t))
|
||||
|
||||
echo("Database created successfully!")
|
||||
db.close()
|
||||
@@ -0,0 +1,51 @@
|
||||
import times, strutils, strformat, sequtils
|
||||
from db_connector/db_sqlite import instantRows, `[]`
|
||||
import ../../../ormin
|
||||
import model
|
||||
|
||||
importModel(DbBackend.sqlite, "tweeter_model")
|
||||
var db {.global.} = open("tweeter.db", "", "", "")
|
||||
|
||||
proc findUser*(username: string, user: var User): bool =
|
||||
let res = query:
|
||||
select user(username)
|
||||
where username == ?username
|
||||
echo res
|
||||
if res.len == 0: return false
|
||||
else: user.username = res[0]
|
||||
|
||||
let following = query:
|
||||
select following(followed_user)
|
||||
where follower == ?username
|
||||
user.following = following.filterIt(it.len != 0)
|
||||
|
||||
return true
|
||||
|
||||
proc create*(user: User) =
|
||||
query:
|
||||
insert user(username = ?user.username)
|
||||
|
||||
proc post*(message: Message) =
|
||||
if message.msg.len > 140:
|
||||
raise newException(ValueError, "Message has to be less than 140 characters.")
|
||||
query:
|
||||
insert message(username = ?message.username,
|
||||
time = ?message.time,
|
||||
msg = ?message.msg)
|
||||
|
||||
proc follow*(follower, user: User) =
|
||||
query:
|
||||
insert following(follower = ?follower.username, followed_user = ?user.username)
|
||||
|
||||
proc findMessage*(usernames: openArray[string], limit = 10): seq[Message] =
|
||||
result = @[]
|
||||
if usernames.len == 0: return
|
||||
var whereClause = "WHERE "
|
||||
for i in 0 ..< usernames.len:
|
||||
whereClause.add("trim(username) = ?")
|
||||
if i < usernames.len - 1:
|
||||
whereClause.add(" or ")
|
||||
|
||||
let s = &"SELECT username, time, msg FROM Message {whereClause} ORDER BY time DESC LIMIT {limit}"
|
||||
for row in db.instantRows(sql(s), usernames):
|
||||
result.add((username: row[0], time: row[1].parseInt, msg: row[2]))
|
||||
@@ -0,0 +1,11 @@
|
||||
type
|
||||
User* = tuple[
|
||||
username: string,
|
||||
following: seq[string]
|
||||
]
|
||||
|
||||
Message* = tuple[
|
||||
username: string,
|
||||
time: int,
|
||||
msg: string
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncdispatch, times
|
||||
import jester
|
||||
import database, model, views/[user, general]
|
||||
|
||||
proc userLogin(request: Request, user: var User): bool =
|
||||
if request.cookies.hasKey("username"):
|
||||
let username = request.cookies["username"]
|
||||
if not findUser(username, user):
|
||||
user = (username: username, following: @[])
|
||||
create(user)
|
||||
return true
|
||||
else:
|
||||
return false
|
||||
|
||||
routes:
|
||||
get "/":
|
||||
var user: User
|
||||
if userLogin(request, user):
|
||||
let messages = findMessage(user.following & user.username)
|
||||
resp renderMain(renderTimeline(user.username, messages))
|
||||
else:
|
||||
resp renderMain(renderLogin())
|
||||
|
||||
get "/@name":
|
||||
cond '.' notin @"name"
|
||||
var user: User
|
||||
if not findUser(@"name", user):
|
||||
halt "User not found"
|
||||
let messages = findMessage([user.username])
|
||||
|
||||
var currentUser: User
|
||||
if userLogin(request, currentUser):
|
||||
resp renderMain(renderUser(user, currentUser) & renderMessages(messages))
|
||||
else:
|
||||
resp renderMain(renderUser(user) & renderMessages(messages))
|
||||
|
||||
post "/follow":
|
||||
var follower, target: User
|
||||
if not findUser(@"follower", follower):
|
||||
halt "Follower not found"
|
||||
if not findUser(@"target", target):
|
||||
halt "Follow target not found"
|
||||
follow(follower, target)
|
||||
redirect uri("/" & @"target")
|
||||
|
||||
post "/login":
|
||||
setCookie("username", @"username", getTime().utc() + 2.hours)
|
||||
redirect "/"
|
||||
|
||||
post "/createMessage":
|
||||
let message = (
|
||||
username: @"username",
|
||||
time: getTime().toUnix().int,
|
||||
msg: @"message"
|
||||
)
|
||||
post(message)
|
||||
redirect "/"
|
||||
|
||||
runForever()
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by ormin_importer. DO NOT EDIT.
|
||||
|
||||
type
|
||||
Attr = object
|
||||
name: string
|
||||
tabIndex: int
|
||||
typ: DbTypekind
|
||||
key: int # 0 nothing special,
|
||||
# +1 -- primary key
|
||||
# -N -- references attribute N
|
||||
const tableNames = [
|
||||
"user",
|
||||
"following",
|
||||
"message"
|
||||
]
|
||||
|
||||
const attributes = [
|
||||
Attr(name: "username", tabIndex: 0, typ: dbVarchar, key: 1),
|
||||
Attr(name: "follower", tabIndex: 1, typ: dbVarchar, key: -1),
|
||||
Attr(name: "followed_user", tabIndex: 1, typ: dbVarchar, key: -1),
|
||||
Attr(name: "username", tabIndex: 2, typ: dbVarchar, key: -1),
|
||||
Attr(name: "time", tabIndex: 2, typ: dbInt, key: 0),
|
||||
Attr(name: "msg", tabIndex: 2, typ: dbVarchar, key: 0)
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS User(
|
||||
username text PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Following(
|
||||
follower text,
|
||||
followed_user text,
|
||||
PRIMARY KEY (follower, followed_user),
|
||||
FOREIGN KEY (follower) REFERENCES User(username),
|
||||
FOREIGN KEY (followed_user) REFERENCES User(username)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Message(
|
||||
username text,
|
||||
time integer,
|
||||
msg text NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES User(username)
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
#import xmltree
|
||||
#import ../model
|
||||
#import user
|
||||
#
|
||||
#proc `$!`(text: string): string = escape(text)
|
||||
#end proc
|
||||
#
|
||||
#proc renderMain*(body: string): string =
|
||||
# result = ""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tweeter written in Nim</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
#end proc
|
||||
#
|
||||
#proc renderLogin*(): string =
|
||||
# result = ""
|
||||
<div id="login">
|
||||
<span>Login</span>
|
||||
<span class="small">Please type in your username...</span>
|
||||
<form action="login" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderTimeline*(username: string, messages: openArray[Message]): string =
|
||||
# result = ""
|
||||
<div id="user">
|
||||
<h1>${$!username} timeline</h1>
|
||||
</div>
|
||||
<div id="newMessage">
|
||||
<span>New message</span>
|
||||
<form action="createMessage" method="post">
|
||||
<input type="text" name="message">
|
||||
<input type="hidden" name="username" value="${$!username}">
|
||||
<input type="submit" value="Tweet">
|
||||
</form>
|
||||
</div>
|
||||
${renderMessages(messages)}
|
||||
#end proc
|
||||
@@ -0,0 +1,41 @@
|
||||
#? stdtmpl(subsChar = '$', metaChar = '#', toString = "xmltree.escape")
|
||||
#import xmltree
|
||||
#import times
|
||||
#import "../model"
|
||||
#
|
||||
#proc renderUser*(user: User): string =
|
||||
# result = ""
|
||||
<div id="user">
|
||||
<h1>${user.username}</h1>
|
||||
<span>Following: ${$user.following.len}</span>
|
||||
</div>
|
||||
#end proc
|
||||
#
|
||||
#proc renderUser*(user, currentUser: User): string =
|
||||
# result = ""
|
||||
<div id="user">
|
||||
<h1>${user.username}</h1>
|
||||
<span>Following: ${$user.following.len}</span>
|
||||
#if user.username notin currentUser.following and user.username != currentUser.username:
|
||||
<form action="follow" method="post">
|
||||
<input type="hidden" name="follower" value="${currentUser.username}">
|
||||
<input type="hidden" name="target" value="${user.username}">
|
||||
<input type="submit" value="Follow">
|
||||
</form>
|
||||
#end if
|
||||
</div>
|
||||
#
|
||||
#end proc
|
||||
#
|
||||
#proc renderMessages*(messages: openArray[Message]): string =
|
||||
# result = ""
|
||||
<div id="messages">
|
||||
#for message in messages:
|
||||
<div>
|
||||
<a href="/${message.username}">${message.username}</a>
|
||||
<span>${message.time.fromUnix().format("HH:mm MMMM d',' yyyy")}</span>
|
||||
<h3>${message.msg}</h3>
|
||||
</div>
|
||||
#end for
|
||||
</div>
|
||||
#end proc
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 Andreas Rumpf
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,49 @@
|
||||
type
|
||||
DbBackend* {.pure.} = enum
|
||||
postgre, sqlite, mysql, baradb
|
||||
|
||||
import os
|
||||
from ormin/importer_core import generateModelCode, ImportTarget
|
||||
|
||||
template importModel*(backend: DbBackend, filename: string, includeStatic: static[bool] = false) {.dirty.} =
|
||||
## imports a model from an SQL file.
|
||||
bind fileExists, parentDir, `/`
|
||||
bind generateModelCode, ImportTarget
|
||||
const file = static:
|
||||
let path = parentDir(instantiationInfo(-1, true)[0])
|
||||
path / filename & ".sql"
|
||||
const importedFile = static:
|
||||
if not includeStatic:
|
||||
let res =
|
||||
if fileExists("./tools/ormin_importer"):
|
||||
gorgeEx("./tools/ormin_importer " & file)
|
||||
else:
|
||||
# run ormin_importer from the PATH
|
||||
gorgeEx("ormin_importer " & file)
|
||||
if res.exitCode != 0:
|
||||
raise newException(Exception, "Failed to generate model: " & res.output)
|
||||
file
|
||||
{.warning: "Imported SQL Model: " & importedFile.}
|
||||
|
||||
const dbBackend = backend
|
||||
|
||||
import db_connector/db_common
|
||||
when includeStatic:
|
||||
import ormin/db_utils
|
||||
|
||||
when dbBackend == DbBackend.sqlite:
|
||||
import ormin/ormin_sqlite
|
||||
elif dbBackend == DbBackend.postgre:
|
||||
import ormin/ormin_postgre
|
||||
elif dbBackend == DbBackend.mysql:
|
||||
import ormin/ormin_mysql
|
||||
elif dbBackend == DbBackend.baradb:
|
||||
import ormin/ormin_baradb
|
||||
else:
|
||||
{.error: "unknown database backend".}
|
||||
|
||||
when includeStatic:
|
||||
generateModelCode(staticRead(file), file, ImportTarget(ord(backend)), true)
|
||||
else:
|
||||
include filename
|
||||
include "ormin/queries"
|
||||
@@ -0,0 +1,23 @@
|
||||
# Package
|
||||
|
||||
version = "0.9.0"
|
||||
author = "Araq"
|
||||
description = "Prepared SQL statement generator. A lightweight ORM."
|
||||
license = "MIT"
|
||||
bin = @["tools/ormin_importer"]
|
||||
skipDirs = @["examples"]
|
||||
installExt = @["nim"]
|
||||
|
||||
# Dependencies
|
||||
requires "nim >= 2.0.0"
|
||||
requires "db_connector >= 0.1.0"
|
||||
|
||||
feature "examples":
|
||||
requires "websocket >= 0.2.2"
|
||||
requires "karax"
|
||||
requires "jester"
|
||||
|
||||
import std/os
|
||||
when fileExists("config.nims"):
|
||||
include "config.nims"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import std/strutils
|
||||
import db_connector/db_common
|
||||
|
||||
proc dbTypFromName*(name: string): DbTypeKind =
|
||||
var k = dbUnknown
|
||||
|
||||
case name.toLowerAscii
|
||||
of "int", "integer", "int8", "smallint", "int16",
|
||||
"longint", "int32", "int64", "tinyint", "hugeint": k = dbInt
|
||||
of "uint", "uint8", "uint16", "uint32", "uint64": k = dbUInt
|
||||
of "serial": k = dbSerial
|
||||
of "bit": k = dbBit
|
||||
of "bool", "boolean": k = dbBool
|
||||
of "blob": k = dbBlob
|
||||
of "fixedchar": k = dbFixedChar
|
||||
of "varchar", "text", "string": k = dbVarchar
|
||||
of "json": k = dbJson
|
||||
of "xml": k = dbXml
|
||||
of "decimal": k = dbDecimal
|
||||
of "float", "double", "longdouble", "real": k = dbFloat
|
||||
of "date", "day": k = dbDate
|
||||
of "time": k = dbTime
|
||||
of "datetime": k = dbDateTime
|
||||
of "timestamp", "timestamptz": k = dbTimestamp
|
||||
of "timeinterval": k = dbTimeInterval
|
||||
of "set": k = dbSet
|
||||
of "array": k = dbArray
|
||||
of "composite": k = dbComposite
|
||||
of "url", "uri": k = dbUrl
|
||||
of "uuid": k = dbUuid
|
||||
of "inet", "ip", "tcpip": k = dbInet
|
||||
of "mac", "macaddress": k = dbMacAddress
|
||||
of "geometry": k = dbGeometry
|
||||
of "point": k = dbPoint
|
||||
of "line": k = dbLine
|
||||
of "lseg": k = dbLseg
|
||||
of "box": k = dbBox
|
||||
of "path": k = dbPath
|
||||
of "polygon": k = dbPolygon
|
||||
of "circle": k = dbCircle
|
||||
else: discard
|
||||
|
||||
return k
|
||||
@@ -0,0 +1,179 @@
|
||||
import std/[os, paths]
|
||||
import db_connector/db_common, strutils, strformat
|
||||
import db_connector/db_postgres as db_postgres
|
||||
import db_connector/db_sqlite as db_sqlite
|
||||
|
||||
import parsesql_tmp # import std/parsesql
|
||||
|
||||
export paths
|
||||
|
||||
type
|
||||
DbConn = db_postgres.DbConn | db_sqlite.DbConn
|
||||
DbId* = distinct string
|
||||
DbSql* = distinct string
|
||||
|
||||
proc `$`*(dbId: DbId): string {.borrow.}
|
||||
proc `$`*(dbSql: DbSql): string {.borrow.}
|
||||
|
||||
template staticLoad*(filename: string): DbSql =
|
||||
bind isAbsolute, parentDir, `/`, instantiationInfo
|
||||
block:
|
||||
const schemaPath {.gensym.} = static:
|
||||
if isAbsolute(filename):
|
||||
filename
|
||||
else:
|
||||
parentDir(instantiationInfo(-1, true)[0]) / filename
|
||||
const schemaSql {.gensym.} = staticRead(schemaPath)
|
||||
static:
|
||||
try:
|
||||
discard parseSql(schemaSql)
|
||||
except SqlParseError as e:
|
||||
doAssert false, "Invalid SQL in " & schemaPath & ": " & e.msg
|
||||
DbSql(schemaSql)
|
||||
|
||||
proc parseQualifiedIdentifier(name: string): seq[(string, bool)] =
|
||||
var current = ""
|
||||
var quoteChar = '\0'
|
||||
var partWasQuoted = false
|
||||
var i = 0
|
||||
while i < name.len:
|
||||
let c = name[i]
|
||||
if quoteChar == '\0':
|
||||
case c
|
||||
of '.':
|
||||
result.add((current, partWasQuoted))
|
||||
current = ""
|
||||
partWasQuoted = false
|
||||
of '"', '\'', '`':
|
||||
if current.len == 0:
|
||||
quoteChar = c
|
||||
partWasQuoted = true
|
||||
else:
|
||||
current.add(c)
|
||||
else:
|
||||
current.add(c)
|
||||
else:
|
||||
if c == quoteChar:
|
||||
if i + 1 < name.len and name[i + 1] == quoteChar:
|
||||
current.add(c)
|
||||
inc(i)
|
||||
else:
|
||||
quoteChar = '\0'
|
||||
else:
|
||||
current.add(c)
|
||||
inc(i)
|
||||
|
||||
result.add((current, partWasQuoted))
|
||||
|
||||
proc canUseBareIdentifier(part: string): bool =
|
||||
if part.len == 0:
|
||||
return false
|
||||
if part[0] notin {'a'..'z', 'A'..'Z', '_'}:
|
||||
return false
|
||||
for c in part[1..^1]:
|
||||
if c notin {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||
return false
|
||||
true
|
||||
|
||||
proc quoteDbIdentifier*(name: string): DbId =
|
||||
## Quote a database identifier for direct interpolation into SQL text.
|
||||
## Dot-separated names are treated as qualified identifiers.
|
||||
var partsSql: seq[string] = @[]
|
||||
for (part, wasQuoted) in parseQualifiedIdentifier(name):
|
||||
let normalizedPart = if wasQuoted: part else: part.toLowerAscii()
|
||||
if wasQuoted or not canUseBareIdentifier(normalizedPart):
|
||||
partsSql.add("\"" & normalizedPart.replace("\"", "\"\"") & "\"")
|
||||
else:
|
||||
partsSql.add(normalizedPart)
|
||||
DbId(partsSql.join("."))
|
||||
|
||||
proc dropTableName(db: DbConn; tableName, lookupName: string) =
|
||||
# SQL parameters bind values, not identifiers, so DROP TABLE needs the
|
||||
# identifier rendered into the statement text with correct quoting.
|
||||
when defined(sqlite) and defined(orminLegacySqliteDropNames):
|
||||
db.exec(sql("drop table if exists " & lookupName))
|
||||
else:
|
||||
let tableIdentSql = quoteDbIdentifier(tableName)
|
||||
when defined(postgre):
|
||||
db.exec(sql("drop table if exists " & $tableIdentSql & " cascade"))
|
||||
else:
|
||||
db.exec(sql("drop table if exists " & $tableIdentSql))
|
||||
|
||||
iterator tableDefs(sql: DbSql): tuple[name, tableName, model: string] =
|
||||
# Parse the entire SQL file and iterate statements via the SQL parser
|
||||
var ast: SqlNode
|
||||
try:
|
||||
ast = parseSql($sql)
|
||||
except SqlParseError as e:
|
||||
echo "SQL Parse Error:\n", sql
|
||||
raise e
|
||||
|
||||
if ast.len > 0:
|
||||
# ast is a statement list; iterate each statement node
|
||||
for i in 0 ..< ast.len:
|
||||
let node = ast[i]
|
||||
if node.kind in {nkCreateTable, nkCreateTableIfNotExists}:
|
||||
yield (node[0].strVal.toLowerAscii(), $node[0], $node)
|
||||
else:
|
||||
# Fallback: ast might be a single statement (not a list)
|
||||
let node = ast
|
||||
if node.kind in {nkCreateTable, nkCreateTableIfNotExists}:
|
||||
yield (node[0].strVal.toLowerAscii(), $node[0], $node)
|
||||
|
||||
iterator tablePairs*(sql: string): tuple[name, model: string] =
|
||||
for name, _, model in tableDefs(DbSql(sql)):
|
||||
yield (name, model)
|
||||
|
||||
iterator tablePairs*(sql: static[DbSql]): tuple[name, model: string] =
|
||||
for name, _, model in tableDefs(sql):
|
||||
yield (name, model)
|
||||
|
||||
|
||||
iterator tablePairs*(sql: Path): tuple[name, model: string] =
|
||||
let f = readFile($sql)
|
||||
for n, m in tablePairs(f):
|
||||
yield (n, m)
|
||||
|
||||
proc createTable*(db: DbConn; sqlFile: Path) =
|
||||
for _, m in tablePairs(sqlFile):
|
||||
db.exec(sql(m))
|
||||
|
||||
proc createTable*(db: DbConn; sqlFile: Path, name: string) =
|
||||
for n, m in tablePairs(sqlFile):
|
||||
if n == name:
|
||||
db.exec(sql(m))
|
||||
return
|
||||
raiseAssert &"table: {name} not found in: {sqlFile}"
|
||||
|
||||
proc createTable*(db: DbConn; schemaSql: static[DbSql]) =
|
||||
for _, m in tablePairs(schemaSql):
|
||||
db.exec(sql(m))
|
||||
|
||||
proc createTable*(db: DbConn; schemaSql: static[DbSql], name: string) =
|
||||
for n, m in tablePairs(schemaSql):
|
||||
if n == name:
|
||||
db.exec(sql(m))
|
||||
return
|
||||
raiseAssert &"table: {name} not found in static schema"
|
||||
|
||||
proc dropTable*(db: DbConn; sqlFile: Path) =
|
||||
for lookupName, tableName, _ in tableDefs(DbSql(readFile($sqlFile))):
|
||||
db.dropTableName(tableName, lookupName)
|
||||
|
||||
proc dropTable*(db: DbConn; sqlFile: Path, name: string) =
|
||||
for lookupName, tableName, _ in tableDefs(DbSql(readFile($sqlFile))):
|
||||
if lookupName == name:
|
||||
db.dropTableName(tableName, lookupName)
|
||||
return
|
||||
raiseAssert &"table: {name} not found in: {sqlFile}"
|
||||
|
||||
proc dropTable*(db: DbConn; schemaSql: static[DbSql]) =
|
||||
for lookupName, tableName, _ in tableDefs(schemaSql):
|
||||
db.dropTableName(tableName, lookupName)
|
||||
|
||||
proc dropTable*(db: DbConn; schemaSql: static[DbSql], name: string) =
|
||||
for lookupName, tableName, _ in tableDefs(schemaSql):
|
||||
if lookupName == name:
|
||||
db.dropTableName(tableName, lookupName)
|
||||
return
|
||||
raiseAssert &"table: {name} not found in static schema"
|
||||
@@ -0,0 +1,71 @@
|
||||
## This module implements a little helper macro to ease
|
||||
## writing the dispatching logic for JSON based servers.
|
||||
|
||||
import macros
|
||||
|
||||
proc tbody(n: NimNode): NimNode =
|
||||
# transforms::
|
||||
# f fields(a, "b")
|
||||
# into::
|
||||
# f a = %args["a"], b = %args["b"]
|
||||
case n.kind
|
||||
of nnkCallKinds:
|
||||
result = copyNimNode(n)
|
||||
result.add tbody(n[0])
|
||||
for i in 1..<n.len:
|
||||
let it = n[i]
|
||||
if it.kind == nnkCall and $it[0] == "fields":
|
||||
for j in 1..<it.len:
|
||||
let field = it[j]
|
||||
let s = if field.kind in {nnkStrLit..nnkTripleStrLit}: field.strVal
|
||||
else: $field
|
||||
let arg = newTree(nnkPrefix, ident"%",
|
||||
newTree(nnkBracketExpr, ident"arg", newLit(s)))
|
||||
result.add newTree(nnkExprEqExpr, ident(s), arg)
|
||||
else:
|
||||
result.add tbody(it)
|
||||
else:
|
||||
result = copyNimNode(n)
|
||||
for x in n: result.add tbody(x)
|
||||
|
||||
macro createDispatcher*(name, n: untyped): untyped =
|
||||
expectKind n, nnkStmtList
|
||||
result = newStmtList()
|
||||
let disp = newTree(nnkCaseStmt, ident"cmd")
|
||||
template cmdProc(name, body) {.dirty.} =
|
||||
proc name(arg: JsonNode): JsonNode = body
|
||||
template callCmd(name) {.dirty.} =
|
||||
result = name(arg)
|
||||
|
||||
for x in n:
|
||||
if x.kind == nnkCall and x.len == 2 and
|
||||
x[0].kind == nnkIdent and x[1].kind == nnkStmtList:
|
||||
result.add getAst(cmdProc(x[0], tbody x[1]))
|
||||
disp.add newTree(nnkOfBranch, newLit($x[0]),
|
||||
newStmtList(getAst(callCmd(x[0]))))
|
||||
else:
|
||||
# do not touch:
|
||||
result.add n
|
||||
disp.add newTree(nnkElse, newStmtList(newTree(nnkDiscardStmt, newEmptyNode())))
|
||||
|
||||
template dispatchProc(name, body) {.dirty.} =
|
||||
proc dispatch(inp: JsonNode): JsonNode =
|
||||
let arg = inp["arg"]
|
||||
let cmd = inp["cmd"].getStr("")
|
||||
body
|
||||
result.add getAst(dispatchProc(name, disp))
|
||||
when defined(debugDispatcherDsl):
|
||||
echo repr result
|
||||
|
||||
when isMainModule:
|
||||
import json, db_sqlite
|
||||
|
||||
createDispatcher(dispatch):
|
||||
insertCustomer:
|
||||
echo "insert customer", fields(a, b, c)
|
||||
result = arg
|
||||
selectCustomers:
|
||||
echo "select customer"
|
||||
result = arg
|
||||
|
||||
echo dispatch(nil, %*{"cmd": "insertCustomer", "arg": [1, 2, 3]})
|
||||
@@ -0,0 +1,178 @@
|
||||
import std/[macros, strutils, tables]
|
||||
import db_connector/db_common
|
||||
|
||||
import ./db_types
|
||||
import ./parsesql_tmp
|
||||
|
||||
const
|
||||
FileHeader* = """
|
||||
# Generated by ormin_importer. DO NOT EDIT.
|
||||
|
||||
type
|
||||
Attr = object
|
||||
name: string
|
||||
tabIndex: int
|
||||
typ: DbTypekind
|
||||
key: int # 0 nothing special,
|
||||
# +1 -- primary key
|
||||
# -N -- references attribute N
|
||||
"""
|
||||
|
||||
type
|
||||
DbColumn* = object
|
||||
name*: string
|
||||
tableName*: string
|
||||
typ*: DbType
|
||||
primaryKey*: bool
|
||||
refs*: (string, string)
|
||||
DbColumns* = seq[DbColumn]
|
||||
|
||||
KnownTables* = OrderedTable[string, DbColumns]
|
||||
ImportTarget* = enum
|
||||
postgre, sqlite, mysql, baradb
|
||||
|
||||
proc hasAttribute(colDesc: SqlNode; k: set[SqlNodeKind]): bool =
|
||||
for i in 2 ..< colDesc.len:
|
||||
if colDesc[i].kind in k:
|
||||
return true
|
||||
|
||||
proc hasRefs(colDesc: SqlNode): (string, string) =
|
||||
for i in 2 ..< colDesc.len:
|
||||
let c = colDesc[i]
|
||||
if c.kind == nkReferences:
|
||||
if c[0].kind == nkCall:
|
||||
return (c[0][0].strVal, c[0][1].strVal)
|
||||
elif c[0].kind == nkIdent:
|
||||
return ($c[0], "id")
|
||||
("", "")
|
||||
|
||||
proc getType(n: SqlNode): DbType =
|
||||
var it = n
|
||||
if it.kind == nkCall:
|
||||
it = it[0]
|
||||
if it.kind == nkEnumDef:
|
||||
result.kind = dbEnum
|
||||
result.validValues = @[]
|
||||
for i in 0 ..< it.len:
|
||||
assert it[i].kind == nkStringLit
|
||||
result.validValues.add it[i].strVal
|
||||
elif it.kind in {nkIdent, nkStringLit}:
|
||||
result.kind = dbTypFromName(it.strVal)
|
||||
result.name = it.strVal
|
||||
|
||||
proc collectTables*(n: SqlNode; t: var KnownTables) =
|
||||
if n.isNil:
|
||||
return
|
||||
case n.kind
|
||||
of nkCreateTable, nkCreateTableIfNotExists:
|
||||
let tableName = n[0].strVal
|
||||
var cols: DbColumns = @[]
|
||||
for i in 1 ..< n.len:
|
||||
let it = n[i]
|
||||
if it.kind == nkColumnDef:
|
||||
var typ = getType(it[1])
|
||||
if hasAttribute(it, {nkNotNull}):
|
||||
typ.notNull = true
|
||||
cols.add DbColumn(
|
||||
name: it[0].strVal,
|
||||
tableName: tableName,
|
||||
typ: typ,
|
||||
primaryKey: hasAttribute(it, {nkPrimaryKey}),
|
||||
refs: hasRefs(it)
|
||||
)
|
||||
for i in 1 ..< n.len:
|
||||
let it = n[i]
|
||||
if it.kind == nkForeignKey:
|
||||
var refNode: SqlNode = nil
|
||||
var localCols: seq[string] = @[]
|
||||
for j in 0 ..< it.len:
|
||||
let child = it[j]
|
||||
if child.kind == nkReferences:
|
||||
refNode = child
|
||||
elif child.kind == nkIdent:
|
||||
localCols.add(child.strVal)
|
||||
if refNode.isNil:
|
||||
continue
|
||||
var r = refNode[0]
|
||||
var refTable = ""
|
||||
var refCols: seq[string] = @[]
|
||||
if r.kind == nkColumnReference or r.kind == nkCall:
|
||||
refTable = r[0].strVal
|
||||
for k in 1 ..< r.len:
|
||||
refCols.add(r[k].strVal)
|
||||
let pairCount = min(localCols.len, refCols.len)
|
||||
for k in 0 ..< pairCount:
|
||||
let localName = localCols[k]
|
||||
let targetCol = refCols[k]
|
||||
for c in mitems(cols):
|
||||
if cmpIgnoreCase(c.name, localName) == 0:
|
||||
c.refs = (refTable, targetCol)
|
||||
break
|
||||
t[tableName] = cols
|
||||
else:
|
||||
for i in 0 ..< n.len:
|
||||
collectTables(n[i], t)
|
||||
|
||||
proc attrToKey(a: DbColumn; t: KnownTables): int =
|
||||
if a.primaryKey:
|
||||
return 1
|
||||
if a.refs[0].len > 0:
|
||||
var i = 0
|
||||
for k, v in pairs(t):
|
||||
for b in v:
|
||||
if cmpIgnoreCase(k, a.refs[0]) == 0 and cmpIgnoreCase(b.name, a.refs[1]) == 0:
|
||||
return -i - 1
|
||||
inc i
|
||||
0
|
||||
|
||||
proc renderModelCode(schemaSql, schemaPath: string; target: ImportTarget; includeStatic = false): string =
|
||||
discard target
|
||||
let sql = parseSql(schemaSql, schemaPath)
|
||||
var knownTables = initOrderedTable[string, DbColumns]()
|
||||
collectTables(sql, knownTables)
|
||||
|
||||
result.add FileHeader
|
||||
result.add "const tableNames = ["
|
||||
var i = 0
|
||||
for k in keys(knownTables):
|
||||
if i > 0:
|
||||
result.add ",\n "
|
||||
else:
|
||||
result.add "\n "
|
||||
result.add escape(k.toLowerAscii)
|
||||
inc i
|
||||
result.add "\n]\n"
|
||||
|
||||
i = 0
|
||||
var j = 0
|
||||
result.add "\nconst attributes = ["
|
||||
for _, v in mpairs(knownTables):
|
||||
for a in v:
|
||||
if j > 0:
|
||||
result.add ",\n "
|
||||
else:
|
||||
result.add "\n "
|
||||
result.add "Attr(name: "
|
||||
result.add escape(a.name.toLowerAscii)
|
||||
result.add ", tabIndex: "
|
||||
result.add $i
|
||||
result.add ", typ: "
|
||||
result.add $a.typ.kind
|
||||
result.add ", key: "
|
||||
result.add $attrToKey(a, knownTables)
|
||||
result.add ")"
|
||||
inc j
|
||||
inc i
|
||||
result.add "\n]\n"
|
||||
|
||||
if includeStatic:
|
||||
result.add "\nconst sqlSchema* = staticLoad("
|
||||
result.add escape(schemaPath)
|
||||
result.add ")\n"
|
||||
|
||||
proc generateModelCode*(schemaSql, schemaPath: string; target: ImportTarget; includeStatic = false): string =
|
||||
renderModelCode(schemaSql, schemaPath, target, includeStatic)
|
||||
|
||||
macro generateModelCode*(schemaSql: static[string], schemaPath: static[string],
|
||||
target: static[ImportTarget], includeStatic: static[bool] = false): untyped =
|
||||
parseStmt(renderModelCode(schemaSql, schemaPath, target, includeStatic))
|
||||
@@ -0,0 +1,231 @@
|
||||
import std/[strutils, json, times, parseutils]
|
||||
import db_connector/db_common
|
||||
import query_hooks
|
||||
export db_common
|
||||
|
||||
import baradb/client
|
||||
export client.WireValue, client.FieldKind
|
||||
|
||||
type
|
||||
DbConn* = SyncClient
|
||||
PStmt = object
|
||||
sql: string
|
||||
|
||||
varcharType* = string
|
||||
intType* = int
|
||||
floatType* = float
|
||||
boolType* = bool
|
||||
timestampType* = DateTime
|
||||
serialType* = int
|
||||
jsonType* = JsonNode
|
||||
|
||||
var jsonTimeFormat* = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
proc dbError*(db: DbConn) {.noreturn.} =
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = "BaraDB query failed"
|
||||
raise e
|
||||
|
||||
proc prepareStmt*(db: DbConn; q: string): PStmt =
|
||||
when defined(debugOrminTrace):
|
||||
echo "[[Ormin Executing]]: ", q
|
||||
result.sql = q
|
||||
|
||||
template startBindings*(s: PStmt; n: int) {.dirty.} =
|
||||
var pparams: seq[WireValue] = newSeq[WireValue](n)
|
||||
|
||||
template bindParam*(db: DbConn; s: PStmt; idx: int; x: untyped; t: untyped) =
|
||||
when t is DateTime:
|
||||
let xx = x.format("yyyy-MM-dd HH:mm:ss")
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: $xx)
|
||||
elif t is int or t is int64:
|
||||
pparams[idx-1] = WireValue(kind: fkInt64, int64Val: int64(x))
|
||||
elif t is float or t is float64:
|
||||
pparams[idx-1] = WireValue(kind: fkFloat64, float64Val: float64(x))
|
||||
elif t is bool:
|
||||
pparams[idx-1] = WireValue(kind: fkBool, boolVal: bool(x))
|
||||
elif t is string:
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: string(x))
|
||||
elif t is JsonNode:
|
||||
pparams[idx-1] = WireValue(kind: fkJson, jsonVal: $x)
|
||||
else:
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: $x)
|
||||
|
||||
template bindNullParam*(db: DbConn; s: PStmt; idx: int) =
|
||||
pparams[idx-1] = WireValue(kind: fkNull)
|
||||
|
||||
template bindParamJson*(db: DbConn; s: PStmt; idx: int; xx: JsonNode;
|
||||
t: typedesc) =
|
||||
let x = xx
|
||||
if x.kind == JNull:
|
||||
pparams[idx-1] = WireValue(kind: fkNull)
|
||||
else:
|
||||
bindFromJson(db, s, idx, x, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[string]) =
|
||||
doAssert x.kind == JString
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: x.str)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[int|int64]) =
|
||||
doAssert x.kind == JInt
|
||||
pparams[idx-1] = WireValue(kind: fkInt64, int64Val: x.num)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[float64]) =
|
||||
doAssert x.kind == JFloat
|
||||
pparams[idx-1] = WireValue(kind: fkFloat64, float64Val: x.fnum)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[bool]) =
|
||||
doAssert x.kind == JBool
|
||||
pparams[idx-1] = WireValue(kind: fkBool, boolVal: x.bval)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[DateTime]) =
|
||||
doAssert x.kind == JString
|
||||
pparams[idx-1] = WireValue(kind: fkString, strVal: x.str)
|
||||
|
||||
template startQuery*(db: DbConn; s: PStmt) =
|
||||
when declared(pparams):
|
||||
var queryResult {.inject.} = db.query(s.sql, pparams)
|
||||
else:
|
||||
var queryResult {.inject.} = db.query(s.sql)
|
||||
var queryI {.inject.} = -1
|
||||
var queryLen {.inject.} = queryResult.rowCount
|
||||
|
||||
template stopQuery*(db: DbConn; s: PStmt) =
|
||||
discard
|
||||
|
||||
template stepQuery*(db: DbConn; s: PStmt; returnsData: bool): bool =
|
||||
inc queryI
|
||||
queryI < queryLen
|
||||
|
||||
template getLastId*(db: DbConn; s: PStmt): int =
|
||||
0
|
||||
|
||||
template getAffectedRows*(db: DbConn; s: PStmt): int =
|
||||
queryResult.affectedRows
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
client.close(db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
let colonPos = connection.find(':')
|
||||
let host = if colonPos < 0: connection else: connection[0..<colonPos]
|
||||
let portStr = if colonPos < 0: "9472" else: connection[colonPos+1..^1]
|
||||
let port = parseInt(portStr)
|
||||
let cfg = ClientConfig(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: user,
|
||||
password: password
|
||||
)
|
||||
result = newSyncClient(cfg)
|
||||
result.connect()
|
||||
|
||||
# --- Result binding helpers ---
|
||||
|
||||
template currentValue(s: PStmt; idx: int): string =
|
||||
queryResult.rows[queryI][idx-1]
|
||||
|
||||
template columnIsNull*(db: DbConn; s: PStmt; idx: int): bool =
|
||||
currentValue(s, idx).len == 0
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int;
|
||||
t: typedesc; name: string) =
|
||||
dest = int(parseBiggestInt(currentValue(s, idx)))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int64;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseBiggestInt(currentValue(s, idx))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: bool;
|
||||
t: typedesc; name: string) =
|
||||
let v = currentValue(s, idx)
|
||||
dest = v == "true" or v == "1" or v == "t"
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var string;
|
||||
t: typedesc; name: string) =
|
||||
dest = currentValue(s, idx)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: float64;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseFloat(currentValue(s, idx))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var DateTime;
|
||||
t: typedesc; name: string) =
|
||||
let src = currentValue(s, idx)
|
||||
if src.len > 0:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss")
|
||||
else:
|
||||
dest = initDateTime(1, 1, 1, 0, 0, 0, utc())
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseJson(currentValue(s, idx))
|
||||
|
||||
template bindResult*[T](db: DbConn; s: PStmt; idx: int; dest: var DbValue[T];
|
||||
t: typedesc; name: string) =
|
||||
let v = currentValue(s, idx)
|
||||
if v.len == 0:
|
||||
dest.isNull = true
|
||||
else:
|
||||
dest.isNull = false
|
||||
when T is string:
|
||||
dest.value = v
|
||||
else:
|
||||
bindResult(db, s, idx, dest.value, t, name)
|
||||
|
||||
template createJObject*(): untyped = newJObject()
|
||||
template createJArray*(): untyped = newJArray()
|
||||
|
||||
template bindResultJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let x = obj
|
||||
doAssert x.kind == JObject
|
||||
let v = currentValue(s, idx)
|
||||
if v.len == 0:
|
||||
x[name] = newJNull()
|
||||
else:
|
||||
bindToJson(db, s, idx, x, t, name)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[string]; name: string) =
|
||||
obj[name] = newJString(currentValue(s, idx))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[int|int64]; name: string) =
|
||||
var v: int64
|
||||
discard parseBiggestInt(currentValue(s, idx), v)
|
||||
obj[name] = newJInt(v)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[float64]; name: string) =
|
||||
obj[name] = newJFloat(parseFloat(currentValue(s, idx)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[bool]; name: string) =
|
||||
let v = currentValue(s, idx)
|
||||
obj[name] = newJBool(v == "true" or v == "1" or v == "t")
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[DateTime]; name: string) =
|
||||
var dt: DateTime
|
||||
bindResult(db, s, idx, dt, t, name)
|
||||
obj[name] = newJString(format(dt, jsonTimeFormat))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[JsonNode]; name: string) =
|
||||
obj[name] = parseJson(currentValue(s, idx))
|
||||
@@ -0,0 +1,274 @@
|
||||
import strutils, db_connector/postgres, json, times
|
||||
|
||||
import db_connector/db_common
|
||||
import query_hooks
|
||||
export db_common
|
||||
|
||||
type
|
||||
DbConn* = PPGconn ## encapsulates a database connection
|
||||
PStmt = string ## a identifier for the prepared queries
|
||||
|
||||
varcharType* = string
|
||||
intType* = int
|
||||
floatType* = float
|
||||
boolType* = bool
|
||||
timestampType* = Datetime
|
||||
serialType* = int
|
||||
jsonType* = JsonNode
|
||||
|
||||
var jsonTimeFormat* = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
proc dbError*(db: DbConn) {.noreturn.} =
|
||||
## raises a DbError exception.
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = $pqErrorMessage(db)
|
||||
raise e
|
||||
|
||||
proc c_strtod(buf: cstring, endptr: ptr cstring = nil): float64 {.
|
||||
importc: "strtod", header: "<stdlib.h>", noSideEffect.}
|
||||
|
||||
proc c_strtol(buf: cstring, endptr: ptr cstring = nil, base: cint = 10): int {.
|
||||
importc: "strtol", header: "<stdlib.h>", noSideEffect.}
|
||||
|
||||
var sid {.compileTime.}: int
|
||||
|
||||
proc prepareStmt*(db: DbConn; q: string): PStmt =
|
||||
when defined(debugOrminTrace):
|
||||
echo "[[Ormin Executing]]: ", q
|
||||
inc sid
|
||||
result = "ormin" & $sid
|
||||
var res = pqprepare(db, result, q, 0, nil)
|
||||
if pqResultStatus(res) != PGRES_COMMAND_OK: dbError(db)
|
||||
|
||||
template startBindings*(s: PStmt; n: int) {.dirty.} =
|
||||
# pparams is a duplicated array to keep the Nim string alive
|
||||
# for the duration of the query. This is safer than relying
|
||||
# on the conservative stack marking:
|
||||
var pparams: array[n, string]
|
||||
var parr: array[n, cstring]
|
||||
|
||||
template bindParam*(db: DbConn; s: PStmt; idx: int; x: untyped; t: untyped) =
|
||||
# when not (x is t):
|
||||
# {.error: "type mismatch for query argument at position " & $idx.}
|
||||
when t is DateTime:
|
||||
let xx = x.format("yyyy-MM-dd HH:mm:ss\'.\'ffffffzzzz")
|
||||
pparams[idx-1] = $xx
|
||||
else:
|
||||
pparams[idx-1] = $x
|
||||
|
||||
parr[idx-1] = cstring(pparams[idx-1])
|
||||
|
||||
template bindParamUnchecked(db: DbConn; s: PStmt; idx: int; x: untyped; t: untyped) =
|
||||
pparams[idx-1] = $x
|
||||
parr[idx-1] = cstring(pparams[idx-1])
|
||||
|
||||
template bindNullParam*(db: DbConn; s: PStmt; idx: int) =
|
||||
parr[idx-1] = cstring(nil)
|
||||
|
||||
template bindParamJson*(db: DbConn; s: PStmt; idx: int; xx: JsonNode;
|
||||
t: typedesc) =
|
||||
let x = xx
|
||||
if x.kind == JNull:
|
||||
# a NULL entry is not reflected in the 'pparams' array:
|
||||
parr[idx-1] = cstring(nil)
|
||||
else:
|
||||
bindFromJson(db, s, idx, x, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[string]) =
|
||||
doAssert x.kind == JString
|
||||
let xs = x.str
|
||||
bindParamUnchecked(db, s, idx, xs, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[int|int64]) =
|
||||
doAssert x.kind == JInt
|
||||
let xi = x.num
|
||||
bindParamUnchecked(db, s, idx, xi, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[float64]) =
|
||||
doAssert x.kind == JFloat
|
||||
let xf = x.fnum
|
||||
bindParamUnchecked(db, s, idx, xf, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[bool]) =
|
||||
doAssert x.kind == JBool
|
||||
let xb = x.bval
|
||||
bindParamUnchecked(db, s, idx, xb, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[DateTime]) =
|
||||
doAssert x.kind == JString
|
||||
let dt = x.str
|
||||
bindParamUnchecked(db, s, idx, dt, t)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int;
|
||||
t: typedesc; name: string) =
|
||||
dest = c_strtol(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int64;
|
||||
t: typedesc; name: string) =
|
||||
dest = c_strtol(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
# XXX This needs testing:
|
||||
template isTrue(x): untyped = x[0] == 't'
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: bool;
|
||||
t: typedesc; name: string) =
|
||||
dest = isTrue(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
proc fillString(dest: var string; src: cstring; srcLen: int) {.inline.} =
|
||||
dest = ""
|
||||
var i = 0
|
||||
while src[i] != '\0':
|
||||
dest.add src[i]
|
||||
inc i
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var string;
|
||||
t: typedesc; name: string) =
|
||||
if pqgetisnull(queryResult, queryI, idx.cint) != 0:
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, 0)
|
||||
else:
|
||||
dest = nil
|
||||
else:
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
let srcLen = int(pqgetlength(queryResult, queryI, idx.cint))
|
||||
fillString(dest, src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: float64;
|
||||
t: typedesc; name: string) =
|
||||
dest = c_strtod(pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var DateTime;
|
||||
t: typedesc; name: string) =
|
||||
let
|
||||
src = $pqgetvalue(queryResult, queryI, idx.cint)
|
||||
i = src.find('.')
|
||||
tzflag = src[src.len-3]
|
||||
if i < 0:
|
||||
if tzflag in {'+', '-'}:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:sszz")
|
||||
else:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss")
|
||||
else:
|
||||
if tzflag in {'+', '-'}:
|
||||
let itz = src.len - 3
|
||||
let dtstr = src[0..<itz] & '0'.repeat(10-src.len+i) & src[src.len-3 .. ^1]
|
||||
dest = parse(dtstr, "yyyy-MM-dd HH:mm:ss\'.\'ffffffzz")
|
||||
else:
|
||||
let dtstr = src & '0'.repeat(7-src.len+i)
|
||||
dest = parse(dtstr, "yyyy-MM-dd HH:mm:ss\'.\'ffffff")
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
dest = parseJson($pqgetvalue(queryResult, queryI, idx.cint))
|
||||
|
||||
template bindResult*[T](db: DbConn; s: PStmt; idx: int; dest: var DbValue[T];
|
||||
t: typedesc; name: string) =
|
||||
if pqgetisnull(queryResult, queryI, idx.cint) != 0:
|
||||
dest.isNull = true
|
||||
else:
|
||||
dest.isNull = false
|
||||
when T is string:
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
let srcLen = int(pqgetlength(queryResult, queryI, idx.cint))
|
||||
fillString(dest.value, src, srcLen)
|
||||
else:
|
||||
bindResult(db, s, idx, dest.value, t, name)
|
||||
|
||||
template createJObject*(): untyped = newJObject()
|
||||
template createJArray*(): untyped = newJArray()
|
||||
|
||||
template bindResultJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let x = obj
|
||||
doAssert x.kind == JObject
|
||||
if pqgetisnull(queryResult, queryI, idx.cint) != 0:
|
||||
x[name] = newJNull()
|
||||
else:
|
||||
bindToJson(db, s, idx, x, t, name)
|
||||
|
||||
template columnIsNull*(db: DbConn; s: PStmt; idx: int): bool =
|
||||
pqgetisnull(queryResult, queryI, idx.cint) != 0
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[string]; name: string) =
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
obj[name] = newJString($src)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[int|int64]; name: string) =
|
||||
obj[name] = newJInt(c_strtol(pqgetvalue(queryResult, queryI, idx.cint)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[float64]; name: string) =
|
||||
obj[name] = newJFloat(c_strtod(pqgetvalue(queryResult, queryI, idx.cint)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[bool]; name: string) =
|
||||
obj[name] = newJBool(isTrue(pqgetvalue(queryResult, queryI, idx.cint)))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[DateTime]; name: string) =
|
||||
var dt: DateTime
|
||||
bindResult(db, s, idx, dt, t, name)
|
||||
obj[name] = newJString(format(dt, jsonTimeFormat))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[JsonNode]; name: string) =
|
||||
let src = pqgetvalue(queryResult, queryI, idx.cint)
|
||||
obj[name] = parseJson($src)
|
||||
|
||||
template startQuery*(db: DbConn; s: PStmt) =
|
||||
when declared(pparams):
|
||||
var queryResult {.inject.} = pqexecPrepared(db, s, int32(parr.len),
|
||||
cast[cstringArray](addr parr), nil, nil, 0)
|
||||
else:
|
||||
var queryResult {.inject.} = pqexecPrepared(db, s, int32(0),
|
||||
nil, nil, nil, 0)
|
||||
if pqResultStatus(queryResult) == PGRES_COMMAND_OK:
|
||||
discard # insert does not returns data in pg
|
||||
elif pqResultStatus(queryResult) != PGRES_TUPLES_OK: ormin_postgre.dbError(db)
|
||||
var queryI {.inject.} = cint(-1)
|
||||
var queryLen {.inject.} = pqntuples(queryResult)
|
||||
|
||||
template stopQuery*(db: DbConn; s: PStmt) =
|
||||
pqclear(queryResult)
|
||||
|
||||
template stepQuery*(db: DbConn; s: PStmt; returnsData: bool): bool =
|
||||
inc queryI
|
||||
queryI < queryLen
|
||||
|
||||
template getLastId*(db: DbConn; s: PStmt): int = 0 # XXX to implement
|
||||
|
||||
template getAffectedRows*(db: DbConn; s: PStmt): int =
|
||||
c_strtol(pqcmdTuples(queryResult))
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
## closes the database connection.
|
||||
if db != nil: pqfinish(db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
## opens a database connection. Raises `DbError` if the connection could not
|
||||
## be established.
|
||||
let
|
||||
colonPos = connection.find(':')
|
||||
host = if colonPos < 0: connection
|
||||
else: substr(connection, 0, colonPos-1)
|
||||
port = if colonPos < 0: ""
|
||||
else: substr(connection, colonPos+1)
|
||||
result = pqsetdbLogin(host, port, nil, nil, database, user, password)
|
||||
if pqStatus(result) != CONNECTION_OK: dbError(result) # result = nil
|
||||
@@ -0,0 +1,295 @@
|
||||
|
||||
{.deadCodeElim: on.}
|
||||
|
||||
import json, times
|
||||
|
||||
import db_connector/db_common
|
||||
import db_connector/sqlite3
|
||||
import query_hooks
|
||||
export db_common
|
||||
|
||||
type
|
||||
DbConn* = PSqlite3 ## encapsulates a database connection
|
||||
|
||||
varcharType* = string
|
||||
blobType* = seq[byte]
|
||||
intType* = int
|
||||
floatType* = float
|
||||
boolType* = bool
|
||||
timestampType* = DateTime
|
||||
jsonType* = JsonNode
|
||||
|
||||
var jsonTimeFormat* = "yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
proc dbError*(db: DbConn) {.noreturn.} =
|
||||
## raises a DbError exception.
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = $sqlite3.errmsg(db)
|
||||
raise e
|
||||
|
||||
proc prepareStmt*(db: DbConn; q: string): PStmt =
|
||||
when defined(debugOrminTrace):
|
||||
echo "[[Ormin Executing]]: ", q
|
||||
if prepare_v2(db, q, q.len.cint, result, nil) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template startBindings*(s: PStmt; n: int) =
|
||||
if clear_bindings(s) != SQLITE_OK: dbError(db)
|
||||
|
||||
template bindParam*(db: DbConn; s: PStmt; idx: int; x, t: untyped) =
|
||||
#when not (x is t):
|
||||
# {.error: "type mismatch for query argument at position " & $idx.}
|
||||
when t is blobType:
|
||||
let xs = x
|
||||
let blobPtr = if xs.len == 0:
|
||||
cast[pointer](nil)
|
||||
else:
|
||||
cast[pointer](unsafeAddr(xs[0]))
|
||||
if bind_blob(s, idx.cint, blobPtr, xs.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is int or t is int64 or t is bool:
|
||||
if bind_int64(s, idx.cint, x.int64) != SQLITE_OK: dbError(db)
|
||||
elif t is string:
|
||||
if bind_text(s, idx.cint, cstring(x), x.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is float64:
|
||||
if bind_double(s, idx.cint, x) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is DateTime:
|
||||
let xx = if x.nanosecond > 0:
|
||||
x.utc().format("yyyy-MM-dd HH:mm:ss\'.\'fff")
|
||||
else:
|
||||
x.utc().format("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
if bind_text(s, idx.cint, cstring(xx), xx.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
elif t is JsonNode:
|
||||
let xx = $x
|
||||
if bind_text(s, idx.cint, cstring(xx), xx.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
else:
|
||||
{.error: "type mismatch for query argument at position " & $idx.}
|
||||
|
||||
template bindNullParam*(db: DbConn; s: PStmt; idx: int) =
|
||||
if bind_null(s, idx.cint) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindParamJson*(db: DbConn; s: PStmt; idx: int; xx: JsonNode;
|
||||
t: typedesc) =
|
||||
let x = xx
|
||||
if x.kind == JNull:
|
||||
if bind_null(s, idx.cint) != SQLITE_OK: dbError(db)
|
||||
else:
|
||||
bindFromJson(db, s, idx, x, t)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[string]) =
|
||||
doAssert x.kind == JString
|
||||
let xs = x.str
|
||||
if bind_text(s, idx.cint, cstring(xs), xs.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[int|int64]) =
|
||||
doAssert x.kind == JInt
|
||||
let xi = x.num
|
||||
if bind_int64(s, idx.cint, xi.int64) != SQLITE_OK: dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[float64]) =
|
||||
doAssert x.kind == JFloat
|
||||
let xf = x.fnum
|
||||
if bind_double(s, idx.cint, xf) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[bool]) =
|
||||
doAssert x.kind == JBool
|
||||
let xb = x.bval
|
||||
if bind_int64(s, idx.cint, xb.int64) != SQLITE_OK: dbError(db)
|
||||
|
||||
template bindFromJson*(db: DbConn; s: PStmt; idx: int; x: JsonNode;
|
||||
t: typedesc[DateTime]) =
|
||||
doAssert x.kind == JString
|
||||
let
|
||||
dtStr = x.str
|
||||
i = dtStr.find('.')
|
||||
dt = if i > 0 and dtStr[i+1 .. ^1] == "000":
|
||||
dtStr[0 ..< i]
|
||||
else:
|
||||
dtStr
|
||||
if bind_text(s, idx.cint, cstring(dt), dt.len.cint, SQLITE_TRANSIENT) != SQLITE_OK:
|
||||
dbError(db)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int;
|
||||
t: typedesc; name: string) =
|
||||
dest = int column_int64(s, idx.cint)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: int64;
|
||||
t: typedesc; name: string) =
|
||||
dest = column_int64(s, idx.cint)
|
||||
|
||||
proc fillString(dest: var string; src: cstring; srcLen: int) =
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, srcLen)
|
||||
else:
|
||||
if dest.isNil: dest = newString(srcLen)
|
||||
else: setLen(dest, srcLen)
|
||||
if srcLen > 0:
|
||||
copyMem(unsafeAddr(dest[0]), src, srcLen)
|
||||
|
||||
proc fillBytes(dest: var seq[byte]; src: pointer; srcLen: int) =
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, srcLen)
|
||||
else:
|
||||
if dest.isNil: dest = newSeq[byte](srcLen)
|
||||
else: setLen(dest, srcLen)
|
||||
if srcLen > 0:
|
||||
copyMem(unsafeAddr(dest[0]), src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var string;
|
||||
t: typedesc; name: string) =
|
||||
if column_type(s, idx.cint) == SQLITE_NULL:
|
||||
when defined(nimNoNilSeqs):
|
||||
setLen(dest, 0)
|
||||
else:
|
||||
dest = nil
|
||||
else:
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_text(s, idx.cint)
|
||||
fillString(dest, src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: var blobType;
|
||||
t: typedesc; name: string) =
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_blob(s, idx.cint)
|
||||
fillBytes(dest, src, srcLen)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: float64;
|
||||
t: typedesc; name: string) =
|
||||
dest = column_double(s, idx.cint)
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: bool;
|
||||
t: typedesc; name: string) =
|
||||
dest = column_int64(s, idx.cint) != 0
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: DateTime;
|
||||
t: typedesc; name: string) =
|
||||
let
|
||||
src = $column_text(s, idx.cint)
|
||||
i = src.find('.')
|
||||
if i < 0:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss", utc())
|
||||
else:
|
||||
dest = parse(src, "yyyy-MM-dd HH:mm:ss\'.\'fff", utc())
|
||||
|
||||
template bindResult*(db: DbConn; s: PStmt; idx: int; dest: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let src = column_text(s, idx.cint)
|
||||
dest = parseJson($src)
|
||||
|
||||
template bindResult*[T](db: DbConn; s: PStmt; idx: int; dest: var DbValue[T];
|
||||
t: typedesc; name: string) =
|
||||
if column_type(s, idx.cint) == SQLITE_NULL:
|
||||
dest.isNull = true
|
||||
else:
|
||||
dest.isNull = false
|
||||
when T is string:
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_text(s, idx.cint)
|
||||
fillString(dest.value, src, srcLen)
|
||||
else:
|
||||
bindResult(db, s, idx, dest.value, t, name)
|
||||
|
||||
template createJObject*(): untyped = newJObject()
|
||||
template createJArray*(): untyped = newJArray()
|
||||
|
||||
template bindResultJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
let x = obj
|
||||
doAssert x.kind == JObject
|
||||
if column_type(s, idx.cint) == SQLITE_NULL:
|
||||
x[name] = newJNull()
|
||||
else:
|
||||
bindToJson(db, s, idx, x, t, name)
|
||||
|
||||
template columnIsNull*(db: DbConn; s: PStmt; idx: int): bool =
|
||||
column_type(s, idx.cint) == SQLITE_NULL
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc; name: string) =
|
||||
{.error: "invalid type for JSON object".}
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[string]; name: string) =
|
||||
let dest = newJString("")
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_text(s, idx.cint)
|
||||
fillString(dest.str, src, srcLen)
|
||||
obj[name] = dest
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[int|int64]; name: string) =
|
||||
obj[name] = newJInt(column_int64(s, idx.cint))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[blobType]; name: string) =
|
||||
let srcLen = column_bytes(s, idx.cint)
|
||||
let src = column_blob(s, idx.cint)
|
||||
var data: blobType
|
||||
fillBytes(data, src, srcLen)
|
||||
let arr = newJArray()
|
||||
for b in data:
|
||||
arr.add newJInt(int(b))
|
||||
obj[name] = arr
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[float64]; name: string) =
|
||||
obj[name] = newJFloat(column_double(s, idx.cint))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[bool]; name: string) =
|
||||
obj[name] = newJBool(column_int64(s, idx.cint) != 0)
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[DateTime]; name: string) =
|
||||
var dt: DateTime
|
||||
bindResult(db, s, idx, dt, t, name)
|
||||
obj[name] = newJString(format(dt, jsonTimeFormat))
|
||||
|
||||
template bindToJson*(db: DbConn; s: PStmt; idx: int; obj: JsonNode;
|
||||
t: typedesc[JsonNode]; name: string) =
|
||||
let src = column_text(s, idx.cint)
|
||||
obj[name] = parseJson($src)
|
||||
|
||||
template startQuery*(db: DbConn; s: PStmt) = discard "nothing to do"
|
||||
|
||||
template stopQuery*(db: DbConn; s: PStmt) =
|
||||
if sqlite3.reset(s) != SQLITE_OK: dbError(db)
|
||||
|
||||
template stepQuery*(db: DbConn; s: PStmt; returnsData: bool): bool =
|
||||
when returnsData:
|
||||
step(s) == SQLITE_ROW
|
||||
else:
|
||||
step(s) == SQLITE_DONE
|
||||
|
||||
template getLastId*(db: DbConn; s: PStmt): int =
|
||||
int(last_insert_rowid(db))
|
||||
|
||||
template getAffectedRows*(db: DbConn; s: PStmt): int =
|
||||
int(changes(db))
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
## closes the database connection.
|
||||
if sqlite3.close(db) != SQLITE_OK: dbError(db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
## opens a database connection. Raises `EDb` if the connection could not
|
||||
## be established. Only the ``connection`` parameter is used for ``sqlite``.
|
||||
if sqlite3.open(connection, result) != SQLITE_OK:
|
||||
dbError(result)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import options, json
|
||||
|
||||
type
|
||||
DbValue*[T] = object
|
||||
isNull*: bool
|
||||
value*: T
|
||||
|
||||
template fromQueryHook*[T, S](val: var T, x: S) =
|
||||
## Default conversion hook used by `query(T): ...`.
|
||||
## Users can overload this proc to customize field/type conversions.
|
||||
val = x
|
||||
|
||||
template toQueryHook*[T, S](val: var T, x: S) =
|
||||
## Default conversion hook used for query parameters.
|
||||
## Users can overload this proc to customize parameter conversions.
|
||||
val = x
|
||||
|
||||
proc nullQueryValueError() {.noreturn.} =
|
||||
raise newException(ValueError, "cannot map NULL query result")
|
||||
|
||||
proc fromQueryHook*[T, S](val: var Option[T], x: var DbValue[S]) =
|
||||
if x.isNull:
|
||||
val = none(T)
|
||||
else:
|
||||
var converted: T
|
||||
fromQueryHook(converted, move x.value)
|
||||
val = some(converted)
|
||||
|
||||
proc fromQueryHook*[T, S](val: var T, x: var DbValue[S]) =
|
||||
if x.isNull:
|
||||
when T is string:
|
||||
val = ""
|
||||
elif T is JsonNode:
|
||||
val = newJNull()
|
||||
else:
|
||||
nullQueryValueError()
|
||||
else:
|
||||
fromQueryHook(val, move x.value)
|
||||
|
||||
proc bindFromQueryHook*[T, S](dest: var T, x: var DbValue[S]) =
|
||||
fromQueryHook(dest, x)
|
||||
|
||||
proc toQueryHook*[S, T](val: var DbValue[S], x: Option[T]) =
|
||||
if x.isSome:
|
||||
val.isNull = false
|
||||
toQueryHook(val.value, x.get)
|
||||
else:
|
||||
val.isNull = true
|
||||
when compiles(val.value = default(S)):
|
||||
val.value = default(S)
|
||||
|
||||
proc toQueryHook*[S, T](val: var DbValue[S], x: T) =
|
||||
val.isNull = false
|
||||
toQueryHook(val.value, x)
|
||||
@@ -0,0 +1,139 @@
|
||||
|
||||
import asynchttpserver, asyncdispatch, asyncnet, json,
|
||||
strutils, times
|
||||
|
||||
import websocket
|
||||
|
||||
type
|
||||
Receivers* {.pure} = enum
|
||||
sender, allExceptSender, all
|
||||
ReqHandler* = proc (msg: JsonNode; receivers: var Receivers): JsonNode {.
|
||||
closure, gcsafe.}
|
||||
|
||||
proc error(msg: string) = echo "[Error] ", msg
|
||||
proc warn(msg: string) = echo "[Warning] ", msg
|
||||
proc hint(msg: string) = echo "[Hint] ", msg
|
||||
|
||||
type
|
||||
Client = ref object
|
||||
socket: AsyncWebSocket
|
||||
connected: bool
|
||||
hostname: string
|
||||
id: int
|
||||
lastMessage: float
|
||||
rapidMessageCount: int
|
||||
|
||||
Server = ref object
|
||||
clients: seq[Client]
|
||||
needsUpdate: bool
|
||||
receivers: Receivers
|
||||
msgs: seq[(string, int)]
|
||||
gid: int
|
||||
handler: ReqHandler
|
||||
|
||||
proc newClient(s: Server; socket: AsyncWebSocket, hostname: string): Client =
|
||||
inc s.gid
|
||||
result = Client(socket: socket, connected: true, hostname: hostname, id: s.gid)
|
||||
|
||||
proc `$`(client: Client): string =
|
||||
"Client(ip: $1)" % [client.hostname]
|
||||
|
||||
proc updateClients(server: Server) {.async.} =
|
||||
while true:
|
||||
var needsUpdate = false
|
||||
for client in server.clients:
|
||||
if not client.connected:
|
||||
needsUpdate = true
|
||||
break
|
||||
|
||||
server.needsUpdate = server.needsUpdate or needsUpdate
|
||||
if server.needsUpdate and server.msgs.len != 0:
|
||||
var someDead = false
|
||||
# perform a copy to prevent the race condition:
|
||||
var msgs = server.msgs
|
||||
setLen(server.msgs, 0)
|
||||
for m in msgs:
|
||||
for c in server.clients:
|
||||
if c.connected:
|
||||
# do not send a broadcast message to the one which sent it:
|
||||
if server.receivers != Receivers.allExceptSender or c.id != m[1]:
|
||||
await c.socket.sendText(m[0]) #, false)
|
||||
else:
|
||||
someDead = true
|
||||
if someDead:
|
||||
var i = 0
|
||||
while i < server.clients.len:
|
||||
if not server.clients[i].connected: del(server.clients, i)
|
||||
else: inc i
|
||||
server.needsUpdate = false
|
||||
# let other stuff in the main loop run:
|
||||
await sleepAsync(10)
|
||||
|
||||
proc processMessage(server: Server, client: Client, data: string) {.async.} =
|
||||
# Check if last message was relatively recent. If so, kick the user.
|
||||
echo "processMessage ", data
|
||||
let now = epochTime()
|
||||
if now - client.lastMessage < 0.1: # 100ms
|
||||
client.rapidMessageCount.inc
|
||||
else:
|
||||
client.rapidMessageCount = 0
|
||||
|
||||
client.lastMessage = now
|
||||
if client.rapidMessageCount > 50:
|
||||
warn("Client ($1) is firing messages too rapidly. Killing." % $client)
|
||||
client.connected = false
|
||||
let msgj = parseJson(data)
|
||||
server.receivers = Receivers.sender
|
||||
if msgj.hasKey("msg") and msgj["msg"].getStr("") == "disconnect":
|
||||
client.connected = false
|
||||
server.needsUpdate = true
|
||||
else:
|
||||
let resp = server.handler(msgj, server.receivers)
|
||||
if not resp.isNil:
|
||||
if server.receivers == sender:
|
||||
await client.socket.sendText($resp) #, false)
|
||||
else:
|
||||
server.msgs.add(($resp, client.id))
|
||||
server.needsUpdate = true
|
||||
|
||||
proc processClient(server: Server, client: Client) {.async.} =
|
||||
while client.connected:
|
||||
var frameFut = client.socket.readData()
|
||||
yield frameFut
|
||||
if frameFut.failed:
|
||||
error("Error occurred handling client messages.\n" &
|
||||
frameFut.error.msg)
|
||||
client.connected = false
|
||||
break
|
||||
|
||||
let frame = frameFut.read()
|
||||
if frame.opcode == Opcode.Text:
|
||||
let processFut = processMessage(server, client, frame.data)
|
||||
if processFut.failed:
|
||||
echo processFut.error.getStackTrace()
|
||||
error("Client ($1) attempted to send bad JSON? " % $client & "\n" &
|
||||
processFut.error.msg)
|
||||
#client.connected = false
|
||||
await client.socket.close()
|
||||
|
||||
proc onRequest(server: Server, req: Request; key: string) {.async.} =
|
||||
let (ws, error) = await verifyWebsocketRequest(req, key)
|
||||
if ws != nil:
|
||||
hint("Client connected from " & req.hostname)
|
||||
ws.protocol = key
|
||||
server.clients.add(newClient(server, ws, req.hostname))
|
||||
asyncCheck processClient(server, server.clients[^1])
|
||||
else:
|
||||
warn("WS negotiation failed: " & $error)
|
||||
await req.respond(Http400, "WebSocket negotiation failed: " & $error)
|
||||
req.client.close()
|
||||
|
||||
proc serve*(key: string; handler: ReqHandler) =
|
||||
let httpServer = newAsyncHttpServer()
|
||||
let server = Server(clients: @[], msgs: @[], handler: handler, gid: 0)
|
||||
|
||||
proc cb(req: Request): Future[void] {.async, gcsafe.} =
|
||||
await onRequest(server, req, key)
|
||||
|
||||
asyncCheck updateClients(server)
|
||||
waitFor httpServer.serve(Port(8080), cb)
|
||||
@@ -0,0 +1,55 @@
|
||||
include system/inclrtl
|
||||
import macros
|
||||
|
||||
proc transLastStmt(n, res, bracketExpr: NimNode): (NimNode, NimNode, NimNode) =
|
||||
case n.kind
|
||||
of nnkIfExpr, nnkIfStmt, nnkTryStmt, nnkCaseStmt:
|
||||
result[0] = copyNimTree(n)
|
||||
result[1] = copyNimTree(n)
|
||||
result[2] = copyNimTree(n)
|
||||
for i in ord(n.kind == nnkCaseStmt)..<n.len:
|
||||
(result[0][i], result[1][^1], result[2][^1]) = transLastStmt(n[i], res, bracketExpr)
|
||||
of nnkStmtList, nnkStmtListExpr, nnkBlockStmt, nnkBlockExpr, nnkWhileStmt,
|
||||
nnkForStmt, nnkElifBranch, nnkElse, nnkElifExpr, nnkOfBranch, nnkExceptBranch:
|
||||
result[0] = copyNimTree(n)
|
||||
result[1] = copyNimTree(n)
|
||||
result[2] = copyNimTree(n)
|
||||
if n.len >= 1:
|
||||
(result[0][^1], result[1][^1], result[2][^1]) = transLastStmt(n[^1], res, bracketExpr)
|
||||
of nnkTableConstr:
|
||||
result[1] = n[0][0]
|
||||
result[2] = n[0][1]
|
||||
if bracketExpr.len == 1:
|
||||
bracketExpr.add([newCall(bindSym"typeof", newEmptyNode()), newCall(
|
||||
bindSym"typeof", newEmptyNode())])
|
||||
template adder(res, k, v) = res[k] = v
|
||||
result[0] = getAst(adder(res, n[0][0], n[0][1]))
|
||||
of nnkCurly:
|
||||
result[2] = n[0]
|
||||
if bracketExpr.len == 1:
|
||||
bracketExpr.add(newCall(bindSym"typeof", newEmptyNode()))
|
||||
template adder(res, v) = res.incl(v)
|
||||
result[0] = getAst(adder(res, n[0]))
|
||||
else:
|
||||
result[2] = n
|
||||
if bracketExpr.len == 1:
|
||||
bracketExpr.add(newCall(bindSym"typeof", newEmptyNode()))
|
||||
template adder(res, v) = res.add(v)
|
||||
result[0] = getAst(adder(res, n))
|
||||
|
||||
macro collect*(init, body: untyped): untyped =
|
||||
let res = genSym(nskVar, "collectResult")
|
||||
expectKind init, {nnkCall, nnkIdent, nnkSym}
|
||||
let bracketExpr = newTree(nnkBracketExpr,
|
||||
if init.kind == nnkCall: init[0] else: init)
|
||||
let (resBody, keyType, valueType) = transLastStmt(body, res, bracketExpr)
|
||||
if bracketExpr.len == 3:
|
||||
bracketExpr[1][1] = keyType
|
||||
bracketExpr[2][1] = valueType
|
||||
else:
|
||||
bracketExpr[1][1] = valueType
|
||||
let call = newTree(nnkCall, bracketExpr)
|
||||
if init.kind == nnkCall:
|
||||
for i in 1 ..< init.len:
|
||||
call.add init[i]
|
||||
result = newTree(nnkStmtListExpr, newVarStmt(res, call), resBody, res)
|
||||
@@ -0,0 +1 @@
|
||||
--path:"$projectDir/../../ormin"
|
||||
@@ -0,0 +1,26 @@
|
||||
-- lower case, upper case, and quoted table names
|
||||
create table lower_table (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
CREATE TABLE UPPER_TABLE (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- std sql quoted table name
|
||||
create table "Quoted Table" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- sqlite quoted table name
|
||||
create table `Quoted Table2` (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "UPPER_QUOTED" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "A""B" (
|
||||
id integer primary key
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
create table if not exists thread(
|
||||
id serial primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified timestamp not null default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create unique index if not exists ThreadNameIx on thread (name);
|
||||
|
||||
create table if not exists person(
|
||||
id serial primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
email varchar(30) not null,
|
||||
creation timestamp not null default CURRENT_TIMESTAMP,
|
||||
salt varchar(128) not null,
|
||||
status varchar(30) not null,
|
||||
lastOnline timestamp not null default CURRENT_TIMESTAMP,
|
||||
ban varchar(128) not null default ''
|
||||
);
|
||||
|
||||
create unique index if not exists UserNameIx on person (name);
|
||||
|
||||
create table if not exists post(
|
||||
id serial primary key,
|
||||
author integer not null,
|
||||
ip varchar(20) not null,
|
||||
header varchar(100) not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation timestamp not null default CURRENT_TIMESTAMP,
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists session(
|
||||
id serial primary key,
|
||||
ip varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
userid integer not null,
|
||||
lastModified timestamp not null default CURRENT_TIMESTAMP,
|
||||
foreign key (userid) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists antibot(
|
||||
id serial primary key,
|
||||
ip varchar(20) not null,
|
||||
answer varchar(30) not null,
|
||||
created timestamp not null default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create table if not exists error(
|
||||
uuid varchar(36) primary key,
|
||||
message varchar(1000) not null,
|
||||
created timestamp not null default CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
create index PersonStatusIdx on person(status);
|
||||
create index PostByAuthorIdx on post(thread, author);
|
||||
@@ -0,0 +1,62 @@
|
||||
pragma foreign_keys = on;
|
||||
|
||||
create table if not exists thread(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create unique index if not exists ThreadNameIx on thread (name);
|
||||
|
||||
create table if not exists person(
|
||||
id integer primary key,
|
||||
name varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
email varchar(30) not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
salt varchar(128) not null,
|
||||
status varchar(30) not null,
|
||||
lastOnline timestamp not null default (DATETIME('now')),
|
||||
ban varchar(128) not null default ''
|
||||
);
|
||||
|
||||
create unique index if not exists UserNameIx on person (name);
|
||||
|
||||
create table if not exists post(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
ip varchar(20) not null,
|
||||
header varchar(100) not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation timestamp not null default (DATETIME('now')),
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists session(
|
||||
id integer primary key,
|
||||
ip varchar(20) not null,
|
||||
password varchar(32) not null,
|
||||
userid integer not null,
|
||||
lastModified timestamp not null default (DATETIME('now')),
|
||||
foreign key (userid) references person(id)
|
||||
);
|
||||
|
||||
create table if not exists antibot(
|
||||
id integer primary key,
|
||||
ip varchar(20) not null,
|
||||
answer varchar(30) not null,
|
||||
created timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create table if not exists error(
|
||||
uuid varchar(36) primary key,
|
||||
message varchar(1000) not null,
|
||||
created timestamp not null default (DATETIME('now'))
|
||||
);
|
||||
|
||||
create index PersonStatusIdx on person(status);
|
||||
create index PostByAuthorIdx on post(thread, author);
|
||||
@@ -0,0 +1,45 @@
|
||||
create table if not exists tb_serial(
|
||||
typserial serial primary key,
|
||||
typinteger integer not null
|
||||
);
|
||||
|
||||
create table if not exists tb_boolean(
|
||||
typboolean boolean not null
|
||||
);
|
||||
|
||||
create table if not exists tb_float(
|
||||
typfloat real not null
|
||||
);
|
||||
|
||||
create table if not exists tb_string(
|
||||
typstring text not null
|
||||
);
|
||||
|
||||
create table if not exists tb_timestamp(
|
||||
dt timestamp not null,
|
||||
dtn timestamptz not null,
|
||||
dtz timestamptz not null
|
||||
);
|
||||
|
||||
create table if not exists tb_json(
|
||||
typjson json not null
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_pk(
|
||||
pk1 integer not null,
|
||||
pk2 integer not null,
|
||||
message text not null,
|
||||
primary key (pk1, pk2)
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_fk(
|
||||
id integer not null,
|
||||
fk1 integer not null,
|
||||
fk2 integer not null,
|
||||
foreign key (fk1, fk2) references tb_composite_pk(pk1, pk2)
|
||||
);
|
||||
|
||||
create table if not exists tb_nullable(
|
||||
id integer primary key,
|
||||
note text null
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
create table if not exists tb_serial(
|
||||
typserial integer primary key,
|
||||
typinteger integer not null
|
||||
);
|
||||
|
||||
create table if not exists tb_boolean(
|
||||
typboolean boolean not null
|
||||
);
|
||||
|
||||
create table if not exists tb_float(
|
||||
typfloat real not null
|
||||
);
|
||||
|
||||
create table if not exists tb_string(
|
||||
typstring text not null
|
||||
);
|
||||
|
||||
create table if not exists tb_timestamp(
|
||||
dt1 timestamp not null,
|
||||
dt2 timestamp not null
|
||||
);
|
||||
|
||||
create table if not exists tb_json(
|
||||
typjson json not null
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_pk(
|
||||
pk1 integer not null,
|
||||
pk2 integer not null,
|
||||
message text not null,
|
||||
primary key (pk1, pk2)
|
||||
);
|
||||
|
||||
create table if not exists tb_composite_fk(
|
||||
id integer not null,
|
||||
fk1 integer not null,
|
||||
fk2 integer not null,
|
||||
foreign key (fk1, fk2) references tb_composite_pk(pk1, pk2)
|
||||
);
|
||||
create table if not exists tb_blob(
|
||||
id integer primary key,
|
||||
typblob blob not null
|
||||
);
|
||||
|
||||
create table if not exists tb_nullable(
|
||||
id integer primary key,
|
||||
note text null
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
create table user_static (
|
||||
id integer primary key,
|
||||
name varchar(255) not null
|
||||
);
|
||||
@@ -0,0 +1,508 @@
|
||||
import unittest, json, strutils, strformat, sequtils, macros, times, os, math, unicode
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
|
||||
when defined(postgre):
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
import db_connector/db_postgres as db_postgres
|
||||
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "model_postgre")
|
||||
const sqlFileName = "model_postgre.sql"
|
||||
let db {.global.} = db_postgres.open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "model_sqlite")
|
||||
const sqlFileName = "model_sqlite.sql"
|
||||
# let db {.global.} = open(":memory:", "", "", "")
|
||||
let db {.global.} = open("test.db", "", "", "")
|
||||
|
||||
let
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / sqlFileName)
|
||||
|
||||
proc substr(s: string; start, length: int): string {.importSql.}
|
||||
|
||||
let
|
||||
min = -3
|
||||
max = 3
|
||||
serial_int_seqs = [(1, min), (2, 1), (3, 2), (4, max)]
|
||||
|
||||
suite &"Test common database types and functions of {backend}":
|
||||
discard
|
||||
|
||||
suite "serial_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_serial")
|
||||
db.createTable(sqlFile, "tb_serial")
|
||||
|
||||
test "insert":
|
||||
for i in serial_int_seqs:
|
||||
query:
|
||||
insert tb_serial(typinteger = ?i[1])
|
||||
check db.getValue(sql"select count(*) from tb_serial") == $serial_int_seqs.len
|
||||
|
||||
test "json":
|
||||
for i in serial_int_seqs:
|
||||
let v = %*{"typinteger": i[1]}
|
||||
query:
|
||||
insert tb_serial(typinteger = %v["typinteger"])
|
||||
check db.getValue(sql"select count(*) from tb_serial") == $serial_int_seqs.len
|
||||
|
||||
|
||||
suite "serial":
|
||||
db.dropTable(sqlFile, "tb_serial")
|
||||
db.createTable(sqlFile, "tb_serial")
|
||||
|
||||
let insertSql = sql"insert into tb_serial(typinteger) values (?)"
|
||||
for (_, v) in serial_int_seqs:
|
||||
db.exec(insertSql, v)
|
||||
doAssert db.getValue(sql"select count(*) from tb_serial") == $serial_int_seqs.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_serial(typserial, typinteger)
|
||||
check res == serial_int_seqs
|
||||
|
||||
test "where":
|
||||
let
|
||||
id = 1
|
||||
res = query:
|
||||
select tb_serial(typserial, typinteger)
|
||||
where typserial == ?id
|
||||
check res == serial_int_seqs.filterIt(it[0] == id)
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_serial(typserial, typinteger)
|
||||
produce json
|
||||
check res == %*serial_int_seqs.mapIt(%*{"typserial": it[0], "typinteger": it[1]})
|
||||
|
||||
test "count":
|
||||
let res = query:
|
||||
select tb_serial(count(_))
|
||||
check res[0] == serial_int_seqs.len
|
||||
|
||||
test "sum":
|
||||
let res = query:
|
||||
select tb_serial(sum(typinteger))
|
||||
check res[0] == serial_int_seqs.mapIt(it[1]).sum()
|
||||
|
||||
test "avg":
|
||||
let res = query:
|
||||
select tb_serial(avg(typinteger))
|
||||
check res[0] == serial_int_seqs.mapIt(it[1]).sum() / serial_int_seqs.len()
|
||||
|
||||
test "min":
|
||||
let res = query:
|
||||
select tb_serial(min(typinteger))
|
||||
check res[0] == min
|
||||
|
||||
test "max":
|
||||
let res = query:
|
||||
select tb_serial(max(typinteger))
|
||||
check res[0] == max
|
||||
|
||||
test "abs":
|
||||
let res = query:
|
||||
select tb_serial(abs(typinteger))
|
||||
check res == serial_int_seqs.mapIt(abs(it[1]))
|
||||
|
||||
|
||||
let seqs = toSeq(1..3)
|
||||
|
||||
suite "boolean_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_boolean")
|
||||
db.createTable(sqlFile, "tb_boolean")
|
||||
|
||||
test "insert":
|
||||
for i in seqs:
|
||||
let b = (i mod 2 == 0)
|
||||
query:
|
||||
insert tb_boolean(typboolean = ?b)
|
||||
check db.getValue(sql"select count(*) from tb_boolean") == $seqs.len
|
||||
|
||||
test "json":
|
||||
for i in seqs:
|
||||
let b = %*{"typboolean": (i mod 2 == 0)}
|
||||
query:
|
||||
insert tb_boolean(typboolean = %b["typboolean"])
|
||||
check db.getValue(sql"select count(*) from tb_boolean") == $seqs.len
|
||||
|
||||
|
||||
suite "boolean":
|
||||
db.dropTable(sqlFile, "tb_boolean")
|
||||
db.createTable(sqlFile, "tb_boolean")
|
||||
|
||||
proc toBool(x: int): bool =
|
||||
if x mod 2 == 0: true else: false
|
||||
|
||||
proc toDbValue(x: int): auto =
|
||||
when defined(postgre):
|
||||
if x mod 2 == 0: "t" else: "f"
|
||||
else:
|
||||
if x mod 2 == 0: 1 else: 0
|
||||
|
||||
let insertSql = sql"insert into tb_boolean(typboolean) values (?)"
|
||||
for i in seqs:
|
||||
db.exec(insertSql, toDbValue(i))
|
||||
doAssert db.getValue(sql"select count(*) from tb_boolean") == $seqs.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_boolean(typboolean)
|
||||
check res == seqs.mapIt(it mod 2 == 0)
|
||||
|
||||
test "where":
|
||||
let
|
||||
b = true
|
||||
res = query:
|
||||
select tb_boolean(typboolean)
|
||||
where typboolean == ?b
|
||||
check res == seqs.map(toBool).filterIt(it == b)
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_boolean(typboolean)
|
||||
produce json
|
||||
check res == %*seqs.mapIt(%*{"typboolean": toBool(it)})
|
||||
|
||||
when defined(sqlite):
|
||||
test "importSql substr wrong type":
|
||||
## TODO: should this be allowed?
|
||||
let res = query:
|
||||
select tb_boolean(substr(typboolean, 1, 2))
|
||||
echo "res: ", res
|
||||
|
||||
let fs = [-3.14, 2.56, 10.45]
|
||||
|
||||
suite "float_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_float")
|
||||
db.createTable(sqlFile, "tb_float")
|
||||
|
||||
test "insert":
|
||||
for f in fs:
|
||||
query:
|
||||
insert tb_float(typfloat = ?f)
|
||||
check db.getValue(sql"select count(*) from tb_float") == $fs.len
|
||||
|
||||
test "json":
|
||||
for f in fs:
|
||||
let v = %*{"typfloat": f}
|
||||
query:
|
||||
insert tb_float(typfloat = %v["typfloat"])
|
||||
check db.getValue(sql"select count(*) from tb_float") == $fs.len
|
||||
|
||||
|
||||
suite "float":
|
||||
db.dropTable(sqlFile, "tb_float")
|
||||
db.createTable(sqlFile, "tb_float")
|
||||
|
||||
let insertSql = sql"insert into tb_float(typfloat) values (?)"
|
||||
for f in fs:
|
||||
db.exec(insertSql, f)
|
||||
doAssert db.getValue(sql"select count(*) from tb_float") == $fs.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_float(typfloat)
|
||||
check res == fs
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_float(typfloat)
|
||||
where typfloat == ?fs[0]
|
||||
check res == [fs[0]]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_float(typfloat)
|
||||
produce json
|
||||
check res == %*fs.mapIt(%*{"typfloat": it})
|
||||
|
||||
test "abs":
|
||||
let res = query:
|
||||
select tb_float(abs(typfloat))
|
||||
check res == fs.mapIt(abs(it))
|
||||
|
||||
let ss = ["one", "Two", "three", "第四", "four'th"]
|
||||
|
||||
suite "string_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_string")
|
||||
db.createTable(sqlFile, "tb_string")
|
||||
|
||||
test "insert":
|
||||
for v in ss:
|
||||
query:
|
||||
insert tb_string(typstring = ?v)
|
||||
check db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
test "insert":
|
||||
for v in ss:
|
||||
query:
|
||||
insert tb_string(typstring = "can't touch this")
|
||||
check db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
test "json":
|
||||
for v in ss:
|
||||
let j = %*{"typstring": v}
|
||||
query:
|
||||
insert tb_string(typstring = %j["typstring"])
|
||||
check db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
suite "string":
|
||||
db.dropTable(sqlFile, "tb_string")
|
||||
db.createTable(sqlFile, "tb_string")
|
||||
|
||||
let insertsql = sql"insert into tb_string(typstring) values (?)"
|
||||
for v in ss:
|
||||
db.exec(insertsql, v)
|
||||
doAssert db.getValue(sql"select count(*) from tb_string") == $ss.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_string(typstring)
|
||||
check res == ss
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_string(typstring)
|
||||
where typstring == ?ss[0]
|
||||
check res[0] == ss[0]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_string(typstring)
|
||||
produce json
|
||||
check res == %*ss.mapIt(%*{"typstring": it})
|
||||
|
||||
test "concat_op":
|
||||
let
|
||||
s = "typstring: "
|
||||
let res = query:
|
||||
select tb_string(?s & typstring)
|
||||
check res == ss.mapIt(s & it)
|
||||
|
||||
test "length":
|
||||
let res = query:
|
||||
select tb_string(length(typstring))
|
||||
check res == ss.mapIt(it.runeLen)
|
||||
|
||||
test "lower":
|
||||
let res = query:
|
||||
select tb_string(lower(typstring))
|
||||
check res == ss.mapIt(it.toLowerAscii)
|
||||
|
||||
test "upper":
|
||||
let res = query:
|
||||
select tb_string(upper(typstring))
|
||||
check res == ss.mapIt(it.toUpperAscii)
|
||||
|
||||
test "replace":
|
||||
let res = query:
|
||||
select tb_string(replace(typstring, "e", "o"))
|
||||
check res == ss.mapIt(it.replace("e", "o"))
|
||||
|
||||
test "importSql substr length 0":
|
||||
let res = query:
|
||||
select tb_string(substr(typstring, 1, 0))
|
||||
let expected = ss.mapIt($(toRunes(it)[0..<0]))
|
||||
check res == expected
|
||||
|
||||
test "importSql substr length no arg types checked":
|
||||
# TODO: fixme!
|
||||
# the `functions` array only contains the types of the return
|
||||
# but sqlite doesn't really care...
|
||||
let res = query:
|
||||
select tb_string(substr(typstring, "1", 2))
|
||||
let expected = ss.mapIt($(toRunes(it)[0..<2]))
|
||||
check res == expected
|
||||
|
||||
let js = [
|
||||
%*{"name": "tom", "age": 30},
|
||||
%*{"name": "bob", "age": 35},
|
||||
%*{"name": "jack", "age": 23}
|
||||
]
|
||||
|
||||
suite "insert_json":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_json")
|
||||
db.createTable(sqlFile, "tb_json")
|
||||
|
||||
test "insert":
|
||||
for j in js:
|
||||
query:
|
||||
insert tb_json(typjson = ?j)
|
||||
check db.getValue(sql"select count(*) from tb_json") == $js.len
|
||||
|
||||
suite "json":
|
||||
db.dropTable(sqlFile, "tb_json")
|
||||
db.createTable(sqlFile, "tb_json")
|
||||
|
||||
let insertSql = sql"insert into tb_json(typjson) values (?)"
|
||||
for j in js:
|
||||
db.exec(insertSql, j)
|
||||
doAssert db.getValue(sql"select count(*) from tb_json") == $js.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_json(typjson)
|
||||
check res == js
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_json(typjson)
|
||||
produce json
|
||||
check res == %*js.mapIt(%*{"typjson": it})
|
||||
|
||||
|
||||
let cps = [
|
||||
(1, 1, "one-one"),
|
||||
(1, 2, "one-two"),
|
||||
(2, 1, "two-one")
|
||||
]
|
||||
|
||||
suite "composite_pk_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
|
||||
test "insert":
|
||||
for r in cps:
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = ?r[0], pk2 = ?r[1], message = ?r[2])
|
||||
check db.getValue(sql"select count(*) from tb_composite_pk") == $cps.len
|
||||
|
||||
test "json":
|
||||
for r in cps:
|
||||
let v = %*{"pk1": r[0], "pk2": r[1], "message": r[2]}
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = %v["pk1"], pk2 = %v["pk2"], message = %v["message"])
|
||||
check db.getValue(sql"select count(*) from tb_composite_pk") == $cps.len
|
||||
|
||||
|
||||
suite "composite_pk":
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
|
||||
let insertSql = sql"insert into tb_composite_pk(pk1, pk2, message) values (?, ?, ?)"
|
||||
for r in cps:
|
||||
db.exec(insertSql, r[0], r[1], r[2])
|
||||
doAssert db.getValue(sql"select count(*) from tb_composite_pk") == $cps.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_composite_pk(pk1, pk2, message)
|
||||
check res == cps
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_composite_pk(pk1, pk2, message)
|
||||
where pk1 == ?cps[0][0]
|
||||
check res == cps.filterIt(it[0] == cps[0][0])
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_composite_pk(pk1, pk2, message)
|
||||
produce json
|
||||
check res == %*cps.mapIt(%*{"pk1": it[0], "pk2": it[1], "message": it[2]})
|
||||
|
||||
|
||||
suite "nullable":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_nullable")
|
||||
db.createTable(sqlFile, "tb_nullable")
|
||||
query:
|
||||
insert tb_nullable(id = 1, note = nil)
|
||||
query:
|
||||
insert tb_nullable(id = 2, note = "hello")
|
||||
|
||||
test "where_null":
|
||||
let res = query:
|
||||
select tb_nullable(id)
|
||||
where note == nil
|
||||
check res == @[1]
|
||||
|
||||
test "where_not_null":
|
||||
let res = query:
|
||||
select tb_nullable(id)
|
||||
where note != null
|
||||
check res == @[2]
|
||||
|
||||
test "update_to_null":
|
||||
query:
|
||||
update tb_nullable(note = null)
|
||||
where id == 2
|
||||
let res = query:
|
||||
select tb_nullable(id)
|
||||
where note == nil
|
||||
check res == @[1, 2]
|
||||
|
||||
test "json_null":
|
||||
let res = query:
|
||||
select tb_nullable(id, note)
|
||||
orderby id
|
||||
produce json
|
||||
check res[0]["note"].kind == JNull
|
||||
check res[1]["note"].getStr == "hello"
|
||||
|
||||
suite "upsert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_nullable")
|
||||
db.createTable(sqlFile, "tb_nullable")
|
||||
|
||||
test "onconflict do nothing":
|
||||
let first = "first value"
|
||||
query:
|
||||
insert tb_nullable(id = 1, note = ?first)
|
||||
|
||||
let ignored = "should not overwrite"
|
||||
query:
|
||||
insert tb_nullable(id = 1, note = ?ignored)
|
||||
onconflict(id)
|
||||
donothing()
|
||||
|
||||
let note = db.getValue(sql"select note from tb_nullable where id = 1")
|
||||
check note == first
|
||||
|
||||
test "onconflict do update":
|
||||
let first = "old note"
|
||||
query:
|
||||
insert tb_nullable(id = 2, note = ?first)
|
||||
|
||||
let replacement = "new note"
|
||||
query:
|
||||
insert tb_nullable(id = 2, note = ?replacement)
|
||||
onconflict(id)
|
||||
doupdate(note = ?replacement)
|
||||
|
||||
let note = db.getValue(sql"select note from tb_nullable where id = 2")
|
||||
check note == replacement
|
||||
|
||||
test "onconflict do update where":
|
||||
query:
|
||||
insert tb_nullable(id = 3, note = "keep-me")
|
||||
|
||||
query:
|
||||
insert tb_nullable(id = 3, note = "replace-me")
|
||||
onconflict(id)
|
||||
doupdate(note = "replace-me")
|
||||
where note != "keep-me"
|
||||
|
||||
let note = db.getValue(sql"select note from tb_nullable where id = 3")
|
||||
check note == "keep-me"
|
||||
|
||||
static:
|
||||
doAssert not compiles(
|
||||
(block:
|
||||
query:
|
||||
insert tb_nullable(id = 100, note = "x")
|
||||
where id == 100
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import unittest, os, sequtils
|
||||
import db_connector/db_common
|
||||
from db_connector/db_sqlite import open, exec, getValue
|
||||
import ormin/db_utils
|
||||
|
||||
const usesLegacySqliteDropNames = defined(sqlite) and defined(orminLegacySqliteDropNames)
|
||||
|
||||
let
|
||||
db {.global.} = open(":memory:", "", "", "")
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / "db_utils_case_quoted.sql")
|
||||
|
||||
let sqlContent = """
|
||||
-- lower case, upper case, and quoted table names
|
||||
create table lower_table (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
CREATE TABLE UPPER_TABLE (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- std sql quoted table name
|
||||
create table "Quoted Table" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
-- sqlite quoted table name
|
||||
create table `Quoted Table2` (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "UPPER_QUOTED" (
|
||||
id integer primary key
|
||||
);
|
||||
|
||||
create table "A""B" (
|
||||
id integer primary key
|
||||
);
|
||||
"""
|
||||
|
||||
const staticSqlContent = staticLoad("db_utils_case_quoted.sql")
|
||||
|
||||
writeFile($sqlFile, sqlContent)
|
||||
|
||||
suite "db_utils: case and quoted names":
|
||||
test "staticLoad returns typed compile-time SQL":
|
||||
let loaded: DbSql = staticSqlContent
|
||||
check $loaded == sqlContent
|
||||
|
||||
test "quoteDbIdentifier quotes and escapes identifiers":
|
||||
check $quoteDbIdentifier("lower_table") == "lower_table"
|
||||
check $quoteDbIdentifier("UPPER_TABLE") == "upper_table"
|
||||
check $quoteDbIdentifier("\"Quoted Table\"") == "\"Quoted Table\""
|
||||
check $quoteDbIdentifier("`Quoted Table2`") == "\"Quoted Table2\""
|
||||
check $quoteDbIdentifier("'Quoted Table3'") == "\"Quoted Table3\""
|
||||
check $quoteDbIdentifier("\"UPPER_TABLE\"") == "\"UPPER_TABLE\""
|
||||
check $quoteDbIdentifier("\"A\"\"B\"") == "\"A\"\"B\""
|
||||
check $quoteDbIdentifier("schema.table") == "schema.table"
|
||||
check $quoteDbIdentifier("Schema.\"Mixed Table\"") == "schema.\"Mixed Table\""
|
||||
check $quoteDbIdentifier("weird\"name") == "\"weird\"\"name\""
|
||||
|
||||
test "check tables names":
|
||||
let pairs = tablePairs(sqlContent).toSeq()
|
||||
check pairs.len == 6
|
||||
check pairs[0][0] == ("lower_table")
|
||||
check pairs[1][0] == ("upper_table")
|
||||
check pairs[2][0] == ("quoted table")
|
||||
check pairs[3][0] == ("quoted table2")
|
||||
check pairs[4][0] == ("upper_quoted")
|
||||
check pairs[5][0] == ("a\"b")
|
||||
|
||||
check pairs[0][1] == "create table lower_table(id integer primary key );"
|
||||
check pairs[1][1] == "create table UPPER_TABLE(id integer primary key );"
|
||||
check pairs[2][1] == "create table \"Quoted Table\"(id integer primary key );"
|
||||
check pairs[4][1] == "create table \"UPPER_QUOTED\"(id integer primary key );"
|
||||
check pairs[5][1] == "create table \"A\"\"B\"(id integer primary key );"
|
||||
|
||||
test "tablePairs parses compile-time SQL":
|
||||
let pairs = tablePairs(staticSqlContent).toSeq()
|
||||
check pairs.len == 6
|
||||
check pairs[0][0] == "lower_table"
|
||||
check pairs[1][0] == "upper_table"
|
||||
check pairs[2][0] == "quoted table"
|
||||
check pairs[3][0] == "quoted table2"
|
||||
check pairs[4][0] == "upper_quoted"
|
||||
check pairs[5][0] == "a\"b"
|
||||
|
||||
test "createTable creates all tables from SQL file":
|
||||
db.createTable(sqlFile)
|
||||
let countAll = db.getValue(sql"select count(*) from sqlite_master where type='table' and name in ('lower_table','UPPER_TABLE','Quoted Table')")
|
||||
check countAll == "3"
|
||||
|
||||
test "createTable with specific lowercased name matches quoted":
|
||||
# Use a new in-memory DB for isolation
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(sqlFile, "quoted table")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table'")
|
||||
check countQuoted == "1"
|
||||
|
||||
test "createTable creates all tables from compile-time SQL":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(staticSqlContent)
|
||||
let countAll = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name in ('lower_table','UPPER_TABLE','Quoted Table','Quoted Table2','UPPER_QUOTED','A\"B')"))
|
||||
check countAll == "6"
|
||||
|
||||
test "createTable with specific lowercased name matches quoted from compile-time SQL":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(staticSqlContent, "quoted table")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table'")
|
||||
check countQuoted == "1"
|
||||
|
||||
test "dropTable handles quoted names from SQL file":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(sqlFile)
|
||||
db2.dropTable(sqlFile, "quoted table2")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table2'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countQuoted == "1"
|
||||
else:
|
||||
check countQuoted == "0"
|
||||
|
||||
db2.dropTable(sqlFile, "upper_quoted")
|
||||
let countUpperQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'UPPER_QUOTED'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countUpperQuoted == "1"
|
||||
else:
|
||||
check countUpperQuoted == "0"
|
||||
|
||||
db2.dropTable(sqlFile, "a\"b")
|
||||
let countEscapedQuoted = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name = 'A\"B'"))
|
||||
when usesLegacySqliteDropNames:
|
||||
check countEscapedQuoted == "1"
|
||||
else:
|
||||
check countEscapedQuoted == "0"
|
||||
|
||||
test "dropTable removes tables from compile-time SQL":
|
||||
let db2 = open(":memory:", "", "", "")
|
||||
db2.createTable(staticSqlContent)
|
||||
db2.dropTable(staticSqlContent, "quoted table2")
|
||||
let countQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'Quoted Table2'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countQuoted == "1"
|
||||
else:
|
||||
check countQuoted == "0"
|
||||
|
||||
db2.dropTable(staticSqlContent, "upper_quoted")
|
||||
let countUpperQuoted = db2.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'UPPER_QUOTED'")
|
||||
when usesLegacySqliteDropNames:
|
||||
check countUpperQuoted == "1"
|
||||
else:
|
||||
check countUpperQuoted == "0"
|
||||
|
||||
db2.dropTable(staticSqlContent, "a\"b")
|
||||
let countEscapedQuoted = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name = 'A\"B'"))
|
||||
when usesLegacySqliteDropNames:
|
||||
check countEscapedQuoted == "1"
|
||||
else:
|
||||
check countEscapedQuoted == "0"
|
||||
|
||||
db2.dropTable(staticSqlContent)
|
||||
let countAll = db2.getValue(sql("select count(*) from sqlite_master where type='table' and name in ('lower_table','UPPER_TABLE','Quoted Table','Quoted Table2','UPPER_QUOTED','A\"B')"))
|
||||
when usesLegacySqliteDropNames:
|
||||
check countAll == "3"
|
||||
else:
|
||||
check countAll == "0"
|
||||
@@ -0,0 +1,822 @@
|
||||
import unittest, strformat, sequtils, algorithm, sugar, json, tables, random
|
||||
import os
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
when NimVersion < "1.2.0": import ./compat
|
||||
|
||||
|
||||
let testDir = currentSourcePath.parentDir()
|
||||
|
||||
when defined postgre:
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
from db_connector/db_postgres import exec, getValue
|
||||
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "forum_model_postgres")
|
||||
const sqlFileName = "forum_model_postgres.sql"
|
||||
let db {.global.} = open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "forum_model_sqlite")
|
||||
const sqlFileName = "forum_model_sqlite.sql"
|
||||
var memoryPath = testDir & "/" & ":memory:"
|
||||
let db {.global.} = open(memoryPath, "", "", "")
|
||||
|
||||
var sqlFilePath = Path(testDir & "/" & sqlFileName)
|
||||
|
||||
type
|
||||
Person = tuple[id: int,
|
||||
name: string,
|
||||
password: string,
|
||||
email: string,
|
||||
salt: string,
|
||||
status: string]
|
||||
Thread = tuple[id: int,
|
||||
name: string,
|
||||
views: int]
|
||||
Post = tuple[id: int,
|
||||
author: int,
|
||||
ip: string,
|
||||
header: string,
|
||||
content: string,
|
||||
thread: int]
|
||||
Antibot = tuple[id: int,
|
||||
ip: string,
|
||||
answer: string]
|
||||
|
||||
const
|
||||
personcount = 5
|
||||
threadcount = 5
|
||||
postcount = 10
|
||||
antibotcount = 5
|
||||
var
|
||||
persondata: seq[Person]
|
||||
threaddata: seq[Thread]
|
||||
postdata: seq[Post]
|
||||
antibotdata: seq[Antibot]
|
||||
|
||||
|
||||
suite &"Test ormin features of {backend}":
|
||||
discard
|
||||
|
||||
suite "query":
|
||||
db.dropTable(sqlFilePath)
|
||||
db.createTable(sqlFilePath)
|
||||
|
||||
static:
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
crossjoin person(name)
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
outerjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
leftjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
leftouterjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
rightjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
rightouterjoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
fulljoin person(name) on author == id
|
||||
)
|
||||
doAssert compiles(block:
|
||||
discard query:
|
||||
select post(author)
|
||||
fullouterjoin person(name) on author == id
|
||||
)
|
||||
|
||||
# prepare data to insert
|
||||
for i in 1..personcount:
|
||||
persondata.add((id: i,
|
||||
name: fmt"john{i}",
|
||||
password: fmt"pass{i}",
|
||||
email: fmt"john{i}@mail.com",
|
||||
salt: fmt"abcd{i}",
|
||||
status: fmt"ok{i}"))
|
||||
|
||||
for i in 1..threadcount:
|
||||
threaddata.add((id: i,
|
||||
name: fmt"thread{i}",
|
||||
views: i))
|
||||
|
||||
for i in 1..postcount:
|
||||
postdata.add((id: i,
|
||||
author: sample({1..personcount}),
|
||||
ip: "",
|
||||
header: fmt"title{i}",
|
||||
content: fmt"content{i}",
|
||||
thread: sample({1..threadcount})))
|
||||
|
||||
for i in 1..antibotcount:
|
||||
antibotdata.add((id: i,
|
||||
ip: "",
|
||||
answer: fmt"answer{i}"))
|
||||
|
||||
# insert data into database
|
||||
let
|
||||
insertperson = sql"insert into person (id, name, password, email, salt, status) values (?, ?, ?, ?, ?, ?)"
|
||||
insertthread = sql"insert into thread (id, name, views) values (?, ?, ?)"
|
||||
insertpost = sql"insert into post (id, author, ip, header, content, thread) values (?, ?, ?, ?, ?, ?)"
|
||||
insertantibot = sql"insert into antibot (id, ip, answer) values (?, ?, ?)"
|
||||
|
||||
for p in persondata:
|
||||
db.exec(insertperson, p.id, p.name, p.password, p.email, p.salt, p.status)
|
||||
|
||||
for t in threaddata:
|
||||
db.exec(insertthread, t.id, t.name, t.views)
|
||||
|
||||
for p in postdata:
|
||||
db.exec(insertpost, p.id, p.author, p.ip, p.header, p.content, p.thread)
|
||||
|
||||
for a in antibotdata:
|
||||
db.exec(insertantibot, a.id, a.ip, a.answer)
|
||||
|
||||
# check data in database
|
||||
let personexpected = db.getValue(sql"select count(*) from person")
|
||||
assert personexpected == $personcount
|
||||
|
||||
let threadexpected = db.getValue(sql"select count(*) from thread")
|
||||
assert threadexpected == $threadcount
|
||||
|
||||
let postexpected = db.getValue(sql"select count(*) from post")
|
||||
assert postexpected == $postcount
|
||||
|
||||
let antibotexpected = db.getValue(sql"select count(*) from antibot")
|
||||
assert antibotexpected == $antibotcount
|
||||
|
||||
test "table":
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
check res == persondata
|
||||
|
||||
test "all_column":
|
||||
let res = query:
|
||||
select person(_)
|
||||
check res.mapIt((it.id, it.name, it.password, it.email, it.salt, it.status)) == persondata
|
||||
|
||||
test "column_alias":
|
||||
let res = query:
|
||||
select person(id as personid, name as personname)
|
||||
check res == persondata.mapIt((personid: it.id, personname: it.name))
|
||||
|
||||
test "arithmetic":
|
||||
let res = query:
|
||||
select person(id * 4 / 2 + 2 - 1 as id)
|
||||
check res == persondata.mapIt(int(it.id * 4 / 2 + 2 - 1))
|
||||
|
||||
test "comparison":
|
||||
let id = 1
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id == ?id
|
||||
check res == [persondata[id - 1]]
|
||||
|
||||
test "comparison_ne":
|
||||
let id = 1
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id != ?id
|
||||
check res == persondata.filterIt(it.id != id)
|
||||
|
||||
test "comparison_ge":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id >= ?id
|
||||
check res == persondata.filterIt(it.id >= id)
|
||||
|
||||
test "comparison_le":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id <= ?id
|
||||
check res == persondata.filterIt(it.id <= id)
|
||||
|
||||
test "comparison_gt":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id > ?id
|
||||
check res == persondata.filterIt(it.id > id)
|
||||
|
||||
test "comparison_lt":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id < ?id
|
||||
check res == persondata.filterIt(it.id < id)
|
||||
|
||||
test "logical_not":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where not (id > ?id)
|
||||
check res == persondata.filterIt(it.id <= id)
|
||||
|
||||
test "logical_and":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id >= ?id1 and id <= ?id2
|
||||
check res == persondata.filterIt(it.id >= id1 and it.id <= id2)
|
||||
|
||||
test "logical_or":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id == ?id1 or id == ?id2
|
||||
check res == persondata.filterIt(it.id == id1 or it.id == id2)
|
||||
|
||||
test "logical_complex":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 3
|
||||
id3 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id > ?id2 and (id == ?id1 or id == ?id3)
|
||||
check res == persondata.filterIt(it.id == id3)
|
||||
|
||||
test "predicate_in":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id in ?id1 .. ?id2
|
||||
check res == persondata.filterIt(it.id in id1..id2)
|
||||
|
||||
test "predicate_not_in":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 4
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
where id notin ?id1 .. ?id2
|
||||
check res == persondata.filterIt(it.id < id1 or it.id > id2)
|
||||
|
||||
test "predicate_like":
|
||||
let pattern = "john1%"
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
where name `like` ?pattern
|
||||
check res == persondata.filterIt(it.name.startsWith("john1")).mapIt((it.id, it.name))
|
||||
|
||||
test "predicate_ilike":
|
||||
let pattern = "JOHN1%"
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
where name `ilike` ?pattern
|
||||
check res == persondata.filterIt(it.name.startsWith("john1")).mapIt((it.id, it.name))
|
||||
|
||||
test "predicate_not_like":
|
||||
let pattern = "john1%"
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
where not (name `like` ?pattern)
|
||||
check res == persondata.filterIt(not it.name.startsWith("john1")).mapIt((it.id, it.name))
|
||||
|
||||
test "distinct":
|
||||
var res = query:
|
||||
select `distinct` post(author)
|
||||
res.sort()
|
||||
let expected = postdata.mapIt(it.author).deduplicate().sortedByIt(it)
|
||||
check res == expected
|
||||
|
||||
test "count_distinct":
|
||||
let res = query:
|
||||
select post(count(distinct author))
|
||||
check res == @[postdata.mapIt(it.author).deduplicate().len]
|
||||
|
||||
test "window_row_number":
|
||||
let res = query:
|
||||
select post(author, id, over(row_number(), partitionby(author), orderby(id)) as rn)
|
||||
orderby author, id
|
||||
var expected: seq[(int, int, int)] = @[]
|
||||
var counters = initCountTable[int]()
|
||||
for p in postdata.sortedByIt((it.author, it.id)):
|
||||
counters.inc(p.author)
|
||||
expected.add((p.author, p.id, counters[p.author]))
|
||||
check res == expected
|
||||
|
||||
test "window_running_sum":
|
||||
let res = query:
|
||||
select post(author, id, over(sum(id), partitionby(author), orderby(id)) as running)
|
||||
orderby author, id
|
||||
var expected: seq[(int, int, int)] = @[]
|
||||
var sums = initTable[int, int]()
|
||||
for p in postdata.sortedByIt((it.author, it.id)):
|
||||
sums[p.author] = sums.getOrDefault(p.author) + p.id
|
||||
expected.add((p.author, p.id, sums[p.author]))
|
||||
check res == expected
|
||||
|
||||
test "limit":
|
||||
let id = 1
|
||||
let res = query:
|
||||
select person(id, name, password, email, salt, status)
|
||||
limit 1
|
||||
static:
|
||||
echo "LIMIT TEST: ", typeof(res)
|
||||
check res == persondata[id - 1]
|
||||
|
||||
test "offset":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
limit 2
|
||||
offset 2
|
||||
check res == persondata[2..3].mapIt((it.id, it.name))
|
||||
|
||||
test "match_assignment":
|
||||
let id = 1
|
||||
let (name, password, email, salt, status) = query:
|
||||
select person(name, password, email, salt, status)
|
||||
where id == ?id
|
||||
limit 1
|
||||
check:
|
||||
name == persondata[id - 1].name
|
||||
password == persondata[id - 1].password
|
||||
email == persondata[id - 1].email
|
||||
salt == persondata[id - 1].salt
|
||||
status == persondata[id - 1].status
|
||||
|
||||
test "groupby":
|
||||
let res = query:
|
||||
select post(author, count(id))
|
||||
groupby author
|
||||
let counttable = postdata.mapIt(it.author).toCountTable()
|
||||
for (author, c) in res:
|
||||
check counttable[author] == c
|
||||
|
||||
test "groupby_aggregate":
|
||||
let author = 3
|
||||
let res = query:
|
||||
select post(count(id))
|
||||
where author == ?author
|
||||
groupby author
|
||||
let c = postdata.mapIt(it.author).toCountTable()[author]
|
||||
check res == [c]
|
||||
|
||||
test "orderby":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
orderby id
|
||||
check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])
|
||||
|
||||
test "orderby_asc":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
orderby asc(id)
|
||||
check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])
|
||||
|
||||
test "orderby_desc":
|
||||
let res = query:
|
||||
select person(id, name)
|
||||
orderby desc(id)
|
||||
let expected = persondata.mapIt((it.id, it.name))
|
||||
.sorted((x, y) => system.cmp(x[0], y[0]), Descending)
|
||||
check res == expected
|
||||
|
||||
test "orderby_key_select":
|
||||
let res = query:
|
||||
select person(id as personid, name)
|
||||
orderby personid
|
||||
check res == persondata.mapIt((it.id, it.name)).sortedByIt(it[0])
|
||||
|
||||
test "orderby_key_not_select":
|
||||
let res = query:
|
||||
select person(name)
|
||||
orderby id
|
||||
check res == persondata.sortedByIt(it.id).mapIt(it.name)
|
||||
|
||||
test "orderby_mulitple":
|
||||
# test fix #30 Incorrect handling of multiple sort keys in orderby
|
||||
let res = query:
|
||||
select post(author, id)
|
||||
orderby author, desc(id)
|
||||
check res == postdata.mapIt((it.author, it.id))
|
||||
.sorted((x, y) => system.cmp(x[1], y[1]), Descending)
|
||||
.sortedByIt(it[0])
|
||||
|
||||
test "having":
|
||||
let id = 4
|
||||
let res = query:
|
||||
select post(author, count(_) as count)
|
||||
groupby author
|
||||
having author == ?id
|
||||
let expected = collect(newSeq):
|
||||
for p in postdata.mapIt(it.author).toCountTable().pairs():
|
||||
if p[0] == id: p
|
||||
check res == expected
|
||||
|
||||
test "having_aggregate":
|
||||
let countvalue = 2
|
||||
let res = query:
|
||||
select post(author, count(id) as count)
|
||||
groupby author
|
||||
having count(id) >= ?countvalue
|
||||
let expected = collect(newSeq):
|
||||
for p in postdata.mapIt(it.author).toCountTable().pairs():
|
||||
if p[1] >= countvalue: p
|
||||
check res.sortedByIt(it[0]) == expected.sortedByIt(it[0])
|
||||
|
||||
test "having_complex":
|
||||
let
|
||||
authorid1 = 1
|
||||
authorid2 = 3
|
||||
countvalue = 2
|
||||
let res = query:
|
||||
select post(author, count(id) as count)
|
||||
groupby author
|
||||
having count(id) >= ?countvalue and (author == ?authorid1 or author == ?authorid2)
|
||||
let expected = collect(newSeq):
|
||||
for p in postdata.mapIt(it.author).toCountTable().pairs():
|
||||
if p[0] in [authorid1, authorid2] and p[1] >= countvalue: p
|
||||
check res == expected
|
||||
|
||||
test "subquery":
|
||||
let res = query:
|
||||
select post(id)
|
||||
where author in
|
||||
(select person(id))
|
||||
check res.sortedByIt(it) == postdata.mapIt(it.id)
|
||||
|
||||
test "subquery_nest2":
|
||||
let
|
||||
personid1 = 1
|
||||
personid2 = 2
|
||||
expectedpost = postdata.filterIt(it.author in [personid1, personid2])
|
||||
.mapIt(it.id)
|
||||
let res = query:
|
||||
select post(id)
|
||||
where author in
|
||||
(select person(id) where id == ?personid1 or id == ?personid2)
|
||||
check res.sortedByIt(it) == expectedpost.sortedByIt(it)
|
||||
|
||||
test "subquery_nest3":
|
||||
var res = query:
|
||||
select thread(id)
|
||||
where id in (select post(thread) where author in
|
||||
(select person(id) where id in {1, 2}))
|
||||
res.sort()
|
||||
check res == postdata.filterIt(it.author in [1, 2])
|
||||
.mapIt(it.thread)
|
||||
.sortedByIt(it)
|
||||
|
||||
test "subquery_having":
|
||||
# feature for having in subquery
|
||||
let id = 4
|
||||
let res = query:
|
||||
select person(id)
|
||||
where id in (
|
||||
select post(author) groupby author having author == ?id)
|
||||
check res == @[id]
|
||||
|
||||
test "exists":
|
||||
let authorid = postdata[0].author
|
||||
let res = query:
|
||||
select person(id)
|
||||
where exists(select post(id) where author == ?authorid)
|
||||
check res.sortedByIt(it) == persondata.mapIt(it.id)
|
||||
|
||||
test "not_exists":
|
||||
let missingAuthor = personcount + postcount
|
||||
let res = query:
|
||||
select person(id)
|
||||
where not exists(select post(id) where author == ?missingAuthor)
|
||||
check res.sortedByIt(it) == persondata.mapIt(it.id)
|
||||
|
||||
test "with_cte":
|
||||
let res = query:
|
||||
with recent(select post(id, author) where id <= 3)
|
||||
select recent(author)
|
||||
orderby id
|
||||
check res == postdata.filterIt(it.id <= 3).sortedByIt(it.id).mapIt(it.author)
|
||||
|
||||
test "with_cte_chain":
|
||||
let res = query:
|
||||
with recent(select post(id, author) where id <= 3)
|
||||
with authors(select recent(author))
|
||||
select authors(author)
|
||||
check res.sortedByIt(it) == postdata.filterIt(it.id <= 3).mapIt(it.author).sortedByIt(it)
|
||||
|
||||
test "with_cte_subquery":
|
||||
let res = query:
|
||||
with recent(select post(id, author) where id <= 3)
|
||||
select person(id)
|
||||
where id in (select recent(author))
|
||||
check res.sortedByIt(it) == postdata.filterIt(it.id <= 3).mapIt(it.author).deduplicate().sortedByIt(it)
|
||||
|
||||
test "union":
|
||||
var res = query:
|
||||
select person(id) where id <= 2
|
||||
union
|
||||
select person(id) where id >= 4
|
||||
res.sort()
|
||||
check res == @[1, 2, 4, 5]
|
||||
|
||||
res = query:
|
||||
union(
|
||||
select person(id) where id <= 2,
|
||||
select person(id) where id >= 4
|
||||
)
|
||||
res.sort()
|
||||
check res == @[1, 2, 4, 5]
|
||||
|
||||
test "intersect":
|
||||
var res = query:
|
||||
select person(id) where id <= 3
|
||||
intersect
|
||||
select person(id) where id >= 2
|
||||
res.sort()
|
||||
check res == @[2, 3]
|
||||
|
||||
res = query:
|
||||
intersect(
|
||||
select person(id) where id <= 3,
|
||||
select person(id) where id >= 2
|
||||
)
|
||||
res.sort()
|
||||
check res == @[2, 3]
|
||||
|
||||
test "except":
|
||||
var res = query:
|
||||
select person(id) where id <= 4
|
||||
`except`
|
||||
select person(id) where id in {2, 3}
|
||||
res.sort()
|
||||
check res == @[1, 4]
|
||||
|
||||
res = query:
|
||||
`except`(
|
||||
select person(id) where id <= 4,
|
||||
select person(id) where id in {2, 3}
|
||||
)
|
||||
res.sort()
|
||||
check res == @[1, 4]
|
||||
|
||||
test "complex_query_subquery_having":
|
||||
let
|
||||
id1 = 2
|
||||
id2 = 3
|
||||
let res = query:
|
||||
select thread(count(_))
|
||||
where id in (
|
||||
select post(thread) where (author == ?id1 or author == ?id2) and id in (
|
||||
select post(min(id)) groupby thread having min(id) > 3))
|
||||
limit 1
|
||||
|
||||
let threadinpost = postdata.mapIt(it.thread).deduplicate().sortedByIt(it)
|
||||
var postminids: seq[int]
|
||||
for id in threadinpost:
|
||||
let group = collect(newSeq):
|
||||
for p in postdata:
|
||||
if p.thread == id: p.id
|
||||
postminids.add(group.min())
|
||||
let threadids = postdata.filterIt(it.author in [id1, id2] and
|
||||
it.id in postminids.filterIt(it > 3))
|
||||
.mapIt(it.thread)
|
||||
check res == threadids.len()
|
||||
|
||||
test "auto_join":
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
join person(name)
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "join_on":
|
||||
# test fix #29 Cannot handle join on condition correctly
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
join person(name) on author == id
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "leftjoin_on":
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
leftjoin person(name) on author == id
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "leftouterjoin_on":
|
||||
let postid = 1
|
||||
let res = query:
|
||||
select post(author)
|
||||
leftouterjoin person(name) on author == id
|
||||
where id == ?postid
|
||||
let (author, name) = res[0]
|
||||
check name == persondata[author - 1].name
|
||||
|
||||
test "casewhen":
|
||||
# need more test, only number
|
||||
# has problem with string or expression in condition
|
||||
let res = query:
|
||||
select person(name, (if id == 1: 10 elif id == 2: 20 else: 30) as pid)
|
||||
check res == persondata.mapIt((
|
||||
name: it.name,
|
||||
pid: if it.id == 1: 10 elif it.id == 2: 20 else: 30))
|
||||
|
||||
test "produce_json":
|
||||
# test fix #27 Error: type mismatch: got <typeof(nil)>
|
||||
let threadjson = query:
|
||||
select thread(id, name)
|
||||
produce json
|
||||
let expected = threaddata.map do (it: tuple) -> JsonNode:
|
||||
result = newJObject()
|
||||
result["id"] = %it.id
|
||||
result["name"] = %it.name
|
||||
check threadjson == %expected
|
||||
|
||||
test "produce_nim":
|
||||
let threadnim = query:
|
||||
select thread(id, name)
|
||||
produce nim
|
||||
check threadnim == threaddata.mapIt((it.id, it.name))
|
||||
|
||||
test "tryquery":
|
||||
# unknow use, only not raise dbError?
|
||||
let res = tryQuery:
|
||||
select thread(id)
|
||||
check res == threaddata.mapIt(it.id)
|
||||
|
||||
test "createproc":
|
||||
createProc getAllThreadIds:
|
||||
select thread(id)
|
||||
check db.getAllThreadIds() == threaddata.mapIt(it.id)
|
||||
|
||||
test "createiter":
|
||||
createIter allThreadIdsIter:
|
||||
select thread(id)
|
||||
let res = collect(newSeq):
|
||||
for id in db.allThreadIdsIter:
|
||||
id
|
||||
check res.sortedByIt(it) == threaddata.mapIt(it.id)
|
||||
|
||||
test "update_concat_op":
|
||||
let
|
||||
id = 3
|
||||
name = persondata[id - 1].name
|
||||
appendstr = "updated"
|
||||
nameupdated = name & appendstr
|
||||
query:
|
||||
update person(name = name & ?appendstr)
|
||||
where id == ?id
|
||||
let res = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
check res[0] == nameupdated
|
||||
|
||||
test "delete_one":
|
||||
# test fix #14: delete not return value
|
||||
let id = 1
|
||||
query:
|
||||
delete antibot
|
||||
where id == ?id
|
||||
let res = query:
|
||||
select antibot(_)
|
||||
where id == ?id
|
||||
check res == []
|
||||
|
||||
test "delete_all":
|
||||
query:
|
||||
delete antibot
|
||||
let res = query:
|
||||
select antibot(_)
|
||||
check res == []
|
||||
|
||||
test "insert_return_id":
|
||||
# test fix #28 returning id fail under postgresql
|
||||
let expectedid = 6
|
||||
let id = query:
|
||||
insert antibot(id = ?expectedid, ip = "", answer = "just insert")
|
||||
returning id
|
||||
check id == expectedid
|
||||
|
||||
test "insert_return_answer":
|
||||
# test returning non-id parameter
|
||||
let expectedanswer = "just insert another"
|
||||
let answer = query:
|
||||
insert antibot(id = 9, ip = "", answer = ?expectedanswer)
|
||||
returning answer
|
||||
check answer == expectedanswer
|
||||
|
||||
test "insert_return_id_auto":
|
||||
# test returning id column
|
||||
when defined(postgre):
|
||||
# fix postgres sequence so next nextval returns 10
|
||||
discard db.getValue(sql"select setval('antibot_id_seq', ?, ?)", 10, false)
|
||||
let answer = query:
|
||||
insert antibot(ip = "", answer = "just auto insert")
|
||||
returning id
|
||||
check answer == 10
|
||||
|
||||
test "insert_returning_uuid":
|
||||
# test returning uuid column
|
||||
let expecteduuid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
let uuid = query:
|
||||
insert error(uuid = ?expecteduuid, message = "just insert")
|
||||
returning uuid
|
||||
check uuid == expecteduuid
|
||||
|
||||
test "where_json":
|
||||
let
|
||||
id = 1
|
||||
p = %*{"id": id}
|
||||
let res = query:
|
||||
select person(id)
|
||||
where id == %p["id"]
|
||||
check res == [id]
|
||||
|
||||
test "insert_json":
|
||||
let
|
||||
id = 7
|
||||
answer = "json answer"
|
||||
a = %*{"answer": answer}
|
||||
query:
|
||||
insert antibot(id = ?id, ip = "", answer = %a["answer"])
|
||||
let res = query:
|
||||
select antibot(answer)
|
||||
where id == ?id
|
||||
check res[0] == answer
|
||||
|
||||
test "update_json":
|
||||
let
|
||||
id = 3
|
||||
name = "json"
|
||||
p = %*{"name": name}
|
||||
query:
|
||||
update person(name = %p["name"])
|
||||
where id == ?id
|
||||
let res = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
check res[0] == name
|
||||
|
||||
test "insert_rawsql":
|
||||
let id = 8
|
||||
query:
|
||||
insert antibot(id = ?id, ip = "", answer = !!"'raw sql'",
|
||||
created = !!"CURRENT_TIMESTAMP")
|
||||
let res = query:
|
||||
select antibot(_)
|
||||
where id == ?id
|
||||
check res[0].answer == "raw sql"
|
||||
|
||||
test "update_rawsql":
|
||||
let id = 3
|
||||
let res = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
query:
|
||||
update person(name = !!"UPPER(name)")
|
||||
where id == ?id
|
||||
let res2 = query:
|
||||
select person(name)
|
||||
where id == ?id
|
||||
check res[0] != res2[0]
|
||||
check res[0].toUpper() == res2[0]
|
||||
test "empty_string":
|
||||
db.exec(insertthread, 6, "", 77)
|
||||
|
||||
createProc getAllThreads:
|
||||
select thread(id, name, views)
|
||||
where id >= 5
|
||||
|
||||
let rows = db.getAllThreads
|
||||
check rows[1].id == 6
|
||||
check rows[1].views == 77
|
||||
check rows[1].name == ""
|
||||
@@ -0,0 +1,29 @@
|
||||
import unittest
|
||||
import ormin/ormin_sqlite as orm_sqlite
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
|
||||
import ormin
|
||||
|
||||
importModel(DbBackend.sqlite, "static_only_model", includeStatic = true)
|
||||
|
||||
let db {.global.} = orm_sqlite.open(":memory:", "", "", "")
|
||||
|
||||
suite "importModel includeStatic":
|
||||
test "sqlSchema is generated and usable without a generated Nim model":
|
||||
let schema: DbSql = sqlSchema
|
||||
check ($schema).len > 0
|
||||
|
||||
db.createTable(sqlSchema)
|
||||
db.exec(sql"insert into user_static (id, name) values (?, ?)", 1, "Ada")
|
||||
|
||||
let row = query:
|
||||
select user_static(id, name)
|
||||
where id == 1
|
||||
limit 1
|
||||
|
||||
check row.id == 1
|
||||
check row.name == "Ada"
|
||||
check db.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'user_static'") == "1"
|
||||
|
||||
db.dropTable(sqlSchema, "user_static")
|
||||
check db.getValue(sql"select count(*) from sqlite_master where type='table' and name = 'user_static'") == "0"
|
||||
@@ -0,0 +1,130 @@
|
||||
import unittest, json, strutils, macros, times, os, sequtils
|
||||
# Postgres connection handled through ormin_postgre backend
|
||||
from db_connector/db_postgres import exec, getValue
|
||||
import ormin
|
||||
import ormin/ormin_postgre as ormin_postgre
|
||||
# import db_connector/db_postgres as db_postgres
|
||||
import ormin/db_utils
|
||||
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
|
||||
|
||||
importModel(DbBackend.postgre, "model_postgre")
|
||||
|
||||
let
|
||||
db {.global.} = ormin_postgre.open("localhost", "test", "test", "test_ormin")
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / "model_postgre.sql")
|
||||
|
||||
|
||||
suite "Test special database types and functions of postgre":
|
||||
discard
|
||||
|
||||
let
|
||||
dtStr1 = "2018-03-30 01:01:01"
|
||||
dt1 = parse(dtStr1, "yyyy-MM-dd HH:mm:ss")
|
||||
dtnStr1 = "2019-03-30 11:02:02+08"
|
||||
dtn1 = parse(dtnStr1, "yyyy-MM-dd HH:mm:sszz")
|
||||
dtzStr1 = "2020-03-30 09:03:03Z"
|
||||
dtz1 = parse(dtzStr1, "yyyy-MM-dd HH:mm:sszz")
|
||||
dtStr2 = "2018-03-30 01:01:01.123"
|
||||
dt2 = parse(dtStr2, "yyyy-MM-dd HH:mm:ss\'.\'fff")
|
||||
dtnStr2 = "2019-03-30 11:02:02.123+08"
|
||||
dtn2 = parse(dtnStr2, "yyyy-MM-dd HH:mm:ss\'.\'fffzz")
|
||||
dtzStr2 = "2020-03-30 09:03:03.123Z"
|
||||
dtz2 = parse(dtzStr2, "yyyy-MM-dd HH:mm:ss\'.\'fffzz")
|
||||
dtStr3 = "2018-03-30 01:01:01.123456"
|
||||
dt3 = parse(dtStr3, "yyyy-MM-dd HH:mm:ss\'.\'ffffff")
|
||||
dtnStr3 = "2019-03-30 11:02:02.123456+08"
|
||||
dtn3 = parse(dtnStr3, "yyyy-MM-dd HH:mm:ss\'.\'ffffffzz")
|
||||
dtzStr3 = "2020-03-30 09:03:03.123456Z"
|
||||
dtz3 = parse(dtzStr3, "yyyy-MM-dd HH:mm:ss\'.\'ffffffzz")
|
||||
dtjson1 = %*{"dt": dt1.format(jsonTimeFormat),
|
||||
"dtn": dtn1.format(jsonTimeFormat),
|
||||
"dtz": dtz1.format(jsonTimeFormat)}
|
||||
dtjson2 = %*{"dt": dt2.format(jsonTimeFormat),
|
||||
"dtn": dtn2.format(jsonTimeFormat),
|
||||
"dtz": dtz2.format(jsonTimeFormat)}
|
||||
dtjson3 = %*{"dt": dt3.format(jsonTimeFormat),
|
||||
"dtn": dtn3.format(jsonTimeFormat),
|
||||
"dtz": dtz3.format(jsonTimeFormat)}
|
||||
|
||||
let insertSql = sql"insert into tb_timestamp(dt, dtn, dtz) values (?, ?, ?)"
|
||||
|
||||
suite "timestamp_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
test "insert":
|
||||
query:
|
||||
insert tb_timestamp(dt = ?dt1, dtn = ?dtn1, dtz = ?dtz1)
|
||||
check db_postgres.getValue(db, sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
test "json":
|
||||
query:
|
||||
insert tb_timestamp(dt = %dtjson1["dt"],
|
||||
dtn = %dtjson1["dtn"],
|
||||
dtz = %dtjson1["dtz"])
|
||||
check db_postgres.getValue(db, sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
suite "timestamp":
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
db_postgres.exec(db, insertSql, dtStr1, dtnStr1, dtzStr1)
|
||||
db_postgres.exec(db, insertSql, dtStr2, dtnStr2, dtzStr2)
|
||||
db_postgres.exec(db, insertSql, dtStr3, dtnStr3, dtzStr3)
|
||||
doAssert db_postgres.getValue(db, sql"select count(*) from tb_timestamp") == "3"
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_timestamp(dt, dtn, dtz)
|
||||
check res == [(dt1, dtn1, dtz1),
|
||||
(dt2, dtn2, dtz2),
|
||||
(dt3, dtn3, dtz3)]
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt)
|
||||
where dt == ?dt1
|
||||
check res == [dt1]
|
||||
|
||||
test "in":
|
||||
let
|
||||
duration = initDuration(hours = 1)
|
||||
dtStart = dt1 - duration
|
||||
dtEnd = dt1 + duration
|
||||
res2 = query:
|
||||
select tb_timestamp(dt)
|
||||
where dt in ?dtStart .. ?dtEnd
|
||||
check res2 == [dt1, dt2, dt3]
|
||||
|
||||
test "iter":
|
||||
createIter iter:
|
||||
select tb_timestamp(dt)
|
||||
where dt == ?dt
|
||||
var res: seq[DateTime]
|
||||
for it in db.iter(dt1):
|
||||
res.add(it)
|
||||
check res == [dt1]
|
||||
|
||||
test "proc":
|
||||
createProc aproc:
|
||||
select tb_timestamp(dt)
|
||||
where dt == ?dt
|
||||
check db.aproc(dt1) == [dt1]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_timestamp(dt, dtn, dtz)
|
||||
produce json
|
||||
check res == %*[dtjson1, dtjson2, dtjson3]
|
||||
|
||||
test "json_where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt, dtn, dtz)
|
||||
where dt == %dtjson1["dt"]
|
||||
produce json
|
||||
check res[0] == dtjson1
|
||||
@@ -0,0 +1,216 @@
|
||||
import unittest, strformat, os, times, std/monotimes
|
||||
import std/options
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
import ormin/query_hooks
|
||||
|
||||
when defined(postgre):
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "model_postgre")
|
||||
const sqlFileName = "model_postgre.sql"
|
||||
let db {.global.} = open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "model_sqlite")
|
||||
const sqlFileName = "model_sqlite.sql"
|
||||
let db {.global.} = open("test.db", "", "", "")
|
||||
|
||||
let
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / sqlFileName)
|
||||
|
||||
type
|
||||
CompositeRow = object
|
||||
id: int
|
||||
message: string
|
||||
|
||||
RefCompositeRow = ref object
|
||||
id: int
|
||||
message: string
|
||||
|
||||
BenchmarkCompositeRow = object
|
||||
pk1: int
|
||||
message: string
|
||||
|
||||
NullableNoteOptionRow = object
|
||||
id: int
|
||||
note: Option[string]
|
||||
|
||||
MessageSize = distinct int
|
||||
|
||||
HookedMessageRow = object
|
||||
id: int
|
||||
message: MessageSize
|
||||
|
||||
NullFallbackNote = distinct string
|
||||
|
||||
HookedNullableNoteRow = object
|
||||
id: int
|
||||
note: NullFallbackNote
|
||||
|
||||
const
|
||||
benchmarkRowCount = 256
|
||||
benchmarkWarmupIterations = 75
|
||||
benchmarkIterations = 250
|
||||
benchmarkRounds = 5
|
||||
maxTypedQuerySlowdown = 1.20
|
||||
|
||||
proc fromQueryHook*(val: var MessageSize, value: string) =
|
||||
val = MessageSize(value.len)
|
||||
|
||||
proc fromQueryHook*(val: var NullFallbackNote, value: DbValue[string]) =
|
||||
if value.isNull:
|
||||
val = NullFallbackNote("<missing>")
|
||||
else:
|
||||
val = NullFallbackNote("note:" & value.value)
|
||||
|
||||
proc loadBenchmarkRows() =
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
for i in 1 .. benchmarkRowCount:
|
||||
let message = &"message-{i}"
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = ?i, pk2 = ?i, message = ?message)
|
||||
|
||||
proc benchmarkCurrentQuery(iterations: int): float =
|
||||
var checksum = 0
|
||||
let started = getMonoTime()
|
||||
for _ in 0 ..< iterations:
|
||||
let rows = query:
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
checksum += rows.len + rows[^1][0] + rows[^1][1].len
|
||||
doAssert checksum > 0
|
||||
result = (getMonoTime() - started).inNanoseconds.float / 1_000_000_000.0
|
||||
|
||||
proc benchmarkTypedQuery(iterations: int): float =
|
||||
var checksum = 0
|
||||
let started = getMonoTime()
|
||||
for _ in 0 ..< iterations:
|
||||
let rows = query(BenchmarkCompositeRow):
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
checksum += rows.len + rows[^1].pk1 + rows[^1].message.len
|
||||
doAssert checksum > 0
|
||||
result = (getMonoTime() - started).inNanoseconds.float / 1_000_000_000.0
|
||||
|
||||
suite &"query(T) mapping on {backend}":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_composite_pk")
|
||||
db.createTable(sqlFile, "tb_composite_pk")
|
||||
db.dropTable(sqlFile, "tb_nullable")
|
||||
db.createTable(sqlFile, "tb_nullable")
|
||||
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = 1, pk2 = 1, message = "hello")
|
||||
query:
|
||||
insert tb_composite_pk(pk1 = 2, pk2 = 2, message = "world")
|
||||
|
||||
query:
|
||||
insert tb_nullable(id = 1, note = nil)
|
||||
query:
|
||||
insert tb_nullable(id = 2, note = "hello")
|
||||
|
||||
test "maps selected rows to objects":
|
||||
let rows = query(CompositeRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
orderby pk1
|
||||
|
||||
check rows == @[
|
||||
CompositeRow(id: 1, message: "hello"),
|
||||
CompositeRow(id: 2, message: "world")
|
||||
]
|
||||
|
||||
test "maps selected rows to ref objects":
|
||||
let rows = query(RefCompositeRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
orderby pk1
|
||||
|
||||
check rows.len == 2
|
||||
check rows[0] != nil
|
||||
check rows[0].id == 1
|
||||
check rows[0].message == "hello"
|
||||
check rows[1] != nil
|
||||
check rows[1].id == 2
|
||||
check rows[1].message == "world"
|
||||
|
||||
test "single-row query(T) returns a single object":
|
||||
let row = query(CompositeRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
where pk1 == 1 and pk2 == 1
|
||||
limit 1
|
||||
|
||||
check row == CompositeRow(id: 1, message: "hello")
|
||||
|
||||
test "maps nullable column to Option":
|
||||
let rows = query(NullableNoteOptionRow):
|
||||
select tb_nullable(id, note)
|
||||
orderby id
|
||||
|
||||
check rows[0].id == 1
|
||||
check rows[0].note.isNone
|
||||
check rows[1].id == 2
|
||||
check rows[1].note.isSome
|
||||
check rows[1].note.get == "hello"
|
||||
|
||||
test "maps object fields through user fromQueryHook overloads":
|
||||
let rows = query(HookedMessageRow):
|
||||
select tb_composite_pk(pk1 as id, message)
|
||||
orderby pk1
|
||||
|
||||
check rows.len == 2
|
||||
check rows[0].id == 1
|
||||
check int(rows[0].message) == "hello".len
|
||||
check rows[1].id == 2
|
||||
check int(rows[1].message) == "world".len
|
||||
|
||||
test "maps scalar query(T) through user fromQueryHook overloads":
|
||||
let values = query(MessageSize):
|
||||
select tb_composite_pk(message)
|
||||
orderby pk1
|
||||
|
||||
check values.len == 2
|
||||
check int(values[0]) == "hello".len
|
||||
check int(values[1]) == "world".len
|
||||
|
||||
test "allows user fromQueryHook overloads to handle NULL values":
|
||||
let rows = query(HookedNullableNoteRow):
|
||||
select tb_nullable(id, note)
|
||||
orderby id
|
||||
|
||||
check rows.len == 2
|
||||
check rows[0].id == 1
|
||||
check string(rows[0].note) == "<missing>"
|
||||
check rows[1].id == 2
|
||||
check string(rows[1].note) == "note:hello"
|
||||
|
||||
test "sqlite benchmark for query and query(T)":
|
||||
loadBenchmarkRows()
|
||||
|
||||
let untypedRows = query:
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
let typedRows = query(BenchmarkCompositeRow):
|
||||
select tb_composite_pk(pk1, message)
|
||||
orderby pk1
|
||||
check untypedRows.len == typedRows.len
|
||||
check typedRows[0] == BenchmarkCompositeRow(pk1: untypedRows[0][0], message: untypedRows[0][1])
|
||||
check typedRows[^1] == BenchmarkCompositeRow(pk1: untypedRows[^1][0], message: untypedRows[^1][1])
|
||||
|
||||
discard benchmarkCurrentQuery(benchmarkWarmupIterations)
|
||||
discard benchmarkTypedQuery(benchmarkWarmupIterations)
|
||||
|
||||
var currentBest = high(float)
|
||||
var typedBest = high(float)
|
||||
for _ in 0 ..< benchmarkRounds:
|
||||
currentBest = min(currentBest, benchmarkCurrentQuery(benchmarkIterations))
|
||||
typedBest = min(typedBest, benchmarkTypedQuery(benchmarkIterations))
|
||||
|
||||
let ratio = typedBest / currentBest
|
||||
echo &"sqlite benchmark query={currentBest:.6f}s query(T)={typedBest:.6f}s ratio={ratio:.3f}x; 20% budget={(ratio <= maxTypedQuerySlowdown)}"
|
||||
check currentBest > 0.0
|
||||
check typedBest > 0.0
|
||||
when defined(release):
|
||||
check ratio <= maxTypedQuerySlowdown
|
||||
@@ -0,0 +1,153 @@
|
||||
import unittest, db_connector/sqlite3, json, times, sequtils
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
|
||||
importModel(DbBackend.sqlite, "model_sqlite")
|
||||
|
||||
let
|
||||
db {.global.} = open(":memory:", "", "", "")
|
||||
testDir = currentSourcePath.parentDir()
|
||||
sqlFile = Path(testDir / "model_sqlite.sql")
|
||||
|
||||
|
||||
suite "Test special database types and functions of sqlite":
|
||||
discard
|
||||
|
||||
jsonTimeFormat = "yyyy-MM-dd HH:mm:ss\'.\'fff"
|
||||
let
|
||||
dtStr1 = "2018-02-20 02:02:02"
|
||||
dt1 = parse(dtStr1, "yyyy-MM-dd HH:mm:ss", utc())
|
||||
dtStr2 = "2019-03-30 03:03:03.123"
|
||||
dt2 = parse(dtStr2, "yyyy-MM-dd HH:mm:ss\'.\'fff", utc())
|
||||
dtjson = %*{"dt1": dt1.format(jsonTimeFormat),
|
||||
"dt2": dt2.format(jsonTimeFormat)}
|
||||
let insertSql = sql"insert into tb_timestamp(dt1, dt2) values (?, ?)"
|
||||
|
||||
proc blobFromBytes(bytes: openArray[int]): seq[byte] =
|
||||
result = newSeq[byte](bytes.len)
|
||||
for i, b in bytes:
|
||||
doAssert b >= 0 and b <= 255
|
||||
result[i] = byte(b)
|
||||
|
||||
let blobFixtures = [
|
||||
blobFromBytes(@[0, 1, 2, 3, 4, 5, 6, 7]),
|
||||
blobFromBytes(@[255, 128, 64, 32, 16, 8, 4, 2, 1]),
|
||||
blobFromBytes(@[ord('O'), ord('R'), ord('M'), ord('I'), ord('N'), 0, 1, 2])
|
||||
]
|
||||
|
||||
suite "timestamp_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
test "insert":
|
||||
query:
|
||||
insert tb_timestamp(dt1 = ?dt1, dt2 = ?dt2)
|
||||
check db.getValue(sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
test "json":
|
||||
query:
|
||||
insert tb_timestamp(dt1 = %dtjson["dt1"], dt2 = %dtjson["dt2"])
|
||||
check db.getValue(sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
suite "timestamp":
|
||||
db.dropTable(sqlFile, "tb_timestamp")
|
||||
db.createTable(sqlFile, "tb_timestamp")
|
||||
|
||||
db.exec(insertSql, dtStr1, dtStr2)
|
||||
doAssert db.getValue(sql"select count(*) from tb_timestamp") == "1"
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
check res == [(dt1, dt2)]
|
||||
|
||||
test "where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == ?dt1
|
||||
check res == [(dt1, dt2)]
|
||||
|
||||
test "in":
|
||||
let
|
||||
duration = initDuration(hours = 1)
|
||||
dtStart = dt1 - duration
|
||||
dtEnd = dt1 + duration
|
||||
res2 = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 in ?dtStart .. ?dtEnd
|
||||
check res2 == [(dt1, dt2)]
|
||||
|
||||
test "iter":
|
||||
createIter iter:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == ?dt1
|
||||
var res: seq[tuple[dt1: DateTime, dt2: DateTime]]
|
||||
for it in db.iter(dt1):
|
||||
res.add(it)
|
||||
check res == [(dt1, dt2)]
|
||||
|
||||
test "proc":
|
||||
createProc aproc:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == ?dt1
|
||||
check db.aproc(dt1) == [(dt1, dt2)]
|
||||
|
||||
test "json":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
produce json
|
||||
check res == %*[dtjson]
|
||||
|
||||
test "json_where":
|
||||
let res = query:
|
||||
select tb_timestamp(dt1, dt2)
|
||||
where dt1 == %dtjson["dt1"]
|
||||
produce json
|
||||
check res == %*[dtjson]
|
||||
|
||||
suite "blob_insert":
|
||||
setup:
|
||||
db.dropTable(sqlFile, "tb_blob")
|
||||
db.createTable(sqlFile, "tb_blob")
|
||||
|
||||
test "insert parameters":
|
||||
for blob in blobFixtures:
|
||||
query:
|
||||
insert tb_blob(typblob = ?blob)
|
||||
check db.getValue(sql"select count(*) from tb_blob") == $blobFixtures.len
|
||||
|
||||
suite "blob":
|
||||
db.dropTable(sqlFile, "tb_blob")
|
||||
db.createTable(sqlFile, "tb_blob")
|
||||
|
||||
for blob in blobFixtures:
|
||||
query:
|
||||
insert tb_blob(typblob = ?blob)
|
||||
doAssert db.getValue(sql"select count(*) from tb_blob") == $blobFixtures.len
|
||||
|
||||
test "query":
|
||||
let res = query:
|
||||
select tb_blob(id, typblob)
|
||||
check res.mapIt(it.typblob) == blobFixtures
|
||||
|
||||
test "where":
|
||||
let target = blobFixtures[1]
|
||||
let res = query:
|
||||
select tb_blob(id, typblob)
|
||||
where typblob == ?target
|
||||
check res.len == 1
|
||||
check res[0].typblob == target
|
||||
|
||||
createProc selectAllBlob:
|
||||
select tb_blob(id, typblob)
|
||||
|
||||
test "createProc":
|
||||
let res = db.selectAllBlob
|
||||
check res.mapIt(it.typblob) == blobFixtures
|
||||
|
||||
test "createProc with empty blob in result":
|
||||
db.exec(sql"insert into tb_blob(id, typblob) values (?, ?)", 9, "")
|
||||
let res = db.selectAllBlob
|
||||
check res[^1].typblob == blobFromBytes(@[])
|
||||
@@ -0,0 +1,132 @@
|
||||
import unittest, os, strformat
|
||||
import ormin
|
||||
import ormin/db_utils
|
||||
when NimVersion < "1.2.0": import ./compat
|
||||
|
||||
let testDir = currentSourcePath.parentDir()
|
||||
|
||||
when defined postgre:
|
||||
when defined(macosx):
|
||||
{.passL: "-Wl,-rpath,/opt/homebrew/lib/postgresql@14".}
|
||||
from db_connector/db_postgres import exec, getValue
|
||||
const backend = DbBackend.postgre
|
||||
importModel(backend, "forum_model_postgres")
|
||||
const sqlFileName = "forum_model_postgres.sql"
|
||||
let db {.global.} = open("localhost", "test", "test", "test_ormin")
|
||||
else:
|
||||
from db_connector/db_sqlite import exec, getValue
|
||||
const backend = DbBackend.sqlite
|
||||
importModel(backend, "forum_model_sqlite")
|
||||
const sqlFileName = "forum_model_sqlite.sql"
|
||||
var memoryPath = testDir & "/" & ":memory:"
|
||||
let db {.global.} = open(memoryPath, "", "", "")
|
||||
|
||||
var sqlFilePath = Path(testDir & "/" & sqlFileName)
|
||||
|
||||
# Fresh schema
|
||||
db.dropTable(sqlFilePath)
|
||||
db.createTable(sqlFilePath)
|
||||
|
||||
suite &"Transactions ({backend})":
|
||||
|
||||
test "commit on success":
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(101), name = ?"john101", password = ?"p101", email = ?"john101@mail.com", salt = ?"s101", status = ?"ok")
|
||||
check db.getValue(sql"select count(*) from person where id = 101") == "1"
|
||||
|
||||
test "rollback on error with manual try except":
|
||||
# prepare one row
|
||||
query:
|
||||
insert person(id = ?(201), name = ?"john201", password = ?"p201", email = ?"john201@mail.com", salt = ?"s201", status = ?"ok")
|
||||
# in transaction insert a new row and then violate PK
|
||||
try:
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(202), name = ?"john202", password = ?"p202", email = ?"john202@mail.com", salt = ?"s202", status = ?"ok")
|
||||
# duplicate key error
|
||||
query:
|
||||
insert person(id = ?(201), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
check false # should not reach
|
||||
except DbError as e:
|
||||
discard
|
||||
# both inserts inside the transaction should be rolled back
|
||||
check db.getValue(sql"select count(*) from person where id = 202") == "0"
|
||||
check db.getValue(sql"select count(*) from person where id = 201 and name = 'dup'") == "0"
|
||||
|
||||
test "rollback on error with else":
|
||||
# prepare one row
|
||||
var failed = false
|
||||
query:
|
||||
insert person(id = ?(501), name = ?"john501", password = ?"p501", email = ?"john501@mail.com", salt = ?"s501", status = ?"ok")
|
||||
# in transaction insert a new row and then violate PK
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(502), name = ?"john502", password = ?"p502", email = ?"john502@mail.com", salt = ?"s502", status = ?"ok")
|
||||
# duplicate key error
|
||||
query:
|
||||
insert person(id = ?(501), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
check false # should not reach
|
||||
else:
|
||||
echo "do something else..."
|
||||
failed = true
|
||||
|
||||
check failed
|
||||
# both inserts inside the transaction should be rolled back
|
||||
check db.getValue(sql"select count(*) from person where id = 502") == "0"
|
||||
check db.getValue(sql"select count(*) from person where id = 501 and name = 'dup'") == "0"
|
||||
|
||||
test "commit normally with else":
|
||||
# prepare one row
|
||||
var failed = false
|
||||
query:
|
||||
insert person(id = ?(601), name = ?"john601", password = ?"p601", email = ?"john601@mail.com", salt = ?"s601", status = ?"ok")
|
||||
# in transaction insert a new row and then violate PK
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(602), name = ?"john602", password = ?"p602", email = ?"john602@mail.com", salt = ?"s602", status = ?"ok")
|
||||
query:
|
||||
insert person(id = ?(603), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
else:
|
||||
failed = true
|
||||
|
||||
check not failed
|
||||
# both inserts inside the transaction should be rolled back
|
||||
check db.getValue(sql"select count(*) from person where id = 603") == "1"
|
||||
check db.getValue(sql"select count(*) from person where id = 602") == "1"
|
||||
check db.getValue(sql"select count(*) from person where id = 601") == "1"
|
||||
|
||||
test "transaction set false on DbError":
|
||||
var failed = false
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(301), name = ?"john301", password = ?"p301", email = ?"john301@mail.com", salt = ?"s301", status = ?"ok")
|
||||
query:
|
||||
insert person(id = ?(301), name = ?"dup", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
else:
|
||||
failed = true
|
||||
check failed
|
||||
check db.getValue(sql"select count(*) from person where id = 301") == "0"
|
||||
|
||||
test "nested savepoints":
|
||||
var failed = false
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(401), name = ?"john401", password = ?"p401", email = ?"john401@mail.com", salt = ?"s401", status = ?"ok")
|
||||
var innerOk = true
|
||||
transaction:
|
||||
query:
|
||||
insert person(id = ?(402), name = ?"john402", password = ?"p402", email = ?"john402@mail.com", salt = ?"s402", status = ?"ok")
|
||||
query:
|
||||
insert person(id = ?(401), name = ?"dup401", password = ?"p", email = ?"e", salt = ?"s", status = ?"x")
|
||||
else:
|
||||
innerOk = false
|
||||
|
||||
check innerOk == false
|
||||
|
||||
# after inner rollback, we can still insert another row and commit outer
|
||||
query:
|
||||
insert person(id = ?(403), name = ?"john403", password = ?"p403", email = ?"john403@mail.com", salt = ?"s403", status = ?"ok")
|
||||
else:
|
||||
failed = true
|
||||
check db.getValue(sql"select count(*) from person where id in (401,402,403)") == "2"
|
||||
@@ -0,0 +1,38 @@
|
||||
## Ormin -- ORM for Nim.
|
||||
## (c) 2017 Andreas Rumpf
|
||||
## MIT License.
|
||||
|
||||
import strutils, os, parseopt
|
||||
|
||||
import ../ormin/importer_core
|
||||
|
||||
proc writeHelp() =
|
||||
echo """
|
||||
ormin <schema.sql> --out:<file.nim> --db:postgre|sqlite|mysql
|
||||
"""
|
||||
|
||||
proc writeVersion() = echo "v1.0"
|
||||
|
||||
var p = initOptParser()
|
||||
var infile = ""
|
||||
var outfile = ""
|
||||
var target: ImportTarget
|
||||
for kind, key, val in p.getopt():
|
||||
case kind
|
||||
of cmdArgument:
|
||||
infile = key
|
||||
of cmdLongOption, cmdShortOption:
|
||||
case key
|
||||
of "help", "h": writeHelp()
|
||||
of "version", "v": writeVersion()
|
||||
of "out", "o": outfile = val
|
||||
of "db": target = parseEnum[ImportTarget](val)
|
||||
else: discard
|
||||
of cmdEnd: assert(false) # cannot happen
|
||||
if infile == "":
|
||||
# no filename has been given, so we show the help:
|
||||
writeHelp()
|
||||
else:
|
||||
if outfile == "":
|
||||
outfile = changeFileExt(infile, "nim")
|
||||
writeFile(outfile, generateModelCode(readFile(infile), absolutePath(infile), target))
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Try default connection first, then fallback to -U postgres
|
||||
run_psql_file() {
|
||||
local db="$1"; shift
|
||||
local file="$1"; shift
|
||||
if ! psql -v ON_ERROR_STOP=1 -d "$db" -f "$file" "$@" >/dev/null 2>&1; then
|
||||
psql -v ON_ERROR_STOP=1 -U postgres -d "$db" -f "$file" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_psql_cmd() {
|
||||
local db="$1"; shift
|
||||
local cmd="$1"; shift
|
||||
if ! psql -v ON_ERROR_STOP=1 -d "$db" -c "$cmd" "$@" >/dev/null 2>&1; then
|
||||
psql -v ON_ERROR_STOP=1 -U postgres -d "$db" -c "$cmd" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create role 'test' if needed
|
||||
run_psql_file postgres tools/setup_postgres_role.sql
|
||||
|
||||
# Create database 'test' if needed (must be outside DO block)
|
||||
if ! psql -v ON_ERROR_STOP=1 -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'test_ormin'" | grep -q 1; then
|
||||
run_psql_cmd postgres "CREATE DATABASE test_ormin OWNER test"
|
||||
fi
|
||||
|
||||
# Grant privileges on public schema
|
||||
run_psql_cmd test_ormin "GRANT ALL PRIVILEGES ON SCHEMA public TO test"
|
||||
|
||||
echo "Postgres test DB/user ensured (role 'test', db 'test_ormin')."
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'test') THEN
|
||||
CREATE ROLE test LOGIN PASSWORD 'test';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'test') THEN
|
||||
CREATE DATABASE test OWNER test;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
\connect test
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO test;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
DO
|
||||
$$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'test') THEN
|
||||
CREATE ROLE test LOGIN PASSWORD 'test';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import std/asyncdispatch
|
||||
import std/asyncnet
|
||||
import std/net as netmod
|
||||
import std/locks
|
||||
import std/strutils
|
||||
import std/endians
|
||||
|
||||
@@ -108,6 +110,16 @@ proc readString(buf: openArray[byte], pos: var int): string =
|
||||
result[i] = char(buf[pos + i])
|
||||
pos += len
|
||||
|
||||
proc toBytes(s: string): seq[byte] =
|
||||
result = newSeq[byte](s.len)
|
||||
for i, c in s:
|
||||
result[i] = byte(c)
|
||||
|
||||
proc toString(s: seq[byte]): string =
|
||||
result = newString(s.len)
|
||||
for i, b in s:
|
||||
result[i] = char(b)
|
||||
|
||||
proc serializeValue*(buf: var seq[byte], val: WireValue) =
|
||||
buf.add(byte(val.kind))
|
||||
case val.kind
|
||||
@@ -289,7 +301,7 @@ proc close*(client: BaraClient) =
|
||||
if client.connected:
|
||||
try:
|
||||
let msg = buildMessage(mkClose, client.nextId(), @[])
|
||||
waitFor client.socket.send(cast[string](msg))
|
||||
waitFor client.socket.send(toString(msg))
|
||||
except: discard
|
||||
client.socket.close()
|
||||
client.connected = false
|
||||
@@ -319,13 +331,13 @@ proc readQueryResponse(client: BaraClient): Future[QueryResult] {.async.} =
|
||||
raise newException(IOError, "Connection closed")
|
||||
|
||||
var pos = 0
|
||||
let hdrData = cast[seq[byte]](headerData)
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
let payloadLen = int(readUint32(hdrData, pos))
|
||||
discard readUint32(hdrData, pos)
|
||||
|
||||
let payloadStr = await client.socket.recv(payloadLen)
|
||||
var payload = cast[seq[byte]](payloadStr)
|
||||
var payload = toBytes(payloadStr)
|
||||
|
||||
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
|
||||
|
||||
@@ -360,14 +372,14 @@ proc readQueryResponse(client: BaraClient): Future[QueryResult] {.async.} =
|
||||
let compHeader = await client.socket.recv(12)
|
||||
if compHeader.len >= 12:
|
||||
var chPos = 0
|
||||
let chData = cast[seq[byte]](compHeader)
|
||||
let chData = toBytes(compHeader)
|
||||
let compKind = MsgKind(readUint32(chData, chPos))
|
||||
let compLen = int(readUint32(chData, chPos))
|
||||
discard readUint32(chData, chPos)
|
||||
let compPayloadStr = await client.socket.recv(compLen)
|
||||
if compKind == mkComplete:
|
||||
var cpPos = 0
|
||||
result.affectedRows = int(readUint32(cast[seq[byte]](compPayloadStr), cpPos))
|
||||
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
|
||||
return
|
||||
if kind == mkComplete:
|
||||
var rpos = 0
|
||||
@@ -379,7 +391,8 @@ proc query*(client: BaraClient, sql: string): Future[QueryResult] {.async.} =
|
||||
raise newException(IOError, "Not connected")
|
||||
|
||||
let msg = makeQueryMessage(client.nextId(), sql)
|
||||
await client.socket.send(cast[string](msg))
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
return await client.readQueryResponse()
|
||||
|
||||
@@ -388,7 +401,8 @@ proc query*(client: BaraClient, sql: string, params: seq[WireValue]): Future[Que
|
||||
raise newException(IOError, "Not connected")
|
||||
|
||||
let msg = makeQueryParamsMessage(client.nextId(), sql, params)
|
||||
await client.socket.send(cast[string](msg))
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
return await client.readQueryResponse()
|
||||
|
||||
@@ -401,14 +415,15 @@ proc auth*(client: BaraClient, token: string) {.async.} =
|
||||
raise newException(IOError, "Not connected")
|
||||
|
||||
let msg = makeAuthMessage(client.nextId(), token)
|
||||
await client.socket.send(cast[string](msg))
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
let headerData = await client.socket.recv(12)
|
||||
if headerData.len < 12:
|
||||
raise newException(IOError, "Connection closed")
|
||||
|
||||
var pos = 0
|
||||
let hdrData = cast[seq[byte]](headerData)
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
let payloadLen = int(readUint32(hdrData, pos))
|
||||
discard readUint32(hdrData, pos)
|
||||
@@ -418,7 +433,7 @@ proc auth*(client: BaraClient, token: string) {.async.} =
|
||||
elif kind == mkError:
|
||||
let payloadStr = await client.socket.recv(payloadLen)
|
||||
var epos = 0
|
||||
let emsg = readString(cast[seq[byte]](payloadStr), epos)
|
||||
let emsg = readString(toBytes(payloadStr), epos)
|
||||
raise newException(IOError, "Auth failed: " & emsg)
|
||||
else:
|
||||
raise newException(IOError, "Unexpected auth response: 0x" & toHex(uint32(kind), 2))
|
||||
@@ -427,14 +442,15 @@ proc ping*(client: BaraClient): Future[bool] {.async.} =
|
||||
if not client.connected:
|
||||
return false
|
||||
let msg = buildMessage(mkPing, client.nextId(), @[])
|
||||
await client.socket.send(cast[string](msg))
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
let headerData = await client.socket.recv(12)
|
||||
if headerData.len < 12:
|
||||
return false
|
||||
|
||||
var pos = 0
|
||||
let hdrData = cast[seq[byte]](headerData)
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
return kind == mkPong
|
||||
|
||||
@@ -516,32 +532,167 @@ proc build*(qb: QueryBuilder): string =
|
||||
proc exec*(qb: QueryBuilder): Future[QueryResult] {.async.} =
|
||||
return await qb.client.query(qb.build())
|
||||
|
||||
# === Sync Wrapper ===
|
||||
# === Blocking Sync Client (production-grade, no waitFor) ===
|
||||
|
||||
type
|
||||
SyncClient* = ref object
|
||||
asyncClient: BaraClient
|
||||
config: ClientConfig
|
||||
socket: netmod.Socket
|
||||
connected: bool
|
||||
requestId: uint32
|
||||
lock: Lock
|
||||
|
||||
proc newSyncClient*(config: ClientConfig = defaultConfig()): SyncClient =
|
||||
SyncClient(asyncClient: newClient(config))
|
||||
result = SyncClient(config: config, connected: false, requestId: 0)
|
||||
result.socket = netmod.newSocket()
|
||||
initLock(result.lock)
|
||||
|
||||
proc recvExact(sock: netmod.Socket, size: int): string =
|
||||
result = ""
|
||||
while result.len < size:
|
||||
let chunk = sock.recv(size - result.len)
|
||||
if chunk.len == 0:
|
||||
raise newException(IOError, "Connection closed")
|
||||
result.add(chunk)
|
||||
|
||||
proc readQueryResponseBlocking(client: SyncClient): QueryResult =
|
||||
let headerData = client.socket.recvExact(12)
|
||||
var pos = 0
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
let payloadLen = int(readUint32(hdrData, pos))
|
||||
discard readUint32(hdrData, pos)
|
||||
|
||||
let payloadStr = client.socket.recvExact(payloadLen)
|
||||
var payload = toBytes(payloadStr)
|
||||
|
||||
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
|
||||
|
||||
if kind == mkReady:
|
||||
return
|
||||
if kind == mkError and payload.len >= 8:
|
||||
var epos = 0
|
||||
let code = readUint32(payload, epos)
|
||||
let emsg = readString(payload, epos)
|
||||
raise newException(IOError, "Error " & $code & ": " & emsg)
|
||||
if kind == mkData:
|
||||
var dpos = 0
|
||||
let colCount = int(readUint32(payload, dpos))
|
||||
var cols: seq[string] = @[]
|
||||
for i in 0..<colCount:
|
||||
cols.add(readString(payload, dpos))
|
||||
result.columns = cols
|
||||
var colTypes: seq[string] = @[]
|
||||
for i in 0..<colCount:
|
||||
colTypes.add($FieldKind(payload[dpos]))
|
||||
inc dpos
|
||||
result.columnTypes = colTypes
|
||||
let rowCount = int(readUint32(payload, dpos))
|
||||
for r in 0..<rowCount:
|
||||
var row: seq[string] = @[]
|
||||
for c in 0..<colCount:
|
||||
let wv = deserializeValue(payload, dpos)
|
||||
row.add(wireValueToString(wv))
|
||||
result.rows.add(row)
|
||||
result.rowCount = rowCount
|
||||
# Read following mkComplete message
|
||||
let compHeader = client.socket.recvExact(12)
|
||||
var chPos = 0
|
||||
let chData = toBytes(compHeader)
|
||||
let compKind = MsgKind(readUint32(chData, chPos))
|
||||
let compLen = int(readUint32(chData, chPos))
|
||||
discard readUint32(chData, chPos)
|
||||
let compPayloadStr = client.socket.recvExact(compLen)
|
||||
if compKind == mkComplete:
|
||||
var cpPos = 0
|
||||
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
|
||||
return
|
||||
if kind == mkComplete:
|
||||
var rpos = 0
|
||||
result.affectedRows = int(readUint32(payload, rpos))
|
||||
return
|
||||
|
||||
proc connect*(client: SyncClient) =
|
||||
waitFor client.asyncClient.connect()
|
||||
netmod.connect(client.socket, client.config.host, Port(client.config.port))
|
||||
client.connected = true
|
||||
|
||||
proc close*(client: SyncClient) =
|
||||
client.asyncClient.close()
|
||||
if client.connected:
|
||||
try:
|
||||
let msg = buildMessage(mkClose, 0, @[])
|
||||
netmod.send(client.socket, toString(msg))
|
||||
except: discard
|
||||
netmod.close(client.socket)
|
||||
client.connected = false
|
||||
deinitLock(client.lock)
|
||||
|
||||
proc query*(client: SyncClient, sql: string): QueryResult =
|
||||
waitFor client.asyncClient.query(sql)
|
||||
acquire(client.lock)
|
||||
try:
|
||||
if not client.connected:
|
||||
raise newException(IOError, "Not connected")
|
||||
let msg = makeQueryMessage(0, sql)
|
||||
netmod.send(client.socket, toString(msg))
|
||||
return readQueryResponseBlocking(client)
|
||||
finally:
|
||||
release(client.lock)
|
||||
|
||||
proc query*(client: SyncClient, sql: string, params: seq[WireValue]): QueryResult =
|
||||
waitFor client.asyncClient.query(sql, params)
|
||||
acquire(client.lock)
|
||||
try:
|
||||
if not client.connected:
|
||||
raise newException(IOError, "Not connected")
|
||||
let msg = makeQueryParamsMessage(0, sql, params)
|
||||
netmod.send(client.socket, toString(msg))
|
||||
return readQueryResponseBlocking(client)
|
||||
finally:
|
||||
release(client.lock)
|
||||
|
||||
proc exec*(client: SyncClient, sql: string): int =
|
||||
let qr = client.query(sql)
|
||||
return qr.affectedRows
|
||||
|
||||
proc auth*(client: SyncClient, token: string) =
|
||||
waitFor client.asyncClient.auth(token)
|
||||
acquire(client.lock)
|
||||
try:
|
||||
if not client.connected:
|
||||
raise newException(IOError, "Not connected")
|
||||
let msg = makeAuthMessage(0, token)
|
||||
netmod.send(client.socket, toString(msg))
|
||||
let headerData = client.socket.recvExact(12)
|
||||
var pos = 0
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
let payloadLen = int(readUint32(hdrData, pos))
|
||||
discard readUint32(hdrData, pos)
|
||||
if kind == mkAuthOk:
|
||||
return
|
||||
elif kind == mkError:
|
||||
let payloadStr = client.socket.recvExact(payloadLen)
|
||||
var epos = 0
|
||||
let emsg = readString(toBytes(payloadStr), epos)
|
||||
raise newException(IOError, "Auth failed: " & emsg)
|
||||
else:
|
||||
raise newException(IOError, "Unexpected auth response")
|
||||
finally:
|
||||
release(client.lock)
|
||||
|
||||
proc ping*(client: SyncClient): bool =
|
||||
waitFor client.asyncClient.ping()
|
||||
acquire(client.lock)
|
||||
try:
|
||||
if not client.connected:
|
||||
return false
|
||||
let msg = buildMessage(mkPing, 0, @[])
|
||||
netmod.send(client.socket, toString(msg))
|
||||
let headerData = client.socket.recvExact(12)
|
||||
var pos = 0
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
return kind == mkPong
|
||||
except:
|
||||
return false
|
||||
finally:
|
||||
release(client.lock)
|
||||
|
||||
proc `$`*(qr: QueryResult): string =
|
||||
if qr.columns.len == 0: return "(no results)"
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
## BaraDB Nim Client — Integration Tests
|
||||
## Requires a running BaraDB server on localhost:9472.
|
||||
## Requires a running BaraDB server.
|
||||
## Set BARADB_HOST / BARADB_PORT env vars to override defaults.
|
||||
|
||||
import std/unittest
|
||||
import std/asyncdispatch
|
||||
import std/asyncnet
|
||||
import std/strutils
|
||||
import std/os
|
||||
import baradb/client
|
||||
|
||||
const
|
||||
TestHost = "127.0.0.1"
|
||||
TestPort = 9472
|
||||
TestHost = getEnv("BARADB_HOST", "127.0.0.1")
|
||||
TestPort = parseInt(getEnv("BARADB_PORT", "9472"))
|
||||
|
||||
proc serverAvailable(): bool =
|
||||
try:
|
||||
|
||||
+50
-28
@@ -1,15 +1,16 @@
|
||||
# BaraDB Python Client
|
||||
# BaraDB Python Async Client
|
||||
|
||||
Official Python client for **BaraDB** — a multimodal database engine written in Nim.
|
||||
Official async Python client for **BaraDB** — a multimodal database engine written in Nim.
|
||||
|
||||
## Features
|
||||
|
||||
- **Async/await** — fully non-blocking, concurrent query support
|
||||
- **Binary wire protocol** — fast, compact TCP communication
|
||||
- **Sync & blocking API** — simple to use in scripts and apps
|
||||
- **Request queueing** — sequential processing with concurrent execution
|
||||
- **Query builder** — fluent SQL construction
|
||||
- **Parameterized queries** — safe from SQL injection
|
||||
- **Vector & JSON support** — first-class multimodal types
|
||||
- **Context managers** — `with` statement support
|
||||
- **Context managers** — async `async with` statement support
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -20,7 +21,7 @@ pip install baradb
|
||||
Or from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/barabadb/baradadb.git
|
||||
git clone https://codeberg.org/baraba/baradb
|
||||
cd clients/python
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
@@ -28,48 +29,62 @@ pip install -e ".[dev]"
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from baradb import Client
|
||||
|
||||
client = Client("localhost", 9472)
|
||||
client.connect()
|
||||
async def main():
|
||||
client = Client("localhost", 9472)
|
||||
await client.connect()
|
||||
|
||||
result = client.query("SELECT name, age FROM users WHERE age > 18")
|
||||
for row in result:
|
||||
result = await client.query("SELECT name, age FROM users WHERE age > 18")
|
||||
for row in result:
|
||||
print(row["name"], row["age"])
|
||||
|
||||
client.close()
|
||||
await client.close()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Context Manager
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from baradb import Client
|
||||
|
||||
with Client("localhost", 9472) as client:
|
||||
result = client.query("SELECT 1")
|
||||
async def main():
|
||||
async with Client("localhost", 9472) as client:
|
||||
result = await client.query("SELECT 1")
|
||||
print(result.row_count)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Parameterized Queries
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from baradb import Client, WireValue
|
||||
|
||||
with Client("localhost", 9472) as client:
|
||||
result = client.query_params(
|
||||
async def main():
|
||||
async with Client("localhost", 9472) as client:
|
||||
result = await client.query_params(
|
||||
"SELECT * FROM users WHERE age > $1 AND country = $2",
|
||||
[WireValue.int64(18), WireValue.string("BG")],
|
||||
)
|
||||
for row in result:
|
||||
print(row)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Query Builder
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from baradb import Client, QueryBuilder
|
||||
|
||||
with Client("localhost", 9472) as client:
|
||||
async def main():
|
||||
async with Client("localhost", 9472) as client:
|
||||
qb = (
|
||||
QueryBuilder(client)
|
||||
.select("name", "email")
|
||||
@@ -78,21 +93,28 @@ with Client("localhost", 9472) as client:
|
||||
.order_by("name")
|
||||
.limit(10)
|
||||
)
|
||||
result = qb.exec()
|
||||
result = await qb.exec()
|
||||
for row in result:
|
||||
print(row)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Vector Search
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from baradb import Client, WireValue
|
||||
|
||||
with Client("localhost", 9472) as client:
|
||||
result = client.query_params(
|
||||
async def main():
|
||||
async with Client("localhost", 9472) as client:
|
||||
result = await client.query_params(
|
||||
"SELECT id, name FROM products ORDER BY embedding <-> $1 LIMIT 5",
|
||||
[WireValue.vector([0.1, 0.2, 0.3])],
|
||||
)
|
||||
print(result.rows)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
@@ -107,7 +129,7 @@ Integration tests (requires server on `localhost:9472`):
|
||||
|
||||
```bash
|
||||
# Start server
|
||||
docker run -d -p 9472:9472 baradb:latest
|
||||
docker run -d -p 9472:9472 barabadb:latest
|
||||
|
||||
# Run all tests
|
||||
pytest
|
||||
@@ -124,17 +146,17 @@ pytest
|
||||
| `database` | `default` | Default database |
|
||||
| `username` | `admin` | Username |
|
||||
| `password` | `""` | Password |
|
||||
| `timeout` | `30` | Socket timeout in seconds |
|
||||
| `timeout` | `30.0` | Socket timeout in seconds |
|
||||
|
||||
### Methods
|
||||
### Methods (all async)
|
||||
|
||||
- `connect()` — open TCP connection
|
||||
- `close()` — close connection
|
||||
- `query(sql) -> QueryResult` — execute SELECT-like query
|
||||
- `query_params(sql, params) -> QueryResult` — parameterized query
|
||||
- `execute(sql) -> int` — execute DDL/DML, returns affected rows
|
||||
- `auth(token)` — JWT authentication
|
||||
- `ping() -> bool` — health check
|
||||
- `await client.connect()` — open TCP connection
|
||||
- `await client.close()` — close connection
|
||||
- `await client.query(sql) -> QueryResult` — execute SELECT-like query
|
||||
- `await client.query_params(sql, params) -> QueryResult` — parameterized query
|
||||
- `await client.execute(sql) -> int` — execute DDL/DML, returns affected rows
|
||||
- `await client.auth(token)` — JWT authentication
|
||||
- `await client.ping() -> bool` — health check
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
"""
|
||||
BaraDB Python Client
|
||||
BaraDB Python Async Client
|
||||
|
||||
Official Python client for BaraDB — Multimodal Database Engine.
|
||||
Official async Python client for BaraDB — Multimodal Database Engine.
|
||||
Communicates via the BaraDB Wire Protocol (binary, big-endian, TCP).
|
||||
|
||||
Install:
|
||||
pip install baradb
|
||||
|
||||
Quick Start:
|
||||
import asyncio
|
||||
from baradb import Client
|
||||
|
||||
async def main():
|
||||
client = Client("localhost", 9472)
|
||||
client.connect()
|
||||
result = client.query("SELECT name FROM users WHERE age > 18")
|
||||
await client.connect()
|
||||
result = await client.query("SELECT name FROM users WHERE age > 18")
|
||||
for row in result:
|
||||
print(row["name"])
|
||||
client.close()
|
||||
await client.close()
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
Parameterized Queries:
|
||||
result = await client.query_params(
|
||||
"SELECT * FROM users WHERE age > $1",
|
||||
[WireValue.int64(18)]
|
||||
)
|
||||
|
||||
Authentication:
|
||||
await client.auth("jwt-token-here")
|
||||
"""
|
||||
|
||||
from .core import (
|
||||
@@ -27,7 +41,7 @@ from .core import (
|
||||
ResultFormat,
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.6"
|
||||
__all__ = [
|
||||
"Client",
|
||||
"QueryBuilder",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
BaraDB Chat Message History — Conversation Buffer with RLS
|
||||
|
||||
Implements LangChain's BaseChatMessageHistory interface backed by BaraDB.
|
||||
Supports multi-tenant isolation via tenant_id and user_id.
|
||||
|
||||
Usage:
|
||||
from baradb import Client, WireValue
|
||||
from baradb.chat_history import BaraDBChatHistory
|
||||
|
||||
client = Client("localhost", 9472)
|
||||
await client.connect()
|
||||
|
||||
history = BaraDBChatHistory(
|
||||
client=client,
|
||||
session_id="session-123",
|
||||
tenant_id="company-a",
|
||||
user_id="user-42",
|
||||
)
|
||||
|
||||
# Add messages
|
||||
history.add_user_message("Hello, AI!")
|
||||
history.add_ai_message("Hello, how can I help?")
|
||||
|
||||
# Retrieve conversation
|
||||
messages = history.messages
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class BaraDBChatHistory:
|
||||
"""
|
||||
Chat message history backed by BaraDB with multi-tenant RLS support.
|
||||
|
||||
Stores conversations in a `chat_history` table with columns:
|
||||
id, session_id, role, content, metadata, tenant_id, user_id, created_at
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
session_id: str,
|
||||
table: str = "chat_history",
|
||||
tenant_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
max_messages: int = 1000,
|
||||
):
|
||||
self.client = client
|
||||
self.session_id = session_id
|
||||
self.table = table
|
||||
self.tenant_id = tenant_id
|
||||
self.user_id = user_id
|
||||
self.max_messages = max_messages
|
||||
self._initialized = False
|
||||
|
||||
async def _ensure_table(self):
|
||||
if self._initialized:
|
||||
return
|
||||
await self.client.query(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.table} (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
metadata TEXT,
|
||||
tenant_id TEXT,
|
||||
user_id TEXT,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
await self.client.query(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_{self.table}_session "
|
||||
f"ON {self.table}(session_id) USING btree"
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
def _build_session(self) -> Dict[str, str]:
|
||||
s = {"app.bara_chat_session": self.session_id}
|
||||
if self.tenant_id:
|
||||
s["app.tenant_id"] = self.tenant_id
|
||||
if self.user_id:
|
||||
s["app.user_id"] = self.user_id
|
||||
return s
|
||||
|
||||
async def add_message(self, message: Any) -> None:
|
||||
await self._ensure_table()
|
||||
role = getattr(message, "type", "human")
|
||||
if role == "human":
|
||||
role = "user"
|
||||
content = getattr(message, "content", str(message))
|
||||
msg_id = f"{self.session_id}:{datetime.utcnow().timestamp()}"
|
||||
metadata = json.dumps(getattr(message, "additional_kwargs", {}) or {})
|
||||
created_at = datetime.utcnow().isoformat()
|
||||
|
||||
for key, val in self._build_session().items():
|
||||
await self.client.query_params(
|
||||
f"SET {key} = $1", [self._wire_string(val)]
|
||||
)
|
||||
|
||||
await self.client.query_params(
|
||||
f"INSERT INTO {self.table} (id, session_id, role, content, metadata, tenant_id, user_id, created_at) "
|
||||
f"VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
[
|
||||
self._wire_string(msg_id),
|
||||
self._wire_string(self.session_id),
|
||||
self._wire_string(role),
|
||||
self._wire_string(content),
|
||||
self._wire_string(metadata),
|
||||
self._wire_string(self.tenant_id or ""),
|
||||
self._wire_string(self.user_id or ""),
|
||||
self._wire_string(created_at),
|
||||
],
|
||||
)
|
||||
|
||||
def add_user_message(self, message: Any) -> None:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
if hasattr(message, "content"):
|
||||
content = message.content
|
||||
else:
|
||||
content = str(message)
|
||||
loop.run_until_complete(self._add_message_internal(content, "user"))
|
||||
|
||||
def add_ai_message(self, message: Any) -> None:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
if hasattr(message, "content"):
|
||||
content = message.content
|
||||
else:
|
||||
content = str(message)
|
||||
loop.run_until_complete(self._add_message_internal(content, "ai"))
|
||||
|
||||
async def _add_message_internal(self, content: str, role: str):
|
||||
await self._ensure_table()
|
||||
msg_id = f"{self.session_id}:{datetime.utcnow().timestamp()}"
|
||||
created_at = datetime.utcnow().isoformat()
|
||||
|
||||
for key, val in self._build_session().items():
|
||||
await self.client.query_params(
|
||||
f"SET {key} = $1", [self._wire_string(val)]
|
||||
)
|
||||
|
||||
await self.client.query_params(
|
||||
f"INSERT INTO {self.table} (id, session_id, role, content, tenant_id, user_id, created_at) "
|
||||
f"VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
[
|
||||
self._wire_string(msg_id),
|
||||
self._wire_string(self.session_id),
|
||||
self._wire_string(role),
|
||||
self._wire_string(content),
|
||||
self._wire_string(self.tenant_id or ""),
|
||||
self._wire_string(self.user_id or ""),
|
||||
self._wire_string(created_at),
|
||||
],
|
||||
)
|
||||
|
||||
async def get_messages(self) -> List[Any]:
|
||||
await self._ensure_table()
|
||||
class SimpleMessage:
|
||||
def __init__(self, role: str, content: str):
|
||||
self.type = "human" if role == "user" else role
|
||||
self.content = content
|
||||
self.additional_kwargs = {}
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.type}: {self.content}"
|
||||
|
||||
for key, val in self._build_session().items():
|
||||
await self.client.query_params(
|
||||
f"SET {key} = $1", [self._wire_string(val)]
|
||||
)
|
||||
|
||||
result = await self.client.query_params(
|
||||
f"SELECT role, content FROM {self.table} "
|
||||
f"WHERE session_id = $1 "
|
||||
f"ORDER BY created_at ASC "
|
||||
f"LIMIT $2",
|
||||
[
|
||||
self._wire_string(self.session_id),
|
||||
self._wire_int(self.max_messages),
|
||||
],
|
||||
)
|
||||
messages = []
|
||||
if result and hasattr(result, "rows"):
|
||||
for row in result.rows:
|
||||
role = row.get("role", "user")
|
||||
content = row.get("content", "")
|
||||
messages.append(SimpleMessage(role, content))
|
||||
|
||||
return messages
|
||||
|
||||
@property
|
||||
def messages(self) -> List[Any]:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(self.get_messages())
|
||||
|
||||
async def clear(self) -> None:
|
||||
await self._ensure_table()
|
||||
for key, val in self._build_session().items():
|
||||
await self.client.query_params(
|
||||
f"SET {key} = $1", [self._wire_string(val)]
|
||||
)
|
||||
await self.client.query_params(
|
||||
f"DELETE FROM {self.table} WHERE session_id = $1",
|
||||
[self._wire_string(self.session_id)],
|
||||
)
|
||||
|
||||
async def get_session_summary(self, max_tokens: int = 2000) -> str:
|
||||
messages = await self.get_messages()
|
||||
parts = []
|
||||
total_chars = 0
|
||||
for msg in reversed(messages):
|
||||
text = f"{msg.type}: {getattr(msg, 'content', '')}"
|
||||
if total_chars + len(text) > max_tokens * 4:
|
||||
break
|
||||
parts.insert(0, text)
|
||||
total_chars += len(text)
|
||||
return "\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _wire_string(val: str) -> Any:
|
||||
# Lazy import to avoid circular dependency
|
||||
from baradb import WireValue
|
||||
return WireValue.string(val)
|
||||
|
||||
@staticmethod
|
||||
def _wire_int(val: int) -> Any:
|
||||
from baradb import WireValue
|
||||
return WireValue.int64(val)
|
||||
+138
-72
@@ -1,33 +1,38 @@
|
||||
"""
|
||||
BaraDB Python Client
|
||||
BaraDB Python Async Client
|
||||
|
||||
Binary protocol client for BaraDB database.
|
||||
Communicates via the BaraDB Wire Protocol (binary, big-endian).
|
||||
Official async Python client for BaraDB — Multimodal Database Engine.
|
||||
Communicates via the BaraDB Wire Protocol (binary, big-endian, TCP).
|
||||
|
||||
Install:
|
||||
pip install baradb
|
||||
|
||||
Quick Start:
|
||||
import asyncio
|
||||
from baradb import Client
|
||||
|
||||
async def main():
|
||||
client = Client("localhost", 9472)
|
||||
client.connect()
|
||||
result = client.query("SELECT name FROM users WHERE age > 18")
|
||||
await client.connect()
|
||||
result = await client.query("SELECT name FROM users WHERE age > 18")
|
||||
for row in result:
|
||||
print(row["name"])
|
||||
client.close()
|
||||
await client.close()
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
Parameterized Queries:
|
||||
result = client.query_params("SELECT * FROM users WHERE age > $1", [WireValue.int64(18)])
|
||||
result = await client.query_params(
|
||||
"SELECT * FROM users WHERE age > $1",
|
||||
[WireValue.int64(18)]
|
||||
)
|
||||
|
||||
Authentication:
|
||||
client = Client("localhost", 9472, username="admin", password="secret")
|
||||
client.connect()
|
||||
client.auth("jwt-token-here")
|
||||
await client.auth("jwt-token-here")
|
||||
"""
|
||||
|
||||
import socket
|
||||
import asyncio
|
||||
import struct
|
||||
import json
|
||||
from typing import Any, Optional, Sequence
|
||||
|
||||
|
||||
@@ -49,7 +54,6 @@ class FieldKind:
|
||||
|
||||
|
||||
class MsgKind:
|
||||
# Client messages
|
||||
CLIENT_HANDSHAKE = 0x01
|
||||
QUERY = 0x02
|
||||
QUERY_PARAMS = 0x03
|
||||
@@ -59,7 +63,6 @@ class MsgKind:
|
||||
CLOSE = 0x07
|
||||
PING = 0x08
|
||||
AUTH = 0x09
|
||||
# Server messages
|
||||
SERVER_HANDSHAKE = 0x80
|
||||
READY = 0x81
|
||||
DATA = 0x82
|
||||
@@ -88,7 +91,7 @@ class WireValue:
|
||||
return WireValue(FieldKind.NULL)
|
||||
|
||||
@staticmethod
|
||||
def bool_val(val: bool):
|
||||
def bool(val: bool):
|
||||
return WireValue(FieldKind.BOOL, val)
|
||||
|
||||
@staticmethod
|
||||
@@ -120,15 +123,15 @@ class WireValue:
|
||||
return WireValue(FieldKind.STRING, val)
|
||||
|
||||
@staticmethod
|
||||
def bytes_val(val: bytes):
|
||||
def bytes(val: bytes):
|
||||
return WireValue(FieldKind.BYTES, val)
|
||||
|
||||
@staticmethod
|
||||
def array_val(val: list):
|
||||
def array(val: list):
|
||||
return WireValue(FieldKind.ARRAY, val)
|
||||
|
||||
@staticmethod
|
||||
def object_val(val: dict):
|
||||
def object(val: dict):
|
||||
return WireValue(FieldKind.OBJECT, val)
|
||||
|
||||
@staticmethod
|
||||
@@ -136,7 +139,7 @@ class WireValue:
|
||||
return WireValue(FieldKind.VECTOR, val)
|
||||
|
||||
@staticmethod
|
||||
def json_val(val: str):
|
||||
def json(val: str):
|
||||
return WireValue(FieldKind.JSON, val)
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
@@ -202,72 +205,92 @@ class QueryResult:
|
||||
|
||||
|
||||
class Client:
|
||||
"""BaraDB database client."""
|
||||
"""Async BaraDB database client."""
|
||||
|
||||
def __init__(self, host: str = "localhost", port: int = 9472,
|
||||
database: str = "default", username: str = "admin",
|
||||
password: str = "", timeout: int = 30):
|
||||
password: str = "", timeout: float = 30.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.database = database
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._connected = False
|
||||
self._request_id = 0
|
||||
self._buffer = bytearray()
|
||||
self._pending_resolve = None
|
||||
self._request_queue: list = []
|
||||
self._request_lock = False
|
||||
|
||||
def connect(self) -> None:
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the BaraDB server."""
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._sock.settimeout(self.timeout)
|
||||
self._sock.connect((self.host, self.port))
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.host, self.port),
|
||||
timeout=self.timeout
|
||||
)
|
||||
self._connected = True
|
||||
|
||||
def close(self) -> None:
|
||||
if self._sock:
|
||||
async def close(self) -> None:
|
||||
"""Close the connection to the server."""
|
||||
if self._writer and self._connected:
|
||||
try:
|
||||
msg = self._build_message(MsgKind.CLOSE, b"")
|
||||
self._sock.send(msg)
|
||||
self._writer.write(msg)
|
||||
await self._writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock.close()
|
||||
if self._writer:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
self._connected = False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if client is connected."""
|
||||
return self._connected
|
||||
|
||||
def _next_id(self) -> int:
|
||||
self._request_id += 1
|
||||
return self._request_id
|
||||
|
||||
def _recv_exact(self, size: int) -> bytes:
|
||||
async def _recv_exact(self, size: int) -> bytes:
|
||||
"""Receive exactly `size` bytes from the socket."""
|
||||
data = b""
|
||||
while len(data) < size:
|
||||
chunk = self._sock.recv(size - len(data))
|
||||
while len(self._buffer) < size:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
self._reader.read(size - len(self._buffer)),
|
||||
timeout=self.timeout
|
||||
)
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed by server")
|
||||
data += chunk
|
||||
self._buffer.extend(chunk)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError("Receive timeout")
|
||||
data = bytes(self._buffer[:size])
|
||||
del self._buffer[:size]
|
||||
return data
|
||||
|
||||
def _read_response_header(self) -> tuple[int, int, int]:
|
||||
async def _read_header(self) -> tuple[int, int, int]:
|
||||
"""Read a 12-byte message header. Returns (kind, length, request_id)."""
|
||||
header = self._recv_exact(12)
|
||||
header = await self._recv_exact(12)
|
||||
kind, length, req_id = struct.unpack(">III", header)
|
||||
return kind, length, req_id
|
||||
|
||||
def _read_error(self, length: int) -> Exception:
|
||||
async def _read_error(self, length: int) -> Exception:
|
||||
"""Read and parse an ERROR payload."""
|
||||
data = self._recv_exact(length)
|
||||
data = await self._recv_exact(length)
|
||||
code = struct.unpack(">I", data[:4])[0]
|
||||
msg_len = struct.unpack(">I", data[4:8])[0]
|
||||
error_msg = data[8:8 + msg_len].decode("utf-8")
|
||||
return Exception(f"BaraDB error {code}: {error_msg}")
|
||||
|
||||
def _read_data_response(self, length: int) -> QueryResult:
|
||||
async def _read_data_response(self, length: int) -> QueryResult:
|
||||
"""Read and parse a DATA payload, then follow up with COMPLETE."""
|
||||
data = self._recv_exact(length)
|
||||
data = await self._recv_exact(length)
|
||||
pos = [0]
|
||||
|
||||
col_count = struct.unpack(">I", data[pos[0]:pos[0]+4])[0]
|
||||
@@ -299,68 +322,107 @@ class Client:
|
||||
result.rows = rows
|
||||
result.row_count = row_count
|
||||
|
||||
comp_kind, comp_len, _ = self._read_response_header()
|
||||
comp_kind, comp_len, _ = await self._read_header()
|
||||
if comp_kind == MsgKind.COMPLETE:
|
||||
comp_data = self._recv_exact(comp_len)
|
||||
comp_data = await self._recv_exact(comp_len)
|
||||
result.affected_rows = struct.unpack(">I", comp_data[:4])[0]
|
||||
elif comp_kind == MsgKind.ERROR:
|
||||
raise self._read_error(comp_len)
|
||||
raise await self._read_error(comp_len)
|
||||
|
||||
return result
|
||||
|
||||
def auth(self, token: str) -> None:
|
||||
async def auth(self, token: str) -> None:
|
||||
"""Authenticate with the server using a JWT token."""
|
||||
if not self._connected:
|
||||
raise Exception("Not connected")
|
||||
encoded = token.encode("utf-8")
|
||||
payload = struct.pack(">I", len(encoded)) + encoded
|
||||
msg = self._build_message(MsgKind.AUTH, payload)
|
||||
self._sock.send(msg)
|
||||
self._writer.write(msg)
|
||||
await self._writer.drain()
|
||||
|
||||
kind, length, _ = self._read_response_header()
|
||||
kind, length, _ = await self._read_header()
|
||||
if kind == MsgKind.AUTH_OK:
|
||||
return
|
||||
elif kind == MsgKind.ERROR:
|
||||
raise self._read_error(length)
|
||||
raise await self._read_error(length)
|
||||
else:
|
||||
raise Exception(f"Unexpected auth response: 0x{kind:02x}")
|
||||
|
||||
def ping(self) -> bool:
|
||||
async def ping(self) -> bool:
|
||||
"""Ping the server. Returns True if pong received."""
|
||||
if not self._connected:
|
||||
raise Exception("Not connected")
|
||||
msg = self._build_message(MsgKind.PING, b"")
|
||||
self._sock.send(msg)
|
||||
self._writer.write(msg)
|
||||
await self._writer.drain()
|
||||
|
||||
kind, length, _ = self._read_response_header()
|
||||
kind, length, _ = await self._read_header()
|
||||
if kind == MsgKind.PONG:
|
||||
return True
|
||||
elif kind == MsgKind.ERROR:
|
||||
raise self._read_error(length)
|
||||
raise await self._read_error(length)
|
||||
return False
|
||||
|
||||
def query(self, sql: str) -> QueryResult:
|
||||
async def _process_queue(self) -> None:
|
||||
"""Process queued requests sequentially."""
|
||||
if self._request_lock or len(self._request_queue) == 0:
|
||||
return
|
||||
self._request_lock = True
|
||||
task_data = self._request_queue.pop(0)
|
||||
try:
|
||||
result = await task_data["task"]()
|
||||
task_data["resolve"](result)
|
||||
except Exception as err:
|
||||
task_data["reject"](err)
|
||||
finally:
|
||||
self._request_lock = False
|
||||
asyncio.create_task(self._process_queue())
|
||||
|
||||
def _enqueue(self, task) -> None:
|
||||
"""Add a task to the request queue."""
|
||||
future = asyncio.Future()
|
||||
self._request_queue.append({"task": task, "resolve": future.set_result, "reject": future.set_exception})
|
||||
asyncio.create_task(self._process_queue())
|
||||
return future
|
||||
|
||||
async def query(self, sql: str) -> QueryResult:
|
||||
"""Execute a BaraQL query."""
|
||||
if not self._connected:
|
||||
raise Exception("Not connected")
|
||||
|
||||
async def _do_query():
|
||||
payload = self._encode_string(sql)
|
||||
payload += bytes([ResultFormat.BINARY])
|
||||
|
||||
msg = self._build_message(MsgKind.QUERY, payload)
|
||||
self._sock.send(msg)
|
||||
self._writer.write(msg)
|
||||
await self._writer.drain()
|
||||
|
||||
kind, length, _ = self._read_response_header()
|
||||
kind, length, _ = await self._read_header()
|
||||
|
||||
if kind == MsgKind.ERROR:
|
||||
raise self._read_error(length)
|
||||
raise await self._read_error(length)
|
||||
|
||||
if kind == MsgKind.DATA:
|
||||
return self._read_data_response(length)
|
||||
return await self._read_data_response(length)
|
||||
|
||||
if kind == MsgKind.COMPLETE:
|
||||
data = self._recv_exact(length)
|
||||
data = await self._recv_exact(length)
|
||||
result = QueryResult()
|
||||
result.affected_rows = struct.unpack(">I", data[:4])[0]
|
||||
return result
|
||||
|
||||
return QueryResult()
|
||||
|
||||
def query_params(self, sql: str, params: Sequence[WireValue]) -> QueryResult:
|
||||
return await self._enqueue(_do_query)
|
||||
|
||||
async def query_params(self, sql: str, params: Sequence[WireValue]) -> QueryResult:
|
||||
"""Execute a parameterized BaraQL query."""
|
||||
if not self._connected:
|
||||
raise Exception("Not connected")
|
||||
|
||||
async def _do_query_params():
|
||||
payload = self._encode_string(sql)
|
||||
payload += bytes([ResultFormat.BINARY])
|
||||
payload += struct.pack(">I", len(params))
|
||||
@@ -368,26 +430,30 @@ class Client:
|
||||
payload += p.serialize()
|
||||
|
||||
msg = self._build_message(MsgKind.QUERY_PARAMS, payload)
|
||||
self._sock.send(msg)
|
||||
self._writer.write(msg)
|
||||
await self._writer.drain()
|
||||
|
||||
kind, length, _ = self._read_response_header()
|
||||
kind, length, _ = await self._read_header()
|
||||
|
||||
if kind == MsgKind.ERROR:
|
||||
raise self._read_error(length)
|
||||
raise await self._read_error(length)
|
||||
|
||||
if kind == MsgKind.DATA:
|
||||
return self._read_data_response(length)
|
||||
return await self._read_data_response(length)
|
||||
|
||||
if kind == MsgKind.COMPLETE:
|
||||
data = self._recv_exact(length)
|
||||
data = await self._recv_exact(length)
|
||||
result = QueryResult()
|
||||
result.affected_rows = struct.unpack(">I", data[:4])[0]
|
||||
return result
|
||||
|
||||
return QueryResult()
|
||||
|
||||
def execute(self, sql: str) -> int:
|
||||
result = self.query(sql)
|
||||
return await self._enqueue(_do_query_params)
|
||||
|
||||
async def execute(self, sql: str) -> int:
|
||||
"""Execute a query and return affected rows count."""
|
||||
result = await self.query(sql)
|
||||
return result.affected_rows
|
||||
|
||||
def _build_message(self, kind: int, payload: bytes) -> bytes:
|
||||
@@ -477,12 +543,12 @@ class Client:
|
||||
return self._read_string(data, pos)
|
||||
return None
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
async def __aenter__(self):
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
async def __aexit__(self, *args):
|
||||
await self.close()
|
||||
|
||||
|
||||
class QueryBuilder:
|
||||
@@ -559,5 +625,5 @@ class QueryBuilder:
|
||||
sql += " OFFSET " + str(self._offset)
|
||||
return sql
|
||||
|
||||
def exec(self) -> QueryResult:
|
||||
return self.client.query(self.build())
|
||||
async def exec(self) -> QueryResult:
|
||||
return await self.client.query(self.build())
|
||||
@@ -0,0 +1,67 @@
|
||||
# BaraDB LangChain Integration
|
||||
|
||||
## Python
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from baradb import Client
|
||||
from baradb.langchain_store import BaraDBStore
|
||||
|
||||
async def main():
|
||||
client = Client("localhost", 9472)
|
||||
await client.connect()
|
||||
|
||||
# Use OpenAI, sentence-transformers, or any embedder
|
||||
def embed(text: str) -> list[float]:
|
||||
# Replace with your embedding model
|
||||
return [0.1, 0.2, 0.3]
|
||||
|
||||
store = BaraDBStore(
|
||||
client=client,
|
||||
table="knowledge",
|
||||
embedding_function=embed,
|
||||
tenant_id="tenant-a",
|
||||
vector_dimension=3,
|
||||
)
|
||||
|
||||
await store.add_texts(["BaraDB is fast", "Vector search in SQL"])
|
||||
results = await store.similarity_search("fast database", k=5)
|
||||
for doc, score in results:
|
||||
print(doc.page_content, score)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## JavaScript
|
||||
|
||||
```javascript
|
||||
const { Client } = require('./baradb');
|
||||
const { BaraDBStore } = require('./baradb_langchain');
|
||||
|
||||
async function main() {
|
||||
const client = new Client('localhost', 9472);
|
||||
await client.connect();
|
||||
|
||||
const store = new BaraDBStore({
|
||||
client,
|
||||
table: 'knowledge',
|
||||
embeddingFunction: async (text) => [0.1, 0.2, 0.3],
|
||||
tenantId: 'tenant-a',
|
||||
vectorDimension: 3,
|
||||
});
|
||||
|
||||
await store.addTexts(['BaraDB is fast', 'Vector search in SQL']);
|
||||
const results = await store.similaritySearch('fast database', 5);
|
||||
console.log(results);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- `add_texts()` / `addDocuments()` — auto-generate embeddings + INSERT
|
||||
- `similarity_search()` — uses `hybrid_search()` (vector + FTS + RRF)
|
||||
- `max_marginal_relevance_search()` — MMR reranking for diversity
|
||||
- `delete()` — remove by IDs
|
||||
- Multi-tenant — `tenant_id` sets session variable + metadata filter
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
BaraDB LangChain Vector Store Integration
|
||||
|
||||
Usage:
|
||||
from baradb import Client, WireValue
|
||||
from baradb.langchain_store import BaraDBStore
|
||||
from langchain.embeddings import OpenAIEmbeddings
|
||||
|
||||
client = Client("localhost", 9472)
|
||||
await client.connect()
|
||||
|
||||
store = BaraDBStore(
|
||||
client=client,
|
||||
table="docs",
|
||||
embedding_col="embedding",
|
||||
text_col="content",
|
||||
embedding_function=OpenAIEmbeddings().embed_query,
|
||||
tenant_id="company-a" # optional, for RLS
|
||||
)
|
||||
|
||||
await store.add_texts(["hello world", "quick brown fox"])
|
||||
results = await store.similarity_search("hello", k=5)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Callable, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
class BaraDBStore:
|
||||
"""LangChain-compatible Vector Store for BaraDB."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
table: str = "documents",
|
||||
embedding_col: str = "embedding",
|
||||
text_col: str = "content",
|
||||
metadata_cols: Optional[List[str]] = None,
|
||||
embedding_function: Optional[Callable[[str], List[float]]] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
vector_dimension: int = 1536,
|
||||
):
|
||||
self.client = client
|
||||
self.table = table
|
||||
self.embedding_col = embedding_col
|
||||
self.text_col = text_col
|
||||
self.metadata_cols = metadata_cols or []
|
||||
self.embedding_function = embedding_function
|
||||
self.tenant_id = tenant_id
|
||||
self.vector_dimension = vector_dimension
|
||||
self._table_created = False
|
||||
|
||||
def _wire(self, val: Any) -> Any:
|
||||
"""Lazy import WireValue to avoid circular deps."""
|
||||
from baradb import WireValue
|
||||
if isinstance(val, str):
|
||||
return WireValue.string(val)
|
||||
if isinstance(val, int):
|
||||
return WireValue.int64(val)
|
||||
if isinstance(val, float):
|
||||
return WireValue.float64(val)
|
||||
if val is None:
|
||||
return WireValue.null()
|
||||
return WireValue.string(str(val))
|
||||
|
||||
async def _ensure_table(self) -> None:
|
||||
if self._table_created:
|
||||
return
|
||||
# Create table with vector + text + tenant_id columns
|
||||
cols = f"id SERIAL PRIMARY KEY, {self.embedding_col} VECTOR({self.vector_dimension}), {self.text_col} TEXT"
|
||||
if self.tenant_id:
|
||||
cols += ", tenant_id TEXT"
|
||||
for mc in self.metadata_cols:
|
||||
cols += f", {mc} TEXT"
|
||||
await self.client.query(f"CREATE TABLE IF NOT EXISTS {self.table} ({cols})")
|
||||
|
||||
# Create indexes if not exist
|
||||
idx_vec = f"idx_{self.table}_vec"
|
||||
idx_fts = f"idx_{self.table}_fts"
|
||||
await self.client.query(f"CREATE INDEX IF NOT EXISTS {idx_vec} ON {self.table}({self.embedding_col}) USING hnsw")
|
||||
await self.client.query(f"CREATE INDEX IF NOT EXISTS {idx_fts} ON {self.table}({self.text_col}) USING FTS")
|
||||
self._table_created = True
|
||||
|
||||
async def add_texts(
|
||||
self,
|
||||
texts: Sequence[str],
|
||||
metadatas: Optional[List[dict]] = None,
|
||||
ids: Optional[List[str]] = None,
|
||||
) -> List[str]:
|
||||
await self._ensure_table()
|
||||
if not self.embedding_function:
|
||||
raise ValueError("embedding_function is required for add_texts")
|
||||
|
||||
inserted_ids: List[str] = []
|
||||
for i, text in enumerate(texts):
|
||||
vec = self.embedding_function(text)
|
||||
vec_str = "[" + ",".join(str(v) for v in vec) + "]"
|
||||
|
||||
meta = metadatas[i] if metadatas and i < len(metadatas) else {}
|
||||
col_names = [self.embedding_col, self.text_col]
|
||||
params = [self._wire(vec_str), self._wire(text)]
|
||||
|
||||
if self.tenant_id:
|
||||
col_names.append("tenant_id")
|
||||
params.append(self._wire(self.tenant_id))
|
||||
for mc in self.metadata_cols:
|
||||
if mc in meta:
|
||||
col_names.append(mc)
|
||||
params.append(self._wire(meta[mc]))
|
||||
|
||||
placeholders = [f"${j + 1}" for j in range(len(params))]
|
||||
sql = (
|
||||
f"INSERT INTO {self.table} ({', '.join(col_names)}) "
|
||||
f"VALUES ({', '.join(placeholders)}) RETURNING id"
|
||||
)
|
||||
result = await self.client.query_params(sql, params)
|
||||
if result.rows:
|
||||
inserted_ids.append(result.rows[0].get("id", str(i)))
|
||||
else:
|
||||
inserted_ids.append(str(i))
|
||||
return inserted_ids
|
||||
|
||||
async def similarity_search(
|
||||
self, query: str, k: int = 4, filter_col: Optional[str] = None, filter_val: Optional[str] = None
|
||||
) -> List[Tuple[Any, float]]:
|
||||
await self._ensure_table()
|
||||
if not self.embedding_function:
|
||||
raise ValueError("embedding_function is required for similarity_search")
|
||||
|
||||
vec = self.embedding_function(query)
|
||||
vec_str = "[" + ",".join(str(v) for v in vec) + "]"
|
||||
|
||||
# Set tenant session variable if multi-tenant
|
||||
if self.tenant_id:
|
||||
await self.client.query_params(
|
||||
"SET app.tenant_id = $1", [self._wire(self.tenant_id)]
|
||||
)
|
||||
|
||||
if filter_col and filter_val:
|
||||
sql = (
|
||||
"SELECT hybrid_search_filtered($1, $2, $3, $4, $5, $6, $7, $8) AS res"
|
||||
)
|
||||
params = [
|
||||
self._wire(self.table),
|
||||
self._wire(self.embedding_col),
|
||||
self._wire(self.text_col),
|
||||
self._wire(query),
|
||||
self._wire(vec_str),
|
||||
self._wire(k),
|
||||
self._wire(filter_col),
|
||||
self._wire(filter_val),
|
||||
]
|
||||
else:
|
||||
sql = "SELECT hybrid_search($1, $2, $3, $4, $5, $6) AS res"
|
||||
params = [
|
||||
self._wire(self.table),
|
||||
self._wire(self.embedding_col),
|
||||
self._wire(self.text_col),
|
||||
self._wire(query),
|
||||
self._wire(vec_str),
|
||||
self._wire(k),
|
||||
]
|
||||
|
||||
result = await self.client.query_params(sql, params)
|
||||
if not result.rows:
|
||||
return []
|
||||
|
||||
raw = result.rows[0].get("res", "[]")
|
||||
try:
|
||||
arr = json.loads(raw)
|
||||
except:
|
||||
return []
|
||||
|
||||
docs: List[Tuple[Any, float]] = []
|
||||
for item in arr:
|
||||
doc_id = item.get("id", "")
|
||||
score = float(item.get("score", 0))
|
||||
# Fetch full row — use parameterized query
|
||||
row_result = await self.client.query_params(
|
||||
f"SELECT * FROM {self.table} WHERE id = $1",
|
||||
[self._wire(doc_id)],
|
||||
)
|
||||
if row_result.rows:
|
||||
page_content = row_result.rows[0].get(self.text_col, "")
|
||||
metadata = dict(row_result.rows[0])
|
||||
# Wrap in a simple Document-like object
|
||||
doc = _SimpleDocument(page_content=page_content, metadata=metadata)
|
||||
docs.append((doc, score))
|
||||
return docs
|
||||
|
||||
async def max_marginal_relevance_search(
|
||||
self, query: str, k: int = 4, fetch_k: int = 20, lambda_mult: float = 0.5
|
||||
) -> List[Any]:
|
||||
"""MMR: diversify results while maintaining relevance."""
|
||||
await self._ensure_table()
|
||||
# Fetch more candidates
|
||||
candidates = await self.similarity_search(query, k=fetch_k)
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Simple MMR: greedily select docs that maximize lambda*relevance - (1-lambda)*max_similarity_to_selected
|
||||
selected: List[Tuple[Any, float]] = []
|
||||
remaining = list(candidates)
|
||||
|
||||
while len(selected) < k and remaining:
|
||||
best_score = -float("inf")
|
||||
best_idx = 0
|
||||
for i, (doc, rel_score) in enumerate(remaining):
|
||||
# Penalize similarity to already selected docs
|
||||
penalty = 0.0
|
||||
for sel_doc, _ in selected:
|
||||
penalty = max(penalty, _doc_similarity(doc, sel_doc))
|
||||
mmr_score = lambda_mult * rel_score - (1 - lambda_mult) * penalty
|
||||
if mmr_score > best_score:
|
||||
best_score = mmr_score
|
||||
best_idx = i
|
||||
selected.append(remaining.pop(best_idx))
|
||||
|
||||
return [doc for doc, _ in selected]
|
||||
|
||||
async def delete(self, ids: Optional[List[str]] = None) -> None:
|
||||
await self._ensure_table()
|
||||
if ids:
|
||||
# Build parameterized IN clause: $1, $2, ...
|
||||
placeholders = [f"${j + 1}" for j in range(len(ids))]
|
||||
params = [self._wire(i) for i in ids]
|
||||
await self.client.query_params(
|
||||
f"DELETE FROM {self.table} WHERE id IN ({', '.join(placeholders)})",
|
||||
params,
|
||||
)
|
||||
|
||||
async def set_tenant(self, tenant_id: str) -> None:
|
||||
self.tenant_id = tenant_id
|
||||
await self.client.query_params(
|
||||
"SET app.tenant_id = $1", [self._wire(tenant_id)]
|
||||
)
|
||||
|
||||
|
||||
class _SimpleDocument:
|
||||
def __init__(self, page_content: str, metadata: dict):
|
||||
self.page_content = page_content
|
||||
self.metadata = metadata
|
||||
|
||||
def __repr__(self):
|
||||
return f"Document(content={self.page_content[:50]}..., metadata={self.metadata})"
|
||||
|
||||
|
||||
def _doc_similarity(a: _SimpleDocument, b: _SimpleDocument) -> float:
|
||||
"""Simple Jaccard similarity on text tokens."""
|
||||
tokens_a = set(a.page_content.lower().split())
|
||||
tokens_b = set(b.page_content.lower().split())
|
||||
if not tokens_a or not tokens_b:
|
||||
return 0.0
|
||||
intersection = tokens_a & tokens_b
|
||||
union = tokens_a | tokens_b
|
||||
return len(intersection) / len(union)
|
||||
@@ -6,35 +6,36 @@ This script demonstrates common operations with the BaraDB Python client.
|
||||
Run it while a BaraDB server is listening on localhost:9472.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from baradb import Client, QueryBuilder, WireValue
|
||||
|
||||
|
||||
def example_connection():
|
||||
async def example_connection():
|
||||
print("=== Connection ===")
|
||||
client = Client("localhost", 9472)
|
||||
client.connect()
|
||||
await client.connect()
|
||||
print(f"Connected: {client.is_connected()}")
|
||||
print(f"Ping: {client.ping()}")
|
||||
client.close()
|
||||
print(f"Ping: {await client.ping()}")
|
||||
await client.close()
|
||||
print(f"Connected after close: {client.is_connected()}")
|
||||
print()
|
||||
|
||||
|
||||
def example_context_manager():
|
||||
async def example_context_manager():
|
||||
print("=== Context Manager ===")
|
||||
with Client("localhost", 9472) as client:
|
||||
async with Client("localhost", 9472) as client:
|
||||
print(f"Inside context: {client.is_connected()}")
|
||||
print(f"Ping: {client.ping()}")
|
||||
print(f"Ping: {await client.ping()}")
|
||||
print("Outside context: closed automatically")
|
||||
print()
|
||||
|
||||
|
||||
def example_simple_query():
|
||||
async def example_simple_query():
|
||||
print("=== Simple Query ===")
|
||||
with Client("localhost", 9472) as client:
|
||||
result = client.query("SELECT 42 as answer, 'BaraDB' as db")
|
||||
async with Client("localhost", 9472) as client:
|
||||
result = await client.query("SELECT 42 as answer, 'BaraDB' as db")
|
||||
print(f"Columns: {result.columns}")
|
||||
print(f"Rows: {result.rows}")
|
||||
print(f"Row count: {result.row_count}")
|
||||
@@ -43,15 +44,15 @@ def example_simple_query():
|
||||
print()
|
||||
|
||||
|
||||
def example_parameterized_query():
|
||||
async def example_parameterized_query():
|
||||
print("=== Parameterized Query ===")
|
||||
with Client("localhost", 9472) as client:
|
||||
result = client.query_params(
|
||||
async with Client("localhost", 9472) as client:
|
||||
result = await client.query_params(
|
||||
"SELECT $1 as num, $2 as txt, $3 as flag",
|
||||
[
|
||||
WireValue.int64(123),
|
||||
WireValue.string("hello world"),
|
||||
WireValue.bool_val(True),
|
||||
WireValue.bool(True),
|
||||
],
|
||||
)
|
||||
for row in result:
|
||||
@@ -59,9 +60,9 @@ def example_parameterized_query():
|
||||
print()
|
||||
|
||||
|
||||
def example_query_builder():
|
||||
async def example_query_builder():
|
||||
print("=== Query Builder ===")
|
||||
with Client("localhost", 9472) as client:
|
||||
async with Client("localhost", 9472) as client:
|
||||
sql = (
|
||||
QueryBuilder(client)
|
||||
.select("id", "name")
|
||||
@@ -75,10 +76,10 @@ def example_query_builder():
|
||||
print()
|
||||
|
||||
|
||||
def example_vector():
|
||||
async def example_vector():
|
||||
print("=== Vector Value ===")
|
||||
with Client("localhost", 9472) as client:
|
||||
result = client.query_params(
|
||||
async with Client("localhost", 9472) as client:
|
||||
result = await client.query_params(
|
||||
"SELECT $1 as embedding",
|
||||
[WireValue.vector([0.1, 0.2, 0.3, 0.4])],
|
||||
)
|
||||
@@ -87,34 +88,34 @@ def example_vector():
|
||||
print()
|
||||
|
||||
|
||||
def example_ddl_dml():
|
||||
async def example_ddl_dml():
|
||||
print("=== DDL & DML ===")
|
||||
with Client("localhost", 9472) as client:
|
||||
async with Client("localhost", 9472) as client:
|
||||
# Clean up
|
||||
try:
|
||||
client.execute("DROP TABLE IF EXISTS demo_products")
|
||||
await client.execute("DROP TABLE IF EXISTS demo_products")
|
||||
except Exception as exc:
|
||||
print(f"Cleanup warning: {exc}")
|
||||
|
||||
client.execute(
|
||||
await client.execute(
|
||||
"CREATE TABLE demo_products (id INT PRIMARY KEY, name STRING, price FLOAT)"
|
||||
)
|
||||
affected = client.execute(
|
||||
affected = await client.execute(
|
||||
"INSERT INTO demo_products (id, name, price) VALUES (1, 'Widget', 9.99)"
|
||||
)
|
||||
print(f"Insert affected rows: {affected}")
|
||||
|
||||
result = client.query("SELECT * FROM demo_products")
|
||||
result = await client.query("SELECT * FROM demo_products")
|
||||
print(f"Select returned {result.row_count} row(s)")
|
||||
for row in result:
|
||||
print(f" {row}")
|
||||
|
||||
client.execute("DROP TABLE demo_products")
|
||||
await client.execute("DROP TABLE demo_products")
|
||||
print("Table dropped")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
async def main():
|
||||
print("BaraDB Python Client Examples")
|
||||
print("Make sure BaraDB is running on localhost:9472")
|
||||
print()
|
||||
@@ -131,10 +132,10 @@ def main():
|
||||
|
||||
for fn in examples:
|
||||
try:
|
||||
fn()
|
||||
await fn()
|
||||
except Exception as exc:
|
||||
print(f"ERROR in {fn.__name__}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "baradb"
|
||||
version = "1.1.0"
|
||||
version = "1.1.6"
|
||||
description = "Official Python client for BaraDB — Multimodal Database Engine"
|
||||
readme = "README.md"
|
||||
license = { text = "Apache-2.0" }
|
||||
@@ -48,6 +48,7 @@ packages = ["baradb"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
|
||||
@@ -5,13 +5,16 @@ Requires a running BaraDB server on localhost:9472.
|
||||
These tests are skipped automatically if the server is unreachable.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from baradb import Client, WireValue, QueryBuilder
|
||||
|
||||
|
||||
BARADB_HOST = "localhost"
|
||||
BARADB_PORT = 9472
|
||||
BARADB_HOST = os.environ.get("BARADB_HOST", "localhost")
|
||||
BARADB_PORT = int(os.environ.get("BARADB_PORT", "9472"))
|
||||
|
||||
|
||||
def _server_available() -> bool:
|
||||
@@ -29,41 +32,41 @@ pytestmark = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
c = Client(BARADB_HOST, BARADB_PORT)
|
||||
c.connect()
|
||||
await c.connect()
|
||||
yield c
|
||||
c.close()
|
||||
await c.close()
|
||||
|
||||
|
||||
class TestConnection:
|
||||
def test_connect_and_close(self):
|
||||
async def test_connect_and_close(self):
|
||||
c = Client(BARADB_HOST, BARADB_PORT)
|
||||
assert not c.is_connected()
|
||||
c.connect()
|
||||
await c.connect()
|
||||
assert c.is_connected()
|
||||
c.close()
|
||||
await c.close()
|
||||
assert not c.is_connected()
|
||||
|
||||
def test_context_manager(self):
|
||||
with Client(BARADB_HOST, BARADB_PORT) as c:
|
||||
async def test_context_manager(self):
|
||||
async with Client(BARADB_HOST, BARADB_PORT) as c:
|
||||
assert c.is_connected()
|
||||
assert not c.is_connected()
|
||||
|
||||
|
||||
class TestPing:
|
||||
def test_ping(self, client):
|
||||
assert client.ping() is True
|
||||
async def test_ping(self, client):
|
||||
assert await client.ping() is True
|
||||
|
||||
|
||||
class TestQuery:
|
||||
def test_simple_select(self, client):
|
||||
result = client.query("SELECT 1 as one")
|
||||
async def test_simple_select(self, client):
|
||||
result = await client.query("SELECT 1 as one")
|
||||
assert result.row_count >= 0 # server may return rows or empty
|
||||
|
||||
def test_query_with_params(self, client):
|
||||
result = client.query_params(
|
||||
async def test_query_with_params(self, client):
|
||||
result = await client.query_params(
|
||||
"SELECT $1 as num, $2 as txt",
|
||||
[WireValue.int64(42), WireValue.string("hello")],
|
||||
)
|
||||
@@ -71,22 +74,22 @@ class TestQuery:
|
||||
|
||||
|
||||
class TestExecute:
|
||||
def test_create_table_and_insert(self, client):
|
||||
async def test_create_table_and_insert(self, client):
|
||||
# Clean up first (best-effort)
|
||||
try:
|
||||
client.execute("DROP TABLE IF EXISTS test_users")
|
||||
await client.execute("DROP TABLE IF EXISTS test_users")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
client.execute(
|
||||
await client.execute(
|
||||
"CREATE TABLE test_users (id INT PRIMARY KEY, name STRING, age INT)"
|
||||
)
|
||||
affected = client.execute(
|
||||
affected = await client.execute(
|
||||
"INSERT INTO test_users (id, name, age) VALUES (1, 'Alice', 30)"
|
||||
)
|
||||
assert affected >= 0
|
||||
|
||||
result = client.query("SELECT name, age FROM test_users WHERE id = 1")
|
||||
result = await client.query("SELECT name, age FROM test_users WHERE id = 1")
|
||||
assert result.row_count == 1
|
||||
row = result.rows[0]
|
||||
# Server returns all columns; map by name via dict iteration
|
||||
@@ -94,20 +97,20 @@ class TestExecute:
|
||||
assert row_dict["name"] == "Alice"
|
||||
assert row_dict["age"] == 30
|
||||
|
||||
client.execute("DROP TABLE test_users")
|
||||
await client.execute("DROP TABLE test_users")
|
||||
|
||||
|
||||
class TestQueryBuilder:
|
||||
def test_builder_exec(self, client):
|
||||
async def test_builder_exec(self, client):
|
||||
try:
|
||||
client.execute("DROP TABLE IF EXISTS test_products")
|
||||
await client.execute("DROP TABLE IF EXISTS test_products")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
client.execute(
|
||||
await client.execute(
|
||||
"CREATE TABLE test_products (id INT PRIMARY KEY, name STRING, price FLOAT)"
|
||||
)
|
||||
client.execute(
|
||||
await client.execute(
|
||||
"INSERT INTO test_products (id, name, price) VALUES (1, 'Widget', 9.99)"
|
||||
)
|
||||
|
||||
@@ -117,36 +120,36 @@ class TestQueryBuilder:
|
||||
.from_("test_products")
|
||||
.where("id = 1")
|
||||
)
|
||||
result = qb.exec()
|
||||
result = await qb.exec()
|
||||
assert result.row_count == 1
|
||||
|
||||
client.execute("DROP TABLE test_products")
|
||||
await client.execute("DROP TABLE test_products")
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_auth_with_dummy_token(self, client):
|
||||
async def test_auth_with_dummy_token(self, client):
|
||||
# Server uses default JWT secret in dev mode, any token format is accepted
|
||||
# depending on server config. We just verify the method does not crash.
|
||||
try:
|
||||
client.auth("dummy-token-for-testing")
|
||||
await client.auth("dummy-token-for-testing")
|
||||
except Exception as exc:
|
||||
# Auth may fail with invalid token — that's acceptable for this test
|
||||
assert "Auth" in str(exc) or "error" in str(exc).lower()
|
||||
|
||||
|
||||
class TestTransactions:
|
||||
def test_transaction_begin_commit(self, client):
|
||||
async def test_transaction_begin_commit(self, client):
|
||||
try:
|
||||
client.execute("DROP TABLE IF EXISTS test_txn")
|
||||
await client.execute("DROP TABLE IF EXISTS test_txn")
|
||||
except Exception:
|
||||
pass
|
||||
client.execute("CREATE TABLE test_txn (id INT PRIMARY KEY)")
|
||||
await client.execute("CREATE TABLE test_txn (id INT PRIMARY KEY)")
|
||||
|
||||
client.execute("BEGIN")
|
||||
client.execute("INSERT INTO test_txn (id) VALUES (1)")
|
||||
client.execute("COMMIT")
|
||||
await client.execute("BEGIN")
|
||||
await client.execute("INSERT INTO test_txn (id) VALUES (1)")
|
||||
await client.execute("COMMIT")
|
||||
|
||||
result = client.query("SELECT COUNT(*) FROM test_txn")
|
||||
result = await client.query("SELECT COUNT(*) FROM test_txn")
|
||||
assert result.row_count >= 0
|
||||
|
||||
client.execute("DROP TABLE test_txn")
|
||||
await client.execute("DROP TABLE test_txn")
|
||||
|
||||
@@ -18,11 +18,11 @@ class TestWireValue:
|
||||
assert data == b"\x00"
|
||||
|
||||
def test_bool_true(self):
|
||||
wv = WireValue.bool_val(True)
|
||||
wv = WireValue.bool(True)
|
||||
assert wv.serialize() == b"\x01\x01"
|
||||
|
||||
def test_bool_false(self):
|
||||
wv = WireValue.bool_val(False)
|
||||
wv = WireValue.bool(False)
|
||||
assert wv.serialize() == b"\x01\x00"
|
||||
|
||||
def test_int8(self):
|
||||
@@ -70,7 +70,7 @@ class TestWireValue:
|
||||
assert data[5:] == b"hello"
|
||||
|
||||
def test_bytes(self):
|
||||
wv = WireValue.bytes_val(b"\xde\xad\xbe\xef")
|
||||
wv = WireValue.bytes(b"\xde\xad\xbe\xef")
|
||||
data = wv.serialize()
|
||||
assert data[0] == FieldKind.BYTES
|
||||
length = struct.unpack(">I", data[1:5])[0]
|
||||
@@ -87,7 +87,7 @@ class TestWireValue:
|
||||
assert floats == [1.0, 2.0, 3.0]
|
||||
|
||||
def test_json(self):
|
||||
wv = WireValue.json_val('{"key": "value"}')
|
||||
wv = WireValue.json('{"key": "value"}')
|
||||
data = wv.serialize()
|
||||
assert data[0] == FieldKind.JSON
|
||||
length = struct.unpack(">I", data[1:5])[0]
|
||||
@@ -95,7 +95,7 @@ class TestWireValue:
|
||||
|
||||
def test_array(self):
|
||||
inner = [WireValue.string("a"), WireValue.string("b")]
|
||||
wv = WireValue.array_val(inner)
|
||||
wv = WireValue.array(inner)
|
||||
data = wv.serialize()
|
||||
assert data[0] == FieldKind.ARRAY
|
||||
count = struct.unpack(">I", data[1:5])[0]
|
||||
@@ -103,7 +103,7 @@ class TestWireValue:
|
||||
|
||||
def test_object(self):
|
||||
inner = {"name": WireValue.string("Bara"), "age": WireValue.int32(42)}
|
||||
wv = WireValue.object_val(inner)
|
||||
wv = WireValue.object(inner)
|
||||
data = wv.serialize()
|
||||
assert data[0] == FieldKind.OBJECT
|
||||
count = struct.unpack(">I", data[1:5])[0]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "baradb"
|
||||
version = "1.1.0"
|
||||
version = "1.1.6"
|
||||
edition = "2021"
|
||||
authors = ["BaraDB Team <team@baradb.dev>"]
|
||||
description = "Official Rust client for BaraDB — binary protocol client"
|
||||
description = "Official async Rust client for BaraDB — binary protocol client"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/barabadb/baradadb"
|
||||
repository = "https://github.com/barabadb/baradadb.git"
|
||||
documentation = "https://docs.baradb.dev"
|
||||
readme = "README.md"
|
||||
keywords = ["database", "baradb", "multimodal", "client", "wire-protocol"]
|
||||
@@ -13,8 +13,10 @@ categories = ["database", "network-programming"]
|
||||
rust-version = "1.70"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
|
||||
[[example]]
|
||||
name = "basic"
|
||||
|
||||
+41
-35
@@ -1,13 +1,13 @@
|
||||
# BaraDB Rust Client
|
||||
# BaraDB Async Rust Client
|
||||
|
||||
Official Rust client for **BaraDB** — a multimodal database engine written in Nim.
|
||||
Official async Rust client for **BaraDB** — a multimodal database engine written in Nim.
|
||||
|
||||
## Features
|
||||
|
||||
- **Binary wire protocol** — fast TCP communication using only `std`
|
||||
- **Zero dependencies** — no external crates required
|
||||
- **Sync API** — blocking I/O suitable for most applications
|
||||
- **Async/await** — fully non-blocking with Tokio runtime
|
||||
- **Binary wire protocol** — fast TCP communication
|
||||
- **Query builder** — fluent SQL construction
|
||||
- **Parameterized queries** — safe from SQL injection
|
||||
- **Vector & JSON support** — first-class multimodal types
|
||||
|
||||
## Installation
|
||||
@@ -16,13 +16,14 @@ Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
baradb = "1.0"
|
||||
baradb = "1.1"
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
```
|
||||
|
||||
Or from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/barabadb/baradadb.git
|
||||
git clone https://codeberg.org/baraba/baradb
|
||||
cd clients/rust
|
||||
cargo build
|
||||
```
|
||||
@@ -32,13 +33,14 @@ cargo build
|
||||
```rust
|
||||
use baradb::Client;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut client = Client::connect("localhost", 9472)?;
|
||||
let result = client.query("SELECT name, age FROM users WHERE age > 18")?;
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut client = Client::connect("localhost", 9472).await?;
|
||||
let result = client.query("SELECT name, age FROM users WHERE age > 18").await?;
|
||||
for row in result.rows() {
|
||||
println!("{:?}", row);
|
||||
}
|
||||
client.close();
|
||||
client.close().await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
@@ -48,16 +50,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
```rust
|
||||
use baradb::{Client, WireValue};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut client = Client::connect("localhost", 9472)?;
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut client = Client::connect("localhost", 9472).await?;
|
||||
let result = client.query_params(
|
||||
"SELECT * FROM users WHERE age > $1 AND country = $2",
|
||||
&[WireValue::Int64(18), WireValue::String("BG".to_string())],
|
||||
)?;
|
||||
).await?;
|
||||
for row in result.rows() {
|
||||
println!("{:?}", row);
|
||||
}
|
||||
client.close();
|
||||
client.close().await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
@@ -65,21 +68,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
### Query Builder
|
||||
|
||||
```rust
|
||||
use baradb::Client;
|
||||
use baradb::{Client, QueryBuilder};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut client = Client::connect("localhost", 9472)?;
|
||||
let result = baradb::QueryBuilder::new(&mut client)
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut client = Client::connect("localhost", 9472).await?;
|
||||
let result = QueryBuilder::new(&mut client)
|
||||
.select(&["name", "email"])
|
||||
.from("users")
|
||||
.where_clause("active = true")
|
||||
.order_by("name", "ASC")
|
||||
.limit(10)
|
||||
.exec()?;
|
||||
.exec()
|
||||
.await?;
|
||||
for row in result.rows() {
|
||||
println!("{:?}", row);
|
||||
}
|
||||
client.close();
|
||||
client.close().await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
@@ -89,13 +94,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
```rust
|
||||
use baradb::{Client, WireValue};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut client = Client::connect("localhost", 9472)?;
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut client = Client::connect("localhost", 9472).await?;
|
||||
let result = client.query_params(
|
||||
"SELECT id, name FROM products ORDER BY embedding <-> $1 LIMIT 5",
|
||||
&[WireValue::Vector(vec![0.1, 0.2, 0.3])],
|
||||
)?;
|
||||
client.close();
|
||||
).await?;
|
||||
client.close().await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
@@ -112,7 +118,7 @@ Integration tests (requires server on `localhost:9472`):
|
||||
|
||||
```bash
|
||||
# Start server
|
||||
docker run -d -p 9472:9472 baradb:latest
|
||||
docker run -d -p 9472:9472 barabadb:latest
|
||||
|
||||
# Run all tests
|
||||
cargo test
|
||||
@@ -120,18 +126,18 @@ cargo test
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Client::connect(host, port)`
|
||||
### `Client::connect(host, port) -> Result<Client>`
|
||||
|
||||
Creates a new client connected to the given host and port.
|
||||
Creates a new async client connected to the given host and port.
|
||||
|
||||
### Methods
|
||||
### Methods (all async)
|
||||
|
||||
- `query(sql) -> Result<QueryResult>` — execute SELECT-like query
|
||||
- `query_params(sql, params) -> Result<QueryResult>` — parameterized query
|
||||
- `execute(sql) -> Result<usize>` — execute DDL/DML, returns affected rows
|
||||
- `auth(token) -> Result<()>` — JWT authentication
|
||||
- `ping() -> Result<bool>` — health check
|
||||
- `close()` — close connection
|
||||
- `await client.query(sql) -> Result<QueryResult>` — execute SELECT-like query
|
||||
- `await client.query_params(sql, params) -> Result<QueryResult>` — parameterized query
|
||||
- `await client.execute(sql) -> Result<usize>` — execute DDL/DML, returns affected rows
|
||||
- `await client.auth(token) -> Result<()>` — JWT authentication
|
||||
- `await client.ping() -> Result<bool>` — health check
|
||||
- `await client.close()` — close connection
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -3,34 +3,65 @@
|
||||
|
||||
use baradb::{Client, QueryBuilder, WireValue};
|
||||
|
||||
fn example_connection() -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("BaraDB Rust Client Examples");
|
||||
println!("Make sure BaraDB is running on localhost:9472");
|
||||
println!();
|
||||
|
||||
if let Err(e) = example_connection().await {
|
||||
eprintln!("ERROR: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = example_simple_query().await {
|
||||
eprintln!("ERROR: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = example_parameterized_query().await {
|
||||
eprintln!("ERROR: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = example_query_builder().await {
|
||||
eprintln!("ERROR: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = example_vector().await {
|
||||
eprintln!("ERROR: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = example_ddl_dml().await {
|
||||
eprintln!("ERROR: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn example_connection() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("=== Connection ===");
|
||||
let mut client = Client::connect("127.0.0.1", 9472)?;
|
||||
let mut client = Client::connect("127.0.0.1", 9472).await?;
|
||||
println!("Connected: {}", client.is_connected());
|
||||
println!("Ping: {}", client.ping()?);
|
||||
client.close();
|
||||
println!("Ping: {}", client.ping().await?);
|
||||
client.close().await;
|
||||
println!("Connected after close: {}", client.is_connected());
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn example_simple_query() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn example_simple_query() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("=== Simple Query ===");
|
||||
let mut client = Client::connect("127.0.0.1", 9472)?;
|
||||
let result = client.query("SELECT 42 as answer, 'BaraDB' as db")?;
|
||||
let mut client = Client::connect("127.0.0.1", 9472).await?;
|
||||
let result = client.query("SELECT 42 as answer, 'BaraDB' as db").await?;
|
||||
println!("Columns: {:?}", result.columns());
|
||||
println!("Row count: {}", result.row_count());
|
||||
for row in result.rows() {
|
||||
println!(" {:?}", row);
|
||||
}
|
||||
client.close();
|
||||
client.close().await;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn example_parameterized_query() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn example_parameterized_query() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("=== Parameterized Query ===");
|
||||
let mut client = Client::connect("127.0.0.1", 9472)?;
|
||||
let mut client = Client::connect("127.0.0.1", 9472).await?;
|
||||
let result = client.query_params(
|
||||
"SELECT $1 as num, $2 as txt, $3 as flag",
|
||||
&[
|
||||
@@ -38,18 +69,18 @@ fn example_parameterized_query() -> Result<(), Box<dyn std::error::Error>> {
|
||||
WireValue::String("hello world".to_string()),
|
||||
WireValue::Bool(true),
|
||||
],
|
||||
)?;
|
||||
).await?;
|
||||
for row in result.rows() {
|
||||
println!(" {:?}", row);
|
||||
}
|
||||
client.close();
|
||||
client.close().await;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn example_query_builder() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn example_query_builder() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("=== Query Builder ===");
|
||||
let mut client = Client::connect("127.0.0.1", 9472)?;
|
||||
let mut client = Client::connect("127.0.0.1", 9472).await?;
|
||||
let sql = QueryBuilder::new(&mut client)
|
||||
.select(&["id", "name"])
|
||||
.from("users")
|
||||
@@ -58,70 +89,49 @@ fn example_query_builder() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.limit(5)
|
||||
.build();
|
||||
println!("Generated SQL: {}", sql);
|
||||
client.close();
|
||||
client.close().await;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn example_vector() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn example_vector() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("=== Vector Value ===");
|
||||
let mut client = Client::connect("127.0.0.1", 9472)?;
|
||||
let mut client = Client::connect("127.0.0.1", 9472).await?;
|
||||
let result = client.query_params(
|
||||
"SELECT $1 as embedding",
|
||||
&[WireValue::Vector(vec![0.1, 0.2, 0.3, 0.4])],
|
||||
)?;
|
||||
).await?;
|
||||
for row in result.rows() {
|
||||
println!(" {:?}", row);
|
||||
}
|
||||
client.close();
|
||||
client.close().await;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn example_ddl_dml() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn example_ddl_dml() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("=== DDL & DML ===");
|
||||
let mut client = Client::connect("127.0.0.1", 9472)?;
|
||||
let mut client = Client::connect("127.0.0.1", 9472).await?;
|
||||
|
||||
let _ = client.execute("DROP TABLE IF EXISTS demo_products");
|
||||
let _ = client.execute("DROP TABLE IF EXISTS demo_products").await;
|
||||
|
||||
client.execute(
|
||||
"CREATE TABLE demo_products (id INT PRIMARY KEY, name STRING, price FLOAT)",
|
||||
)?;
|
||||
).await?;
|
||||
let affected = client.execute(
|
||||
"INSERT INTO demo_products (id, name, price) VALUES (1, 'Widget', 9.99)",
|
||||
)?;
|
||||
).await?;
|
||||
println!("Insert affected rows: {}", affected);
|
||||
|
||||
let result = client.query("SELECT * FROM demo_products")?;
|
||||
let result = client.query("SELECT * FROM demo_products").await?;
|
||||
println!("Select returned {} row(s)", result.row_count());
|
||||
for row in result.rows() {
|
||||
println!(" {:?}", row);
|
||||
}
|
||||
|
||||
client.execute("DROP TABLE demo_products")?;
|
||||
client.execute("DROP TABLE demo_products").await?;
|
||||
println!("Table dropped");
|
||||
client.close();
|
||||
client.close().await;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("BaraDB Rust Client Examples");
|
||||
println!("Make sure BaraDB is running on localhost:9472");
|
||||
println!();
|
||||
|
||||
let examples: Vec<fn() -> Result<(), Box<dyn std::error::Error>>> = vec![
|
||||
example_connection,
|
||||
example_simple_query,
|
||||
example_parameterized_query,
|
||||
example_query_builder,
|
||||
example_vector,
|
||||
example_ddl_dml,
|
||||
];
|
||||
|
||||
for example in examples {
|
||||
if let Err(e) = example() {
|
||||
eprintln!("ERROR: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user