coffy 0.1.2__py3-none-any.whl → 0.1.5__py3-none-any.whl

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.
@@ -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))
coffy/graph/graphdb_nx.py CHANGED
@@ -1,43 +1,85 @@
1
+ # coffy/graph/graphdb_nx.py
2
+ # author: nsarathy
3
+
1
4
  import networkx as nx
2
5
  import json
3
6
  import os
4
7
 
5
8
  class GraphDB:
9
+
6
10
  def __init__(self, directed=False, path=None):
7
11
  self.g = nx.DiGraph() if directed else nx.Graph()
8
12
  self.directed = directed
9
- self.path = path
10
- if path and os.path.exists(path):
11
- self.load(path)
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
+
12
27
 
13
28
  # Node operations
14
- def add_node(self, node_id, **attrs):
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]
15
32
  self.g.add_node(node_id, **attrs)
33
+ self._persist()
16
34
 
17
35
  def add_nodes(self, nodes):
18
36
  for node in nodes:
19
- self.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
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)
20
41
 
21
42
  def get_node(self, node_id):
22
43
  return self.g.nodes[node_id]
23
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
+
24
58
  def remove_node(self, node_id):
25
59
  self.g.remove_node(node_id)
60
+ self._persist()
26
61
 
27
62
  # Relationship (edge) operations
28
- def add_relationship(self, source, target, **attrs):
63
+ def add_relationship(self, source, target, rel_type=None, **attrs):
64
+ if rel_type:
65
+ attrs["_type"] = rel_type
29
66
  self.g.add_edge(source, target, **attrs)
67
+ self._persist()
30
68
 
31
69
  def add_relationships(self, relationships):
32
70
  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"]})
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)
35
76
 
36
77
  def get_relationship(self, source, target):
37
78
  return self.g.get_edge_data(source, target)
38
79
 
39
80
  def remove_relationship(self, source, target):
40
81
  self.g.remove_edge(source, target)
82
+ self._persist()
41
83
 
42
84
  # Basic queries
43
85
  def neighbors(self, node_id):
@@ -51,24 +93,74 @@ class GraphDB:
51
93
 
52
94
  def has_relationship(self, u, v):
53
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()
54
115
 
55
116
  # Advanced node search
56
- def find_nodes(self, **conditions):
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):
57
136
  return [
58
- {"id": n, **a} for n, a in self.g.nodes(data=True)
59
- if self._match_conditions(a, conditions)
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)
60
139
  ]
61
140
 
62
- def find_relationships(self, **conditions):
141
+ def find_by_label(self, label, fields=None):
63
142
  return [
64
- {"source": u, "target": v, **a} for u, v, a in self.g.edges(data=True)
65
- if self._match_conditions(a, conditions)
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
66
157
  ]
67
158
 
68
159
  def _match_conditions(self, attrs, conditions):
69
160
  if not conditions:
70
161
  return True
71
- logic = conditions.pop("_logic", "and")
162
+ logic = conditions.get("_logic", "and")
163
+ conditions = {k: v for k, v in conditions.items() if k != "_logic"}
72
164
  results = []
73
165
 
74
166
  for key, expected in conditions.items():
@@ -90,13 +182,160 @@ class GraphDB:
90
182
  elif logic == "not":
91
183
  return not all(results)
92
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
+ )
93
324
 
94
325
  # Export
95
326
  def nodes(self):
96
- return [{"id": n, **a} for n, a in self.g.nodes(data=True)]
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)]
97
328
 
98
329
  def relationships(self):
99
- return [{"source": u, "target": v, **a} for u, v, a in self.g.edges(data=True)]
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
+ ]
100
339
 
101
340
  def to_dict(self):
102
341
  return {
@@ -115,11 +354,26 @@ class GraphDB:
115
354
  path = path or self.path
116
355
  if not path:
117
356
  raise ValueError("No path specified to load the graph.")
357
+ if os.path.getsize(path) == 0:
358
+ return
118
359
  with open(path, "r", encoding="utf-8") as f:
119
360
  data = json.load(f)
120
361
  self.g.clear()
121
362
  for node in data.get("nodes", []):
122
363
  self.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
123
364
  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"]})
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)
coffy/nosql/engine.py CHANGED
@@ -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: d.get(self.current_field) == value)
23
- def ne(self, value): return self._add_filter(lambda d: d.get(self.current_field) != value)
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(d.get(self.current_field), (int, float)) and d.get(self.current_field) > value
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(d.get(self.current_field), (int, float)) and d.get(self.current_field) >= value
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(d.get(self.current_field), (int, float)) and d.get(self.current_field) < value
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(d.get(self.current_field), (int, float)) and d.get(self.current_field) <= value
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: d.get(self.current_field) in values
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: d.get(self.current_field) not in values
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(d.get(self.current_field))))
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): return self._add_filter(lambda d: self.current_field in d)
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
- os.makedirs(self.DEFAULT_DIR, exist_ok=True)
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.2
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.sql`: A wrapper over SQLite for executing raw SQL with clean tabular results
28
- - `coffy.graph`: An graph engine built on `networkx` with advanced filtering and logic-based querying
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
- No dependencies (except `networkx`). No boilerplate. Just data.
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
- - JSON-based collections with fluent `.where().eq().gt()...` query chaining
47
- - Joins, updates, filters, aggregation, export/import
48
- - All data saved to human-readable `.json` files
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.sql`
59
+ ### `coffy.graph`
55
60
 
56
- - SQLite-backed engine with raw SQL query support
57
- - Outputs as readable tables or exportable lists
58
- - Uses in-memory DB by default, or json-based if initialized with a path
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
- 📄 [SQL Documentation →](https://github.com/nsarathy/Coffy/blob/main/SQL_DOCS.md)
65
+ 📄 [Graph Documentation →](https://github.com/nsarathy/Coffy/blob/main/GRAPH_DOCS.md)
61
66
 
62
67
  ---
63
68
 
64
- ### `coffy.graph`
69
+ ### `coffy.sql`
65
70
 
66
- - Wrapper around `networkx` with simplified node/relationship API
67
- - Query nodes and relationships using filters like `gt`, `lt`, `eq`, `or`, `not`
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
- 📄 [Graph Documentation →](https://github.com/nsarathy/Coffy/blob/main/GRAPH_DOCS.md)
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
@@ -2,15 +2,17 @@ coffy/__init__.py,sha256=Q5FwcCZtemm41gSD-n-t9zAipeh5XV7JuoBzEHze7J8,39
2
2
  coffy/__pycache__/__init__.cpython-311.pyc,sha256=Mf7ImWR3gBHSLuRr-Wf__3OsjKGpdH8D5T2qm-em5AM,150
3
3
  coffy/__pycache__/__init__.cpython-312.pyc,sha256=n_nvLEknScZM-FT6aNmDlpZGQd_ZHB3p_zRJirbI_Ic,146
4
4
  coffy/graph/__init__.py,sha256=Z0cIgmxre3YgwBrstubB1PTElP5uatz3ZOEIuW9EwY4,80
5
- coffy/graph/graphdb_nx.py,sha256=h9we_VSZVE_vDttuxI7msgKX7besldvp29cwRDo_8lw,4270
5
+ coffy/graph/graph_tests.py,sha256=PmgoR2t14_2H2ynSavCmORuuxXqC5co1KWfYM1svKko,5661
6
+ coffy/graph/graphdb_nx.py,sha256=ol4tmfTSbLm8o-i_zXOvxjKUSi5Z4h1rl2-Hd9HOBKg,13648
6
7
  coffy/graph/__pycache__/__init__.cpython-312.pyc,sha256=GCuchZzMG77ILVDNh1wX5eerxwQlrnm0VGNAqjGITXg,199
7
- coffy/graph/__pycache__/graphdb_nx.cpython-312.pyc,sha256=USv0c6J20Iu-meznDh2PehAlSnEU2fRxH3_9pplXj0c,8580
8
+ coffy/graph/__pycache__/graphdb_nx.cpython-312.pyc,sha256=JSv_32YuTTsDy9Yfl3jCSUYORtH95_pL6OHTtrvf7Ts,21201
8
9
  coffy/nosql/__init__.py,sha256=HN9UfUd9pooB7udUxNn3EAttEjGG394fECOXDb6SH60,197
9
- coffy/nosql/engine.py,sha256=-rXYu4Ps6P_6C8nIbEpGRuDeJgAi33_FebGZNlM4jsg,10103
10
+ coffy/nosql/engine.py,sha256=6pN9uIC_Z07ZWlIkD8g4TZv8kwjZ9RMreM41HIsa6mM,11009
11
+ coffy/nosql/nosql_tests.py,sha256=fL7C8zm-o48CZPIA4-FPtDHsn59Ahj4zH4e6LlJFHQk,3743
10
12
  coffy/nosql/__pycache__/__init__.cpython-311.pyc,sha256=0dDD3adswuWrOOUVE_2KjO80zmOHkPAjW7pxMg4fukk,463
11
13
  coffy/nosql/__pycache__/__init__.cpython-312.pyc,sha256=NdU26hkvPHawUktm4TUoEirspFgWDMaixaQV7cGAYIc,409
12
14
  coffy/nosql/__pycache__/engine.cpython-311.pyc,sha256=lJm3ZWQbStmBV1p9pmXdrIBcf2PMPM0SGozUEHdInA8,27471
13
- coffy/nosql/__pycache__/engine.cpython-312.pyc,sha256=YCxgeof-6PE5SlrmDLyYLu8967UXN42vpciaUAM5XCg,24241
15
+ coffy/nosql/__pycache__/engine.cpython-312.pyc,sha256=wzPBARp1nNbNEWhnAcHRIoI4yPxDFivFs-eGY7Db40A,25140
14
16
  coffy/sql/__init__.py,sha256=dXoCW3Qyk3WoYl-y2gHCc8YK6dAUt0Eaqhtk3PEDHMQ,202
15
17
  coffy/sql/engine.py,sha256=q9A7zvg9JANpWAFvbw8wfGY5BTkvObpM3eoQ6dZYu_8,1065
16
18
  coffy/sql/sqldict.py,sha256=0IxlCz1hi1zj5Q4_8If9YXwQBZGczpMir7PMXBOxbjs,1837
@@ -21,8 +23,8 @@ coffy/sql/__pycache__/engine.cpython-312.pyc,sha256=fKlvycKKYZOslQd6SDHexrWV_NDc
21
23
  coffy/sql/__pycache__/io.cpython-312.pyc,sha256=TPkeJ3qUE_ZcvcykGIf-Yyc0__5FZKB1J95_2yTFrXY,2517
22
24
  coffy/sql/__pycache__/sqldict.cpython-311.pyc,sha256=jJQDFH9ULzi2ay4pyXXye-W_PSjxuT_ULb20CdL8Ec0,5131
23
25
  coffy/sql/__pycache__/sqldict.cpython-312.pyc,sha256=T4P9qMjH7dHqDTp3gzZQbhpdpfgdopmC3ccm8O8gooc,4339
24
- coffy-0.1.2.dist-info/licenses/LICENSE,sha256=iRyxG6b7B-JPKuOcS7w5lDhrL0AD9fFSDUh6-KMKDO8,1068
25
- coffy-0.1.2.dist-info/METADATA,sha256=c5qu5C1bdV6hvTX1GKUPoWHu1IoPvWMIlsNCcKOE4Nw,2856
26
- coffy-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
27
- coffy-0.1.2.dist-info/top_level.txt,sha256=J1zGvXA_bfET3PsE4-qbFrtFMIN3bJNxBRMHKk6YIv0,6
28
- coffy-0.1.2.dist-info/RECORD,,
26
+ coffy-0.1.5.dist-info/licenses/LICENSE,sha256=iRyxG6b7B-JPKuOcS7w5lDhrL0AD9fFSDUh6-KMKDO8,1068
27
+ coffy-0.1.5.dist-info/METADATA,sha256=9RYiM5TAqAJ8_HUsPHNGskTVgoILpI3xkCMhSdQsVU8,3256
28
+ coffy-0.1.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
+ coffy-0.1.5.dist-info/top_level.txt,sha256=J1zGvXA_bfET3PsE4-qbFrtFMIN3bJNxBRMHKk6YIv0,6
30
+ coffy-0.1.5.dist-info/RECORD,,
File without changes