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/__init__.py +226 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/engines/__init__.py +0 -0
- sde/engines/clickhouse.py +689 -0
- sde/engines/orderbook.py +454 -0
- sde/engines/postgres.py +672 -0
- sde/entity.py +170 -0
- sde/errors.py +88 -0
- sde/explain.py +300 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/infer.py +461 -0
- sde/internal.py +90 -0
- sde/layout.py +660 -0
- sde/logging.py +132 -0
- sde/migration.py +820 -0
- sde/model.py +482 -0
- sde/placement.py +818 -0
- sde/py.typed +0 -0
- sde/routing.py +85 -0
- sde/schema.py +370 -0
- sde/session.py +507 -0
- sde/shapes.py +153 -0
- sde/telemetry.py +736 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +318 -0
- sde/types.py +228 -0
- sde/watermark.py +222 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/METADATA +152 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/RECORD +35 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dev0.dist-info/licenses/NOTICE +13 -0
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
|
+
)
|