coffy 0.1.2__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {coffy-0.1.2/coffy.egg-info → coffy-0.1.5}/PKG-INFO +30 -25
- {coffy-0.1.2 → coffy-0.1.5}/README.md +29 -24
- coffy-0.1.5/coffy/graph/__pycache__/graphdb_nx.cpython-312.pyc +0 -0
- coffy-0.1.5/coffy/graph/graph_tests.py +137 -0
- coffy-0.1.5/coffy/graph/graphdb_nx.py +379 -0
- coffy-0.1.5/coffy/nosql/__pycache__/engine.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/nosql/engine.py +37 -15
- coffy-0.1.5/coffy/nosql/nosql_tests.py +104 -0
- {coffy-0.1.2 → coffy-0.1.5/coffy.egg-info}/PKG-INFO +30 -25
- {coffy-0.1.2 → coffy-0.1.5}/coffy.egg-info/SOURCES.txt +2 -0
- {coffy-0.1.2 → coffy-0.1.5}/setup.py +1 -1
- coffy-0.1.2/coffy/graph/__pycache__/graphdb_nx.cpython-312.pyc +0 -0
- coffy-0.1.2/coffy/graph/graphdb_nx.py +0 -125
- coffy-0.1.2/coffy/nosql/__pycache__/engine.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/LICENSE +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/MANIFEST.in +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/__init__.py +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/__pycache__/__init__.cpython-311.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/__pycache__/__init__.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/graph/__init__.py +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/graph/__pycache__/__init__.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/nosql/__init__.py +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/nosql/__pycache__/__init__.cpython-311.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/nosql/__pycache__/__init__.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/nosql/__pycache__/engine.cpython-311.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__init__.py +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__pycache__/__init__.cpython-311.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__pycache__/__init__.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__pycache__/engine.cpython-311.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__pycache__/engine.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__pycache__/io.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__pycache__/sqldict.cpython-311.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/__pycache__/sqldict.cpython-312.pyc +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/engine.py +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy/sql/sqldict.py +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy.egg-info/dependency_links.txt +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy.egg-info/requires.txt +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/coffy.egg-info/top_level.txt +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/pyproject.toml +0 -0
- {coffy-0.1.2 → coffy-0.1.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: coffy
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.5
|
4
4
|
Summary: Lightweight local NoSQL, SQL, and Graph embedded database engine
|
5
5
|
Author: nsarathy
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
@@ -24,10 +24,15 @@ Dynamic: summary
|
|
24
24
|
**Coffy** is a lightweight embedded database engine for Python, designed for local-first apps, scripts, and tools. It includes:
|
25
25
|
|
26
26
|
- `coffy.nosql`: A simple JSON-backed NoSQL engine with a fluent, chainable query interface
|
27
|
-
- `coffy.
|
28
|
-
- `coffy.
|
27
|
+
- `coffy.graph`: An graph engine built on `networkx` with advanced filtering and logic-based querying
|
28
|
+
- `coffy.sql`: A wrapper over `SQLite` for executing raw SQL
|
29
29
|
|
30
|
-
|
30
|
+
---
|
31
|
+
## Latest Updates
|
32
|
+
- Added projection and dot-notation support for nested fields in NoSQL queries. Expanded logic chaining (`_and`, `_or`, `_not`) with improved test and better query semantics.
|
33
|
+
- GraphDB now supports saving query results, fixed relationship type serialization, and added more robust unit coverage.
|
34
|
+
- Unit tests added to test NoSQL and Graph database features.
|
35
|
+
- Documentation Updated.
|
31
36
|
|
32
37
|
---
|
33
38
|
|
@@ -43,31 +48,31 @@ pip install coffy
|
|
43
48
|
|
44
49
|
### `coffy.nosql`
|
45
50
|
|
46
|
-
-
|
47
|
-
-
|
48
|
-
-
|
51
|
+
- Embedded NoSQL document store with a fluent, chainable query API
|
52
|
+
- Supports nested fields, logical filters, aggregations, projections, and joins
|
53
|
+
- Built for local usage with optional persistence; minimal setup, fast iteration
|
49
54
|
|
50
55
|
📄 [NoSQL Documentation →](https://github.com/nsarathy/Coffy/blob/main/NOSQL_DOCS.md)
|
51
56
|
|
52
57
|
---
|
53
58
|
|
54
|
-
### `coffy.
|
59
|
+
### `coffy.graph`
|
55
60
|
|
56
|
-
-
|
57
|
-
-
|
58
|
-
-
|
61
|
+
- Lightweight, file-backed graph database using `networkx` under the hood
|
62
|
+
- Supports pattern matching, label/type filtering, logical conditions, and projections
|
63
|
+
- Query results can be saved, updated, or transformed; ideal for local, schema-flexible graph data
|
59
64
|
|
60
|
-
📄 [
|
65
|
+
📄 [Graph Documentation →](https://github.com/nsarathy/Coffy/blob/main/GRAPH_DOCS.md)
|
61
66
|
|
62
67
|
---
|
63
68
|
|
64
|
-
### `coffy.
|
69
|
+
### `coffy.sql`
|
65
70
|
|
66
|
-
-
|
67
|
-
-
|
71
|
+
- SQLite-backed engine with raw SQL query support
|
72
|
+
- Outputs as readable tables or exportable lists
|
68
73
|
- Uses in-memory DB by default, or json-based if initialized with a path
|
69
74
|
|
70
|
-
📄 [
|
75
|
+
📄 [SQL Documentation →](https://github.com/nsarathy/Coffy/blob/main/SQL_DOCS.md)
|
71
76
|
|
72
77
|
---
|
73
78
|
|
@@ -81,15 +86,6 @@ users.add({"id": 1, "name": "Neel"})
|
|
81
86
|
print(users.where("name").eq("Neel").first())
|
82
87
|
```
|
83
88
|
|
84
|
-
```python
|
85
|
-
from coffy.sql import init, query
|
86
|
-
|
87
|
-
init("app.db")
|
88
|
-
query("CREATE TABLE test (id INT, name TEXT)")
|
89
|
-
query("INSERT INTO test VALUES (1, 'Neel')")
|
90
|
-
print(query("SELECT * FROM test"))
|
91
|
-
```
|
92
|
-
|
93
89
|
```python
|
94
90
|
from coffy.graph import GraphDB
|
95
91
|
|
@@ -99,6 +95,15 @@ g.add_relationships([{"source": 1, "target": 2, "type": "friend"}])
|
|
99
95
|
print(g.find_relationships(type="friend"))
|
100
96
|
```
|
101
97
|
|
98
|
+
```python
|
99
|
+
from coffy.sql import init, query
|
100
|
+
|
101
|
+
init("app.db")
|
102
|
+
query("CREATE TABLE test (id INT, name TEXT)")
|
103
|
+
query("INSERT INTO test VALUES (1, 'Neel')")
|
104
|
+
print(query("SELECT * FROM test"))
|
105
|
+
```
|
106
|
+
|
102
107
|
---
|
103
108
|
|
104
109
|
## 📄 License
|
@@ -3,10 +3,15 @@
|
|
3
3
|
**Coffy** is a lightweight embedded database engine for Python, designed for local-first apps, scripts, and tools. It includes:
|
4
4
|
|
5
5
|
- `coffy.nosql`: A simple JSON-backed NoSQL engine with a fluent, chainable query interface
|
6
|
-
- `coffy.
|
7
|
-
- `coffy.
|
6
|
+
- `coffy.graph`: An graph engine built on `networkx` with advanced filtering and logic-based querying
|
7
|
+
- `coffy.sql`: A wrapper over `SQLite` for executing raw SQL
|
8
8
|
|
9
|
-
|
9
|
+
---
|
10
|
+
## Latest Updates
|
11
|
+
- Added projection and dot-notation support for nested fields in NoSQL queries. Expanded logic chaining (`_and`, `_or`, `_not`) with improved test and better query semantics.
|
12
|
+
- GraphDB now supports saving query results, fixed relationship type serialization, and added more robust unit coverage.
|
13
|
+
- Unit tests added to test NoSQL and Graph database features.
|
14
|
+
- Documentation Updated.
|
10
15
|
|
11
16
|
---
|
12
17
|
|
@@ -22,31 +27,31 @@ pip install coffy
|
|
22
27
|
|
23
28
|
### `coffy.nosql`
|
24
29
|
|
25
|
-
-
|
26
|
-
-
|
27
|
-
-
|
30
|
+
- Embedded NoSQL document store with a fluent, chainable query API
|
31
|
+
- Supports nested fields, logical filters, aggregations, projections, and joins
|
32
|
+
- Built for local usage with optional persistence; minimal setup, fast iteration
|
28
33
|
|
29
34
|
📄 [NoSQL Documentation →](https://github.com/nsarathy/Coffy/blob/main/NOSQL_DOCS.md)
|
30
35
|
|
31
36
|
---
|
32
37
|
|
33
|
-
### `coffy.
|
38
|
+
### `coffy.graph`
|
34
39
|
|
35
|
-
-
|
36
|
-
-
|
37
|
-
-
|
40
|
+
- Lightweight, file-backed graph database using `networkx` under the hood
|
41
|
+
- Supports pattern matching, label/type filtering, logical conditions, and projections
|
42
|
+
- Query results can be saved, updated, or transformed; ideal for local, schema-flexible graph data
|
38
43
|
|
39
|
-
📄 [
|
44
|
+
📄 [Graph Documentation →](https://github.com/nsarathy/Coffy/blob/main/GRAPH_DOCS.md)
|
40
45
|
|
41
46
|
---
|
42
47
|
|
43
|
-
### `coffy.
|
48
|
+
### `coffy.sql`
|
44
49
|
|
45
|
-
-
|
46
|
-
-
|
50
|
+
- SQLite-backed engine with raw SQL query support
|
51
|
+
- Outputs as readable tables or exportable lists
|
47
52
|
- Uses in-memory DB by default, or json-based if initialized with a path
|
48
53
|
|
49
|
-
📄 [
|
54
|
+
📄 [SQL Documentation →](https://github.com/nsarathy/Coffy/blob/main/SQL_DOCS.md)
|
50
55
|
|
51
56
|
---
|
52
57
|
|
@@ -60,15 +65,6 @@ users.add({"id": 1, "name": "Neel"})
|
|
60
65
|
print(users.where("name").eq("Neel").first())
|
61
66
|
```
|
62
67
|
|
63
|
-
```python
|
64
|
-
from coffy.sql import init, query
|
65
|
-
|
66
|
-
init("app.db")
|
67
|
-
query("CREATE TABLE test (id INT, name TEXT)")
|
68
|
-
query("INSERT INTO test VALUES (1, 'Neel')")
|
69
|
-
print(query("SELECT * FROM test"))
|
70
|
-
```
|
71
|
-
|
72
68
|
```python
|
73
69
|
from coffy.graph import GraphDB
|
74
70
|
|
@@ -78,6 +74,15 @@ g.add_relationships([{"source": 1, "target": 2, "type": "friend"}])
|
|
78
74
|
print(g.find_relationships(type="friend"))
|
79
75
|
```
|
80
76
|
|
77
|
+
```python
|
78
|
+
from coffy.sql import init, query
|
79
|
+
|
80
|
+
init("app.db")
|
81
|
+
query("CREATE TABLE test (id INT, name TEXT)")
|
82
|
+
query("INSERT INTO test VALUES (1, 'Neel')")
|
83
|
+
print(query("SELECT * FROM test"))
|
84
|
+
```
|
85
|
+
|
81
86
|
---
|
82
87
|
|
83
88
|
## 📄 License
|
Binary file
|
@@ -0,0 +1,137 @@
|
|
1
|
+
import json
|
2
|
+
import unittest
|
3
|
+
import tempfile
|
4
|
+
import os
|
5
|
+
from graphdb_nx import GraphDB
|
6
|
+
|
7
|
+
|
8
|
+
class TestGraphDB(unittest.TestCase):
|
9
|
+
|
10
|
+
def setUp(self):
|
11
|
+
self.temp_path = tempfile.NamedTemporaryFile(delete=False, suffix=".json").name
|
12
|
+
self.db = GraphDB(path=self.temp_path)
|
13
|
+
self.db.add_node("A", labels="Person", name="Alice", age=30)
|
14
|
+
self.db.add_node("B", labels="Person", name="Bob", age=25)
|
15
|
+
self.db.add_node("C", labels="Person", name="Carol", age=40)
|
16
|
+
self.db.add_relationship("A", "B", rel_type="KNOWS", since=2010)
|
17
|
+
self.db.add_relationship("B", "C", rel_type="KNOWS", since=2015)
|
18
|
+
|
19
|
+
def tearDown(self):
|
20
|
+
os.remove(self.temp_path)
|
21
|
+
|
22
|
+
def test_add_and_get_node(self):
|
23
|
+
self.db.add_node("D", labels="Person", name="Dan")
|
24
|
+
node = self.db.get_node("D")
|
25
|
+
self.assertEqual(node["name"], "Dan")
|
26
|
+
|
27
|
+
def test_remove_node(self):
|
28
|
+
self.db.remove_node("C")
|
29
|
+
self.assertFalse(self.db.has_node("C"))
|
30
|
+
|
31
|
+
def test_add_and_get_relationship(self):
|
32
|
+
rel = self.db.get_relationship("A", "B")
|
33
|
+
self.assertEqual(rel["_type"], "KNOWS")
|
34
|
+
|
35
|
+
def test_update_node(self):
|
36
|
+
self.db.update_node("A", age=35, city="Wonderland")
|
37
|
+
node = self.db.get_node("A")
|
38
|
+
self.assertEqual(node["age"], 35)
|
39
|
+
self.assertEqual(node["city"], "Wonderland")
|
40
|
+
|
41
|
+
def test_update_relationship(self):
|
42
|
+
self.db.update_relationship("A", "B", since=2020, weight=0.8)
|
43
|
+
rel = self.db.get_relationship("A", "B")
|
44
|
+
self.assertEqual(rel["since"], 2020)
|
45
|
+
self.assertEqual(rel["weight"], 0.8)
|
46
|
+
|
47
|
+
def test_set_node_update(self):
|
48
|
+
self.db.set_node("A", name="Alicia", mood="happy")
|
49
|
+
node = self.db.get_node("A")
|
50
|
+
self.assertEqual(node["name"], "Alicia")
|
51
|
+
self.assertEqual(node["mood"], "happy")
|
52
|
+
|
53
|
+
def test_set_node_add(self):
|
54
|
+
self.db.set_node("Z", labels="Robot", name="Zeta", age=5)
|
55
|
+
self.assertTrue(self.db.has_node("Z"))
|
56
|
+
node = self.db.get_node("Z")
|
57
|
+
self.assertEqual(node["name"], "Zeta")
|
58
|
+
self.assertEqual(node["age"], 5)
|
59
|
+
self.assertIn("Robot", node["_labels"])
|
60
|
+
|
61
|
+
def test_remove_relationship(self):
|
62
|
+
self.db.remove_relationship("A", "B")
|
63
|
+
self.assertFalse(self.db.has_relationship("A", "B"))
|
64
|
+
|
65
|
+
def test_find_nodes_basic(self):
|
66
|
+
results = self.db.find_nodes(name="Alice")
|
67
|
+
self.assertEqual(len(results), 1)
|
68
|
+
self.assertEqual(results[0]["name"], "Alice")
|
69
|
+
|
70
|
+
def test_find_nodes_logic_or(self):
|
71
|
+
results = self.db.find_nodes(_logic="or", name="Alice", age={"gt": 35})
|
72
|
+
names = {r["name"] for r in results}
|
73
|
+
self.assertIn("Alice", names)
|
74
|
+
self.assertIn("Carol", names)
|
75
|
+
|
76
|
+
def test_find_nodes_logic_not(self):
|
77
|
+
results = self.db.find_nodes(_logic="not", age={"lt": 35})
|
78
|
+
self.assertEqual(len(results), 1)
|
79
|
+
self.assertEqual(results[0]["name"], "Carol")
|
80
|
+
|
81
|
+
def test_find_by_label(self):
|
82
|
+
results = self.db.find_by_label("Person")
|
83
|
+
self.assertEqual(len(results), 3)
|
84
|
+
|
85
|
+
def test_find_relationships_type_and_filter(self):
|
86
|
+
results = self.db.find_relationships(rel_type="KNOWS", since={"gte": 2011})
|
87
|
+
self.assertEqual(len(results), 1)
|
88
|
+
self.assertEqual(results[0]["target"], "C")
|
89
|
+
|
90
|
+
def test_project_node_fields(self):
|
91
|
+
result = self.db.project_node("A", fields=["name"])
|
92
|
+
self.assertEqual(result, {"name": "Alice"})
|
93
|
+
|
94
|
+
def test_project_relationship_fields(self):
|
95
|
+
result = self.db.project_relationship("A", "B", fields=["since"])
|
96
|
+
self.assertEqual(result, {"since": 2010})
|
97
|
+
|
98
|
+
def test_match_node_path(self):
|
99
|
+
pattern = [{"rel_type": "KNOWS", "node": {"name": "Bob"}}]
|
100
|
+
paths = self.db.match_node_path(start={"name": "Alice"}, pattern=pattern)
|
101
|
+
self.assertEqual(len(paths), 1)
|
102
|
+
self.assertEqual(paths[0][0]["name"], "Alice")
|
103
|
+
self.assertEqual(paths[0][1]["name"], "Bob")
|
104
|
+
|
105
|
+
def test_match_full_path(self):
|
106
|
+
pattern = [{"rel_type": "KNOWS", "node": {"name": "Bob"}}, {"rel_type": "KNOWS", "node": {"name": "Carol"}}]
|
107
|
+
results = self.db.match_full_path(start={"name": "Alice"}, pattern=pattern)
|
108
|
+
self.assertEqual(len(results), 1)
|
109
|
+
self.assertEqual(results[0]["nodes"][2]["name"], "Carol")
|
110
|
+
self.assertEqual(results[0]["relationships"][0]["type"], "KNOWS")
|
111
|
+
|
112
|
+
def test_match_path_structured(self):
|
113
|
+
pattern = [{"rel_type": "KNOWS", "node": {"name": "Bob"}}]
|
114
|
+
result = self.db.match_path_structured(start={"name": "Alice"}, pattern=pattern)
|
115
|
+
self.assertEqual(len(result), 1)
|
116
|
+
path = result[0]["path"]
|
117
|
+
self.assertEqual(path[0]["node"]["name"], "Alice")
|
118
|
+
self.assertEqual(path[1]["relationship"]["type"], "KNOWS")
|
119
|
+
self.assertEqual(path[2]["node"]["name"], "Bob")
|
120
|
+
|
121
|
+
def test_save_and_load(self):
|
122
|
+
self.db.save()
|
123
|
+
new_db = GraphDB(path=self.temp_path)
|
124
|
+
self.assertTrue(new_db.has_node("A"))
|
125
|
+
self.assertTrue(new_db.has_relationship("A", "B"))
|
126
|
+
|
127
|
+
def test_save_query_result(self):
|
128
|
+
result = self.db.find_nodes(name="Alice")
|
129
|
+
temp_result_path = self.temp_path.replace(".json", "_result.json")
|
130
|
+
self.db.save_query_result(result, path=temp_result_path)
|
131
|
+
with open(temp_result_path) as f:
|
132
|
+
loaded = json.load(f)
|
133
|
+
self.assertEqual(loaded[0]["name"], "Alice")
|
134
|
+
os.remove(temp_result_path)
|
135
|
+
|
136
|
+
|
137
|
+
unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromTestCase(TestGraphDB))
|
@@ -0,0 +1,379 @@
|
|
1
|
+
# coffy/graph/graphdb_nx.py
|
2
|
+
# author: nsarathy
|
3
|
+
|
4
|
+
import networkx as nx
|
5
|
+
import json
|
6
|
+
import os
|
7
|
+
|
8
|
+
class GraphDB:
|
9
|
+
|
10
|
+
def __init__(self, directed=False, path=None):
|
11
|
+
self.g = nx.DiGraph() if directed else nx.Graph()
|
12
|
+
self.directed = directed
|
13
|
+
self.in_memory = path == ":memory:"
|
14
|
+
|
15
|
+
if path:
|
16
|
+
if not path.endswith(".json"):
|
17
|
+
raise ValueError("Path must be to a .json file")
|
18
|
+
self.path = path
|
19
|
+
else:
|
20
|
+
self.in_memory = True
|
21
|
+
if not self.in_memory and os.path.exists(self.path):
|
22
|
+
self.load(self.path)
|
23
|
+
elif not self.in_memory:
|
24
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
25
|
+
self.save(self.path)
|
26
|
+
|
27
|
+
|
28
|
+
# Node operations
|
29
|
+
def add_node(self, node_id, labels=None, **attrs):
|
30
|
+
if labels is not None:
|
31
|
+
attrs["_labels"] = labels if isinstance(labels, list) else [labels]
|
32
|
+
self.g.add_node(node_id, **attrs)
|
33
|
+
self._persist()
|
34
|
+
|
35
|
+
def add_nodes(self, nodes):
|
36
|
+
for node in nodes:
|
37
|
+
node_id = node["id"]
|
38
|
+
labels = node.get("labels") or node.get("_labels") # Accept either form
|
39
|
+
attrs = {k: v for k, v in node.items() if k not in ["id", "labels", "_labels"]}
|
40
|
+
self.add_node(node_id, labels=labels, **attrs)
|
41
|
+
|
42
|
+
def get_node(self, node_id):
|
43
|
+
return self.g.nodes[node_id]
|
44
|
+
|
45
|
+
def _get_neighbors(self, node_id, direction):
|
46
|
+
if self.directed:
|
47
|
+
if direction == "out":
|
48
|
+
return self.g.successors(node_id)
|
49
|
+
elif direction == "in":
|
50
|
+
return self.g.predecessors(node_id)
|
51
|
+
elif direction == "any":
|
52
|
+
return set(self.g.successors(node_id)).union(self.g.predecessors(node_id))
|
53
|
+
else:
|
54
|
+
raise ValueError("Direction must be 'in', 'out', or 'any'")
|
55
|
+
else:
|
56
|
+
return self.g.neighbors(node_id)
|
57
|
+
|
58
|
+
def remove_node(self, node_id):
|
59
|
+
self.g.remove_node(node_id)
|
60
|
+
self._persist()
|
61
|
+
|
62
|
+
# Relationship (edge) operations
|
63
|
+
def add_relationship(self, source, target, rel_type=None, **attrs):
|
64
|
+
if rel_type:
|
65
|
+
attrs["_type"] = rel_type
|
66
|
+
self.g.add_edge(source, target, **attrs)
|
67
|
+
self._persist()
|
68
|
+
|
69
|
+
def add_relationships(self, relationships):
|
70
|
+
for rel in relationships:
|
71
|
+
source = rel["source"]
|
72
|
+
target = rel["target"]
|
73
|
+
rel_type = rel.get("type") or rel.get("_type")
|
74
|
+
attrs = {k: v for k, v in rel.items() if k not in ["source", "target", "type", "_type"]}
|
75
|
+
self.add_relationship(source, target, rel_type=rel_type, **attrs)
|
76
|
+
|
77
|
+
def get_relationship(self, source, target):
|
78
|
+
return self.g.get_edge_data(source, target)
|
79
|
+
|
80
|
+
def remove_relationship(self, source, target):
|
81
|
+
self.g.remove_edge(source, target)
|
82
|
+
self._persist()
|
83
|
+
|
84
|
+
# Basic queries
|
85
|
+
def neighbors(self, node_id):
|
86
|
+
return list(self.g.neighbors(node_id))
|
87
|
+
|
88
|
+
def degree(self, node_id):
|
89
|
+
return self.g.degree[node_id]
|
90
|
+
|
91
|
+
def has_node(self, node_id):
|
92
|
+
return self.g.has_node(node_id)
|
93
|
+
|
94
|
+
def has_relationship(self, u, v):
|
95
|
+
return self.g.has_edge(u, v)
|
96
|
+
|
97
|
+
def update_node(self, node_id, **attrs):
|
98
|
+
if not self.has_node(node_id):
|
99
|
+
raise KeyError(f"Node '{node_id}' does not exist.")
|
100
|
+
self.g.nodes[node_id].update(attrs)
|
101
|
+
self._persist()
|
102
|
+
|
103
|
+
def update_relationship(self, source, target, **attrs):
|
104
|
+
if not self.has_relationship(source, target):
|
105
|
+
raise KeyError(f"Relationship '{source}->{target}' does not exist.")
|
106
|
+
self.g.edges[source, target].update(attrs)
|
107
|
+
self._persist()
|
108
|
+
|
109
|
+
def set_node(self, node_id, labels=None, **attrs):
|
110
|
+
if self.has_node(node_id):
|
111
|
+
self.update_node(node_id, **attrs)
|
112
|
+
else:
|
113
|
+
self.add_node(node_id, labels=labels, **attrs)
|
114
|
+
self._persist()
|
115
|
+
|
116
|
+
# Advanced node search
|
117
|
+
def project_node(self, node_id, fields=None):
|
118
|
+
if not self.has_node(node_id):
|
119
|
+
return None
|
120
|
+
node = self.get_node(node_id).copy()
|
121
|
+
node["id"] = node_id
|
122
|
+
if fields is None:
|
123
|
+
return node
|
124
|
+
return {k: node[k] for k in fields if k in node}
|
125
|
+
|
126
|
+
def project_relationship(self, source, target, fields=None):
|
127
|
+
if not self.has_relationship(source, target):
|
128
|
+
return None
|
129
|
+
rel = self.get_relationship(source, target).copy()
|
130
|
+
rel.update({"source": source, "target": target, "type": rel.get("_type")})
|
131
|
+
if fields is None:
|
132
|
+
return rel
|
133
|
+
return {k: rel[k] for k in fields if k in rel}
|
134
|
+
|
135
|
+
def find_nodes(self, label=None, fields=None, **conditions):
|
136
|
+
return [
|
137
|
+
self.project_node(n, fields) for n, a in self.g.nodes(data=True)
|
138
|
+
if (label is None or label in a.get("_labels", [])) and self._match_conditions(a, conditions)
|
139
|
+
]
|
140
|
+
|
141
|
+
def find_by_label(self, label, fields=None):
|
142
|
+
return [
|
143
|
+
self.project_node(n, fields) for n, a in self.g.nodes(data=True)
|
144
|
+
if label in a.get("_labels", [])
|
145
|
+
]
|
146
|
+
|
147
|
+
def find_relationships(self, rel_type=None, fields=None, **conditions):
|
148
|
+
return [
|
149
|
+
self.project_relationship(u, v, fields) for u, v, a in self.g.edges(data=True)
|
150
|
+
if (rel_type is None or a.get("_type") == rel_type) and self._match_conditions(a, conditions)
|
151
|
+
]
|
152
|
+
|
153
|
+
def find_by_relationship_type(self, rel_type, fields=None):
|
154
|
+
return [
|
155
|
+
self.project_relationship(u, v, fields) for u, v, a in self.g.edges(data=True)
|
156
|
+
if a.get("_type") == rel_type
|
157
|
+
]
|
158
|
+
|
159
|
+
def _match_conditions(self, attrs, conditions):
|
160
|
+
if not conditions:
|
161
|
+
return True
|
162
|
+
logic = conditions.get("_logic", "and")
|
163
|
+
conditions = {k: v for k, v in conditions.items() if k != "_logic"}
|
164
|
+
results = []
|
165
|
+
|
166
|
+
for key, expected in conditions.items():
|
167
|
+
actual = attrs.get(key)
|
168
|
+
if isinstance(expected, dict):
|
169
|
+
for op, val in expected.items():
|
170
|
+
if op == "gt": results.append(actual > val)
|
171
|
+
elif op == "lt": results.append(actual < val)
|
172
|
+
elif op == "gte": results.append(actual >= val)
|
173
|
+
elif op == "lte": results.append(actual <= val)
|
174
|
+
elif op == "ne": results.append(actual != val)
|
175
|
+
elif op == "eq": results.append(actual == val)
|
176
|
+
else: results.append(False)
|
177
|
+
else:
|
178
|
+
results.append(actual == expected)
|
179
|
+
|
180
|
+
if logic == "or":
|
181
|
+
return any(results)
|
182
|
+
elif logic == "not":
|
183
|
+
return not all(results)
|
184
|
+
return all(results)
|
185
|
+
|
186
|
+
def match_node_path(self, start, pattern, return_nodes=True, node_fields=None, direction="out"):
|
187
|
+
start_nodes = self.find_nodes(**start)
|
188
|
+
node_paths = []
|
189
|
+
|
190
|
+
for s in start_nodes:
|
191
|
+
self._match_node_path(
|
192
|
+
current_id=s["id"],
|
193
|
+
pattern=pattern,
|
194
|
+
node_path=[s["id"]],
|
195
|
+
node_paths=node_paths,
|
196
|
+
direction=direction
|
197
|
+
)
|
198
|
+
|
199
|
+
unique_paths = list({tuple(p) for p in node_paths})
|
200
|
+
|
201
|
+
if return_nodes:
|
202
|
+
return [
|
203
|
+
[self.project_node(n, node_fields) for n in path]
|
204
|
+
for path in unique_paths
|
205
|
+
]
|
206
|
+
return unique_paths
|
207
|
+
|
208
|
+
|
209
|
+
def _match_node_path(self, current_id, pattern, node_path, node_paths, direction):
|
210
|
+
if not pattern:
|
211
|
+
node_paths.append(node_path)
|
212
|
+
return
|
213
|
+
|
214
|
+
step = pattern[0]
|
215
|
+
rel_type = step.get("rel_type")
|
216
|
+
next_node_cond = step.get("node", {})
|
217
|
+
|
218
|
+
for neighbor in self._get_neighbors(current_id, direction):
|
219
|
+
rel = self.get_relationship(current_id, neighbor)
|
220
|
+
if rel_type and rel.get("_type") != rel_type:
|
221
|
+
continue
|
222
|
+
node_attrs = self.get_node(neighbor)
|
223
|
+
if not self._match_conditions(node_attrs, next_node_cond):
|
224
|
+
continue
|
225
|
+
if neighbor in node_path: # avoid cycles
|
226
|
+
continue
|
227
|
+
self._match_node_path(neighbor, pattern[1:], node_path + [neighbor], node_paths, direction)
|
228
|
+
|
229
|
+
def match_full_path(self, start, pattern, node_fields=None, rel_fields=None, direction="out"):
|
230
|
+
start_nodes = self.find_nodes(**start)
|
231
|
+
matched_paths = []
|
232
|
+
|
233
|
+
for s in start_nodes:
|
234
|
+
self._match_full_path(
|
235
|
+
current_id=s["id"],
|
236
|
+
pattern=pattern,
|
237
|
+
relationship_path=[],
|
238
|
+
node_path=[s["id"]],
|
239
|
+
matched_paths=matched_paths,
|
240
|
+
direction=direction
|
241
|
+
)
|
242
|
+
|
243
|
+
return [
|
244
|
+
{
|
245
|
+
"nodes": [self.project_node(n, node_fields) for n in nodes],
|
246
|
+
"relationships": [self.project_relationship(u, v, rel_fields) for u, v in path]
|
247
|
+
}
|
248
|
+
for path, nodes in matched_paths
|
249
|
+
]
|
250
|
+
|
251
|
+
def _match_full_path(self, current_id, pattern, relationship_path, node_path, matched_paths, direction):
|
252
|
+
if not pattern:
|
253
|
+
matched_paths.append((relationship_path, node_path))
|
254
|
+
return
|
255
|
+
|
256
|
+
step = pattern[0]
|
257
|
+
rel_type = step.get("rel_type")
|
258
|
+
next_node_cond = step.get("node", {})
|
259
|
+
|
260
|
+
for neighbor in self._get_neighbors(current_id, direction):
|
261
|
+
if neighbor in node_path:
|
262
|
+
continue
|
263
|
+
rel = self.get_relationship(current_id, neighbor)
|
264
|
+
if rel_type and rel.get("_type") != rel_type:
|
265
|
+
continue
|
266
|
+
if not self._match_conditions(self.get_node(neighbor), next_node_cond):
|
267
|
+
continue
|
268
|
+
|
269
|
+
self._match_full_path(
|
270
|
+
neighbor,
|
271
|
+
pattern[1:],
|
272
|
+
relationship_path + [(current_id, neighbor)],
|
273
|
+
node_path + [neighbor],
|
274
|
+
matched_paths,
|
275
|
+
direction
|
276
|
+
)
|
277
|
+
|
278
|
+
def match_path_structured(self, start, pattern, node_fields=None, rel_fields=None, direction="out"):
|
279
|
+
start_nodes = self.find_nodes(**start)
|
280
|
+
structured_paths = []
|
281
|
+
|
282
|
+
for s in start_nodes:
|
283
|
+
self._match_structured_path(
|
284
|
+
current_id=s["id"],
|
285
|
+
pattern=pattern,
|
286
|
+
path=[{"node": self.project_node(s["id"], node_fields)}],
|
287
|
+
structured_paths=structured_paths,
|
288
|
+
direction=direction
|
289
|
+
)
|
290
|
+
|
291
|
+
return structured_paths
|
292
|
+
|
293
|
+
def _match_structured_path(self, current_id, pattern, path, structured_paths, direction):
|
294
|
+
if not pattern:
|
295
|
+
structured_paths.append({"path": path})
|
296
|
+
return
|
297
|
+
|
298
|
+
step = pattern[0]
|
299
|
+
rel_type = step.get("rel_type")
|
300
|
+
next_node_cond = step.get("node", {})
|
301
|
+
|
302
|
+
for neighbor in self._get_neighbors(current_id, direction):
|
303
|
+
if any(e.get("node", {}).get("id") == neighbor for e in path):
|
304
|
+
continue # Avoid cycles
|
305
|
+
|
306
|
+
rel = self.get_relationship(current_id, neighbor)
|
307
|
+
if rel_type and rel.get("_type") != rel_type:
|
308
|
+
continue
|
309
|
+
if not self._match_conditions(self.get_node(neighbor), next_node_cond):
|
310
|
+
continue
|
311
|
+
|
312
|
+
extended_path = path + [
|
313
|
+
{"relationship": self.project_relationship(current_id, neighbor)},
|
314
|
+
{"node": self.project_node(neighbor)}
|
315
|
+
]
|
316
|
+
|
317
|
+
self._match_structured_path(
|
318
|
+
neighbor,
|
319
|
+
pattern[1:],
|
320
|
+
extended_path,
|
321
|
+
structured_paths,
|
322
|
+
direction
|
323
|
+
)
|
324
|
+
|
325
|
+
# Export
|
326
|
+
def nodes(self):
|
327
|
+
return [{"id": n, "labels": a.get("_labels", []), **{k: v for k, v in a.items() if k != "_labels"}} for n, a in self.g.nodes(data=True)]
|
328
|
+
|
329
|
+
def relationships(self):
|
330
|
+
return [
|
331
|
+
{
|
332
|
+
"source": u,
|
333
|
+
"target": v,
|
334
|
+
"type": a.get("_type"),
|
335
|
+
**{k: v for k, v in a.items() if k != "_type"}
|
336
|
+
}
|
337
|
+
for u, v, a in self.g.edges(data=True)
|
338
|
+
]
|
339
|
+
|
340
|
+
def to_dict(self):
|
341
|
+
return {
|
342
|
+
"nodes": self.nodes(),
|
343
|
+
"relationships": self.relationships()
|
344
|
+
}
|
345
|
+
|
346
|
+
def save(self, path=None):
|
347
|
+
path = path or self.path
|
348
|
+
if not path:
|
349
|
+
raise ValueError("No path specified to save the graph.")
|
350
|
+
with open(path, "w", encoding="utf-8") as f:
|
351
|
+
json.dump(self.to_dict(), f, indent=4)
|
352
|
+
|
353
|
+
def load(self, path=None):
|
354
|
+
path = path or self.path
|
355
|
+
if not path:
|
356
|
+
raise ValueError("No path specified to load the graph.")
|
357
|
+
if os.path.getsize(path) == 0:
|
358
|
+
return
|
359
|
+
with open(path, "r", encoding="utf-8") as f:
|
360
|
+
data = json.load(f)
|
361
|
+
self.g.clear()
|
362
|
+
for node in data.get("nodes", []):
|
363
|
+
self.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
|
364
|
+
for rel in data.get("relationships", []):
|
365
|
+
self.add_relationship(
|
366
|
+
rel["source"], rel["target"],
|
367
|
+
rel_type=rel.get("type") or rel.get("_type"),
|
368
|
+
**{k: v for k, v in rel.items() if k not in ["source", "target", "type", "_type"]}
|
369
|
+
)
|
370
|
+
|
371
|
+
def save_query_result(self, result, path=None):
|
372
|
+
if path is None:
|
373
|
+
raise ValueError("No path specified to save the query result.")
|
374
|
+
with open(path, "w", encoding="utf-8") as f:
|
375
|
+
json.dump(result, f, indent=4)
|
376
|
+
|
377
|
+
def _persist(self):
|
378
|
+
if not self.in_memory:
|
379
|
+
self.save(self.path)
|
Binary file
|
@@ -13,47 +13,57 @@ class QueryBuilder:
|
|
13
13
|
self.all_collections = all_collections or {}
|
14
14
|
self._lookup_done = False
|
15
15
|
self._lookup_results = None
|
16
|
+
|
17
|
+
@staticmethod
|
18
|
+
def _get_nested(doc, dotted_key):
|
19
|
+
keys = dotted_key.split('.')
|
20
|
+
for k in keys:
|
21
|
+
if not isinstance(doc, dict) or k not in doc:
|
22
|
+
return None
|
23
|
+
doc = doc[k]
|
24
|
+
return doc
|
16
25
|
|
17
26
|
def where(self, field):
|
18
27
|
self.current_field = field
|
19
28
|
return self
|
20
29
|
|
21
30
|
# Comparison
|
22
|
-
def eq(self, value): return self._add_filter(lambda d:
|
23
|
-
def ne(self, value): return self._add_filter(lambda d:
|
31
|
+
def eq(self, value): return self._add_filter(lambda d: QueryBuilder._get_nested(d, self.current_field) == value)
|
32
|
+
def ne(self, value): return self._add_filter(lambda d: QueryBuilder._get_nested(d, self.current_field) != value)
|
24
33
|
def gt(self, value):
|
25
34
|
return self._add_filter(
|
26
|
-
lambda d: isinstance(
|
35
|
+
lambda d: isinstance(QueryBuilder._get_nested(d, self.current_field), (int, float)) and QueryBuilder._get_nested(d, self.current_field) > value
|
27
36
|
)
|
28
37
|
|
29
38
|
def gte(self, value):
|
30
39
|
return self._add_filter(
|
31
|
-
lambda d: isinstance(
|
40
|
+
lambda d: isinstance(QueryBuilder._get_nested(d, self.current_field), (int, float)) and QueryBuilder._get_nested(d, self.current_field) >= value
|
32
41
|
)
|
33
42
|
|
34
43
|
def lt(self, value):
|
35
44
|
return self._add_filter(
|
36
|
-
lambda d: isinstance(
|
45
|
+
lambda d: isinstance(QueryBuilder._get_nested(d, self.current_field), (int, float)) and QueryBuilder._get_nested(d, self.current_field) < value
|
37
46
|
)
|
38
47
|
|
39
48
|
def lte(self, value):
|
40
49
|
return self._add_filter(
|
41
|
-
lambda d: isinstance(
|
50
|
+
lambda d: isinstance(QueryBuilder._get_nested(d, self.current_field), (int, float)) and QueryBuilder._get_nested(d, self.current_field) <= value
|
42
51
|
)
|
43
52
|
|
44
53
|
def in_(self, values):
|
45
54
|
return self._add_filter(
|
46
|
-
lambda d:
|
55
|
+
lambda d: QueryBuilder._get_nested(d, self.current_field) in values
|
47
56
|
)
|
48
57
|
|
49
58
|
def nin(self, values):
|
50
59
|
return self._add_filter(
|
51
|
-
lambda d:
|
60
|
+
lambda d: QueryBuilder._get_nested(d, self.current_field) not in values
|
52
61
|
)
|
53
62
|
|
54
|
-
def matches(self, regex): return self._add_filter(lambda d: re.search(regex, str(
|
63
|
+
def matches(self, regex): return self._add_filter(lambda d: re.search(regex, str(QueryBuilder._get_nested(d, self.current_field))))
|
55
64
|
|
56
|
-
def exists(self):
|
65
|
+
def exists(self):
|
66
|
+
return self._add_filter(lambda d: QueryBuilder._get_nested(d, self.current_field) is not None)
|
57
67
|
|
58
68
|
# Logic grouping
|
59
69
|
def _and(self, *fns):
|
@@ -87,12 +97,24 @@ class QueryBuilder:
|
|
87
97
|
return self
|
88
98
|
|
89
99
|
# Core execution
|
90
|
-
def run(self):
|
100
|
+
def run(self, fields=None):
|
91
101
|
results = [doc for doc in self.documents if all(f(doc) for f in self.filters)]
|
92
102
|
if self._lookup_done:
|
93
103
|
results = self._lookup_results
|
104
|
+
|
105
|
+
if fields is not None:
|
106
|
+
projected = []
|
107
|
+
for doc in results:
|
108
|
+
proj = {}
|
109
|
+
for f in fields:
|
110
|
+
value = QueryBuilder._get_nested(doc, f)
|
111
|
+
proj[f] = value
|
112
|
+
projected.append(proj)
|
113
|
+
return DocList(projected)
|
114
|
+
|
94
115
|
return DocList(results)
|
95
116
|
|
117
|
+
|
96
118
|
def update(self, changes):
|
97
119
|
count = 0
|
98
120
|
for doc in self.documents:
|
@@ -165,18 +187,17 @@ class QueryBuilder:
|
|
165
187
|
_collection_registry = {}
|
166
188
|
|
167
189
|
class CollectionManager:
|
168
|
-
DEFAULT_DIR = os.path.join(os.getcwd(), "nosql_data")
|
169
190
|
|
170
191
|
def __init__(self, name: str, path: str = None):
|
171
192
|
self.name = name
|
172
193
|
self.in_memory = False
|
173
194
|
|
174
195
|
if path:
|
196
|
+
if not path.endswith(".json"):
|
197
|
+
raise ValueError("Path must be to a .json file")
|
175
198
|
self.path = path
|
176
199
|
else:
|
177
|
-
|
178
|
-
self.path = os.path.join(self.DEFAULT_DIR, f"{name}.json")
|
179
|
-
self.in_memory = True if name == ":memory:" else False
|
200
|
+
self.in_memory = True
|
180
201
|
|
181
202
|
self.documents = []
|
182
203
|
self._load()
|
@@ -190,6 +211,7 @@ class CollectionManager:
|
|
190
211
|
with open(self.path, 'r', encoding='utf-8') as f:
|
191
212
|
self.documents = json.load(f)
|
192
213
|
except FileNotFoundError:
|
214
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
193
215
|
self.documents = []
|
194
216
|
|
195
217
|
def _save(self):
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# coffy/nosql/nosql_tests.py
|
2
|
+
# author: nsarathy
|
3
|
+
|
4
|
+
import unittest
|
5
|
+
from engine import CollectionManager
|
6
|
+
|
7
|
+
class TestCollectionManager(unittest.TestCase):
|
8
|
+
|
9
|
+
def setUp(self):
|
10
|
+
self.col = CollectionManager(name="test_collection")
|
11
|
+
self.col.clear()
|
12
|
+
self.col.add_many([
|
13
|
+
{"name": "Alice", "age": 30, "tags": ["x", "y"]},
|
14
|
+
{"name": "Bob", "age": 25, "tags": ["y", "z"]},
|
15
|
+
{"name": "Carol", "age": 40, "nested": {"score": 100}},
|
16
|
+
])
|
17
|
+
|
18
|
+
def test_add_and_all_docs(self):
|
19
|
+
result = self.col.all_docs()
|
20
|
+
self.assertEqual(len(result), 3)
|
21
|
+
|
22
|
+
def test_where_eq(self):
|
23
|
+
q = self.col.where("name").eq("Alice")
|
24
|
+
self.assertEqual(q.count(), 1)
|
25
|
+
self.assertEqual(q.first()["age"], 30)
|
26
|
+
|
27
|
+
def test_where_gt_and_lt(self):
|
28
|
+
gt_q = self.col.where("age").gt(26)
|
29
|
+
lt_q = self.col.where("age").lt(40)
|
30
|
+
self.assertEqual(gt_q.count(), 2)
|
31
|
+
self.assertEqual(lt_q.count(), 2)
|
32
|
+
|
33
|
+
def test_exists(self):
|
34
|
+
q = self.col.where("nested").exists()
|
35
|
+
self.assertEqual(q.count(), 1)
|
36
|
+
self.assertEqual(q.first()["name"], "Carol")
|
37
|
+
|
38
|
+
def test_in_and_nin(self):
|
39
|
+
q1 = self.col.where("name").in_(["Alice", "Bob"])
|
40
|
+
q2 = self.col.where("name").nin(["Carol"])
|
41
|
+
self.assertEqual(q1.count(), 2)
|
42
|
+
self.assertEqual(q2.count(), 2)
|
43
|
+
|
44
|
+
def test_matches(self):
|
45
|
+
q = self.col.where("name").matches("^A")
|
46
|
+
self.assertEqual(q.count(), 1)
|
47
|
+
self.assertEqual(q.first()["name"], "Alice")
|
48
|
+
|
49
|
+
def test_nested_field_access(self):
|
50
|
+
q = self.col.where("nested.score").eq(100)
|
51
|
+
self.assertEqual(q.count(), 1)
|
52
|
+
self.assertEqual(q.first()["name"], "Carol")
|
53
|
+
|
54
|
+
def test_logic_and_or_not(self):
|
55
|
+
q = self.col.match_all(
|
56
|
+
lambda q: q.where("age").gte(25),
|
57
|
+
lambda q: q.where("age").lt(40)
|
58
|
+
)
|
59
|
+
self.assertEqual(q.count(), 2)
|
60
|
+
|
61
|
+
q = self.col.match_any(
|
62
|
+
lambda q: q.where("name").eq("Alice"),
|
63
|
+
lambda q: q.where("name").eq("Bob")
|
64
|
+
)
|
65
|
+
self.assertEqual(q.count(), 2)
|
66
|
+
|
67
|
+
q = self.col.not_any(
|
68
|
+
lambda q: q.where("name").eq("Bob"),
|
69
|
+
lambda q: q.where("age").eq(40)
|
70
|
+
)
|
71
|
+
self.assertEqual(q.count(), 1)
|
72
|
+
self.assertEqual(q.first()["name"], "Alice")
|
73
|
+
|
74
|
+
def test_run_with_projection(self):
|
75
|
+
q = self.col.where("age").gte(25)
|
76
|
+
result = q.run(fields=["name"])
|
77
|
+
self.assertEqual(len(result), 3)
|
78
|
+
for doc in result:
|
79
|
+
self.assertEqual(list(doc.keys()), ["name"])
|
80
|
+
|
81
|
+
def test_update_and_delete_and_replace(self):
|
82
|
+
self.col.where("name").eq("Alice").update({"updated": True})
|
83
|
+
updated = self.col.where("updated").eq(True).first()
|
84
|
+
self.assertEqual(updated["name"], "Alice")
|
85
|
+
|
86
|
+
self.col.where("name").eq("Bob").delete()
|
87
|
+
self.assertEqual(self.col.where("name").eq("Bob").count(), 0)
|
88
|
+
|
89
|
+
self.col.where("name").eq("Carol").replace({"name": "New", "age": 99})
|
90
|
+
new_doc = self.col.where("name").eq("New").first()
|
91
|
+
self.assertEqual(new_doc["age"], 99)
|
92
|
+
|
93
|
+
def test_aggregates(self):
|
94
|
+
self.assertEqual(self.col.sum("age"), 95)
|
95
|
+
self.assertEqual(self.col.avg("age"), 95 / 3)
|
96
|
+
self.assertEqual(self.col.min("age"), 25)
|
97
|
+
self.assertEqual(self.col.max("age"), 40)
|
98
|
+
|
99
|
+
def test_merge(self):
|
100
|
+
q = self.col.where("name").eq("Alice")
|
101
|
+
merged = q.merge(lambda d: {"new": d["age"] + 10}).run()
|
102
|
+
self.assertEqual(merged[0]["new"], 40)
|
103
|
+
|
104
|
+
unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromTestCase(TestCollectionManager))
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: coffy
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.5
|
4
4
|
Summary: Lightweight local NoSQL, SQL, and Graph embedded database engine
|
5
5
|
Author: nsarathy
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
@@ -24,10 +24,15 @@ Dynamic: summary
|
|
24
24
|
**Coffy** is a lightweight embedded database engine for Python, designed for local-first apps, scripts, and tools. It includes:
|
25
25
|
|
26
26
|
- `coffy.nosql`: A simple JSON-backed NoSQL engine with a fluent, chainable query interface
|
27
|
-
- `coffy.
|
28
|
-
- `coffy.
|
27
|
+
- `coffy.graph`: An graph engine built on `networkx` with advanced filtering and logic-based querying
|
28
|
+
- `coffy.sql`: A wrapper over `SQLite` for executing raw SQL
|
29
29
|
|
30
|
-
|
30
|
+
---
|
31
|
+
## Latest Updates
|
32
|
+
- Added projection and dot-notation support for nested fields in NoSQL queries. Expanded logic chaining (`_and`, `_or`, `_not`) with improved test and better query semantics.
|
33
|
+
- GraphDB now supports saving query results, fixed relationship type serialization, and added more robust unit coverage.
|
34
|
+
- Unit tests added to test NoSQL and Graph database features.
|
35
|
+
- Documentation Updated.
|
31
36
|
|
32
37
|
---
|
33
38
|
|
@@ -43,31 +48,31 @@ pip install coffy
|
|
43
48
|
|
44
49
|
### `coffy.nosql`
|
45
50
|
|
46
|
-
-
|
47
|
-
-
|
48
|
-
-
|
51
|
+
- Embedded NoSQL document store with a fluent, chainable query API
|
52
|
+
- Supports nested fields, logical filters, aggregations, projections, and joins
|
53
|
+
- Built for local usage with optional persistence; minimal setup, fast iteration
|
49
54
|
|
50
55
|
📄 [NoSQL Documentation →](https://github.com/nsarathy/Coffy/blob/main/NOSQL_DOCS.md)
|
51
56
|
|
52
57
|
---
|
53
58
|
|
54
|
-
### `coffy.
|
59
|
+
### `coffy.graph`
|
55
60
|
|
56
|
-
-
|
57
|
-
-
|
58
|
-
-
|
61
|
+
- Lightweight, file-backed graph database using `networkx` under the hood
|
62
|
+
- Supports pattern matching, label/type filtering, logical conditions, and projections
|
63
|
+
- Query results can be saved, updated, or transformed; ideal for local, schema-flexible graph data
|
59
64
|
|
60
|
-
📄 [
|
65
|
+
📄 [Graph Documentation →](https://github.com/nsarathy/Coffy/blob/main/GRAPH_DOCS.md)
|
61
66
|
|
62
67
|
---
|
63
68
|
|
64
|
-
### `coffy.
|
69
|
+
### `coffy.sql`
|
65
70
|
|
66
|
-
-
|
67
|
-
-
|
71
|
+
- SQLite-backed engine with raw SQL query support
|
72
|
+
- Outputs as readable tables or exportable lists
|
68
73
|
- Uses in-memory DB by default, or json-based if initialized with a path
|
69
74
|
|
70
|
-
📄 [
|
75
|
+
📄 [SQL Documentation →](https://github.com/nsarathy/Coffy/blob/main/SQL_DOCS.md)
|
71
76
|
|
72
77
|
---
|
73
78
|
|
@@ -81,15 +86,6 @@ users.add({"id": 1, "name": "Neel"})
|
|
81
86
|
print(users.where("name").eq("Neel").first())
|
82
87
|
```
|
83
88
|
|
84
|
-
```python
|
85
|
-
from coffy.sql import init, query
|
86
|
-
|
87
|
-
init("app.db")
|
88
|
-
query("CREATE TABLE test (id INT, name TEXT)")
|
89
|
-
query("INSERT INTO test VALUES (1, 'Neel')")
|
90
|
-
print(query("SELECT * FROM test"))
|
91
|
-
```
|
92
|
-
|
93
89
|
```python
|
94
90
|
from coffy.graph import GraphDB
|
95
91
|
|
@@ -99,6 +95,15 @@ g.add_relationships([{"source": 1, "target": 2, "type": "friend"}])
|
|
99
95
|
print(g.find_relationships(type="friend"))
|
100
96
|
```
|
101
97
|
|
98
|
+
```python
|
99
|
+
from coffy.sql import init, query
|
100
|
+
|
101
|
+
init("app.db")
|
102
|
+
query("CREATE TABLE test (id INT, name TEXT)")
|
103
|
+
query("INSERT INTO test VALUES (1, 'Neel')")
|
104
|
+
print(query("SELECT * FROM test"))
|
105
|
+
```
|
106
|
+
|
102
107
|
---
|
103
108
|
|
104
109
|
## 📄 License
|
@@ -12,11 +12,13 @@ coffy.egg-info/top_level.txt
|
|
12
12
|
coffy/__pycache__/__init__.cpython-311.pyc
|
13
13
|
coffy/__pycache__/__init__.cpython-312.pyc
|
14
14
|
coffy/graph/__init__.py
|
15
|
+
coffy/graph/graph_tests.py
|
15
16
|
coffy/graph/graphdb_nx.py
|
16
17
|
coffy/graph/__pycache__/__init__.cpython-312.pyc
|
17
18
|
coffy/graph/__pycache__/graphdb_nx.cpython-312.pyc
|
18
19
|
coffy/nosql/__init__.py
|
19
20
|
coffy/nosql/engine.py
|
21
|
+
coffy/nosql/nosql_tests.py
|
20
22
|
coffy/nosql/__pycache__/__init__.cpython-311.pyc
|
21
23
|
coffy/nosql/__pycache__/__init__.cpython-312.pyc
|
22
24
|
coffy/nosql/__pycache__/engine.cpython-311.pyc
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
2
2
|
|
3
3
|
setup(
|
4
4
|
name="coffy",
|
5
|
-
version="0.1.
|
5
|
+
version="0.1.5",
|
6
6
|
author="nsarathy",
|
7
7
|
description="Lightweight local NoSQL, SQL, and Graph embedded database engine",
|
8
8
|
long_description=open("README.md", encoding="utf-8").read(),
|
Binary file
|
@@ -1,125 +0,0 @@
|
|
1
|
-
import networkx as nx
|
2
|
-
import json
|
3
|
-
import os
|
4
|
-
|
5
|
-
class GraphDB:
|
6
|
-
def __init__(self, directed=False, path=None):
|
7
|
-
self.g = nx.DiGraph() if directed else nx.Graph()
|
8
|
-
self.directed = directed
|
9
|
-
self.path = path
|
10
|
-
if path and os.path.exists(path):
|
11
|
-
self.load(path)
|
12
|
-
|
13
|
-
# Node operations
|
14
|
-
def add_node(self, node_id, **attrs):
|
15
|
-
self.g.add_node(node_id, **attrs)
|
16
|
-
|
17
|
-
def add_nodes(self, nodes):
|
18
|
-
for node in nodes:
|
19
|
-
self.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
|
20
|
-
|
21
|
-
def get_node(self, node_id):
|
22
|
-
return self.g.nodes[node_id]
|
23
|
-
|
24
|
-
def remove_node(self, node_id):
|
25
|
-
self.g.remove_node(node_id)
|
26
|
-
|
27
|
-
# Relationship (edge) operations
|
28
|
-
def add_relationship(self, source, target, **attrs):
|
29
|
-
self.g.add_edge(source, target, **attrs)
|
30
|
-
|
31
|
-
def add_relationships(self, relationships):
|
32
|
-
for rel in relationships:
|
33
|
-
self.add_relationship(rel["source"], rel["target"],
|
34
|
-
**{k: v for k, v in rel.items() if k not in ["source", "target"]})
|
35
|
-
|
36
|
-
def get_relationship(self, source, target):
|
37
|
-
return self.g.get_edge_data(source, target)
|
38
|
-
|
39
|
-
def remove_relationship(self, source, target):
|
40
|
-
self.g.remove_edge(source, target)
|
41
|
-
|
42
|
-
# Basic queries
|
43
|
-
def neighbors(self, node_id):
|
44
|
-
return list(self.g.neighbors(node_id))
|
45
|
-
|
46
|
-
def degree(self, node_id):
|
47
|
-
return self.g.degree[node_id]
|
48
|
-
|
49
|
-
def has_node(self, node_id):
|
50
|
-
return self.g.has_node(node_id)
|
51
|
-
|
52
|
-
def has_relationship(self, u, v):
|
53
|
-
return self.g.has_edge(u, v)
|
54
|
-
|
55
|
-
# Advanced node search
|
56
|
-
def find_nodes(self, **conditions):
|
57
|
-
return [
|
58
|
-
{"id": n, **a} for n, a in self.g.nodes(data=True)
|
59
|
-
if self._match_conditions(a, conditions)
|
60
|
-
]
|
61
|
-
|
62
|
-
def find_relationships(self, **conditions):
|
63
|
-
return [
|
64
|
-
{"source": u, "target": v, **a} for u, v, a in self.g.edges(data=True)
|
65
|
-
if self._match_conditions(a, conditions)
|
66
|
-
]
|
67
|
-
|
68
|
-
def _match_conditions(self, attrs, conditions):
|
69
|
-
if not conditions:
|
70
|
-
return True
|
71
|
-
logic = conditions.pop("_logic", "and")
|
72
|
-
results = []
|
73
|
-
|
74
|
-
for key, expected in conditions.items():
|
75
|
-
actual = attrs.get(key)
|
76
|
-
if isinstance(expected, dict):
|
77
|
-
for op, val in expected.items():
|
78
|
-
if op == "gt": results.append(actual > val)
|
79
|
-
elif op == "lt": results.append(actual < val)
|
80
|
-
elif op == "gte": results.append(actual >= val)
|
81
|
-
elif op == "lte": results.append(actual <= val)
|
82
|
-
elif op == "ne": results.append(actual != val)
|
83
|
-
elif op == "eq": results.append(actual == val)
|
84
|
-
else: results.append(False)
|
85
|
-
else:
|
86
|
-
results.append(actual == expected)
|
87
|
-
|
88
|
-
if logic == "or":
|
89
|
-
return any(results)
|
90
|
-
elif logic == "not":
|
91
|
-
return not all(results)
|
92
|
-
return all(results)
|
93
|
-
|
94
|
-
# Export
|
95
|
-
def nodes(self):
|
96
|
-
return [{"id": n, **a} for n, a in self.g.nodes(data=True)]
|
97
|
-
|
98
|
-
def relationships(self):
|
99
|
-
return [{"source": u, "target": v, **a} for u, v, a in self.g.edges(data=True)]
|
100
|
-
|
101
|
-
def to_dict(self):
|
102
|
-
return {
|
103
|
-
"nodes": self.nodes(),
|
104
|
-
"relationships": self.relationships()
|
105
|
-
}
|
106
|
-
|
107
|
-
def save(self, path=None):
|
108
|
-
path = path or self.path
|
109
|
-
if not path:
|
110
|
-
raise ValueError("No path specified to save the graph.")
|
111
|
-
with open(path, "w", encoding="utf-8") as f:
|
112
|
-
json.dump(self.to_dict(), f, indent=4)
|
113
|
-
|
114
|
-
def load(self, path=None):
|
115
|
-
path = path or self.path
|
116
|
-
if not path:
|
117
|
-
raise ValueError("No path specified to load the graph.")
|
118
|
-
with open(path, "r", encoding="utf-8") as f:
|
119
|
-
data = json.load(f)
|
120
|
-
self.g.clear()
|
121
|
-
for node in data.get("nodes", []):
|
122
|
-
self.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
|
123
|
-
for rel in data.get("relationships", []):
|
124
|
-
self.add_relationship(rel["source"], rel["target"],
|
125
|
-
**{k: v for k, v in rel.items() if k not in ["source", "target"]})
|
Binary file
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|