post-graph 0.1.0__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,1265 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from typing import Any, Dict, List, Optional, Tuple, Union
5
+ import asyncpg
6
+
7
+ from post_graph.errors import (
8
+ VertexNotFoundError,
9
+ EdgeNotFoundError,
10
+ TableExistsError,
11
+ TableNotFoundError,
12
+ PostGraphError,
13
+ )
14
+ from post_graph.models import Vertex, Edge, DataRecord
15
+
16
+ logger = logging.getLogger("post_graph")
17
+
18
+
19
+ class AsyncPostGraph:
20
+ def __init__(
21
+ self,
22
+ connection_or_pool: Union[asyncpg.Connection, asyncpg.Pool, None] = None,
23
+ dsn: Optional[str] = None,
24
+ schema_per_realm: bool = False,
25
+ **conn_kwargs
26
+ ):
27
+ self.connection = connection_or_pool
28
+ self.dsn = dsn
29
+ self.schema_per_realm = schema_per_realm
30
+ self.conn_kwargs = conn_kwargs
31
+ self._pool = None
32
+ self._schema_cache = {} # Cache for edge metadata: key is edge_table (or (realm, edge_table) if schema_per_realm)
33
+
34
+ async def connect(self):
35
+ """Establish connection or connection pool to PostgreSQL."""
36
+ if self.connection is None:
37
+ if self.dsn:
38
+ self._pool = await asyncpg.create_pool(self.dsn, **self.conn_kwargs)
39
+ else:
40
+ self._pool = await asyncpg.create_pool(**self.conn_kwargs)
41
+ self.connection = self._pool
42
+
43
+ async def close(self):
44
+ """Close connection pool if it was managed by this client."""
45
+ if self._pool:
46
+ await self._pool.close()
47
+ self._pool = None
48
+ self.connection = None
49
+
50
+ def _validate_identifier(self, identifier: str):
51
+ """Ensure identifiers are safe and valid to prevent SQL injection."""
52
+ if not identifier or not isinstance(identifier, str):
53
+ raise ValueError("Identifier must be a non-empty string.")
54
+ if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', identifier):
55
+ raise ValueError(
56
+ f"Invalid identifier: '{identifier}'. Must be alphanumeric and underscores only, "
57
+ f"starting with a letter or underscore."
58
+ )
59
+
60
+ def _get_table_ref(self, table_name: str, realm: Optional[str] = None) -> str:
61
+ """Get the table reference. Qualifies with schema (realm) if schema_per_realm is active."""
62
+ self._validate_identifier(table_name)
63
+ if self.schema_per_realm:
64
+ if not realm:
65
+ raise PostGraphError(f"Realm must be specified for table reference '{table_name}' under schema_per_realm mode.")
66
+ self._validate_identifier(realm)
67
+ return f'"{realm}"."{table_name}"'
68
+ return f'"{table_name}"'
69
+
70
+ async def _execute(self, query: str, *args) -> str:
71
+ if isinstance(self.connection, asyncpg.Pool):
72
+ async with self.connection.acquire() as conn:
73
+ return await conn.execute(query, *args)
74
+ else:
75
+ return await self.connection.execute(query, *args)
76
+
77
+ async def _fetch(self, query: str, *args) -> List[asyncpg.Record]:
78
+ if isinstance(self.connection, asyncpg.Pool):
79
+ async with self.connection.acquire() as conn:
80
+ return await conn.fetch(query, *args)
81
+ else:
82
+ return await self.connection.fetch(query, *args)
83
+
84
+ async def _fetchrow(self, query: str, *args) -> Optional[asyncpg.Record]:
85
+ if isinstance(self.connection, asyncpg.Pool):
86
+ async with self.connection.acquire() as conn:
87
+ return await conn.fetchrow(query, *args)
88
+ else:
89
+ return await self.connection.fetchrow(query, *args)
90
+
91
+ async def _run_in_tx(self, func, user_id: Optional[str] = None):
92
+ """Helper to run a block of operations inside a transaction, setting the user_id session context."""
93
+ async def _execute_block(conn):
94
+ if user_id:
95
+ # Set transaction-local session variable for auditing
96
+ await conn.execute("SELECT set_config('app.current_user_id', $1, true)", str(user_id))
97
+ else:
98
+ await conn.execute("SELECT set_config('app.current_user_id', '', true)")
99
+ return await func(conn)
100
+
101
+ if isinstance(self.connection, asyncpg.Pool):
102
+ async with self.connection.acquire() as conn:
103
+ async with conn.transaction():
104
+ return await _execute_block(conn)
105
+ else:
106
+ async with self.connection.transaction():
107
+ return await _execute_block(self.connection)
108
+
109
+ async def _table_exists(self, table_name: str, realm: Optional[str] = None) -> bool:
110
+ """Check if a table exists in the database within the proper schema namespace."""
111
+ self._validate_identifier(table_name)
112
+ if self.schema_per_realm:
113
+ if not realm:
114
+ raise PostGraphError(f"Realm must be specified for _table_exists('{table_name}') in schema_per_realm mode.")
115
+ self._validate_identifier(realm)
116
+ query = """
117
+ SELECT 1
118
+ FROM pg_class c
119
+ JOIN pg_namespace n ON c.relnamespace = n.oid
120
+ WHERE c.relname = $1 AND c.relkind = 'r' AND n.nspname = $2
121
+ """
122
+ row = await self._fetchrow(query, table_name, realm)
123
+ else:
124
+ query = """
125
+ SELECT 1
126
+ FROM pg_class c
127
+ WHERE c.relname = $1 AND c.relkind = 'r' AND pg_table_is_visible(c.oid)
128
+ """
129
+ row = await self._fetchrow(query, table_name)
130
+ return row is not None
131
+
132
+ async def create_vertex_table(self, table_name: str, realm: Optional[str] = None):
133
+ """Create a new vertex table and its associated shadow audit table, indexes, and triggers."""
134
+ self._validate_identifier(table_name)
135
+ if self.schema_per_realm:
136
+ if not realm:
137
+ raise PostGraphError("realm must be specified in schema_per_realm mode when creating a table.")
138
+ self._validate_identifier(realm)
139
+
140
+ table_ref = self._get_table_ref(table_name, realm)
141
+ audit_table_ref = self._get_table_ref(f"{table_name}_audit", realm)
142
+ data_table_ref = self._get_table_ref(f"{table_name}_data", realm)
143
+
144
+ # Resolve schema prefix for triggers/functions
145
+ if self.schema_per_realm:
146
+ schema_prefix = f'"{realm}".'
147
+ await self._execute(f'CREATE SCHEMA IF NOT EXISTS "{realm}"')
148
+ else:
149
+ schema_prefix = ""
150
+
151
+ # Create trigger function for updated_at
152
+ await self._execute(f"""
153
+ CREATE OR REPLACE FUNCTION {schema_prefix}update_modified_column()
154
+ RETURNS TRIGGER AS $$
155
+ BEGIN
156
+ NEW.updated_at = now();
157
+ RETURN NEW;
158
+ END;
159
+ $$ language 'plpgsql';
160
+ """)
161
+
162
+ # Create shared trigger function for auditing
163
+ await self._execute(f"""
164
+ CREATE OR REPLACE FUNCTION {schema_prefix}audit_trigger_func()
165
+ RETURNS TRIGGER AS $$
166
+ DECLARE
167
+ v_user_id TEXT;
168
+ v_old_row JSONB := NULL;
169
+ v_new_row JSONB := NULL;
170
+ v_realm TEXT;
171
+ BEGIN
172
+ v_user_id := NULLIF(current_setting('app.current_user_id', true), '');
173
+
174
+ IF (TG_OP = 'DELETE') THEN
175
+ v_old_row := to_jsonb(OLD);
176
+ v_realm := OLD.realm;
177
+ ELSIF (TG_OP = 'UPDATE') THEN
178
+ v_old_row := to_jsonb(OLD);
179
+ v_new_row := to_jsonb(NEW);
180
+ v_realm := NEW.realm;
181
+ ELSIF (TG_OP = 'INSERT') THEN
182
+ v_new_row := to_jsonb(NEW);
183
+ v_realm := NEW.realm;
184
+ END IF;
185
+
186
+ EXECUTE format(
187
+ 'INSERT INTO %I.%I (realm, action, changed_by, old_row, new_row) VALUES ($1, $2, $3, $4, $5)',
188
+ TG_TABLE_SCHEMA, TG_TABLE_NAME || '_audit'
189
+ ) USING v_realm, TG_OP, v_user_id, v_old_row, v_new_row;
190
+
191
+ IF (TG_OP = 'DELETE') THEN
192
+ RETURN OLD;
193
+ ELSE
194
+ RETURN NEW;
195
+ END IF;
196
+ END;
197
+ $$ LANGUAGE plpgsql;
198
+ """)
199
+
200
+ # 1. Create main table
201
+ query = f"""
202
+ CREATE TABLE IF NOT EXISTS {table_ref} (
203
+ realm TEXT NOT NULL,
204
+ id BIGSERIAL,
205
+ fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{table_name}' || '/' || id::text) STORED NOT NULL,
206
+ payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
207
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
208
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
209
+ PRIMARY KEY (realm, id)
210
+ );
211
+ """
212
+ await self._execute(query)
213
+ await self._execute(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{table_name}' || '/' || id::text) STORED;")
214
+
215
+ # 2. Create shadow audit table
216
+ audit_query = f"""
217
+ CREATE TABLE IF NOT EXISTS {audit_table_ref} (
218
+ audit_id BIGSERIAL PRIMARY KEY,
219
+ realm TEXT NOT NULL,
220
+ action TEXT NOT NULL,
221
+ changed_by TEXT,
222
+ changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
223
+ old_row JSONB,
224
+ new_row JSONB
225
+ );
226
+ """
227
+ await self._execute(audit_query)
228
+
229
+ # 3. Create append-only data table
230
+ data_query = f"""
231
+ CREATE TABLE IF NOT EXISTS {data_table_ref} (
232
+ data_id BIGSERIAL PRIMARY KEY,
233
+ realm TEXT NOT NULL,
234
+ id BIGINT NOT NULL,
235
+ payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
236
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
237
+ FOREIGN KEY (realm, id) REFERENCES {table_ref}(realm, id) ON DELETE CASCADE
238
+ );
239
+ """
240
+ await self._execute(data_query)
241
+ await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_id" ON {data_table_ref} (realm, id);')
242
+ await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_payload" ON {data_table_ref} USING gin (payload);')
243
+
244
+ # 4. Create GIN index on payload
245
+ await self._execute(
246
+ f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_payload" ON {table_ref} USING gin (payload);'
247
+ )
248
+
249
+ # 5. Create trigger for updated_at
250
+ await self._execute(f'DROP TRIGGER IF EXISTS "update_{table_name}_modtime" ON {table_ref};')
251
+ await self._execute(f"""
252
+ CREATE TRIGGER "update_{table_name}_modtime"
253
+ BEFORE UPDATE ON {table_ref}
254
+ FOR EACH ROW
255
+ EXECUTE FUNCTION {schema_prefix}update_modified_column();
256
+ """)
257
+
258
+ # 6. Create trigger for auditing
259
+ await self._execute(f'DROP TRIGGER IF EXISTS "audit_{table_name}_trigger" ON {table_ref};')
260
+ await self._execute(f"""
261
+ CREATE TRIGGER "audit_{table_name}_trigger"
262
+ AFTER INSERT OR UPDATE OR DELETE ON {table_ref}
263
+ FOR EACH ROW
264
+ EXECUTE FUNCTION {schema_prefix}audit_trigger_func();
265
+ """)
266
+
267
+ async def create_edge_table(
268
+ self,
269
+ table_name: Optional[str] = None,
270
+ *,
271
+ from_vertex_table: str,
272
+ to_vertex_table: str,
273
+ cascade_delete_from: bool = False,
274
+ cascade_delete_to: bool = False,
275
+ realm: Optional[str] = None
276
+ ):
277
+ """Create a new edge table linking two vertex tables, plus shadow audit table and constraints."""
278
+ # Validate vertex table identifiers
279
+ self._validate_identifier(from_vertex_table)
280
+ self._validate_identifier(to_vertex_table)
281
+
282
+ # Determine effective edge table name: default is "{from}TO{to}" if not provided
283
+ if not table_name:
284
+ table_name = f"{from_vertex_table}TO{to_vertex_table}"
285
+ else:
286
+ self._validate_identifier(table_name)
287
+
288
+ if self.schema_per_realm:
289
+ if not realm:
290
+ raise PostGraphError("realm must be specified in schema_per_realm mode when creating an edge table.")
291
+ self._validate_identifier(realm)
292
+
293
+ table_ref = self._get_table_ref(table_name, realm)
294
+ audit_table_ref = self._get_table_ref(f"{table_name}_audit", realm)
295
+ data_table_ref = self._get_table_ref(f"{table_name}_data", realm)
296
+ from_vertex_ref = self._get_table_ref(from_vertex_table, realm)
297
+ to_vertex_ref = self._get_table_ref(to_vertex_table, realm)
298
+
299
+ # Check that from_vertex_table and to_vertex_table exist
300
+ if not await self._table_exists(from_vertex_table, realm=realm):
301
+ raise TableNotFoundError(f"Referenced from_vertex_table '{from_vertex_table}' does not exist.")
302
+ if not await self._table_exists(to_vertex_table, realm=realm):
303
+ raise TableNotFoundError(f"Referenced to_vertex_table '{to_vertex_table}' does not exist.")
304
+
305
+ # Resolve schema prefix for triggers/functions
306
+ if self.schema_per_realm:
307
+ schema_prefix = f'"{realm}".'
308
+ else:
309
+ schema_prefix = ""
310
+
311
+ # 1. Create main edge table
312
+ query = f"""
313
+ CREATE TABLE IF NOT EXISTS {table_ref} (
314
+ realm TEXT NOT NULL,
315
+ id BIGSERIAL,
316
+ fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{from_vertex_table}-{to_vertex_table}' || '/' || id::text) STORED NOT NULL,
317
+ from_id BIGINT NOT NULL,
318
+ to_id BIGINT NOT NULL,
319
+ relation_type TEXT NOT NULL,
320
+ payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
321
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
322
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
323
+ PRIMARY KEY (realm, id),
324
+ FOREIGN KEY (realm, from_id) REFERENCES {from_vertex_ref}(realm, id) ON DELETE CASCADE,
325
+ FOREIGN KEY (realm, to_id) REFERENCES {to_vertex_ref}(realm, id) ON DELETE CASCADE
326
+ );
327
+ """
328
+ await self._execute(query)
329
+ await self._execute(f"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS fqid TEXT GENERATED ALWAYS AS (realm || '/' || '{from_vertex_table}-{to_vertex_table}' || '/' || id::text) STORED;")
330
+
331
+ # 2. Create shadow audit table
332
+ audit_query = f"""
333
+ CREATE TABLE IF NOT EXISTS {audit_table_ref} (
334
+ audit_id BIGSERIAL PRIMARY KEY,
335
+ realm TEXT NOT NULL,
336
+ action TEXT NOT NULL,
337
+ changed_by TEXT,
338
+ changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
339
+ old_row JSONB,
340
+ new_row JSONB
341
+ );
342
+ """
343
+ await self._execute(audit_query)
344
+
345
+ # 3. Create append-only data table
346
+ data_query = f"""
347
+ CREATE TABLE IF NOT EXISTS {data_table_ref} (
348
+ data_id BIGSERIAL PRIMARY KEY,
349
+ realm TEXT NOT NULL,
350
+ id BIGINT NOT NULL,
351
+ payload JSONB NOT NULL DEFAULT '{{}}'::jsonb,
352
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
353
+ FOREIGN KEY (realm, id) REFERENCES {table_ref}(realm, id) ON DELETE CASCADE
354
+ );
355
+ """
356
+ await self._execute(data_query)
357
+ await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_id" ON {data_table_ref} (realm, id);')
358
+ await self._execute(f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_data_payload" ON {data_table_ref} USING gin (payload);')
359
+
360
+ # 3. Create indexes
361
+ await self._execute(
362
+ f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_to" ON {table_ref} (realm, to_id);'
363
+ )
364
+ await self._execute(
365
+ f'CREATE INDEX IF NOT EXISTS "idx_{table_name}_payload" ON {table_ref} USING gin (payload);'
366
+ )
367
+
368
+ # 4. Create trigger for updated_at
369
+ await self._execute(f'DROP TRIGGER IF EXISTS "update_{table_name}_modtime" ON {table_ref};')
370
+ await self._execute(f"""
371
+ CREATE TRIGGER "update_{table_name}_modtime"
372
+ BEFORE UPDATE ON {table_ref}
373
+ FOR EACH ROW
374
+ EXECUTE FUNCTION {schema_prefix}update_modified_column();
375
+ """)
376
+
377
+ # 5. Create trigger for auditing
378
+ await self._execute(f'DROP TRIGGER IF EXISTS "audit_{table_name}_trigger" ON {table_ref};')
379
+ await self._execute(f"""
380
+ CREATE TRIGGER "audit_{table_name}_trigger"
381
+ AFTER INSERT OR UPDATE OR DELETE ON {table_ref}
382
+ FOR EACH ROW
383
+ EXECUTE FUNCTION {schema_prefix}audit_trigger_func();
384
+ """)
385
+
386
+ # 6. Create custom cascade delete trigger if either boolean is True
387
+ await self._execute(f'DROP TRIGGER IF EXISTS "cascade_delete_trigger_{table_name}" ON {table_ref};')
388
+ await self._execute(f'DROP FUNCTION IF EXISTS {schema_prefix}"cascade_delete_func_{table_name}"();')
389
+ if cascade_delete_from or cascade_delete_to:
390
+ from_clause = f'DELETE FROM {from_vertex_ref} WHERE realm = OLD.realm AND id = OLD.from_id;' if cascade_delete_from else ''
391
+ to_clause = f'DELETE FROM {to_vertex_ref} WHERE realm = OLD.realm AND id = OLD.to_id;' if cascade_delete_to else ''
392
+
393
+ await self._execute(f"""
394
+ CREATE OR REPLACE FUNCTION {schema_prefix}"cascade_delete_func_{table_name}"()
395
+ RETURNS TRIGGER AS $$
396
+ BEGIN
397
+ {from_clause}
398
+ {to_clause}
399
+ RETURN OLD;
400
+ END;
401
+ $$ LANGUAGE plpgsql;
402
+ """)
403
+ await self._execute(f"""
404
+ CREATE TRIGGER "cascade_delete_trigger_{table_name}"
405
+ AFTER DELETE ON {table_ref}
406
+ FOR EACH ROW
407
+ EXECUTE FUNCTION {schema_prefix}"cascade_delete_func_{table_name}"();
408
+ """)
409
+
410
+ # Clear cached schema as a new edge is declared
411
+ cache_key = (realm, table_name) if self.schema_per_realm else table_name
412
+ self._schema_cache.pop(cache_key, None)
413
+
414
+ async def add_vertex(
415
+ self,
416
+ table_name: str,
417
+ realm: str,
418
+ vertex_id: Optional[Union[str, int]] = None,
419
+ payload: Optional[Dict[str, Any]] = None,
420
+ user_id: Optional[str] = None
421
+ ) -> Vertex:
422
+ """Add a new vertex. Raises TableExistsError if it already exists."""
423
+ self._validate_identifier(table_name)
424
+ payload_json = json.dumps(payload or {})
425
+ table_ref = self._get_table_ref(table_name, realm)
426
+
427
+ async def _op(conn):
428
+ nonlocal vertex_id
429
+ table_ref_pg = f'"{realm}"."{table_name}"' if self.schema_per_realm else f'"{table_name}"'
430
+ if vertex_id is None:
431
+ seq_query = f"SELECT nextval(pg_get_serial_sequence('{table_ref_pg}', 'id'))"
432
+ v_id_int = await conn.fetchval(seq_query)
433
+ else:
434
+ v_id_int = int(str(vertex_id).split('/')[-1]) if '/' in str(vertex_id) else int(vertex_id)
435
+
436
+ v_id_str = str(v_id_int)
437
+
438
+ query = f"""
439
+ INSERT INTO {table_ref} (realm, id, payload)
440
+ VALUES ($1, $2, $3::jsonb)
441
+ RETURNING realm, id, fqid, payload, created_at, updated_at
442
+ """
443
+ try:
444
+ row = await conn.fetchrow(query, realm, v_id_int, payload_json)
445
+ if vertex_id is not None:
446
+ await conn.execute(
447
+ f"SELECT setval(pg_get_serial_sequence('{table_ref_pg}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM {table_ref}))"
448
+ )
449
+ return Vertex(
450
+ realm=row['realm'],
451
+ id=str(row['id']),
452
+ fqid=row['fqid'],
453
+ payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
454
+ created_at=row['created_at'],
455
+ updated_at=row['updated_at'],
456
+ table_name=table_name,
457
+ _client=self
458
+ )
459
+ except asyncpg.UniqueViolationError:
460
+ raise TableExistsError(
461
+ f"Vertex with ID '{v_id_str}' already exists in table '{table_name}' under realm '{realm}'."
462
+ )
463
+ except asyncpg.UndefinedTableError:
464
+ raise TableNotFoundError(f"Vertex table '{table_name}' does not exist.")
465
+
466
+ return await self._run_in_tx(_op, user_id)
467
+
468
+ async def upsert_vertex(
469
+ self,
470
+ table_name: str,
471
+ realm: str,
472
+ vertex_id: Optional[Union[str, int]] = None,
473
+ payload: Optional[Dict[str, Any]] = None,
474
+ user_id: Optional[str] = None
475
+ ) -> Vertex:
476
+ """Upsert a vertex (merges payload JSONB on conflict)."""
477
+ self._validate_identifier(table_name)
478
+ payload_json = json.dumps(payload or {})
479
+ table_ref = self._get_table_ref(table_name, realm)
480
+
481
+ async def _op(conn):
482
+ nonlocal vertex_id
483
+ table_ref_pg = f'"{realm}"."{table_name}"' if self.schema_per_realm else f'"{table_name}"'
484
+ if vertex_id is None:
485
+ seq_query = f"SELECT nextval(pg_get_serial_sequence('{table_ref_pg}', 'id'))"
486
+ v_id_int = await conn.fetchval(seq_query)
487
+ else:
488
+ v_id_int = int(str(vertex_id).split('/')[-1]) if '/' in str(vertex_id) else int(vertex_id)
489
+
490
+ v_id_str = str(v_id_int)
491
+
492
+ query = f"""
493
+ INSERT INTO {table_ref} (realm, id, payload)
494
+ VALUES ($1, $2, $3::jsonb)
495
+ ON CONFLICT (realm, id) DO UPDATE
496
+ SET payload = {table_ref}.payload || EXCLUDED.payload,
497
+ updated_at = CURRENT_TIMESTAMP
498
+ RETURNING realm, id, fqid, payload, created_at, updated_at
499
+ """
500
+ try:
501
+ row = await conn.fetchrow(query, realm, v_id_int, payload_json)
502
+ return Vertex(
503
+ realm=row['realm'],
504
+ id=str(row['id']),
505
+ fqid=row['fqid'],
506
+ payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
507
+ created_at=row['created_at'],
508
+ updated_at=row['updated_at'],
509
+ table_name=table_name,
510
+ _client=self
511
+ )
512
+ except asyncpg.UndefinedTableError:
513
+ raise TableNotFoundError(f"Vertex table '{table_name}' does not exist.")
514
+
515
+ return await self._run_in_tx(_op, user_id)
516
+
517
+ async def get_vertex(self, table_name: str, realm: str, vertex_id: str) -> Optional[Vertex]:
518
+ """Fetch a vertex by realm and id."""
519
+ self._validate_identifier(table_name)
520
+ table_ref = self._get_table_ref(table_name, realm)
521
+ v_str = str(vertex_id)
522
+ query = f"""
523
+ SELECT realm, id, fqid, payload, created_at, updated_at
524
+ FROM {table_ref}
525
+ WHERE realm = $1 AND ((CASE WHEN $2 ~ '^[0-9]+$' THEN id = $2::bigint ELSE FALSE END) OR fqid = $2)
526
+ """
527
+ try:
528
+ row = await self._fetchrow(query, realm, v_str)
529
+ if not row:
530
+ return None
531
+ return Vertex(
532
+ realm=row['realm'],
533
+ id=str(row['id']),
534
+ fqid=row['fqid'] or f"{row['realm']}/{table_name}/{row['id']}",
535
+ payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
536
+ created_at=row['created_at'],
537
+ updated_at=row['updated_at'],
538
+ table_name=table_name,
539
+ _client=self
540
+ )
541
+ except asyncpg.UndefinedTableError:
542
+ raise TableNotFoundError(f"Vertex table '{table_name}' does not exist.")
543
+
544
+ async def delete_vertex(self, table_name: str, realm: str, vertex_id: str, user_id: Optional[str] = None) -> bool:
545
+ """Delete a vertex. Cascading foreign keys will automatically delete referencing edges."""
546
+ self._validate_identifier(table_name)
547
+ table_ref = self._get_table_ref(table_name, realm)
548
+ v_str = str(vertex_id)
549
+
550
+ async def _op(conn):
551
+ query = f"""
552
+ DELETE FROM {table_ref}
553
+ WHERE realm = $1 AND ((CASE WHEN $2 ~ '^[0-9]+$' THEN id = $2::bigint ELSE FALSE END) OR fqid = $2)
554
+ """
555
+ try:
556
+ res = await conn.execute(query, realm, v_str)
557
+ return res == "DELETE 1"
558
+ except asyncpg.UndefinedTableError:
559
+ raise TableNotFoundError(f"Vertex table '{table_name}' does not exist.")
560
+
561
+ return await self._run_in_tx(_op, user_id)
562
+
563
+ async def add_edge(
564
+ self,
565
+ table_name: str,
566
+ realm: str,
567
+ from_id: str,
568
+ to_id: str,
569
+ relation_type: str,
570
+ edge_id: Optional[Union[str, int]] = None,
571
+ payload: Optional[Dict[str, Any]] = None,
572
+ user_id: Optional[str] = None,
573
+ check_cycle: Union[bool, List[str]] = False
574
+ ) -> Edge:
575
+ """Add a new edge. Raises TableExistsError if it already exists."""
576
+ self._validate_identifier(table_name)
577
+ payload_json = json.dumps(payload or {})
578
+ table_ref = self._get_table_ref(table_name, realm)
579
+
580
+ async def _op(conn):
581
+ nonlocal edge_id
582
+ schema = await self.get_edge_schema(table_name, realm=realm, conn=conn)
583
+ from_table = schema["from_id"]
584
+ to_table = schema["to_id"]
585
+
586
+ table_ref_pg = f'"{realm}"."{table_name}"' if self.schema_per_realm else f'"{table_name}"'
587
+ if edge_id is None:
588
+ seq_query = f"SELECT nextval(pg_get_serial_sequence('{table_ref_pg}', 'id'))"
589
+ e_id_int = await conn.fetchval(seq_query)
590
+ else:
591
+ e_id_int = int(str(edge_id).split('/')[-1]) if '/' in str(edge_id) else int(edge_id)
592
+
593
+ from_id_int = int(str(from_id).split('/')[-1]) if '/' in str(from_id) else int(from_id)
594
+ to_id_int = int(str(to_id).split('/')[-1]) if '/' in str(to_id) else int(to_id)
595
+
596
+ e_id_str = str(e_id_int)
597
+
598
+ if check_cycle:
599
+ cycle_tables = check_cycle if isinstance(check_cycle, list) else [table_name]
600
+
601
+ path = await self.shortest_path(
602
+ realm=realm,
603
+ start_table=to_table,
604
+ start_id=str(to_id_int),
605
+ target_table=from_table,
606
+ target_id=str(from_id_int),
607
+ edge_tables=cycle_tables,
608
+ conn=conn
609
+ )
610
+ if path:
611
+ from post_graph.errors import CyclicReferenceError
612
+ raise CyclicReferenceError(
613
+ f"Adding edge '{e_id_str}' from '{from_id}' to '{to_id}' would create a cyclic reference. "
614
+ f"Existing path: {' -> '.join(path['path'])}"
615
+ )
616
+
617
+ query = f"""
618
+ INSERT INTO {table_ref} (realm, id, from_id, to_id, relation_type, payload)
619
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb)
620
+ RETURNING realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at
621
+ """
622
+ try:
623
+ row = await conn.fetchrow(query, realm, e_id_int, from_id_int, to_id_int, relation_type, payload_json)
624
+ if edge_id is not None:
625
+ await conn.execute(
626
+ f"SELECT setval(pg_get_serial_sequence('{table_ref_pg}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM {table_ref}))"
627
+ )
628
+ return Edge(
629
+ realm=row['realm'],
630
+ id=str(row['id']),
631
+ fqid=row['fqid'],
632
+ from_id=str(row['from_id']),
633
+ to_id=str(row['to_id']),
634
+ relation_type=row['relation_type'],
635
+ payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
636
+ created_at=row['created_at'],
637
+ updated_at=row['updated_at'],
638
+ table_name=table_name,
639
+ _client=self
640
+ )
641
+ except asyncpg.UniqueViolationError:
642
+ raise TableExistsError(
643
+ f"Edge with ID '{e_id_str}' already exists in table '{table_name}' under realm '{realm}'."
644
+ )
645
+ except asyncpg.ForeignKeyViolationError as e:
646
+ raise VertexNotFoundError(f"Foreign key violation: referenced vertices do not exist. Details: {e}")
647
+ except asyncpg.UndefinedTableError:
648
+ raise TableNotFoundError(f"Edge table '{table_name}' does not exist.")
649
+
650
+ return await self._run_in_tx(_op, user_id)
651
+
652
+ async def upsert_edge(
653
+ self,
654
+ table_name: str,
655
+ realm: str,
656
+ from_id: str,
657
+ to_id: str,
658
+ relation_type: str,
659
+ edge_id: Optional[Union[str, int]] = None,
660
+ payload: Optional[Dict[str, Any]] = None,
661
+ user_id: Optional[str] = None,
662
+ check_cycle: Union[bool, List[str]] = False
663
+ ) -> Edge:
664
+ """Upsert an edge (merges payload JSONB on conflict)."""
665
+ self._validate_identifier(table_name)
666
+ payload_json = json.dumps(payload or {})
667
+ table_ref = self._get_table_ref(table_name, realm)
668
+
669
+ async def _op(conn):
670
+ nonlocal edge_id
671
+ schema = await self.get_edge_schema(table_name, realm=realm, conn=conn)
672
+ from_table = schema["from_id"]
673
+ to_table = schema["to_id"]
674
+
675
+ table_ref_pg = f'"{realm}"."{table_name}"' if self.schema_per_realm else f'"{table_name}"'
676
+ if edge_id is None:
677
+ seq_query = f"SELECT nextval(pg_get_serial_sequence('{table_ref_pg}', 'id'))"
678
+ e_id_int = await conn.fetchval(seq_query)
679
+ else:
680
+ e_id_int = int(str(edge_id).split('/')[-1]) if '/' in str(edge_id) else int(edge_id)
681
+
682
+ from_id_int = int(str(from_id).split('/')[-1]) if '/' in str(from_id) else int(from_id)
683
+ to_id_int = int(str(to_id).split('/')[-1]) if '/' in str(to_id) else int(to_id)
684
+
685
+ e_id_str = str(e_id_int)
686
+
687
+ if check_cycle:
688
+ cycle_tables = check_cycle if isinstance(check_cycle, list) else [table_name]
689
+
690
+ path = await self.shortest_path(
691
+ realm=realm,
692
+ start_table=to_table,
693
+ start_id=str(to_id_int),
694
+ target_table=from_table,
695
+ target_id=str(from_id_int),
696
+ edge_tables=cycle_tables,
697
+ conn=conn
698
+ )
699
+ if path:
700
+ from post_graph.errors import CyclicReferenceError
701
+ raise CyclicReferenceError(
702
+ f"Upserting edge '{e_id_str}' from '{from_id}' to '{to_id}' would create a cyclic reference. "
703
+ f"Existing path: {' -> '.join(path['path'])}"
704
+ )
705
+
706
+ query = f"""
707
+ INSERT INTO {table_ref} (realm, id, from_id, to_id, relation_type, payload)
708
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb)
709
+ ON CONFLICT (realm, id) DO UPDATE
710
+ SET from_id = EXCLUDED.from_id,
711
+ to_id = EXCLUDED.to_id,
712
+ relation_type = EXCLUDED.relation_type,
713
+ payload = {table_ref}.payload || EXCLUDED.payload,
714
+ updated_at = CURRENT_TIMESTAMP
715
+ RETURNING realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at
716
+ """
717
+ try:
718
+ row = await conn.fetchrow(query, realm, e_id_int, from_id_int, to_id_int, relation_type, payload_json)
719
+ return Edge(
720
+ realm=row['realm'],
721
+ id=str(row['id']),
722
+ fqid=row['fqid'],
723
+ from_id=str(row['from_id']),
724
+ to_id=str(row['to_id']),
725
+ relation_type=row['relation_type'],
726
+ payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
727
+ created_at=row['created_at'],
728
+ updated_at=row['updated_at'],
729
+ table_name=table_name,
730
+ _client=self
731
+ )
732
+ except asyncpg.ForeignKeyViolationError as e:
733
+ raise VertexNotFoundError(f"Foreign key violation: referenced vertices do not exist. Details: {e}")
734
+ except asyncpg.UndefinedTableError:
735
+ raise TableNotFoundError(f"Edge table '{table_name}' does not exist.")
736
+
737
+ return await self._run_in_tx(_op, user_id)
738
+
739
+ async def get_edge(self, table_name: str, realm: str, edge_id: str) -> Optional[Edge]:
740
+ """Fetch an edge by realm and id."""
741
+ self._validate_identifier(table_name)
742
+ table_ref = self._get_table_ref(table_name, realm)
743
+ e_str = str(edge_id)
744
+ query = f"""
745
+ SELECT realm, id, fqid, from_id, to_id, relation_type, payload, created_at, updated_at
746
+ FROM {table_ref}
747
+ WHERE realm = $1 AND ((CASE WHEN $2 ~ '^[0-9]+$' THEN id = $2::bigint ELSE FALSE END) OR fqid = $2)
748
+ """
749
+ try:
750
+ row = await self._fetchrow(query, realm, e_str)
751
+ if not row:
752
+ return None
753
+ return Edge(
754
+ realm=row['realm'],
755
+ id=str(row['id']),
756
+ fqid=row['fqid'],
757
+ from_id=str(row['from_id']),
758
+ to_id=str(row['to_id']),
759
+ relation_type=row['relation_type'],
760
+ payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
761
+ created_at=row['created_at'],
762
+ updated_at=row['updated_at'],
763
+ table_name=table_name,
764
+ _client=self
765
+ )
766
+ except asyncpg.UndefinedTableError:
767
+ raise TableNotFoundError(f"Edge table '{table_name}' does not exist.")
768
+
769
+ async def delete_edge(self, table_name: str, realm: str, edge_id: str, user_id: Optional[str] = None) -> bool:
770
+ """Delete an edge."""
771
+ self._validate_identifier(table_name)
772
+ table_ref = self._get_table_ref(table_name, realm)
773
+ e_str = str(edge_id)
774
+
775
+ async def _op(conn):
776
+ query = f"""
777
+ DELETE FROM {table_ref}
778
+ WHERE realm = $1 AND ((CASE WHEN $2 ~ '^[0-9]+$' THEN id = $2::bigint ELSE FALSE END) OR fqid = $2)
779
+ """
780
+ try:
781
+ res = await conn.execute(query, realm, e_str)
782
+ return res == "DELETE 1"
783
+ except asyncpg.UndefinedTableError:
784
+ raise TableNotFoundError(f"Edge table '{table_name}' does not exist.")
785
+
786
+ return await self._run_in_tx(_op, user_id)
787
+
788
+ async def delete_realm(self, realm: str, user_id: Optional[str] = None) -> int:
789
+ """Delete all rows belonging to a specific realm from all graph tables (vertices, edges, and audit tables)."""
790
+ async def _op(conn):
791
+ if self.schema_per_realm:
792
+ query = """
793
+ SELECT DISTINCT table_name
794
+ FROM information_schema.columns
795
+ WHERE column_name = 'realm'
796
+ AND table_schema = $1
797
+ """
798
+ rows = await conn.fetch(query, realm)
799
+ else:
800
+ query = """
801
+ SELECT DISTINCT table_name
802
+ FROM information_schema.columns
803
+ WHERE column_name = 'realm'
804
+ AND table_schema = CURRENT_SCHEMA()
805
+ """
806
+ rows = await conn.fetch(query)
807
+
808
+ tables = [row['table_name'] for row in rows]
809
+
810
+ total_deleted = 0
811
+ for table in tables:
812
+ table_ref = self._get_table_ref(table, realm)
813
+ delete_query = f'DELETE FROM {table_ref} WHERE realm = $1'
814
+ res = await conn.execute(delete_query, realm)
815
+ if res.startswith("DELETE "):
816
+ total_deleted += int(res.split(" ")[1])
817
+ return total_deleted
818
+
819
+ return await self._run_in_tx(_op, user_id)
820
+
821
+ async def get_edge_schema(self, edge_table: str, realm: Optional[str] = None, conn = None) -> Dict[str, str]:
822
+ """
823
+ Query system catalog pg_constraint to discover referenced vertex tables
824
+ for 'from_id' and 'to_id' keys of the edge table. Caches the schema.
825
+ """
826
+ cache_key = (realm, edge_table) if self.schema_per_realm else edge_table
827
+ if cache_key in self._schema_cache:
828
+ return self._schema_cache[cache_key]
829
+
830
+ self._validate_identifier(edge_table)
831
+ table_ref = self._get_table_ref(edge_table, realm)
832
+
833
+ query = """
834
+ SELECT
835
+ a.attname AS column_name,
836
+ c.relname AS referenced_table
837
+ FROM pg_constraint con
838
+ JOIN pg_class c ON con.confrelid = c.oid
839
+ JOIN pg_attribute a ON a.attnum = ANY(con.conkey) AND a.attrelid = con.conrelid
840
+ WHERE con.conrelid = $1::regclass
841
+ AND con.contype = 'f'
842
+ AND a.attname IN ('from_id', 'to_id');
843
+ """
844
+
845
+ try:
846
+ if conn:
847
+ rows = await conn.fetch(query, table_ref)
848
+ else:
849
+ rows = await self._fetch(query, table_ref)
850
+ except asyncpg.UndefinedTableError:
851
+ raise TableNotFoundError(f"Edge table '{edge_table}' does not exist.")
852
+ except Exception as e:
853
+ raise PostGraphError(f"Error querying schema for '{edge_table}': {e}")
854
+
855
+ schema = {}
856
+ for row in rows:
857
+ schema[row['column_name']] = row['referenced_table']
858
+
859
+ if len(schema) < 2:
860
+ raise PostGraphError(
861
+ f"Edge table '{edge_table}' is missing proper foreign key constraints "
862
+ f"on 'from_id' and/or 'to_id' referencing vertex tables."
863
+ )
864
+
865
+ self._schema_cache[cache_key] = schema
866
+ return schema
867
+
868
+ async def get_neighbors(
869
+ self,
870
+ realm: str,
871
+ vertex_table: str,
872
+ vertex_id: str,
873
+ edge_tables: List[str],
874
+ direction: str = 'out'
875
+ ) -> List[Tuple[Vertex, Edge]]:
876
+ """
877
+ Get direct neighbor vertices and connecting edges.
878
+ Returns a list of tuples: (NeighborVertex, ConnectingEdge)
879
+ """
880
+ self._validate_identifier(vertex_table)
881
+ if direction not in ('out', 'in', 'both'):
882
+ raise ValueError("Direction must be 'out', 'in', or 'both'")
883
+
884
+ v_id_int = int(str(vertex_id).split('/')[-1]) if '/' in str(vertex_id) else int(vertex_id)
885
+
886
+ results = []
887
+ for edge_table in edge_tables:
888
+ self._validate_identifier(edge_table)
889
+ schema = await self.get_edge_schema(edge_table, realm=realm)
890
+
891
+ from_ref = schema['from_id']
892
+ to_ref = schema['to_id']
893
+
894
+ queries = []
895
+
896
+ # 1. Outgoing paths: from_id is the start, to_id is the neighbor
897
+ if direction in ('out', 'both') and from_ref == vertex_table:
898
+ edge_ref = self._get_table_ref(edge_table, realm)
899
+ to_ref_ref = self._get_table_ref(to_ref, realm)
900
+ queries.append((
901
+ to_ref,
902
+ f"""
903
+ SELECT
904
+ v.id AS v_id, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
905
+ e.id AS e_id, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
906
+ FROM {edge_ref} e
907
+ JOIN {to_ref_ref} v ON e.realm = v.realm AND e.to_id = v.id
908
+ WHERE e.realm = $1 AND e.from_id = $2
909
+ """
910
+ ))
911
+
912
+ # 2. Incoming paths: to_id is the start, from_id is the neighbor
913
+ if direction in ('in', 'both') and to_ref == vertex_table:
914
+ edge_ref = self._get_table_ref(edge_table, realm)
915
+ from_ref_ref = self._get_table_ref(from_ref, realm)
916
+ queries.append((
917
+ from_ref,
918
+ f"""
919
+ SELECT
920
+ v.id AS v_id, v.fqid AS v_fqid, v.payload AS v_payload, v.created_at AS v_created_at, v.updated_at AS v_updated_at,
921
+ e.id AS e_id, e.fqid AS e_fqid, e.from_id AS e_from, e.to_id AS e_to, e.relation_type AS e_rel, e.payload AS e_payload, e.created_at AS e_created_at, e.updated_at AS e_updated_at
922
+ FROM {edge_ref} e
923
+ JOIN {from_ref_ref} v ON e.realm = v.realm AND e.from_id = v.id
924
+ WHERE e.realm = $1 AND e.to_id = $2
925
+ """
926
+ ))
927
+
928
+ for neighbor_table, sql in queries:
929
+ rows = await self._fetch(sql, realm, v_id_int)
930
+ for r in rows:
931
+ v = Vertex(
932
+ realm=realm,
933
+ id=str(r['v_id']),
934
+ fqid=r['v_fqid'],
935
+ payload=r['v_payload'] if isinstance(r['v_payload'], dict) else json.loads(r['v_payload']),
936
+ created_at=r['v_created_at'],
937
+ updated_at=r['v_updated_at'],
938
+ table_name=neighbor_table,
939
+ _client=self
940
+ )
941
+ e = Edge(
942
+ realm=realm,
943
+ id=str(r['e_id']),
944
+ fqid=r['e_fqid'],
945
+ from_id=str(r['e_from']),
946
+ to_id=str(r['e_to']),
947
+ relation_type=r['e_rel'],
948
+ payload=r['e_payload'] if isinstance(r['e_payload'], dict) else json.loads(r['e_payload']),
949
+ created_at=r['e_created_at'],
950
+ updated_at=r['e_updated_at'],
951
+ table_name=edge_table,
952
+ _client=self
953
+ )
954
+ results.append((v, e))
955
+
956
+ return results
957
+
958
+ async def traverse(
959
+ self,
960
+ realm: str,
961
+ start_table: str,
962
+ start_id: str,
963
+ edge_tables: List[str],
964
+ max_depth: int = 3,
965
+ direction: str = 'out'
966
+ ) -> List[Dict[str, Any]]:
967
+ """
968
+ Perform a dynamic graph traversal starting from (start_table, start_id)
969
+ up to max_depth. scoped to a specific realm.
970
+ """
971
+ self._validate_identifier(start_table)
972
+ if direction not in ('out', 'in', 'both'):
973
+ raise ValueError("Direction must be 'out', 'in', or 'both'")
974
+
975
+ start_id_str = str(start_id).split('/')[-1] if '/' in str(start_id) else str(start_id)
976
+
977
+ subqueries = []
978
+ for edge_table in edge_tables:
979
+ schema = await self.get_edge_schema(edge_table, realm=realm)
980
+ from_ref = schema['from_id']
981
+ to_ref = schema['to_id']
982
+ edge_ref = self._get_table_ref(edge_table, realm)
983
+
984
+ if direction in ('out', 'both'):
985
+ subqueries.append(f"""
986
+ SELECT
987
+ to_id::text AS next_id,
988
+ '{to_ref}'::text AS next_table,
989
+ id::text AS edge_id,
990
+ relation_type,
991
+ payload,
992
+ '{edge_table}'::text AS edge_table
993
+ FROM {edge_ref}
994
+ WHERE realm = $1 AND from_id = (CASE WHEN t.current_id ~ '^[0-9]+$' THEN t.current_id::bigint ELSE NULL END) AND t.current_table = '{from_ref}'
995
+ """)
996
+
997
+ if direction in ('in', 'both'):
998
+ subqueries.append(f"""
999
+ SELECT
1000
+ from_id::text AS next_id,
1001
+ '{from_ref}'::text AS next_table,
1002
+ id::text AS edge_id,
1003
+ relation_type,
1004
+ payload,
1005
+ '{edge_table}'::text AS edge_table
1006
+ FROM {edge_ref}
1007
+ WHERE realm = $1 AND to_id = (CASE WHEN t.current_id ~ '^[0-9]+$' THEN t.current_id::bigint ELSE NULL END) AND t.current_table = '{to_ref}'
1008
+ """)
1009
+
1010
+ if not subqueries:
1011
+ return []
1012
+
1013
+ union_all_steps = "\nUNION ALL\n".join(subqueries)
1014
+
1015
+ cte_query = f"""
1016
+ WITH RECURSIVE graph_traversal AS (
1017
+ -- Anchor Member
1018
+ SELECT
1019
+ $2::text AS current_id,
1020
+ $3::text AS current_table,
1021
+ 0 AS depth,
1022
+ ARRAY[$3::text || ':' || $2::text]::text[] AS path,
1023
+ ARRAY[]::text[] AS edge_path,
1024
+ ARRAY[]::text[] AS edge_ids
1025
+
1026
+ UNION ALL
1027
+
1028
+ -- Recursive Member
1029
+ SELECT
1030
+ step.next_id,
1031
+ step.next_table,
1032
+ t.depth + 1,
1033
+ t.path || (step.next_table || ':' || step.next_id),
1034
+ t.edge_path || (step.edge_table || ':' || step.relation_type),
1035
+ t.edge_ids || step.edge_id
1036
+ FROM graph_traversal t
1037
+ CROSS JOIN LATERAL (
1038
+ {union_all_steps}
1039
+ ) step
1040
+ WHERE t.depth < $4
1041
+ AND NOT ((step.next_table || ':' || step.next_id) = ANY(t.path))
1042
+ )
1043
+ SELECT current_id, current_table, depth, path, edge_path, edge_ids FROM graph_traversal;
1044
+ """
1045
+
1046
+ rows = await self._fetch(cte_query, realm, start_id_str, start_table, max_depth)
1047
+
1048
+ return [
1049
+ {
1050
+ 'id': row['current_id'],
1051
+ 'table_name': row['current_table'],
1052
+ 'depth': row['depth'],
1053
+ 'path': row['path'],
1054
+ 'edge_path': row['edge_path'],
1055
+ 'edge_ids': row['edge_ids']
1056
+ }
1057
+ for row in rows
1058
+ ]
1059
+
1060
+ async def shortest_path(
1061
+ self,
1062
+ realm: str,
1063
+ start_table: str,
1064
+ start_id: str,
1065
+ target_table: str,
1066
+ target_id: str,
1067
+ edge_tables: List[str],
1068
+ max_depth: int = 5,
1069
+ direction: str = 'out',
1070
+ conn = None
1071
+ ) -> Optional[Dict[str, Any]]:
1072
+ """
1073
+ Find shortest path from (start_table, start_id) to (target_table, target_id)
1074
+ in a specific realm. Returns None if no path exists.
1075
+ """
1076
+ self._validate_identifier(start_table)
1077
+ self._validate_identifier(target_table)
1078
+ if direction not in ('out', 'in', 'both'):
1079
+ raise ValueError("Direction must be 'out', 'in', or 'both'")
1080
+
1081
+ start_id_str = str(start_id).split('/')[-1] if '/' in str(start_id) else str(start_id)
1082
+ target_id_str = str(target_id).split('/')[-1] if '/' in str(target_id) else str(target_id)
1083
+
1084
+ subqueries = []
1085
+ for edge_table in edge_tables:
1086
+ schema = await self.get_edge_schema(edge_table, realm=realm)
1087
+ from_ref = schema['from_id']
1088
+ to_ref = schema['to_id']
1089
+ edge_ref = self._get_table_ref(edge_table, realm)
1090
+
1091
+ if direction in ('out', 'both'):
1092
+ subqueries.append(f"""
1093
+ SELECT
1094
+ to_id::text AS next_id,
1095
+ '{to_ref}'::text AS next_table,
1096
+ id::text AS edge_id,
1097
+ relation_type,
1098
+ '{edge_table}'::text AS edge_table
1099
+ FROM {edge_ref}
1100
+ WHERE realm = $1 AND from_id = (CASE WHEN t.current_id ~ '^[0-9]+$' THEN t.current_id::bigint ELSE NULL END) AND t.current_table = '{from_ref}'
1101
+ """)
1102
+
1103
+ if direction in ('in', 'both'):
1104
+ subqueries.append(f"""
1105
+ SELECT
1106
+ from_id::text AS next_id,
1107
+ '{from_ref}'::text AS next_table,
1108
+ id::text AS edge_id,
1109
+ relation_type,
1110
+ '{edge_table}'::text AS edge_table
1111
+ FROM {edge_ref}
1112
+ WHERE realm = $1 AND to_id = (CASE WHEN t.current_id ~ '^[0-9]+$' THEN t.current_id::bigint ELSE NULL END) AND t.current_table = '{to_ref}'
1113
+ """)
1114
+
1115
+ if not subqueries:
1116
+ return None
1117
+
1118
+ union_all_steps = "\nUNION ALL\n".join(subqueries)
1119
+
1120
+ cte_query = f"""
1121
+ WITH RECURSIVE graph_traversal AS (
1122
+ -- Anchor Member
1123
+ SELECT
1124
+ $2::text AS current_id,
1125
+ $3::text AS current_table,
1126
+ 0 AS depth,
1127
+ ARRAY[$3::text || ':' || $2::text]::text[] AS path,
1128
+ ARRAY[]::text[] AS edge_path,
1129
+ ARRAY[]::text[] AS edge_ids
1130
+
1131
+ UNION ALL
1132
+
1133
+ -- Recursive Member
1134
+ SELECT
1135
+ step.next_id,
1136
+ step.next_table,
1137
+ t.depth + 1,
1138
+ t.path || (step.next_table || ':' || step.next_id),
1139
+ t.edge_path || (step.edge_table || ':' || step.relation_type),
1140
+ t.edge_ids || step.edge_id
1141
+ FROM graph_traversal t
1142
+ CROSS JOIN LATERAL (
1143
+ {union_all_steps}
1144
+ ) step
1145
+ WHERE t.depth < $4
1146
+ AND NOT ((step.next_table || ':' || step.next_id) = ANY(t.path))
1147
+ AND NOT (t.current_id = $5 AND t.current_table = $6)
1148
+ )
1149
+ SELECT depth, path, edge_path, edge_ids
1150
+ FROM graph_traversal
1151
+ WHERE current_id = $5 AND current_table = $6
1152
+ ORDER BY depth ASC
1153
+ LIMIT 1;
1154
+ """
1155
+
1156
+ if conn:
1157
+ row = await conn.fetchrow(cte_query, realm, start_id_str, start_table, max_depth, target_id_str, target_table)
1158
+ else:
1159
+ row = await self._fetchrow(cte_query, realm, start_id_str, start_table, max_depth, target_id_str, target_table)
1160
+
1161
+ if not row:
1162
+ return None
1163
+
1164
+ return {
1165
+ 'depth': row['depth'],
1166
+ 'path': row['path'],
1167
+ 'edge_path': row['edge_path'],
1168
+ 'edge_ids': row['edge_ids']
1169
+ }
1170
+
1171
+ async def add_vertex_data(
1172
+ self,
1173
+ table_name: str,
1174
+ realm: str,
1175
+ vertex_id: Union[str, int],
1176
+ payload: Dict[str, Any],
1177
+ timestamp: Optional[Any] = None,
1178
+ user_id: Optional[str] = None
1179
+ ) -> DataRecord:
1180
+ """Append a historical record to {table_name}_data table for a vertex."""
1181
+ self._validate_identifier(table_name)
1182
+ v_id_int = int(str(vertex_id).split('/')[-1]) if '/' in str(vertex_id) else int(vertex_id)
1183
+ payload_json = json.dumps(payload or {})
1184
+ data_table_ref = self._get_table_ref(f"{table_name}_data", realm)
1185
+
1186
+ async def _op(conn):
1187
+ if timestamp:
1188
+ query = f"""
1189
+ INSERT INTO {data_table_ref} (realm, id, payload, timestamp)
1190
+ VALUES ($1, $2, $3::jsonb, $4)
1191
+ RETURNING data_id, realm, id, payload, timestamp
1192
+ """
1193
+ row = await conn.fetchrow(query, realm, v_id_int, payload_json, timestamp)
1194
+ else:
1195
+ query = f"""
1196
+ INSERT INTO {data_table_ref} (realm, id, payload)
1197
+ VALUES ($1, $2, $3::jsonb)
1198
+ RETURNING data_id, realm, id, payload, timestamp
1199
+ """
1200
+ row = await conn.fetchrow(query, realm, v_id_int, payload_json)
1201
+
1202
+ return DataRecord(
1203
+ data_id=str(row['data_id']),
1204
+ realm=row['realm'],
1205
+ id=str(row['id']),
1206
+ payload=row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload']),
1207
+ timestamp=row['timestamp']
1208
+ )
1209
+
1210
+ return await self._run_in_tx(_op, user_id)
1211
+
1212
+ async def get_vertex_data(
1213
+ self,
1214
+ table_name: str,
1215
+ realm: str,
1216
+ vertex_id: Union[str, int],
1217
+ limit: Optional[int] = None
1218
+ ) -> List[DataRecord]:
1219
+ """Fetch append-only data records for a vertex sorted by timestamp descending."""
1220
+ self._validate_identifier(table_name)
1221
+ v_id_int = int(str(vertex_id).split('/')[-1]) if '/' in str(vertex_id) else int(vertex_id)
1222
+ data_table_ref = self._get_table_ref(f"{table_name}_data", realm)
1223
+
1224
+ limit_clause = f"LIMIT {limit}" if limit and limit > 0 else ""
1225
+ query = f"""
1226
+ SELECT data_id, realm, id, payload, timestamp
1227
+ FROM {data_table_ref}
1228
+ WHERE realm = $1 AND id = $2
1229
+ ORDER BY timestamp DESC, data_id DESC
1230
+ {limit_clause}
1231
+ """
1232
+ rows = await self._fetch(query, realm, v_id_int)
1233
+ return [
1234
+ DataRecord(
1235
+ data_id=str(r['data_id']),
1236
+ realm=r['realm'],
1237
+ id=str(r['id']),
1238
+ payload=r['payload'] if isinstance(r['payload'], dict) else json.loads(r['payload']),
1239
+ timestamp=r['timestamp']
1240
+ )
1241
+ for r in rows
1242
+ ]
1243
+
1244
+ async def add_edge_data(
1245
+ self,
1246
+ table_name: str,
1247
+ realm: str,
1248
+ edge_id: Union[str, int],
1249
+ payload: Dict[str, Any],
1250
+ timestamp: Optional[Any] = None,
1251
+ user_id: Optional[str] = None
1252
+ ) -> DataRecord:
1253
+ """Append a historical record to {table_name}_data table for an edge."""
1254
+ return await self.add_vertex_data(table_name, realm, edge_id, payload, timestamp=timestamp, user_id=user_id)
1255
+
1256
+ async def get_edge_data(
1257
+ self,
1258
+ table_name: str,
1259
+ realm: str,
1260
+ edge_id: Union[str, int],
1261
+ limit: Optional[int] = None
1262
+ ) -> List[DataRecord]:
1263
+ """Fetch append-only data records for an edge sorted by timestamp descending."""
1264
+ return await self.get_vertex_data(table_name, realm, edge_id, limit=limit)
1265
+