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.
- {post_graph-1.2.0 → post_graph-1.3.0}/PKG-INFO +1 -1
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/__init__.py +1 -1
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/client_asyncpg.py +63 -5
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/client_sqlalchemy.py +63 -5
- {post_graph-1.2.0 → post_graph-1.3.0}/pyproject.toml +1 -1
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_range_queries.py +61 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/.github/workflows/publish.yml +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/.gitignore +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/LICENSE +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/Makefile +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/README.md +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/demo.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/demo_cypher.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/demo_schema_per_realm.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/demo_space.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/demo_transaction.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/demo_uuid.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/demo_vector.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/docs/_config.yml +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/docs/cypher.md +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/docs/index.md +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/docs/pg19-property-graphs.html +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/greek_gods.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/__init__.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/ast.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/engine.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/lexer.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/parser.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/cypher/translator.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/errors.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/models.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/post_graph/promoted.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/requirements.txt +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tables/README.md +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tables/capability-comparison.csv +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tables/extract.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/conftest.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/tck/fetch_tck.sh +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/tck/gherkin.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/tck/runner.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_client_asyncpg.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_client_sqlalchemy.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_cypher.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_errors.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_find_filters.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_init.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_models.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_promoted.py +0 -0
- {post_graph-1.2.0 → post_graph-1.3.0}/tests/test_realm_isolation.py +0 -0
- {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.
|
|
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
|
|
@@ -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;
|
|
2339
|
-
|
|
2340
|
-
|
|
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
|
-
|
|
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;
|
|
2283
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|