smart-data-engine-sdk 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 (73) hide show
  1. sde/__init__.py +318 -0
  2. sde/_cutover_project.py +179 -0
  3. sde/_local_state.py +188 -0
  4. sde/_operator_deadline.py +50 -0
  5. sde/_usage.py +314 -0
  6. sde/bulk.py +79 -0
  7. sde/canonical.py +141 -0
  8. sde/capabilities.py +62 -0
  9. sde/cutover.py +286 -0
  10. sde/engines/__init__.py +0 -0
  11. sde/engines/_clickhouse_connection.py +224 -0
  12. sde/engines/_index_build.py +294 -0
  13. sde/engines/_operator.py +394 -0
  14. sde/engines/_staging.py +222 -0
  15. sde/engines/_storage.py +22 -0
  16. sde/engines/_write_fences.py +271 -0
  17. sde/engines/clickhouse.py +1115 -0
  18. sde/engines/orderbook.py +457 -0
  19. sde/engines/postgres.py +967 -0
  20. sde/entity.py +170 -0
  21. sde/errors.py +103 -0
  22. sde/explain.py +300 -0
  23. sde/frozen_verification.py +152 -0
  24. sde/generation.py +131 -0
  25. sde/groups.py +97 -0
  26. sde/hashing.py +242 -0
  27. sde/index_build.py +313 -0
  28. sde/index_operator.py +347 -0
  29. sde/infer.py +461 -0
  30. sde/inspection.py +62 -0
  31. sde/internal.py +90 -0
  32. sde/layout.py +669 -0
  33. sde/local_cutover.py +801 -0
  34. sde/logging.py +143 -0
  35. sde/migration.py +856 -0
  36. sde/model.py +482 -0
  37. sde/physical.py +531 -0
  38. sde/placement.py +1010 -0
  39. sde/provisioning.py +63 -0
  40. sde/py.typed +0 -0
  41. sde/query.py +521 -0
  42. sde/routing.py +85 -0
  43. sde/schema.py +466 -0
  44. sde/session.py +993 -0
  45. sde/shapes.py +153 -0
  46. sde/staging.py +264 -0
  47. sde/staging_operator.py +393 -0
  48. sde/telemetry.py +1087 -0
  49. sde/testing/__init__.py +14 -0
  50. sde/testing/loader.py +175 -0
  51. sde/testing/memory.py +331 -0
  52. sde/types.py +228 -0
  53. sde/verification.py +220 -0
  54. sde/watermark.py +222 -0
  55. sde/write_fence.py +283 -0
  56. sde_demo/__init__.py +1 -0
  57. sde_demo/__main__.py +183 -0
  58. sde_demo/diagnostics.py +92 -0
  59. sde_demo/model.py +75 -0
  60. sde_demo/project.py +312 -0
  61. sde_demo/py.typed +0 -0
  62. sde_demo/query_count.py +301 -0
  63. sde_demo/resources.py +969 -0
  64. sde_demo/runtime.py +419 -0
  65. sde_demo/verification.py +242 -0
  66. sde_operator/__init__.py +1 -0
  67. sde_operator/__main__.py +210 -0
  68. smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
  69. smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
  70. smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
  71. smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  72. smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  73. smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,394 @@
1
+ """Native operations for the local cutover executor. Credentials and all rows stay local."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from ..errors import EngineError, MigrationRefused
10
+ from ..schema import QUOTE
11
+ from ._storage import STORAGE_COLUMNS
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TableIdentity:
16
+ dialect: str
17
+ server: str
18
+ database: str
19
+ object: str
20
+ namespace: str
21
+ name: str
22
+
23
+ @property
24
+ def physical_key(self) -> tuple[str, str, str, str]:
25
+ return self.dialect, self.server, self.database, self.object
26
+
27
+ def as_record(self) -> dict[str, str]:
28
+ return {
29
+ "dialect": self.dialect,
30
+ "server": self.server,
31
+ "database": self.database,
32
+ "object": self.object,
33
+ "namespace": self.namespace,
34
+ "name": self.name,
35
+ }
36
+
37
+
38
+ class NativeOperator:
39
+ """A dedicated stock adapter plus dedicated read-only probes for declared runtime logins."""
40
+
41
+ def __init__(self, engine: Any, runtime: Sequence[Any]) -> None:
42
+ from .clickhouse import ClickHouseEngine
43
+ from .postgres import PostgresEngine
44
+
45
+ if not isinstance(engine, (PostgresEngine, ClickHouseEngine)) or not runtime:
46
+ raise MigrationRefused(
47
+ "local cutover requires stock operator adapters and runtime probes"
48
+ )
49
+ if any(
50
+ type(probe) is not type(engine) or probe is engine or probe._cx is engine._cx
51
+ for probe in runtime
52
+ ):
53
+ raise MigrationRefused(
54
+ "runtime probes must be separate connections of the same dialect"
55
+ )
56
+ self.engine = engine
57
+ self.runtime = tuple(runtime)
58
+ self.dialect = engine.dialect
59
+ self.quote = QUOTE[self.dialect]
60
+ self.principals: tuple[str, ...] = ()
61
+ self.principal_ids: dict[str, str] = {}
62
+ if self.dialect == "postgres" and int(engine._cx.info.transaction_status) != 0:
63
+ raise MigrationRefused("operator connection must not have an open transaction")
64
+ if self.dialect == "clickhouse":
65
+ # Every acknowledged repair/metadata INSERT must have reached the engine.
66
+ engine._cx.set_client_setting("async_insert", 0)
67
+ engine._cx.set_client_setting("wait_for_async_insert", 1)
68
+
69
+ def rows(self, sql: str, params: Any = None, *, engine: Any = None) -> list[Any]:
70
+ adapter = self.engine if engine is None else engine
71
+ try:
72
+ if self.dialect == "postgres":
73
+ with adapter._cx.cursor() as cursor:
74
+ cursor.execute(sql, params)
75
+ return list(cursor.fetchall()) if cursor.description else []
76
+ return list(adapter._cx.query(sql, parameters=params).result_rows)
77
+ except Exception as exc:
78
+ raise EngineError(f"local operator metadata query failed: {exc}") from exc
79
+
80
+ def command(self, sql: str) -> None:
81
+ if self.dialect == "postgres":
82
+ self.rows(sql)
83
+ else:
84
+ try:
85
+ self.engine._cx.command(sql)
86
+ except Exception as exc:
87
+ raise EngineError(
88
+ f"local operator command has an uncertain outcome: {exc}"
89
+ ) from exc
90
+
91
+ def endpoint(self, engine: Any = None) -> tuple[str, str, str]:
92
+ if self.dialect == "postgres":
93
+ rows = self.rows(
94
+ "SELECT c.system_identifier::text, d.oid::text, current_database() "
95
+ "FROM pg_control_system() c CROSS JOIN pg_database d "
96
+ "WHERE d.datname=current_database()",
97
+ engine=engine,
98
+ )
99
+ else:
100
+ rows = self.rows(
101
+ "SELECT toString(serverUUID()), toString(uuid), name "
102
+ "FROM system.databases WHERE name=currentDatabase()",
103
+ engine=engine,
104
+ )
105
+ if len(rows) != 1 or any(not str(value) for value in rows[0]):
106
+ raise MigrationRefused("native endpoint identity is unavailable")
107
+ return tuple(str(value) for value in rows[0]) # type: ignore[return-value]
108
+
109
+ def identity(self, table: str, *, engine: Any = None) -> TableIdentity:
110
+ server, database, database_name = self.endpoint(engine)
111
+ if self.dialect == "postgres":
112
+ rows = self.rows(
113
+ "SELECT c.oid::text,n.nspname,c.relname,c.relkind,c.relpersistence,"
114
+ "c.relrowsecurity,c.relforcerowsecurity,"
115
+ "EXISTS(SELECT 1 FROM pg_inherits i WHERE i.inhrelid=c.oid OR i.inhparent=c.oid),"
116
+ "EXISTS(SELECT 1 FROM pg_trigger t WHERE t.tgrelid=c.oid AND NOT t.tgisinternal),"
117
+ "EXISTS(SELECT 1 FROM pg_rewrite r WHERE r.ev_class=c.oid) "
118
+ "FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace "
119
+ "WHERE c.oid=to_regclass(%s)",
120
+ [self.quote(table)],
121
+ engine=engine,
122
+ )
123
+ if len(rows) != 1:
124
+ raise MigrationRefused("cutover table is missing or invisible")
125
+ oid, namespace, actual, kind, persistence, *unsupported = rows[0]
126
+ if kind != "r" or persistence != "p" or any(unsupported):
127
+ raise MigrationRefused(
128
+ "cutover requires persistent ordinary tables "
129
+ "without RLS, triggers, rules or inheritance"
130
+ )
131
+ else:
132
+ rows = self.rows(
133
+ "SELECT toString(uuid),database,name,engine,length(dependencies_table) "
134
+ "FROM system.tables WHERE database=currentDatabase() AND name={table:String}",
135
+ {"table": table},
136
+ engine=engine,
137
+ )
138
+ if len(rows) != 1:
139
+ raise MigrationRefused("cutover table is missing or invisible")
140
+ oid, namespace, actual, kind, dependencies = rows[0]
141
+ if kind not in ("MergeTree", "ReplacingMergeTree") or dependencies:
142
+ raise MigrationRefused(
143
+ "cutover requires local MergeTree tables without dependent views"
144
+ )
145
+ if self.rows(
146
+ "SELECT count() FROM system.row_policies WHERE database={database:String} "
147
+ "AND table={table:String}",
148
+ {"database": database_name, "table": table},
149
+ )[0][0]:
150
+ raise MigrationRefused("cutover does not transfer row policies")
151
+ return TableIdentity(self.dialect, server, database, str(oid), str(namespace), str(actual))
152
+
153
+ def qualify(
154
+ self, tables: Sequence[str], allowed_tables: Sequence[str], *, required_access: bool = True
155
+ ) -> tuple[str, ...]:
156
+ """Require direct restricted logins whose table grants can be removed independently."""
157
+ endpoint = self.endpoint()
158
+ principals: list[str] = []
159
+ for probe in self.runtime:
160
+ if self.endpoint(probe) != endpoint:
161
+ raise MigrationRefused(
162
+ "runtime and operator connections name different native databases"
163
+ )
164
+ if self.dialect == "postgres":
165
+ rows = self.rows(
166
+ "SELECT current_user,session_user,r.oid::text,r.rolsuper,r.rolcreaterole,"
167
+ "r.rolcreatedb,r.rolreplication,r.rolbypassrls,"
168
+ "EXISTS(SELECT 1 FROM pg_auth_members m WHERE m.member=r.oid) "
169
+ "FROM pg_roles r WHERE r.rolname=current_user",
170
+ engine=probe,
171
+ )
172
+ user, session_user, oid, *powers = rows[0]
173
+ if user != session_user or any(powers):
174
+ raise MigrationRefused(
175
+ "cutover runtime must be an unprivileged direct login "
176
+ "without role memberships"
177
+ )
178
+ for table in tables:
179
+ if (
180
+ self.identity(table, engine=probe).physical_key
181
+ != self.identity(table).physical_key
182
+ ):
183
+ raise MigrationRefused("runtime resolves a different physical table")
184
+ rights = self.rows(
185
+ "SELECT c.relowner=%s::oid,has_schema_privilege(%s,n.oid,'CREATE'),"
186
+ "has_table_privilege(%s,c.oid,'SELECT'),has_table_privilege(%s,c.oid,'INSERT'),"
187
+ "has_table_privilege(%s,c.oid,'UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') "
188
+ "FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace "
189
+ "WHERE c.oid=to_regclass(%s)",
190
+ [oid, user, user, user, user, self.quote(table)],
191
+ )[0]
192
+ if (
193
+ rights[0]
194
+ or rights[1]
195
+ or rights[4]
196
+ or (required_access and (not rights[2] or not rights[3]))
197
+ ):
198
+ raise MigrationRefused(
199
+ "runtime needs only SELECT/INSERT table rights "
200
+ "and no ownership or schema CREATE"
201
+ )
202
+ shared = self.rows(
203
+ "SELECT count(*) FROM pg_class c, LATERAL aclexplode(c.relacl) a "
204
+ "WHERE c.oid=to_regclass(%s) AND (a.grantee=0 OR "
205
+ "(a.grantee=%s::oid AND a.is_grantable))",
206
+ [self.quote(table), oid],
207
+ )[0][0]
208
+ columns = self.rows(
209
+ "SELECT count(*) FROM pg_attribute c, LATERAL aclexplode(c.attacl) a "
210
+ "WHERE c.attrelid=to_regclass(%s) AND a.grantee IN (0,%s::oid)",
211
+ [self.quote(table), oid],
212
+ )[0][0]
213
+ if shared or columns:
214
+ raise MigrationRefused(
215
+ "PUBLIC, grant-option or column grants "
216
+ "prevent isolated runtime revocation"
217
+ )
218
+ else:
219
+ user = str(self.rows("SELECT currentUser()", engine=probe)[0][0])
220
+ if self.rows(
221
+ "SELECT count() FROM system.role_grants WHERE user_name={user:String}",
222
+ {"user": user},
223
+ )[0][0]:
224
+ raise MigrationRefused(
225
+ "cutover runtime must use direct grants without role memberships"
226
+ )
227
+ rows = self.rows(
228
+ "SELECT storage,toString(id) FROM system.users WHERE name={user:String}",
229
+ {"user": user},
230
+ )
231
+ if len(rows) != 1 or rows[0][0] != "local_directory":
232
+ raise MigrationRefused("cutover runtime must be a local SQL-managed user")
233
+ oid = str(rows[0][1])
234
+ grants = self.rows(
235
+ "SELECT access_type,database,table,column,is_partial_revoke,grant_option "
236
+ "FROM system.grants WHERE user_name={user:String}",
237
+ {"user": user},
238
+ )
239
+ for access, database, table, column, partial, option in grants:
240
+ ordinary = (
241
+ database == endpoint[2]
242
+ and table in allowed_tables
243
+ and access in ("SELECT", "INSERT")
244
+ )
245
+ # Two whole system tables: settings, which the Python driver reads on connect,
246
+ # and data_skipping_indices, the optional grant docs/runtime-roles.md offers
247
+ # for verifying declared indexes - it shows a login its own tables' indexes
248
+ # only (measured on 24.8).
249
+ settings = (
250
+ database == "system"
251
+ and table in ("settings", "data_skipping_indices")
252
+ and access == "SELECT"
253
+ )
254
+ # The one column grant admitted: what a storage measurement reads of
255
+ # `system.parts`, which shows a login the parts of its own tables only
256
+ # (measured) and says nothing about a migrated table's access.
257
+ storage = (
258
+ database == "system"
259
+ and table == "parts"
260
+ and access == "SELECT"
261
+ and column in STORAGE_COLUMNS
262
+ )
263
+ whole = (ordinary or settings) and column is None
264
+ if not (whole or storage) or partial or option:
265
+ raise MigrationRefused(
266
+ "runtime grants must be direct SELECT/INSERT on declared tables, "
267
+ "plus the storage columns of system.parts"
268
+ )
269
+ for table in tables:
270
+ if required_access and (
271
+ self.identity(table, engine=probe).physical_key
272
+ != self.identity(table).physical_key
273
+ ):
274
+ raise MigrationRefused("runtime resolves a different physical table")
275
+ rights = {(str(row[0]), row[1], row[2]) for row in grants}
276
+ if required_access and any(
277
+ (kind, endpoint[2], table) not in rights for kind in ("SELECT", "INSERT")
278
+ ):
279
+ raise MigrationRefused("runtime is missing direct SELECT/INSERT grants")
280
+ principals.append(str(user))
281
+ self.principal_ids[str(user)] = str(oid)
282
+ if len(set(principals)) != len(principals):
283
+ raise MigrationRefused("runtime probes repeat the same login")
284
+ self._qualify_readers(tables, principals)
285
+ self.principals = tuple(sorted(principals))
286
+ return self.principals
287
+
288
+ def _qualify_readers(self, tables: Sequence[str], principals: Sequence[str]) -> None:
289
+ """A missing runtime principal would keep reading the retired authority after cutover."""
290
+ if self.dialect == "postgres":
291
+ operator = str(self.rows("SELECT current_user")[0][0])
292
+ allowed = {*principals, operator}
293
+ for table in tables:
294
+ grants = self.rows(
295
+ "SELECT pg_get_userbyid(a.grantee) FROM pg_class c, "
296
+ "LATERAL aclexplode(c.relacl) a WHERE c.oid=to_regclass(%s) "
297
+ "AND a.grantee <> c.relowner UNION "
298
+ "SELECT pg_get_userbyid(a.grantee) FROM pg_attribute p "
299
+ "JOIN pg_class c ON c.oid=p.attrelid, LATERAL aclexplode(p.attacl) a "
300
+ "WHERE c.oid=to_regclass(%s) AND a.grantee <> c.relowner",
301
+ [self.quote(table), self.quote(table)],
302
+ )
303
+ if any(str(row[0]) not in allowed for row in grants):
304
+ raise MigrationRefused("cutover table has an undeclared runtime grantee")
305
+ else:
306
+ operator = str(self.rows("SELECT currentUser()")[0][0])
307
+ database = self.endpoint()[2]
308
+ allowed = {*principals, operator}
309
+ for table in tables:
310
+ grants = self.rows(
311
+ "SELECT user_name,role_name FROM system.grants "
312
+ "WHERE (database IS NULL OR database='' OR database={database:String}) "
313
+ "AND (table IS NULL OR table='' OR table={table:String})",
314
+ {"database": database, "table": table},
315
+ )
316
+ if any(role is not None or user not in allowed for user, role in grants):
317
+ raise MigrationRefused("cutover table has an undeclared runtime grantee")
318
+
319
+ def access(self, tables: Sequence[TableIdentity], *, enabled: bool) -> None:
320
+ if not self.principals:
321
+ raise MigrationRefused("runtime principals were not qualified")
322
+ for table in tables:
323
+ if self.identity(table.name).physical_key != table.physical_key:
324
+ raise MigrationRefused("physical table identity changed before access transition")
325
+ qualified = self.quote(table.namespace) + "." + self.quote(table.name)
326
+ for name in self.principals:
327
+ action, direction = ("GRANT", "TO") if enabled else ("REVOKE", "FROM")
328
+ self.command(
329
+ f"{action} SELECT, INSERT ON {qualified} {direction} {self.quote(name)}"
330
+ )
331
+ for table in tables:
332
+ qualified = self.quote(table.namespace) + "." + self.quote(table.name)
333
+ for name in self.principals:
334
+ if self.dialect == "postgres":
335
+ rights = self.rows(
336
+ "SELECT has_table_privilege(%s,to_regclass(%s),'SELECT'),"
337
+ "has_table_privilege(%s,to_regclass(%s),'INSERT')",
338
+ [name, qualified, name, qualified],
339
+ )[0]
340
+ else:
341
+ memberships = self.rows(
342
+ "SELECT count() FROM system.role_grants WHERE user_name={user:String}",
343
+ {"user": name},
344
+ )[0][0]
345
+ if memberships:
346
+ raise MigrationRefused(
347
+ "runtime memberships changed during access transition"
348
+ )
349
+ grants = self.rows(
350
+ "SELECT access_type FROM system.grants WHERE user_name={user:String} "
351
+ "AND database={database:String} AND table={table:String} "
352
+ "AND column IS NULL AND is_partial_revoke=0",
353
+ {"user": name, "database": table.namespace, "table": table.name},
354
+ )
355
+ present = {str(row[0]) for row in grants}
356
+ rights = [kind in present for kind in ("SELECT", "INSERT")]
357
+ if list(rights) != [enabled, enabled]:
358
+ raise MigrationRefused(
359
+ "runtime SELECT/INSERT grants did not reach the expected state"
360
+ )
361
+ for probe in self.runtime:
362
+ for table in tables:
363
+ query = f"SELECT 1 FROM {self.quote(table.name)} LIMIT 0"
364
+ try:
365
+ if self.dialect == "postgres":
366
+ probe._cx.execute(query)
367
+ else:
368
+ probe._cx.query(query)
369
+ except Exception as exc:
370
+ denied = (
371
+ getattr(exc, "sqlstate", None) == "42501"
372
+ if self.dialect == "postgres"
373
+ else getattr(exc, "code", None) == 497
374
+ )
375
+ if enabled or not denied:
376
+ raise EngineError(
377
+ "runtime access probe did not establish the expected permissions"
378
+ ) from exc
379
+ else:
380
+ if not enabled:
381
+ raise MigrationRefused(
382
+ "runtime still reads a table after its grants were revoked"
383
+ )
384
+
385
+ def truncate(self, tables: Sequence[TableIdentity]) -> None:
386
+ for table in tables:
387
+ if self.identity(table.name).physical_key != table.physical_key:
388
+ raise MigrationRefused("physical target identity changed before repair")
389
+ names = [self.quote(table.namespace) + "." + self.quote(table.name) for table in tables]
390
+ if self.dialect == "postgres":
391
+ self.command("TRUNCATE TABLE " + ", ".join(names))
392
+ else:
393
+ for name in names:
394
+ self.command("TRUNCATE TABLE " + name + " SYNC")
@@ -0,0 +1,222 @@
1
+ """Create and recognize stage-owned objects through dedicated native operator connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any
9
+
10
+ from ..errors import EngineError, MigrationRefused
11
+ from ..physical import POSTGRES_METHODS, index_method
12
+ from ..placement import PhysicalLayout
13
+ from ..schema import schema_statements
14
+ from ._operator import NativeOperator, TableIdentity
15
+
16
+
17
+ def creation_marker(project_id: str, stage_id: str, table: str) -> str:
18
+ suffix = hashlib.sha256(table.encode("utf-8")).hexdigest()[:16]
19
+ return f"sde.stage1.{project_id}.{stage_id}.{suffix}"
20
+
21
+
22
+ class NativeStaging:
23
+ def __init__(self, native: NativeOperator) -> None:
24
+ self.native = native
25
+ self.engine = native.engine
26
+ self.dialect = native.dialect
27
+ self.quote = native.quote
28
+
29
+ def marker(self, table: str) -> tuple[str, str | None] | None:
30
+ if self.dialect == "postgres":
31
+ rows = self.native.rows(
32
+ "SELECT c.oid::text,obj_description(c.oid,'pg_class') FROM pg_class c "
33
+ "WHERE c.oid=to_regclass(%s)",
34
+ [self.quote(table)],
35
+ )
36
+ else:
37
+ rows = self.native.rows(
38
+ "SELECT toString(uuid),comment FROM system.tables "
39
+ "WHERE database=currentDatabase() AND name={table:String}",
40
+ {"table": table},
41
+ )
42
+ if not rows:
43
+ return None
44
+ if len(rows) != 1:
45
+ raise MigrationRefused("staging table name has an ambiguous native identity")
46
+ return str(rows[0][0]), None if rows[0][1] is None else str(rows[0][1])
47
+
48
+ def preflight(self, layout: PhysicalLayout, keys: Mapping[str, Sequence[str]]) -> None:
49
+ schema_statements(layout, keys=keys, dialect=self.dialect)
50
+ for table in layout.tables.values():
51
+ if self.marker(table) is not None:
52
+ raise MigrationRefused("a fresh staging name already exists in the target")
53
+ if self.dialect == "postgres":
54
+ identifiers = [
55
+ *layout.tables.values(),
56
+ *(name for columns in layout.columns.values() for name in columns),
57
+ *(str(index["name"]) for index in layout.indexes),
58
+ ]
59
+ if any(len(name.encode("utf-8")) > 63 for name in identifiers):
60
+ raise MigrationRefused("staging identifiers cannot be truncated by PostgreSQL")
61
+ for index in layout.indexes:
62
+ found = self.native.rows("SELECT to_regclass(%s)", [self.quote(str(index["name"]))])
63
+ if found[0][0] is not None:
64
+ raise MigrationRefused(
65
+ "a staging index name already exists in the target namespace"
66
+ )
67
+
68
+ def create_table(
69
+ self, *, entity: str, layout: PhysicalLayout, keys: Mapping[str, Sequence[str]], marker: str
70
+ ) -> TableIdentity:
71
+ if re.fullmatch(r"sde\.stage1\.[0-9a-f]{32}\.[0-9a-f]{32}\.[0-9a-f]{16}", marker) is None:
72
+ raise MigrationRefused("staging creation marker is not valid protocol metadata")
73
+ table = layout.tables[entity]
74
+ present = self.marker(table)
75
+ if present is not None:
76
+ if present[1] != marker:
77
+ raise MigrationRefused("staging refuses a table without its exact creation marker")
78
+ return self.native.identity(table)
79
+ # The whole physical design of this one table: key order, partition and - in ClickHouse,
80
+ # where `CREATE TABLE` is the only moment an index can be declared without a mutation over
81
+ # every part - its data-skipping indexes. An earlier version passed the partition alone,
82
+ # so a staged copy would have silently lost the key order and the indexes its signed map
83
+ # authorized. PostgreSQL indexes follow separately in `create_indexes`.
84
+ single = PhysicalLayout(
85
+ tables={entity: table},
86
+ columns={entity: layout.columns[entity]},
87
+ partition_by={entity: layout.partition_by[entity]}
88
+ if entity in layout.partition_by
89
+ else {},
90
+ key_order={entity: layout.key_order[entity]} if entity in layout.key_order else {},
91
+ indexes=tuple(
92
+ index
93
+ for index in layout.indexes
94
+ if self.dialect == "clickhouse" and str(index["entity"]) == entity
95
+ ),
96
+ )
97
+ statements = schema_statements(single, keys=keys, dialect=self.dialect)
98
+ if len(statements) != 1 or not statements[0].startswith("CREATE TABLE IF NOT EXISTS "):
99
+ raise MigrationRefused("staging requires the SDK's ordinary table creation statement")
100
+ statement = statements[0].replace("CREATE TABLE IF NOT EXISTS ", "CREATE TABLE ", 1)
101
+ try:
102
+ if self.dialect == "postgres":
103
+ from psycopg import sql
104
+
105
+ with self.engine._cx.transaction():
106
+ self.engine._cx.execute(statement)
107
+ self.engine._cx.execute(
108
+ sql.SQL("COMMENT ON TABLE {} IS {}").format(
109
+ sql.Identifier(table), sql.Literal(marker)
110
+ )
111
+ )
112
+ else:
113
+ # The marker is fixed ASCII protocol metadata, never a credential or a row value.
114
+ self.engine._cx.command(statement + " COMMENT '" + marker + "'")
115
+ except Exception as exc:
116
+ raise EngineError(
117
+ "staging table creation has an uncertain outcome; resume its intent"
118
+ ) from exc
119
+ found = self.marker(table)
120
+ if found is None or found[1] != marker:
121
+ raise MigrationRefused("staging creation marker was not established with the table")
122
+ return self.native.identity(table)
123
+
124
+ def owned(
125
+ self, table: str, marker: str, identity: TableIdentity | None
126
+ ) -> TableIdentity | None:
127
+ """This staging's own table under ``table``, or ``None`` when it is absent or not ours.
128
+
129
+ Ours means the exact creation marker and, once the identity was recorded, that native
130
+ object. Anything else under the name - another marker, none, another object - belongs to
131
+ somebody else and is left alone by an abandonment.
132
+ """
133
+ present = self.marker(table)
134
+ if present is None or present[1] != marker:
135
+ return None
136
+ if identity is not None:
137
+ return identity if present[0] == identity.object else None
138
+ return self.native.identity(table)
139
+
140
+ def drop_owned(self, table: TableIdentity, marker: str) -> None:
141
+ """Drop this staging's own table; its indexes and generation constraints go with it."""
142
+ present = self.marker(table.name)
143
+ if present is None:
144
+ return # dropped before a crash; nothing is left to remove
145
+ if present[1] != marker or present[0] != table.object:
146
+ raise MigrationRefused("the table to abandon is no longer this staging's own")
147
+ # SYNC: in an Atomic database a dropped table otherwise lingers until the server removes
148
+ # it, and its name and data with it.
149
+ suffix = " SYNC" if self.dialect == "clickhouse" else ""
150
+ self.native.command(f"DROP TABLE {self.quote(table.name)}{suffix}")
151
+ if self.marker(table.name) is not None:
152
+ raise MigrationRefused("an abandoned staging table is still in the catalogue")
153
+
154
+ def revoke_runtime(self, tables: Sequence[TableIdentity], principals: Sequence[str]) -> None:
155
+ """Take back the runtime grants on dropped tables; ClickHouse keeps them after DROP TABLE.
156
+
157
+ Measured on 24.8.14.39: ``system.grants`` still lists a table's grants once the table is
158
+ gone, and ``REVOKE`` on the dropped table is accepted and removes them. PostgreSQL removes a
159
+ table's privileges with the table, so there is nothing to take back there.
160
+ """
161
+ if self.dialect != "clickhouse":
162
+ return
163
+ for table in tables:
164
+ qualified = self.quote(table.namespace) + "." + self.quote(table.name)
165
+ for name in principals:
166
+ self.native.command(f"REVOKE SELECT, INSERT ON {qualified} FROM {self.quote(name)}")
167
+ for table in tables:
168
+ for name in principals:
169
+ rows = self.native.rows(
170
+ "SELECT count() FROM system.grants WHERE user_name={user:String} "
171
+ "AND database={database:String} AND table={table:String}",
172
+ {"user": name, "database": table.namespace, "table": table.name},
173
+ )
174
+ if rows[0][0]:
175
+ raise MigrationRefused("a runtime grant on an abandoned staging table remains")
176
+
177
+ def create_indexes(self, layout: PhysicalLayout) -> None:
178
+ if self.dialect != "postgres":
179
+ # ClickHouse data-skipping indexes were declared inside `CREATE TABLE`; qualification
180
+ # reads them back from `system.data_skipping_indices` before the stage is accepted.
181
+ return
182
+ for index in layout.indexes:
183
+ name, entity = str(index["name"]), str(index["entity"])
184
+ declared_method = index_method(index)
185
+ if declared_method not in POSTGRES_METHODS:
186
+ raise MigrationRefused(f"PostgreSQL cannot create a {declared_method} index")
187
+ table = self.native.identity(layout.tables[entity])
188
+ columns = [str(column) for column in index["columns"]]
189
+ rows = self._index(name)
190
+ if not rows:
191
+ column_sql = ", ".join(self.quote(column) for column in columns)
192
+ using = "" if declared_method == "btree" else f"USING {declared_method} "
193
+ self.native.command(
194
+ f"CREATE INDEX {self.quote(name)} ON {self.quote(table.name)} "
195
+ f"{using}({column_sql})"
196
+ )
197
+ rows = self._index(name)
198
+ if len(rows) != 1:
199
+ raise MigrationRefused("staging index identity could not be established")
200
+ target, unique, valid, ready, simple, method, actual, options = rows[0]
201
+ if (
202
+ str(target) != table.object
203
+ or unique
204
+ or not valid
205
+ or not ready
206
+ or not simple
207
+ or method != declared_method
208
+ or list(actual) != columns
209
+ or any(options)
210
+ ):
211
+ raise MigrationRefused("staging index does not match its owned table and columns")
212
+
213
+ def _index(self, name: str) -> list[Any]:
214
+ return self.native.rows(
215
+ "SELECT i.indrelid::text,i.indisunique,i.indisvalid,i.indisready,"
216
+ "i.indpred IS NULL AND i.indexprs IS NULL AND i.indnkeyatts=i.indnatts,a.amname,"
217
+ "ARRAY(SELECT p.attname FROM unnest(i.indkey) WITH ORDINALITY k(num,pos) "
218
+ "JOIN pg_attribute p ON p.attrelid=i.indrelid AND p.attnum=k.num ORDER BY k.pos),"
219
+ "i.indoption::smallint[] FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid "
220
+ "JOIN pg_am a ON a.oid=c.relam WHERE c.oid=to_regclass(%s)",
221
+ [self.quote(name)],
222
+ )
@@ -0,0 +1,22 @@
1
+ """The ``system.parts`` columns a ClickHouse storage measurement reads - in a module of its own.
2
+
3
+ Shared by the adapter that reads them, the operator's runtime qualification that admits exactly
4
+ this grant, and the starter that gives it; kept apart from the adapter so that importing the
5
+ qualification does not import a driver.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ STORAGE_COLUMNS = (
11
+ "database",
12
+ "table",
13
+ "active",
14
+ "bytes_on_disk",
15
+ "secondary_indices_compressed_bytes",
16
+ "secondary_indices_marks_bytes",
17
+ )
18
+ """The columns of ``system.parts`` a storage measurement reads, and all a runtime login is granted.
19
+
20
+ ClickHouse 24.8 refuses ``system.parts`` to a login with only table grants; a column grant on these
21
+ names lets it read the parts of the tables it may use and no others (measured). The operator's
22
+ runtime qualification admits exactly this grant, and the starter gives it."""