diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8dc84181f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,205 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Minigraf is a tiny, portable GQL (Graph Query Language) engine written in Rust. It's a work-in-progress learning project designed to run as a standalone binary, library, or WebAssembly module. The project currently implements a working PoC with: +- Property graph data model (nodes, edges, properties) +- In-memory storage engine +- Custom GQL query parser +- Query executor +- Interactive REPL console + +**Important**: This is a GQL-inspired implementation (~2-5% of ISO/IEC 39075:2024 spec). It is NOT fully spec-compliant and prioritizes learning over conformance. See README.md's "GQL Spec Compliance" section for detailed feature coverage and roadmap. + +## Core Philosophy - CRITICAL + +**Before implementing ANY feature or change, you MUST assess it against the design philosophy in PHILOSOPHY.md.** + +Minigraf follows the "SQLite for graph databases" philosophy: +- **Zero-configuration** - No setup, no config files, just works +- **Embedded-first** - Library not server, in-process execution +- **Single-file database** - One portable `.graph` file +- **Self-contained** - Minimal dependencies, small binary (<1MB goal) +- **Cross-platform** - Native, WASM, mobile, embedded +- **Reliability over features** - Do less, do it perfectly +- **Stability** - Backwards compatibility, stable file format +- **Long-term support** - Decades-long commitment + +### Philosophy Compliance Check + +**CRITICAL INSTRUCTION**: When the user requests a feature or change, you MUST: + +1. **First**, assess whether it aligns with the philosophy in PHILOSOPHY.md +2. **If it violates the philosophy**, WARN the user BEFORE implementing: + - Explain which principle(s) it violates + - Explain why it's problematic + - Suggest alternatives that align with the philosophy + - Ask for explicit confirmation to proceed despite the violation +3. **If it aligns**, proceed with implementation + +**Examples of philosophy violations to warn about**: +- Adding client-server architecture (violates embedded-first) +- Requiring external services or complex setup (violates zero-configuration) +- Large dependencies that bloat binary size (violates self-contained) +- Features only useful for distributed systems (violates target use cases) +- Breaking changes to API or file format (violates stability) +- Complex features before basics are reliable (violates reliability-first) + +**Your response format when detecting a violation**: +``` +⚠️ PHILOSOPHY WARNING ⚠️ + +The requested feature/change may violate Minigraf's core philosophy: + +**Violated Principle**: [principle name from PHILOSOPHY.md] + +**Why this is problematic**: [explanation] + +**Philosophy-aligned alternatives**: +- [alternative 1] +- [alternative 2] + +Do you want to proceed anyway? This would be a deviation from the "SQLite for graph databases" philosophy. +``` + +See PHILOSOPHY.md for complete design principles and decision framework. + +## Build and Run Commands + +```bash +# Build the project +cargo build + +# Build release version (with panic=abort optimization) +cargo build --release + +# Run the REPL +cargo run + +# Run tests +cargo test + +# Run integration tests with output +cargo test --test integration_test -- --nocapture +``` + +## Architecture + +### Module Structure + +The codebase is organized into the following modules: + +1. **Graph Module (`src/graph/`)**: + - `types.rs`: Core property graph types + - `Node`: Vertices with UUID IDs, multiple labels, and typed properties + - `Edge`: Directed edges with UUID IDs, source/target nodes, label, and properties + - `PropertyValue`: Enum supporting String, Integer, Float, Boolean, and Null types + - `storage.rs`: Thread-safe in-memory storage using `Arc>` + - CRUD operations for nodes and edges + - Query helpers: filter by labels, properties, get edges from/to nodes + +2. **Query Module (`src/query/`)**: + - `parser.rs`: Text-based query parser supporting: + - `CREATE NODE (:Label1:Label2) {prop: value}` - Create nodes + - `CREATE EDGE (id1)-[LABEL]->(id2) {prop: value}` - Create edges + - `MATCH (:Label) [WHERE prop = value]` - Find nodes + - `MATCH -[:LABEL]->` - Find edges + - `SHOW NODES` / `SHOW EDGES` - List all entities + - `HELP` / `EXIT` - Console commands + - `executor.rs`: Query execution engine + - Executes parsed queries against storage + - Validates edge creation (source/target existence) + - Returns formatted results + +3. **REPL Module (`src/repl.rs`)**: Interactive console + - Prompt-based interface (`minigraf>`) + - Reads user input, parses queries, executes, and displays results + - Handles EOF gracefully (src/repl.rs:27-30) for piped input + - Error handling for parse/execution errors + +4. **Library (`src/lib.rs`)**: Public API + - Exports all core types: `Node`, `Edge`, `PropertyValue`, `GraphStorage` + - Exports query functions: `parse_query`, `QueryExecutor`, `QueryResult` + - Exports REPL: `Repl` + +5. **Binary (`src/main.rs`)**: Standalone executable + - Creates in-memory storage + - Launches interactive REPL + +### Storage Implementation + +The current implementation uses **in-memory storage** with: +- `Arc>>` for nodes +- `Arc>>` for edges +- Thread-safe concurrent access via read/write locks +- UUIDs for automatic ID generation + +Note: RocksDB was initially considered but switched to in-memory storage for the PoC due to compilation issues. + +### Query Language Syntax + +The query language is inspired by GQL (ISO/IEC 39075:2024) but simplified for the PoC: + +``` +# Create nodes with labels and properties +CREATE NODE (:Person) {name: "Alice", age: 30} +CREATE NODE (:Person:Employee) {name: "Bob", age: 25} + +# Create edges between existing nodes (use node IDs) +CREATE EDGE (node-id-1)-[KNOWS]->(node-id-2) {since: 2020} + +# Match nodes by label +MATCH (:Person) + +# Match nodes with property filter +MATCH (:Person) WHERE name = "Alice" + +# Match edges by label +MATCH -[:KNOWS]-> + +# Show all nodes or edges +SHOW NODES +SHOW EDGES +``` + +### Error Handling + +- Parse errors: Reported with descriptive messages, REPL continues +- Execution errors: Validated before execution (e.g., edge endpoints must exist) +- EOF handling: REPL exits gracefully when stdin closes (for piped input/scripts) + +## Test Coverage + +The project has comprehensive test coverage: + +- **Unit tests** (19 tests): + - `src/graph/types.rs`: Node/Edge creation, PropertyValue accessors + - `src/graph/storage.rs`: CRUD operations, queries, edge relationships + - `src/query/parser.rs`: All query syntax variations + - `src/query/executor.rs`: Query execution, validation + +- **Integration tests** (`tests/integration_test.rs`): + - Complete workflow: create nodes, create edges, query, filter + +Run tests with: `cargo test` + +## Development Notes + +- **No schema file required**: The engine uses in-memory storage and doesn't require a schema on startup +- **UUID-based IDs**: All nodes and edges get automatic UUID identifiers +- **Thread-safe**: Storage can be safely shared across threads +- **Property types**: Supports String, Integer, Float, Boolean, and Null values +- **EOF handling**: REPL properly exits when stdin closes (fixed in src/repl.rs:27-30) + +## Future Work + +- Persistent storage (RocksDB integration) +- Indexes for fast property lookups +- Complex graph traversals (multi-hop paths) +- Aggregations and analytics +- Library embedding support +- WebAssembly compilation +- Transaction support +- Schema validation diff --git a/Cargo.toml b/Cargo.toml index 825cf7e90..2687db477 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,13 @@ name = "minigraf" version = "0.1.0" edition = "2021" +description = "A tiny, portable GQL (Graph Query Language) engine" [dependencies] -graphql-parser = "0.4.0" -lazy_static = "1.4.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +uuid = { version = "1.0", features = ["v4", "serde"] } [profile.release] panic = "abort" diff --git a/PHILOSOPHY.md b/PHILOSOPHY.md new file mode 100644 index 000000000..3a023d902 --- /dev/null +++ b/PHILOSOPHY.md @@ -0,0 +1,414 @@ +# Minigraf Design Philosophy + +> "Minigraf is not trying to replace Neo4j. It's trying to replace `serde_json` for graph data." + +Minigraf aims to be **the SQLite of graph databases** - a small, fast, reliable, embedded graph database engine that runs in-process with your application. + +## Core Inspiration: SQLite + +SQLite's success comes from a clear philosophy: be a library, not a server. Be small, not feature-complete. Be reliable, not cutting-edge. Minigraf adopts these same principles for graph databases. + +## Guiding Principles + +### 1. Zero-Configuration + +**Philosophy**: It should just work, immediately, with no setup. + +**Implementation**: +- No installation process beyond adding a dependency +- No server process to start or manage +- No configuration files to edit +- No connection strings or authentication for local use +- `Minigraf::open("data.graph")` and you're done + +**Anti-pattern**: Requiring users to install external dependencies, start services, or edit config files. + +### 2. Embedded-First Design + +**Philosophy**: Minigraf is a library you link against, not a server you connect to. + +**Implementation**: +- In-process execution - direct function calls, no network overhead +- Runs in the same address space as your application +- No client-server architecture for embedded use +- Network protocols are opt-in extensions, not core features + +**Anti-pattern**: Designing for client-server first and retrofitting embedded mode. + +**Target statement**: "The graph database you compile into your app, not connect to." + +### 3. Single-File Database + +**Philosophy**: All data in one portable file that's easy to manage. + +**Implementation**: +- Single `.graph` file contains nodes, edges, properties, indexes, schema +- Easy to backup: copy one file +- Easy to share: email, USB drive, version control (for small DBs) +- Easy to delete: remove one file +- WASM: Store in browser's IndexedDB as single blob + +**Anti-pattern**: Multiple files, directories, or complex file structures that are hard to manage. + +### 4. Self-Contained + +**Philosophy**: Minimal dependencies. Small binary size. No external requirements. + +**Implementation**: +- Pure Rust implementation +- Minimal dependency tree (currently: serde, uuid, anyhow) +- No required system libraries (optional backends OK) +- Target: <1MB binary for core engine +- No runtime dependencies (no JVM, no Python, no Node.js) + +**Anti-pattern**: Requiring external services, libraries, or runtimes to function. + +### 5. Cross-Platform Portability + +**Philosophy**: Run anywhere, from embedded devices to browsers to servers. + +**Implementation**: +- Native: Linux, macOS, Windows, BSD, mobile +- WebAssembly: Run in any modern browser +- File format is endian-agnostic and cross-platform +- No platform-specific features in core (OS-specific optimizations OK) + +**Target platforms**: +- Desktop: Windows, macOS, Linux +- Mobile: iOS, Android (via FFI/JNI) +- Web: WASM in browsers +- Embedded: Raspberry Pi, IoT devices +- Server: As a library in server applications + +**Anti-pattern**: Platform-specific code in the core engine. + +### 6. Reliability Over Features + +**Philosophy**: It's better to do less and do it perfectly than to do more and do it poorly. + +**Implementation**: +- ACID transactions (Atomicity, Consistency, Isolation, Durability) +- Write-ahead logging (WAL) for crash recovery +- Data integrity checks on every operation +- Rigorous testing (aim for 100% branch coverage) +- Conservative feature addition +- No data loss, ever + +**Quality bar**: +- Every feature must be fully tested +- Every feature must handle edge cases +- Every feature must be crash-safe +- Prefer proven algorithms over novel ones + +**Anti-pattern**: Adding features before existing ones are bulletproof. + +### 7. Stability & Backwards Compatibility + +**Philosophy**: Your graph database files should work forever. + +**Implementation**: +- Stable file format once v1.0 ships +- Can read graphs created 20+ years ago +- API stability: semantic versioning, no breaking changes in minor versions +- Clear migration paths when absolutely necessary +- Deprecation warnings 12+ months before removal + +**Commitment**: Once v1.0 ships, file format is stable for decades. + +**Anti-pattern**: Breaking changes, format churn, forced migrations. + +### 8. Performance Through Simplicity + +**Philosophy**: Fast because simple, not simple because fast. + +**Implementation**: +- Optimize the common case (small to medium graphs, <1M nodes) +- Page-based storage with locality of reference +- Indexes for frequently queried patterns +- Memory-mapped I/O where beneficial +- Avoid premature optimization + +**Target performance**: +- Sub-millisecond queries for indexed lookups +- Thousands of transactions per second on commodity hardware +- Efficient memory usage (<100MB for medium graphs) + +**Anti-pattern**: Complex optimization that sacrifices reliability or adds dependencies. + +### 9. Well-Documented + +**Philosophy**: Documentation is as important as code. + +**Implementation**: +- Every public API has rustdoc comments with examples +- Query language reference manual (like SQL reference) +- Architecture documentation for contributors +- Performance tuning guide +- Common patterns and recipes +- Migration guides between versions + +**Documentation types**: +- API reference (generated from code) +- User guide (getting started, tutorials) +- Query language specification +- Internals guide (for contributors) + +**Anti-pattern**: "The code is the documentation." + +### 10. Long-Term Support + +**Philosophy**: This is a marathon, not a sprint. + +**Implementation**: +- Commitment to decades of support +- Conservative, deliberate feature additions +- No rewrites or "version 2.0" churn +- Security patches for old versions +- Focus on stability over novelty + +**Inspiration**: SQLite has been maintained for 20+ years and is committed to 2050. + +**Anti-pattern**: Framework churn, major rewrites, abandoned versions. + +## What Minigraf IS + +✅ **An embedded graph database library** +- Link it into your application like SQLite +- Direct function calls, no network overhead +- Runs in-process with your app + +✅ **A local-first storage solution** +- Perfect for desktop applications +- Ideal for mobile apps +- Great for WASM in browsers +- Suitable for embedded devices + +✅ **A single-file graph store** +- One `.graph` file, easy to manage +- Portable across platforms +- Simple backup and versioning + +✅ **A reliable, ACID-compliant database** +- Transactions with rollback support +- Crash recovery via WAL +- Data integrity guarantees + +✅ **A learning-friendly implementation** +- Readable Rust code +- Well-documented internals +- Clear architecture + +## What Minigraf IS NOT + +❌ **Not a distributed database** +- No clustering, no sharding, no replication +- Single-node only (by design) +- If you need distributed, use Neo4j or similar + +❌ **Not a graph analytics engine** +- No built-in PageRank, community detection, etc. +- You can build these on top, or use external tools +- Focus is on storage and queries, not analytics + +❌ **Not a client-server system** +- No network protocol in core +- No authentication/authorization layer +- No multi-user access control (use OS permissions) + +❌ **Not enterprise-focused** +- No role-based access control (RBAC) +- No audit logging +- No high-availability features +- (These can be built on top if needed) + +❌ **Not trying to be Neo4j** +- Different use case (embedded vs. server) +- Different scale (millions vs. billions of nodes) +- Different philosophy (library vs. service) + +❌ **Not chasing full GQL spec compliance** +- Implement what's useful, not what's in the spec +- Pragmatic subset of GQL +- Extensions for common patterns + +## Target Use Cases + +**Primary use cases** (optimize for these): + +1. **Mobile applications** - Local graph storage on phones/tablets +2. **Desktop applications** - Apps that need relationship data (IDEs, note-taking, etc.) +3. **Web applications (WASM)** - Client-side graph storage in browsers +4. **Embedded devices** - IoT, edge computing with graph data +5. **Development/testing** - Local graph database for testing +6. **Small to medium production apps** - Where embedded DB is sufficient + +**Secondary use cases** (should work, but not optimized for): + +7. **Server applications** - Using Minigraf as an embedded component +8. **Data analysis** - Exploring graph datasets locally +9. **Education** - Learning graph databases and algorithms + +**Non-use cases** (explicitly out of scope): + +- Large-scale distributed systems +- Multi-datacenter replication +- Billion-node graphs +- Real-time analytics at scale + +## Design Decision Framework + +When evaluating a feature or design choice, ask: + +### 1. Does it align with "SQLite for graphs"? +- Would SQLite do this? +- Does it keep things simple and embedded? + +### 2. Does it compromise reliability? +- Can it cause data loss or corruption? +- Does it make the codebase harder to test? + +### 3. Does it add complexity? +- How many lines of code? +- How many new dependencies? +- Does it complicate the API? + +### 4. Does it serve the primary use cases? +- Is this needed for embedded/mobile/WASM? +- Or is it only useful for enterprise/distributed? + +### 5. Can it be a separate crate instead? +- Could this be an optional feature flag? +- Could this be a separate library on top of Minigraf? + +### Decision rubric: +- **YES**: Aligns with philosophy, improves reliability, serves primary use cases +- **MAYBE**: Useful but adds complexity, consider making optional +- **NO**: Violates philosophy, compromises reliability, or only serves non-use cases + +## Success Metrics + +You'll know Minigraf has succeeded when: + +1. ✅ **Ubiquity**: Developers say "just use Minigraf" for embedded graph storage +2. ✅ **Trust**: Known for never losing data, crash-safe, reliable +3. ✅ **Simplicity**: New users are productive in under 5 minutes +4. ✅ **Size**: Core binary under 1MB, minimal dependencies +5. ✅ **Portability**: Runs everywhere from Raspberry Pi to browsers +6. ✅ **Stability**: API hasn't broken in years +7. ✅ **Documentation**: Comprehensive docs with examples +8. ✅ **Longevity**: Still maintained and improved 10+ years later + +## Non-Goals + +To maintain focus, these are explicitly NOT goals: + +- ❌ Distributed consensus algorithms +- ❌ Multi-master replication +- ❌ Built-in authentication/authorization +- ❌ Full GQL standard compliance (implement useful subset) +- ❌ Competing with Neo4j/TigerGraph on their turf +- ❌ Real-time analytics (OLAP workloads) +- ❌ Graph visualization (provide data, let others visualize) +- ❌ Built-in ML/AI (provide APIs for external tools) + +## Testing Philosophy + +Inspired by SQLite's legendary testing rigor: + +**Test coverage goals**: +- 100% branch coverage (aspirational) +- Property-based testing (quickcheck, proptest) +- Fuzz testing (cargo-fuzz) +- Fault injection (simulate disk errors, OOM) +- Memory safety (miri, valgrind) +- Cross-platform testing (CI on Linux, macOS, Windows) + +**Test-to-code ratio**: Aim for 5:1 (5x more test code than library code) + +**Release criteria**: +- All tests pass on all platforms +- No memory leaks detected +- No undefined behavior (miri clean) +- Performance benchmarks within 5% of baseline +- Documentation complete for new features + +## File Format Principles + +The `.graph` file format must be: + +1. **Stable** - Once v1.0 ships, format is frozen for decades +2. **Self-describing** - Header with magic number and version +3. **Portable** - Endian-agnostic, cross-platform +4. **Efficient** - Page-based, locality of reference +5. **Extensible** - Can add features without breaking old readers +6. **Verifiable** - Checksums for integrity validation + +## API Design Principles + +1. **Simple common case**: `db.add_node()` should be one line +2. **Safe by default**: Require `unsafe` only where truly needed +3. **Transactions explicit**: Clear when you're in a transaction +4. **Ergonomic errors**: `Result` with helpful messages +5. **Builder patterns**: Complex operations use builders +6. **Zero-cost abstractions**: No runtime penalty for nice APIs + +## Evolution Strategy + +**Phase 1 (Current)**: Prove the concept +- Basic graph model, simple queries, in-memory storage + +**Phase 2**: Establish reliability +- Single-file storage, ACID transactions, WAL, crash recovery + +**Phase 3**: Build performance +- Indexes, query optimization, efficient I/O + +**Phase 4**: Expand reach +- WASM support, mobile bindings, multiple backends + +**Phase 5**: Mature the ecosystem +- Full documentation, tooling, ecosystem libraries + +**Phase 6+**: Long-term maintenance +- Bug fixes, security patches, conservative improvements + +## When to Say "No" + +It's important to say "no" to preserve the project's focus: + +**Say NO to**: +- Features that only serve enterprise/distributed use cases +- Complexity that compromises reliability +- Dependencies that increase binary size significantly +- Breaking changes without overwhelming justification +- Features that should be separate libraries +- Premature optimization + +**It's OK to say**: "That's a great feature, but it's better suited for a library built on top of Minigraf." + +## Inspirations + +Beyond SQLite, we draw inspiration from: + +- **SQLite**: Embedded database philosophy +- **Redis**: Simple, focused, well-documented +- **Rust**: Memory safety, zero-cost abstractions +- **Git**: Single-file stores (packfiles), content-addressed storage +- **DuckDB**: Modern analytics, SQLite-style +- **Local-first software**: Offline-capable, user-owned data + +## Closing Thoughts + +Minigraf is a decades-long project. We optimize for: +- **Reliability** over features +- **Simplicity** over flexibility +- **Longevity** over hype +- **Users** over competitors + +The goal is not to be the most feature-complete graph database. The goal is to be the one that's always there when you need it, works reliably, and never gets in your way. + +Be boring. Be reliable. Be Minigraf. + +--- + +*This document is a living guide. When in doubt, refer back to these principles.* diff --git a/POC_SUMMARY.md b/POC_SUMMARY.md new file mode 100644 index 000000000..05682a299 --- /dev/null +++ b/POC_SUMMARY.md @@ -0,0 +1,102 @@ +# Minigraf PoC - Implementation Summary + +## Overview +A working proof-of-concept for a Graph Query Language (GQL) engine built in Rust with in-memory storage. + +## What Was Built + +### 1. Graph Data Model (`src/graph/`) +- **types.rs**: Core property graph constructs + - `Node`: Vertices with UUID IDs, multiple labels, and properties + - `Edge`: Directed edges with UUID IDs, source/target nodes, label, and properties + - `PropertyValue`: Support for String, Integer, Float, Boolean, and Null types + +- **storage.rs**: Thread-safe in-memory storage + - Uses `Arc>` for concurrent access + - CRUD operations for nodes and edges + - Query helpers: get edges from/to nodes, filter by properties + - 5 passing unit tests + +### 2. Query Language (`src/query/`) +- **parser.rs**: Text-based query parser supporting: + - `CREATE NODE (:Label) {prop: value}` - Create nodes with labels and properties + - `CREATE EDGE (id1)-[LABEL]->(id2) {prop: value}` - Create edges between nodes + - `MATCH (:Label) [WHERE prop = value]` - Find nodes by label/property + - `MATCH -[:LABEL]->` - Find edges by label + - `SHOW NODES` / `SHOW EDGES` - List all nodes/edges + - `HELP` / `EXIT` - Console commands + - 10 passing unit tests + +- **executor.rs**: Query execution engine + - Executes parsed queries against storage + - Returns formatted results + - Validates edge creation (source/target existence) + - 4 passing unit tests + +### 3. Interactive Console (`src/repl.rs`) +- REPL interface with `minigraf>` prompt +- Reads user input, parses queries, executes, and displays results +- Graceful error handling for parse/execution errors + +### 4. Library & Binary +- **src/lib.rs**: Public API exposing all core types and functions +- **src/main.rs**: Standalone executable that launches the REPL + +## Test Results +``` +✓ 19 unit tests (types, storage, parser, executor) +✓ 1 integration test (complete workflow) +✓ All tests passing +✓ Clean build with no errors +``` + +## Example Usage + +### Starting the Console +```bash +cargo run +``` + +### Sample Commands +``` +minigraf> CREATE NODE (:Person) {name: "Alice", age: 30} +Node created: 986f25e2-ad2f-4fa9-939c-4292b938581f (labels: Person, properties: {age: 30, name: "Alice"}) + +minigraf> CREATE NODE (:Person) {name: "Bob", age: 25} +Node created: 4699faa4-6286-491b-bf29-2f15a58a9268 (labels: Person, properties: {age: 25, name: "Bob"}) + +minigraf> CREATE EDGE (986f25e2-ad2f-4fa9-939c-4292b938581f)-[KNOWS]->(4699faa4-6286-491b-bf29-2f15a58a9268) {since: 2020} +Edge created: 5cfd7eb6-95ee-427e-935d-8fe217adcf43 (986f... -[KNOWS]-> 469..., properties: {since: 2020}) + +minigraf> MATCH (:Person) WHERE name = "Alice" +Found 1 node(s): + - 986f25e2-ad2f-4fa9-939c-4292b938581f (labels: Person, properties: {age: 30, name: "Alice"}) + +minigraf> SHOW EDGES +Found 1 edge(s): + - 5cfd7eb6-95ee-427e-935d-8fe217adcf43 (986f... -[KNOWS]-> 469..., properties: {since: 2020}) +``` + +## Key Features Implemented +✓ Property graph model (nodes, edges, properties) +✓ In-memory storage (thread-safe) +✓ Query parser (CREATE, MATCH, SHOW commands) +✓ Query executor with validation +✓ Interactive REPL console +✓ Comprehensive test coverage +✓ Clean error handling + +## Architecture Decisions +- **In-memory storage**: Replaced RocksDB due to compilation issues in previous session +- **UUID-based IDs**: Automatic generation for all nodes and edges +- **Thread-safe**: Arc/RwLock allows concurrent access to storage +- **Simple syntax**: GQL-inspired but simplified for PoC + +## Not Yet Implemented (Future Work) +- Persistent storage (RocksDB integration) +- Indexes for fast lookups +- Complex graph traversals +- Aggregations and analytics +- Library embedding / WASM support +- Transaction support +- Schema validation diff --git a/README.md b/README.md index 7ee6156dc..0c0316b27 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,292 @@ -# minigraf -A tiny, portable GraphQL engine. **W.I.P.** +# Minigraf + +A tiny, portable GQL (Graph Query Language) engine written in Rust. **W.I.P.** + +GQL stands for Graph Query Language, which has been standardized in [ISO/IEC 39075:2024](https://www.iso.org/standard/76120.html). ## Purpose + This project was started to (in order): - Learn Rust, - Learn how to write a parser, -- Learn GraphQL, +- Learn GQL, - Possibly create a borderline useful tool in the process. +## Current Status - PoC + +This is a proof-of-concept implementation featuring: +- ✅ Basic property graph model (nodes, edges with properties) +- ✅ In-memory storage +- ✅ Interactive REPL console +- ✅ Simple GQL-like query language +- ✅ Test coverage + +## Build and Run + +```bash +# Build the project +cargo build + +# Build release version +cargo build --release + +# Run the interactive console +cargo run + +# Run tests +cargo test +``` + +## Query Language + +The PoC implements a simple GQL-like query language: + +### Create a Node + +```gql +CREATE NODE (:Person) {name: "Alice", age: 30} +CREATE NODE (:Person:Employee) {name: "Bob"} +CREATE NODE (:Company) +``` + +### Create an Edge + +```gql +CREATE EDGE (node-id-1)-[KNOWS]->(node-id-2) {since: 2020} +CREATE EDGE (alice-id)-[WORKS_AT]->(company-id) +``` + +### Match/Query Nodes + +```gql +MATCH (:Person) +MATCH (:Person) WHERE name = "Alice" +MATCH (:Employee) +``` + +### Match/Query Edges + +```gql +MATCH -[:KNOWS]-> +MATCH -[:WORKS_AT]-> +``` + +### Show All Data + +```gql +SHOW NODES +SHOW EDGES +``` + +### Help and Exit + +```gql +HELP # Show available commands +EXIT # Exit the console (or use QUIT) +``` + +## Example Session + +``` +$ cargo run +Minigraf v0.1.0 - Graph Query Language Engine +Using in-memory storage + +Minigraf v0.1.0 - Interactive Graph Query Console +Type HELP for available commands, EXIT to quit. + +minigraf> CREATE NODE (:Person) {name: "Alice", age: 30} +Node created: (labels: Person, properties: {age: 30, name: "Alice"}) + +minigraf> CREATE NODE (:Person) {name: "Bob", age: 25} +Node created: (labels: Person, properties: {age: 25, name: "Bob"}) + +minigraf> CREATE EDGE ()-[KNOWS]->() {since: 2020} +Edge created: ( -[KNOWS]-> , properties: {since: 2020}) + +minigraf> MATCH (:Person) +Found 2 node(s): + - (labels: Person, properties: {age: 30, name: "Alice"}) + - (labels: Person, properties: {age: 25, name: "Bob"}) + +minigraf> MATCH (:Person) WHERE name = "Alice" +Found 1 node(s): + - (labels: Person, properties: {age: 30, name: "Alice"}) + +minigraf> SHOW EDGES +Found 1 edge(s): + - ( -[KNOWS]-> , properties: {since: 2020}) + +minigraf> EXIT +Goodbye! +``` + +## Architecture + +### Module Structure + +- **`src/graph/types.rs`**: Core property graph data structures (Node, Edge, Property, PropertyValue) +- **`src/graph/storage.rs`**: In-memory storage layer with thread-safe operations +- **`src/query/parser.rs`**: Query language parser +- **`src/query/executor.rs`**: Query execution engine +- **`src/repl.rs`**: Interactive REPL console +- **`src/lib.rs`**: Library exports +- **`src/main.rs`**: Binary entry point + +### Property Graph Model + +**Nodes**: +- Unique UUID identifier +- Multiple labels (e.g., `Person`, `Employee`) +- Properties as key-value pairs + +**Edges**: +- Unique UUID identifier +- Source and target node IDs +- Single label/type (e.g., `KNOWS`, `WORKS_AT`) +- Properties as key-value pairs + +**Property Values**: +- String +- Integer (i64) +- Float (f64) +- Boolean +- Null + ## Scope + Minigraf will be designed to run in multiple environments, including: -- As a standalone binary, -- As a library, -- As a WebAssembly module (for browsers). +- As a standalone binary ✅ (PoC done) +- As a library ✅ (PoC done) +- As a WebAssembly module (for browsers) ⏳ (future) ## Unscope + Minigraf will **NOT** be designed to be (for now): - Distributed, - Fault-tolerant, - ACID-compliant. -## Features +## Future Features + Minigraf will support multiple backends to store its data, including: -- In-memory, -- IndexedDB (browser only), -- SQLite, -- One or more embedded KV stores (such as LevelDB or RocksDB). +- In-memory ✅ (PoC done) +- IndexedDB (browser only) ⏳ +- SQLite ⏳ +- One or more embedded KV stores (such as LevelDB or RocksDB) ⏳ + +## GQL Spec Compliance + +**Current Status: ~2-5% of ISO/IEC 39075:2024** + +This is a learning project implementing a GQL-inspired query language. It is **not fully compliant** with the GQL standard and does not aim for complete compliance in the near term. + +### ✅ Implemented (PoC Level) + +**Basic Graph Model:** +- ✅ Nodes with multiple labels +- ✅ Directed edges with single label +- ✅ Properties on nodes and edges +- ✅ Property types: String, Integer, Float, Boolean, Null + +**Basic Query Operations:** +- ✅ CREATE NODE with labels and properties +- ✅ CREATE EDGE between existing nodes +- ✅ Simple MATCH by label: `MATCH (:Label)` +- ✅ Single property equality filter: `WHERE prop = value` +- ✅ SHOW NODES / SHOW EDGES (non-standard convenience commands) + +### ❌ Not Yet Implemented (Majority of GQL Spec) + +**Graph Pattern Matching:** +- ❌ Complex path patterns: `(a)-[:REL]->(b)-[:REL2]->(c)` +- ❌ Variable-length paths: `(a)-[:REL*1..5]->(b)` +- ❌ Shortest path queries +- ❌ Optional patterns (OPTIONAL MATCH) +- ❌ Pattern alternatives/disjunction +- ❌ Quantified path patterns + +**Query Clauses:** +- ❌ RETURN clause (projections, expressions) +- ❌ WITH clause (intermediate results) +- ❌ ORDER BY, LIMIT, SKIP +- ❌ DISTINCT +- ❌ OPTIONAL MATCH +- ❌ UNION, INTERSECT, EXCEPT + +**Data Manipulation:** +- ❌ INSERT (vs. CREATE) +- ❌ SET (update properties/labels) +- ❌ REMOVE (remove properties/labels) +- ❌ DELETE (delete nodes/edges) +- ❌ MERGE (upsert semantics) + +**Expressions & Operators:** +- ❌ Arithmetic operations (+, -, *, /, %) +- ❌ Comparison operators (<, >, <=, >=, <>) +- ❌ Logical operators (AND, OR, NOT) in WHERE +- ❌ String operations (CONTAINS, STARTS WITH, ENDS WITH) +- ❌ List operations +- ❌ Map/record operations +- ❌ CASE expressions +- ❌ NULL handling (IS NULL, COALESCE) + +**Aggregations & Grouping:** +- ❌ Aggregation functions (COUNT, SUM, AVG, MIN, MAX) +- ❌ GROUP BY +- ❌ HAVING + +**Advanced Data Types:** +- ❌ Lists/Arrays +- ❌ Maps/Records +- ❌ Path type +- ❌ Temporal types (Date, Time, DateTime, Duration) +- ❌ Spatial types (Point, Geography) + +**Advanced Features:** +- ❌ Multiple named graphs +- ❌ Graph constructors +- ❌ Schema definitions and validation +- ❌ Constraints and indexes +- ❌ Functions (string, math, temporal, etc.) +- ❌ Subqueries +- ❌ Transactions + +**Conformance:** +- ❌ No formal conformance testing +- ❌ Not validated against official GQL test suite +- ❌ Syntax may differ from official spec + +### Roadmap to Compliance + +This project prioritizes learning over spec compliance. Future milestones: + +1. **Phase 1 (Current)**: Basic PoC - simple CRUD and queries ✅ +2. **Phase 2**: Complex patterns - multi-hop paths, variable-length +3. **Phase 3**: RETURN clause, projections, ORDER BY/LIMIT +4. **Phase 4**: UPDATE/DELETE operations +5. **Phase 5**: Aggregations and GROUP BY +6. **Phase 6**: Advanced expressions and operators +7. **Phase 7**: Schema, constraints, indexes +8. **Phase 8+**: Advanced types, multiple graphs, full spec compliance + +For official GQL resources, see: [ISO/IEC 39075:2024](https://www.iso.org/standard/76120.html) + +## Testing + +The project includes comprehensive test coverage: + +```bash +cargo test +``` + +Tests cover: +- Property graph data structures +- Storage operations (CRUD for nodes and edges) +- Query parser +- Query executor +- Edge traversal operations + +## License + +This project is open source and available under the MIT License. diff --git a/demo_commands.txt b/demo_commands.txt new file mode 100644 index 000000000..6df1948ec --- /dev/null +++ b/demo_commands.txt @@ -0,0 +1,10 @@ +HELP +CREATE NODE (:Person) {name: "Alice", age: 30} +CREATE NODE (:Person) {name: "Bob", age: 25} +CREATE NODE (:Company) {name: "TechCorp"} +SHOW NODES +CREATE EDGE (REPLACE_WITH_ALICE_ID)-[KNOWS]->(REPLACE_WITH_BOB_ID) {since: 2020} +SHOW EDGES +MATCH (:Person) +MATCH (:Person) WHERE name = "Alice" +EXIT diff --git a/schema.graphql.example b/schema.graphql.example deleted file mode 100644 index 9fc28c228..000000000 --- a/schema.graphql.example +++ /dev/null @@ -1,14 +0,0 @@ -schema { - query: Query -} -type Query { - users: [User!]!, -} -""" - Example user object - - This is just a demo comment. -""" -type User { - name: String!, -} diff --git a/src/graph/mod.rs b/src/graph/mod.rs new file mode 100644 index 000000000..4b7a27633 --- /dev/null +++ b/src/graph/mod.rs @@ -0,0 +1,5 @@ +pub mod types; +pub mod storage; + +pub use types::{Node, Edge, Property, PropertyValue, NodeId, EdgeId}; +pub use storage::GraphStorage; diff --git a/src/graph/storage.rs b/src/graph/storage.rs new file mode 100644 index 000000000..a104281dd --- /dev/null +++ b/src/graph/storage.rs @@ -0,0 +1,201 @@ +use crate::graph::types::{Edge, EdgeId, Node, NodeId}; +use anyhow::Result; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +#[derive(Clone)] +pub struct GraphStorage { + nodes: Arc>>, + edges: Arc>>, +} + +impl GraphStorage { + pub fn new() -> Self { + GraphStorage { + nodes: Arc::new(RwLock::new(HashMap::new())), + edges: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub fn create_node(&self, node: &Node) -> Result<()> { + let mut nodes = self.nodes.write().unwrap(); + nodes.insert(node.id.clone(), node.clone()); + Ok(()) + } + + pub fn get_node(&self, id: &NodeId) -> Result> { + let nodes = self.nodes.read().unwrap(); + Ok(nodes.get(id).cloned()) + } + + pub fn get_all_nodes(&self) -> Result> { + let nodes = self.nodes.read().unwrap(); + Ok(nodes.values().cloned().collect()) + } + + pub fn delete_node(&self, id: &NodeId) -> Result<()> { + let mut nodes = self.nodes.write().unwrap(); + nodes.remove(id); + Ok(()) + } + + pub fn create_edge(&self, edge: &Edge) -> Result<()> { + let mut edges = self.edges.write().unwrap(); + edges.insert(edge.id.clone(), edge.clone()); + Ok(()) + } + + pub fn get_edge(&self, id: &EdgeId) -> Result> { + let edges = self.edges.read().unwrap(); + Ok(edges.get(id).cloned()) + } + + pub fn get_all_edges(&self) -> Result> { + let edges = self.edges.read().unwrap(); + Ok(edges.values().cloned().collect()) + } + + pub fn get_edges_from_node(&self, node_id: &NodeId) -> Result> { + let edges = self.edges.read().unwrap(); + Ok(edges + .values() + .filter(|edge| &edge.source == node_id) + .cloned() + .collect()) + } + + pub fn get_edges_to_node(&self, node_id: &NodeId) -> Result> { + let edges = self.edges.read().unwrap(); + Ok(edges + .values() + .filter(|edge| &edge.target == node_id) + .cloned() + .collect()) + } + + pub fn delete_edge(&self, id: &EdgeId) -> Result<()> { + let mut edges = self.edges.write().unwrap(); + edges.remove(id); + Ok(()) + } + + pub fn clear(&self) -> Result<()> { + let mut nodes = self.nodes.write().unwrap(); + let mut edges = self.edges.write().unwrap(); + nodes.clear(); + edges.clear(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::types::{Property, PropertyValue}; + use std::collections::HashMap; + + #[test] + fn test_node_crud() { + let storage = GraphStorage::new(); + + let mut props = HashMap::new(); + props.insert("name".to_string(), PropertyValue::String("Alice".to_string())); + + let node = Node::new(vec!["Person".to_string()], props); + let node_id = node.id.clone(); + + storage.create_node(&node).unwrap(); + + let retrieved = storage.get_node(&node_id).unwrap(); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().id, node_id); + + storage.delete_node(&node_id).unwrap(); + + let deleted = storage.get_node(&node_id).unwrap(); + assert!(deleted.is_none()); + } + + #[test] + fn test_edge_crud() { + let storage = GraphStorage::new(); + + let node1 = Node::new(vec!["Person".to_string()], HashMap::new()); + let node2 = Node::new(vec!["Person".to_string()], HashMap::new()); + + storage.create_node(&node1).unwrap(); + storage.create_node(&node2).unwrap(); + + let mut props = HashMap::new(); + props.insert("since".to_string(), PropertyValue::Integer(2020)); + + let edge = Edge::new( + node1.id.clone(), + node2.id.clone(), + "KNOWS".to_string(), + props, + ); + let edge_id = edge.id.clone(); + + storage.create_edge(&edge).unwrap(); + + let retrieved = storage.get_edge(&edge_id).unwrap(); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().id, edge_id); + + storage.delete_edge(&edge_id).unwrap(); + + let deleted = storage.get_edge(&edge_id).unwrap(); + assert!(deleted.is_none()); + } + + #[test] + fn test_get_edges_from_node() { + let storage = GraphStorage::new(); + + let node1 = Node::new(vec!["Person".to_string()], HashMap::new()); + let node2 = Node::new(vec!["Person".to_string()], HashMap::new()); + let node3 = Node::new(vec!["Person".to_string()], HashMap::new()); + + storage.create_node(&node1).unwrap(); + storage.create_node(&node2).unwrap(); + storage.create_node(&node3).unwrap(); + + let edge1 = Edge::new( + node1.id.clone(), + node2.id.clone(), + "KNOWS".to_string(), + HashMap::new(), + ); + + let edge2 = Edge::new( + node1.id.clone(), + node3.id.clone(), + "KNOWS".to_string(), + HashMap::new(), + ); + + storage.create_edge(&edge1).unwrap(); + storage.create_edge(&edge2).unwrap(); + + let edges_from_node1 = storage.get_edges_from_node(&node1.id).unwrap(); + assert_eq!(edges_from_node1.len(), 2); + + let edges_from_node2 = storage.get_edges_from_node(&node2.id).unwrap(); + assert_eq!(edges_from_node2.len(), 0); + } + + #[test] + fn test_get_all_nodes() { + let storage = GraphStorage::new(); + + let node1 = Node::new(vec!["Person".to_string()], HashMap::new()); + let node2 = Node::new(vec!["Person".to_string()], HashMap::new()); + + storage.create_node(&node1).unwrap(); + storage.create_node(&node2).unwrap(); + + let all_nodes = storage.get_all_nodes().unwrap(); + assert_eq!(all_nodes.len(), 2); + } +} diff --git a/src/graph/types.rs b/src/graph/types.rs new file mode 100644 index 000000000..070f722f4 --- /dev/null +++ b/src/graph/types.rs @@ -0,0 +1,174 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +pub type NodeId = String; +pub type EdgeId = String; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum PropertyValue { + String(String), + Integer(i64), + Float(f64), + Boolean(bool), + Null, +} + +impl PropertyValue { + pub fn as_string(&self) -> Option<&str> { + match self { + PropertyValue::String(s) => Some(s), + _ => None, + } + } + + pub fn as_integer(&self) -> Option { + match self { + PropertyValue::Integer(i) => Some(*i), + _ => None, + } + } + + pub fn as_float(&self) -> Option { + match self { + PropertyValue::Float(f) => Some(*f), + _ => None, + } + } + + pub fn as_boolean(&self) -> Option { + match self { + PropertyValue::Boolean(b) => Some(*b), + _ => None, + } + } +} + +pub type Property = HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Node { + pub id: NodeId, + pub labels: Vec, + pub properties: Property, +} + +impl Node { + pub fn new(labels: Vec, properties: Property) -> Self { + Node { + id: Uuid::new_v4().to_string(), + labels, + properties, + } + } + + pub fn with_id(id: NodeId, labels: Vec, properties: Property) -> Self { + Node { + id, + labels, + properties, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Edge { + pub id: EdgeId, + pub source: NodeId, + pub target: NodeId, + pub label: String, + pub properties: Property, +} + +impl Edge { + pub fn new(source: NodeId, target: NodeId, label: String, properties: Property) -> Self { + Edge { + id: Uuid::new_v4().to_string(), + source, + target, + label, + properties, + } + } + + pub fn with_id( + id: EdgeId, + source: NodeId, + target: NodeId, + label: String, + properties: Property, + ) -> Self { + Edge { + id, + source, + target, + label, + properties, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_node_creation() { + let mut props = HashMap::new(); + props.insert("name".to_string(), PropertyValue::String("Alice".to_string())); + props.insert("age".to_string(), PropertyValue::Integer(30)); + + let node = Node::new(vec!["Person".to_string()], props); + + assert_eq!(node.labels, vec!["Person"]); + assert_eq!( + node.properties.get("name"), + Some(&PropertyValue::String("Alice".to_string())) + ); + assert_eq!( + node.properties.get("age"), + Some(&PropertyValue::Integer(30)) + ); + } + + #[test] + fn test_edge_creation() { + let source_id = "node1".to_string(); + let target_id = "node2".to_string(); + + let mut props = HashMap::new(); + props.insert("since".to_string(), PropertyValue::Integer(2020)); + + let edge = Edge::new( + source_id.clone(), + target_id.clone(), + "KNOWS".to_string(), + props, + ); + + assert_eq!(edge.source, source_id); + assert_eq!(edge.target, target_id); + assert_eq!(edge.label, "KNOWS"); + assert_eq!( + edge.properties.get("since"), + Some(&PropertyValue::Integer(2020)) + ); + } + + #[test] + fn test_property_value_accessors() { + let string_val = PropertyValue::String("test".to_string()); + assert_eq!(string_val.as_string(), Some("test")); + assert_eq!(string_val.as_integer(), None); + + let int_val = PropertyValue::Integer(42); + assert_eq!(int_val.as_integer(), Some(42)); + assert_eq!(int_val.as_string(), None); + + let float_val = PropertyValue::Float(3.14); + assert_eq!(float_val.as_float(), Some(3.14)); + + let bool_val = PropertyValue::Boolean(true); + assert_eq!(bool_val.as_boolean(), Some(true)); + } +} diff --git a/src/lib.rs b/src/lib.rs index a3022a6ea..47680443c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,7 @@ -pub use graphql_parser::{parse_query, parse_schema}; +pub mod graph; +pub mod query; +pub mod repl; + +pub use graph::{GraphStorage, Node, Edge, Property, PropertyValue}; +pub use query::{parse_query, Query, QueryExecutor}; +pub use repl::Repl; diff --git a/src/main.rs b/src/main.rs index d76286423..df5f2f0f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,56 +1,10 @@ -use std::io::Write; -use std::{env, fs, io, process}; - -use minigraf::{parse_query, parse_schema}; -use server::error_codes::ErrorCode; -use server::logger; - -mod server; +use minigraf::{GraphStorage, Repl}; fn main() { - // Get the schema file from the command line arguments or use the default. - let default_schema_file = "schema.graphql".to_string(); - let schema_file = &env::args().nth(1).unwrap_or(default_schema_file); - logger::debug_log("schema_file", schema_file); - - // Read the schema file and parse it into a schema object. - let schema_file = fs::read_to_string(schema_file).unwrap_or_else(|err| { - logger::error_log( - ErrorCode::InvalidSchemaFile, - "Couldn't read schema file", - err, - ); - process::exit(ErrorCode::InvalidSchemaFile as i32); - }); - - let schema = parse_schema::(&schema_file).unwrap_or_else(|err| { - logger::error_log(ErrorCode::InvalidSchema, "Couldn't parse schema", err); - process::exit(ErrorCode::InvalidSchema as i32); - }); - logger::debug_log("schema", &schema); - - // Start a Read-Eval-Print-Loop (REPL) for the user to enter queries. - loop { - // Prompt the user for a query. - print!("> "); - io::stdout().flush().unwrap(); - - // Get the query from the user. - let mut query = String::new(); - io::stdin().read_line(&mut query).unwrap(); - let query = query.trim(); + println!("Minigraf v0.1.0 - Graph Query Language Engine"); + println!("Using in-memory storage\n"); - // Parse the query into a query object. - let query = parse_query::(query); - if query.is_err() { - logger::error_log( - ErrorCode::BadQuery, - "Couldn't parse query", - query.err().unwrap(), - ); - continue; - } - let query = query.unwrap(); - logger::debug_log("query", &query); - } + let storage = GraphStorage::new(); + let repl = Repl::new(storage); + repl.run(); } diff --git a/src/query/executor.rs b/src/query/executor.rs new file mode 100644 index 000000000..14b60d31f --- /dev/null +++ b/src/query/executor.rs @@ -0,0 +1,336 @@ +use crate::graph::storage::GraphStorage; +use crate::graph::types::{Edge, Node}; +use crate::query::parser::Query; +use anyhow::Result; + +pub struct QueryExecutor<'a> { + storage: &'a GraphStorage, +} + +impl<'a> QueryExecutor<'a> { + pub fn new(storage: &'a GraphStorage) -> Self { + QueryExecutor { storage } + } + + pub fn execute(&self, query: Query) -> Result { + match query { + Query::CreateNode { labels, properties } => { + let node = Node::new(labels, properties); + self.storage.create_node(&node)?; + Ok(QueryResult::NodeCreated(node)) + } + Query::CreateEdge { + source_id, + target_id, + label, + properties, + } => { + if self.storage.get_node(&source_id)?.is_none() { + return Ok(QueryResult::Error(format!( + "Source node '{}' does not exist", + source_id + ))); + } + + if self.storage.get_node(&target_id)?.is_none() { + return Ok(QueryResult::Error(format!( + "Target node '{}' does not exist", + target_id + ))); + } + + let edge = Edge::new(source_id, target_id, label, properties); + self.storage.create_edge(&edge)?; + Ok(QueryResult::EdgeCreated(edge)) + } + Query::MatchNodes { + label, + property_filter, + } => { + let all_nodes = self.storage.get_all_nodes()?; + + let filtered_nodes: Vec = all_nodes + .into_iter() + .filter(|node| { + let label_match = label + .as_ref() + .map(|l| node.labels.contains(l)) + .unwrap_or(true); + + let property_match = property_filter + .as_ref() + .map(|(key, value)| { + node.properties.get(key).map(|v| v == value).unwrap_or(false) + }) + .unwrap_or(true); + + label_match && property_match + }) + .collect(); + + Ok(QueryResult::Nodes(filtered_nodes)) + } + Query::MatchEdges { label } => { + let all_edges = self.storage.get_all_edges()?; + + let filtered_edges: Vec = all_edges + .into_iter() + .filter(|edge| { + label + .as_ref() + .map(|l| &edge.label == l) + .unwrap_or(true) + }) + .collect(); + + Ok(QueryResult::Edges(filtered_edges)) + } + Query::ShowNodes => { + let nodes = self.storage.get_all_nodes()?; + Ok(QueryResult::Nodes(nodes)) + } + Query::ShowEdges => { + let edges = self.storage.get_all_edges()?; + Ok(QueryResult::Edges(edges)) + } + Query::Help => Ok(QueryResult::Help), + Query::Exit => Ok(QueryResult::Exit), + } + } +} + +#[derive(Debug)] +pub enum QueryResult { + NodeCreated(Node), + EdgeCreated(Edge), + Nodes(Vec), + Edges(Vec), + Error(String), + Help, + Exit, +} + +impl QueryResult { + pub fn format(&self) -> String { + match self { + QueryResult::NodeCreated(node) => { + format!( + "Node created: {} (labels: {}, properties: {})", + node.id, + node.labels.join(", "), + format_properties(&node.properties) + ) + } + QueryResult::EdgeCreated(edge) => { + format!( + "Edge created: {} ({} -[{}]-> {}, properties: {})", + edge.id, + edge.source, + edge.label, + edge.target, + format_properties(&edge.properties) + ) + } + QueryResult::Nodes(nodes) => { + if nodes.is_empty() { + "No nodes found.".to_string() + } else { + let mut result = format!("Found {} node(s):\n", nodes.len()); + for node in nodes { + result.push_str(&format!( + " - {} (labels: {}, properties: {})\n", + node.id, + node.labels.join(", "), + format_properties(&node.properties) + )); + } + result + } + } + QueryResult::Edges(edges) => { + if edges.is_empty() { + "No edges found.".to_string() + } else { + let mut result = format!("Found {} edge(s):\n", edges.len()); + for edge in edges { + result.push_str(&format!( + " - {} ({} -[{}]-> {}, properties: {})\n", + edge.id, + edge.source, + edge.label, + edge.target, + format_properties(&edge.properties) + )); + } + result + } + } + QueryResult::Error(msg) => format!("Error: {}", msg), + QueryResult::Help => { + r#" +Minigraf Query Language Commands: + +CREATE NODE (:Label1:Label2) {prop1: "value", prop2: 123} + - Create a node with labels and properties + - Properties are optional + - Examples: + CREATE NODE (:Person) {name: "Alice", age: 30} + CREATE NODE (:Person:Employee) + +CREATE EDGE (source_id)-[LABEL]->(target_id) {prop: "value"} + - Create an edge between two existing nodes + - Properties are optional + - Example: + CREATE EDGE (node-id-1)-[KNOWS]->(node-id-2) {since: 2020} + +MATCH (:Label) [WHERE property = value] + - Find nodes by label and optional property filter + - Examples: + MATCH (:Person) + MATCH (:Person) WHERE name = "Alice" + +MATCH -[:LABEL]-> + - Find edges by label + - Example: + MATCH -[:KNOWS]-> + +SHOW NODES + - Show all nodes in the graph + +SHOW EDGES + - Show all edges in the graph + +HELP + - Show this help message + +EXIT or QUIT + - Exit the console +"# + .to_string() + } + QueryResult::Exit => "Goodbye!".to_string(), + } + } +} + +fn format_properties(props: &std::collections::HashMap) -> String { + if props.is_empty() { + return "{}".to_string(); + } + + let mut items: Vec = props + .iter() + .map(|(k, v)| format!("{}: {}", k, format_property_value(v))) + .collect(); + items.sort(); + + format!("{{{}}}", items.join(", ")) +} + +fn format_property_value(value: &crate::graph::types::PropertyValue) -> String { + match value { + crate::graph::types::PropertyValue::String(s) => format!("\"{}\"", s), + crate::graph::types::PropertyValue::Integer(i) => i.to_string(), + crate::graph::types::PropertyValue::Float(f) => f.to_string(), + crate::graph::types::PropertyValue::Boolean(b) => b.to_string(), + crate::graph::types::PropertyValue::Null => "null".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::types::{Property, PropertyValue}; + use crate::query::parser::parse_query; + use std::collections::HashMap; + + #[test] + fn test_create_node() { + let storage = GraphStorage::new(); + let executor = QueryExecutor::new(&storage); + + let query = parse_query("CREATE NODE (:Person) {name: \"Alice\"}").unwrap(); + let result = executor.execute(query).unwrap(); + + match result { + QueryResult::NodeCreated(node) => { + assert_eq!(node.labels, vec!["Person"]); + assert_eq!( + node.properties.get("name"), + Some(&PropertyValue::String("Alice".to_string())) + ); + } + _ => panic!("Expected NodeCreated"), + } + } + + #[test] + fn test_create_edge() { + let storage = GraphStorage::new(); + let executor = QueryExecutor::new(&storage); + + let node1 = Node::new(vec!["Person".to_string()], HashMap::new()); + let node2 = Node::new(vec!["Person".to_string()], HashMap::new()); + storage.create_node(&node1).unwrap(); + storage.create_node(&node2).unwrap(); + + let query_str = format!( + "CREATE EDGE ({})-[KNOWS]->({}) {{since: 2020}}", + node1.id, node2.id + ); + let query = parse_query(&query_str).unwrap(); + let result = executor.execute(query).unwrap(); + + match result { + QueryResult::EdgeCreated(edge) => { + assert_eq!(edge.label, "KNOWS"); + assert_eq!(edge.source, node1.id); + assert_eq!(edge.target, node2.id); + } + _ => panic!("Expected EdgeCreated"), + } + } + + #[test] + fn test_match_nodes() { + let storage = GraphStorage::new(); + let executor = QueryExecutor::new(&storage); + + let mut props = HashMap::new(); + props.insert("name".to_string(), PropertyValue::String("Alice".to_string())); + let node = Node::new(vec!["Person".to_string()], props); + storage.create_node(&node).unwrap(); + + let query = parse_query("MATCH (:Person)").unwrap(); + let result = executor.execute(query).unwrap(); + + match result { + QueryResult::Nodes(nodes) => { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id, node.id); + } + _ => panic!("Expected Nodes"), + } + } + + #[test] + fn test_show_nodes() { + let storage = GraphStorage::new(); + let executor = QueryExecutor::new(&storage); + + let node1 = Node::new(vec!["Person".to_string()], HashMap::new()); + let node2 = Node::new(vec!["Person".to_string()], HashMap::new()); + storage.create_node(&node1).unwrap(); + storage.create_node(&node2).unwrap(); + + let query = parse_query("SHOW NODES").unwrap(); + let result = executor.execute(query).unwrap(); + + match result { + QueryResult::Nodes(nodes) => { + assert_eq!(nodes.len(), 2); + } + _ => panic!("Expected Nodes"), + } + } +} diff --git a/src/query/mod.rs b/src/query/mod.rs new file mode 100644 index 000000000..9e0b8263e --- /dev/null +++ b/src/query/mod.rs @@ -0,0 +1,5 @@ +pub mod parser; +pub mod executor; + +pub use parser::{parse_query, Query}; +pub use executor::QueryExecutor; diff --git a/src/query/parser.rs b/src/query/parser.rs new file mode 100644 index 000000000..74c0aac15 --- /dev/null +++ b/src/query/parser.rs @@ -0,0 +1,403 @@ +use crate::graph::types::{Property, PropertyValue}; +use anyhow::{anyhow, Result}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq)] +pub enum Query { + CreateNode { + labels: Vec, + properties: Property, + }, + CreateEdge { + source_id: String, + target_id: String, + label: String, + properties: Property, + }, + MatchNodes { + label: Option, + property_filter: Option<(String, PropertyValue)>, + }, + MatchEdges { + label: Option, + }, + ShowNodes, + ShowEdges, + Help, + Exit, +} + +pub fn parse_query(input: &str) -> Result { + let input = input.trim(); + + if input.is_empty() { + return Err(anyhow!("Empty query")); + } + + let input_upper = input.to_uppercase(); + + if input_upper == "EXIT" || input_upper == "QUIT" { + return Ok(Query::Exit); + } + + if input_upper == "HELP" { + return Ok(Query::Help); + } + + if input_upper.starts_with("SHOW NODES") { + return Ok(Query::ShowNodes); + } + + if input_upper.starts_with("SHOW EDGES") { + return Ok(Query::ShowEdges); + } + + if input_upper.starts_with("CREATE NODE") { + return parse_create_node(input); + } + + if input_upper.starts_with("CREATE EDGE") { + return parse_create_edge(input); + } + + if input_upper.starts_with("MATCH") { + return parse_match(input); + } + + Err(anyhow!("Unknown query type. Type HELP for available commands.")) +} + +fn parse_create_node(input: &str) -> Result { + let input = input.trim(); + let rest = input.strip_prefix("CREATE NODE").or_else(|| input.strip_prefix("create node")) + .ok_or_else(|| anyhow!("Invalid CREATE NODE syntax"))? + .trim(); + + let (labels, properties) = if rest.contains('{') { + let parts: Vec<&str> = rest.splitn(2, '{').collect(); + let label_part = parts[0].trim(); + let prop_part = parts.get(1) + .ok_or_else(|| anyhow!("Unclosed property block"))? + .trim_end_matches('}') + .trim(); + + let labels = parse_labels(label_part)?; + let properties = parse_properties(prop_part)?; + (labels, properties) + } else { + let labels = parse_labels(rest)?; + (labels, HashMap::new()) + }; + + Ok(Query::CreateNode { labels, properties }) +} + +fn parse_create_edge(input: &str) -> Result { + let input = input.trim(); + let rest = input.strip_prefix("CREATE EDGE").or_else(|| input.strip_prefix("create edge")) + .ok_or_else(|| anyhow!("Invalid CREATE EDGE syntax"))? + .trim(); + + let (edge_pattern, properties) = if rest.contains('{') { + let parts: Vec<&str> = rest.splitn(2, '{').collect(); + let pattern = parts[0].trim(); + let prop_part = parts.get(1) + .ok_or_else(|| anyhow!("Unclosed property block"))? + .trim_end_matches('}') + .trim(); + + let properties = parse_properties(prop_part)?; + (pattern, properties) + } else { + (rest, HashMap::new()) + }; + + if !edge_pattern.contains("->") { + return Err(anyhow!("Edge pattern must contain '->' (e.g., (source_id)-[LABEL]->(target_id))")); + } + + let parts: Vec<&str> = edge_pattern.split("->").collect(); + if parts.len() != 2 { + return Err(anyhow!("Invalid edge pattern")); + } + + let source_part = parts[0].trim(); + let target_part = parts[1].trim(); + + let source_id = source_part + .trim_start_matches('(') + .trim_end_matches(')') + .split(")-[") + .next() + .ok_or_else(|| anyhow!("Invalid source node ID"))? + .trim() + .to_string(); + + let label = source_part + .split(")-[") + .nth(1) + .ok_or_else(|| anyhow!("Invalid edge label"))? + .trim_end_matches(']') + .trim() + .to_string(); + + let target_id = target_part + .trim_start_matches('(') + .trim_end_matches(')') + .trim() + .to_string(); + + Ok(Query::CreateEdge { + source_id, + target_id, + label, + properties, + }) +} + +fn parse_match(input: &str) -> Result { + let input = input.trim(); + let rest = input.strip_prefix("MATCH").or_else(|| input.strip_prefix("match")) + .ok_or_else(|| anyhow!("Invalid MATCH syntax"))? + .trim(); + + if rest.starts_with('(') && rest.contains(')') { + let node_pattern = rest + .trim_start_matches('(') + .split(')') + .next() + .ok_or_else(|| anyhow!("Invalid node pattern"))? + .trim(); + + let remaining = rest.split(')').skip(1).collect::(); + + let (label, property_filter) = if node_pattern.contains(':') { + let parts: Vec<&str> = node_pattern.splitn(2, ':').collect(); + let label = Some(parts[1].trim().to_string()); + + if remaining.trim().to_uppercase().starts_with("WHERE") { + let where_clause = remaining.trim().strip_prefix("WHERE") + .or_else(|| remaining.trim().strip_prefix("where")) + .ok_or_else(|| anyhow!("Invalid WHERE clause"))? + .trim(); + + let filter = parse_where_clause(where_clause)?; + (label, Some(filter)) + } else { + (label, None) + } + } else { + (None, None) + }; + + Ok(Query::MatchNodes { + label, + property_filter, + }) + } else if rest.starts_with('-') || rest.starts_with('[') { + let label = if rest.contains('[') && rest.contains(']') { + let label_str = rest + .split('[') + .nth(1) + .ok_or_else(|| anyhow!("Invalid edge pattern"))? + .split(']') + .next() + .ok_or_else(|| anyhow!("Invalid edge pattern"))? + .trim(); + + if label_str.is_empty() { + None + } else { + Some(label_str.to_string()) + } + } else { + None + }; + + Ok(Query::MatchEdges { label }) + } else { + Err(anyhow!("Invalid MATCH pattern. Use (:Label) for nodes or -[:LABEL]-> for edges")) + } +} + +fn parse_labels(input: &str) -> Result> { + let input = input.trim() + .trim_start_matches('(') + .trim_end_matches(')') + .trim(); + + if input.is_empty() { + return Ok(Vec::new()); + } + + Ok(input + .split(':') + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().to_string()) + .collect()) +} + +fn parse_properties(input: &str) -> Result { + let mut properties = HashMap::new(); + + if input.is_empty() { + return Ok(properties); + } + + for pair in input.split(',') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + + let parts: Vec<&str> = pair.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(anyhow!("Invalid property format: '{}'", pair)); + } + + let key = parts[0].trim().trim_matches('"').to_string(); + let value_str = parts[1].trim(); + + let value = parse_property_value(value_str)?; + properties.insert(key, value); + } + + Ok(properties) +} + +fn parse_property_value(input: &str) -> Result { + let input = input.trim(); + + if input.starts_with('"') && input.ends_with('"') { + let s = input.trim_matches('"').to_string(); + return Ok(PropertyValue::String(s)); + } + + if input == "true" { + return Ok(PropertyValue::Boolean(true)); + } + + if input == "false" { + return Ok(PropertyValue::Boolean(false)); + } + + if input == "null" { + return Ok(PropertyValue::Null); + } + + if let Ok(i) = input.parse::() { + return Ok(PropertyValue::Integer(i)); + } + + if let Ok(f) = input.parse::() { + return Ok(PropertyValue::Float(f)); + } + + Err(anyhow!("Cannot parse property value: '{}'", input)) +} + +fn parse_where_clause(input: &str) -> Result<(String, PropertyValue)> { + let input = input.trim(); + + if input.contains('=') { + let parts: Vec<&str> = input.splitn(2, '=').collect(); + if parts.len() != 2 { + return Err(anyhow!("Invalid WHERE clause")); + } + + let key = parts[0].trim().to_string(); + let value = parse_property_value(parts[1].trim())?; + + Ok((key, value)) + } else { + Err(anyhow!("WHERE clause must use '=' operator")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_create_node() { + let query = parse_query("CREATE NODE (:Person) {name: \"Alice\", age: 30}").unwrap(); + match query { + Query::CreateNode { labels, properties } => { + assert_eq!(labels, vec!["Person"]); + assert_eq!(properties.get("name"), Some(&PropertyValue::String("Alice".to_string()))); + assert_eq!(properties.get("age"), Some(&PropertyValue::Integer(30))); + } + _ => panic!("Expected CreateNode"), + } + } + + #[test] + fn test_parse_create_node_no_props() { + let query = parse_query("CREATE NODE (:Person)").unwrap(); + match query { + Query::CreateNode { labels, properties } => { + assert_eq!(labels, vec!["Person"]); + assert!(properties.is_empty()); + } + _ => panic!("Expected CreateNode"), + } + } + + #[test] + fn test_parse_create_edge() { + let query = parse_query("CREATE EDGE (node1)-[KNOWS]->(node2) {since: 2020}").unwrap(); + match query { + Query::CreateEdge { source_id, target_id, label, properties } => { + assert_eq!(source_id, "node1"); + assert_eq!(target_id, "node2"); + assert_eq!(label, "KNOWS"); + assert_eq!(properties.get("since"), Some(&PropertyValue::Integer(2020))); + } + _ => panic!("Expected CreateEdge"), + } + } + + #[test] + fn test_parse_match_nodes() { + let query = parse_query("MATCH (:Person)").unwrap(); + match query { + Query::MatchNodes { label, property_filter } => { + assert_eq!(label, Some("Person".to_string())); + assert_eq!(property_filter, None); + } + _ => panic!("Expected MatchNodes"), + } + } + + #[test] + fn test_parse_match_nodes_with_where() { + let query = parse_query("MATCH (:Person) WHERE name = \"Alice\"").unwrap(); + match query { + Query::MatchNodes { label, property_filter } => { + assert_eq!(label, Some("Person".to_string())); + assert_eq!( + property_filter, + Some(("name".to_string(), PropertyValue::String("Alice".to_string()))) + ); + } + _ => panic!("Expected MatchNodes"), + } + } + + #[test] + fn test_parse_show_commands() { + assert_eq!(parse_query("SHOW NODES").unwrap(), Query::ShowNodes); + assert_eq!(parse_query("SHOW EDGES").unwrap(), Query::ShowEdges); + } + + #[test] + fn test_parse_exit() { + assert_eq!(parse_query("EXIT").unwrap(), Query::Exit); + assert_eq!(parse_query("QUIT").unwrap(), Query::Exit); + } + + #[test] + fn test_parse_help() { + assert_eq!(parse_query("HELP").unwrap(), Query::Help); + } +} diff --git a/src/repl.rs b/src/repl.rs new file mode 100644 index 000000000..28cc6f46b --- /dev/null +++ b/src/repl.rs @@ -0,0 +1,69 @@ +use crate::graph::storage::GraphStorage; +use crate::query::executor::{QueryExecutor, QueryResult}; +use crate::query::parser::parse_query; +use std::io::{self, Write}; + +pub struct Repl { + storage: GraphStorage, +} + +impl Repl { + pub fn new(storage: GraphStorage) -> Self { + Repl { storage } + } + + pub fn run(&self) { + println!("Minigraf v0.1.0 - Interactive Graph Query Console"); + println!("Type HELP for available commands, EXIT to quit.\n"); + + let executor = QueryExecutor::new(&self.storage); + + loop { + print!("minigraf> "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + match io::stdin().read_line(&mut input) { + Ok(n) => { + // EOF reached (stdin closed) + if n == 0 { + break; + } + + let input = input.trim(); + + if input.is_empty() { + continue; + } + + match parse_query(input) { + Ok(query) => { + match executor.execute(query) { + Ok(result) => { + let formatted = result.format(); + println!("{}", formatted); + + if matches!(result, QueryResult::Exit) { + break; + } + } + Err(e) => { + eprintln!("Execution error: {}", e); + } + } + } + Err(e) => { + eprintln!("Parse error: {}", e); + } + } + + println!(); + } + Err(e) => { + eprintln!("Error reading input: {}", e); + break; + } + } + } + } +} diff --git a/src/server/error_codes.rs b/src/server/error_codes.rs deleted file mode 100644 index 76c454630..000000000 --- a/src/server/error_codes.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::fmt::{Display, Formatter, Result}; - -pub enum ErrorCode { - InvalidSchemaFile = 1, - InvalidSchema = 2, - BadQuery = 3, -} - -impl Display for ErrorCode { - fn fmt(&self, f: &mut Formatter<'_>) -> Result { - let error_code = match self { - ErrorCode::InvalidSchemaFile => "InvalidSchemaFile", - ErrorCode::InvalidSchema => "InvalidSchema", - ErrorCode::BadQuery => "BadQuery", - }; - write!(f, "{}", error_code) - } -} diff --git a/src/server/logger.rs b/src/server/logger.rs deleted file mode 100644 index 171502b60..000000000 --- a/src/server/logger.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::env; -use std::fmt::{Debug, Display}; -use std::str::FromStr; - -use lazy_static::lazy_static; -use crate::server::error_codes::ErrorCode; - -#[derive(PartialEq, PartialOrd)] -pub enum LogLevel { - Error, - Warn, - Info, - Debug, - Trace, -} - -impl FromStr for LogLevel { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "Error" => Ok(LogLevel::Error), - "Warn" => Ok(LogLevel::Warn), - "Info" => Ok(LogLevel::Info), - "Debug" => Ok(LogLevel::Debug), - "Trace" => Ok(LogLevel::Trace), - _ => Err(()), - } - } -} - -lazy_static! { - static ref LOG_LEVEL: LogLevel = { - let log_level = env::var("LOG_LEVEL").unwrap_or("Info".to_string()); - log_level.parse().unwrap() - }; -} - -pub fn error_log(error_code: ErrorCode, message: &str, err: E) -where - E: Display, -{ - eprintln!("ERROR {}: {}: {}", error_code, message, err); -} - -pub fn debug_log(key: &str, object: &T) -where - T: Debug, -{ - if *LOG_LEVEL < LogLevel::Debug { - return; - } - - println!("DEBUG {}: {:#?}", key, object); -} diff --git a/src/server/mod.rs b/src/server/mod.rs deleted file mode 100644 index 3e6e48816..000000000 --- a/src/server/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod error_codes; -pub mod logger; diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 000000000..ddb56382c --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,56 @@ +use minigraf::{GraphStorage, parse_query, QueryExecutor}; + +#[test] +fn test_complete_workflow() { + let storage = GraphStorage::new(); + let executor = QueryExecutor::new(&storage); + + // Create nodes + let create_alice = parse_query("CREATE NODE (:Person) {name: \"Alice\", age: 30}").unwrap(); + let result = executor.execute(create_alice).unwrap(); + + let alice_id = match result { + minigraf::query::executor::QueryResult::NodeCreated(node) => { + println!("Created Alice: {}", node.id); + node.id + } + _ => panic!("Expected NodeCreated"), + }; + + let create_bob = parse_query("CREATE NODE (:Person:Employee) {name: \"Bob\", age: 25}").unwrap(); + let result = executor.execute(create_bob).unwrap(); + + let bob_id = match result { + minigraf::query::executor::QueryResult::NodeCreated(node) => { + println!("Created Bob: {}", node.id); + node.id + } + _ => panic!("Expected NodeCreated"), + }; + + // Show all nodes + let show_nodes = parse_query("SHOW NODES").unwrap(); + let result = executor.execute(show_nodes).unwrap(); + println!("\n{}", result.format()); + + // Create edge + let create_edge_query = format!("CREATE EDGE ({})-[KNOWS]->({}) {{since: 2020}}", alice_id, bob_id); + let create_edge = parse_query(&create_edge_query).unwrap(); + let result = executor.execute(create_edge).unwrap(); + println!("\n{}", result.format()); + + // Show all edges + let show_edges = parse_query("SHOW EDGES").unwrap(); + let result = executor.execute(show_edges).unwrap(); + println!("\n{}", result.format()); + + // Match Person nodes + let match_persons = parse_query("MATCH (:Person)").unwrap(); + let result = executor.execute(match_persons).unwrap(); + println!("\n{}", result.format()); + + // Match with filter + let match_alice = parse_query("MATCH (:Person) WHERE name = \"Alice\"").unwrap(); + let result = executor.execute(match_alice).unwrap(); + println!("\n{}", result.format()); +}