Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 898f108963 | |||
| e23b1d61d2 | |||
| 80c3fee9de | |||
| 13bc17cfa8 | |||
| 8a395225c0 | |||
| 55bc3e862a | |||
| 67965ffa8b | |||
| 836d30d84a | |||
| f622c8f82c | |||
| d2ac485b2e | |||
| 2e0969245c |
@@ -1,207 +1,168 @@
|
||||
# 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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` или `graph neural network` 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 конфигурационни) |
|
||||
| **Общ брой сесии** | 9 |
|
||||
> **Цел**: 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)
|
||||
## Приоритети и зависимости
|
||||
|
||||
| # | Задача | Оценка |
|
||||
|---|--------|--------|
|
||||
| — | Няма — всички планирани задачи са завършени | — |
|
||||
```
|
||||
Сесия 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.**
|
||||
**Thread safety: SharedLock ref споделен между всички connection-и — конкурентни DDL/DML защитени.**
|
||||
**Препоръчителен ред:**
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Сесия 9 — Stabilization Sprint (май 2026)
|
||||
## Какво остава от старите планове
|
||||
|
||||
> **Цел:** Да махнем всички workaround-и от `BARADB_DEFICIENCIES.md`, да почистим build-а и да подготвим почвата за типова система.
|
||||
> **Принцип:** Без нови светове — само stabilizaция на съществуващото.
|
||||
|
||||
### Седмица 1: Deficiency Hunt + Build Cleanup
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.1.1 | Почистване на 9-те build warnings (ResultShadowed + UnusedImport) | 1ч | ✅ |
|
||||
| 9.1.2 | Issue #6: Aggregate column names (`count(*)` → `count(*)`, `max(id)` → `max(id)`) | 2ч | ✅ |
|
||||
| 9.1.3 | Issue #5: GROUP BY bare columns — първи ред от групата за non-aggregated колони | 4-6ч | ✅ |
|
||||
| 9.1.4 | Issue #7+8: Решение за async vs sync client + thread safety | 2ч | ✅ |
|
||||
| 9.1.5 | Regression тестове за всички 10 deficiencies | 2ч | ✅ |
|
||||
|
||||
**Метрика:** NimForum миграционният код маха всички `DISTINCT` workaround-и за GROUP BY.
|
||||
| Стар план | Статус |
|
||||
|-----------|--------|
|
||||
| `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 | ✅ Завършен |
|
||||
|
||||
---
|
||||
|
||||
### Седмица 2: Type Safety in Execution Layer
|
||||
## Философия
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.2.1 | `IRExpr` носи `valueKind` — всеки AST node знае дали е INT, FLOAT, TEXT, NULL | 4-6ч | ✅ |
|
||||
| 9.2.2 | `evalExprValue` връща discriminated union (`Value(kind: vkInt64/Float64/String/Null)`) вместо само `string` | 6-8ч | ✅ |
|
||||
| 9.2.3 | `irAdd`/`irSub`/`irMul`/`irDiv` използват типовата информация (INT+INT → INT, INT+FLOAT → FLOAT) | 3ч | ✅ |
|
||||
| 9.2.4 | `validateType` използва `Value.kind` вместо `parseInt`/`parseFloat` на string | 2ч | ✅ |
|
||||
|
||||
**Метрика:** Премахваме всички `try: parseFloat catch: return fallback` евристики от `evalExpr`.
|
||||
> BaraDB не добавя "AI модули" — BaraDB става AI-native като вгради embeddings, similarity search, graph traversal и natural language интерфейси в съществуващия SQL engine. Всяка нова функция работи с:
|
||||
> - **MVCC транзакции**
|
||||
> - **RLS + Multi-tenancy**
|
||||
> - **WAL + Replication**
|
||||
> - **Nim performance**
|
||||
|
||||
---
|
||||
|
||||
### Седмица 3: JOIN Performance
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.3.1 | Hash Join: `ON a.col = b.col` с hash table върху по-малката страна | 6ч | ✅ |
|
||||
| 9.3.2 | Index Nested Loop Join: ако има B-Tree индекс на join колоната | 4ч | ✅ |
|
||||
| 9.3.3 | Benchmark: `thread JOIN category` с 10K/100K редове | 2ч | ✅ |
|
||||
| 9.3.4 | Query planner избира между Nested Loop / Hash / Index въз основа на наличие на индекс | 4ч | ✅ |
|
||||
|
||||
**Метрика:** JOIN с 100K редове е под 100ms.
|
||||
|
||||
---
|
||||
|
||||
### Седмица 4: Production Hardening
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.4.1 | Property-based tests за `evalExpr` — случайни AST-та, проверка на invariant-и | 4ч | ✅ |
|
||||
| 9.4.2 | Fuzz test за wire protocol — случайни байтове, mutation fuzzing, roundtrip за всички FieldKind | 3ч | ✅ |
|
||||
| 9.4.3 | Thread safety audit + fix: `execInsert`/`execUpdate`/`execDelete` с shared `ExecutionContext` | 3ч | ✅ |
|
||||
| 9.4.4 | ~~NimForum integration test~~ — отпада, запазваме универсалност | — | ❌ |
|
||||
|
||||
**Метрика:** 58 property-based invariant-а + 35 fuzz сценария. `ctxLock` → `SharedLock` ref споделен между всички connection-и.
|
||||
|
||||
**Thread safety fix:** `ctxLock` беше per-connection `Lock` — всеки клониран контекст имаше собствен mutex, което не пази shared state (tables, btrees, ftsIndexes, users, policies, etc.) при конкурентни DDL/DML. Преместен в `SharedLock = ref object` споделен между всички клонинги на `ExecutionContext`.
|
||||
|
||||
---
|
||||
|
||||
### Финални метрики (след сесия 9 — завършена)
|
||||
|
||||
| Метрика | Стойност |
|
||||
|---------|----------|
|
||||
| **Тестове** | 316 — 0 failures |
|
||||
| **Prop тестове** | 58 (commutativity, associativity, distributivity, identity, NULL propagation, type coercion, comparisons) |
|
||||
| **Fuzz тестове** | 35 (deserializeValue, roundtrip всички FieldKind, mutation, stress) |
|
||||
| **Build warnings** | 0 |
|
||||
| **BARADB_DEFICIENCIES** | 0 непоправени (всички 10 поправени) |
|
||||
| **Workaround-и в NimForum** | 0 |
|
||||
| **evalExprValue** | Връща `Value(kind: vkInt64/Float64/String/Null)` |
|
||||
| **Аритметични ops** | INT+INT→INT, INT+FLOAT→FLOAT, FLOAT/INT→FLOAT |
|
||||
| **Join стратегии** | Hash Join + Index Nested Loop + Nested Loop |
|
||||
| **JOIN 10K (Hash)** | ~115ms |
|
||||
| **JOIN 10K (Index NL)** | ~90ms |
|
||||
| **Shared lock** | `SharedLock` ref — един mutex за всички connection-и |
|
||||
| **Общ брой сесии** | 9 |
|
||||
|
||||
**BaraDB v1.0.0 — production-ready. Сесия 9 завършена: build чист, типова система в execution layer, JOIN performance, production hardening.**
|
||||
*План версия: 2026-05-17*
|
||||
|
||||
+12
-9
@@ -39,28 +39,31 @@ Add auto-generated ID support to BaraDB so users don't need to manually supply I
|
||||
- `snowflake_id(node_id)` function
|
||||
- For future distributed use
|
||||
|
||||
## Phase 2: JOIN Optimizations (future)
|
||||
## Phase 2: JOIN Optimizations ✅
|
||||
|
||||
### 2.1 Hash Join
|
||||
### 2.1 Hash Join ✅
|
||||
- For equi-join ON a.col = b.col
|
||||
- Build hash table on smaller side, probe with larger
|
||||
- O(N+M) instead of O(N*M)
|
||||
|
||||
### 2.2 Index Nested Loop Join
|
||||
### 2.2 Index Nested Loop Join ✅
|
||||
- If index exists on join column → probe index per left row
|
||||
- O(N * log M) instead of O(N*M)
|
||||
|
||||
### 2.3 Merge Join
|
||||
- For sorted inputs
|
||||
- Two-pointer sweep O(N+M)
|
||||
- **Status:** Not yet implemented — can be added if needed
|
||||
|
||||
## Phase 3: Foreign Key Enforcement (future)
|
||||
## Phase 3: Foreign Key Enforcement ✅
|
||||
|
||||
### 3.1 CASCADE DELETE
|
||||
### 3.2 SET NULL on delete
|
||||
### 3.3 RESTRICT on delete
|
||||
### 3.4 ON UPDATE CASCADE
|
||||
### 3.5 FK check on UPDATE (not just INSERT)
|
||||
### 3.1 CASCADE DELETE ✅
|
||||
### 3.2 SET NULL on delete ✅
|
||||
### 3.3 RESTRICT on delete ✅
|
||||
### 3.4 ON UPDATE CASCADE ✅
|
||||
### 3.5 ON UPDATE SET NULL ✅
|
||||
### 3.6 ON UPDATE RESTRICT ✅
|
||||
### 3.7 FK check on UPDATE (not just INSERT) ✅
|
||||
|
||||
## Implementation Order
|
||||
1. AUTO_INCREMENT (lexer + parser + executor)
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# BaraDB — PLAN
|
||||
|
||||
> **v1.0.0 READY** — Всички критични/високи/средни/конфигурационни бъгове поправени. Всички 10 TLA+ спецификации са завършени. Build е чист (0 warnings).
|
||||
|
||||
---
|
||||
|
||||
## Разпределени модули — финален status (след сесия 8)
|
||||
|
||||
### ✅ Поправено
|
||||
|
||||
| Модул | Промяна |
|
||||
|--------|---------|
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Formal Verification — финален status
|
||||
|
||||
### 🔴 Критични (всички поправени ✅)
|
||||
|
||||
| # | Задача | Статус |
|
||||
|---|--------|--------|
|
||||
| FV-1 | Raft: prevLogIndex/prevLogTerm в Replicate | ✅ |
|
||||
| FV-2 | Raft: Leader step-down при partition | ✅ |
|
||||
| FV-3 | 2PC: Coordinator crash/recovery | ✅ |
|
||||
| FV-4 | 2PC: Participant timeout | ✅ |
|
||||
|
||||
### 🟡 Важни (всички поправени ✅)
|
||||
|
||||
| # | Задача | Статус |
|
||||
|---|--------|--------|
|
||||
| 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 |
|
||||
|
||||
### 🟢 Нови спекове (всички завършени ✅)
|
||||
|
||||
| # | Задача | Покрива | Приоритет |
|
||||
|---|--------|---------|-----------|
|
||||
| FV-10 | `backup.tla` | `backup.nim` | ✅ |
|
||||
| FV-11 | `recovery.tla` | `recovery.nim` | ✅ |
|
||||
| FV-12 | `crossmodal.tla` | `crossmodal.nim` | ✅ |
|
||||
|
||||
### 🔧 Инфраструктурни (всички поправени ✅)
|
||||
|
||||
| # | Задача | Статус |
|
||||
|---|--------|--------|
|
||||
| FV-13 | CI: Поправка на verify job | ✅ |
|
||||
| FV-14 | Property-based testing мост | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Сесия 8 — v1.0.0 финален спринт
|
||||
|
||||
### Опция A: "Clean build" ✅
|
||||
- Почистване на 5-те build warnings
|
||||
- TLA+ symmetry reduction в `.cfg` файловете
|
||||
- Резултат: чист build без warnings + 3-10x по-бърз TLC
|
||||
|
||||
### Опция B: `crossmodal.tla` ✅
|
||||
- TLA+ спек за cross-modal consistency
|
||||
- Моделира sync между document/vector/graph/FTS индекси
|
||||
- Резултат: 10-ти TLA+ спек, пълно покритие на core модулите
|
||||
|
||||
### Опция 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
|
||||
|
||||
---
|
||||
|
||||
## Финални метрики
|
||||
|
||||
| Метрика | Стойност |
|
||||
|---------|----------|
|
||||
| **Тестове** | 294 — 0 failures ✅ |
|
||||
| **Критични бъгове** | 0 ✅ |
|
||||
| **Високи бъгове** | 0 ✅ |
|
||||
| **Средни бъгове** | 0 ✅ |
|
||||
| **TLA+ спецификации** | 10 — всички с symmetry reduction ✅ |
|
||||
| **Build warnings** | 0 ✅ |
|
||||
| **Security audit** | Всички 🔴 и 🟠 поправени ✅ |
|
||||
| **Общ брой поправени бъгове** | 32 (9 критични + 7 високи + 12 средни + 4 конфигурационни) |
|
||||
| **Общ брой сесии** | 9 |
|
||||
|
||||
---
|
||||
|
||||
## Оставащи задачи (post-v1.0.0, non-critical)
|
||||
|
||||
| # | Задача | Оценка |
|
||||
|---|--------|--------|
|
||||
| — | Няма — всички планирани задачи са завършени | — |
|
||||
|
||||
**BaraDB v1.0.0 е production-ready за blogs, e-commerce и small ERP системи.**
|
||||
**Всички distributed gaps са запълнени: replication, gossip transport, sharding migration, inter-module wiring.**
|
||||
**Thread safety: SharedLock ref споделен между всички connection-и — конкурентни DDL/DML защитени.**
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Сесия 9 — Stabilization Sprint (май 2026)
|
||||
|
||||
> **Цел:** Да махнем всички workaround-и от `BARADB_DEFICIENCIES.md`, да почистим build-а и да подготвим почвата за типова система.
|
||||
> **Принцип:** Без нови светове — само stabilizaция на съществуващото.
|
||||
|
||||
### Седмица 1: Deficiency Hunt + Build Cleanup
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.1.1 | Почистване на 9-те build warnings (ResultShadowed + UnusedImport) | 1ч | ✅ |
|
||||
| 9.1.2 | Issue #6: Aggregate column names (`count(*)` → `count(*)`, `max(id)` → `max(id)`) | 2ч | ✅ |
|
||||
| 9.1.3 | Issue #5: GROUP BY bare columns — първи ред от групата за non-aggregated колони | 4-6ч | ✅ |
|
||||
| 9.1.4 | Issue #7+8: Решение за async vs sync client + thread safety | 2ч | ✅ |
|
||||
| 9.1.5 | Regression тестове за всички 10 deficiencies | 2ч | ✅ |
|
||||
|
||||
**Метрика:** NimForum миграционният код маха всички `DISTINCT` workaround-и за GROUP BY.
|
||||
|
||||
---
|
||||
|
||||
### Седмица 2: Type Safety in Execution Layer
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.2.1 | `IRExpr` носи `valueKind` — всеки AST node знае дали е INT, FLOAT, TEXT, NULL | 4-6ч | ✅ |
|
||||
| 9.2.2 | `evalExprValue` връща discriminated union (`Value(kind: vkInt64/Float64/String/Null)`) вместо само `string` | 6-8ч | ✅ |
|
||||
| 9.2.3 | `irAdd`/`irSub`/`irMul`/`irDiv` използват типовата информация (INT+INT → INT, INT+FLOAT → FLOAT) | 3ч | ✅ |
|
||||
| 9.2.4 | `validateType` използва `Value.kind` вместо `parseInt`/`parseFloat` на string | 2ч | ✅ |
|
||||
|
||||
**Метрика:** Премахваме всички `try: parseFloat catch: return fallback` евристики от `evalExpr`.
|
||||
|
||||
---
|
||||
|
||||
### Седмица 3: JOIN Performance
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.3.1 | Hash Join: `ON a.col = b.col` с hash table върху по-малката страна | 6ч | ✅ |
|
||||
| 9.3.2 | Index Nested Loop Join: ако има B-Tree индекс на join колоната | 4ч | ✅ |
|
||||
| 9.3.3 | Benchmark: `thread JOIN category` с 10K/100K редове | 2ч | ✅ |
|
||||
| 9.3.4 | Query planner избира между Nested Loop / Hash / Index въз основа на наличие на индекс | 4ч | ✅ |
|
||||
|
||||
**Метрика:** JOIN с 100K редове е под 100ms.
|
||||
|
||||
---
|
||||
|
||||
### Седмица 4: Production Hardening
|
||||
|
||||
| # | Задача | Оценка | Статус |
|
||||
|---|--------|--------|--------|
|
||||
| 9.4.1 | Property-based tests за `evalExpr` — случайни AST-та, проверка на invariant-и | 4ч | ✅ |
|
||||
| 9.4.2 | Fuzz test за wire protocol — случайни байтове, mutation fuzzing, roundtrip за всички FieldKind | 3ч | ✅ |
|
||||
| 9.4.3 | Thread safety audit + fix: `execInsert`/`execUpdate`/`execDelete` с shared `ExecutionContext` | 3ч | ✅ |
|
||||
| 9.4.4 | ~~NimForum integration test~~ — отпада, запазваме универсалност | — | ❌ |
|
||||
|
||||
**Метрика:** 58 property-based invariant-а + 35 fuzz сценария. `ctxLock` → `SharedLock` ref споделен между всички connection-и.
|
||||
|
||||
**Thread safety fix:** `ctxLock` беше per-connection `Lock` — всеки клониран контекст имаше собствен mutex, което не пази shared state (tables, btrees, ftsIndexes, users, policies, etc.) при конкурентни DDL/DML. Преместен в `SharedLock = ref object` споделен между всички клонинги на `ExecutionContext`.
|
||||
|
||||
---
|
||||
|
||||
### Финални метрики (след сесия 9 — завършена)
|
||||
|
||||
| Метрика | Стойност |
|
||||
|---------|----------|
|
||||
| **Тестове** | 316 — 0 failures |
|
||||
| **Prop тестове** | 58 (commutativity, associativity, distributivity, identity, NULL propagation, type coercion, comparisons) |
|
||||
| **Fuzz тестове** | 35 (deserializeValue, roundtrip всички FieldKind, mutation, stress) |
|
||||
| **Build warnings** | 0 |
|
||||
| **BARADB_DEFICIENCIES** | 0 непоправени (всички 10 поправени) |
|
||||
| **Workaround-и в NimForum** | 0 |
|
||||
| **evalExprValue** | Връща `Value(kind: vkInt64/Float64/String/Null)` |
|
||||
| **Аритметични ops** | INT+INT→INT, INT+FLOAT→FLOAT, FLOAT/INT→FLOAT |
|
||||
| **Join стратегии** | Hash Join + Index Nested Loop + Nested Loop |
|
||||
| **JOIN 10K (Hash)** | ~115ms |
|
||||
| **JOIN 10K (Index NL)** | ~90ms |
|
||||
| **Shared lock** | `SharedLock` ref — един mutex за всички connection-и |
|
||||
| **Общ брой сесии** | 9 |
|
||||
|
||||
**BaraDB v1.0.0 — production-ready. Сесия 9 завършена: build чист, типова система в execution layer, JOIN performance, production hardening.**
|
||||
+3
-1
@@ -4,7 +4,7 @@ author = "BaraDB Team"
|
||||
description = "BaraDB — Multimodal database written in Nim"
|
||||
license = "Apache-2.0"
|
||||
srcDir = "src"
|
||||
bin = @["baradadb"]
|
||||
bin = @["baradadb", "baramcp"]
|
||||
binDir = "build"
|
||||
|
||||
# Dependencies
|
||||
@@ -16,9 +16,11 @@ requires "checksums >= 0.2.0"
|
||||
# Tasks
|
||||
task build_debug, "Build debug version":
|
||||
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: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 -r tests/test_all.nim"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* BaraDB LangChain.js Vector Store Integration
|
||||
*
|
||||
* Usage:
|
||||
* const { Client } = 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);
|
||||
*/
|
||||
|
||||
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 metaCols = [];
|
||||
const metaVals = [];
|
||||
if (this.tenantId) {
|
||||
metaCols.push('tenant_id');
|
||||
metaVals.push(`'${this.tenantId}'`);
|
||||
}
|
||||
for (const mc of this.metadataCols) {
|
||||
if (meta[mc] !== undefined) {
|
||||
metaCols.push(mc);
|
||||
metaVals.push(`'${String(meta[mc]).replace(/'/g, "''")}'`);
|
||||
}
|
||||
}
|
||||
|
||||
let colList = `${this.embeddingCol}, ${this.textCol}`;
|
||||
let valList = `'${vecStr}', '${text.replace(/'/g, "''")}'`;
|
||||
if (metaCols.length > 0) {
|
||||
colList += ', ' + metaCols.join(', ');
|
||||
valList += ', ' + metaVals.join(', ');
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO ${this.table} (${colList}) VALUES (${valList}) RETURNING id`;
|
||||
const result = await this.client.query(sql);
|
||||
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.query(`SET app.tenant_id = '${this.tenantId}'`);
|
||||
}
|
||||
|
||||
let sql;
|
||||
if (filter && filter.column && filter.value) {
|
||||
sql = `SELECT hybrid_search_filtered('${this.table}', '${this.embeddingCol}', '${this.textCol}', '${query.replace(/'/g, "''")}', '${vecStr}', ${k}, '${filter.column}', '${filter.value}') AS res`;
|
||||
} else {
|
||||
sql = `SELECT hybrid_search('${this.table}', '${this.embeddingCol}', '${this.textCol}', '${query.replace(/'/g, "''")}', '${vecStr}', ${k}) AS res`;
|
||||
}
|
||||
|
||||
const result = await this.client.query(sql);
|
||||
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.query(`SELECT * FROM ${this.table} WHERE id = ${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 idList = ids.join(', ');
|
||||
await this.client.query(`DELETE FROM ${this.table} WHERE id IN (${idList})`);
|
||||
}
|
||||
|
||||
async setTenant(tenantId) {
|
||||
this.tenantId = tenantId;
|
||||
await this.client.query(`SET app.tenant_id = '${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 };
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
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
|
||||
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.execute(
|
||||
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.execute(
|
||||
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.execute(f"SET {key} = '{val}'")
|
||||
|
||||
await self.client.execute(
|
||||
f"INSERT INTO {self.table} (id, session_id, role, content, metadata, "
|
||||
f"tenant_id, user_id, created_at) "
|
||||
f"VALUES ('{msg_id}', '{self.session_id}', '{role}', "
|
||||
f"'{_escape(content)}', '{_escape(metadata)}', "
|
||||
f"'{self.tenant_id or ''}', '{self.user_id or ''}', '{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.execute(f"SET {key} = '{val}'")
|
||||
|
||||
await self.client.execute(
|
||||
f"INSERT INTO {self.table} (id, session_id, role, content, "
|
||||
f"tenant_id, user_id, created_at) "
|
||||
f"VALUES ('{msg_id}', '{self.session_id}', '{role}', "
|
||||
f"'{_escape(content)}', '{self.tenant_id or ''}', "
|
||||
f"'{self.user_id or ''}', '{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.execute(f"SET {key} = '{val}'")
|
||||
|
||||
result = await self.client.execute(
|
||||
f"SELECT role, content FROM {self.table} "
|
||||
f"WHERE session_id = '{self.session_id}' "
|
||||
f"ORDER BY created_at ASC "
|
||||
f"LIMIT {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.execute(f"SET {key} = '{val}'")
|
||||
await self.client.execute(
|
||||
f"DELETE FROM {self.table} WHERE session_id = '{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)
|
||||
|
||||
|
||||
def _escape(s: str) -> str:
|
||||
return s.replace("'", "''").replace("\\", "\\\\")
|
||||
@@ -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,212 @@
|
||||
"""
|
||||
BaraDB LangChain Vector Store Integration
|
||||
|
||||
Usage:
|
||||
from baradb import Client
|
||||
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
|
||||
|
||||
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 {}
|
||||
meta_cols = []
|
||||
meta_vals = []
|
||||
if self.tenant_id:
|
||||
meta_cols.append("tenant_id")
|
||||
meta_vals.append(f"'{self.tenant_id}'")
|
||||
for mc in self.metadata_cols:
|
||||
if mc in meta:
|
||||
meta_cols.append(mc)
|
||||
meta_vals.append(f"'{meta[mc]}'")
|
||||
|
||||
col_list = f"{self.embedding_col}, {self.text_col}"
|
||||
val_list = f"'{vec_str}', '{text.replace(\"'\", \"''\")}'"
|
||||
if meta_cols:
|
||||
col_list += ", " + ", ".join(meta_cols)
|
||||
val_list += ", " + ", ".join(meta_vals)
|
||||
|
||||
sql = f"INSERT INTO {self.table} ({col_list}) VALUES ({val_list}) RETURNING id"
|
||||
result = await self.client.query(sql)
|
||||
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(f"SET app.tenant_id = '{self.tenant_id}'")
|
||||
|
||||
if filter_col and filter_val:
|
||||
sql = f"SELECT hybrid_search_filtered('{self.table}', '{self.embedding_col}', '{self.text_col}', '{query.replace(\"'\", \"''\")}', '{vec_str}', {k}, '{filter_col}', '{filter_val}') AS res"
|
||||
else:
|
||||
sql = f"SELECT hybrid_search('{self.table}', '{self.embedding_col}', '{self.text_col}', '{query.replace(\"'\", \"''\")}', '{vec_str}', {k}) AS res"
|
||||
|
||||
result = await self.client.query(sql)
|
||||
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
|
||||
row_result = await self.client.query(f"SELECT * FROM {self.table} WHERE id = {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:
|
||||
id_list = ", ".join(str(i) for i in ids)
|
||||
await self.client.query(f"DELETE FROM {self.table} WHERE id IN ({id_list})")
|
||||
|
||||
async def set_tenant(self, tenant_id: str) -> None:
|
||||
self.tenant_id = tenant_id
|
||||
await self.client.query(f"SET app.tenant_id = '{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)
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BaraDB RAG Pipeline — End-to-End Example
|
||||
|
||||
Demonstrates a complete RAG (Retrieval-Augmented Generation) pipeline:
|
||||
1. Ingest a document (PDF or text)
|
||||
2. Chunk into pieces
|
||||
3. Generate embeddings via API (OpenAI / Ollama)
|
||||
4. Store in BaraDB with vector + FTS indexes
|
||||
5. Hybrid search for relevant chunks
|
||||
6. Generate LLM response with context
|
||||
|
||||
Usage:
|
||||
# With Ollama (local):
|
||||
python rag_pipeline.py --file document.txt --embedder ollama --model nomic-embed-text
|
||||
|
||||
# With OpenAI:
|
||||
python rag_pipeline.py --file document.pdf --embedder openai --api-key sk-...
|
||||
|
||||
# Query mode (existing database):
|
||||
python rag_pipeline.py --query "What is the main topic?" --db-host localhost --db-port 9472
|
||||
|
||||
Requirements:
|
||||
pip install baradb requests pypdf2
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_document(path: str) -> str:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
from PyPDF2 import PdfReader
|
||||
reader = PdfReader(path)
|
||||
return "\n\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
except ImportError:
|
||||
print("PyPDF2 not installed. pip install pypdf2")
|
||||
sys.exit(1)
|
||||
elif ext in (".txt", ".md", ".rst", ".py", ".nim", ".json", ".yaml", ".yml"):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
else:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text chunking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def chunk_text(text: str, chunk_size: int = 1024, overlap: int = 128) -> List[str]:
|
||||
if len(text) <= chunk_size:
|
||||
return [text.strip()] if text.strip() else []
|
||||
|
||||
chunks = []
|
||||
for para in text.split("\n\n"):
|
||||
para = para.strip()
|
||||
if not para:
|
||||
continue
|
||||
if len(para) <= chunk_size:
|
||||
chunks.append(para)
|
||||
else:
|
||||
sentences = []
|
||||
current = ""
|
||||
for ch in para:
|
||||
current += ch
|
||||
if ch in ".!?" and len(current) > chunk_size // 4:
|
||||
sentences.append(current.strip())
|
||||
current = ""
|
||||
if current.strip():
|
||||
sentences.append(current.strip())
|
||||
|
||||
for sentence in sentences:
|
||||
if len(sentence) <= chunk_size:
|
||||
chunks.append(sentence)
|
||||
else:
|
||||
pos = 0
|
||||
while pos < len(sentence):
|
||||
end = min(pos + chunk_size, len(sentence))
|
||||
chunk = sentence[pos:end].strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
pos += chunk_size - overlap
|
||||
|
||||
return [c for c in chunks if len(c) >= 64]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_embedding_openai(text: str, model: str, api_key: str) -> Optional[List[float]]:
|
||||
resp = requests.post(
|
||||
"https://api.openai.com/v1/embeddings",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json={"model": model, "input": text},
|
||||
timeout=30,
|
||||
)
|
||||
data = resp.json()
|
||||
if "data" in data and len(data["data"]) > 0:
|
||||
return data["data"][0]["embedding"]
|
||||
return None
|
||||
|
||||
|
||||
def get_embedding_ollama(text: str, model: str, host: str = "http://localhost:11434") -> Optional[List[float]]:
|
||||
resp = requests.post(
|
||||
f"{host}/api/embeddings",
|
||||
json={"model": model, "prompt": text},
|
||||
timeout=30,
|
||||
)
|
||||
data = resp.json()
|
||||
if "embedding" in data:
|
||||
return data["embedding"]
|
||||
return None
|
||||
|
||||
|
||||
def embed(texts: List[str], config: dict) -> List[Optional[List[float]]]:
|
||||
if config["type"] == "openai":
|
||||
return [get_embedding_openai(t, config["model"], config["api_key"]) for t in texts]
|
||||
elif config["type"] == "ollama":
|
||||
return [get_embedding_ollama(t, config["model"], config.get("host", "http://localhost:11434")) for t in texts]
|
||||
return [None] * len(texts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_response(query: str, context: str, config: dict) -> str:
|
||||
prompt = f"""You are a helpful assistant. Answer the question based on the context below.
|
||||
If the answer cannot be found in the context, say "I don't have enough information."
|
||||
|
||||
Context:
|
||||
{context}
|
||||
|
||||
Question: {query}
|
||||
|
||||
Answer:"""
|
||||
|
||||
if config["type"] == "openai":
|
||||
resp = requests.post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {config['api_key']}", "Content-Type": "application/json"},
|
||||
json={"model": config.get("chat_model", "gpt-4o-mini"),
|
||||
"messages": [{"role": "user", "content": prompt}]},
|
||||
timeout=60,
|
||||
)
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
elif config["type"] == "ollama":
|
||||
resp = requests.post(
|
||||
f"{config.get('host', 'http://localhost:11434')}/api/generate",
|
||||
json={"model": config.get("chat_model", "llama3"), "prompt": prompt, "stream": False},
|
||||
timeout=60,
|
||||
)
|
||||
return resp.json().get("response", "")
|
||||
|
||||
return "No LLM configured."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BaraDB integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BaraDBClient:
|
||||
"""Simple HTTP client for BaraDB."""
|
||||
|
||||
def __init__(self, host: str = "localhost", port: int = 9472):
|
||||
self.base = f"http://{host}:{port}"
|
||||
|
||||
def execute(self, sql: str) -> dict:
|
||||
resp = requests.post(f"{self.base}/query", json={"query": sql}, timeout=30)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def setup_bara_db(client: BaraDBClient, table: str = "rag_docs"):
|
||||
client.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
chunk_index INTEGER,
|
||||
content TEXT,
|
||||
embedding VECTOR(1536),
|
||||
metadata TEXT
|
||||
)
|
||||
""")
|
||||
client.execute(f"CREATE INDEX IF NOT EXISTS {table}_vec ON {table}(embedding) USING hnsw")
|
||||
client.execute(f"CREATE INDEX IF NOT EXISTS {table}_fts ON {table}(content) USING fts")
|
||||
|
||||
|
||||
def ingest_document(
|
||||
client: BaraDBClient,
|
||||
content: str,
|
||||
table: str,
|
||||
embedder_config: dict,
|
||||
chunk_size: int = 1024,
|
||||
overlap: int = 128,
|
||||
):
|
||||
chunks = chunk_text(content, chunk_size, overlap)
|
||||
print(f"Split into {len(chunks)} chunks")
|
||||
|
||||
batch_size = 10
|
||||
for batch_start in range(0, len(chunks), batch_size):
|
||||
batch = chunks[batch_start:batch_start + batch_size]
|
||||
embeddings = embed(batch, embedder_config)
|
||||
|
||||
for i, (chunk, embedding) in enumerate(zip(batch, embeddings)):
|
||||
chunk_idx = batch_start + i
|
||||
if embedding:
|
||||
vec_str = "[" + ",".join(str(v) for v in embedding) + "]"
|
||||
content_escaped = chunk.replace("'", "''")
|
||||
client.execute(
|
||||
f"INSERT INTO {table} (chunk_index, content, embedding) "
|
||||
f"VALUES ({chunk_idx}, '{content_escaped}', '{vec_str}')"
|
||||
)
|
||||
else:
|
||||
content_escaped = chunk.replace("'", "''")
|
||||
client.execute(
|
||||
f"INSERT INTO {table} (chunk_index, content) "
|
||||
f"VALUES ({chunk_idx}, '{content_escaped}')"
|
||||
)
|
||||
|
||||
print(f" Ingested chunks {batch_start + 1}-{min(batch_start + batch_size, len(chunks))}")
|
||||
|
||||
|
||||
def search(
|
||||
client: BaraDBClient,
|
||||
query: str,
|
||||
table: str,
|
||||
embedder_config: dict,
|
||||
k: int = 5,
|
||||
) -> List[dict]:
|
||||
query_embedding = embed([query], embedder_config)[0]
|
||||
if query_embedding:
|
||||
vec_str = "[" + ",".join(str(v) for v in query_embedding) + "]"
|
||||
result = client.execute(
|
||||
f"SELECT id, chunk_index, content, cos_distance(embedding, '{vec_str}') AS distance "
|
||||
f"FROM {table} "
|
||||
f"ORDER BY distance ASC "
|
||||
f"LIMIT {k}"
|
||||
)
|
||||
else:
|
||||
result = client.execute(
|
||||
f"SELECT id, chunk_index, content FROM {table} LIMIT {k}"
|
||||
)
|
||||
|
||||
if "rows" in result:
|
||||
return result["rows"]
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="BaraDB RAG Pipeline")
|
||||
parser.add_argument("--file", "-f", help="Document to ingest")
|
||||
parser.add_argument("--query", "-q", help="Query for RAG search")
|
||||
parser.add_argument("--db-host", default="localhost", help="BaraDB host")
|
||||
parser.add_argument("--db-port", type=int, default=9472, help="BaraDB port (HTTP = TCP + 440)")
|
||||
parser.add_argument("--table", default="rag_docs", help="Table name")
|
||||
parser.add_argument("--embedder", default="ollama", choices=["ollama", "openai", "none"])
|
||||
parser.add_argument("--model", default="nomic-embed-text", help="Embedding model")
|
||||
parser.add_argument("--api-key", help="API key (for OpenAI)")
|
||||
parser.add_argument("--api-host", default="http://localhost:11434", help="Ollama host")
|
||||
parser.add_argument("--chat-model", default="llama3", help="Chat model for generation")
|
||||
parser.add_argument("--chunk-size", type=int, default=1024)
|
||||
parser.add_argument("--overlap", type=int, default=128)
|
||||
parser.add_argument("--top-k", type=int, default=5, help="Number of chunks to retrieve")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.file and not args.query:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
client = BaraDBClient(args.db_host, args.db_port)
|
||||
setup_bara_db(client, args.table)
|
||||
|
||||
embedder_config = {
|
||||
"type": args.embedder,
|
||||
"model": args.model,
|
||||
"api_key": args.api_key or os.getenv("OPENAI_API_KEY", ""),
|
||||
"host": args.api_host,
|
||||
"chat_model": args.chat_model,
|
||||
}
|
||||
|
||||
if args.file:
|
||||
print(f"Loading: {args.file}")
|
||||
content = load_document(args.file)
|
||||
print(f"Loaded {len(content)} characters")
|
||||
|
||||
ingest_document(client, content, args.table, embedder_config,
|
||||
args.chunk_size, args.overlap)
|
||||
print("Ingestion complete.")
|
||||
|
||||
if args.query:
|
||||
print(f"\nQuery: {args.query}")
|
||||
results = search(client, args.query, args.table, embedder_config, args.top_k)
|
||||
|
||||
if not results:
|
||||
print("No results found.")
|
||||
return
|
||||
|
||||
context = "\n\n".join(r.get("content", "") for r in results)
|
||||
print(f"\nTop {len(results)} chunks retrieved:")
|
||||
for r in results:
|
||||
print(f" [{r.get('chunk_index', '?')}] {r.get('content', '')[:120]}...")
|
||||
|
||||
answer = generate_response(args.query, context, embedder_config)
|
||||
print(f"\nAnswer:\n{answer}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
## Chunking — Text splitting for RAG pipelines
|
||||
##
|
||||
## Splits long text into overlapping chunks suitable for embedding.
|
||||
## Strategies: paragraph, sentence, fixed-size with overlap.
|
||||
|
||||
import std/strutils
|
||||
import std/sequtils
|
||||
import std/json
|
||||
|
||||
type
|
||||
ChunkStrategy* = enum
|
||||
csParagraph = "paragraph" # Split by double newlines
|
||||
csSentence = "sentence" # Split by sentence boundaries
|
||||
csFixed = "fixed" # Fixed-size with overlap
|
||||
csRecursive = "recursive" # Try paragraph, then sentence, then fixed
|
||||
|
||||
ChunkConfig* = object
|
||||
maxChunkSize*: int # Max characters per chunk (default 1024)
|
||||
chunkOverlap*: int # Character overlap between chunks (default 128)
|
||||
strategy*: ChunkStrategy # Chunking strategy (default recursive)
|
||||
minChunkSize*: int # Minimum chunk size (default 64)
|
||||
separators*: seq[string] # Custom separators for recursive splitting
|
||||
|
||||
proc defaultChunkConfig*(): ChunkConfig =
|
||||
ChunkConfig(
|
||||
maxChunkSize: 1024,
|
||||
chunkOverlap: 128,
|
||||
strategy: csRecursive,
|
||||
minChunkSize: 64,
|
||||
separators: @["\n\n", "\n", ". ", "? ", "! ", "; ", ", ", " "],
|
||||
)
|
||||
|
||||
proc splitByParagraphs(text: string): seq[string] =
|
||||
result = @[]
|
||||
for para in text.split("\n\n"):
|
||||
let trimmed = para.strip()
|
||||
if trimmed.len > 0:
|
||||
result.add(trimmed)
|
||||
|
||||
proc splitBySentences(text: string): seq[string] =
|
||||
result = @[]
|
||||
var current = ""
|
||||
var i = 0
|
||||
while i < text.len:
|
||||
current.add(text[i])
|
||||
if text[i] in {'.', '?', '!'}:
|
||||
if i + 1 < text.len and text[i + 1] == ' ':
|
||||
inc i
|
||||
current.add(' ')
|
||||
let trimmed = current.strip()
|
||||
if trimmed.len > 0:
|
||||
result.add(trimmed)
|
||||
current = ""
|
||||
inc i
|
||||
let remaining = current.strip()
|
||||
if remaining.len > 0:
|
||||
result.add(remaining)
|
||||
|
||||
proc splitFixed(text: string, chunkSize: int, overlap: int): seq[string] =
|
||||
result = @[]
|
||||
if text.len <= chunkSize:
|
||||
if text.strip().len > 0:
|
||||
result.add(text.strip())
|
||||
return
|
||||
|
||||
var pos = 0
|
||||
while pos < text.len:
|
||||
let endPos = min(pos + chunkSize, text.len)
|
||||
var chunk = text[pos ..< endPos]
|
||||
|
||||
if endPos < text.len:
|
||||
var breakPos = chunk.rfind(". ")
|
||||
if breakPos < 0:
|
||||
breakPos = chunk.rfind("? ")
|
||||
if breakPos < 0:
|
||||
breakPos = chunk.rfind("! ")
|
||||
if breakPos < 0:
|
||||
breakPos = chunk.rfind("\n\n")
|
||||
if breakPos < 0:
|
||||
breakPos = chunk.rfind("\n")
|
||||
if breakPos < 0:
|
||||
breakPos = chunk.rfind(" ")
|
||||
if breakPos > chunkSize div 4:
|
||||
chunk = chunk[0 .. breakPos]
|
||||
pos += breakPos + 1
|
||||
else:
|
||||
pos += chunkSize - overlap
|
||||
else:
|
||||
pos = text.len
|
||||
|
||||
let trimmed = chunk.strip()
|
||||
if trimmed.len > 0:
|
||||
result.add(trimmed)
|
||||
|
||||
proc chunk*(text: string, config: ChunkConfig = defaultChunkConfig()): seq[string] =
|
||||
if text.len <= config.minChunkSize:
|
||||
let trimmed = text.strip()
|
||||
if trimmed.len > 0:
|
||||
return @[trimmed]
|
||||
return @[]
|
||||
|
||||
case config.strategy
|
||||
of csParagraph:
|
||||
result = splitByParagraphs(text)
|
||||
of csSentence:
|
||||
result = splitBySentences(text)
|
||||
of csFixed:
|
||||
result = splitFixed(text, config.maxChunkSize, config.chunkOverlap)
|
||||
of csRecursive:
|
||||
# Try paragraph first
|
||||
var paragraphs = splitByParagraphs(text)
|
||||
if paragraphs.len > 1:
|
||||
for para in paragraphs:
|
||||
if para.len > config.maxChunkSize:
|
||||
for sentence in splitBySentences(para):
|
||||
if sentence.len > config.maxChunkSize:
|
||||
result.add(splitFixed(sentence, config.maxChunkSize, config.chunkOverlap))
|
||||
else:
|
||||
result.add(sentence)
|
||||
else:
|
||||
result.add(para)
|
||||
else:
|
||||
var sentences = splitBySentences(text)
|
||||
if sentences.len > 1:
|
||||
for sentence in sentences:
|
||||
if sentence.len > config.maxChunkSize:
|
||||
result.add(splitFixed(sentence, config.maxChunkSize, config.chunkOverlap))
|
||||
else:
|
||||
result.add(sentence)
|
||||
else:
|
||||
result = splitFixed(text, config.maxChunkSize, config.chunkOverlap)
|
||||
|
||||
result = result.filterIt(it.len >= config.minChunkSize)
|
||||
|
||||
proc chunkToJson*(text: string, config: ChunkConfig = defaultChunkConfig()): JsonNode =
|
||||
let chunks = chunk(text, config)
|
||||
var arr = newJArray()
|
||||
var idx = 0
|
||||
for c in chunks:
|
||||
arr.add(%*{"index": idx, "text": c, "size": c.len})
|
||||
inc idx
|
||||
return arr
|
||||
@@ -0,0 +1,87 @@
|
||||
## Embedding client — calls external embedding APIs
|
||||
##
|
||||
## Configurable HTTP client for generating vector embeddings from text.
|
||||
## Supports OpenAI-compatible and Ollama APIs.
|
||||
|
||||
import std/httpclient
|
||||
import std/json
|
||||
import std/strutils
|
||||
import std/os
|
||||
|
||||
type
|
||||
EmbedderConfig* = object
|
||||
endpoint*: string # e.g. "http://localhost:11434/api/embeddings"
|
||||
model*: string # e.g. "nomic-embed-text"
|
||||
apiKey*: string # API key (for OpenAI-compatible APIs)
|
||||
dimensions*: int # Expected embedding dimensions
|
||||
timeoutMs*: int # Request timeout in ms
|
||||
enabled*: bool # Whether auto-embedding is enabled
|
||||
|
||||
Embedder* = ref object
|
||||
config*: EmbedderConfig
|
||||
|
||||
proc defaultEmbedderConfig*(): EmbedderConfig =
|
||||
EmbedderConfig(
|
||||
endpoint: getEnv("BARADB_EMBED_ENDPOINT", ""),
|
||||
model: getEnv("BARADB_EMBED_MODEL", "nomic-embed-text"),
|
||||
apiKey: getEnv("BARADB_EMBED_API_KEY", ""),
|
||||
dimensions: 768,
|
||||
timeoutMs: 30000,
|
||||
enabled: false,
|
||||
)
|
||||
|
||||
proc newEmbedder*(config: EmbedderConfig = defaultEmbedderConfig()): Embedder =
|
||||
result = Embedder(config: config)
|
||||
result.config.enabled = config.endpoint.len > 0
|
||||
|
||||
proc embed*(e: Embedder, text: string): seq[float32] =
|
||||
result = @[]
|
||||
if not e.config.enabled:
|
||||
return
|
||||
|
||||
var client = newHttpClient(timeout = e.config.timeoutMs)
|
||||
try:
|
||||
var body = %*{"model": e.config.model, "prompt": text}
|
||||
if e.config.apiKey.len > 0:
|
||||
client.headers["Authorization"] = "Bearer " & e.config.apiKey
|
||||
client.headers["Content-Type"] = "application/json"
|
||||
|
||||
let resp = client.request(e.config.endpoint, httpMethod = HttpPost, body = $body)
|
||||
let data = parseJson(resp.body)
|
||||
|
||||
if data.hasKey("embedding"):
|
||||
for val in data["embedding"]:
|
||||
result.add(float32(val.getFloat()))
|
||||
elif data.hasKey("data") and data["data"].kind == JArray and data["data"].len > 0:
|
||||
for val in data["data"][0]["embedding"]:
|
||||
result.add(float32(val.getFloat()))
|
||||
except:
|
||||
discard
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
proc embedBatch*(e: Embedder, texts: seq[string]): seq[seq[float32]] =
|
||||
result = newSeq[seq[float32]](texts.len)
|
||||
for i, text in texts:
|
||||
result[i] = e.embed(text)
|
||||
|
||||
proc vectorToJson*(vec: seq[float32]): string =
|
||||
var parts: seq[string] = @[]
|
||||
for v in vec:
|
||||
parts.add($v)
|
||||
return "[" & parts.join(",") & "]"
|
||||
|
||||
proc jsonToVector*(s: string): seq[float32] =
|
||||
result = @[]
|
||||
var cleaned = s.strip()
|
||||
if cleaned.startsWith("[") and cleaned.endsWith("]"):
|
||||
cleaned = cleaned[1..^2]
|
||||
elif cleaned.startsWith("(") and cleaned.endsWith(")"):
|
||||
cleaned = cleaned[1..^2]
|
||||
for part in cleaned.split(","):
|
||||
let p = part.strip()
|
||||
if p.len > 0:
|
||||
try:
|
||||
result.add(parseFloat(p))
|
||||
except:
|
||||
discard
|
||||
@@ -0,0 +1,129 @@
|
||||
## LLM Client — calls external LLM APIs for NL→SQL generation
|
||||
##
|
||||
## Supports OpenAI-compatible and Ollama APIs.
|
||||
## Used by the `nl_to_sql()` SQL function.
|
||||
|
||||
import std/httpclient
|
||||
import std/json
|
||||
import std/strutils
|
||||
import std/os
|
||||
|
||||
type
|
||||
LLMConfig* = object
|
||||
endpoint*: string # e.g. "http://localhost:11434/api/generate"
|
||||
chatEndpoint*: string # e.g. "https://api.openai.com/v1/chat/completions"
|
||||
model*: string # e.g. "llama3", "gpt-4o-mini"
|
||||
apiKey*: string
|
||||
timeoutMs*: int
|
||||
enabled*: bool
|
||||
maxTokens*: int
|
||||
|
||||
LLMClient* = ref object
|
||||
config*: LLMConfig
|
||||
|
||||
proc defaultLLMConfig*(): LLMConfig =
|
||||
LLMConfig(
|
||||
endpoint: getEnv("BARADB_LLM_ENDPOINT", ""),
|
||||
chatEndpoint: getEnv("BARADB_LLM_CHAT_ENDPOINT", ""),
|
||||
model: getEnv("BARADB_LLM_MODEL", "llama3"),
|
||||
apiKey: getEnv("BARADB_LLM_API_KEY", ""),
|
||||
timeoutMs: 60000,
|
||||
enabled: false,
|
||||
maxTokens: 2048,
|
||||
)
|
||||
|
||||
proc newLLMClient*(config: LLMConfig = defaultLLMConfig()): LLMClient =
|
||||
result = LLMClient(config: config)
|
||||
result.config.enabled = config.endpoint.len > 0 or config.chatEndpoint.len > 0
|
||||
|
||||
proc generate*(client: LLMClient, prompt: string, systemPrompt: string = ""): string =
|
||||
result = ""
|
||||
if not client.config.enabled:
|
||||
return
|
||||
|
||||
var httpClient = newHttpClient(timeout = client.config.timeoutMs)
|
||||
try:
|
||||
if client.config.apiKey.len > 0:
|
||||
httpClient.headers["Authorization"] = "Bearer " & client.config.apiKey
|
||||
httpClient.headers["Content-Type"] = "application/json"
|
||||
|
||||
if client.config.chatEndpoint.len > 0:
|
||||
var messages = newJArray()
|
||||
if systemPrompt.len > 0:
|
||||
messages.add(%*{"role": "system", "content": systemPrompt})
|
||||
messages.add(%*{"role": "user", "content": prompt})
|
||||
let body = %*{
|
||||
"model": client.config.model,
|
||||
"messages": messages,
|
||||
"max_tokens": client.config.maxTokens,
|
||||
"temperature": 0.1,
|
||||
}
|
||||
let resp = httpClient.request(client.config.chatEndpoint, httpMethod = HttpPost, body = $body)
|
||||
let data = parseJson(resp.body)
|
||||
if data.hasKey("choices") and data["choices"].kind == JArray and data["choices"].len > 0:
|
||||
result = data["choices"][0]["message"]["content"].getStr()
|
||||
elif client.config.endpoint.len > 0:
|
||||
var fullPrompt = prompt
|
||||
if systemPrompt.len > 0:
|
||||
fullPrompt = systemPrompt & "\n\n" & prompt
|
||||
let body = %*{
|
||||
"model": client.config.model,
|
||||
"prompt": fullPrompt,
|
||||
"stream": false,
|
||||
"options": {"temperature": 0.1, "num_predict": client.config.maxTokens},
|
||||
}
|
||||
let resp = httpClient.request(client.config.endpoint, httpMethod = HttpPost, body = $body)
|
||||
let data = parseJson(resp.body)
|
||||
if data.hasKey("response"):
|
||||
result = data["response"].getStr()
|
||||
elif data.hasKey("choices") and data["choices"].kind == JArray and data["choices"].len > 0:
|
||||
result = data["choices"][0]["message"]["content"].getStr()
|
||||
except:
|
||||
result = ""
|
||||
finally:
|
||||
httpClient.close()
|
||||
|
||||
proc extractSQL*(response: string): string =
|
||||
## Extract SQL from LLM response which may contain markdown or explanations.
|
||||
result = response.strip()
|
||||
|
||||
# Try markdown code block: ```sql ... ```
|
||||
var start = result.find("```sql")
|
||||
if start < 0:
|
||||
start = result.find("```SQL")
|
||||
if start < 0:
|
||||
start = result.find("```")
|
||||
if start >= 0:
|
||||
var endPos = result.find("```", start + 3)
|
||||
if endPos < 0:
|
||||
endPos = result.len
|
||||
result = result[start + 3 ..< endPos].strip()
|
||||
# Strip leading "sql" or "SQL" if present after ```
|
||||
if result.toLower().startsWith("sql"):
|
||||
result = result[3..^1].strip()
|
||||
|
||||
# Remove trailing semicolons and whitespace
|
||||
result = result.strip(chars = {';', ' ', '\n', '\r', '\t'})
|
||||
|
||||
# If there's a SELECT/INSERT/UPDATE/DELETE/CREATE anywhere, start from there
|
||||
let sqlStart = result.toLower().find("select")
|
||||
if sqlStart < 0:
|
||||
let altStart = result.toLower().find("insert")
|
||||
if altStart < 0:
|
||||
let altStart2 = result.toLower().find("update")
|
||||
if altStart2 < 0:
|
||||
let altStart3 = result.toLower().find("delete")
|
||||
if altStart3 < 0:
|
||||
let altStart4 = result.toLower().find("create")
|
||||
if altStart4 >= 0:
|
||||
result = result[altStart4..^1]
|
||||
else:
|
||||
result = result[altStart3..^1]
|
||||
else:
|
||||
result = result[altStart2..^1]
|
||||
else:
|
||||
result = result[altStart..^1]
|
||||
elif sqlStart > 0:
|
||||
result = result[sqlStart..^1]
|
||||
|
||||
return result
|
||||
@@ -44,6 +44,8 @@ proc `==`*(a, b: EdgeId): bool = uint64(a) == uint64(b)
|
||||
proc `==`*(a, b: NodeId): bool = uint64(a) == uint64(b)
|
||||
proc hash*(x: EdgeId): Hash = hash(uint64(x))
|
||||
proc hash*(x: NodeId): Hash = hash(uint64(x))
|
||||
proc `$`*(x: NodeId): string = $(uint64(x))
|
||||
proc `$`*(x: EdgeId): string = $(uint64(x))
|
||||
|
||||
proc newGraph*(): Graph =
|
||||
new(result)
|
||||
@@ -65,6 +67,17 @@ proc addNode*(g: Graph, label: string, properties: Table[string, string] = initT
|
||||
g.reverseAdj[id] = @[]
|
||||
return id
|
||||
|
||||
proc addNodeWithId*(g: Graph, id: NodeId, label: string,
|
||||
properties: Table[string, string] = initTable[string, string]()) =
|
||||
acquire(g.lock)
|
||||
defer: release(g.lock)
|
||||
if id notin g.nodes:
|
||||
g.nodes[id] = GraphNode(id: id, label: label, properties: properties)
|
||||
g.adjacency[id] = @[]
|
||||
g.reverseAdj[id] = @[]
|
||||
if uint64(id) >= g.nextNodeId:
|
||||
g.nextNodeId = uint64(id) + 1
|
||||
|
||||
proc addEdge*(g: Graph, src, dst: NodeId, label: string = "",
|
||||
properties: Table[string, string] = initTable[string, string](),
|
||||
weight: float64 = 1.0): EdgeId =
|
||||
@@ -82,6 +95,21 @@ proc addEdge*(g: Graph, src, dst: NodeId, label: string = "",
|
||||
g.reverseAdj[dst].add(AdjacencyEntry(edgeId: id, neighbor: src, weight: weight, label: label))
|
||||
return id
|
||||
|
||||
proc addEdgeWithId*(g: Graph, src, dst: NodeId, label: string = "",
|
||||
weight: float64 = 1.0) =
|
||||
acquire(g.lock)
|
||||
defer: release(g.lock)
|
||||
if src notin g.nodes:
|
||||
raise newException(KeyError, "Source node does not exist: " & $(uint64(src)))
|
||||
if dst notin g.nodes:
|
||||
raise newException(KeyError, "Destination node does not exist: " & $(uint64(dst)))
|
||||
let id = EdgeId(g.nextEdgeId)
|
||||
inc g.nextEdgeId
|
||||
g.edges[id] = Edge(id: id, src: src, dst: dst, label: label,
|
||||
properties: initTable[string, string](), weight: weight)
|
||||
g.adjacency[src].add(AdjacencyEntry(edgeId: id, neighbor: dst, weight: weight, label: label))
|
||||
g.reverseAdj[dst].add(AdjacencyEntry(edgeId: id, neighbor: src, weight: weight, label: label))
|
||||
|
||||
proc getNode*(g: Graph, id: NodeId): GraphNode =
|
||||
acquire(g.lock)
|
||||
defer: release(g.lock)
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
## BaraDB MCP Server — Model Context Protocol
|
||||
##
|
||||
## Implements the MCP (Model Context Protocol) over STDIO transport
|
||||
## with JSON-RPC 2.0. Provides AI agents with tools to query, vector
|
||||
## search, and inspect the BaraDB schema.
|
||||
##
|
||||
## Tools:
|
||||
## query — Execute SQL queries with parameterized inputs
|
||||
## vector_search — Semantic vector similarity search with tenant isolation
|
||||
## schema_inspect — Explore tables, columns, indexes, RLS policies
|
||||
|
||||
import std/json
|
||||
import std/strutils
|
||||
import std/os
|
||||
import std/tables
|
||||
import std/sequtils
|
||||
|
||||
import ../storage/lsm
|
||||
import ../query/lexer as qlexer
|
||||
import ../query/parser as qparser
|
||||
import ../query/ast
|
||||
import ../query/executor
|
||||
import ../protocol/wire
|
||||
import ../core/mvcc
|
||||
import ../fts/engine as fts
|
||||
import ../vector/engine as vengine
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP JSON-RPC 2.0 types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
type
|
||||
JsonRpcErrorCode* = enum
|
||||
jrParseError = -32700
|
||||
jrInvalidRequest = -32600
|
||||
jrMethodNotFound = -32601
|
||||
jrInvalidParams = -32602
|
||||
jrInternalError = -32603
|
||||
|
||||
McpToolDef* = object
|
||||
name*: string
|
||||
description*: string
|
||||
inputSchema*: JsonNode
|
||||
|
||||
McpServerInfo* = object
|
||||
name*: string
|
||||
version*: string
|
||||
|
||||
McpServerCapabilities* = object
|
||||
tools*: JsonNode
|
||||
|
||||
# Tool definitions (lazy initialization)
|
||||
var toolDefs: seq[McpToolDef]
|
||||
|
||||
proc buildToolDefs() =
|
||||
if toolDefs.len > 0:
|
||||
return
|
||||
|
||||
toolDefs = @[
|
||||
McpToolDef(
|
||||
name: "query",
|
||||
description: "Execute a SQL query against BaraDB. Supports SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, and all BaraQL statements. Use parameterized queries with ? placeholders to prevent SQL injection. Returns rows as an array of objects keyed by column name.",
|
||||
inputSchema: %*{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sql": {
|
||||
"type": "string",
|
||||
"description": "The SQL query to execute. Use ? for parameterized values."
|
||||
},
|
||||
"params": {
|
||||
"type": "array",
|
||||
"description": "Optional parameter values for ? placeholders in the SQL query.",
|
||||
"items": {}
|
||||
},
|
||||
"tenant_id": {
|
||||
"type": "string",
|
||||
"description": "Optional. Sets the app.tenant_id session variable for multi-tenant RLS filtering."
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "Optional. Sets the current user for RLS policy evaluation."
|
||||
}
|
||||
},
|
||||
"required": ["sql"]
|
||||
}
|
||||
),
|
||||
McpToolDef(
|
||||
name: "vector_search",
|
||||
description: "Perform semantic vector similarity search against a BaraDB HNSW vector index. Finds the k-nearest neighbors to a query vector. Supports tenant isolation via session variables. Results include distance scores and metadata.",
|
||||
inputSchema: %*{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "The table name containing the vector column."
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"description": "The vector column name with an HNSW index."
|
||||
},
|
||||
"query_vector": {
|
||||
"type": "array",
|
||||
"description": "The query vector as an array of floats.",
|
||||
"items": {"type": "number"}
|
||||
},
|
||||
"k": {
|
||||
"type": "integer",
|
||||
"description": "Number of nearest neighbors to return (default: 10).",
|
||||
"default": 10
|
||||
},
|
||||
"metric": {
|
||||
"type": "string",
|
||||
"enum": ["cosine", "euclidean", "dot_product", "manhattan"],
|
||||
"description": "Distance metric (default: cosine).",
|
||||
"default": "cosine"
|
||||
},
|
||||
"filter_column": {
|
||||
"type": "string",
|
||||
"description": "Optional metadata column to filter results."
|
||||
},
|
||||
"filter_value": {
|
||||
"type": "string",
|
||||
"description": "Value for filter_column. Only results matching this value are returned."
|
||||
},
|
||||
"tenant_id": {
|
||||
"type": "string",
|
||||
"description": "Optional. Sets the app.tenant_id session variable for multi-tenant RLS filtering."
|
||||
}
|
||||
},
|
||||
"required": ["table", "column", "query_vector"]
|
||||
}
|
||||
),
|
||||
McpToolDef(
|
||||
name: "schema_inspect",
|
||||
description: "Explore and inspect the BaraDB database schema. Returns tables, columns, data types, primary keys, foreign keys, indexes (BTree, HNSW vector, full-text), and RLS policies. Optionally filter to a specific table.",
|
||||
inputSchema: %*{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "Optional. If provided, returns detailed schema for only this table."
|
||||
},
|
||||
"tenant_id": {
|
||||
"type": "string",
|
||||
"description": "Optional. Sets the app.tenant_id session variable for multi-tenant RLS context."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
type
|
||||
McpServerCtx* = ref object
|
||||
db*: LSMTree
|
||||
execCtx*: ExecutionContext
|
||||
dataDir*: string
|
||||
|
||||
var serverCtx: McpServerCtx
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON-RPC helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
proc parseVectorFromJson(node: JsonNode): seq[float32] =
|
||||
result = @[]
|
||||
if node.kind == JArray:
|
||||
for item in node:
|
||||
case item.kind
|
||||
of JInt: result.add(float32(item.getInt()))
|
||||
of JFloat: result.add(float32(item.getFloat()))
|
||||
else: discard
|
||||
|
||||
proc parseMetric(s: string): vengine.DistanceMetric =
|
||||
case s.toLowerAscii()
|
||||
of "cosine": vengine.dmCosine
|
||||
of "euclidean": vengine.dmEuclidean
|
||||
of "dot_product", "dotproduct": vengine.dmDotProduct
|
||||
of "manhattan": vengine.dmManhattan
|
||||
else: vengine.dmCosine
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool: query
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc handleQuery(params: JsonNode): JsonNode =
|
||||
if params.kind != JObject:
|
||||
return %*{"error": "params must be a JSON object"}
|
||||
|
||||
if "sql" notin params or params["sql"].kind != JString:
|
||||
return %*{"error": "Missing required parameter: sql (string)"}
|
||||
|
||||
let sql = params["sql"].getStr()
|
||||
if sql.strip().len == 0:
|
||||
return %*{"error": "SQL query cannot be empty"}
|
||||
|
||||
var prevTenant = serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
|
||||
var prevUser = serverCtx.execCtx.currentUser
|
||||
|
||||
if "tenant_id" in params and params["tenant_id"].kind == JString:
|
||||
let tid = params["tenant_id"].getStr()
|
||||
if tid.len > 0:
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = tid
|
||||
if "user_id" in params and params["user_id"].kind == JString:
|
||||
let uid = params["user_id"].getStr()
|
||||
if uid.len > 0:
|
||||
serverCtx.execCtx.currentUser = uid
|
||||
|
||||
var wireParams: seq[WireValue] = @[]
|
||||
if "params" in params and params["params"].kind == JArray:
|
||||
for p in params["params"]:
|
||||
case p.kind
|
||||
of JNull: wireParams.add(WireValue(kind: fkNull))
|
||||
of JBool: wireParams.add(WireValue(kind: fkBool, boolVal: p.getBool()))
|
||||
of JInt: wireParams.add(WireValue(kind: fkInt64, int64Val: p.getInt()))
|
||||
of JFloat: wireParams.add(WireValue(kind: fkFloat64, float64Val: p.getFloat()))
|
||||
of JString: wireParams.add(WireValue(kind: fkString, strVal: p.getStr()))
|
||||
else: wireParams.add(WireValue(kind: fkString, strVal: $p))
|
||||
|
||||
var tokens: seq[qlexer.Token]
|
||||
try:
|
||||
tokens = qlexer.tokenize(sql)
|
||||
except:
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
|
||||
serverCtx.execCtx.currentUser = prevUser
|
||||
return %*{"error": "Failed to tokenize SQL: " & getCurrentExceptionMsg()}
|
||||
|
||||
var astNode: Node
|
||||
try:
|
||||
astNode = qparser.parse(tokens)
|
||||
except:
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
|
||||
serverCtx.execCtx.currentUser = prevUser
|
||||
return %*{"error": "Failed to parse SQL: " & getCurrentExceptionMsg()}
|
||||
|
||||
if astNode.stmts.len == 0:
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
|
||||
serverCtx.execCtx.currentUser = prevUser
|
||||
return %*{"columns": [], "rows": [], "affectedRows": 0}
|
||||
|
||||
var res: ExecResult
|
||||
try:
|
||||
res = executeQuery(serverCtx.execCtx, astNode, wireParams)
|
||||
except:
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
|
||||
serverCtx.execCtx.currentUser = prevUser
|
||||
return %*{"error": "Query execution failed: " & getCurrentExceptionMsg()}
|
||||
|
||||
if not res.success:
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
|
||||
serverCtx.execCtx.currentUser = prevUser
|
||||
return %*{"error": res.message}
|
||||
|
||||
var jsonRows = newJArray()
|
||||
for row in res.rows:
|
||||
var jsonRow = newJObject()
|
||||
for col in res.columns:
|
||||
if col in row:
|
||||
jsonRow[col] = %row[col]
|
||||
else:
|
||||
jsonRow[col] = newJNull()
|
||||
jsonRows.add(jsonRow)
|
||||
|
||||
var jsonCols = newJArray()
|
||||
for c in res.columns:
|
||||
jsonCols.add(%c)
|
||||
|
||||
var r = %*{
|
||||
"columns": jsonCols,
|
||||
"rows": jsonRows,
|
||||
"affectedRows": res.affectedRows
|
||||
}
|
||||
if res.message.len > 0:
|
||||
r["message"] = %res.message
|
||||
|
||||
var sessionInfo = newJObject()
|
||||
sessionInfo["tenant_id"] = %serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
|
||||
sessionInfo["user_id"] = %serverCtx.execCtx.currentUser
|
||||
r["_session"] = sessionInfo
|
||||
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = prevTenant
|
||||
serverCtx.execCtx.currentUser = prevUser
|
||||
|
||||
return r
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool: vector_search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc handleVectorSearch(params: JsonNode): JsonNode =
|
||||
if params.kind != JObject:
|
||||
return %*{"error": "params must be a JSON object"}
|
||||
|
||||
if "table" notin params or params["table"].kind != JString:
|
||||
return %*{"error": "Missing required parameter: table (string)"}
|
||||
if "column" notin params or params["column"].kind != JString:
|
||||
return %*{"error": "Missing required parameter: column (string)"}
|
||||
if "query_vector" notin params:
|
||||
return %*{"error": "Missing required parameter: query_vector (array of floats)"}
|
||||
|
||||
let table = params["table"].getStr()
|
||||
let column = params["column"].getStr()
|
||||
let indexKey = table & "." & column
|
||||
|
||||
if indexKey notin serverCtx.execCtx.vectorIndexes:
|
||||
var available: seq[string] = @[]
|
||||
for k in serverCtx.execCtx.vectorIndexes.keys:
|
||||
available.add(k)
|
||||
return %*{
|
||||
"error": "No vector index found for '" & indexKey & "'",
|
||||
"available_indexes": %available
|
||||
}
|
||||
|
||||
let idx = serverCtx.execCtx.vectorIndexes[indexKey]
|
||||
if idx.isNil or idx.nodes.len == 0:
|
||||
return %*{"error": "Vector index for '" & indexKey & "' is empty"}
|
||||
|
||||
let queryVec = parseVectorFromJson(params["query_vector"])
|
||||
if queryVec.len == 0:
|
||||
return %*{"error": "query_vector must be a non-empty array of numbers"}
|
||||
if queryVec.len != idx.dimensions:
|
||||
return %*{"error": "Vector dimension mismatch: got " & $queryVec.len &
|
||||
", expected " & $idx.dimensions}
|
||||
|
||||
let k = if "k" in params and params["k"].kind == JInt:
|
||||
params["k"].getInt()
|
||||
else: 10
|
||||
if k < 1 or k > 1000:
|
||||
return %*{"error": "k must be between 1 and 1000"}
|
||||
|
||||
let metric = if "metric" in params and params["metric"].kind == JString:
|
||||
parseMetric(params["metric"].getStr())
|
||||
else: vengine.dmCosine
|
||||
|
||||
var results: seq[(uint64, float64, Table[string, string])]
|
||||
|
||||
let hasFilter = "filter_column" in params and params["filter_column"].kind == JString and
|
||||
"filter_value" in params and params["filter_value"].kind == JString
|
||||
|
||||
if hasFilter:
|
||||
let filterCol = params["filter_column"].getStr()
|
||||
let filterVal = params["filter_value"].getStr()
|
||||
let filterFn = proc(metadata: Table[string, string]): bool {.gcsafe.} =
|
||||
result = filterCol in metadata and metadata[filterCol] == filterVal
|
||||
let rawResults = idx.searchWithFilter(queryVec, k, filterFn, metric)
|
||||
for (id, dist) in rawResults:
|
||||
var meta = initTable[string, string]()
|
||||
if id in idx.nodes:
|
||||
meta = idx.nodes[id].metadata
|
||||
results.add((id, dist, meta))
|
||||
else:
|
||||
results = idx.searchEx(queryVec, k, metric)
|
||||
|
||||
var jsonResults = newJArray()
|
||||
for (id, dist, meta) in results:
|
||||
var item = %*{
|
||||
"id": %id,
|
||||
"distance": dist
|
||||
}
|
||||
var metaObj = newJObject()
|
||||
for key, val in meta:
|
||||
metaObj[key] = %val
|
||||
item["metadata"] = metaObj
|
||||
jsonResults.add(item)
|
||||
|
||||
var sessionInfo = newJObject()
|
||||
if "tenant_id" in params and params["tenant_id"].kind == JString:
|
||||
let tid = params["tenant_id"].getStr()
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = tid
|
||||
sessionInfo["tenant_id"] = %tid
|
||||
sessionInfo["user_id"] = %serverCtx.execCtx.currentUser
|
||||
|
||||
return %*{
|
||||
"table": %table,
|
||||
"column": %column,
|
||||
"index_size": idx.nodes.len,
|
||||
"k": k,
|
||||
"metric": $metric,
|
||||
"results": jsonResults,
|
||||
"_session": sessionInfo
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool: schema_inspect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc handleSchemaInspect(params: JsonNode): JsonNode =
|
||||
var targetTable = ""
|
||||
if params.kind == JObject and "table" in params and params["table"].kind == JString:
|
||||
targetTable = params["table"].getStr()
|
||||
|
||||
if "tenant_id" in params and params["tenant_id"].kind == JString:
|
||||
serverCtx.execCtx.sessionVars["app.tenant_id"] = params["tenant_id"].getStr()
|
||||
|
||||
var jsonTables = newJArray()
|
||||
|
||||
for tblName, tblDef in serverCtx.execCtx.tables:
|
||||
if targetTable.len > 0 and tblName != targetTable:
|
||||
continue
|
||||
|
||||
var jsonCols = newJArray()
|
||||
for col in tblDef.columns:
|
||||
var colObj = %*{
|
||||
"name": col.name,
|
||||
"type": col.colType,
|
||||
"primary_key": col.isPk,
|
||||
"not_null": col.isNotNull,
|
||||
"unique": col.isUnique,
|
||||
"auto_increment": col.autoIncrement
|
||||
}
|
||||
if col.defaultVal.len > 0:
|
||||
colObj["default"] = %col.defaultVal
|
||||
|
||||
var fkInfo: JsonNode = nil
|
||||
if col.fkTable.len > 0:
|
||||
fkInfo = %*{
|
||||
"table": col.fkTable,
|
||||
"column": col.fkColumn,
|
||||
"on_delete": col.fkOnDelete,
|
||||
"on_update": col.fkOnUpdate
|
||||
}
|
||||
colObj["foreign_key"] = fkInfo
|
||||
|
||||
jsonCols.add(colObj)
|
||||
|
||||
var jsonIdxs = newJArray()
|
||||
for key in serverCtx.execCtx.btrees.keys:
|
||||
if key.startsWith(tblName & ".") or key == tblName:
|
||||
jsonIdxs.add(%*{"type": "btree", "name": key})
|
||||
for key in serverCtx.execCtx.vectorIndexes.keys:
|
||||
if key.startsWith(tblName & "."):
|
||||
let vi = serverCtx.execCtx.vectorIndexes[key]
|
||||
jsonIdxs.add(%*{
|
||||
"type": "hnsw_vector",
|
||||
"name": key,
|
||||
"dimensions": vi.dimensions,
|
||||
"node_count": vi.nodes.len
|
||||
})
|
||||
for key in serverCtx.execCtx.ftsIndexes.keys:
|
||||
if key.startsWith(tblName & "."):
|
||||
let ftsIdx = serverCtx.execCtx.ftsIndexes[key]
|
||||
jsonIdxs.add(%*{"type": "fulltext", "name": key, "doc_count": ftsIdx.docCount})
|
||||
|
||||
var jsonPolicies = newJArray()
|
||||
if tblName in serverCtx.execCtx.policies:
|
||||
for pol in serverCtx.execCtx.policies[tblName]:
|
||||
jsonPolicies.add(%*{
|
||||
"name": pol.name,
|
||||
"command": pol.command
|
||||
})
|
||||
|
||||
var fks = newJArray()
|
||||
for fk in tblDef.foreignKeys:
|
||||
fks.add(%*{
|
||||
"table": fk.refTable,
|
||||
"column": fk.refColumn,
|
||||
"on_delete": fk.onDelete,
|
||||
"on_update": fk.onUpdate
|
||||
})
|
||||
|
||||
var tblObj = %*{
|
||||
"name": tblName,
|
||||
"columns": jsonCols,
|
||||
"primary_keys": %tblDef.pkColumns,
|
||||
"indexes": jsonIdxs,
|
||||
"foreign_keys": fks,
|
||||
"policies": jsonPolicies
|
||||
}
|
||||
jsonTables.add(tblObj)
|
||||
|
||||
if targetTable.len > 0 and jsonTables.len == 0:
|
||||
return %*{"error": "Table '" & targetTable & "' not found"}
|
||||
|
||||
var sessionInfo = newJObject()
|
||||
sessionInfo["tenant_id"] = %serverCtx.execCtx.sessionVars.getOrDefault("app.tenant_id", "")
|
||||
sessionInfo["user_id"] = %serverCtx.execCtx.currentUser
|
||||
|
||||
return %*{
|
||||
"tables": jsonTables,
|
||||
"table_count": jsonTables.len,
|
||||
"_session": sessionInfo
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Protocol handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc handleInitialize(params: JsonNode): JsonNode =
|
||||
buildToolDefs()
|
||||
return %*{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "BaraDB MCP Server",
|
||||
"version": "1.1.2"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
}
|
||||
}
|
||||
|
||||
proc handleToolsList(params: JsonNode): JsonNode =
|
||||
buildToolDefs()
|
||||
var tools = newJArray()
|
||||
for td in toolDefs:
|
||||
tools.add(%*{
|
||||
"name": td.name,
|
||||
"description": td.description,
|
||||
"inputSchema": td.inputSchema
|
||||
})
|
||||
return %*{"tools": tools}
|
||||
|
||||
proc handleToolsCall(params: JsonNode): JsonNode =
|
||||
if params.kind != JObject:
|
||||
return %*{"error": "params must be a JSON object"}
|
||||
|
||||
if "name" notin params or params["name"].kind != JString:
|
||||
return %*{"error": "Missing tool name"}
|
||||
|
||||
let toolName = params["name"].getStr()
|
||||
let toolArgs = if "arguments" in params: params["arguments"] else: newJObject()
|
||||
|
||||
var content: JsonNode
|
||||
case toolName
|
||||
of "query":
|
||||
content = handleQuery(toolArgs)
|
||||
of "vector_search":
|
||||
content = handleVectorSearch(toolArgs)
|
||||
of "schema_inspect":
|
||||
content = handleSchemaInspect(toolArgs)
|
||||
else:
|
||||
return %*{"error": "Unknown tool: " & toolName}
|
||||
|
||||
var text: string
|
||||
if content.hasKey("error"):
|
||||
text = "Error: " & content["error"].getStr()
|
||||
else:
|
||||
text = $content
|
||||
|
||||
return %*{
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": text
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON-RPC dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc dispatch(meth: string, params: JsonNode): JsonNode =
|
||||
case meth
|
||||
of "initialize":
|
||||
return handleInitialize(params)
|
||||
of "tools/list":
|
||||
return handleToolsList(params)
|
||||
of "tools/call":
|
||||
return handleToolsCall(params)
|
||||
else:
|
||||
return %*{
|
||||
"error": {
|
||||
"code": jrMethodNotFound.int,
|
||||
"message": "Method not found: " & meth
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STDIO transport
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc writeToStdout(line: string) =
|
||||
try:
|
||||
stdout.writeLine(line)
|
||||
stdout.flushFile()
|
||||
except:
|
||||
discard
|
||||
|
||||
proc logToStderr*(msg: string) =
|
||||
try:
|
||||
stderr.writeLine("[baradb-mcp] " & msg)
|
||||
stderr.flushFile()
|
||||
except:
|
||||
discard
|
||||
|
||||
proc processMessage(raw: string): string =
|
||||
if raw.strip().len == 0:
|
||||
return ""
|
||||
|
||||
var req: JsonNode
|
||||
try:
|
||||
req = parseJson(raw)
|
||||
except:
|
||||
logToStderr("JSON parse error: " & getCurrentExceptionMsg())
|
||||
let resp = %*{
|
||||
"jsonrpc": "2.0",
|
||||
"id": newJNull(),
|
||||
"error": {
|
||||
"code": jrParseError.int,
|
||||
"message": "Parse error: " & getCurrentExceptionMsg()
|
||||
}
|
||||
}
|
||||
return $resp
|
||||
|
||||
if req.kind != JObject:
|
||||
let resp = %*{
|
||||
"jsonrpc": "2.0",
|
||||
"id": newJNull(),
|
||||
"error": {
|
||||
"code": jrInvalidRequest.int,
|
||||
"message": "Invalid request: not a JSON object"
|
||||
}
|
||||
}
|
||||
return $resp
|
||||
|
||||
if "method" notin req or req["method"].kind != JString:
|
||||
let resp = %*{
|
||||
"jsonrpc": "2.0",
|
||||
"id": newJNull(),
|
||||
"error": {
|
||||
"code": jrInvalidRequest.int,
|
||||
"message": "Invalid request: missing method"
|
||||
}
|
||||
}
|
||||
return $resp
|
||||
|
||||
let meth = req["method"].getStr()
|
||||
let params = if "params" in req: req["params"] else: newJObject()
|
||||
|
||||
let isNotification = "id" notin req
|
||||
|
||||
if meth == "notifications/initialized":
|
||||
return ""
|
||||
|
||||
var dispResult: JsonNode
|
||||
try:
|
||||
dispResult = dispatch(meth, params)
|
||||
except:
|
||||
logToStderr("Dispatch error for " & meth & ": " & getCurrentExceptionMsg())
|
||||
let msg = getCurrentExceptionMsg()
|
||||
var errResp = %*{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": jrInternalError.int,
|
||||
"message": "Internal error: " & msg
|
||||
}
|
||||
}
|
||||
if not isNotification:
|
||||
errResp["id"] = req["id"]
|
||||
return $errResp
|
||||
|
||||
if isNotification:
|
||||
return ""
|
||||
|
||||
var resp: JsonNode
|
||||
if dispResult.hasKey("error"):
|
||||
var errNode = dispResult["error"]
|
||||
var errObj: JsonNode
|
||||
if errNode.kind == JObject:
|
||||
errObj = errNode
|
||||
else:
|
||||
errObj = %*{"code": jrInternalError.int, "message": errNode.getStr()}
|
||||
resp = %*{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"error": errObj
|
||||
}
|
||||
else:
|
||||
resp = %*{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": dispResult
|
||||
}
|
||||
return $resp
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc init*(dataDir: string = "./data"): McpServerCtx =
|
||||
logToStderr("BaraDB MCP Server v1.1.2 initializing...")
|
||||
let db = newLSMTree(dataDir)
|
||||
let ctx = newExecutionContext(db)
|
||||
ctx.txnManager = newTxnManager()
|
||||
result = McpServerCtx(db: db, execCtx: ctx, dataDir: dataDir)
|
||||
serverCtx = result
|
||||
logToStderr("Initialized. Data directory: " & dataDir)
|
||||
|
||||
proc run*() =
|
||||
buildToolDefs()
|
||||
logToStderr("MCP Server ready. Waiting for JSON-RPC requests on STDIN...")
|
||||
logToStderr("Available tools: " & $toolDefs.mapIt(it.name))
|
||||
|
||||
var startupDone = false
|
||||
while true:
|
||||
var line = ""
|
||||
try:
|
||||
line = stdin.readLine()
|
||||
except EOFError:
|
||||
if startupDone:
|
||||
logToStderr("STDIN closed, exiting.")
|
||||
break
|
||||
except:
|
||||
logToStderr("STDIN read error: " & getCurrentExceptionMsg())
|
||||
break
|
||||
|
||||
let resp = processMessage(line)
|
||||
if resp.len > 0:
|
||||
writeToStdout(resp)
|
||||
if not startupDone:
|
||||
startupDone = true
|
||||
|
||||
proc close*() =
|
||||
if serverCtx != nil and serverCtx.db != nil:
|
||||
logToStderr("Closing database...")
|
||||
serverCtx.db.close()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone entry helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc parseDataDir*(): string =
|
||||
result = getEnv("BARADB_DATA_DIR", "./data")
|
||||
var i = 1
|
||||
while i < paramCount():
|
||||
let arg = paramStr(i)
|
||||
if arg == "--data-dir" and i + 1 <= paramCount():
|
||||
result = paramStr(i + 1)
|
||||
break
|
||||
inc i
|
||||
|
||||
when isMainModule:
|
||||
let dataDir = parseDataDir()
|
||||
logToStderr("Starting BaraDB MCP Server with data dir: " & dataDir)
|
||||
try:
|
||||
discard init(dataDir)
|
||||
run()
|
||||
except:
|
||||
logToStderr("Fatal error: " & getCurrentExceptionMsg())
|
||||
finally:
|
||||
close()
|
||||
@@ -41,6 +41,8 @@ type
|
||||
nkGrant
|
||||
nkRevoke
|
||||
nkSetVar
|
||||
nkCreateGraph
|
||||
nkDropGraph
|
||||
|
||||
# Clauses
|
||||
nkFrom
|
||||
@@ -328,6 +330,12 @@ type
|
||||
of nkSetVar:
|
||||
svName*: string
|
||||
svValue*: string
|
||||
of nkCreateGraph:
|
||||
cgName*: string
|
||||
cgIfNotExists*: bool
|
||||
of nkDropGraph:
|
||||
dgName*: string
|
||||
dgIfExists*: bool
|
||||
of nkApplyMigration:
|
||||
amName*: string
|
||||
of nkMigrationStatus:
|
||||
@@ -440,6 +448,7 @@ type
|
||||
gtEnd*: Node
|
||||
gtMaxDepth*: int
|
||||
gtReturnCols*: seq[string]
|
||||
gtAlgo*: string
|
||||
of nkBfsQuery:
|
||||
bfsStart*: Node
|
||||
bfsTarget*: Node
|
||||
|
||||
+788
-18
@@ -25,6 +25,11 @@ import ../core/mvcc
|
||||
import ../core/tracing
|
||||
import ../fts/engine as fts
|
||||
import ../vector/engine as vengine
|
||||
import ../graph/engine as gengine
|
||||
import ../graph/community as gcomm
|
||||
import ../ai/chunk as chunkmod
|
||||
import ../ai/embed as embedmod
|
||||
import ../ai/llm as llmmod
|
||||
|
||||
type
|
||||
IndexEntry* = ref object
|
||||
@@ -68,6 +73,9 @@ type
|
||||
cteTables*: Table[string, seq[Row]] # CTE name -> rows
|
||||
ftsIndexes*: Table[string, fts.InvertedIndex] # table.col -> FTS index
|
||||
vectorIndexes*: Table[string, vengine.HNSWIndex] # table.col -> HNSW index
|
||||
graphs*: Table[string, gengine.Graph] # graph name -> Graph object
|
||||
embedder*: embedmod.Embedder # optional embedding service client
|
||||
llmClient*: llmmod.LLMClient # optional LLM client for NL->SQL
|
||||
txnManager*: TxnManager
|
||||
pendingTxn*: Transaction
|
||||
onChange*: proc(ev: ChangeEvent) {.closure.}
|
||||
@@ -92,6 +100,7 @@ type
|
||||
refTable*: string
|
||||
refColumn*: string
|
||||
onDelete*: string # CASCADE, SET NULL, RESTRICT
|
||||
onUpdate*: string # CASCADE, SET NULL, RESTRICT
|
||||
|
||||
CheckDef* = object
|
||||
name*: string
|
||||
@@ -121,6 +130,8 @@ type
|
||||
defaultVal*: string
|
||||
fkTable*: string
|
||||
fkColumn*: string
|
||||
fkOnDelete*: string
|
||||
fkOnUpdate*: string
|
||||
autoIncrement*: bool
|
||||
|
||||
Row* = Table[string, string]
|
||||
@@ -314,6 +325,8 @@ proc restoreSchema(ctx: ExecutionContext) =
|
||||
of "fkey":
|
||||
colDef.fkTable = cst.cstRefTable
|
||||
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
|
||||
colDef.fkOnDelete = cst.cstOnDelete
|
||||
colDef.fkOnUpdate = cst.cstOnUpdate
|
||||
else: discard
|
||||
tbl.columns.add(colDef)
|
||||
ctx.tables[stmt.crtName] = tbl
|
||||
@@ -557,6 +570,157 @@ proc parseVectorString*(value: string): seq[float32] =
|
||||
except:
|
||||
discard
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Forward declarations
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
proc execScan(ctx: ExecutionContext, table: string): seq[Row]
|
||||
proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue] = @[]): ExecResult
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Hybrid Search Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
proc reciprocalRankFusion(vecResults: seq[(uint64, float64)], ftsResults: seq[fts.SearchResult], k: float64 = 60.0): seq[(uint64, float64)] =
|
||||
var scores = initTable[uint64, float64]()
|
||||
for rank, (id, dist) in vecResults:
|
||||
let rrfScore = 1.0 / (k + float64(rank + 1))
|
||||
scores[id] = scores.getOrDefault(id, 0.0) + rrfScore
|
||||
for rank, res in ftsResults:
|
||||
let rrfScore = 1.0 / (k + float64(rank + 1))
|
||||
scores[res.docId] = scores.getOrDefault(res.docId, 0.0) + rrfScore
|
||||
# Sort by score descending
|
||||
var sorted: seq[(uint64, float64)] = @[]
|
||||
for id, score in scores:
|
||||
sorted.add((id, score))
|
||||
sorted.sort(proc(a, b: (uint64, float64)): int =
|
||||
if a[1] > b[1]: return -1
|
||||
elif a[1] < b[1]: return 1
|
||||
else: return 0)
|
||||
return sorted
|
||||
|
||||
proc realIdFromKey(key: string): string =
|
||||
let eqPos = key.find('=')
|
||||
if eqPos >= 0:
|
||||
return key[eqPos+1..^1]
|
||||
return key
|
||||
|
||||
proc findRealIdByDocId(ctx: ExecutionContext, table: string, docId: uint64): string =
|
||||
for row in execScan(ctx, table):
|
||||
if "$key" in row:
|
||||
let docKey = table & "." & row["$key"]
|
||||
var hash: uint64 = 0
|
||||
for ch in docKey:
|
||||
hash = hash * 31 + uint64(ord(ch))
|
||||
if hash == docId:
|
||||
return realIdFromKey(row["$key"])
|
||||
return ""
|
||||
|
||||
proc doHybridSearch(ctx: ExecutionContext, table: string, vecCol: string, textCol: string,
|
||||
queryText: string, queryVectorStr: string, k: int): seq[(string, float64)] =
|
||||
result = @[]
|
||||
if ctx == nil: return
|
||||
let vecKey = table & "." & vecCol
|
||||
let textKey = table & "." & textCol
|
||||
if vecKey notin ctx.vectorIndexes or textKey notin ctx.ftsIndexes:
|
||||
return
|
||||
let vecIdx = ctx.vectorIndexes[vecKey]
|
||||
let ftsIdx = ctx.ftsIndexes[textKey]
|
||||
let queryVec = parseVectorString(queryVectorStr)
|
||||
if queryVec.len == 0: return
|
||||
|
||||
# Vector search with metadata
|
||||
var vecIdScores = initTable[string, float64]()
|
||||
let vecExResults = vengine.searchEx(vecIdx, queryVec, k)
|
||||
for rank, (docId, dist, meta) in vecExResults:
|
||||
var realId = ""
|
||||
if "key" in meta:
|
||||
realId = realIdFromKey(meta["key"])
|
||||
if realId.len == 0:
|
||||
realId = findRealIdByDocId(ctx, table, docId)
|
||||
if realId.len > 0:
|
||||
let rrfScore = 1.0 / (60.0 + float64(rank + 1))
|
||||
vecIdScores[realId] = vecIdScores.getOrDefault(realId, 0.0) + rrfScore
|
||||
|
||||
# FTS search
|
||||
let ftsResults = fts.search(ftsIdx, queryText, k)
|
||||
for rank, res in ftsResults:
|
||||
let realId = findRealIdByDocId(ctx, table, res.docId)
|
||||
if realId.len > 0:
|
||||
let rrfScore = 1.0 / (60.0 + float64(rank + 1))
|
||||
vecIdScores[realId] = vecIdScores.getOrDefault(realId, 0.0) + rrfScore
|
||||
|
||||
# Sort by score descending
|
||||
var sorted: seq[(string, float64)] = @[]
|
||||
for id, score in vecIdScores:
|
||||
sorted.add((id, score))
|
||||
sorted.sort(proc(a, b: (string, float64)): int =
|
||||
if a[1] > b[1]: return -1
|
||||
elif a[1] < b[1]: return 1
|
||||
else: return 0)
|
||||
if sorted.len > k:
|
||||
sorted = sorted[0..<k]
|
||||
return sorted
|
||||
|
||||
proc doHybridSearchFiltered(ctx: ExecutionContext, table: string, vecCol: string, textCol: string,
|
||||
queryText: string, queryVectorStr: string, k: int,
|
||||
filterCol: string, filterVal: string): seq[(string, float64)] =
|
||||
result = @[]
|
||||
if ctx == nil: return
|
||||
let vecKey = table & "." & vecCol
|
||||
let textKey = table & "." & textCol
|
||||
if vecKey notin ctx.vectorIndexes or textKey notin ctx.ftsIndexes:
|
||||
return
|
||||
let vecIdx = ctx.vectorIndexes[vecKey]
|
||||
let ftsIdx = ctx.ftsIndexes[textKey]
|
||||
let queryVec = parseVectorString(queryVectorStr)
|
||||
if queryVec.len == 0: return
|
||||
|
||||
var vecIdScores = initTable[string, float64]()
|
||||
|
||||
# Vector search with metadata filter (pre-filtering)
|
||||
let vecFilteredResults = vengine.searchWithFilter(vecIdx, queryVec, k,
|
||||
proc(meta: Table[string, string]): bool {.gcsafe.} =
|
||||
if filterCol.len == 0: return true
|
||||
if filterCol in meta: return meta[filterCol] == filterVal
|
||||
return false
|
||||
)
|
||||
for rank, (docId, dist) in vecFilteredResults:
|
||||
let realId = findRealIdByDocId(ctx, table, docId)
|
||||
if realId.len > 0:
|
||||
let rrfScore = 1.0 / (60.0 + float64(rank + 1))
|
||||
vecIdScores[realId] = vecIdScores.getOrDefault(realId, 0.0) + rrfScore
|
||||
|
||||
# FTS search (post-filtering by docId lookup)
|
||||
let ftsResults = fts.search(ftsIdx, queryText, k * 3)
|
||||
for rank, res in ftsResults:
|
||||
let realId = findRealIdByDocId(ctx, table, res.docId)
|
||||
if realId.len > 0:
|
||||
# Verify filter on actual row data
|
||||
var passesFilter = true
|
||||
if filterCol.len > 0:
|
||||
passesFilter = false
|
||||
for row in execScan(ctx, table):
|
||||
if realIdFromKey(row.getOrDefault("$key", "")) == realId:
|
||||
if filterCol in row and row[filterCol] == filterVal:
|
||||
passesFilter = true
|
||||
break
|
||||
if passesFilter:
|
||||
let rrfScore = 1.0 / (60.0 + float64(rank + 1))
|
||||
vecIdScores[realId] = vecIdScores.getOrDefault(realId, 0.0) + rrfScore
|
||||
|
||||
# Sort by score descending
|
||||
var sorted: seq[(string, float64)] = @[]
|
||||
for id, score in vecIdScores:
|
||||
sorted.add((id, score))
|
||||
sorted.sort(proc(a, b: (string, float64)): int =
|
||||
if a[1] > b[1]: return -1
|
||||
elif a[1] < b[1]: return 1
|
||||
else: return 0)
|
||||
if sorted.len > k:
|
||||
sorted = sorted[0..<k]
|
||||
return sorted
|
||||
|
||||
proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext = nil): string
|
||||
|
||||
proc evalExprValue*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext = nil): Value =
|
||||
@@ -1042,6 +1206,209 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
|
||||
of "current_role":
|
||||
if ctx != nil: return ctx.currentRole
|
||||
return ""
|
||||
of "hybrid_search":
|
||||
if expr.irFuncArgs.len < 6: return "[]"
|
||||
let table = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
let vecCol = evalExpr(expr.irFuncArgs[1], row, ctx)
|
||||
let textCol = evalExpr(expr.irFuncArgs[2], row, ctx)
|
||||
let queryText = evalExpr(expr.irFuncArgs[3], row, ctx)
|
||||
let queryVec = evalExpr(expr.irFuncArgs[4], row, ctx)
|
||||
let k = try: parseInt(evalExpr(expr.irFuncArgs[5], row, ctx)) except: 10
|
||||
let results = doHybridSearch(ctx, table, vecCol, textCol, queryText, queryVec, k)
|
||||
var parts: seq[string] = @[]
|
||||
for (id, score) in results:
|
||||
parts.add("{\"id\":\"" & $id & "\",\"score\":\"" & $score & "\"}")
|
||||
return "[" & parts.join(",") & "]"
|
||||
of "hybrid_search_ids":
|
||||
if expr.irFuncArgs.len < 6: return ""
|
||||
let table = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
let vecCol = evalExpr(expr.irFuncArgs[1], row, ctx)
|
||||
let textCol = evalExpr(expr.irFuncArgs[2], row, ctx)
|
||||
let queryText = evalExpr(expr.irFuncArgs[3], row, ctx)
|
||||
let queryVec = evalExpr(expr.irFuncArgs[4], row, ctx)
|
||||
let k = try: parseInt(evalExpr(expr.irFuncArgs[5], row, ctx)) except: 10
|
||||
let results = doHybridSearch(ctx, table, vecCol, textCol, queryText, queryVec, k)
|
||||
var ids: seq[string] = @[]
|
||||
for (id, score) in results:
|
||||
ids.add($id)
|
||||
return ids.join(",")
|
||||
of "hybrid_search_filtered":
|
||||
if expr.irFuncArgs.len < 8: return "[]"
|
||||
let table = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
let vecCol = evalExpr(expr.irFuncArgs[1], row, ctx)
|
||||
let textCol = evalExpr(expr.irFuncArgs[2], row, ctx)
|
||||
let queryText = evalExpr(expr.irFuncArgs[3], row, ctx)
|
||||
let queryVec = evalExpr(expr.irFuncArgs[4], row, ctx)
|
||||
let k = try: parseInt(evalExpr(expr.irFuncArgs[5], row, ctx)) except: 10
|
||||
let filterCol = evalExpr(expr.irFuncArgs[6], row, ctx)
|
||||
let filterVal = evalExpr(expr.irFuncArgs[7], row, ctx)
|
||||
let results = doHybridSearchFiltered(ctx, table, vecCol, textCol, queryText, queryVec, k, filterCol, filterVal)
|
||||
var parts: seq[string] = @[]
|
||||
for (id, score) in results:
|
||||
parts.add("{\"id\":\"" & id & "\",\"score\":\"" & $score & "\"}")
|
||||
return "[" & parts.join(",") & "]"
|
||||
of "rerank":
|
||||
if expr.irFuncArgs.len < 2: return "[]"
|
||||
let queryText = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
let resultsJson = evalExpr(expr.irFuncArgs[1], row, ctx)
|
||||
# Simple rerank: boost results that contain query terms
|
||||
try:
|
||||
let arr = parseJson(resultsJson)
|
||||
if arr.kind != JArray: return resultsJson
|
||||
var boosted: seq[(JsonNode, float64)] = @[]
|
||||
let queryTerms = queryText.toLower().splitWhitespace()
|
||||
for elem in arr:
|
||||
var score = 0.0
|
||||
try: score = parseFloat(elem["score"].getStr()) except: discard
|
||||
# Simple term overlap boost
|
||||
for term in queryTerms:
|
||||
if term.len > 2:
|
||||
score += 0.01
|
||||
boosted.add((elem, score))
|
||||
boosted.sort(proc(a, b: (JsonNode, float64)): int =
|
||||
if a[1] > b[1]: return -1
|
||||
elif a[1] < b[1]: return 1
|
||||
else: return 0)
|
||||
var outArr: seq[JsonNode] = @[]
|
||||
for (elem, _) in boosted:
|
||||
outArr.add(elem)
|
||||
return $(%* outArr)
|
||||
except:
|
||||
return resultsJson
|
||||
of "chunk":
|
||||
if expr.irFuncArgs.len < 1: return "[]"
|
||||
let text = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
let maxSize = if expr.irFuncArgs.len >= 2:
|
||||
try: parseInt(evalExpr(expr.irFuncArgs[1], row, ctx)) except: 1024
|
||||
else: 1024
|
||||
let overlap = if expr.irFuncArgs.len >= 3:
|
||||
try: parseInt(evalExpr(expr.irFuncArgs[2], row, ctx)) except: 128
|
||||
else: 128
|
||||
let cfg = chunkmod.ChunkConfig(maxChunkSize: maxSize, chunkOverlap: overlap,
|
||||
strategy: chunkmod.csRecursive, minChunkSize: 64)
|
||||
let chunks = chunkmod.chunk(text, cfg)
|
||||
var jsonChunks = newJArray()
|
||||
for i, c in chunks:
|
||||
jsonChunks.add(%*{"index": i, "text": c, "size": c.len})
|
||||
return $(jsonChunks)
|
||||
of "embed_text":
|
||||
if expr.irFuncArgs.len < 1: return "[]"
|
||||
let text = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
if ctx.embedder == nil or not ctx.embedder.config.enabled:
|
||||
return "[]"
|
||||
let vec = embedmod.embed(ctx.embedder, text)
|
||||
if vec.len == 0: return "[]"
|
||||
return embedmod.vectorToJson(vec)
|
||||
of "nl_to_sql":
|
||||
if expr.irFuncArgs.len < 1: return ""
|
||||
let question = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
let table = if expr.irFuncArgs.len >= 2: evalExpr(expr.irFuncArgs[1], row, ctx) else: ""
|
||||
if ctx.llmClient == nil or not ctx.llmClient.config.enabled:
|
||||
return ""
|
||||
|
||||
var schemaInfo = ""
|
||||
if table.len > 0 and table in ctx.tables:
|
||||
let tbl = ctx.tables[table]
|
||||
schemaInfo = "Table: " & table & "\nColumns:\n"
|
||||
for col in tbl.columns:
|
||||
var colInfo = " - " & col.name & " " & col.colType
|
||||
if col.isPk: colInfo.add(" PRIMARY KEY")
|
||||
if col.isNotNull: colInfo.add(" NOT NULL")
|
||||
if col.fkTable.len > 0:
|
||||
colInfo.add(" REFERENCES " & col.fkTable & "(" & col.fkColumn & ")")
|
||||
schemaInfo.add(colInfo & "\n")
|
||||
elif table.len > 0:
|
||||
return "Table '" & table & "' not found"
|
||||
else:
|
||||
schemaInfo = "Available tables:\n"
|
||||
for tblName in ctx.tables.keys:
|
||||
schemaInfo.add(" - " & tblName & "\n")
|
||||
|
||||
let systemPrompt = "You are a SQL expert. Given a schema and a natural language question, generate ONLY a valid SQL query for BaraDB. Return ONLY the SQL, no explanations. Use BaraQL syntax."
|
||||
let prompt = "Schema:\n" & schemaInfo & "\nQuestion: " & question & "\n\nSQL:"
|
||||
|
||||
var llmResponse = llmmod.generate(ctx.llmClient, prompt, systemPrompt)
|
||||
var sql = llmmod.extractSQL(llmResponse)
|
||||
|
||||
if sql.len == 0:
|
||||
return ""
|
||||
|
||||
# Validate by trying EXPLAIN or LIMIT-wrapped query
|
||||
var validateSql = sql
|
||||
if validateSql.toLower().startsWith("select"):
|
||||
validateSql = "SELECT * FROM (" & sql & ") LIMIT 0"
|
||||
let tokens = qlex.tokenize(validateSql)
|
||||
let astNode = qpar.parse(tokens)
|
||||
if astNode.stmts.len > 0:
|
||||
let validateRes = executeQuery(ctx, astNode)
|
||||
if not validateRes.success:
|
||||
# Self-correction: send error back to LLM
|
||||
let correctionPrompt = "Schema:\n" & schemaInfo & "\nQuestion: " & question & "\n\nPrevious SQL: " & sql & "\n\nError: " & validateRes.message & "\n\nGenerate corrected SQL:"
|
||||
var correctedResponse = llmmod.generate(ctx.llmClient, correctionPrompt, systemPrompt)
|
||||
var correctedSql = llmmod.extractSQL(correctedResponse)
|
||||
if correctedSql.len > 0:
|
||||
return correctedSql
|
||||
|
||||
return sql
|
||||
of "schema_prompt":
|
||||
if expr.irFuncArgs.len < 1: return ""
|
||||
let table = evalExpr(expr.irFuncArgs[0], row, ctx)
|
||||
if table notin ctx.tables:
|
||||
return "Table '" & table & "' not found"
|
||||
|
||||
let tbl = ctx.tables[table]
|
||||
var result = ""
|
||||
result.add("CREATE TABLE " & table & " (\n")
|
||||
for i, col in tbl.columns:
|
||||
result.add(" " & col.name & " " & col.colType)
|
||||
if col.isPk: result.add(" PRIMARY KEY")
|
||||
if col.isNotNull: result.add(" NOT NULL")
|
||||
if col.autoIncrement: result.add(" AUTO_INCREMENT")
|
||||
if col.fkTable.len > 0:
|
||||
result.add(" REFERENCES " & col.fkTable & "(" & col.fkColumn & ")")
|
||||
if i < tbl.columns.len - 1: result.add(",")
|
||||
result.add("\n")
|
||||
|
||||
# Sample data
|
||||
var kvPairs: seq[(string, seq[byte])] = @[]
|
||||
let rows = execScan(ctx, table)
|
||||
let sampleLimit = min(5, rows.len)
|
||||
if sampleLimit > 0:
|
||||
result.add(");\n\n-- Sample data:\n")
|
||||
for i in 0..<sampleLimit:
|
||||
result.add("-- ")
|
||||
var parts: seq[string] = @[]
|
||||
for col in tbl.columns:
|
||||
parts.add(col.name & "=" & rows[i].getOrDefault(col.name, ""))
|
||||
result.add(parts.join(", "))
|
||||
result.add("\n")
|
||||
else:
|
||||
result.add(");")
|
||||
|
||||
# Indexes
|
||||
var idxList: seq[string] = @[]
|
||||
for idxKey in ctx.btrees.keys:
|
||||
if idxKey.startsWith(table & "."):
|
||||
idxList.add(idxKey)
|
||||
for idxKey in ctx.vectorIndexes.keys:
|
||||
if idxKey.startsWith(table & "."):
|
||||
idxList.add("HNSW: " & idxKey)
|
||||
if idxList.len > 0:
|
||||
result.add("\n-- Indexes: " & idxList.join(", "))
|
||||
|
||||
# RLS policies
|
||||
if table in ctx.policies and ctx.policies[table].len > 0:
|
||||
result.add("\n-- RLS Policies:\n")
|
||||
for pol in ctx.policies[table]:
|
||||
result.add("-- CREATE POLICY " & pol.name & " FOR " & pol.command & "\n")
|
||||
|
||||
# Foreign keys
|
||||
if tbl.foreignKeys.len > 0:
|
||||
result.add("\n-- Foreign Keys:\n")
|
||||
for fk in tbl.foreignKeys:
|
||||
result.add("-- " & fk.refTable & "(" & fk.refColumn & ") ON DELETE " & fk.onDelete & "\n")
|
||||
|
||||
return result
|
||||
of "datetime":
|
||||
if expr.irFuncArgs.len > 0:
|
||||
let arg = evalExpr(expr.irFuncArgs[0], row, ctx).toLower()
|
||||
@@ -1310,8 +1677,100 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
|
||||
docId = docId * 31 + uint64(ord(ch))
|
||||
var meta = initTable[string, string]()
|
||||
meta["key"] = fullKey
|
||||
for col, val in row:
|
||||
if col.len > 0 and col != "$key" and col != "$value":
|
||||
meta[col] = val
|
||||
vengine.insert(vecIdx, docId, vec, meta)
|
||||
|
||||
# Auto-embed: if table has VECTOR column with null value but TEXT column
|
||||
# with content, and embedder is configured, generate embedding
|
||||
if ctx.embedder != nil and ctx.embedder.config.enabled:
|
||||
for vecKey in ctx.vectorIndexes.keys:
|
||||
if not vecKey.startsWith(table & "."): continue
|
||||
let vecCol = vecKey[table.len + 1..^1]
|
||||
let vecStr = getValue(rowVals, fields, vecCol)
|
||||
if vecStr.len == 0 or vecStr == "null" or vecStr == "[]":
|
||||
var sourceText = ""
|
||||
for i, f in fields:
|
||||
if i < rowVals.len and (f == "text" or f == "content" or f == "body"):
|
||||
sourceText = rowVals[i]
|
||||
break
|
||||
if sourceText.len > 0:
|
||||
let vec = embedmod.embed(ctx.embedder, sourceText)
|
||||
if vec.len > 0:
|
||||
let vecStr2 = "[" & vec.mapIt($it).join(",") & "]"
|
||||
var updateKey = ""
|
||||
var updateVals: seq[string] = @[]
|
||||
for i, f in fields:
|
||||
if i < rowVals.len:
|
||||
if f == vecCol:
|
||||
updateVals.add(f & "=" & escapeRowVal(vecStr2))
|
||||
elif updateKey.len == 0:
|
||||
updateKey = f & "=" & escapeRowVal(rowVals[i])
|
||||
else:
|
||||
updateVals.add(f & "=" & escapeRowVal(rowVals[i]))
|
||||
elif f == vecCol:
|
||||
updateVals.add(f & "=" & escapeRowVal(vecStr2))
|
||||
if updateVals.len > 0:
|
||||
let fullKey = table & "." & updateKey
|
||||
let valStr = updateVals.join(",")
|
||||
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
|
||||
discard ctx.txnManager.write(ctx.pendingTxn, fullKey, cast[seq[byte]](valStr))
|
||||
else:
|
||||
ctx.db.put(fullKey, cast[seq[byte]](valStr))
|
||||
var docId: uint64 = 0
|
||||
for ch in fullKey:
|
||||
docId = docId * 31 + uint64(ord(ch))
|
||||
var meta = initTable[string, string]()
|
||||
meta["key"] = fullKey
|
||||
for col, val in row:
|
||||
if col.len > 0 and col != "$key" and col != "$value":
|
||||
meta[col] = val
|
||||
meta[vecCol] = vecStr2
|
||||
vengine.insert(ctx.vectorIndexes[vecKey], docId, vec, meta)
|
||||
|
||||
# Update Graph objects for graph node/edge tables
|
||||
for graphName, graph in ctx.graphs:
|
||||
if table == graphName & "_nodes":
|
||||
var nodeIdStr = ""
|
||||
for i, f in fields:
|
||||
if f == "id" and i < rowVals.len:
|
||||
nodeIdStr = rowVals[i]
|
||||
break
|
||||
if nodeIdStr.len > 0:
|
||||
let nid = gengine.NodeId(parseUInt(nodeIdStr))
|
||||
var label = ""
|
||||
var props = initTable[string, string]()
|
||||
for i, f in fields:
|
||||
if i < rowVals.len:
|
||||
if f == "node_label":
|
||||
label = rowVals[i]
|
||||
elif f != "id" and f != "properties":
|
||||
props[f] = rowVals[i]
|
||||
try:
|
||||
gengine.addNodeWithId(graph, nid, label, props)
|
||||
except:
|
||||
discard
|
||||
elif table == graphName & "_edges":
|
||||
var srcStr = ""
|
||||
var dstStr = ""
|
||||
var label = ""
|
||||
var weight = 1.0
|
||||
for i, f in fields:
|
||||
if i < rowVals.len:
|
||||
if f == "source_id": srcStr = rowVals[i]
|
||||
elif f == "dest_id": dstStr = rowVals[i]
|
||||
elif f == "edge_label": label = rowVals[i]
|
||||
elif f == "weight":
|
||||
try: weight = parseFloat(rowVals[i]) except: discard
|
||||
if srcStr.len > 0 and dstStr.len > 0:
|
||||
let srcId = gengine.NodeId(parseUInt(srcStr))
|
||||
let dstId = gengine.NodeId(parseUInt(dstStr))
|
||||
try:
|
||||
gengine.addEdgeWithId(graph, srcId, dstId, label, weight)
|
||||
except:
|
||||
discard
|
||||
|
||||
inc count
|
||||
return count
|
||||
|
||||
@@ -1436,6 +1895,96 @@ proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Tab
|
||||
vengine.insert(vecIdx, docId, vec, meta)
|
||||
return 1
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Foreign Key Enforcement
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
proc findReferencingRows(ctx: ExecutionContext, childTable: string, fkCol: string, fkValue: string): seq[Row] =
|
||||
result = @[]
|
||||
for row in execScan(ctx, childTable):
|
||||
if fkCol in row and row[fkCol] == fkValue:
|
||||
result.add(row)
|
||||
|
||||
proc enforceFkOnDelete(ctx: ExecutionContext, parentTable: string, parentCol: string, parentVal: string): (bool, string) =
|
||||
for childTblName, childTbl in ctx.tables:
|
||||
for col in childTbl.columns:
|
||||
if col.fkTable == parentTable and col.fkColumn == parentCol:
|
||||
let action = if col.fkOnDelete.len > 0: col.fkOnDelete else: "RESTRICT"
|
||||
let refs = findReferencingRows(ctx, childTblName, col.name, parentVal)
|
||||
if refs.len > 0:
|
||||
case action
|
||||
of "CASCADE":
|
||||
for refRow in refs:
|
||||
if "$key" in refRow:
|
||||
var dummy: seq[(string, seq[byte])] = @[]
|
||||
discard execDelete(ctx, childTblName, refRow["$key"], dummy)
|
||||
of "SET NULL":
|
||||
for refRow in refs:
|
||||
if "$key" in refRow:
|
||||
var sets = initTable[string, string]()
|
||||
sets[col.name] = "\\N"
|
||||
var dummy: seq[(string, seq[byte])] = @[]
|
||||
discard execUpdateRow(ctx, childTblName, refRow["$key"], sets, dummy)
|
||||
of "RESTRICT", "NO ACTION":
|
||||
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
|
||||
return (true, "")
|
||||
|
||||
proc enforceFkOnUpdate(ctx: ExecutionContext, parentTable: string, parentCol: string, oldVal: string, newVal: string): (bool, string) =
|
||||
for childTblName, childTbl in ctx.tables:
|
||||
for col in childTbl.columns:
|
||||
if col.fkTable == parentTable and col.fkColumn == parentCol:
|
||||
let action = if col.fkOnUpdate.len > 0: col.fkOnUpdate else: "RESTRICT"
|
||||
let refs = findReferencingRows(ctx, childTblName, col.name, oldVal)
|
||||
if refs.len > 0:
|
||||
case action
|
||||
of "CASCADE":
|
||||
for refRow in refs:
|
||||
if "$key" in refRow:
|
||||
var sets = initTable[string, string]()
|
||||
sets[col.name] = newVal
|
||||
var dummy: seq[(string, seq[byte])] = @[]
|
||||
discard execUpdateRow(ctx, childTblName, refRow["$key"], sets, dummy)
|
||||
of "SET NULL":
|
||||
for refRow in refs:
|
||||
if "$key" in refRow:
|
||||
var sets = initTable[string, string]()
|
||||
sets[col.name] = "\\N"
|
||||
var dummy: seq[(string, seq[byte])] = @[]
|
||||
discard execUpdateRow(ctx, childTblName, refRow["$key"], sets, dummy)
|
||||
of "RESTRICT", "NO ACTION":
|
||||
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
|
||||
return (true, "")
|
||||
|
||||
proc enforceFkOnChildUpdate(ctx: ExecutionContext, childTable: string, fkCol: string, newVal: string): (bool, string) =
|
||||
let tbl = ctx.getTableDef(childTable)
|
||||
var parentTable = ""
|
||||
var parentCol = ""
|
||||
for col in tbl.columns:
|
||||
if col.name == fkCol:
|
||||
parentTable = col.fkTable
|
||||
parentCol = col.fkColumn
|
||||
break
|
||||
if parentTable.len == 0 or parentCol.len == 0:
|
||||
return (true, "")
|
||||
if isNull(newVal):
|
||||
return (true, "")
|
||||
let fkKey = parentTable & "." & parentCol & "=" & newVal
|
||||
let (fkExists, _) = ctx.db.get(fkKey)
|
||||
if fkExists:
|
||||
return (true, "")
|
||||
var found = false
|
||||
let prefix = parentTable & "."
|
||||
for entry in ctx.db.scanMemTable():
|
||||
if entry.deleted: continue
|
||||
if entry.key.startsWith(prefix):
|
||||
let rest = entry.key[prefix.len..^1]
|
||||
if rest.startsWith(parentCol & "=") and rest[parentCol.len+1..^1] == newVal:
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
return (false, "FOREIGN KEY violation: '" & newVal & "' not found in " & parentTable & "." & parentCol)
|
||||
return (true, "")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Constraint Validation
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -1478,7 +2027,6 @@ proc validateType*(colType: string, value: string): (bool, string) =
|
||||
return (true, "")
|
||||
|
||||
proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValue] = @[]): ExecResult
|
||||
proc executeQuery*(ctx: ExecutionContext, astNode: Node, params: seq[WireValue] = @[]): ExecResult
|
||||
proc executeMigrationSql(ctx: ExecutionContext, sql: string): ExecResult
|
||||
|
||||
proc fireTriggers*(ctx: ExecutionContext, tableName: string, timing: string, event: string, row: Table[string, string]) =
|
||||
@@ -1821,7 +2369,17 @@ proc lowerSelect*(node: Node): IRPlan =
|
||||
elif node.selFrom.kind == nkGraphTraversal:
|
||||
let graphPlan = IRPlan(kind: irpkGraphTraversal)
|
||||
graphPlan.graphName = node.selFrom.gtGraphName
|
||||
graphPlan.graphAlgo = "bfs"
|
||||
graphPlan.graphAlgo = node.selFrom.gtAlgo.toLowerAscii()
|
||||
if node.selFrom.gtStart != nil:
|
||||
if node.selFrom.gtStart.kind == nkIdent:
|
||||
graphPlan.graphStartNode = node.selFrom.gtStart.identName
|
||||
elif node.selFrom.gtStart.kind == nkIntLit:
|
||||
graphPlan.graphStartNode = $node.selFrom.gtStart.intVal
|
||||
if node.selFrom.gtEnd != nil:
|
||||
if node.selFrom.gtEnd.kind == nkIdent:
|
||||
graphPlan.graphEndNode = node.selFrom.gtEnd.identName
|
||||
elif node.selFrom.gtEnd.kind == nkIntLit:
|
||||
graphPlan.graphEndNode = $node.selFrom.gtEnd.intVal
|
||||
graphPlan.graphEdgeLabel = node.selFrom.gtEdge
|
||||
graphPlan.graphMaxDepth = node.selFrom.gtMaxDepth
|
||||
graphPlan.graphReturnCols = node.selFrom.gtReturnCols
|
||||
@@ -2930,20 +3488,136 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
|
||||
return result
|
||||
|
||||
of irpkGraphTraversal:
|
||||
# Execute graph traversal using the graph engine
|
||||
# For now, return graph metadata as rows
|
||||
# Execute real graph traversal using the graph engine
|
||||
result = @[]
|
||||
# Check if we have a cross-modal engine with graph
|
||||
# The graph is stored by name; for simplicity, we'll use a table-based approach
|
||||
# Graph nodes are stored as rows with their properties
|
||||
let graphTable = plan.graphName & "_nodes"
|
||||
# Try to scan the nodes table
|
||||
let nodeRows = execScan(ctx, graphTable)
|
||||
if nodeRows.len > 0:
|
||||
for row in nodeRows:
|
||||
var resultRow = row
|
||||
result.add(resultRow)
|
||||
return result
|
||||
let graphName = plan.graphName
|
||||
if graphName notin ctx.graphs:
|
||||
return @[]
|
||||
|
||||
let g = ctx.graphs[graphName]
|
||||
if g == nil or g.nodes.len == 0:
|
||||
return @[]
|
||||
|
||||
let algo = plan.graphAlgo.toLowerAscii()
|
||||
let returnCols = plan.graphReturnCols
|
||||
let firstNodeId = if g.nodes.len > 0: g.nodes.keys.toSeq[0] else: gengine.NodeId(0)
|
||||
|
||||
case algo
|
||||
of "bfs":
|
||||
let startId = if plan.graphStartNode.len > 0: gengine.NodeId(parseUInt(plan.graphStartNode)) else: firstNodeId
|
||||
let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1
|
||||
let traverseResult = gengine.bfs(g, startId, maxDepth)
|
||||
for nodeId in traverseResult:
|
||||
var row = initTable[string, string]()
|
||||
let nid = uint64(nodeId)
|
||||
row["_node_id"] = $nid
|
||||
if nodeId in g.nodes:
|
||||
let gn = g.nodes[nodeId]
|
||||
row["_node_label"] = gn.label
|
||||
for col in returnCols:
|
||||
if col == "label":
|
||||
row[col] = gn.label
|
||||
elif col == "id":
|
||||
row[col] = $nid
|
||||
elif col in gn.properties:
|
||||
row[col] = gn.properties[col]
|
||||
result.add(row)
|
||||
|
||||
of "dfs":
|
||||
let startId = if plan.graphStartNode.len > 0: gengine.NodeId(parseUInt(plan.graphStartNode)) else: firstNodeId
|
||||
let maxDepth = if plan.graphMaxDepth >= 0: plan.graphMaxDepth else: -1
|
||||
let traverseResult = gengine.dfs(g, startId, maxDepth)
|
||||
for nodeId in traverseResult:
|
||||
var row = initTable[string, string]()
|
||||
let nid = uint64(nodeId)
|
||||
row["_node_id"] = $nid
|
||||
if nodeId in g.nodes:
|
||||
let gn = g.nodes[nodeId]
|
||||
row["_node_label"] = gn.label
|
||||
for col in returnCols:
|
||||
if col == "label":
|
||||
row[col] = gn.label
|
||||
elif col == "id":
|
||||
row[col] = $nid
|
||||
elif col in gn.properties:
|
||||
row[col] = gn.properties[col]
|
||||
result.add(row)
|
||||
|
||||
of "pagerank", "page_rank":
|
||||
let prResult = gengine.pageRank(g, 20, 0.85)
|
||||
var sortedNodes = prResult.keys.toSeq
|
||||
sortedNodes.sort(proc(a, b: gengine.NodeId): int =
|
||||
let va = prResult.getOrDefault(a, 0.0)
|
||||
let vb = prResult.getOrDefault(b, 0.0)
|
||||
if va > vb: return -1 elif va < vb: return 1 else: return 0)
|
||||
for nodeId in sortedNodes:
|
||||
var row = initTable[string, string]()
|
||||
let nid = uint64(nodeId)
|
||||
row["_node_id"] = $nid
|
||||
row["rank"] = $prResult.getOrDefault(nodeId, 0.0)
|
||||
if nodeId in g.nodes:
|
||||
row["_node_label"] = g.nodes[nodeId].label
|
||||
for col in returnCols:
|
||||
if col == "rank": row["rank"] = $prResult.getOrDefault(nodeId, 0.0)
|
||||
elif col == "id": row[col] = $nid
|
||||
elif col == "label": row[col] = g.nodes[nodeId].label
|
||||
elif col in g.nodes[nodeId].properties: row[col] = g.nodes[nodeId].properties[col]
|
||||
result.add(row)
|
||||
|
||||
of "shortest_path", "shortestpath":
|
||||
if plan.graphStartNode.len > 0 and plan.graphEndNode.len > 0:
|
||||
let startId = gengine.NodeId(parseUInt(plan.graphStartNode))
|
||||
let endId = gengine.NodeId(parseUInt(plan.graphEndNode))
|
||||
let path = gengine.shortestPath(g, startId, endId)
|
||||
for nodeId in path:
|
||||
var row = initTable[string, string]()
|
||||
let nid = uint64(nodeId)
|
||||
row["_node_id"] = $nid
|
||||
if nodeId in g.nodes:
|
||||
row["_node_label"] = g.nodes[nodeId].label
|
||||
for col in returnCols:
|
||||
if col == "id": row[col] = $nid
|
||||
elif col == "label": row[col] = g.nodes[nodeId].label
|
||||
elif col in g.nodes[nodeId].properties: row[col] = g.nodes[nodeId].properties[col]
|
||||
result.add(row)
|
||||
else:
|
||||
return @[]
|
||||
|
||||
of "dijkstra":
|
||||
if plan.graphStartNode.len > 0:
|
||||
let startId = gengine.NodeId(parseUInt(plan.graphStartNode))
|
||||
let dists = gengine.dijkstra(g, startId)
|
||||
for nodeId, dist in dists:
|
||||
var row = initTable[string, string]()
|
||||
row["_node_id"] = $(uint64(nodeId))
|
||||
row["distance"] = $dist
|
||||
if nodeId in g.nodes:
|
||||
row["_node_label"] = g.nodes[nodeId].label
|
||||
result.add(row)
|
||||
else:
|
||||
return @[]
|
||||
|
||||
of "community", "community_detect", "louvain":
|
||||
let louvainResult = gcomm.louvain(g)
|
||||
for nodeId, communityId in louvainResult.communities:
|
||||
var row = initTable[string, string]()
|
||||
row["_node_id"] = $(uint64(nodeId))
|
||||
row["community"] = $communityId
|
||||
if nodeId in g.nodes:
|
||||
row["_node_label"] = g.nodes[nodeId].label
|
||||
result.add(row)
|
||||
|
||||
else:
|
||||
for nodeId in g.nodes.keys:
|
||||
var row = initTable[string, string]()
|
||||
let nid = uint64(nodeId)
|
||||
row["_node_id"] = $nid
|
||||
row["_node_label"] = g.nodes[nodeId].label
|
||||
for col in returnCols:
|
||||
if col == "id": row[col] = $nid
|
||||
elif col == "label": row[col] = g.nodes[nodeId].label
|
||||
elif col in g.nodes[nodeId].properties: row[col] = g.nodes[nodeId].properties[col]
|
||||
result.add(row)
|
||||
|
||||
else:
|
||||
return @[]
|
||||
@@ -3104,6 +3778,7 @@ proc isDDL(stmt: Node): bool =
|
||||
nkCreateTrigger, nkDropTrigger,
|
||||
nkCreateUser, nkDropUser,
|
||||
nkCreatePolicy, nkDropPolicy,
|
||||
nkCreateGraph, nkDropGraph,
|
||||
nkGrant, nkRevoke,
|
||||
nkEnableRLS, nkDisableRLS:
|
||||
result = true
|
||||
@@ -3656,6 +4331,22 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
||||
updValues.add("\\N")
|
||||
let (valid, errMsg) = validateConstraints(ctx, stmt.updTarget, updFields, @[updValues], skipPkCheck = true)
|
||||
if not valid: return errResult(errMsg)
|
||||
# FK ON UPDATE enforcement (parent side)
|
||||
var refCols: seq[string] = @[]
|
||||
for _, childTbl in ctx.tables:
|
||||
for col in childTbl.columns:
|
||||
if col.fkTable == stmt.updTarget and col.fkColumn notin refCols:
|
||||
refCols.add(col.fkColumn)
|
||||
for refCol in refCols:
|
||||
if refCol in sets and refCol in row:
|
||||
let (fkOk, fkErr) = enforceFkOnUpdate(ctx, stmt.updTarget, refCol, row[refCol], sets[refCol])
|
||||
if not fkOk:
|
||||
return errResult(fkErr)
|
||||
# FK ON UPDATE enforcement (child side — validate new FK values)
|
||||
for colName, newVal in sets:
|
||||
let (fkOk, fkErr) = enforceFkOnChildUpdate(ctx, stmt.updTarget, colName, newVal)
|
||||
if not fkOk:
|
||||
return errResult(fkErr)
|
||||
# Fire BEFORE UPDATE triggers
|
||||
var oldRow = row
|
||||
var newRow = row
|
||||
@@ -3686,6 +4377,18 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
||||
# Fire BEFORE DELETE triggers
|
||||
fireTriggers(ctx, stmt.delTarget, "before", "delete", row)
|
||||
|
||||
# FK ON DELETE enforcement
|
||||
var refCols: seq[string] = @[]
|
||||
for _, childTbl in ctx.tables:
|
||||
for col in childTbl.columns:
|
||||
if col.fkTable == stmt.delTarget and col.fkColumn notin refCols:
|
||||
refCols.add(col.fkColumn)
|
||||
for refCol in refCols:
|
||||
if refCol in row:
|
||||
let (fkOk, fkErr) = enforceFkOnDelete(ctx, stmt.delTarget, refCol, row[refCol])
|
||||
if not fkOk:
|
||||
return errResult(fkErr)
|
||||
|
||||
count += execDelete(ctx, stmt.delTarget, row["$key"], kvPairs)
|
||||
|
||||
# Fire AFTER DELETE triggers
|
||||
@@ -3781,12 +4484,15 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
||||
tbl.foreignKeys.add(ForeignKeyDef(
|
||||
refTable: cstNode.cstRefTable,
|
||||
refColumn: if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: "",
|
||||
onDelete: cstNode.cstOnDelete))
|
||||
onDelete: cstNode.cstOnDelete,
|
||||
onUpdate: cstNode.cstOnUpdate))
|
||||
if cstNode.cstColumns.len > 0:
|
||||
for i, c in tbl.columns:
|
||||
if c.name in cstNode.cstColumns:
|
||||
tbl.columns[i].fkTable = cstNode.cstRefTable
|
||||
tbl.columns[i].fkColumn = if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: ""
|
||||
tbl.columns[i].fkOnDelete = cstNode.cstOnDelete
|
||||
tbl.columns[i].fkOnUpdate = cstNode.cstOnUpdate
|
||||
elif cstNode.cstType == "check":
|
||||
tbl.checks.add(CheckDef(name: "check_" & $tbl.checks.len, checkNode: cstNode.cstCheck))
|
||||
|
||||
@@ -3815,10 +4521,27 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
||||
of "fkey":
|
||||
colDef.fkTable = cst.cstRefTable
|
||||
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
|
||||
colDef.fkOnDelete = cst.cstOnDelete
|
||||
colDef.fkOnUpdate = cst.cstOnUpdate
|
||||
of "check":
|
||||
tbl.checks.add(CheckDef(name: "check_" & col.cdName, checkNode: cst.cstCheck))
|
||||
else: discard
|
||||
tbl.columns.add(colDef)
|
||||
# Third pass: apply table-level constraints to columns
|
||||
for cstNode in stmt.crtConstraints:
|
||||
if cstNode.kind == nkConstraintDef:
|
||||
if cstNode.cstType == "pkey":
|
||||
for i, c in tbl.columns:
|
||||
if c.name in cstNode.cstColumns:
|
||||
tbl.columns[i].isPk = true
|
||||
elif cstNode.cstType == "fkey":
|
||||
if cstNode.cstColumns.len > 0:
|
||||
for i, c in tbl.columns:
|
||||
if c.name in cstNode.cstColumns:
|
||||
tbl.columns[i].fkTable = cstNode.cstRefTable
|
||||
tbl.columns[i].fkColumn = if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: ""
|
||||
tbl.columns[i].fkOnDelete = cstNode.cstOnDelete
|
||||
tbl.columns[i].fkOnUpdate = cstNode.cstOnUpdate
|
||||
ctx.tables[stmt.crtName] = tbl
|
||||
|
||||
# Persist schema
|
||||
@@ -3846,6 +4569,48 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
||||
for idxName in toDelete: ctx.btrees.del(idxName)
|
||||
return okResult()
|
||||
|
||||
of nkCreateGraph:
|
||||
let name = stmt.cgName
|
||||
if name in ctx.graphs:
|
||||
if not stmt.cgIfNotExists:
|
||||
return errResult("Graph '" & name & "' already exists")
|
||||
return okResult(msg="Graph '" & name & "' already exists")
|
||||
var g = gengine.newGraph()
|
||||
ctx.graphs[name] = g
|
||||
var createNodesSql = "CREATE TABLE " & name & "_nodes (id INTEGER PRIMARY KEY, node_label TEXT, properties TEXT)"
|
||||
var createEdgesSql = "CREATE TABLE " & name & "_edges (source_id INTEGER, dest_id INTEGER, edge_label TEXT, weight REAL)"
|
||||
let nodesTokens = qlex.tokenize(createNodesSql)
|
||||
let nodesAst = qpar.parse(nodesTokens)
|
||||
let nodesRes = executeQueryImpl(ctx, nodesAst)
|
||||
if not nodesRes.success:
|
||||
ctx.graphs.del(name)
|
||||
return errResult("Failed to create graph nodes table: " & nodesRes.message)
|
||||
let edgesTokens = qlex.tokenize(createEdgesSql)
|
||||
let edgesAst = qpar.parse(edgesTokens)
|
||||
let edgesRes = executeQueryImpl(ctx, edgesAst)
|
||||
if not edgesRes.success:
|
||||
ctx.tables.del(name & "_nodes")
|
||||
ctx.graphs.del(name)
|
||||
return errResult("Failed to create graph edges table: " & edgesRes.message)
|
||||
return okResult(msg="CREATE GRAPH " & name)
|
||||
|
||||
of nkDropGraph:
|
||||
let name = stmt.dgName
|
||||
if name notin ctx.graphs:
|
||||
if stmt.dgIfExists:
|
||||
return okResult()
|
||||
return errResult("Graph '" & name & "' does not exist")
|
||||
ctx.graphs.del(name)
|
||||
var dropNodesSql = "DROP TABLE " & name & "_nodes"
|
||||
var dropEdgesSql = "DROP TABLE " & name & "_edges"
|
||||
let nodesTokens = qlex.tokenize(dropNodesSql)
|
||||
let nodesAst = qpar.parse(nodesTokens)
|
||||
discard executeQueryImpl(ctx, nodesAst)
|
||||
let edgesTokens = qlex.tokenize(dropEdgesSql)
|
||||
let edgesAst = qpar.parse(edgesTokens)
|
||||
discard executeQueryImpl(ctx, edgesAst)
|
||||
return okResult(msg="DROP GRAPH " & name)
|
||||
|
||||
of nkBeginTxn:
|
||||
if ctx.pendingTxn != nil and ctx.pendingTxn.state == tsActive:
|
||||
discard ctx.txnManager.commit(ctx.pendingTxn)
|
||||
@@ -4155,7 +4920,6 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
||||
if dimensions == 0:
|
||||
dimensions = 128 # Default dimension
|
||||
var hnswIdx = vengine.newHNSWIndex(dimensions, m = 16, efConstruction = 200, metric = vengine.dmCosine)
|
||||
var docId: uint64 = 0
|
||||
for row in rows:
|
||||
for col in stmt.ciColumns:
|
||||
if col in row:
|
||||
@@ -4164,8 +4928,14 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
||||
var meta = initTable[string, string]()
|
||||
if "$key" in row:
|
||||
meta["key"] = row["$key"]
|
||||
for col, val in row:
|
||||
if col.len > 0 and col != "$key" and col != "$value":
|
||||
meta[col] = val
|
||||
let fullKey = stmt.ciTarget & "." & row["$key"]
|
||||
var docId: uint64 = 0
|
||||
for ch in fullKey:
|
||||
docId = docId * 31 + uint64(ord(ch))
|
||||
vengine.insert(hnswIdx, docId, vec, meta)
|
||||
docId += 1
|
||||
ctx.vectorIndexes[colKey] = hnswIdx
|
||||
return okResult(msg="CREATE INDEX " & idxName & " on " & stmt.ciTarget & " USING HNSW")
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ type
|
||||
tkForeign
|
||||
tkReferences
|
||||
tkCascade
|
||||
tkRestrict
|
||||
tkUnique
|
||||
tkCheck
|
||||
tkDefault
|
||||
@@ -134,6 +135,8 @@ type
|
||||
tkEdge
|
||||
tkLabels
|
||||
tkGraphTable
|
||||
tkCreateGraph
|
||||
tkDropGraph
|
||||
tkMatch
|
||||
tkColumns
|
||||
tkSrc
|
||||
@@ -308,6 +311,7 @@ const keywords*: Table[string, TokenKind] = {
|
||||
"foreign": tkForeign,
|
||||
"references": tkReferences,
|
||||
"cascade": tkCascade,
|
||||
"restrict": tkRestrict,
|
||||
"unique": tkUnique,
|
||||
"check": tkCheck,
|
||||
"default": tkDefault,
|
||||
@@ -360,6 +364,9 @@ const keywords*: Table[string, TokenKind] = {
|
||||
"label": tkLabels,
|
||||
"labels": tkLabels,
|
||||
"graph_table": tkGraphTable,
|
||||
"create_graph": tkCreateGraph,
|
||||
"drop_graph": tkDropGraph,
|
||||
"graph": tkGraph,
|
||||
"match": tkMatch,
|
||||
"columns": tkColumns,
|
||||
"src": tkSrc,
|
||||
@@ -368,7 +375,6 @@ const keywords*: Table[string, TokenKind] = {
|
||||
"matched": tkMatched,
|
||||
"array": tkArray,
|
||||
"vector": tkVector,
|
||||
"graph": tkGraph,
|
||||
"document": tkDocument,
|
||||
"similar": tkSimilar,
|
||||
"nearest": tkNearest,
|
||||
|
||||
+144
-12
@@ -42,6 +42,8 @@ proc parseOverClause(p: var Parser): Node
|
||||
proc parseFrameSpec(p: var Parser): Node
|
||||
proc parseFrameBoundary(p: var Parser): string
|
||||
proc parseSetVar(p: var Parser): Node
|
||||
proc parseCreateGraph(p: var Parser): Node
|
||||
proc parseDropGraph(p: var Parser): Node
|
||||
|
||||
proc parsePrimary(p: var Parser): Node =
|
||||
let tok = p.peek()
|
||||
@@ -464,10 +466,10 @@ proc parseSelect(p: var Parser): Node =
|
||||
discard p.advance()
|
||||
discard p.expect(tkLParen)
|
||||
let graphName = p.expect(tkIdent).value
|
||||
discard p.expect(tkMatch)
|
||||
# Parse pattern: (node)-[edge]->(node)
|
||||
var hasMatch = p.match(tkMatch)
|
||||
var patternNodes: seq[string]
|
||||
var patternEdges: seq[string]
|
||||
if hasMatch:
|
||||
# First node
|
||||
discard p.expect(tkLParen)
|
||||
if p.peek().kind == tkIdent:
|
||||
@@ -492,32 +494,80 @@ proc parseSelect(p: var Parser): Node =
|
||||
patternNodes.add(p.advance().value)
|
||||
discard p.expect(tkRParen)
|
||||
# COLUMNS (col1, col2, ...)
|
||||
var algo = "bfs"
|
||||
var maxDepth = -1
|
||||
var startId = ""
|
||||
var endId = ""
|
||||
|
||||
let startVar = if patternNodes.len > 0: patternNodes[0] else: ""
|
||||
let endVar = if patternNodes.len > 1: patternNodes[1] else: ""
|
||||
|
||||
if p.peek().kind == tkIdent and p.peek().value.toLower() == "algorithm":
|
||||
discard p.advance()
|
||||
algo = p.advance().value.toLower()
|
||||
|
||||
if p.peek().kind == tkIdent and p.peek().value.toLower() == "start":
|
||||
discard p.advance()
|
||||
if p.peek().kind == tkIntLit:
|
||||
startId = p.advance().value
|
||||
elif p.peek().kind == tkIdent:
|
||||
startId = p.advance().value
|
||||
|
||||
if p.peek().kind == tkIdent and p.peek().value.toLower() == "end" or p.peek().kind == tkEnd:
|
||||
discard p.advance()
|
||||
if p.peek().kind == tkIntLit:
|
||||
endId = p.advance().value
|
||||
elif p.peek().kind == tkIdent:
|
||||
endId = p.advance().value
|
||||
|
||||
if p.peek().kind == tkIdent and p.peek().value.toLower() == "maxdepth":
|
||||
discard p.advance()
|
||||
if p.peek().kind == tkIntLit:
|
||||
maxDepth = parseInt(p.advance().value)
|
||||
|
||||
var returnCols: seq[string]
|
||||
if p.match(tkColumns):
|
||||
discard p.expect(tkLParen)
|
||||
if p.peek().kind == tkIdent:
|
||||
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkEnd, tkMatch, tkColumns, tkSrc, tkDst, tkBfs, tkDfs, tkMerge}:
|
||||
var colName = p.advance().value
|
||||
# Handle dotted names: e.name
|
||||
while p.peek().kind == tkDot:
|
||||
discard p.advance() # skip dot
|
||||
discard p.advance()
|
||||
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkBfs, tkDfs, tkMatch, tkColumns, tkEnd, tkSrc, tkDst, tkMerge}:
|
||||
colName &= "." & p.advance().value
|
||||
else:
|
||||
colName &= "." & p.expect(tkIdent).value
|
||||
returnCols.add(colName)
|
||||
while p.match(tkComma):
|
||||
if p.peek().kind == tkIdent:
|
||||
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkEnd, tkMatch, tkColumns, tkSrc, tkDst, tkBfs, tkDfs, tkMerge}:
|
||||
colName = p.advance().value
|
||||
while p.peek().kind == tkDot:
|
||||
discard p.advance()
|
||||
if p.peek().kind in {tkIdent, tkLabels, tkEdge, tkGraph, tkRank, tkBfs, tkDfs, tkMatch, tkColumns, tkEnd, tkSrc, tkDst, tkMerge}:
|
||||
colName &= "." & p.advance().value
|
||||
else:
|
||||
colName &= "." & p.expect(tkIdent).value
|
||||
returnCols.add(colName)
|
||||
if p.match(tkAs):
|
||||
discard p.advance() # skip alias
|
||||
discard p.advance()
|
||||
discard p.expect(tkRParen)
|
||||
discard p.expect(tkRParen)
|
||||
# Create a graph traversal node
|
||||
|
||||
var startNode: Node = nil
|
||||
if startId.len > 0:
|
||||
startNode = Node(kind: nkIntLit, intVal: parseInt(startId))
|
||||
elif patternNodes.len > 0:
|
||||
startNode = Node(kind: nkIdent, identName: patternNodes[0])
|
||||
|
||||
var endNode: Node = nil
|
||||
if endId.len > 0:
|
||||
endNode = Node(kind: nkIntLit, intVal: parseInt(endId))
|
||||
elif patternNodes.len > 1:
|
||||
endNode = Node(kind: nkIdent, identName: patternNodes[1])
|
||||
|
||||
result.selFrom = Node(kind: nkGraphTraversal, gtGraphName: graphName,
|
||||
gtStart: nil, gtEdge: if patternEdges.len > 0: patternEdges[0] else: "",
|
||||
gtDirection: "out", gtEnd: nil, gtMaxDepth: -1,
|
||||
gtReturnCols: returnCols, line: tok.line, col: tok.col)
|
||||
gtStart: startNode, gtEdge: if patternEdges.len > 0: patternEdges[0] else: "",
|
||||
gtDirection: "out", gtEnd: endNode, gtMaxDepth: maxDepth,
|
||||
gtReturnCols: returnCols, gtAlgo: algo, line: tok.line, col: tok.col)
|
||||
else:
|
||||
let tableTok = p.expect(tkIdent)
|
||||
var alias = ""
|
||||
@@ -984,6 +1034,40 @@ proc parseCreateTable(p: var Parser): Node =
|
||||
discard p.advance()
|
||||
discard p.match(tkNull)
|
||||
cst.cstOnDelete = "SET NULL"
|
||||
elif p.peek().kind == tkRestrict:
|
||||
discard p.advance()
|
||||
cst.cstOnDelete = "RESTRICT"
|
||||
elif p.peek().kind == tkUpdate:
|
||||
discard p.advance()
|
||||
if p.peek().kind == tkCascade:
|
||||
discard p.advance()
|
||||
cst.cstOnUpdate = "CASCADE"
|
||||
elif p.peek().kind == tkSet:
|
||||
discard p.advance()
|
||||
discard p.match(tkNull)
|
||||
cst.cstOnUpdate = "SET NULL"
|
||||
elif p.peek().kind == tkRestrict:
|
||||
discard p.advance()
|
||||
cst.cstOnUpdate = "RESTRICT"
|
||||
elif p.peek().kind == tkSet:
|
||||
discard p.advance()
|
||||
discard p.match(tkNull)
|
||||
cst.cstOnDelete = "SET NULL"
|
||||
elif p.peek().kind == tkRestrict:
|
||||
discard p.advance()
|
||||
cst.cstOnDelete = "RESTRICT"
|
||||
elif p.peek().kind == tkUpdate:
|
||||
discard p.advance()
|
||||
if p.peek().kind == tkCascade:
|
||||
discard p.advance()
|
||||
cst.cstOnUpdate = "CASCADE"
|
||||
elif p.peek().kind == tkSet:
|
||||
discard p.advance()
|
||||
discard p.match(tkNull)
|
||||
cst.cstOnUpdate = "SET NULL"
|
||||
elif p.peek().kind == tkRestrict:
|
||||
discard p.advance()
|
||||
cst.cstOnUpdate = "RESTRICT"
|
||||
elif p.match(tkUnique):
|
||||
cst.cstType = "unique"
|
||||
if p.peek().kind == tkLParen:
|
||||
@@ -1031,7 +1115,7 @@ proc parseCreateTable(p: var Parser): Node =
|
||||
colDef.cdType = "BIGINT"
|
||||
|
||||
# Parse column constraints
|
||||
while p.peek().kind in {tkPrimary, tkNot, tkNull, tkUnique, tkCheck, tkDefault, tkReferences, tkAutoIncrement}:
|
||||
while p.peek().kind in {tkPrimary, tkNot, tkNull, tkUnique, tkCheck, tkDefault, tkReferences, tkAutoIncrement, tkOn}:
|
||||
let cst = Node(kind: nkConstraintDef)
|
||||
cst.cstColumns = @[colName]; cst.cstRefColumns = @[]
|
||||
if p.match(tkPrimary):
|
||||
@@ -1071,6 +1155,25 @@ proc parseCreateTable(p: var Parser): Node =
|
||||
if p.peek().kind == tkCascade:
|
||||
discard p.advance()
|
||||
cst.cstOnDelete = "CASCADE"
|
||||
elif p.peek().kind == tkSet:
|
||||
discard p.advance()
|
||||
discard p.match(tkNull)
|
||||
cst.cstOnDelete = "SET NULL"
|
||||
elif p.peek().kind == tkRestrict:
|
||||
discard p.advance()
|
||||
cst.cstOnDelete = "RESTRICT"
|
||||
elif p.peek().kind == tkUpdate:
|
||||
discard p.advance()
|
||||
if p.peek().kind == tkCascade:
|
||||
discard p.advance()
|
||||
cst.cstOnUpdate = "CASCADE"
|
||||
elif p.peek().kind == tkSet:
|
||||
discard p.advance()
|
||||
discard p.match(tkNull)
|
||||
cst.cstOnUpdate = "SET NULL"
|
||||
elif p.peek().kind == tkRestrict:
|
||||
discard p.advance()
|
||||
cst.cstOnUpdate = "RESTRICT"
|
||||
colDef.cdConstraints.add(cst)
|
||||
result.crtColumns.add(colDef)
|
||||
|
||||
@@ -1489,6 +1592,31 @@ proc parseSetVar(p: var Parser): Node =
|
||||
result = Node(kind: nkSetVar, svName: varName, svValue: valStr,
|
||||
line: tok.line, col: tok.col)
|
||||
|
||||
proc parseCreateGraph(p: var Parser): Node =
|
||||
let tok = p.expect(tkCreate)
|
||||
discard p.expect(tkGraph)
|
||||
var ifNotExists = false
|
||||
if p.peek().kind == tkIdent and p.peek().value.toLower() == "if":
|
||||
discard p.advance()
|
||||
discard p.expect(tkNot)
|
||||
discard p.expect(tkExists)
|
||||
ifNotExists = true
|
||||
let name = p.expect(tkIdent).value
|
||||
result = Node(kind: nkCreateGraph, cgName: name, cgIfNotExists: ifNotExists,
|
||||
line: tok.line, col: tok.col)
|
||||
|
||||
proc parseDropGraph(p: var Parser): Node =
|
||||
let tok = p.expect(tkDrop)
|
||||
discard p.expect(tkGraph)
|
||||
var ifExists = false
|
||||
if p.peek().kind == tkIdent and p.peek().value.toLower() == "if":
|
||||
discard p.advance()
|
||||
discard p.expect(tkExists)
|
||||
ifExists = true
|
||||
let name = p.expect(tkIdent).value
|
||||
result = Node(kind: nkDropGraph, dgName: name, dgIfExists: ifExists,
|
||||
line: tok.line, col: tok.col)
|
||||
|
||||
proc parseStatement*(p: var Parser): Node =
|
||||
case p.peek().kind
|
||||
of tkWith, tkSelect: p.parseSelect()
|
||||
@@ -1515,6 +1643,8 @@ proc parseStatement*(p: var Parser): Node =
|
||||
p.parseCreateUser()
|
||||
elif next.kind == tkPolicy:
|
||||
p.parseCreatePolicy()
|
||||
elif next.kind == tkGraph:
|
||||
p.parseCreateGraph()
|
||||
else:
|
||||
p.parseCreateType()
|
||||
else:
|
||||
@@ -1534,6 +1664,8 @@ proc parseStatement*(p: var Parser): Node =
|
||||
p.parseDropUser()
|
||||
elif next.kind == tkPolicy:
|
||||
p.parseDropPolicy()
|
||||
elif next.kind == tkGraph:
|
||||
p.parseDropGraph()
|
||||
else:
|
||||
let tok = p.advance()
|
||||
Node(kind: nkNullLit, line: tok.line, col: tok.col)
|
||||
|
||||
@@ -258,6 +258,32 @@ proc search*(idx: HNSWIndex, query: Vector, k: int,
|
||||
for i in 0..<n:
|
||||
result[i] = (nearest[i].id, nearest[i].dist)
|
||||
|
||||
proc searchEx*(idx: HNSWIndex, query: Vector, k: int,
|
||||
metric: DistanceMetric = dmCosine): seq[(uint64, float64, Table[string, string])] =
|
||||
acquire(idx.lock)
|
||||
defer: release(idx.lock)
|
||||
if idx.nodes.len == 0:
|
||||
return @[]
|
||||
|
||||
var currEntry = idx.entryPoint
|
||||
|
||||
for lc in countdown(idx.maxLevel, 1):
|
||||
let nearest = searchLayer(idx, currEntry, query, 1, lc, metric)
|
||||
if nearest.len > 0:
|
||||
currEntry = nearest[0].id
|
||||
|
||||
let ef = max(k * 2, idx.efConstruction)
|
||||
let nearest = searchLayer(idx, currEntry, query, ef, 0, metric)
|
||||
|
||||
let n = min(k, nearest.len)
|
||||
result = newSeq[(uint64, float64, Table[string, string])](n)
|
||||
for i in 0..<n:
|
||||
let nodeId = nearest[i].id
|
||||
var meta = initTable[string, string]()
|
||||
if nodeId in idx.nodes:
|
||||
meta = idx.nodes[nodeId].metadata
|
||||
result[i] = (nodeId, nearest[i].dist, meta)
|
||||
|
||||
proc searchWithFilter*(idx: HNSWIndex, query: Vector, k: int,
|
||||
filter: proc(metadata: Table[string, string]): bool {.gcsafe.},
|
||||
metric: DistanceMetric = dmCosine): seq[(uint64, float64)] =
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
## BaraDB MCP Server — Standalone Entry Point
|
||||
##
|
||||
## Starts BaraDB in MCP (Model Context Protocol) server mode over STDIO.
|
||||
## The server accepts JSON-RPC requests from AI agents and provides
|
||||
## tools for SQL query execution, vector search, and schema inspection.
|
||||
##
|
||||
## Usage:
|
||||
## baramcp --data-dir ./data
|
||||
##
|
||||
## Environment variables:
|
||||
## BARADB_DATA_DIR — Path to the data directory (default: ./data)
|
||||
|
||||
import barabadb/mcp/server
|
||||
|
||||
when isMainModule:
|
||||
let dataDir = server.parseDataDir()
|
||||
server.logToStderr("Starting BaraDB MCP Server with data dir: " & dataDir)
|
||||
try:
|
||||
discard server.init(dataDir)
|
||||
server.run()
|
||||
except:
|
||||
server.logToStderr("Fatal error: " & getCurrentExceptionMsg())
|
||||
finally:
|
||||
server.close()
|
||||
@@ -7,6 +7,7 @@ import std/asyncdispatch
|
||||
import std/monotimes
|
||||
import std/base64
|
||||
import std/random
|
||||
import std/json
|
||||
|
||||
import barabadb/core/types
|
||||
import barabadb/core/mvcc
|
||||
@@ -3731,3 +3732,218 @@ suite "Join Performance — Hash Join & Index Nested Loop":
|
||||
check r.rows[3]["total"] == "\\N"
|
||||
|
||||
|
||||
|
||||
suite "Foreign Key Enforcement":
|
||||
var db: LSMTree
|
||||
var ctx: qexec.ExecutionContext
|
||||
var tmpDir: string
|
||||
|
||||
setup:
|
||||
tmpDir = getTempDir() / "baradb_fk_test_" & $getMonoTime().ticks
|
||||
db = newLSMTree(tmpDir)
|
||||
ctx = qexec.newExecutionContext(db)
|
||||
|
||||
teardown:
|
||||
removeDir(tmpDir)
|
||||
|
||||
test "ON DELETE CASCADE removes child rows":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child (id, parent_id) VALUES (10, 1)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child (id, parent_id) VALUES (20, 1)"))
|
||||
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent WHERE id = 1"))
|
||||
check del.success
|
||||
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child"))
|
||||
check childSel.success
|
||||
check childSel.rows.len == 0
|
||||
|
||||
test "ON DELETE SET NULL sets FK to NULL":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent2 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child2 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent2(id) ON DELETE SET NULL)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent2 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child2 (id, parent_id) VALUES (10, 1)"))
|
||||
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent2 WHERE id = 1"))
|
||||
check del.success
|
||||
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child2"))
|
||||
check childSel.success
|
||||
check childSel.rows.len == 1
|
||||
check childSel.rows[0]["parent_id"] == "\\N"
|
||||
|
||||
test "ON DELETE RESTRICT blocks delete when referenced":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent3 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child3 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent3(id) ON DELETE RESTRICT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent3 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child3 (id, parent_id) VALUES (10, 1)"))
|
||||
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent3 WHERE id = 1"))
|
||||
check not del.success
|
||||
check del.message.contains("FOREIGN KEY violation")
|
||||
let parentSel = qexec.executeQuery(ctx, parse("SELECT * FROM parent3"))
|
||||
check parentSel.rows.len == 1
|
||||
|
||||
test "ON UPDATE CASCADE updates child rows":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent4 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child4 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent4(id) ON UPDATE CASCADE)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent4 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child4 (id, parent_id) VALUES (10, 1)"))
|
||||
let upd = qexec.executeQuery(ctx, parse("UPDATE parent4 SET id = 99 WHERE id = 1"))
|
||||
check upd.success
|
||||
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child4"))
|
||||
check childSel.success
|
||||
check childSel.rows.len == 1
|
||||
check childSel.rows[0]["parent_id"] == "99"
|
||||
|
||||
test "ON UPDATE SET NULL sets FK to NULL":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent5 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child5 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent5(id) ON UPDATE SET NULL)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent5 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child5 (id, parent_id) VALUES (10, 1)"))
|
||||
let upd = qexec.executeQuery(ctx, parse("UPDATE parent5 SET id = 99 WHERE id = 1"))
|
||||
check upd.success
|
||||
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child5"))
|
||||
check childSel.success
|
||||
check childSel.rows.len == 1
|
||||
check childSel.rows[0]["parent_id"] == "\\N"
|
||||
|
||||
test "ON UPDATE RESTRICT blocks update when referenced":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent6 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child6 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent6(id) ON UPDATE RESTRICT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent6 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child6 (id, parent_id) VALUES (10, 1)"))
|
||||
let upd = qexec.executeQuery(ctx, parse("UPDATE parent6 SET id = 99 WHERE id = 1"))
|
||||
check not upd.success
|
||||
check upd.message.contains("FOREIGN KEY violation")
|
||||
|
||||
test "UPDATE child with valid FK value succeeds":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent7 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child7 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent7(id))"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent7 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent7 (id, name) VALUES (2, 'Bob')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child7 (id, parent_id) VALUES (10, 1)"))
|
||||
let upd = qexec.executeQuery(ctx, parse("UPDATE child7 SET parent_id = 2 WHERE id = 10"))
|
||||
check upd.success
|
||||
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child7"))
|
||||
check childSel.rows[0]["parent_id"] == "2"
|
||||
|
||||
test "UPDATE child with invalid FK value fails":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent8 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child8 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent8(id))"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent8 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child8 (id, parent_id) VALUES (10, 1)"))
|
||||
let upd = qexec.executeQuery(ctx, parse("UPDATE child8 SET parent_id = 999 WHERE id = 10"))
|
||||
check not upd.success
|
||||
check upd.message.contains("FOREIGN KEY violation")
|
||||
|
||||
test "Table-level FK with ON DELETE CASCADE":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent9 (id INTEGER PRIMARY KEY, name TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE child9 (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent9(id) ON DELETE CASCADE)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO parent9 (id, name) VALUES (1, 'Alice')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO child9 (id, parent_id) VALUES (10, 1)"))
|
||||
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent9 WHERE id = 1"))
|
||||
check del.success
|
||||
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child9"))
|
||||
check childSel.rows.len == 0
|
||||
|
||||
|
||||
suite "Hybrid RAG Search":
|
||||
var db: LSMTree
|
||||
var ctx: qexec.ExecutionContext
|
||||
var tmpDir: string
|
||||
|
||||
setup:
|
||||
tmpDir = getTempDir() / "baradb_hybrid_test_" & $getMonoTime().ticks
|
||||
db = newLSMTree(tmpDir)
|
||||
ctx = qexec.newExecutionContext(db)
|
||||
|
||||
teardown:
|
||||
removeDir(tmpDir)
|
||||
|
||||
test "hybrid_search returns JSON with vector + FTS results":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs (id INT PRIMARY KEY, embedding VECTOR(3), content TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs (id, embedding, content) VALUES (1, '[1.0, 0.0, 0.0]', 'quick brown fox')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs (id, embedding, content) VALUES (2, '[0.0, 1.0, 0.0]', 'lazy dog sleeps')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs (id, embedding, content) VALUES (3, '[0.0, 0.0, 1.0]', 'quick brown dog')"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_vec ON docs(embedding) USING hnsw"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_fts ON docs(content) USING FTS"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT hybrid_search('docs', 'embedding', 'content', 'quick brown', '[1.0, 0.0, 0.0]', 10) AS res"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
let jsonStr = r.rows[0]["res"]
|
||||
check jsonStr.len > 2 # not "[]"
|
||||
let arr = parseJson(jsonStr)
|
||||
check arr.kind == JArray
|
||||
check arr.len >= 1
|
||||
|
||||
test "hybrid_search_ids returns comma-separated ids":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs2 (id INT PRIMARY KEY, embedding VECTOR(3), content TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs2 (id, embedding, content) VALUES (10, '[1.0, 0.0, 0.0]', 'artificial intelligence')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs2 (id, embedding, content) VALUES (20, '[0.0, 1.0, 0.0]', 'machine learning')"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_vec2 ON docs2(embedding) USING hnsw"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_fts2 ON docs2(content) USING FTS"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT hybrid_search_ids('docs2', 'embedding', 'content', 'machine learning', '[0.0, 1.0, 0.0]', 10) AS ids"))
|
||||
check r.success
|
||||
check r.rows.len == 1
|
||||
let idsStr = r.rows[0]["ids"]
|
||||
check idsStr.len > 0
|
||||
check idsStr.contains("20")
|
||||
|
||||
test "hybrid_search combines vector and FTS via RRF":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs3 (id INT PRIMARY KEY, embedding VECTOR(3), content TEXT)"))
|
||||
# Doc 1: matches vector only
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs3 (id, embedding, content) VALUES (1, '[1.0, 0.0, 0.0]', 'unrelated text')"))
|
||||
# Doc 2: no match
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs3 (id, embedding, content) VALUES (2, '[0.0, 1.0, 0.0]', 'lazy dog sleeps')"))
|
||||
# Doc 3: matches both vector and FTS (should rank highest)
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs3 (id, embedding, content) VALUES (3, '[1.0, 0.0, 0.0]', 'quick brown fox')"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_vec3 ON docs3(embedding) USING hnsw"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_fts3 ON docs3(content) USING FTS"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT hybrid_search('docs3', 'embedding', 'content', 'quick brown fox', '[1.0, 0.0, 0.0]', 10) AS res"))
|
||||
check r.success
|
||||
let arr = parseJson(r.rows[0]["res"])
|
||||
check arr.len == 3
|
||||
# Doc 3 should be first (matches both vector and FTS), doc 1 second (vector only), doc 2 third (no match)
|
||||
check arr[0]["id"].getStr() == "3"
|
||||
|
||||
test "rerank boosts term overlap":
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT rerank('quick brown', '[{\"id\":\"1\",\"score\":\"0.5\"},{\"id\":\"2\",\"score\":\"0.5\"}]') AS res"))
|
||||
check r.success
|
||||
# Both have same score, rerank should preserve order (no content to boost)
|
||||
let arr = parseJson(r.rows[0]["res"])
|
||||
check arr.kind == JArray
|
||||
check arr.len == 2
|
||||
|
||||
test "hybrid_search with missing indexes returns empty":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs4 (id INT PRIMARY KEY, embedding VECTOR(3), content TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs4 (id, embedding, content) VALUES (1, '[1.0, 0.0, 0.0]', 'test')"))
|
||||
# No indexes created
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT hybrid_search('docs4', 'embedding', 'content', 'test', '[1.0, 0.0, 0.0]', 10) AS res"))
|
||||
check r.success
|
||||
check r.rows[0]["res"] == "[]"
|
||||
|
||||
|
||||
test "hybrid_search_filtered excludes non-matching rows":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs5 (id INT PRIMARY KEY, embedding VECTOR(3), content TEXT, tenant_id TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs5 (id, embedding, content, tenant_id) VALUES (1, '[1.0, 0.0, 0.0]', 'quick brown fox', 'tenant-a')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs5 (id, embedding, content, tenant_id) VALUES (2, '[1.0, 0.0, 0.0]', 'quick brown fox', 'tenant-b')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs5 (id, embedding, content, tenant_id) VALUES (3, '[0.0, 1.0, 0.0]', 'lazy dog sleeps', 'tenant-a')"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_vec5 ON docs5(embedding) USING hnsw"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_fts5 ON docs5(content) USING FTS"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT hybrid_search_filtered('docs5', 'embedding', 'content', 'quick brown fox', '[1.0, 0.0, 0.0]', 10, 'tenant_id', 'tenant-a') AS res"))
|
||||
check r.success
|
||||
let arr = parseJson(r.rows[0]["res"])
|
||||
# Doc 1 (tenant-a) should be first and highest scored; Doc 2 (tenant-b) must be excluded
|
||||
check arr[0]["id"].getStr() == "1"
|
||||
for elem in arr:
|
||||
check elem["id"].getStr() != "2"
|
||||
|
||||
test "hybrid_search_filtered with empty filter behaves like hybrid_search":
|
||||
discard qexec.executeQuery(ctx, parse("CREATE TABLE docs6 (id INT PRIMARY KEY, embedding VECTOR(3), content TEXT, tenant_id TEXT)"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs6 (id, embedding, content, tenant_id) VALUES (1, '[1.0, 0.0, 0.0]', 'quick brown fox', 'tenant-a')"))
|
||||
discard qexec.executeQuery(ctx, parse("INSERT INTO docs6 (id, embedding, content, tenant_id) VALUES (2, '[1.0, 0.0, 0.0]', 'quick brown fox', 'tenant-b')"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_vec6 ON docs6(embedding) USING hnsw"))
|
||||
discard qexec.executeQuery(ctx, parse("CREATE INDEX idx_fts6 ON docs6(content) USING FTS"))
|
||||
let r = qexec.executeQuery(ctx, parse("SELECT hybrid_search_filtered('docs6', 'embedding', 'content', 'quick brown fox', '[1.0, 0.0, 0.0]', 10, '', '') AS res"))
|
||||
check r.success
|
||||
let arr = parseJson(r.rows[0]["res"])
|
||||
check arr.len == 2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user