post-graph 1.2.0__tar.gz → 1.3.0__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.
Files changed (50) hide show
  1. {post_graph-1.2.0 → post_graph-1.3.0}/PKG-INFO +1 -1
  2. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/__init__.py +1 -1
  3. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/client_asyncpg.py +63 -5
  4. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/client_sqlalchemy.py +63 -5
  5. {post_graph-1.2.0 → post_graph-1.3.0}/pyproject.toml +1 -1
  6. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_range_queries.py +61 -0
  7. {post_graph-1.2.0 → post_graph-1.3.0}/.github/workflows/publish.yml +0 -0
  8. {post_graph-1.2.0 → post_graph-1.3.0}/.gitignore +0 -0
  9. {post_graph-1.2.0 → post_graph-1.3.0}/LICENSE +0 -0
  10. {post_graph-1.2.0 → post_graph-1.3.0}/Makefile +0 -0
  11. {post_graph-1.2.0 → post_graph-1.3.0}/README.md +0 -0
  12. {post_graph-1.2.0 → post_graph-1.3.0}/demo.py +0 -0
  13. {post_graph-1.2.0 → post_graph-1.3.0}/demo_cypher.py +0 -0
  14. {post_graph-1.2.0 → post_graph-1.3.0}/demo_schema_per_realm.py +0 -0
  15. {post_graph-1.2.0 → post_graph-1.3.0}/demo_space.py +0 -0
  16. {post_graph-1.2.0 → post_graph-1.3.0}/demo_transaction.py +0 -0
  17. {post_graph-1.2.0 → post_graph-1.3.0}/demo_uuid.py +0 -0
  18. {post_graph-1.2.0 → post_graph-1.3.0}/demo_vector.py +0 -0
  19. {post_graph-1.2.0 → post_graph-1.3.0}/docs/_config.yml +0 -0
  20. {post_graph-1.2.0 → post_graph-1.3.0}/docs/cypher.md +0 -0
  21. {post_graph-1.2.0 → post_graph-1.3.0}/docs/index.md +0 -0
  22. {post_graph-1.2.0 → post_graph-1.3.0}/docs/pg19-property-graphs.html +0 -0
  23. {post_graph-1.2.0 → post_graph-1.3.0}/greek_gods.py +0 -0
  24. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/__init__.py +0 -0
  25. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/ast.py +0 -0
  26. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/engine.py +0 -0
  27. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/lexer.py +0 -0
  28. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/parser.py +0 -0
  29. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/translator.py +0 -0
  30. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/errors.py +0 -0
  31. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/models.py +0 -0
  32. {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/promoted.py +0 -0
  33. {post_graph-1.2.0 → post_graph-1.3.0}/requirements.txt +0 -0
  34. {post_graph-1.2.0 → post_graph-1.3.0}/tables/README.md +0 -0
  35. {post_graph-1.2.0 → post_graph-1.3.0}/tables/capability-comparison.csv +0 -0
  36. {post_graph-1.2.0 → post_graph-1.3.0}/tables/extract.py +0 -0
  37. {post_graph-1.2.0 → post_graph-1.3.0}/tests/conftest.py +0 -0
  38. {post_graph-1.2.0 → post_graph-1.3.0}/tests/tck/fetch_tck.sh +0 -0
  39. {post_graph-1.2.0 → post_graph-1.3.0}/tests/tck/gherkin.py +0 -0
  40. {post_graph-1.2.0 → post_graph-1.3.0}/tests/tck/runner.py +0 -0
  41. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_client_asyncpg.py +0 -0
  42. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_client_sqlalchemy.py +0 -0
  43. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_cypher.py +0 -0
  44. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_errors.py +0 -0
  45. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_find_filters.py +0 -0
  46. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_init.py +0 -0
  47. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_models.py +0 -0
  48. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_promoted.py +0 -0
  49. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_realm_isolation.py +0 -0
  50. {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_tck.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: post-graph
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: High-performance PostgreSQL-backed graph database library supporting multi-tenant realms, space sub-grouping, schema-per-realm, shadow auditing, and append-only data history.
5
5
  Project-URL: Homepage, https://github.com/crajah/post-graph
6
6
  Project-URL: Repository, https://github.com/crajah/post-graph
@@ -20,7 +20,7 @@ try:
20
20
  except ImportError:
21
21
  SQLAlchemyPostGraph = None
22
22
 
23
- __version__ = "1.2.0"
23
+ __version__ = "1.3.0"
24
24
 
25
25
  __all__ = [
26
26
  "JSON_NULL",
@@ -1289,6 +1289,50 @@ class AsyncPostGraph:
1289
1289
  else:
1290
1290
  return await _op(self.connection)
1291
1291
 
1292
+ async def count_edges(
1293
+ self,
1294
+ table_name: str,
1295
+ realm: str,
1296
+ filters: Optional[Dict[str, Any]] = None,
1297
+ space: Optional[str] = None,
1298
+ relation_type: Optional[str] = None,
1299
+ where: Optional[List[tuple]] = None,
1300
+ ) -> int:
1301
+ """COUNT(*) over edges under the same predicates as :meth:`find_edges`."""
1302
+ self._validate_identifier(table_name)
1303
+ table_ref = self._get_table_ref(table_name, realm)
1304
+
1305
+ async def _op(conn):
1306
+ params: list = [realm]
1307
+ clauses = ""
1308
+ if space and space != RESERVED_SPACE_ALL:
1309
+ params.append(space)
1310
+ clauses += f" AND (t.space = ${len(params)} OR (${len(params)} = 'default' AND (t.space IS NULL OR t.space = 'default')))"
1311
+ if relation_type:
1312
+ params.append(relation_type)
1313
+ clauses += f" AND t.relation_type = ${len(params)}"
1314
+ containment, absent_keys = self._prepare_filters(filters)
1315
+ if containment is not None:
1316
+ params.append(containment)
1317
+ clauses += f" AND t.payload @> ${len(params)}::jsonb"
1318
+ for key in absent_keys:
1319
+ params.append(key)
1320
+ clauses += f" AND NOT jsonb_exists(t.payload, ${len(params)})"
1321
+ where_sql, _ = self._compile_where(where, params)
1322
+ clauses += where_sql
1323
+ try:
1324
+ return await conn.fetchval(
1325
+ f"SELECT COUNT(*) FROM {table_ref} t WHERE t.realm = $1{clauses}",
1326
+ *params)
1327
+ except asyncpg.UndefinedTableError:
1328
+ raise TableNotFoundError(f"Edge table '{table_name}' does not exist.")
1329
+
1330
+ if isinstance(self.connection, asyncpg.Pool):
1331
+ async with self.connection.acquire() as conn:
1332
+ return await _op(conn)
1333
+ else:
1334
+ return await _op(self.connection)
1335
+
1292
1336
  async def delete_vertices(
1293
1337
  self,
1294
1338
  table_name: str,
@@ -2328,18 +2372,23 @@ class AsyncPostGraph:
2328
2372
  self,
2329
2373
  table_name: str,
2330
2374
  realm: str,
2331
- filters: Dict[str, Any],
2375
+ filters: Optional[Dict[str, Any]] = None,
2332
2376
  space: Optional[str] = None,
2333
2377
  relation_type: Optional[str] = None,
2334
2378
  limit: Optional[int] = None,
2379
+ where: Optional[List[tuple]] = None,
2380
+ order_by: Optional[str] = None,
2381
+ descending: bool = False,
2335
2382
  ) -> List[Edge]:
2336
2383
  """Find edges whose payload matches the given key-value filters.
2337
2384
 
2338
- Matching is by JSONB containment; see :meth:`find_vertices` for the
2339
- semantics (type-sensitive, index-backed, containment for nested
2340
- values). Optional *relation_type* further restricts results.
2385
+ Matching is by JSONB containment; *where*, *order_by* and *descending*
2386
+ behave exactly as on :meth:`find_vertices`. Optional *relation_type*
2387
+ further restricts results.
2341
2388
  """
2342
2389
  self._validate_identifier(table_name)
2390
+ if order_by is not None:
2391
+ self._validate_payload_key(order_by)
2343
2392
  table_ref = self._get_table_ref(table_name, realm)
2344
2393
 
2345
2394
  async def _op(conn):
@@ -2358,6 +2407,15 @@ class AsyncPostGraph:
2358
2407
  for key in absent_keys:
2359
2408
  params.append(key)
2360
2409
  clauses += f" AND NOT jsonb_exists(t.payload, ${len(params)})"
2410
+ where_sql, numeric_keys = self._compile_where(where, params)
2411
+ clauses += where_sql
2412
+ if order_by is not None:
2413
+ acc = f"t.payload->>'{order_by}'"
2414
+ if order_by in numeric_keys:
2415
+ acc = f"({acc})::numeric"
2416
+ order_clause = f"ORDER BY {acc} {'DESC' if descending else 'ASC'}, t.id ASC"
2417
+ else:
2418
+ order_clause = "ORDER BY t.id ASC"
2361
2419
  limit_clause = ""
2362
2420
  if limit:
2363
2421
  params.append(limit)
@@ -2369,7 +2427,7 @@ class AsyncPostGraph:
2369
2427
  to_jsonb(t)->>'embedding' AS embedding_text
2370
2428
  FROM {table_ref} t
2371
2429
  WHERE t.realm = $1{clauses}
2372
- ORDER BY t.id ASC{limit_clause}
2430
+ {order_clause}{limit_clause}
2373
2431
  """
2374
2432
  try:
2375
2433
  rows = await conn.fetch(query, *params)
@@ -1511,6 +1511,51 @@ class SQLAlchemyPostGraph:
1511
1511
  async with self.engine_or_connection.connect() as conn:
1512
1512
  return await _op(conn)
1513
1513
 
1514
+ async def count_edges(
1515
+ self,
1516
+ table_name: str,
1517
+ realm: str,
1518
+ filters: Optional[Dict[str, Any]] = None,
1519
+ space: Optional[str] = None,
1520
+ relation_type: Optional[str] = None,
1521
+ where: Optional[List[tuple]] = None,
1522
+ ) -> int:
1523
+ """COUNT(*) over edges under the same predicates as :meth:`find_edges`."""
1524
+ self._validate_identifier(table_name)
1525
+ table_ref = self._get_table_ref(table_name, realm)
1526
+
1527
+ async def _op(conn):
1528
+ params: Dict[str, Any] = {"realm": realm}
1529
+ clauses = ""
1530
+ if space and space != RESERVED_SPACE_ALL:
1531
+ params["space"] = space
1532
+ clauses += " AND (t.space = :space OR (:space = 'default' AND (t.space IS NULL OR t.space = 'default')))"
1533
+ if relation_type:
1534
+ params["rtype"] = relation_type
1535
+ clauses += " AND t.relation_type = :rtype"
1536
+ containment, absent_keys = self._prepare_filters(filters)
1537
+ if containment is not None:
1538
+ params["fjson"] = containment
1539
+ clauses += " AND t.payload @> CAST(:fjson AS jsonb)"
1540
+ for i, key in enumerate(absent_keys):
1541
+ pname = f"fabs{i}"
1542
+ params[pname] = key
1543
+ clauses += f" AND NOT jsonb_exists(t.payload, :{pname})"
1544
+ where_sql, _ = self._compile_where(where, params)
1545
+ clauses += where_sql
1546
+ try:
1547
+ result = await conn.execute(text(
1548
+ f"SELECT COUNT(*) FROM {table_ref} t WHERE t.realm = :realm{clauses}"),
1549
+ params)
1550
+ return int(result.scalar_one())
1551
+ except ProgrammingError:
1552
+ raise TableNotFoundError(f"Edge table '{table_name}' does not exist.")
1553
+
1554
+ if isinstance(self.engine_or_connection, AsyncConnection):
1555
+ return await _op(self.engine_or_connection)
1556
+ async with self.engine_or_connection.connect() as conn:
1557
+ return await _op(conn)
1558
+
1514
1559
  async def delete_vertices(
1515
1560
  self,
1516
1561
  table_name: str,
@@ -2272,19 +2317,23 @@ class SQLAlchemyPostGraph:
2272
2317
  self,
2273
2318
  table_name: str,
2274
2319
  realm: str,
2275
- filters: Dict[str, Any],
2320
+ filters: Optional[Dict[str, Any]] = None,
2276
2321
  space: Optional[str] = None,
2277
2322
  relation_type: Optional[str] = None,
2278
2323
  limit: Optional[int] = None,
2324
+ where: Optional[List[tuple]] = None,
2325
+ order_by: Optional[str] = None,
2326
+ descending: bool = False,
2279
2327
  ) -> List[Edge]:
2280
2328
  """Find edges whose payload matches the given key-value filters.
2281
2329
 
2282
- Matching is by JSONB containment; see :meth:`find_vertices` for the
2283
- semantics (type-sensitive, index-backed, containment for nested
2284
- values, sentinels for null and absent keys). Optional *relation_type*
2330
+ Matching is by JSONB containment; *where*, *order_by* and *descending*
2331
+ behave exactly as on :meth:`find_vertices`. Optional *relation_type*
2285
2332
  further restricts results.
2286
2333
  """
2287
2334
  self._validate_identifier(table_name)
2335
+ if order_by is not None:
2336
+ self._validate_payload_key(order_by)
2288
2337
  table_ref = self._get_table_ref(table_name, realm)
2289
2338
 
2290
2339
  async def _op(conn):
@@ -2304,6 +2353,15 @@ class SQLAlchemyPostGraph:
2304
2353
  pname = f"fabs{i}"
2305
2354
  params[pname] = key
2306
2355
  clauses += f" AND NOT jsonb_exists(t.payload, :{pname})"
2356
+ where_sql, numeric_keys = self._compile_where(where, params)
2357
+ clauses += where_sql
2358
+ if order_by is not None:
2359
+ acc = f"t.payload->>'{order_by}'"
2360
+ if order_by in numeric_keys:
2361
+ acc = f"({acc})::numeric"
2362
+ order_clause = f"ORDER BY {acc} {'DESC' if descending else 'ASC'}, t.id ASC"
2363
+ else:
2364
+ order_clause = "ORDER BY t.id ASC"
2307
2365
  limit_clause = ""
2308
2366
  if limit:
2309
2367
  params["limit"] = limit
@@ -2315,7 +2373,7 @@ class SQLAlchemyPostGraph:
2315
2373
  to_jsonb(t)->>'embedding' AS embedding_text
2316
2374
  FROM {table_ref} t
2317
2375
  WHERE t.realm = :realm{clauses}
2318
- ORDER BY t.id ASC{limit_clause}
2376
+ {order_clause}{limit_clause}
2319
2377
  """
2320
2378
  try:
2321
2379
  result = await conn.execute(text(query), params)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "post-graph"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "High-performance PostgreSQL-backed graph database library supporting multi-tenant realms, space sub-grouping, schema-per-realm, shadow auditing, and append-only data history."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -239,3 +239,64 @@ class TestSchemaPerRealm:
239
239
  await c.delete_realm(realm)
240
240
  except Exception:
241
241
  pass
242
+
243
+
244
+ class TestEdgeRangeQueries:
245
+ """The same where/order_by semantics on edges, which is what a bi-temporal
246
+ relation store needs: t_created/t_expired live on edge payloads."""
247
+
248
+ async def _seed_edges(self, c, realm):
249
+ await c.create_vertex_table("rq_nodes2", realm=realm)
250
+ await c.create_edge_table("rq_rel", from_vertex_table="rq_nodes2",
251
+ to_vertex_table="rq_nodes2", realm=realm)
252
+ a = await c.add_vertex("rq_nodes2", realm=realm, payload={"name": "a"})
253
+ b = await c.add_vertex("rq_nodes2", realm=realm, payload={"name": "b"})
254
+ stamps = ["2026-01-01T00:00:00", "2026-02-01T00:00:00", "2026-03-01T00:00:00"]
255
+ for i, ts in enumerate(stamps):
256
+ payload = {"t_created": ts, "k": i}
257
+ if i == 0:
258
+ payload["t_expired"] = "2026-02-15T00:00:00"
259
+ await c.add_edge("rq_rel", realm=realm, from_id=a.id, to_id=b.id,
260
+ relation_type=f"r{i}", payload=payload)
261
+ return "rq_rel"
262
+
263
+ async def test_edge_where_text_range_and_order(self, any_client, clean_realm):
264
+ c, realm = any_client, clean_realm
265
+ t = await self._seed_edges(c, realm)
266
+ got = await c.find_edges(t, realm=realm,
267
+ where=[("t_created", ">", "2026-01-15T00:00:00")],
268
+ order_by="t_created", descending=True)
269
+ assert [e.payload["k"] for e in got] == [2, 1]
270
+
271
+ async def test_edge_is_null_and_not_null(self, any_client, clean_realm):
272
+ c, realm = any_client, clean_realm
273
+ t = await self._seed_edges(c, realm)
274
+ expired = await c.find_edges(t, realm=realm,
275
+ where=[("t_expired", "not_null", None)])
276
+ assert [e.payload["k"] for e in expired] == [0]
277
+ live = await c.find_edges(t, realm=realm,
278
+ where=[("t_expired", "is_null", None)])
279
+ assert sorted(e.payload["k"] for e in live) == [1, 2]
280
+
281
+ async def test_count_edges(self, any_client, clean_realm):
282
+ c, realm = any_client, clean_realm
283
+ t = await self._seed_edges(c, realm)
284
+ assert await c.count_edges(t, realm=realm) == 3
285
+ assert await c.count_edges(
286
+ t, realm=realm,
287
+ where=[("t_created", ">=", "2026-02-01T00:00:00")]) == 2
288
+ assert await c.count_edges(t, realm=realm, relation_type="r0") == 1
289
+
290
+ async def test_edge_numeric_where(self, any_client, clean_realm):
291
+ c, realm = any_client, clean_realm
292
+ t = await self._seed_edges(c, realm)
293
+ got = await c.find_edges(t, realm=realm, where=[("k", ">=", 1)],
294
+ order_by="k")
295
+ assert [e.payload["k"] for e in got] == [1, 2]
296
+
297
+ async def test_edge_payload_index(self, any_client, clean_realm):
298
+ c, realm = any_client, clean_realm
299
+ t = await self._seed_edges(c, realm)
300
+ n1 = await c.create_payload_index(t, realm=realm, key="t_created")
301
+ n2 = await c.create_payload_index(t, realm=realm, key="t_created")
302
+ assert n1 == n2 == f"idx_{t}_payload_t_created"
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