tinybird-toolset 2.4.13__tar.gz → 2.5.0__tar.gz

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 (30) hide show
  1. {tinybird_toolset-2.4.13/src/tinybird_toolset.egg-info → tinybird_toolset-2.5.0}/PKG-INFO +1 -1
  2. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/conf.py +31 -3
  3. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/setup.py +2 -1
  4. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/src/chtoolset/query.py +1 -0
  5. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0/src/tinybird_toolset.egg-info}/PKG-INFO +1 -1
  6. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/src/tinybird_toolset.egg-info/SOURCES.txt +2 -1
  7. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_convert_to_row_binary.py +6 -4
  8. tinybird_toolset-2.5.0/tests/test_validate_ttl.py +394 -0
  9. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/LICENSE +0 -0
  10. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/MANIFEST.in +0 -0
  11. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/README.md +0 -0
  12. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/setup.cfg +0 -0
  13. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/src/chtoolset/__init__.py +0 -0
  14. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/src/tinybird_toolset.egg-info/dependency_links.txt +0 -0
  15. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/src/tinybird_toolset.egg-info/requires.txt +0 -0
  16. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/src/tinybird_toolset.egg-info/top_level.txt +0 -0
  17. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_check_compatible_types.py +0 -0
  18. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_check_write_query.py +0 -0
  19. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_chquery.py +0 -0
  20. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_explain_ast.py +0 -0
  21. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_get_columns_from_create_query.py +0 -0
  22. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_get_left_table.py +0 -0
  23. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_internal_cache.py +0 -0
  24. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_normalize_query_keep_names.py +0 -0
  25. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_normalize_table_column.py +0 -0
  26. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_parse_create_materialized_view_target_table.py +0 -0
  27. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_replace_tables.py +0 -0
  28. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_replace_tables_backward_compat.py +0 -0
  29. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_rewrite_aggregation_states.py +0 -0
  30. {tinybird_toolset-2.4.13 → tinybird_toolset-2.5.0}/tests/test_tables.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinybird-toolset
3
- Version: 2.4.13
3
+ Version: 2.5.0
4
4
  Home-page: https://gitlab.com/tinybird/clickhouse-toolset
5
5
  Author: Tinybird.co
6
6
  Author-email: support@tinybird.co
@@ -94,9 +94,11 @@ class ClickHouseBuildExt(build_ext):
94
94
  if not os.path.exists(os.path.join(CLICKHOUSE_PATH, 'CMakeLists.txt')):
95
95
  raise RuntimeError('Git submodules are not initialized. Run: `git submodule update --init --recursive`.')
96
96
 
97
- # One of the libraries, chosen randomly
97
+ # One of the libraries, chosen randomly (+ the TTL functions one, added later)
98
98
  if os.path.isfile(os.path.join(CLICKHOUSE_BUILD_PATH,
99
- 'src/AggregateFunctions/libclickhouse_aggregate_functions.a')):
99
+ 'src/AggregateFunctions/libclickhouse_aggregate_functions.a')) \
100
+ and os.path.isfile(os.path.join(CLICKHOUSE_BUILD_PATH,
101
+ 'src/Functions/libclickhouse_ttl_functions.a')):
100
102
  return
101
103
 
102
104
  # Avoid segfault caused by missing global context
@@ -138,9 +140,18 @@ class ClickHouseBuildExt(build_ext):
138
140
  # Removes TSA warnings (we don't compile with a libc++ with annotations)
139
141
  apply_patch("patches/tsa.patch")
140
142
 
141
- # Temporary for debugging: some functions are missing from libdbms.a, this is in the private repo pmaster already so we should include it from the repo
143
+ # Builds the functions needed by validate_ttl into libclickhouse_ttl_functions.a,
144
+ # which is linked with --whole-archive/-force_load (see extra_libs).
145
+ # NOTE: after editing this patch (or any other), the early-return above skips the
146
+ # ClickHouse build; delete ch_build/src/Functions/libclickhouse_ttl_functions.a
147
+ # (or the whole ch_build) to force it to run again.
142
148
  apply_patch("patches/dbmsfunctions.patch")
143
149
 
150
+ # Range-check narrow-integer Field inserts so JSON/Dynamic paths widen instead of
151
+ # silently wrapping (1944 -> -104). Backport of the ColumnVector::tryInsert fix;
152
+ # remove once the fix lands in the pinned ClickHouse submodule.
153
+ apply_patch("patches/ColumnVectorTryInsert.patch")
154
+
144
155
  if sys.platform == 'darwin':
145
156
  # We disable whole_archive options destined for AppleClang, which is unsupported but keeps being a PITA
146
157
  apply_patch("patches/darwin.patch")
@@ -243,6 +254,14 @@ class ClickHouseBuildExt(build_ext):
243
254
  ['--config', 'Release',
244
255
  '--target', 'src/AggregateFunctions/libclickhouse_aggregate_functions.a'])
245
256
 
257
+ # Functions for TTL validation, also with MinSize as performance is not a concern.
258
+ # This library is linked with --whole-archive/-force_load (see extra_libs) so that
259
+ # its REGISTER_FUNCTION static registrars run and DB::registerFunctions() can
260
+ # register all of them without listing them one by one.
261
+ subprocess.check_call([cmake_cmd, '--build', CLICKHOUSE_BUILD_PATH] +
262
+ ['--config', 'Release',
263
+ '--target', 'src/Functions/libclickhouse_ttl_functions.a'])
264
+
246
265
  # Now build with Release just the parser, which is where we want speed
247
266
  subprocess.check_call([cmake_cmd, CLICKHOUSE_PATH] + cmake_args + ['-DCMAKE_BUILD_TYPE=Release'],
248
267
  cwd=CLICKHOUSE_BUILD_PATH)
@@ -375,6 +394,15 @@ class CustomBuildWithFromCH(build_ext):
375
394
  # - Remove .o. These files only contain function declaration and we don't need them
376
395
  ch_libs = list(filter(lambda lib: lib and lib.find('whole-archive') == -1 and lib.find('-lm') == -1, ch_libs))
377
396
 
397
+ # Force-load the TTL validation functions so their REGISTER_FUNCTION static
398
+ # registrars run even though nothing references their symbols directly.
399
+ # DB::registerFunctions() (called from ValidateTTL.cpp) registers all of them.
400
+ ttl_functions_lib = os.path.join(CLICKHOUSE_BUILD_PATH, 'src/Functions/libclickhouse_ttl_functions.a')
401
+ if sys.platform == 'darwin':
402
+ ch_libs = [f'-Wl,-force_load,{ttl_functions_lib}'] + ch_libs
403
+ else:
404
+ ch_libs = ['-Wl,--whole-archive', ttl_functions_lib, '-Wl,--no-whole-archive'] + ch_libs
405
+
378
406
  if sys.platform == 'linux':
379
407
  ch_libs = ['-Wl,--no-whole-archive'] + ch_libs
380
408
 
@@ -1,7 +1,7 @@
1
1
  from setuptools import setup, Extension
2
2
 
3
3
  NAME = 'tinybird-toolset'
4
- VERSION = '2.4.13'
4
+ VERSION = '2.5.0'
5
5
 
6
6
  try:
7
7
  from conf import *
@@ -20,6 +20,7 @@ try:
20
20
  "functions/ReplaceTables.h",
21
21
  "functions/Tables.h",
22
22
  "functions/TBQueryParser.h",
23
+ "functions/ValidateTTL.h",
23
24
  "functions/Validation.h",
24
25
  "functions/simdjsonHelpers.h",
25
26
  "functions/JSONPathQuery.h",
@@ -20,6 +20,7 @@ from chtoolset._query import replace_tables, \
20
20
  set_row_binary_encoder_thread_buffer_size, \
21
21
  get_columns_from_create_query, \
22
22
  normalize_table_column, \
23
+ validate_ttl, \
23
24
  parse_create_materialized_view_target_table as _parse_create_materialized_view_target_table
24
25
 
25
26
  from typing import Optional, TypedDict
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinybird-toolset
3
- Version: 2.4.13
3
+ Version: 2.5.0
4
4
  Home-page: https://gitlab.com/tinybird/clickhouse-toolset
5
5
  Author: Tinybird.co
6
6
  Author-email: support@tinybird.co
@@ -24,4 +24,5 @@ tests/test_parse_create_materialized_view_target_table.py
24
24
  tests/test_replace_tables.py
25
25
  tests/test_replace_tables_backward_compat.py
26
26
  tests/test_rewrite_aggregation_states.py
27
- tests/test_tables.py
27
+ tests/test_tables.py
28
+ tests/test_validate_ttl.py
@@ -8346,20 +8346,22 @@ TEST_CASES = [
8346
8346
  1, 0),
8347
8347
 
8348
8348
 
8349
- # schemaless JSON encoded as string can choose too-narrow integer types and overflow values
8349
+ # schemaless JSON encoded as string widens too-narrow integer types instead of
8350
+ # overflowing: y=22 first pins the path to Int8, then y=220 widens it to Int16
8351
+ # rather than wrapping to -36 (CH-440, ColumnVector::tryInsert range check)
8350
8352
  (ConversionMode.ONLY_LEGACY,
8351
8353
  [('v', 'Map(String, JSON)', '$', False)],
8352
8354
  '{"a":{"x":11,"y":22},"b":{"x":110,"y":220}}',
8353
- b'\x00\x02\x01a\x0f{"x":11,"y":22}\x01b\x11{"x":110,"y":-36}',
8355
+ b'\x00\x02\x01a\x0f{"x":11,"y":22}\x01b\x11{"x":110,"y":220}',
8354
8356
  b'',
8355
8357
  1, 0),
8356
8358
  (ConversionMode.ONLY_LEGACY,
8357
8359
  [('json', 'Map(String, JSON)', "$", False)],
8358
8360
  '{"a":"{\\"x\\":11,\\"y\\":22}","b":"{\\"x\\":110,\\"y\\":220}"}',
8359
- b'\x00\x02\x01a\x0f{"x":11,"y":22}\x01b\x11{"x":110,"y":-36}',
8361
+ b'\x00\x02\x01a\x0f{"x":11,"y":22}\x01b\x11{"x":110,"y":220}',
8360
8362
  b'',
8361
8363
  1, 0),
8362
- # doesn't happen with binary JSON encoding
8364
+ # binary JSON encoding stores per-value type tags, so it already preserved the value
8363
8365
  (ConversionMode.ONLY_NONLEGACY,
8364
8366
  [('v', 'Map(String, JSON)', '$', False)],
8365
8367
  '{"a":{"x":11,"y":22},"b":{"x":110,"y":220}}',
@@ -0,0 +1,394 @@
1
+ import unittest
2
+ from chtoolset import query as chquery
3
+
4
+
5
+ class TestValidateTTL(unittest.TestCase):
6
+ def test_empty_ttl_is_valid(self):
7
+ self.assertIsNone(
8
+ chquery.validate_ttl(
9
+ ttl="",
10
+ columns=[{"name": "ts", "type": "DateTime"}],
11
+ sorting_key="ts",
12
+ primary_key="ts",
13
+ )
14
+ )
15
+
16
+ def test_valid_no_primary_key_uses_sorting_key(self):
17
+ self.assertIsNone(
18
+ chquery.validate_ttl(
19
+ ttl="ts + INTERVAL 7 DAY",
20
+ columns=[{"name": "ts", "type": "DateTime"}],
21
+ sorting_key="ts",
22
+ )
23
+ )
24
+
25
+ def test_valid_datetime_plus_interval(self):
26
+ self.assertIsNone(
27
+ chquery.validate_ttl(
28
+ ttl="ts + INTERVAL 7 DAY",
29
+ columns=[{"name": "ts", "type": "DateTime"}, {"name": "v", "type": "Int32"}],
30
+ sorting_key="ts",
31
+ primary_key="ts",
32
+ )
33
+ )
34
+
35
+ def test_valid_datetime_plus_to_interval(self):
36
+ self.assertIsNone(
37
+ chquery.validate_ttl(
38
+ ttl="ts + toIntervalDay(7)",
39
+ columns=[{"name": "ts", "type": "DateTime"}],
40
+ sorting_key="ts",
41
+ primary_key="ts",
42
+ )
43
+ )
44
+
45
+ def test_valid_date_column(self):
46
+ self.assertIsNone(
47
+ chquery.validate_ttl(
48
+ ttl="d + INTERVAL 30 DAY",
49
+ columns=[{"name": "d", "type": "Date"}],
50
+ sorting_key="d",
51
+ primary_key="d",
52
+ )
53
+ )
54
+
55
+ def test_valid_wrapped_datetime64(self):
56
+ self.assertIsNone(
57
+ chquery.validate_ttl(
58
+ ttl="toDateTime(event_time) + toIntervalDay(7)",
59
+ columns=[{"name": "event_time", "type": "DateTime64(3)"}],
60
+ sorting_key="event_time",
61
+ primary_key="event_time",
62
+ )
63
+ )
64
+
65
+ def test_invalid_non_date_result_type(self):
66
+ err = chquery.validate_ttl(
67
+ ttl="counter + toIntervalDay(7)",
68
+ columns=[{"name": "counter", "type": "Int32"}],
69
+ sorting_key="counter",
70
+ primary_key="counter",
71
+ )
72
+ self.assertIsNotNone(err)
73
+ self.assertIn("Illegal types", err)
74
+
75
+ def test_valid_datetime_plus_integer_expression(self):
76
+ self.assertIsNone(
77
+ chquery.validate_ttl(
78
+ ttl="ts + 7",
79
+ columns=[{"name": "ts", "type": "DateTime"}],
80
+ sorting_key="ts",
81
+ primary_key="ts",
82
+ )
83
+ )
84
+
85
+ def test_invalid_unwrapped_datetime64(self):
86
+ err = chquery.validate_ttl(
87
+ ttl="event_time + toIntervalDay(7)",
88
+ columns=[{"name": "event_time", "type": "DateTime64(3)"}],
89
+ sorting_key="event_time",
90
+ primary_key="event_time",
91
+ )
92
+ self.assertIsNotNone(err)
93
+ self.assertIn("TTL expression result column should have DateTime or Date type", err)
94
+
95
+ def test_invalid_unknown_column(self):
96
+ err = chquery.validate_ttl(
97
+ ttl="missing + INTERVAL 7 DAY",
98
+ columns=[{"name": "ts", "type": "DateTime"}],
99
+ sorting_key="ts",
100
+ primary_key="ts",
101
+ )
102
+ self.assertIsNotNone(err)
103
+ self.assertIn("Missing columns: 'missing'", err)
104
+
105
+ def test_invalid_malformed_ttl(self):
106
+ err = chquery.validate_ttl(
107
+ ttl="this is not a valid ttl !!!",
108
+ columns=[{"name": "ts", "type": "DateTime"}],
109
+ sorting_key="ts",
110
+ primary_key="ts",
111
+ )
112
+ self.assertIsNotNone(err)
113
+ self.assertIn("Syntax error", err)
114
+
115
+ # --- Date/time functions supported by ClickHouse in TTL expressions ---
116
+ def test_valid_from_unix_timestamp(self):
117
+ self.assertIsNone(
118
+ chquery.validate_ttl(
119
+ ttl="fromUnixTimestamp(uts) + INTERVAL 7 DAY",
120
+ columns=[{"name": "uts", "type": "UInt32"}],
121
+ sorting_key="uts",
122
+ primary_key="uts",
123
+ )
124
+ )
125
+
126
+ def test_valid_from_unix_timestamp64_milli(self):
127
+ self.assertIsNone(
128
+ chquery.validate_ttl(
129
+ ttl="toDateTime(fromUnixTimestamp64Milli(uts)) + INTERVAL 7 DAY",
130
+ columns=[{"name": "uts", "type": "Int64"}],
131
+ sorting_key="uts",
132
+ primary_key="uts",
133
+ )
134
+ )
135
+
136
+ def test_valid_from_unix_timestamp64_micro_and_nano(self):
137
+ for fn in ("fromUnixTimestamp64Micro", "fromUnixTimestamp64Nano"):
138
+ with self.subTest(fn=fn):
139
+ self.assertIsNone(
140
+ chquery.validate_ttl(
141
+ ttl=f"toDateTime({fn}(uts)) + INTERVAL 1 DAY",
142
+ columns=[{"name": "uts", "type": "Int64"}],
143
+ sorting_key="uts",
144
+ primary_key="uts",
145
+ )
146
+ )
147
+
148
+ def test_valid_to_start_of_functions(self):
149
+ for fn in (
150
+ "toStartOfDay",
151
+ "toStartOfHour",
152
+ "toStartOfMinute",
153
+ "toStartOfFifteenMinutes",
154
+ "toStartOfMonth",
155
+ "toStartOfQuarter",
156
+ "toStartOfYear",
157
+ "toStartOfWeek",
158
+ "toMonday",
159
+ "toLastDayOfMonth",
160
+ ):
161
+ with self.subTest(fn=fn):
162
+ self.assertIsNone(
163
+ chquery.validate_ttl(
164
+ ttl=f"{fn}(ts) + INTERVAL 30 DAY",
165
+ columns=[{"name": "ts", "type": "DateTime"}],
166
+ sorting_key="ts",
167
+ primary_key="ts",
168
+ )
169
+ )
170
+
171
+ def test_valid_to_start_of_interval(self):
172
+ self.assertIsNone(
173
+ chquery.validate_ttl(
174
+ ttl="toStartOfInterval(ts, INTERVAL 1 DAY) + INTERVAL 90 DAY",
175
+ columns=[{"name": "ts", "type": "DateTime"}],
176
+ sorting_key="ts",
177
+ primary_key="ts",
178
+ )
179
+ )
180
+
181
+ def test_valid_date_trunc(self):
182
+ self.assertIsNone(
183
+ chquery.validate_ttl(
184
+ ttl="dateTrunc('month', ts) + INTERVAL 6 MONTH",
185
+ columns=[{"name": "ts", "type": "DateTime"}],
186
+ sorting_key="ts",
187
+ primary_key="ts",
188
+ )
189
+ )
190
+
191
+ def test_valid_to_timezone(self):
192
+ self.assertIsNone(
193
+ chquery.validate_ttl(
194
+ ttl="toTimezone(ts, 'UTC') + INTERVAL 7 DAY",
195
+ columns=[{"name": "ts", "type": "DateTime"}],
196
+ sorting_key="ts",
197
+ primary_key="ts",
198
+ )
199
+ )
200
+
201
+ def test_valid_make_date(self):
202
+ self.assertIsNone(
203
+ chquery.validate_ttl(
204
+ ttl="makeDate(y, m, d) + INTERVAL 7 DAY",
205
+ columns=[
206
+ {"name": "y", "type": "UInt16"},
207
+ {"name": "m", "type": "UInt8"},
208
+ {"name": "d", "type": "UInt8"},
209
+ ],
210
+ sorting_key="y",
211
+ primary_key="y",
212
+ )
213
+ )
214
+
215
+ # --- Conditional / multi-clause TTLs ---
216
+ def test_valid_ttl_with_where(self):
217
+ self.assertIsNone(
218
+ chquery.validate_ttl(
219
+ ttl="ts + INTERVAL 7 DAY WHERE status = 'expired'",
220
+ columns=[
221
+ {"name": "ts", "type": "DateTime"},
222
+ {"name": "status", "type": "String"},
223
+ ],
224
+ sorting_key="ts",
225
+ primary_key="ts",
226
+ )
227
+ )
228
+
229
+ def test_valid_ttl_with_where_numeric(self):
230
+ self.assertIsNone(
231
+ chquery.validate_ttl(
232
+ ttl="ts + INTERVAL 30 DAY WHERE x % 10 == 0 AND y > 5",
233
+ columns=[
234
+ {"name": "ts", "type": "DateTime"},
235
+ {"name": "x", "type": "Int32"},
236
+ {"name": "y", "type": "Int32"},
237
+ ],
238
+ sorting_key="ts",
239
+ primary_key="ts",
240
+ )
241
+ )
242
+
243
+ def test_invalid_ttl_where_unknown_column(self):
244
+ err = chquery.validate_ttl(
245
+ ttl="ts + INTERVAL 7 DAY WHERE missing = 1",
246
+ columns=[{"name": "ts", "type": "DateTime"}],
247
+ sorting_key="ts",
248
+ primary_key="ts",
249
+ )
250
+ self.assertIsNotNone(err)
251
+ self.assertIn("Missing columns: 'missing'", err)
252
+
253
+ def test_valid_ttl_where_string_expression(self):
254
+ self.assertIsNone(
255
+ chquery.validate_ttl(
256
+ ttl="ts + INTERVAL 7 DAY WHERE status",
257
+ columns=[
258
+ {"name": "ts", "type": "DateTime"},
259
+ {"name": "status", "type": "String"},
260
+ ],
261
+ sorting_key="ts",
262
+ primary_key="ts",
263
+ )
264
+ )
265
+
266
+ def test_valid_multiple_ttls_comma_separated(self):
267
+ self.assertIsNone(
268
+ chquery.validate_ttl(
269
+ ttl="ts + INTERVAL 7 DAY WHERE status = 'a', ts + INTERVAL 14 DAY WHERE status = 'b'",
270
+ columns=[
271
+ {"name": "ts", "type": "DateTime"},
272
+ {"name": "status", "type": "String"},
273
+ ],
274
+ sorting_key="ts",
275
+ primary_key="ts",
276
+ )
277
+ )
278
+
279
+ def test_valid_ttl_group_by(self):
280
+ # GROUP BY rollup; must group by a prefix of the primary key
281
+ self.assertIsNone(
282
+ chquery.validate_ttl(
283
+ ttl="ts + INTERVAL 1 DAY GROUP BY k SET v = sum(v)",
284
+ columns=[
285
+ {"name": "k", "type": "String"},
286
+ {"name": "ts", "type": "DateTime"},
287
+ {"name": "v", "type": "Int64"},
288
+ ],
289
+ sorting_key="k",
290
+ primary_key="k",
291
+ )
292
+ )
293
+
294
+ def test_invalid_ttl_group_by_not_in_primary_key(self):
295
+ err = chquery.validate_ttl(
296
+ ttl="ts + INTERVAL 1 DAY GROUP BY missing SET v = sum(v)",
297
+ columns=[
298
+ {"name": "k", "type": "String"},
299
+ {"name": "ts", "type": "DateTime"},
300
+ {"name": "v", "type": "Int64"},
301
+ ],
302
+ sorting_key="k",
303
+ primary_key="k",
304
+ )
305
+ self.assertIsNotNone(err)
306
+ self.assertIn("GROUP BY key should be a prefix of primary key", err)
307
+
308
+ def test_valid_where_string_functions(self):
309
+ columns = [
310
+ {"name": "ts", "type": "DateTime"},
311
+ {"name": "s", "type": "String"},
312
+ {"name": "n", "type": "Int32"},
313
+ ]
314
+ predicates = {
315
+ "concat": "concat(s, 'x') = 'ax'",
316
+ "startsWith": "startsWith(s, 'a')",
317
+ "endsWith": "endsWith(s, 'z')",
318
+ "length": "length(s) > 3",
319
+ "lower": "lower(s) = 'a'",
320
+ "substring": "substring(s, 1, 2) = 'ab'",
321
+ "match": "match(s, '^a')",
322
+ "in": "n IN (1, 2, 3)",
323
+ }
324
+ for name, predicate in predicates.items():
325
+ with self.subTest(fn=name):
326
+ self.assertIsNone(
327
+ chquery.validate_ttl(
328
+ ttl=f"ts + INTERVAL 7 DAY WHERE {predicate}",
329
+ columns=columns,
330
+ sorting_key="ts",
331
+ primary_key="ts",
332
+ ),
333
+ f"{name} should be a known function in TTL WHERE clauses",
334
+ )
335
+
336
+ # --- Input validation ---
337
+ def test_columns_must_be_list_of_dicts(self):
338
+ with self.assertRaises(TypeError):
339
+ chquery.validate_ttl(ttl="ts + INTERVAL 7 DAY", columns="ts DateTime", sorting_key="ts", primary_key="ts")
340
+
341
+ def test_column_dict_requires_name_and_type(self):
342
+ with self.assertRaises((TypeError, ValueError)):
343
+ chquery.validate_ttl(
344
+ ttl="ts + INTERVAL 7 DAY",
345
+ columns=[{"name": "ts"}],
346
+ sorting_key="ts",
347
+ primary_key="ts",
348
+ )
349
+
350
+
351
+ # --- Regression guard: sequential throwing calls must not crash ---
352
+ def test_sequential_throwing_calls_do_not_crash(self):
353
+ # A validate_ttl call that throws must not corrupt shared state and take
354
+ # down a subsequent call. validateTTL parses against a private per-call
355
+ # context copy (functions/ValidateTTL.cpp) precisely so this holds.
356
+ # Run in a subprocess so a hypothetical crash doesn't kill pytest; the
357
+ # child must exit 0.
358
+ import signal
359
+ import subprocess
360
+ import sys
361
+ import textwrap
362
+ repro = textwrap.dedent("""\
363
+ from chtoolset import query as q
364
+ err1 = q.validate_ttl(
365
+ ttl="counter + toIntervalDay(7)",
366
+ columns=[{"name": "counter", "type": "Int32"}],
367
+ sorting_key="counter",
368
+ primary_key="counter",
369
+ )
370
+ assert err1 is not None and "Illegal types" in err1, (
371
+ f"call 1 should return an 'Illegal types' error, got: {err1!r}")
372
+ q.validate_ttl(
373
+ ttl="ts + INTERVAL 7 DAY WHERE missing = 1",
374
+ columns=[{"name": "ts", "type": "DateTime"}],
375
+ sorting_key="ts",
376
+ primary_key="ts",
377
+ )
378
+ """)
379
+ result = subprocess.run([sys.executable, "-c", repro],
380
+ capture_output=True, text=True, timeout=30)
381
+ if result.returncode != 0 and result.returncode != -signal.SIGABRT:
382
+ raise RuntimeError(
383
+ f"Unexpected subprocess exit {result.returncode} "
384
+ f"(expected 0 or SIGABRT {-signal.SIGABRT})."
385
+ f"\nSTDERR:\n{result.stderr}"
386
+ f"\nSTDOUT:\n{result.stdout}")
387
+ self.assertEqual(result.returncode, 0,
388
+ f"Subprocess crashed with SIGABRT."
389
+ f"\nSTDERR:\n{result.stderr}"
390
+ f"\nSTDOUT:\n{result.stdout}")
391
+
392
+
393
+ if __name__ == "__main__":
394
+ unittest.main()