vecshift 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.
Files changed (62) hide show
  1. vecshift/__init__.py +15 -0
  2. vecshift/assets/bench.css +39 -0
  3. vecshift/assets/eval.css +91 -0
  4. vecshift/assets/report.css +252 -0
  5. vecshift/assets/report.js +50 -0
  6. vecshift/bench/__init__.py +18 -0
  7. vecshift/bench/corpus.py +175 -0
  8. vecshift/bench/generate.py +106 -0
  9. vecshift/bench/html.py +325 -0
  10. vecshift/bench/metrics.py +50 -0
  11. vecshift/bench/runner.py +183 -0
  12. vecshift/cli.py +236 -0
  13. vecshift/cli_apply.py +283 -0
  14. vecshift/cli_bench.py +354 -0
  15. vecshift/cli_cutover.py +431 -0
  16. vecshift/cli_eval.py +591 -0
  17. vecshift/cli_plan.py +335 -0
  18. vecshift/cli_style.py +57 -0
  19. vecshift/connectors/__init__.py +1 -0
  20. vecshift/connectors/pgvector/__init__.py +29 -0
  21. vecshift/connectors/pgvector/connection.py +155 -0
  22. vecshift/connectors/pgvector/documents.py +96 -0
  23. vecshift/connectors/pgvector/inspect.py +427 -0
  24. vecshift/connectors/pgvector/search.py +240 -0
  25. vecshift/connectors/pgvector/switch.py +481 -0
  26. vecshift/connectors/pgvector/target.py +195 -0
  27. vecshift/connectors/pgvector/writer.py +431 -0
  28. vecshift/core/__init__.py +4 -0
  29. vecshift/core/capabilities.py +33 -0
  30. vecshift/core/contracts.py +57 -0
  31. vecshift/core/fingerprint.py +57 -0
  32. vecshift/core/record.py +75 -0
  33. vecshift/doctor/__init__.py +15 -0
  34. vecshift/doctor/checks.py +490 -0
  35. vecshift/doctor/findings.py +78 -0
  36. vecshift/doctor/html.py +493 -0
  37. vecshift/doctor/profile.py +69 -0
  38. vecshift/embeddings/__init__.py +22 -0
  39. vecshift/embeddings/cache.py +86 -0
  40. vecshift/embeddings/providers.py +244 -0
  41. vecshift/embeddings/spec.py +240 -0
  42. vecshift/eval/__init__.py +20 -0
  43. vecshift/eval/html.py +444 -0
  44. vecshift/eval/metrics.py +81 -0
  45. vecshift/eval/queries.py +97 -0
  46. vecshift/eval/runner.py +394 -0
  47. vecshift/html_kit.py +143 -0
  48. vecshift/jobs/__init__.py +5 -0
  49. vecshift/jobs/spec.py +202 -0
  50. vecshift/migrate/__init__.py +6 -0
  51. vecshift/migrate/engine.py +272 -0
  52. vecshift/migrate/state.py +50 -0
  53. vecshift/planning/__init__.py +14 -0
  54. vecshift/planning/plan.py +87 -0
  55. vecshift/planning/planner.py +493 -0
  56. vecshift/py.typed +0 -0
  57. vecshift-0.1.0.dist-info/METADATA +264 -0
  58. vecshift-0.1.0.dist-info/RECORD +62 -0
  59. vecshift-0.1.0.dist-info/WHEEL +4 -0
  60. vecshift-0.1.0.dist-info/entry_points.txt +2 -0
  61. vecshift-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. vecshift-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,481 @@
1
+ """Cutover and rollback: give the new vectors the column name the application searches.
2
+
3
+ Cutover renames the live column to ``<name>_old`` and the new column to ``<name>`` in one
4
+ short transaction, so every query switches at once and the application's SQL doesn't
5
+ change. Rollback renames them back. The sync trigger always guards whichever column isn't
6
+ live: before cutover it clears new vectors when text changes, and after cutover it clears
7
+ the old ones, so rollback can say exactly which rows lack an old-model vector.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Literal
15
+
16
+ from psycopg import errors, sql
17
+
18
+ from vecshift.connectors.pgvector.target import table_limits
19
+ from vecshift.connectors.pgvector.writer import SCAN_TIMEOUT_MS, Busy, PgWriter, Row, _limits
20
+ from vecshift.doctor.findings import Finding, Severity
21
+
22
+ SWITCH_SCAN_MS = 15_000
23
+ MAX_FILL = 500
24
+ """Most rows cutover embeds while writes wait; more than this means catch up first."""
25
+ """How long the final check may hold off writes before cutover gives up and retries."""
26
+ METHODS = ("hnsw", "ivfflat")
27
+
28
+ Stage = Literal["not_applied", "applied", "cut_over", "conflict"]
29
+
30
+
31
+ class StillPending(Exception):
32
+ def __init__(self, rows: int) -> None:
33
+ super().__init__(f"{rows} rows still need a new vector")
34
+ self.rows = rows
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Readiness:
39
+ stage: Stage
40
+ findings: list[Finding]
41
+ pending: int
42
+ live_dims: int | None
43
+ new_dims: int | None
44
+
45
+ @property
46
+ def ok(self) -> bool:
47
+ return not any(f.severity is Severity.ERROR for f in self.findings)
48
+
49
+
50
+ class PgSwitch:
51
+ def __init__(self, writer: PgWriter) -> None:
52
+ if not writer.layout.live:
53
+ raise ValueError("cutover needs the live column's name")
54
+ self.writer = writer
55
+ self.conn = writer.conn
56
+ self.layout = writer.layout
57
+
58
+ # --- what's there
59
+
60
+ def _attribute(self, column: str) -> tuple[int, int | None] | None:
61
+ """(attnum, dimensions) of a column, or None when it doesn't exist."""
62
+ row = self.conn.execute(
63
+ """
64
+ SELECT attnum, atttypmod FROM pg_attribute
65
+ WHERE attrelid = %s::regclass AND attname = %s AND attnum > 0 AND NOT attisdropped
66
+ """,
67
+ (self.writer._regclass(), column),
68
+ ).fetchone()
69
+ if row is None:
70
+ return None
71
+ return int(row[0]), (int(row[1]) if row[1] > 0 else None)
72
+
73
+ def stage(self) -> Stage:
74
+ lay = self.layout
75
+ target = self._attribute(lay.target) is not None
76
+ previous = self._attribute(lay.previous) is not None
77
+ if target and previous:
78
+ return "conflict"
79
+ if target:
80
+ return "applied"
81
+ if previous:
82
+ return "cut_over"
83
+ return "not_applied"
84
+
85
+ def dependents(self, column: str) -> list[str]:
86
+ """Views and SQL-standard functions bound to a column, which a rename won't move."""
87
+ attribute = self._attribute(column)
88
+ if attribute is None:
89
+ return []
90
+ rows = self.conn.execute(
91
+ """
92
+ SELECT DISTINCT CASE
93
+ WHEN d.classid = 'pg_rewrite'::regclass THEN 'view ' || r.ev_class::regclass::text
94
+ ELSE 'function ' || d.objid::regprocedure::text END
95
+ FROM pg_depend d
96
+ LEFT JOIN pg_rewrite r ON d.classid = 'pg_rewrite'::regclass AND r.oid = d.objid
97
+ WHERE d.refclassid = 'pg_class'::regclass AND d.refobjid = %s::regclass
98
+ AND d.refobjsubid = %s
99
+ AND d.classid IN ('pg_rewrite'::regclass, 'pg_proc'::regclass)
100
+ AND (r.ev_class IS NULL OR r.ev_class <> d.refobjid)
101
+ ORDER BY 1
102
+ """,
103
+ (self.writer._regclass(), attribute[0]),
104
+ ).fetchall()
105
+ return [str(r[0]) for r in rows]
106
+
107
+ def _count(self, condition: sql.Composable) -> int:
108
+ query = sql.SQL("SELECT count(*) FROM {table} WHERE {condition}").format(
109
+ table=self.layout.qualified, condition=condition
110
+ )
111
+ with self.conn.transaction():
112
+ _limits(self.conn, SCAN_TIMEOUT_MS)
113
+ row = self.conn.execute(query).fetchone()
114
+ return int(row[0]) if row else 0
115
+
116
+ def _no_text(self) -> sql.Composable:
117
+ return sql.SQL("({text} IS NULL OR btrim({text}::text) = '')").format(
118
+ text=sql.Identifier(self.layout.text)
119
+ )
120
+
121
+ def _index_names(self, column: str) -> list[str]:
122
+ rows = self.conn.execute(
123
+ """
124
+ SELECT c.relname FROM pg_index i
125
+ JOIN pg_class c ON c.oid = i.indexrelid
126
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
127
+ WHERE i.indrelid = %s::regclass AND a.attname = %s
128
+ """,
129
+ (self.writer._regclass(), column),
130
+ ).fetchall()
131
+ return [str(r[0]) for r in rows]
132
+
133
+ def _name_taken(self, name: str) -> bool:
134
+ row = self.conn.execute(
135
+ """
136
+ SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
137
+ WHERE n.nspname = %s AND c.relname = %s
138
+ """,
139
+ (self.layout.schema, name),
140
+ ).fetchone()
141
+ return row is not None
142
+
143
+ def _index_renames(self, moves: list[tuple[str, str, str]]) -> list[sql.Composed]:
144
+ """Index renames that follow column renames, so names keep matching columns.
145
+
146
+ Each move is (column as named now, old name, new name). Only indexes named the
147
+ way vecshift names them are renamed, and only into names that are free.
148
+ """
149
+ lay = self.layout
150
+ statements = []
151
+ freed: set[str] = set()
152
+ claimed: set[str] = set()
153
+ for column, old, new in moves:
154
+ names = self._index_names(column)
155
+ for method in METHODS:
156
+ current, wanted = lay.index_name(method, old), lay.index_name(method, new)
157
+ free = wanted in freed or not self._name_taken(wanted)
158
+ if current in names and wanted not in claimed and free:
159
+ statements.append(
160
+ sql.SQL("ALTER INDEX {} RENAME TO {}").format(
161
+ sql.Identifier(lay.schema, current), sql.Identifier(wanted)
162
+ )
163
+ )
164
+ freed.add(current)
165
+ claimed.add(wanted)
166
+ return statements
167
+
168
+ def _rename(self, old: str, new: str) -> sql.Composed:
169
+ return sql.SQL("ALTER TABLE {table} RENAME COLUMN {old} TO {new}").format(
170
+ table=self.layout.qualified, old=sql.Identifier(old), new=sql.Identifier(new)
171
+ )
172
+
173
+ # --- checks
174
+
175
+ def check(self, index_method: str | None) -> Readiness:
176
+ lay = self.layout
177
+ findings: list[Finding] = []
178
+ stage = self.stage()
179
+ live = self._attribute(lay.live)
180
+ new = self._attribute(lay.target)
181
+ live_dims = live[1] if live else None
182
+ new_dims = new[1] if new else None
183
+ pending = 0
184
+
185
+ if stage == "not_applied":
186
+ findings.append(
187
+ Finding(
188
+ "cutover.not_applied",
189
+ Severity.ERROR,
190
+ "Nothing to cut over yet",
191
+ f"Column {lay.target} doesn't exist.",
192
+ hint="Run vecshift apply first.",
193
+ )
194
+ )
195
+ elif stage == "cut_over":
196
+ findings.append(
197
+ Finding(
198
+ "cutover.done",
199
+ Severity.ERROR,
200
+ "Already cut over",
201
+ f"{lay.live} holds the new vectors and {lay.previous} the old ones.",
202
+ hint="Run vecshift rollback to undo it.",
203
+ )
204
+ )
205
+ elif stage == "conflict":
206
+ findings.append(
207
+ Finding(
208
+ "cutover.previous_exists",
209
+ Severity.ERROR,
210
+ f"Column {lay.previous} already exists",
211
+ "Cutover keeps the old vectors under that name, so it has to be free.",
212
+ hint="Drop it if it's left over from an earlier migration, or rename it.",
213
+ )
214
+ )
215
+ if live is None:
216
+ findings.append(
217
+ Finding(
218
+ "cutover.no_live_column",
219
+ Severity.ERROR,
220
+ f"Column {lay.live} doesn't exist",
221
+ "That's the column your application searches.",
222
+ hint="Set source.vector_column in the job file.",
223
+ )
224
+ )
225
+ if stage != "applied" or live is None:
226
+ return Readiness(stage, findings, pending, live_dims, new_dims)
227
+
228
+ relid = self.conn.execute("SELECT %s::regclass::oid", (self.writer._regclass(),)).fetchone()
229
+ partitioned, row_security = (
230
+ table_limits(self.conn, int(relid[0])) if relid else (False, False)
231
+ )
232
+ if partitioned:
233
+ findings.append(
234
+ Finding(
235
+ "cutover.partitioned",
236
+ Severity.ERROR,
237
+ "Partitioned tables aren't supported yet",
238
+ "Cutover's final check needs a concurrent index, which PostgreSQL can't "
239
+ "build on a partitioned table.",
240
+ )
241
+ )
242
+ if row_security:
243
+ findings.append(
244
+ Finding(
245
+ "cutover.row_security",
246
+ Severity.ERROR,
247
+ "Row-level security hides rows from this role",
248
+ "Cutover couldn't confirm that every row has a new vector.",
249
+ hint="Connect as a role with BYPASSRLS, or turn off FORCE ROW LEVEL "
250
+ "SECURITY for the migration.",
251
+ )
252
+ )
253
+
254
+ pending = self.writer.pending_count()
255
+ if pending:
256
+ findings.append(
257
+ Finding(
258
+ "cutover.pending",
259
+ Severity.ERROR,
260
+ f"{pending:,} rows have no new vector yet",
261
+ "After cutover they wouldn't be found by searches.",
262
+ hint="Cutover embeds them first; or run vecshift apply.",
263
+ )
264
+ )
265
+ if index_method and self.writer.index_state(index_method) != "valid":
266
+ findings.append(
267
+ Finding(
268
+ "cutover.no_index",
269
+ Severity.ERROR,
270
+ f"The {index_method} index on {lay.target} isn't built",
271
+ "Without it, searches would scan the whole table after cutover.",
272
+ hint="Run vecshift apply to build it.",
273
+ )
274
+ )
275
+ sizes = self.writer.dimensions_in_use()
276
+ if sizes and sizes != {lay.dims}:
277
+ findings.append(
278
+ Finding(
279
+ "cutover.mixed_sizes",
280
+ Severity.ERROR,
281
+ "The new column holds vectors of different sizes",
282
+ f"Found sizes {sorted(sizes)}; expected {lay.dims}.",
283
+ )
284
+ )
285
+ bound = self.dependents(lay.live) + self.dependents(lay.target)
286
+ if bound:
287
+ findings.append(
288
+ Finding(
289
+ "cutover.dependents",
290
+ Severity.ERROR,
291
+ "Views or functions are bound to the vector columns",
292
+ "PostgreSQL ties these to a column, not its name, so after a rename they "
293
+ f"would still read the old vectors: {', '.join(bound)}.",
294
+ hint="Drop them before cutover and recreate them after it.",
295
+ )
296
+ )
297
+ orphans = self._count(
298
+ sql.SQL("{live} IS NOT NULL AND {no_text}").format(
299
+ live=sql.Identifier(lay.live), no_text=self._no_text()
300
+ )
301
+ )
302
+ if orphans:
303
+ findings.append(
304
+ Finding(
305
+ "cutover.no_text",
306
+ Severity.WARNING,
307
+ f"{orphans:,} rows have a vector but no text",
308
+ "They can't be re-embedded, so they'll have no vector after cutover.",
309
+ )
310
+ )
311
+ if live_dims and new_dims and live_dims != new_dims:
312
+ findings.append(
313
+ Finding(
314
+ "cutover.size_change",
315
+ Severity.WARNING,
316
+ f"Vectors change size, from {live_dims} to {new_dims}",
317
+ "Searches and inserts that still use the old model will fail after cutover "
318
+ "until your application switches to the new one.",
319
+ hint="Switch the model in your application's settings at cutover time.",
320
+ )
321
+ )
322
+ else:
323
+ findings.append(
324
+ Finding(
325
+ "cutover.model_switch",
326
+ Severity.INFO,
327
+ "Switch your application's model at cutover",
328
+ "Queries embedded with the old model would still run, but match poorly, "
329
+ "until your application embeds them with the new one.",
330
+ )
331
+ )
332
+ return Readiness(stage, findings, pending, live_dims, new_dims)
333
+
334
+ # --- the switch
335
+
336
+ @property
337
+ def _pending_index(self) -> str:
338
+ return f"{self.layout.table}_{self.layout.target}_pending_idx"[:63]
339
+
340
+ def prepare(self) -> None:
341
+ """Index the rows still missing a vector, so the final check under lock is instant."""
342
+ lay = self.layout
343
+ self.finish()
344
+ self.conn.execute("SET statement_timeout = 0")
345
+ try:
346
+ self.conn.execute(
347
+ sql.SQL(
348
+ "CREATE INDEX CONCURRENTLY {name} ON {table} ({pk}) WHERE {target} IS NULL"
349
+ ).format(
350
+ name=sql.Identifier(self._pending_index),
351
+ table=lay.qualified,
352
+ pk=sql.Identifier(lay.pk),
353
+ target=sql.Identifier(lay.target),
354
+ )
355
+ )
356
+ finally:
357
+ self.conn.execute("RESET statement_timeout")
358
+
359
+ def finish(self) -> None:
360
+ self.conn.execute(
361
+ sql.SQL("DROP INDEX CONCURRENTLY IF EXISTS {}").format(
362
+ sql.Identifier(self.layout.schema, self._pending_index)
363
+ )
364
+ )
365
+
366
+ def _transaction(
367
+ self, run: Callable[[], None], what: str, sleep: Callable[[float], None] | None = None
368
+ ) -> None:
369
+ writer = self.writer
370
+ pause = sleep or writer._sleep
371
+ delay = 1.0
372
+ for attempt in range(1, writer.lock_retries + 1):
373
+ try:
374
+ with self.conn.transaction():
375
+ _limits(self.conn, SWITCH_SCAN_MS, writer.lock_timeout_ms)
376
+ run()
377
+ return
378
+ except errors.LockNotAvailable as exc:
379
+ if attempt == writer.lock_retries:
380
+ raise Busy(
381
+ f"Couldn't {what}: the table stayed busy. Try again when it's quieter."
382
+ ) from exc
383
+ pause(delay)
384
+ delay = min(delay * 2, 30)
385
+
386
+ def cutover(
387
+ self,
388
+ allow_missing: int = 0,
389
+ fill: Callable[[list[Row]], list[tuple[Row, list[float]]]] | None = None,
390
+ exclude: Sequence[str] = (),
391
+ ) -> int:
392
+ """Switch in one transaction, after a final check that every row has a new vector.
393
+
394
+ Writes wait while the check runs (it uses the index from :meth:`prepare`); reads
395
+ continue until the renames, which take milliseconds. On a busy table a few rows
396
+ always arrive after the last catch-up: up to ``MAX_FILL`` of them are embedded with
397
+ ``fill`` while writes wait, so cutover doesn't chase them forever. ``exclude`` lists
398
+ rows the provider rejected, which aren't sent again. Raises :class:`StillPending`
399
+ if more rows are missing, leaving everything unchanged. Returns rows filled.
400
+ """
401
+ lay = self.layout
402
+ writer = self.writer
403
+ filled = 0
404
+
405
+ def missing_count() -> int:
406
+ row = self.conn.execute(
407
+ sql.SQL("SELECT count(*) FROM {table} WHERE {pending}").format(
408
+ table=lay.qualified, pending=writer._pending_filter()
409
+ )
410
+ ).fetchone()
411
+ return int(row[0]) if row else 0
412
+
413
+ def run() -> None:
414
+ nonlocal filled
415
+ filled = 0
416
+ self.conn.execute(
417
+ sql.SQL("LOCK TABLE {} IN SHARE ROW EXCLUSIVE MODE").format(lay.qualified)
418
+ )
419
+ missing = missing_count()
420
+ if missing > allow_missing and fill is not None:
421
+ rows = writer.fetch(None, MAX_FILL + 1, exclude)
422
+ if 0 < len(rows) <= MAX_FILL:
423
+ filled = writer.write(fill(rows))
424
+ missing = missing_count()
425
+ if missing > allow_missing:
426
+ raise StillPending(missing)
427
+ statements = [
428
+ *writer.drop_sync_sql(lay.target),
429
+ *self._index_renames(
430
+ [(lay.live, lay.live, lay.previous), (lay.target, lay.target, lay.live)]
431
+ ),
432
+ self._rename(lay.live, lay.previous),
433
+ self._rename(lay.target, lay.live),
434
+ writer.sync_function_sql(lay.previous),
435
+ writer.sync_trigger_sql(lay.previous),
436
+ ]
437
+ for statement in statements:
438
+ self.conn.execute(statement)
439
+
440
+ self._transaction(run, "cut over")
441
+ return filled
442
+
443
+ def rollback(self) -> int:
444
+ """Rename the columns back. Returns rows that have no old-model vector."""
445
+ lay = self.layout
446
+ writer = self.writer
447
+
448
+ def run() -> None:
449
+ statements = [
450
+ *writer.drop_sync_sql(lay.previous),
451
+ *self._index_renames(
452
+ [(lay.live, lay.live, lay.target), (lay.previous, lay.previous, lay.live)]
453
+ ),
454
+ self._rename(lay.live, lay.target),
455
+ self._rename(lay.previous, lay.live),
456
+ writer.sync_function_sql(lay.target),
457
+ writer.sync_trigger_sql(lay.target),
458
+ ]
459
+ for statement in statements:
460
+ self.conn.execute(statement)
461
+
462
+ self._transaction(run, "roll back")
463
+ return self._count(
464
+ sql.SQL("{live} IS NULL AND NOT {no_text}").format(
465
+ live=sql.Identifier(lay.live), no_text=self._no_text()
466
+ )
467
+ )
468
+
469
+ def cleanup(self) -> None:
470
+ """Drop the old vectors, their index, and the trigger guarding them. Irreversible."""
471
+ lay = self.layout
472
+ writer = self.writer
473
+ drop = sql.SQL("ALTER TABLE {table} DROP COLUMN {col}").format(
474
+ table=lay.qualified, col=sql.Identifier(lay.previous)
475
+ )
476
+
477
+ def run() -> None:
478
+ for statement in [*writer.drop_sync_sql(lay.previous), drop]:
479
+ self.conn.execute(statement)
480
+
481
+ self._transaction(run, "drop the old column")
@@ -0,0 +1,195 @@
1
+ """What the target of a side-by-side migration looks like, and the SQL to create it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ import psycopg
9
+
10
+ from vecshift.connectors.pgvector.inspect import (
11
+ VectorColumn,
12
+ _extension_schema,
13
+ find_vector_columns,
14
+ select_column,
15
+ )
16
+
17
+ OPCLASS_SUFFIX = {"cosine": "cosine_ops", "inner_product": "ip_ops", "l2": "l2_ops"}
18
+
19
+ _SIMPLE = re.compile(r"^[a-z_][a-z0-9_$]*$")
20
+
21
+ # PostgreSQL's reserved and type/function-name keywords, used when the server's own list
22
+ # isn't available. They must be quoted to be used as names.
23
+ _KEYWORDS = (
24
+ "all analyse analyze and any array as asc asymmetric authorization binary both case "
25
+ "cast check collate collation column concurrently constraint create cross "
26
+ "current_catalog current_date current_role current_schema current_time "
27
+ "current_timestamp current_user default deferrable desc distinct do else end except "
28
+ "false fetch for foreign freeze from full grant group having ilike in initially inner "
29
+ "intersect into is isnull join lateral leading left like limit localtime "
30
+ "localtimestamp natural not notnull null offset on only or order outer overlaps "
31
+ "placing primary references returning right select session_user similar some "
32
+ "symmetric system_user table tablesample then to trailing true union unique user "
33
+ "using variadic verbose when where window with"
34
+ )
35
+ FALLBACK_KEYWORDS = frozenset(_KEYWORDS.split())
36
+
37
+
38
+ def quote_ident(name: str, keywords: frozenset[str] = FALLBACK_KEYWORDS) -> str:
39
+ """Quote an identifier exactly when PostgreSQL needs it, like its quote_ident()."""
40
+ if _SIMPLE.match(name) and name not in keywords:
41
+ return name
42
+ return '"' + name.replace('"', '""') + '"'
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class TargetState:
47
+ source: VectorColumn
48
+ extension_schema: str
49
+ owner: bool
50
+ """Whether the connecting role owns the table, which adding a column requires."""
51
+ column_exists: bool
52
+ column_type: str | None
53
+ column_dimensions: int | None
54
+ maintenance_work_mem: int
55
+ """Bytes available to an index build before it slows down."""
56
+ keywords: frozenset[str] = FALLBACK_KEYWORDS
57
+ """Words this server needs quoted when used as names."""
58
+ primary_key: tuple[str, ...] = ()
59
+ """Primary key columns, in order. Apply needs exactly one."""
60
+ text_column: str | None = None
61
+ previous_column_exists: bool = False
62
+ """Whether ``<column>_old`` exists: a cutover happened and hasn't been cleaned up."""
63
+ partitioned: bool = False
64
+ """A partitioned table, which apply can't index concurrently yet."""
65
+ row_security: bool = False
66
+ """Row-level security applies to the connecting role, so it can't see or write every row."""
67
+
68
+ def q(self, name: str) -> str:
69
+ return quote_ident(name, self.keywords)
70
+
71
+
72
+ def table_limits(conn: psycopg.Connection, relid: int) -> tuple[bool, bool]:
73
+ """(partitioned, row security applies to the current role).
74
+
75
+ Superusers and BYPASSRLS roles always see every row; the table's owner (or a role
76
+ inheriting its privileges) does too, unless the table forces row-level security.
77
+ """
78
+ row = conn.execute(
79
+ """
80
+ SELECT c.relkind = 'p',
81
+ c.relrowsecurity AND NOT (r.rolbypassrls OR r.rolsuper)
82
+ AND (c.relforcerowsecurity OR NOT pg_has_role(current_user, c.relowner, 'USAGE'))
83
+ FROM pg_class c, pg_roles r
84
+ WHERE c.oid = %s AND r.rolname = current_user
85
+ """,
86
+ (relid,),
87
+ ).fetchone()
88
+ return (bool(row[0]), bool(row[1])) if row else (False, False)
89
+
90
+
91
+ def inspect_target(
92
+ conn: psycopg.Connection, table: str, vector_column: str | None, target_column: str
93
+ ) -> TargetState:
94
+ source = select_column(find_vector_columns(conn), table, vector_column)
95
+ ext = _extension_schema(conn)
96
+ owner_row = conn.execute(
97
+ "SELECT pg_has_role(current_user, relowner, 'MEMBER') FROM pg_class WHERE oid = %s",
98
+ (source.relid,),
99
+ ).fetchone()
100
+ col = conn.execute(
101
+ """
102
+ SELECT t.typname, a.atttypmod FROM pg_attribute a
103
+ JOIN pg_type t ON t.oid = a.atttypid
104
+ WHERE a.attrelid = %s AND a.attname = %s AND a.attnum > 0 AND NOT a.attisdropped
105
+ """,
106
+ (source.relid, target_column),
107
+ ).fetchone()
108
+ mem = conn.execute(
109
+ "SELECT setting::bigint, unit FROM pg_settings WHERE name = 'maintenance_work_mem'"
110
+ ).fetchone()
111
+ words = conn.execute("SELECT word FROM pg_get_keywords() WHERE catcode <> 'U'").fetchall()
112
+ pk_rows = conn.execute(
113
+ """
114
+ SELECT a.attname FROM pg_index i
115
+ CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
116
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
117
+ WHERE i.indrelid = %s AND i.indisprimary
118
+ ORDER BY k.ord
119
+ """,
120
+ (source.relid,),
121
+ ).fetchall()
122
+ partitioned, row_security = table_limits(conn, source.relid)
123
+ units = {"kB": 1024, "MB": 1024**2, "8kB": 8192, "B": 1}
124
+ mem_bytes = int(mem[0]) * units.get(mem[1] or "kB", 1024) if mem else 64 * 1024**2
125
+ return TargetState(
126
+ source=source,
127
+ extension_schema=ext,
128
+ owner=bool(owner_row and owner_row[0]),
129
+ column_exists=col is not None,
130
+ column_type=str(col[0]) if col else None,
131
+ column_dimensions=int(col[1]) if col and col[1] > 0 else None,
132
+ maintenance_work_mem=mem_bytes,
133
+ keywords=frozenset(w[0] for w in words) or FALLBACK_KEYWORDS,
134
+ primary_key=tuple(str(r[0]) for r in pk_rows),
135
+ partitioned=partitioned,
136
+ row_security=row_security,
137
+ previous_column_exists=conn.execute(
138
+ """
139
+ SELECT 1 FROM pg_attribute
140
+ WHERE attrelid = %s AND attname = %s AND attnum > 0 AND NOT attisdropped
141
+ """,
142
+ (source.relid, f"{source.column}_old"[:63]),
143
+ ).fetchone()
144
+ is not None,
145
+ )
146
+
147
+
148
+ def resolve_source_column(
149
+ conn: psycopg.Connection, table: str, vector_column: str | None, target_column: str
150
+ ) -> str:
151
+ """The column the application searches.
152
+
153
+ It ignores the target column a previous run may have added, and the ``<name>_old``
154
+ column a cutover keeps for rollback.
155
+ """
156
+ if vector_column:
157
+ return vector_column
158
+ columns = find_vector_columns(conn)
159
+ schema, _, name = table.rpartition(".")
160
+ table_columns = [c for c in columns if c.table == name and (not schema or c.schema == schema)]
161
+ names = {c.column for c in table_columns}
162
+ in_table = [
163
+ c
164
+ for c in table_columns
165
+ if c.column != target_column
166
+ and not (c.column.endswith("_old") and c.column.removesuffix("_old") in names)
167
+ ]
168
+ if len(in_table) == 1:
169
+ return in_table[0].column
170
+ return select_column(columns, table, None).column # raises a helpful error
171
+
172
+
173
+ def add_column_sql(state: TargetState, column: str, vector_type: str, dims: int) -> str:
174
+ if vector_type not in {"vector", "halfvec"}:
175
+ raise ValueError(f"unsupported vector type {vector_type!r}")
176
+ table = f"{state.q(state.source.schema)}.{state.q(state.source.table)}"
177
+ kind = f"{state.q(state.extension_schema)}.{state.q(vector_type)}({int(dims)})"
178
+ return f"ALTER TABLE {table} ADD COLUMN {state.q(column)} {kind};"
179
+
180
+
181
+ def index_sql(
182
+ state: TargetState, column: str, vector_type: str, method: str, metric: str, rows: int
183
+ ) -> str:
184
+ if method not in {"hnsw", "ivfflat"} or vector_type not in {"vector", "halfvec"}:
185
+ raise ValueError(f"unsupported index {method!r} on {vector_type!r}")
186
+ table = f"{state.q(state.source.schema)}.{state.q(state.source.table)}"
187
+ name = state.q(f"{state.source.table}_{column}_{method}_idx"[:63])
188
+ ops = state.q(f"{vector_type}_{OPCLASS_SUFFIX[metric]}")
189
+ target = f"{state.q(column)} {state.q(state.extension_schema)}.{ops}"
190
+ using = f"CREATE INDEX CONCURRENTLY {name} ON {table} USING {method} ({target})"
191
+ if method == "ivfflat":
192
+ # pgvector's guidance: rows / 1000 lists up to a million rows, sqrt(rows) beyond.
193
+ lists = max(1, rows // 1000 if rows <= 1_000_000 else int(rows**0.5))
194
+ using += f" WITH (lists = {lists})"
195
+ return using + ";"