remdb 0.3.180__py3-none-any.whl → 0.3.258__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.
- rem/agentic/README.md +36 -2
- rem/agentic/__init__.py +10 -1
- rem/agentic/context.py +185 -1
- rem/agentic/context_builder.py +56 -35
- rem/agentic/mcp/tool_wrapper.py +2 -2
- rem/agentic/providers/pydantic_ai.py +303 -111
- rem/agentic/schema.py +2 -2
- rem/api/main.py +1 -1
- rem/api/mcp_router/resources.py +223 -0
- rem/api/mcp_router/server.py +4 -0
- rem/api/mcp_router/tools.py +608 -166
- rem/api/routers/admin.py +30 -4
- rem/api/routers/auth.py +219 -20
- rem/api/routers/chat/child_streaming.py +393 -0
- rem/api/routers/chat/completions.py +77 -40
- rem/api/routers/chat/sse_events.py +7 -3
- rem/api/routers/chat/streaming.py +381 -291
- rem/api/routers/chat/streaming_utils.py +325 -0
- rem/api/routers/common.py +18 -0
- rem/api/routers/dev.py +7 -1
- rem/api/routers/feedback.py +11 -3
- rem/api/routers/messages.py +176 -38
- rem/api/routers/models.py +9 -1
- rem/api/routers/query.py +17 -15
- rem/api/routers/shared_sessions.py +16 -0
- rem/auth/jwt.py +19 -4
- rem/auth/middleware.py +42 -28
- rem/cli/README.md +62 -0
- rem/cli/commands/ask.py +205 -114
- rem/cli/commands/db.py +55 -31
- rem/cli/commands/experiments.py +1 -1
- rem/cli/commands/process.py +179 -43
- rem/cli/commands/query.py +109 -0
- rem/cli/commands/session.py +117 -0
- rem/cli/main.py +2 -0
- rem/models/core/experiment.py +1 -1
- rem/models/entities/ontology.py +18 -20
- rem/models/entities/session.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +1 -1
- rem/schemas/agents/rem.yaml +1 -1
- rem/schemas/agents/test_orchestrator.yaml +42 -0
- rem/schemas/agents/test_structured_output.yaml +52 -0
- rem/services/content/providers.py +151 -49
- rem/services/content/service.py +18 -5
- rem/services/embeddings/worker.py +26 -12
- rem/services/postgres/__init__.py +28 -3
- rem/services/postgres/diff_service.py +57 -5
- rem/services/postgres/programmable_diff_service.py +635 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +2 -2
- rem/services/postgres/register_type.py +11 -10
- rem/services/postgres/repository.py +39 -28
- rem/services/postgres/schema_generator.py +5 -5
- rem/services/postgres/sql_builder.py +6 -5
- rem/services/rem/README.md +4 -3
- rem/services/rem/parser.py +7 -10
- rem/services/rem/service.py +47 -0
- rem/services/session/__init__.py +8 -1
- rem/services/session/compression.py +47 -5
- rem/services/session/pydantic_messages.py +310 -0
- rem/services/session/reload.py +2 -1
- rem/settings.py +92 -7
- rem/sql/migrations/001_install.sql +125 -7
- rem/sql/migrations/002_install_models.sql +159 -149
- rem/sql/migrations/004_cache_system.sql +10 -276
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/schema_loader.py +180 -120
- {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/METADATA +7 -6
- {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/RECORD +70 -61
- {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/WHEEL +0 -0
- {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Programmable object diff service for comparing functions, triggers, and views.
|
|
3
|
+
|
|
4
|
+
The schema diff service (Alembic-based) only compares tables/columns/indexes.
|
|
5
|
+
This service fills the gap by comparing programmable objects.
|
|
6
|
+
|
|
7
|
+
Problem solved:
|
|
8
|
+
- Functions/triggers/views defined with CREATE OR REPLACE in migration files
|
|
9
|
+
- Once migration is marked "applied", object changes aren't detected
|
|
10
|
+
- Database can drift from source SQL files
|
|
11
|
+
|
|
12
|
+
Solution:
|
|
13
|
+
- Extract object definitions from SQL files (source of truth)
|
|
14
|
+
- Compare against installed objects in database
|
|
15
|
+
- Generate sync SQL to update stale objects
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import asyncpg
|
|
25
|
+
from loguru import logger
|
|
26
|
+
|
|
27
|
+
from ...settings import settings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ObjectType(str, Enum):
|
|
31
|
+
FUNCTION = "function"
|
|
32
|
+
TRIGGER = "trigger"
|
|
33
|
+
VIEW = "view"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ObjectDiff:
|
|
38
|
+
"""Difference detected for a single database object."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
object_type: ObjectType
|
|
42
|
+
status: str # "missing", "different", "extra"
|
|
43
|
+
source_def: Optional[str] = None
|
|
44
|
+
db_def: Optional[str] = None
|
|
45
|
+
# For triggers, track the table they're on
|
|
46
|
+
table_name: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class DiffResult:
|
|
51
|
+
"""Result of object comparison."""
|
|
52
|
+
|
|
53
|
+
has_changes: bool
|
|
54
|
+
diffs: list[ObjectDiff] = field(default_factory=list)
|
|
55
|
+
sync_sql: str = ""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def missing_count(self) -> int:
|
|
59
|
+
return sum(1 for d in self.diffs if d.status == "missing")
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def different_count(self) -> int:
|
|
63
|
+
return sum(1 for d in self.diffs if d.status == "different")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def extra_count(self) -> int:
|
|
67
|
+
return sum(1 for d in self.diffs if d.status == "extra")
|
|
68
|
+
|
|
69
|
+
def by_type(self, obj_type: ObjectType) -> list[ObjectDiff]:
|
|
70
|
+
"""Get diffs filtered by object type."""
|
|
71
|
+
return [d for d in self.diffs if d.object_type == obj_type]
|
|
72
|
+
|
|
73
|
+
def summary(self) -> str:
|
|
74
|
+
"""Human-readable summary of differences."""
|
|
75
|
+
lines = []
|
|
76
|
+
for obj_type in ObjectType:
|
|
77
|
+
type_diffs = self.by_type(obj_type)
|
|
78
|
+
if type_diffs:
|
|
79
|
+
missing = sum(1 for d in type_diffs if d.status == "missing")
|
|
80
|
+
different = sum(1 for d in type_diffs if d.status == "different")
|
|
81
|
+
extra = sum(1 for d in type_diffs if d.status == "extra")
|
|
82
|
+
parts = []
|
|
83
|
+
if missing:
|
|
84
|
+
parts.append(f"{missing} missing")
|
|
85
|
+
if different:
|
|
86
|
+
parts.append(f"{different} different")
|
|
87
|
+
if extra:
|
|
88
|
+
parts.append(f"{extra} extra")
|
|
89
|
+
lines.append(f" {obj_type.value}s: {', '.join(parts)}")
|
|
90
|
+
return "\n".join(lines) if lines else " No differences found"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ProgrammableDiffService:
|
|
94
|
+
"""
|
|
95
|
+
Service for comparing SQL functions, triggers, and views between source files and database.
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
service = ProgrammableDiffService()
|
|
99
|
+
result = await service.compute_diff()
|
|
100
|
+
|
|
101
|
+
if result.has_changes:
|
|
102
|
+
print(result.sync_sql) # SQL to sync objects
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, sql_dir: Optional[Path] = None):
|
|
106
|
+
"""
|
|
107
|
+
Initialize diff service.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
sql_dir: Directory containing SQL files with object definitions.
|
|
111
|
+
Defaults to rem/sql/migrations/
|
|
112
|
+
"""
|
|
113
|
+
if sql_dir is None:
|
|
114
|
+
import rem
|
|
115
|
+
|
|
116
|
+
sql_dir = Path(rem.__file__).parent / "sql" / "migrations"
|
|
117
|
+
|
|
118
|
+
self.sql_dir = sql_dir
|
|
119
|
+
|
|
120
|
+
# =========================================================================
|
|
121
|
+
# FUNCTION EXTRACTION AND COMPARISON
|
|
122
|
+
# =========================================================================
|
|
123
|
+
|
|
124
|
+
def _extract_functions_from_sql(self, sql_content: str) -> dict[str, str]:
|
|
125
|
+
"""
|
|
126
|
+
Extract function definitions from SQL content.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Dict mapping function name -> full CREATE OR REPLACE FUNCTION statement
|
|
130
|
+
"""
|
|
131
|
+
functions = {}
|
|
132
|
+
|
|
133
|
+
# Pattern to match CREATE OR REPLACE FUNCTION ... $$ ... $$ LANGUAGE ...
|
|
134
|
+
# Handles both $function$ and $$ delimiters
|
|
135
|
+
# Captures through LANGUAGE clause and optional modifiers (STABLE, IMMUTABLE, etc.)
|
|
136
|
+
pattern = r"""
|
|
137
|
+
(CREATE\s+OR\s+REPLACE\s+FUNCTION\s+
|
|
138
|
+
(?:public\.)?(\w+)\s*\([^)]*\) # function name and params
|
|
139
|
+
.*? # return type etc
|
|
140
|
+
AS\s+\$(\w*)\$ # opening delimiter
|
|
141
|
+
.*? # function body
|
|
142
|
+
\$\3\$ # matching closing delimiter
|
|
143
|
+
\s*LANGUAGE\s+\w+ # LANGUAGE clause (required)
|
|
144
|
+
(?:\s+(?:STABLE|IMMUTABLE|VOLATILE|SECURITY\s+DEFINER|SECURITY\s+INVOKER))* # optional modifiers
|
|
145
|
+
)
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
for match in re.finditer(pattern, sql_content, re.DOTALL | re.IGNORECASE | re.VERBOSE):
|
|
149
|
+
full_def = match.group(1).strip()
|
|
150
|
+
func_name = match.group(2).lower()
|
|
151
|
+
functions[func_name] = full_def
|
|
152
|
+
|
|
153
|
+
return functions
|
|
154
|
+
|
|
155
|
+
async def get_db_functions(self, conn: asyncpg.Connection) -> dict[str, str]:
|
|
156
|
+
"""Get all function definitions from the database."""
|
|
157
|
+
rows = await conn.fetch("""
|
|
158
|
+
SELECT
|
|
159
|
+
proname as name,
|
|
160
|
+
pg_get_functiondef(oid) as definition
|
|
161
|
+
FROM pg_proc
|
|
162
|
+
WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
|
163
|
+
AND prokind = 'f'
|
|
164
|
+
""")
|
|
165
|
+
|
|
166
|
+
return {row["name"]: row["definition"] for row in rows}
|
|
167
|
+
|
|
168
|
+
def _normalize_function_def(self, func_def: str) -> str:
|
|
169
|
+
"""Normalize function definition for comparison."""
|
|
170
|
+
# Remove comments
|
|
171
|
+
func_def = re.sub(r"--.*$", "", func_def, flags=re.MULTILINE)
|
|
172
|
+
|
|
173
|
+
# Normalize whitespace
|
|
174
|
+
func_def = re.sub(r"\s+", " ", func_def).strip()
|
|
175
|
+
|
|
176
|
+
# Normalize case for keywords
|
|
177
|
+
keywords = [
|
|
178
|
+
"CREATE", "OR", "REPLACE", "FUNCTION", "RETURNS", "LANGUAGE",
|
|
179
|
+
"AS", "BEGIN", "END", "IF", "THEN", "ELSE", "ELSIF", "RETURN",
|
|
180
|
+
"DECLARE", "SELECT", "INSERT", "UPDATE", "DELETE", "FROM",
|
|
181
|
+
"WHERE", "INTO", "VALUES", "ON", "CONFLICT", "DO", "SET",
|
|
182
|
+
"NULL", "NOT", "AND", "TRUE", "FALSE", "COALESCE", "EXISTS",
|
|
183
|
+
"TRIGGER", "FOR", "EACH", "ROW", "EXECUTE", "PROCEDURE",
|
|
184
|
+
"IMMUTABLE", "STABLE", "VOLATILE", "PLPGSQL", "SQL",
|
|
185
|
+
]
|
|
186
|
+
for kw in keywords:
|
|
187
|
+
func_def = re.sub(rf"\b{kw}\b", kw, func_def, flags=re.IGNORECASE)
|
|
188
|
+
|
|
189
|
+
return func_def
|
|
190
|
+
|
|
191
|
+
def _functions_match(self, source_def: str, db_def: str) -> bool:
|
|
192
|
+
"""Compare two function definitions for equivalence."""
|
|
193
|
+
norm_source = self._normalize_function_def(source_def)
|
|
194
|
+
norm_db = self._normalize_function_def(db_def)
|
|
195
|
+
|
|
196
|
+
def extract_body(text: str) -> str:
|
|
197
|
+
match = re.search(r"\$\w*\$(.*)\$\w*\$", text, re.DOTALL)
|
|
198
|
+
if match:
|
|
199
|
+
return self._normalize_function_def(match.group(1))
|
|
200
|
+
return text
|
|
201
|
+
|
|
202
|
+
return extract_body(norm_source) == extract_body(norm_db)
|
|
203
|
+
|
|
204
|
+
# =========================================================================
|
|
205
|
+
# TRIGGER EXTRACTION AND COMPARISON
|
|
206
|
+
# =========================================================================
|
|
207
|
+
|
|
208
|
+
def _extract_triggers_from_sql(self, sql_content: str) -> dict[str, tuple[str, str]]:
|
|
209
|
+
"""
|
|
210
|
+
Extract trigger definitions from SQL content.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Dict mapping trigger name -> (full CREATE TRIGGER statement, table_name)
|
|
214
|
+
"""
|
|
215
|
+
triggers = {}
|
|
216
|
+
|
|
217
|
+
# Pattern for CREATE TRIGGER (with optional OR REPLACE for pg14+)
|
|
218
|
+
# CREATE [OR REPLACE] TRIGGER name {BEFORE|AFTER|INSTEAD OF} event ON table ...
|
|
219
|
+
pattern = r"""
|
|
220
|
+
(CREATE\s+(?:OR\s+REPLACE\s+)?TRIGGER\s+
|
|
221
|
+
(\w+)\s+ # trigger name
|
|
222
|
+
(?:BEFORE|AFTER|INSTEAD\s+OF)\s+ # timing
|
|
223
|
+
(?:INSERT|UPDATE|DELETE|TRUNCATE) # first event
|
|
224
|
+
(?:\s+OR\s+(?:INSERT|UPDATE|DELETE|TRUNCATE))* # additional events
|
|
225
|
+
\s+ON\s+
|
|
226
|
+
(?:public\.)?(\w+) # table name
|
|
227
|
+
.*? # rest of definition
|
|
228
|
+
EXECUTE\s+(?:FUNCTION|PROCEDURE)\s+
|
|
229
|
+
(?:public\.)?(\w+)\s*\([^)]*\) # function name
|
|
230
|
+
)
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
for match in re.finditer(pattern, sql_content, re.DOTALL | re.IGNORECASE | re.VERBOSE):
|
|
234
|
+
full_def = match.group(1).strip()
|
|
235
|
+
trigger_name = match.group(2).lower()
|
|
236
|
+
table_name = match.group(3).lower()
|
|
237
|
+
triggers[trigger_name] = (full_def, table_name)
|
|
238
|
+
|
|
239
|
+
return triggers
|
|
240
|
+
|
|
241
|
+
async def get_db_triggers(self, conn: asyncpg.Connection) -> dict[str, tuple[str, str]]:
|
|
242
|
+
"""
|
|
243
|
+
Get all trigger definitions from the database.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Dict mapping trigger name -> (definition, table_name)
|
|
247
|
+
"""
|
|
248
|
+
rows = await conn.fetch("""
|
|
249
|
+
SELECT
|
|
250
|
+
t.tgname as name,
|
|
251
|
+
pg_get_triggerdef(t.oid) as definition,
|
|
252
|
+
c.relname as table_name
|
|
253
|
+
FROM pg_trigger t
|
|
254
|
+
JOIN pg_class c ON t.tgrelid = c.oid
|
|
255
|
+
WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
|
256
|
+
AND NOT t.tgisinternal
|
|
257
|
+
""")
|
|
258
|
+
|
|
259
|
+
return {row["name"]: (row["definition"], row["table_name"]) for row in rows}
|
|
260
|
+
|
|
261
|
+
def _normalize_trigger_def(self, trigger_def: str) -> str:
|
|
262
|
+
"""Normalize trigger definition for comparison."""
|
|
263
|
+
# Remove comments
|
|
264
|
+
trigger_def = re.sub(r"--.*$", "", trigger_def, flags=re.MULTILINE)
|
|
265
|
+
|
|
266
|
+
# Normalize whitespace
|
|
267
|
+
trigger_def = re.sub(r"\s+", " ", trigger_def).strip()
|
|
268
|
+
|
|
269
|
+
# Normalize case for keywords
|
|
270
|
+
keywords = [
|
|
271
|
+
"CREATE", "OR", "REPLACE", "TRIGGER", "BEFORE", "AFTER",
|
|
272
|
+
"INSTEAD", "OF", "INSERT", "UPDATE", "DELETE", "TRUNCATE",
|
|
273
|
+
"ON", "FOR", "EACH", "ROW", "STATEMENT", "EXECUTE",
|
|
274
|
+
"FUNCTION", "PROCEDURE", "WHEN", "NEW", "OLD", "AND", "OR",
|
|
275
|
+
]
|
|
276
|
+
for kw in keywords:
|
|
277
|
+
trigger_def = re.sub(rf"\b{kw}\b", kw, trigger_def, flags=re.IGNORECASE)
|
|
278
|
+
|
|
279
|
+
return trigger_def
|
|
280
|
+
|
|
281
|
+
def _triggers_match(self, source_def: str, db_def: str) -> bool:
|
|
282
|
+
"""Compare two trigger definitions for equivalence."""
|
|
283
|
+
norm_source = self._normalize_trigger_def(source_def)
|
|
284
|
+
norm_db = self._normalize_trigger_def(db_def)
|
|
285
|
+
|
|
286
|
+
# pg_get_triggerdef() output differs from source slightly
|
|
287
|
+
# Extract key components for comparison
|
|
288
|
+
def extract_key_parts(text: str) -> tuple:
|
|
289
|
+
# Extract: timing, events, table, for each, function
|
|
290
|
+
timing = re.search(r"\b(BEFORE|AFTER|INSTEAD OF)\b", text, re.IGNORECASE)
|
|
291
|
+
events = re.findall(r"\b(INSERT|UPDATE|DELETE|TRUNCATE)\b", text, re.IGNORECASE)
|
|
292
|
+
table = re.search(r"\bON\s+(?:public\.)?(\w+)", text, re.IGNORECASE)
|
|
293
|
+
for_each = re.search(r"\bFOR\s+EACH\s+(ROW|STATEMENT)\b", text, re.IGNORECASE)
|
|
294
|
+
func = re.search(r"\bEXECUTE\s+(?:FUNCTION|PROCEDURE)\s+(?:public\.)?(\w+)", text, re.IGNORECASE)
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
timing.group(1).upper() if timing else "",
|
|
298
|
+
sorted(set(e.upper() for e in events)),
|
|
299
|
+
table.group(1).lower() if table else "",
|
|
300
|
+
for_each.group(1).upper() if for_each else "STATEMENT",
|
|
301
|
+
func.group(1).lower() if func else "",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return extract_key_parts(norm_source) == extract_key_parts(norm_db)
|
|
305
|
+
|
|
306
|
+
# =========================================================================
|
|
307
|
+
# VIEW EXTRACTION AND COMPARISON
|
|
308
|
+
# =========================================================================
|
|
309
|
+
|
|
310
|
+
def _extract_views_from_sql(self, sql_content: str) -> dict[str, str]:
|
|
311
|
+
"""
|
|
312
|
+
Extract view definitions from SQL content.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Dict mapping view name -> full CREATE VIEW statement
|
|
316
|
+
"""
|
|
317
|
+
views = {}
|
|
318
|
+
|
|
319
|
+
# Pattern for CREATE [OR REPLACE] VIEW name AS SELECT ...
|
|
320
|
+
# Views end at semicolon
|
|
321
|
+
pattern = r"""
|
|
322
|
+
(CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+
|
|
323
|
+
(?:public\.)?(\w+)\s+ # view name
|
|
324
|
+
AS\s+
|
|
325
|
+
SELECT\s+.*?) # SELECT query
|
|
326
|
+
; # terminated by semicolon
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
for match in re.finditer(pattern, sql_content, re.DOTALL | re.IGNORECASE | re.VERBOSE):
|
|
330
|
+
full_def = match.group(1).strip()
|
|
331
|
+
view_name = match.group(2).lower()
|
|
332
|
+
views[view_name] = full_def
|
|
333
|
+
|
|
334
|
+
return views
|
|
335
|
+
|
|
336
|
+
async def get_db_views(self, conn: asyncpg.Connection) -> dict[str, str]:
|
|
337
|
+
"""Get all view definitions from the database."""
|
|
338
|
+
rows = await conn.fetch("""
|
|
339
|
+
SELECT
|
|
340
|
+
viewname as name,
|
|
341
|
+
'CREATE OR REPLACE VIEW ' || viewname || ' AS ' || definition as definition
|
|
342
|
+
FROM pg_views
|
|
343
|
+
WHERE schemaname = 'public'
|
|
344
|
+
""")
|
|
345
|
+
|
|
346
|
+
return {row["name"]: row["definition"] for row in rows}
|
|
347
|
+
|
|
348
|
+
def _normalize_view_def(self, view_def: str) -> str:
|
|
349
|
+
"""Normalize view definition for comparison."""
|
|
350
|
+
# Remove comments
|
|
351
|
+
view_def = re.sub(r"--.*$", "", view_def, flags=re.MULTILINE)
|
|
352
|
+
|
|
353
|
+
# Normalize whitespace
|
|
354
|
+
view_def = re.sub(r"\s+", " ", view_def).strip()
|
|
355
|
+
|
|
356
|
+
# Normalize case for keywords
|
|
357
|
+
keywords = [
|
|
358
|
+
"CREATE", "OR", "REPLACE", "VIEW", "AS", "SELECT", "FROM",
|
|
359
|
+
"WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL",
|
|
360
|
+
"ON", "AND", "OR", "NOT", "IN", "EXISTS", "BETWEEN", "LIKE",
|
|
361
|
+
"IS", "NULL", "TRUE", "FALSE", "CASE", "WHEN", "THEN", "ELSE",
|
|
362
|
+
"END", "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET",
|
|
363
|
+
"UNION", "INTERSECT", "EXCEPT", "ALL", "DISTINCT", "AS",
|
|
364
|
+
"COALESCE", "NULLIF", "CAST", "EXTRACT", "COUNT", "SUM",
|
|
365
|
+
"AVG", "MIN", "MAX", "WITH",
|
|
366
|
+
]
|
|
367
|
+
for kw in keywords:
|
|
368
|
+
view_def = re.sub(rf"\b{kw}\b", kw, view_def, flags=re.IGNORECASE)
|
|
369
|
+
|
|
370
|
+
return view_def
|
|
371
|
+
|
|
372
|
+
def _views_match(self, source_def: str, db_def: str) -> bool:
|
|
373
|
+
"""Compare two view definitions for equivalence."""
|
|
374
|
+
norm_source = self._normalize_view_def(source_def)
|
|
375
|
+
norm_db = self._normalize_view_def(db_def)
|
|
376
|
+
|
|
377
|
+
# Extract just the SELECT query for comparison
|
|
378
|
+
def extract_select(text: str) -> str:
|
|
379
|
+
match = re.search(r"\bAS\s+(SELECT\s+.*)", text, re.IGNORECASE | re.DOTALL)
|
|
380
|
+
if match:
|
|
381
|
+
return self._normalize_view_def(match.group(1))
|
|
382
|
+
return text
|
|
383
|
+
|
|
384
|
+
return extract_select(norm_source) == extract_select(norm_db)
|
|
385
|
+
|
|
386
|
+
# =========================================================================
|
|
387
|
+
# UNIFIED SOURCE EXTRACTION
|
|
388
|
+
# =========================================================================
|
|
389
|
+
|
|
390
|
+
def get_source_objects(self) -> tuple[dict[str, str], dict[str, tuple[str, str]], dict[str, str]]:
|
|
391
|
+
"""
|
|
392
|
+
Extract all object definitions from SQL migration files.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Tuple of (functions, triggers, views) dicts
|
|
396
|
+
"""
|
|
397
|
+
all_functions = {}
|
|
398
|
+
all_triggers = {}
|
|
399
|
+
all_views = {}
|
|
400
|
+
|
|
401
|
+
if not self.sql_dir.exists():
|
|
402
|
+
logger.warning(f"SQL directory not found: {self.sql_dir}")
|
|
403
|
+
return all_functions, all_triggers, all_views
|
|
404
|
+
|
|
405
|
+
# Process migration files in order (later definitions override earlier)
|
|
406
|
+
for sql_file in sorted(self.sql_dir.glob("*.sql")):
|
|
407
|
+
content = sql_file.read_text()
|
|
408
|
+
|
|
409
|
+
functions = self._extract_functions_from_sql(content)
|
|
410
|
+
triggers = self._extract_triggers_from_sql(content)
|
|
411
|
+
views = self._extract_views_from_sql(content)
|
|
412
|
+
|
|
413
|
+
all_functions.update(functions)
|
|
414
|
+
all_triggers.update(triggers)
|
|
415
|
+
all_views.update(views)
|
|
416
|
+
|
|
417
|
+
if functions or triggers or views:
|
|
418
|
+
logger.debug(
|
|
419
|
+
f"{sql_file.name}: {len(functions)} functions, "
|
|
420
|
+
f"{len(triggers)} triggers, {len(views)} views"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return all_functions, all_triggers, all_views
|
|
424
|
+
|
|
425
|
+
# =========================================================================
|
|
426
|
+
# DIFF COMPUTATION
|
|
427
|
+
# =========================================================================
|
|
428
|
+
|
|
429
|
+
async def compute_diff(
|
|
430
|
+
self,
|
|
431
|
+
connection_string: Optional[str] = None,
|
|
432
|
+
include_extra: bool = False,
|
|
433
|
+
object_types: Optional[list[ObjectType]] = None,
|
|
434
|
+
) -> DiffResult:
|
|
435
|
+
"""
|
|
436
|
+
Compare objects between SQL files and database.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
connection_string: PostgreSQL connection string (uses settings if not provided)
|
|
440
|
+
include_extra: If True, report objects in DB but not in source
|
|
441
|
+
object_types: Which object types to check (default: all)
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
DiffResult with detected differences
|
|
445
|
+
"""
|
|
446
|
+
if object_types is None:
|
|
447
|
+
object_types = list(ObjectType)
|
|
448
|
+
|
|
449
|
+
conn_str = connection_string or settings.postgres.connection_string
|
|
450
|
+
|
|
451
|
+
# Get source objects
|
|
452
|
+
source_functions, source_triggers, source_views = self.get_source_objects()
|
|
453
|
+
logger.info(
|
|
454
|
+
f"Source: {len(source_functions)} functions, "
|
|
455
|
+
f"{len(source_triggers)} triggers, {len(source_views)} views"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Get database objects
|
|
459
|
+
conn = await asyncpg.connect(conn_str)
|
|
460
|
+
try:
|
|
461
|
+
db_functions = await self.get_db_functions(conn) if ObjectType.FUNCTION in object_types else {}
|
|
462
|
+
db_triggers = await self.get_db_triggers(conn) if ObjectType.TRIGGER in object_types else {}
|
|
463
|
+
db_views = await self.get_db_views(conn) if ObjectType.VIEW in object_types else {}
|
|
464
|
+
logger.info(
|
|
465
|
+
f"Database: {len(db_functions)} functions, "
|
|
466
|
+
f"{len(db_triggers)} triggers, {len(db_views)} views"
|
|
467
|
+
)
|
|
468
|
+
finally:
|
|
469
|
+
await conn.close()
|
|
470
|
+
|
|
471
|
+
diffs = []
|
|
472
|
+
sync_sql_parts = []
|
|
473
|
+
|
|
474
|
+
# Compare functions
|
|
475
|
+
if ObjectType.FUNCTION in object_types:
|
|
476
|
+
for name, source_def in source_functions.items():
|
|
477
|
+
if name not in db_functions:
|
|
478
|
+
diffs.append(ObjectDiff(
|
|
479
|
+
name=name,
|
|
480
|
+
object_type=ObjectType.FUNCTION,
|
|
481
|
+
status="missing",
|
|
482
|
+
source_def=source_def,
|
|
483
|
+
))
|
|
484
|
+
sync_sql_parts.append(f"-- Missing function: {name}")
|
|
485
|
+
sync_sql_parts.append(source_def + ";")
|
|
486
|
+
sync_sql_parts.append("")
|
|
487
|
+
elif not self._functions_match(source_def, db_functions[name]):
|
|
488
|
+
diffs.append(ObjectDiff(
|
|
489
|
+
name=name,
|
|
490
|
+
object_type=ObjectType.FUNCTION,
|
|
491
|
+
status="different",
|
|
492
|
+
source_def=source_def,
|
|
493
|
+
db_def=db_functions[name],
|
|
494
|
+
))
|
|
495
|
+
sync_sql_parts.append(f"-- Different function: {name}")
|
|
496
|
+
sync_sql_parts.append(source_def + ";")
|
|
497
|
+
sync_sql_parts.append("")
|
|
498
|
+
|
|
499
|
+
if include_extra:
|
|
500
|
+
for name in db_functions:
|
|
501
|
+
if name not in source_functions:
|
|
502
|
+
diffs.append(ObjectDiff(
|
|
503
|
+
name=name,
|
|
504
|
+
object_type=ObjectType.FUNCTION,
|
|
505
|
+
status="extra",
|
|
506
|
+
db_def=db_functions[name],
|
|
507
|
+
))
|
|
508
|
+
|
|
509
|
+
# Compare triggers
|
|
510
|
+
if ObjectType.TRIGGER in object_types:
|
|
511
|
+
for name, (source_def, table_name) in source_triggers.items():
|
|
512
|
+
if name not in db_triggers:
|
|
513
|
+
diffs.append(ObjectDiff(
|
|
514
|
+
name=name,
|
|
515
|
+
object_type=ObjectType.TRIGGER,
|
|
516
|
+
status="missing",
|
|
517
|
+
source_def=source_def,
|
|
518
|
+
table_name=table_name,
|
|
519
|
+
))
|
|
520
|
+
# Drop trigger first if exists (for CREATE without OR REPLACE)
|
|
521
|
+
sync_sql_parts.append(f"-- Missing trigger: {name}")
|
|
522
|
+
sync_sql_parts.append(f"DROP TRIGGER IF EXISTS {name} ON {table_name};")
|
|
523
|
+
sync_sql_parts.append(source_def + ";")
|
|
524
|
+
sync_sql_parts.append("")
|
|
525
|
+
elif not self._triggers_match(source_def, db_triggers[name][0]):
|
|
526
|
+
diffs.append(ObjectDiff(
|
|
527
|
+
name=name,
|
|
528
|
+
object_type=ObjectType.TRIGGER,
|
|
529
|
+
status="different",
|
|
530
|
+
source_def=source_def,
|
|
531
|
+
db_def=db_triggers[name][0],
|
|
532
|
+
table_name=table_name,
|
|
533
|
+
))
|
|
534
|
+
sync_sql_parts.append(f"-- Different trigger: {name}")
|
|
535
|
+
sync_sql_parts.append(f"DROP TRIGGER IF EXISTS {name} ON {table_name};")
|
|
536
|
+
sync_sql_parts.append(source_def + ";")
|
|
537
|
+
sync_sql_parts.append("")
|
|
538
|
+
|
|
539
|
+
if include_extra:
|
|
540
|
+
for name in db_triggers:
|
|
541
|
+
if name not in source_triggers:
|
|
542
|
+
diffs.append(ObjectDiff(
|
|
543
|
+
name=name,
|
|
544
|
+
object_type=ObjectType.TRIGGER,
|
|
545
|
+
status="extra",
|
|
546
|
+
db_def=db_triggers[name][0],
|
|
547
|
+
table_name=db_triggers[name][1],
|
|
548
|
+
))
|
|
549
|
+
|
|
550
|
+
# Compare views
|
|
551
|
+
if ObjectType.VIEW in object_types:
|
|
552
|
+
for name, source_def in source_views.items():
|
|
553
|
+
if name not in db_views:
|
|
554
|
+
diffs.append(ObjectDiff(
|
|
555
|
+
name=name,
|
|
556
|
+
object_type=ObjectType.VIEW,
|
|
557
|
+
status="missing",
|
|
558
|
+
source_def=source_def,
|
|
559
|
+
))
|
|
560
|
+
sync_sql_parts.append(f"-- Missing view: {name}")
|
|
561
|
+
sync_sql_parts.append(source_def + ";")
|
|
562
|
+
sync_sql_parts.append("")
|
|
563
|
+
elif not self._views_match(source_def, db_views[name]):
|
|
564
|
+
diffs.append(ObjectDiff(
|
|
565
|
+
name=name,
|
|
566
|
+
object_type=ObjectType.VIEW,
|
|
567
|
+
status="different",
|
|
568
|
+
source_def=source_def,
|
|
569
|
+
db_def=db_views[name],
|
|
570
|
+
))
|
|
571
|
+
sync_sql_parts.append(f"-- Different view: {name}")
|
|
572
|
+
sync_sql_parts.append(source_def + ";")
|
|
573
|
+
sync_sql_parts.append("")
|
|
574
|
+
|
|
575
|
+
if include_extra:
|
|
576
|
+
for name in db_views:
|
|
577
|
+
if name not in source_views:
|
|
578
|
+
diffs.append(ObjectDiff(
|
|
579
|
+
name=name,
|
|
580
|
+
object_type=ObjectType.VIEW,
|
|
581
|
+
status="extra",
|
|
582
|
+
db_def=db_views[name],
|
|
583
|
+
))
|
|
584
|
+
|
|
585
|
+
has_changes = len([d for d in diffs if d.status in ("missing", "different")]) > 0
|
|
586
|
+
|
|
587
|
+
return DiffResult(
|
|
588
|
+
has_changes=has_changes,
|
|
589
|
+
diffs=diffs,
|
|
590
|
+
sync_sql="\n".join(sync_sql_parts) if sync_sql_parts else "",
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
async def sync(
|
|
594
|
+
self,
|
|
595
|
+
connection_string: Optional[str] = None,
|
|
596
|
+
dry_run: bool = True,
|
|
597
|
+
object_types: Optional[list[ObjectType]] = None,
|
|
598
|
+
) -> DiffResult:
|
|
599
|
+
"""
|
|
600
|
+
Sync objects from SQL files to database.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
connection_string: PostgreSQL connection string
|
|
604
|
+
dry_run: If True, only report what would change (don't apply)
|
|
605
|
+
object_types: Which object types to sync (default: all)
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
DiffResult with applied changes
|
|
609
|
+
"""
|
|
610
|
+
result = await self.compute_diff(connection_string, object_types=object_types)
|
|
611
|
+
|
|
612
|
+
if not result.has_changes:
|
|
613
|
+
logger.info("All programmable objects are in sync")
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
if dry_run:
|
|
617
|
+
logger.info(f"Dry run - changes needed:\n{result.summary()}")
|
|
618
|
+
return result
|
|
619
|
+
|
|
620
|
+
# Apply sync SQL
|
|
621
|
+
conn_str = connection_string or settings.postgres.connection_string
|
|
622
|
+
conn = await asyncpg.connect(conn_str)
|
|
623
|
+
try:
|
|
624
|
+
await conn.execute(result.sync_sql)
|
|
625
|
+
logger.info(f"Applied changes:\n{result.summary()}")
|
|
626
|
+
finally:
|
|
627
|
+
await conn.close()
|
|
628
|
+
|
|
629
|
+
return result
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# Backwards compatibility alias
|
|
633
|
+
FunctionDiffService = ProgrammableDiffService
|
|
634
|
+
FunctionDiff = ObjectDiff
|
|
635
|
+
FunctionDiffResult = DiffResult
|
|
@@ -386,8 +386,8 @@ def _build_table(model: type[BaseModel], table_name: str, metadata: MetaData) ->
|
|
|
386
386
|
)
|
|
387
387
|
)
|
|
388
388
|
|
|
389
|
-
# Tenant and user scoping
|
|
390
|
-
columns.append(Column("tenant_id", String(100), nullable=
|
|
389
|
+
# Tenant and user scoping (tenant_id nullable - NULL means public/shared)
|
|
390
|
+
columns.append(Column("tenant_id", String(100), nullable=True))
|
|
391
391
|
columns.append(Column("user_id", String(256), nullable=True))
|
|
392
392
|
|
|
393
393
|
# Process Pydantic fields (skip system fields)
|