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.
- post_graph/__init__.py +25 -0
- post_graph/client_asyncpg.py +1265 -0
- post_graph/client_sqlalchemy.py +1307 -0
- post_graph/errors.py +28 -0
- post_graph/models.py +279 -0
- post_graph-0.1.0.dist-info/METADATA +290 -0
- post_graph-0.1.0.dist-info/RECORD +8 -0
- post_graph-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
|