smart-data-engine-sdk 0.1.0.dev0__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.
sde/py.typed ADDED
File without changes
sde/routing.py ADDED
@@ -0,0 +1,85 @@
1
+ """Routing: a lookup and three conditions, and deliberately nothing more.
2
+
3
+ The library does not decide where an operation goes. The planner decided, ahead of time, for every
4
+ shape the model admits, and put the answers in the placement map. This module reads them.
5
+
6
+ That division is the reason it is affordable to have four libraries. Decisions need telemetry,
7
+ history, a cost model and an explanation, and they need to be reproducible and testable - all of
8
+ which live on our side, once. If the library decided anything, that judgement would have to be
9
+ reimplemented in Python, TypeScript, Java and Rust, and kept identical in all four forever. So it
10
+ looks things up.
11
+
12
+ The three conditions that are *not* lookups exist because they are correctness, not judgement:
13
+
14
+ 1. Writes go to the source materialisation. There is exactly one, and derived copies are derived.
15
+ 2. An operation inside a transaction that has already written goes to the source, because a derived
16
+ copy is behind by design and would not show the write the caller just made.
17
+ 3. An operation asking for no staleness goes to the source, for the same reason.
18
+
19
+ Anything else follows the routing table, and if the table has nothing to say the answer is the
20
+ source. That fallback is safe by construction, since the source is always correct and merely
21
+ sometimes slower, and it is what makes a hand-written map a two-line affair rather than a table of
22
+ hashes.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+
29
+ from .logging import log
30
+ from .placement import Materialization, PlacementMap
31
+ from .shapes import WRITE_KINDS, OperationShape
32
+
33
+ __all__ = ["Router", "resolve"]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Router:
38
+ """Resolves shapes against one placement map."""
39
+
40
+ placement: PlacementMap
41
+
42
+ def resolve(
43
+ self,
44
+ shape: OperationShape,
45
+ *,
46
+ in_write_transaction: bool = False,
47
+ fresh: bool = False,
48
+ ) -> Materialization:
49
+ group = self.placement.placement_of(shape.group)
50
+
51
+ if shape.kind in WRITE_KINDS:
52
+ return group.source
53
+ if in_write_transaction or fresh:
54
+ return group.source
55
+
56
+ target_id = self.placement.routing.get(shape.id)
57
+ if target_id is None:
58
+ # Not an error. A map with no routing table is the normal shape of a hand-written one,
59
+ # and the source is always a correct answer.
60
+ log("sde.route.fallback", group=shape.group, shape=shape.id, kind=shape.kind)
61
+ return group.source
62
+
63
+ materialization = group.by_id(target_id)
64
+ log(
65
+ "sde.route.resolved",
66
+ group=shape.group,
67
+ shape=shape.id,
68
+ kind=shape.kind,
69
+ materialization=materialization.id,
70
+ engine=materialization.engine,
71
+ )
72
+ return materialization
73
+
74
+
75
+ def resolve(
76
+ placement: PlacementMap,
77
+ shape: OperationShape,
78
+ *,
79
+ in_write_transaction: bool = False,
80
+ fresh: bool = False,
81
+ ) -> Materialization:
82
+ """Functional form, for the conformance vectors and for one-off resolution."""
83
+ return Router(placement).resolve(
84
+ shape, in_write_transaction=in_write_transaction, fresh=fresh
85
+ )
sde/schema.py ADDED
@@ -0,0 +1,370 @@
1
+ """DDL as a value, not as a side effect.
2
+
3
+ The statements that create a group's tables used to exist only inside ``ensure_schema``, built and
4
+ executed in the same breath. That made them impossible to read without a database and impossible to
5
+ show to anybody - which matters, because "here is the schema we chose for you" is one of the things
6
+ the control plane hands a client, and the only honest way to produce it is to ask the library that
7
+ would apply it. Rebuilding the DDL at the other end would be a second implementation of the type
8
+ mapping, the quoting and the key handling, and two implementations of one thing is the failure the
9
+ byte contract exists to prevent.
10
+
11
+ So this module is pure: a layout and a set of keys in, statements out, no connection anywhere. It
12
+ lives outside ``sde.engines`` deliberately. That package is the part of the library that opens
13
+ connections - the control plane's import allowlist refuses it by name for exactly that reason - and
14
+ rendering DDL needs a driver about as much as printing a receipt needs a bank.
15
+
16
+ Being a pure function also makes it the natural home for the `schema/` conformance vectors, which
17
+ section 10 of the format contract records as missing. Two libraries that agree on the map and
18
+ disagree on the DDL place the same entity in tables with different columns.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable, Mapping, Sequence
24
+ from dataclasses import dataclass
25
+
26
+ from .errors import EngineError
27
+ from .layout import DIALECTS, FIXED_SCHEMA
28
+ from .placement import PhysicalLayout
29
+
30
+
31
+ def _quote_ansi(identifier: str) -> str:
32
+ return '"' + identifier.replace('"', '""') + '"'
33
+
34
+
35
+ def _quote_backtick(identifier: str) -> str:
36
+ r"""Backticks, with a backslash escape for the backtick **and for the backslash**.
37
+
38
+ ClickHouse accepts double quotes too. Backticks are the idiomatic form and, more usefully, they
39
+ make a generated statement obviously ClickHouse when it turns up in a log next to a PostgreSQL
40
+ one.
41
+
42
+ **Doubling the backtick alone was not enough and the missing half was the backslash.** Inside a
43
+ backtick-quoted identifier this lexer reads ``\X`` as an escape, so a field a client called
44
+ ``a\nb`` reached the server as a column called ``a``, a newline, ``b`` - a different name,
45
+ accepted without a word. Measured against the ClickHouse this repository tests against, reading
46
+ the name back out of ``system.columns`` rather than trusting that the statement was accepted:
47
+ ``a\nb`` left raw creates the two-line name, ``a\\nb`` creates the one the model declared, and
48
+ ``back\slash`` survives untouched because ``\s`` is not an escape the lexer knows - which is
49
+ exactly what made the defect look absent.
50
+
51
+ PostgreSQL is the other way round: a backslash is literal inside ``"..."`` and doubling the
52
+ quote is the whole rule, and backslash-escaping the quote there is a *syntax error* (measured).
53
+ So the two dialects genuinely differ and there is no escaper to share, which is why this
54
+ mapping has two.
55
+
56
+ One pass rather than two ``replace`` calls, because the order of two would matter: escaping the
57
+ backtick first and the backslash afterwards turns ``a\`b`` into ``a\\`b``, whose backslash the
58
+ lexer eats and whose backtick then closes the identifier early. A single pass cannot be
59
+ sequenced wrongly. ``schema/011`` pins both dialects, and the live schema test runs them
60
+ against real servers and reads the names back out of the catalogue.
61
+ """
62
+ escaped = "".join("\\" + c if c in "\\`" else c for c in identifier)
63
+ return f"`{escaped}`"
64
+
65
+
66
+ # Quoting is a property of the dialect rather than of DDL, and it is here because this is the only
67
+ # module that needs more than one dialect at a time. The adapters bind their own from this mapping
68
+ # instead of keeping a copy: two implementations of an escaping rule is how one of them ends up
69
+ # missing the doubling.
70
+ QUOTE: Mapping[str, Callable[[str], str]] = {
71
+ "postgres": _quote_ansi,
72
+ "clickhouse": _quote_backtick,
73
+ }
74
+
75
+
76
+ def _columns_and_key(
77
+ layout: PhysicalLayout, entity: str, keys: Mapping[str, Sequence[str]]
78
+ ) -> tuple[Mapping[str, str], list[str]]:
79
+ cols = layout.columns.get(entity, {})
80
+ if not cols:
81
+ raise EngineError(f"the layout gives no columns for {entity!r}")
82
+ key = list(keys.get(entity, ()))
83
+ if not key:
84
+ raise EngineError(f"no key for {entity!r}; a table without one cannot be addressed")
85
+ return cols, key
86
+
87
+
88
+ def _postgres_statements(
89
+ layout: PhysicalLayout, keys: Mapping[str, Sequence[str]]
90
+ ) -> tuple[str, ...]:
91
+ statements: list[str] = []
92
+ for entity, table in sorted(layout.tables.items()):
93
+ cols, key = _columns_and_key(layout, entity, keys)
94
+ defs = ", ".join(f"{_quote_ansi(c)} {t}" for c, t in sorted(cols.items()))
95
+ pk = ", ".join(_quote_ansi(c) for c in key)
96
+ statements.append(
97
+ f"CREATE TABLE IF NOT EXISTS {_quote_ansi(table)} ({defs}, PRIMARY KEY ({pk}))"
98
+ )
99
+
100
+ for index in layout.indexes:
101
+ index_entity = str(index["entity"])
102
+ index_table = layout.tables.get(index_entity)
103
+ if index_table is None:
104
+ continue
105
+ index_name = str(index["name"])
106
+ index_cols = ", ".join(_quote_ansi(str(c)) for c in index["columns"])
107
+ statements.append(
108
+ f"CREATE INDEX IF NOT EXISTS {_quote_ansi(index_name)} "
109
+ f"ON {_quote_ansi(index_table)} ({index_cols})"
110
+ )
111
+ return tuple(statements)
112
+
113
+
114
+ def _clickhouse_statements(
115
+ layout: PhysicalLayout, keys: Mapping[str, Sequence[str]]
116
+ ) -> tuple[str, ...]:
117
+ statements: list[str] = []
118
+ for entity, table in sorted(layout.tables.items()):
119
+ cols, key = _columns_and_key(layout, entity, keys)
120
+ missing = [column for column in key if column not in cols]
121
+ if missing:
122
+ raise EngineError(
123
+ f"the key of {entity!r} names columns the layout does not have: {missing}. In "
124
+ f"ClickHouse the key becomes ORDER BY, so this would produce a table that cannot "
125
+ f"be created rather than one with a missing constraint."
126
+ )
127
+ defs = ", ".join(f"{_quote_backtick(c)} {t}" for c, t in sorted(cols.items()))
128
+ # ORDER BY is the declared key, in declared order. That order is positional and carries
129
+ # meaning: it decides which prefixes of the key can prune granules, so sorting it would
130
+ # change the physical performance of the table while leaving the map looking identical.
131
+ order = ", ".join(_quote_backtick(c) for c in key)
132
+ statements.append(
133
+ f"CREATE TABLE IF NOT EXISTS {_quote_backtick(table)} ({defs}) "
134
+ f"ENGINE = ReplacingMergeTree ORDER BY ({order})"
135
+ )
136
+
137
+ if layout.indexes:
138
+ raise EngineError(
139
+ f"the layout carries {len(layout.indexes)} index definitions and this engine has no "
140
+ f"B-tree to put them in. A ClickHouse index is a data-skipping index with a type and a "
141
+ f"granularity, so this map was built for another dialect."
142
+ )
143
+ return tuple(statements)
144
+
145
+
146
+ def _no_statements(layout: PhysicalLayout, keys: Mapping[str, Sequence[str]]) -> tuple[str, ...]:
147
+ """No DDL, because this engine's schema is not ours to create.
148
+
149
+ An empty tuple rather than a raise, and the difference matters. "Run nothing" is the *correct*
150
+ action for a caller preparing a fixed-schema engine: the storage exists the moment the engine
151
+ opens its data directory, and the client's obligation is that their model matches the shape -
152
+ which ``default_layout`` has already enforced by the time a layout exists to render.
153
+
154
+ Raising would push the branch out to every caller, which is the shape of the defect this module
155
+ was extracted to remove. What the empty tuple loses is the *explanation*, and
156
+ :func:`schema_is_fixed` supplies that to anybody printing one - the control plane's placement
157
+ report does, because "here is the schema we chose for you: (nothing)" needs a sentence after it.
158
+ """
159
+ return ()
160
+
161
+
162
+ _BY_DIALECT = {
163
+ "postgres": _postgres_statements,
164
+ "clickhouse": _clickhouse_statements,
165
+ "orderbook": _no_statements,
166
+ }
167
+
168
+ # A dialect this library types columns for and cannot render DDL for would be a map it can build and
169
+ # not apply, so the two lists have to be the same list.
170
+ assert set(_BY_DIALECT) == set(DIALECTS), sorted(set(_BY_DIALECT) ^ set(DIALECTS))
171
+
172
+ # Quoting is not the same list, and the exception is named rather than absorbed: a fixed-schema
173
+ # engine never has an identifier of ours to escape. It emits no DDL, and its query language takes
174
+ # the symbol and the exchange as string *literals* rather than identifiers. A no-op quoting function
175
+ # entered here to satisfy a symmetry would look usable and silently escape nothing the day somebody
176
+ # reached for it.
177
+ _QUOTED = set(DIALECTS) - FIXED_SCHEMA
178
+ assert set(QUOTE) == _QUOTED, sorted(set(QUOTE) ^ _QUOTED)
179
+
180
+
181
+ def schema_is_fixed(dialect: str) -> bool:
182
+ """Whether this engine imposes its own schema, so an empty statement list means "nothing to do".
183
+
184
+ The one public way to tell that apart from "no tables in this layout". A caller that never asks
185
+ still behaves correctly - creating nothing is right - but a caller *reporting* what the client
186
+ must do has a different sentence to write, and guessing which by testing the dialect string
187
+ would put the set of fixed-schema engines in two places.
188
+ """
189
+ if dialect not in _BY_DIALECT:
190
+ raise EngineError(
191
+ f"unknown dialect {dialect!r}; this library renders {sorted(_BY_DIALECT)}. Answering "
192
+ f"False would say 'that engine takes DDL from us' about an engine it has never heard "
193
+ f"of."
194
+ )
195
+ return dialect in FIXED_SCHEMA
196
+
197
+
198
+ def schema_statements(
199
+ layout: PhysicalLayout, *, keys: Mapping[str, Sequence[str]], dialect: str
200
+ ) -> tuple[str, ...]:
201
+ """The statements that would create this layout, in the order they must run.
202
+
203
+ Idempotent by construction - every statement is ``IF NOT EXISTS`` - because an application
204
+ restarting must not reapply DDL and two instances starting at once must not race. Anything
205
+ beyond creation is a migration, which carries a rollback path and a safety classification, and a
206
+ library that quietly altered a live column would be doing the one thing this product promises
207
+ never to do without one.
208
+
209
+ An unknown dialect raises. Falling back to ANSI would emit a statement that looks right, runs on
210
+ the wrong engine, and creates a table with the wrong storage semantics.
211
+ """
212
+ try:
213
+ build = _BY_DIALECT[dialect]
214
+ except KeyError:
215
+ raise EngineError(
216
+ f"no DDL for dialect {dialect!r}; this library renders "
217
+ f"{sorted(_BY_DIALECT)}. Refusing rather than falling back to ANSI: a statement that "
218
+ f"looks right on the wrong engine creates a table with the wrong storage semantics."
219
+ ) from None
220
+ return build(layout, keys)
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class CompatibilityViews:
225
+ """What can stand under a table's old name after a group has moved, and what cannot.
226
+
227
+ Requirement 19.7 of the product specification: during a migration's grace period a view stays
228
+ under the old table name, so a query somebody wrote by hand - outside the entity API, against
229
+ the physical schema - does not fail in the second of the switch. It is removed with the source
230
+ when the period ends.
231
+
232
+ ``not_possible`` is the field worth reading, because in this library's engine set it is usually
233
+ the populated one, and the reasons are structural rather than missing work:
234
+
235
+ - **The two engines call the table the same thing.** A layout's table name is derived from the
236
+ entity name and nothing else, so ``postgres`` and ``clickhouse`` agree on it. There is no old
237
+ name for a view to occupy - the name is not what moved. What moved is the dialect, and the
238
+ sentence says so.
239
+ - **The engine imposes its own schema.** A fixed-schema engine takes no DDL from us at all, so
240
+ there is nowhere to put a view. Its table name *is* different, which is exactly the case a
241
+ view would help with, and it is the case that cannot have one.
242
+
243
+ So the honest output is often "no view, here is why, and here is what the query has to become",
244
+ and that is a better artefact than an empty tuple: a caller who gets nothing back cannot tell
245
+ "nothing needed" from "nothing thought about".
246
+ """
247
+
248
+ create: tuple[str, ...]
249
+ """Statements to run on the **target** when reads switch, in order."""
250
+ drop: tuple[str, ...]
251
+ """Statements to run when the source is dropped, so the view goes with what it replaced."""
252
+ not_possible: tuple[tuple[str, str], ...]
253
+ """``(entity, why)`` for each table that cannot have one. Sorted by entity."""
254
+
255
+ @property
256
+ def complete(self) -> bool:
257
+ """Whether every table of the group got one. False is ordinary - see the class docstring."""
258
+ return not self.not_possible
259
+
260
+
261
+ def compatibility_views(
262
+ layout: PhysicalLayout, *, was: Mapping[str, str], dialect: str
263
+ ) -> CompatibilityViews:
264
+ """Views on the target under the table names the group had in the engine it left.
265
+
266
+ ``layout`` is the group's layout in the target and ``was`` is entity name to the table name it
267
+ had in the source - both from :func:`~sde.layout.default_layout`, one per dialect, so the two
268
+ names come from the same derivation the DDL uses rather than from a caller's memory of it.
269
+
270
+ **A view cannot cross engines, and that is why this renders on the target.** The old table is
271
+ in the old engine; no dialect here has a way to select from another server, and the one
272
+ PostgreSQL function that could - ``dblink`` - is refused by the control plane's read-only gate
273
+ by name. So what this offers is for the case where somebody re-points their tool at the new
274
+ engine and their SQL still says the old table name.
275
+
276
+ Columns are listed rather than ``SELECT *``, so the view names exactly what the map names: a
277
+ column added to the table later does not silently appear in a view somebody's query is
278
+ counting on the shape of. They are listed **sorted by name, exactly as ``CREATE TABLE`` sorts
279
+ them** - a view whose columns came out in a different order would break anything reading them
280
+ by position, which is most of what a hand-written query does with ``SELECT *``.
281
+
282
+ **That sort is a fix, and the sentence it replaced was false.** This function used to list the
283
+ columns in the layout document's own order, on the stated grounds that the order was already
284
+ sorted and a second sort would be a duplicated guarantee. It is not sorted:
285
+ :func:`~sde.layout._neutral_columns` returns declared fields first and then a foreign-key
286
+ column per relation, and says so in its own docstring. So for any entity with a relation - the
287
+ ordinary case - the view listed its columns in one order and its table declared them in
288
+ another, and a query moved verbatim onto the view read them by position and got different
289
+ values. The test that was meant to hold this used a fixture with no relations, which is why it
290
+ passed: a check whose fixture cannot reach the case reports on something else. Found by writing
291
+ the ``schema/`` vectors, and ``schema/009`` now feeds a document whose columns are deliberately
292
+ out of order, so removing either sort changes a different string and neither can hide behind
293
+ the other.
294
+
295
+ For ClickHouse the view reads ``FINAL``, and that is the substance rather than a detail. The
296
+ table is a ``ReplacingMergeTree``, so a plain read returns rows the declared key should have
297
+ collapsed until a background merge happens - which means a hand-written query moved verbatim
298
+ to the target returns numbers that are too big, silently, and gets no error to notice. A view
299
+ that quietly reproduced that would be worse than no view.
300
+ """
301
+ if dialect not in _BY_DIALECT:
302
+ raise EngineError(
303
+ f"no compatibility view for dialect {dialect!r}; this library renders "
304
+ f"{sorted(_BY_DIALECT)}."
305
+ )
306
+ if dialect in FIXED_SCHEMA:
307
+ return CompatibilityViews(
308
+ create=(),
309
+ drop=(),
310
+ not_possible=tuple(
311
+ (
312
+ entity,
313
+ f"{dialect} imposes its own schema and accepts no DDL from this library, so "
314
+ f"there is nowhere to put a view. Its table is {layout.tables.get(entity)!r} "
315
+ f"and the old name was {was.get(entity)!r}: a query naming the old one has to "
316
+ f"be edited.",
317
+ )
318
+ for entity in sorted(layout.tables)
319
+ ),
320
+ )
321
+
322
+ quote = QUOTE[dialect]
323
+ # Idempotent like every statement `schema_statements` renders, and the two dialects spell that
324
+ # differently - measured against both servers rather than assumed. PostgreSQL has no
325
+ # `CREATE VIEW IF NOT EXISTS`: it is a syntax error, and the first draft of this function had
326
+ # one. `CREATE OR REPLACE VIEW` is idempotent there and running it twice is a no-op.
327
+ opening = "CREATE OR REPLACE VIEW" if dialect == "postgres" else "CREATE VIEW IF NOT EXISTS"
328
+ final = " FINAL" if dialect == "clickhouse" else ""
329
+ create: list[str] = []
330
+ drop: list[str] = []
331
+ not_possible: list[tuple[str, str]] = []
332
+ for entity, table in sorted(layout.tables.items()):
333
+ old = was.get(entity)
334
+ if old is None:
335
+ not_possible.append(
336
+ (
337
+ entity,
338
+ f"the source layout gives no table for {entity!r}, so there is no old name to "
339
+ f"stand in for.",
340
+ )
341
+ )
342
+ continue
343
+ if old == table:
344
+ not_possible.append(
345
+ (
346
+ entity,
347
+ f"both engines call this table {table!r}, so the name is not what moved - the "
348
+ f"dialect is."
349
+ + (
350
+ f" A query moved here verbatim must read `FROM {quote(table)} FINAL`: the "
351
+ f"table is a ReplacingMergeTree, so without it a row written twice "
352
+ f"under one key is counted twice until a background merge collapses it - "
353
+ f"measured, two rows against one."
354
+ if dialect == "clickhouse"
355
+ else ""
356
+ ),
357
+ )
358
+ )
359
+ continue
360
+ cols = layout.columns.get(entity, {})
361
+ if not cols:
362
+ raise EngineError(f"the layout gives no columns for {entity!r}")
363
+ # Sorted by name, which is what `CREATE TABLE` does. See the docstring: reading this order
364
+ # off the document instead was wrong, and wrong in a way one fixture could not show.
365
+ selected = ", ".join(quote(column) for column in sorted(cols))
366
+ create.append(f"{opening} {quote(old)} AS SELECT {selected} FROM {quote(table)}{final}")
367
+ drop.append(f"DROP VIEW IF EXISTS {quote(old)}")
368
+ return CompatibilityViews(
369
+ create=tuple(create), drop=tuple(drop), not_possible=tuple(not_possible)
370
+ )