tinybird-cli 6.5.7.dev0__py3-none-any.whl → 7.0.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.
tinybird/sql_template.py CHANGED
@@ -1,5 +1,4 @@
1
1
  import ast
2
- import builtins
3
2
  import linecache
4
3
  import logging
5
4
  import re
@@ -18,7 +17,7 @@ from tinybird.context import (
18
17
  )
19
18
 
20
19
  from .datatypes import testers
21
- from .tornado_template import VALID_CUSTOM_FUNCTION_NAMES, SecurityException, Template
20
+ from .tornado_template import SAFE_BUILTINS, VALID_CUSTOM_FUNCTION_NAMES, SecurityException, Template
22
21
 
23
22
  VALID_ACTIVATE_FEATURES = frozenset(["analyzer", "parallel_replicas", "optimize_aggregation_in_order"])
24
23
 
@@ -84,6 +83,7 @@ class SQLTemplateException(ValueError):
84
83
  JOB_TIMESTAMP_PARAM = "job_timestamp"
85
84
  DEFAULT_PARAM_NAMES = ["format", "q"]
86
85
  RESERVED_PARAM_NAMES = [
86
+ "__tb__csv_header",
87
87
  "__tb__semver",
88
88
  "debug_source_tables",
89
89
  "debug",
@@ -142,8 +142,7 @@ def transform_type(
142
142
  if x is None:
143
143
  if defined:
144
144
  raise SQLTemplateException(REQUIRED_PARAM_NOT_DEFINED, documentation="/cli/advanced-templates.html")
145
- else:
146
- return None
145
+ return None
147
146
  if tester == "String":
148
147
  if x is not None:
149
148
  return transform(x)
@@ -167,12 +166,11 @@ def _and(*args, **kwargs):
167
166
  tk = k.rsplit("__", 1)
168
167
  if len(tk) == 1:
169
168
  return "="
170
- else:
171
- if tk[1] in operands:
172
- return operands[tk[1]]
173
- raise SQLTemplateException(
174
- f"operand {tk[1]} not supported", documentation="/cli/advanced-templates.html#sql_and"
175
- )
169
+ if tk[1] in operands:
170
+ return operands[tk[1]]
171
+ raise SQLTemplateException(
172
+ f"operand {tk[1]} not supported", documentation="/cli/advanced-templates.html#sql_and"
173
+ )
176
174
 
177
175
  return Expression(
178
176
  " and ".join([f"{_name(k)} {_op(k)} {expression_wrapper(v, k)}" for k, v in kwargs.items() if v is not None])
@@ -277,8 +275,7 @@ def columns(x, default=None, fn=None):
277
275
 
278
276
  if fn:
279
277
  return Expression(",".join(f"{fn}({str(column(c, c))}) as {c}" for c in _columns))
280
- else:
281
- return Expression(",".join(str(column(c, c)) for c in _columns))
278
+ return Expression(",".join(str(column(c, c)) for c in _columns))
282
279
 
283
280
 
284
281
  def column(x, default=None):
@@ -368,11 +365,10 @@ def boolean(x, default=None):
368
365
  if default is None:
369
366
  return 0
370
367
  return boolean(default)
371
- elif isinstance(x, Placeholder):
368
+ if isinstance(x, Placeholder):
372
369
  return boolean(default)
373
- elif isinstance(x, str):
374
- if x == "0" or x.lower() == "false":
375
- return 0
370
+ if isinstance(x, str) and (x == "0" or x.lower() == "false"):
371
+ return 0
376
372
 
377
373
  return int(bool(x))
378
374
 
@@ -403,8 +399,7 @@ def array_type(types):
403
399
  raise SQLTemplateException(
404
400
  REQUIRED_PARAM_NOT_DEFINED, documentation="/cli/advanced-templates.html"
405
401
  )
406
- else:
407
- return None
402
+ return None
408
403
  values = []
409
404
  list_values = x if type(x) == list else x.split(",") # noqa: E721
410
405
  for i, t in enumerate(list_values):
@@ -428,22 +423,29 @@ def array_type(types):
428
423
  return _f
429
424
 
430
425
 
431
- def sql_unescape(x, what=""):
426
+ def sql_unescape(x: str, what: str = "") -> Expression:
432
427
  """
433
428
  unescapes specific characters in a string. It allows to allow some
434
429
  special characters to be used, for example in like condictionals
435
430
 
436
431
  {{sql_unescape(String(like_filter), '%')}}
437
432
 
433
+ `what` must be given and must not contain quotes or backslashes: unescaping
434
+ those would let the caller close the string literal, so they stay escaped.
438
435
 
439
436
  >>> sql_unescape('testing%', '%')
440
437
  "'testing%'"
441
438
  >>> sql_unescape('testing%', '$')
442
439
  "'testing\\\\%'"
443
440
  >>> sql_unescape('testing"')
444
- '\\'testing"\\''
441
+ '\\'testing\\\\"\\''
442
+ >>> sql_unescape("x' OR 1=1", "'")
443
+ "'x\\\\' OR 1=1'"
445
444
  """
446
- return Expression("'" + sqlescape_for_string_expression(x).replace(f"\\{what}", what) + "'")
445
+ escaped = sqlescape_for_string_expression(x)
446
+ if what and not any(c in what for c in "'\"\\"):
447
+ escaped = escaped.replace(f"\\{what}", what)
448
+ return Expression("'" + escaped + "'")
447
449
 
448
450
 
449
451
  def split_to_array(x, default="", separator: str = ","):
@@ -744,12 +746,11 @@ def __date_diff(
744
746
 
745
747
  if unit == "days":
746
748
  return int(diff / 86400)
747
- elif unit == "hours":
749
+ if unit == "hours":
748
750
  return int(diff / 3600)
749
- elif unit == "minutes":
751
+ if unit == "minutes":
750
752
  return int(diff / 60)
751
- else:
752
- return int(diff)
753
+ return int(diff)
753
754
  except Exception:
754
755
  if none_if_error:
755
756
  return None
@@ -1341,16 +1342,18 @@ sqlescape_for_string_expression = sqlescape_generator(
1341
1342
  )
1342
1343
 
1343
1344
 
1344
- def escape_single_quote_str(s):
1345
- return "'" + s.replace("'", "''") + "'"
1345
+ def escape_single_quote_str(s: str) -> str:
1346
+ # ClickHouse string literals treat backslash as an escape char, so it must be doubled too:
1347
+ # otherwise a trailing backslash escapes the closing quote and the following input becomes SQL
1348
+ return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
1346
1349
 
1347
1350
 
1348
1351
  def expression_wrapper(x, name, escape_arrays: bool = False):
1349
1352
  if type(x) in (unicode_type, bytes, str):
1350
1353
  return "'" + sqlescape_for_string_expression(x) + "'"
1351
- elif isinstance(x, Placeholder):
1354
+ if isinstance(x, Placeholder):
1352
1355
  return "'__no_value__'"
1353
- elif isinstance(x, Comment):
1356
+ if isinstance(x, Comment):
1354
1357
  return "-- {x} \n"
1355
1358
  if x is None:
1356
1359
  truncated_name = name[:20] + "..." if len(name) > 20 else name
@@ -1361,10 +1364,7 @@ def expression_wrapper(x, name, escape_arrays: bool = False):
1361
1364
  logging.warning(f"expression_wrapper -> list :{x}:")
1362
1365
 
1363
1366
  try:
1364
- result = (
1365
- f"[{','.join(escape_single_quote_str(item) if isinstance(item, str) else str(item) for item in x)}]"
1366
- )
1367
- return result
1367
+ return f"[{','.join(escape_single_quote_str(item) if isinstance(item, str) else str(item) for item in x)}]"
1368
1368
  except Exception as e:
1369
1369
  logging.error(f"Error escaping array: {e}")
1370
1370
  return x
@@ -1383,7 +1383,7 @@ _namespace = {
1383
1383
  # they raise a pretty non understandable error but if someone
1384
1384
  # is using them they know what they are trying to do
1385
1385
  # read https://anee.me/escaping-python-jails-849c65cf306e on how to escape from python jails
1386
- "__buildins__": {},
1386
+ "__builtins__": SAFE_BUILTINS,
1387
1387
  "__import__": {},
1388
1388
  "__debug__": {},
1389
1389
  "__doc__": {},
@@ -1396,10 +1396,13 @@ _namespace = {
1396
1396
  }
1397
1397
 
1398
1398
 
1399
- reserved_vars = {"_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *list(vars(builtins))}
1399
+ # Built from the builtins templates can actually resolve: names missing from SAFE_BUILTINS (`type`, `dir`,
1400
+ # `property`, `object`, ...) can only be template parameters, so they must be discovered as such to get a
1401
+ # value (None or a test-mode placeholder) instead of raising NameError.
1402
+ reserved_vars = {"_tt_tmp", "_tt_append", "isinstance", "str", "error", "custom_error", *SAFE_BUILTINS}
1400
1403
  for p in DEFAULT_PARAM_NAMES: # we handle these in an specific manner
1401
1404
  reserved_vars.discard(p) # `format` is part of builtins
1402
- # Allow 'id' to be used as a template parameter - https://gitlab.com/tinybird/analytics/-/issues/19119
1405
+ # Allow 'id' as a template parameter name despite being a builtin - https://gitlab.com/tinybird/analytics/-/issues/19119
1403
1406
  reserved_vars.discard("id")
1404
1407
  error_vars = ["error", "custom_error"]
1405
1408
 
@@ -1425,20 +1428,18 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1425
1428
  # secret available: Always use workspace secret regardless of test mode
1426
1429
  template_execution_results.add_ch_param(x)
1427
1430
  return Symbol("{" + sqlescape(x) + ": String}")
1428
- else:
1429
- # secret not available: Check test mode and defaults
1430
- is_test_mode = TB_SECRET_IN_TEST_MODE in template_execution_results
1431
- if default is not None:
1432
- # Use provided default value
1433
- return default
1434
- elif is_test_mode:
1435
- # In test mode without default - return placeholder
1436
- return Symbol("{" + sqlescape(x) + ": String}")
1437
- else:
1438
- # Not in test mode, no secret, no default - raise error
1439
- raise SQLTemplateException(
1440
- f"Cannot access secret '{x}'. Check the secret exists in the Workspace and the token has the required scope."
1441
- )
1431
+ # secret not available: Check test mode and defaults
1432
+ is_test_mode = TB_SECRET_IN_TEST_MODE in template_execution_results
1433
+ if default is not None:
1434
+ # Use provided default value
1435
+ return default
1436
+ if is_test_mode:
1437
+ # In test mode without default - return placeholder
1438
+ return Symbol("{" + sqlescape(x) + ": String}")
1439
+ # Not in test mode, no secret, no default - raise error
1440
+ raise SQLTemplateException(
1441
+ f"Cannot access secret '{x}'. Check the secret exists in the Workspace and the token has the required scope."
1442
+ )
1442
1443
  except Exception:
1443
1444
  raise SQLTemplateException(
1444
1445
  f"Cannot access secret '{x}'. Check the secret exists in the Workspace and the token has the required scope."
@@ -1478,8 +1479,7 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1478
1479
  return Expression(f"-- disable {feature}\n")
1479
1480
 
1480
1481
  namespace.update(_namespace)
1481
- # This is to fix the issue https://gitlab.com/tinybird/analytics/-/issues/19119
1482
- # We need to do the override here because if we modify the _namespace, we would filter out parameters from the users
1482
+ # Default it here, not in _namespace, so it stays visible to variable extraction.
1483
1483
  namespace.update({"id": None})
1484
1484
  namespace.update(kwargs)
1485
1485
  namespace.update(
@@ -1488,6 +1488,8 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1488
1488
  # the generated source code.
1489
1489
  "__name__": self.name.replace(".", "_"),
1490
1490
  "__loader__": ObjectDict(get_source=lambda name: self.code),
1491
+ # Set again after kwargs so request parameters can't override it
1492
+ "__builtins__": SAFE_BUILTINS,
1491
1493
  "max_threads": set_max_threads,
1492
1494
  "tb_secret": set_tb_secret,
1493
1495
  "tb_var": set_tb_secret,
@@ -1522,14 +1524,12 @@ def generate(self, **kwargs) -> Tuple[str, TemplateExecutionResults]:
1522
1524
 
1523
1525
  if line:
1524
1526
  raise SQLTemplateException(f"{message.strip()} line {line[0]}")
1525
- else:
1526
- raise SQLTemplateException(f"{message.strip()}")
1527
+ raise SQLTemplateException(f"{message.strip()}")
1527
1528
  except Exception as e:
1528
1529
  if isinstance(e, SQLTemplateException):
1529
1530
  raise e
1530
- else:
1531
- logging.exception(f"Error on unbound local error: {e}")
1532
- raise ValueError(str(e))
1531
+ logging.exception(f"Error on unbound local error: {e}")
1532
+ raise ValueError(str(e))
1533
1533
  except TypeError as e:
1534
1534
  error = str(e)
1535
1535
  if "not supported between instances of 'Placeholder' and " in str(e):
@@ -1829,7 +1829,7 @@ def get_var_names(t: Template):
1829
1829
  >>> [v['name'] for v in get_var_names(Template("{% for name in secret_names %}{{tb_secret(name)}}{% end %}"))]
1830
1830
  ['secret_names', 'name', 'tb_secret', 'name']
1831
1831
 
1832
- Type casting with date function in elif chain (type is in reserved_vars):
1832
+ Type casting with date function in elif chain:
1833
1833
  >>> t = Template("{% if kind == 'int' %}{{Int32(val, 0)}}{% elif kind == 'date' %}{{DateTime(val)}}{% elif kind == 'diff' %}{{date_diff_in_days(val, now)}}{% end %}")
1834
1834
  >>> [v['name'] for v in get_var_names(t)]
1835
1835
  ['kind', 'Int32', 'val', 'kind', 'DateTime', 'val', 'kind', 'date_diff_in_days', 'val', 'now']
@@ -1960,29 +1960,27 @@ def get_var_data(content, node_id=None):
1960
1960
  # ast.Constant nodes, so .value holds str/bytes/int/float/bool/None.
1961
1961
  if type(x) == ast.Constant: # noqa: E721
1962
1962
  return x.value
1963
- elif type(x) == ast.Name: # noqa: E721
1963
+ if type(x) == ast.Name: # noqa: E721
1964
1964
  return x.id
1965
- elif type(x) == ast.List: # noqa: E721
1965
+ if type(x) == ast.List: # noqa: E721
1966
1966
  # List can hold different types
1967
1967
  return _get_list_var_data(x)
1968
- elif type(x) == ast.BinOp: # noqa: E721
1968
+ if type(x) == ast.BinOp: # noqa: E721
1969
1969
  # in this case there could be several variables
1970
1970
  # if that's the case the left one is the main
1971
1971
  r = node_to_value(x.left)
1972
1972
  if not r:
1973
1973
  r = node_to_value(x.right)
1974
1974
  return r
1975
- elif type(x) == ast.UnaryOp and type(x.operand) == ast.Constant: # noqa: E721
1975
+ if type(x) == ast.UnaryOp and type(x.operand) == ast.Constant: # noqa: E721
1976
1976
  if type(x.op) == ast.USub: # noqa: E721
1977
1977
  return x.operand.value * -1
1978
- else:
1979
- return x.operand.value
1980
- else:
1981
- try:
1982
- return x.id
1983
- except Exception:
1984
- # don't let this ruin the parsing
1985
- pass
1978
+ return x.operand.value
1979
+ try:
1980
+ return x.id
1981
+ except Exception:
1982
+ # don't let this ruin the parsing
1983
+ pass
1986
1984
  return None
1987
1985
 
1988
1986
  def _get_list_var_data(x):
@@ -1994,7 +1992,7 @@ def get_var_data(content, node_id=None):
1994
1992
  # ast.Bytes/ast.Str/ast.Num/ast.NameConstant (removed in 3.14).
1995
1993
  if type(first_elem) == ast.Constant: # noqa: E721
1996
1994
  return [elem.value for elem in x.elts]
1997
- elif type(first_elem) == ast.Name: # noqa: E721
1995
+ if type(first_elem) == ast.Name: # noqa: E721
1998
1996
  return [elem.id for elem in x.elts]
1999
1997
 
2000
1998
  return []
@@ -2013,8 +2011,7 @@ def get_var_data(content, node_id=None):
2013
2011
  # to just `ast.parse(content)` once we confirm all environments use patched Python versions.
2014
2012
  def parse_content(content, retries=0):
2015
2013
  try:
2016
- parsed = ast.parse(content)
2017
- return parsed
2014
+ return ast.parse(content)
2018
2015
  except Exception as e:
2019
2016
  if "AST constructor recursion depth mismatch" not in str(e):
2020
2017
  raise e
@@ -2042,8 +2039,7 @@ def get_var_data(content, node_id=None):
2042
2039
  if type(x) == ast.Call: # noqa: E721
2043
2040
  # Nested calls are traversed via `ast.iter_child_nodes` below.
2044
2041
  continue
2045
- else:
2046
- args.append(node_to_value(x))
2042
+ args.append(node_to_value(x))
2047
2043
 
2048
2044
  kwargs = {}
2049
2045
  for x in node.keywords:
@@ -2385,7 +2381,24 @@ def wrap_vars(t, escape_arrays: bool = False):
2385
2381
  return var
2386
2382
 
2387
2383
 
2388
- def get_used_tables_in_template(sql):
2384
+ def _constant_table_name(node: ast.expr) -> Optional[str]:
2385
+ """Value of a constant table() argument: a literal or a concatenation of string literals."""
2386
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
2387
+ return str(node.value)
2388
+ return _string_literal(node)
2389
+
2390
+
2391
+ def _string_literal(node: ast.expr) -> Optional[str]:
2392
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
2393
+ return node.value
2394
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
2395
+ left, right = _string_literal(node.left), _string_literal(node.right)
2396
+ if left is not None and right is not None:
2397
+ return left + right
2398
+ return None
2399
+
2400
+
2401
+ def get_used_tables_in_template(sql: str) -> List[str]:
2389
2402
  """
2390
2403
  >>> get_used_tables_in_template("select * from {{table('test')}}")
2391
2404
  ['test']
@@ -2399,30 +2412,42 @@ def get_used_tables_in_template(sql):
2399
2412
  []
2400
2413
  >>> get_used_tables_in_template("select * from {{table('my.test')}}, {{table('another.one')}}")
2401
2414
  ['my.test', 'another.one']
2415
+
2416
+ Only constant table names can be known without rendering, so anything else is skipped
2417
+ >>> get_used_tables_in_template("select * from {{table(tbl)}}, {{TABLE('b')}}, {{table('my.' + 'c')}}")
2418
+ ['b', 'my.c']
2402
2419
  """
2420
+ return list(_get_used_tables_in_template_cached(sql))
2421
+
2422
+
2423
+ # Building the Template dominates the cost and the result only depends on the SQL, which deployments and
2424
+ # /v0/sql pipelines scan repeatedly
2425
+ @lru_cache(maxsize=2**10)
2426
+ def _get_used_tables_in_template_cached(sql: str) -> Tuple[str, ...]:
2403
2427
  try:
2404
2428
  t = Template(sql)
2405
2429
 
2406
2430
  def _n(chunks, tables):
2407
2431
  for x in chunks:
2408
2432
  if type(x).__name__ == "_Expression":
2409
- c = compile(x.expression, "<string>", "exec", dont_inherit=True)
2410
- v = [x.lower() for x in c.co_names if x not in _namespace and x not in reserved_vars]
2411
- if "table" in v:
2412
-
2413
- def _t(*args, **kwargs):
2414
- return str(args[0])
2415
-
2416
- n = {"table": _t, "TABLE": _t}
2417
- e = "_tt_tmp = %s" % x.expression
2418
- exec_in(e, n)
2419
- tables += [n["_tt_tmp"]]
2433
+ # Read the names from the AST instead of executing the expression: this runs outside
2434
+ # generate(), without the restricted builtins, on every pipe, query and deployment.
2435
+ for node in ast.walk(ast.parse(x.expression)):
2436
+ if (
2437
+ isinstance(node, ast.Call)
2438
+ and isinstance(node.func, ast.Name)
2439
+ and node.func.id in ("table", "TABLE")
2440
+ and node.args
2441
+ ):
2442
+ name = _constant_table_name(node.args[0])
2443
+ if name is not None:
2444
+ tables.append(name)
2420
2445
  elif type(x).__name__ == "_ControlBlock":
2421
2446
  _n(x.body.chunks, tables)
2422
2447
 
2423
- tables = []
2448
+ tables: List[str] = []
2424
2449
  _n(t.file.body.chunks, tables)
2425
- return tables
2450
+ return tuple(tables)
2426
2451
  except SecurityException as e:
2427
2452
  raise SQLTemplateException(e)
2428
2453
 
@@ -2531,8 +2556,7 @@ def format_SQLTemplateException_message(e: SQLTemplateException, vars_and_types:
2531
2556
  f"{REQUIRED_PARAM_NOT_DEFINED}. Check the parameters {join_with_different_last_separator(vars_with_default_none)}. Please provide a value or set a default value in the pipe code.",
2532
2557
  e.documentation,
2533
2558
  )
2534
- else:
2535
- raise e
2559
+ raise e
2536
2560
 
2537
2561
 
2538
2562
  def render_sql_template(
@@ -2913,12 +2937,12 @@ def render_sql_template(
2913
2937
  # https://gitlab.com/tinybird/analytics/-/issues/943
2914
2938
  if "length" in v and not v["length"]:
2915
2939
  raise SQLTemplateException("length cannot be used as a variable name or as a function inside of a template")
2916
- elif "missing 1 required positional argument" in str(e):
2940
+ if "missing 1 required positional argument" in str(e):
2917
2941
  raise SQLTemplateException(
2918
2942
  "one of the transform type functions is missing an argument",
2919
2943
  documentation="/cli/advanced-templates.html#transform-types-functions",
2920
2944
  )
2921
- elif "not callable" in str(e) or "unhashable type" in str(e):
2945
+ if "not callable" in str(e) or "unhashable type" in str(e):
2922
2946
  raise SQLTemplateException(
2923
2947
  "wrong syntax, you might be using a not valid function inside a control block",
2924
2948
  documentation="/cli/advanced-templates.html",
@@ -3063,7 +3087,7 @@ def render_template_with_secrets(
3063
3087
  )
3064
3088
  return '""'
3065
3089
  return value
3066
- elif default is not None:
3090
+ if default is not None:
3067
3091
  if isinstance(default, str) and len(default) == 0:
3068
3092
  if empty_secret_raises:
3069
3093
  raise SQLTemplateException(
@@ -3071,10 +3095,9 @@ def render_template_with_secrets(
3071
3095
  )
3072
3096
  return '""'
3073
3097
  return default
3074
- else:
3075
- raise SQLTemplateException(
3076
- f"Cannot access secret '{secret_name}'. Check the secret exists in the Workspace and the token has the required scope."
3077
- )
3098
+ raise SQLTemplateException(
3099
+ f"Cannot access secret '{secret_name}'. Check the secret exists in the Workspace and the token has the required scope."
3100
+ )
3078
3101
 
3079
3102
  # Create the template
3080
3103
  t = Template(content, name=name, autoescape=None)
@@ -33,8 +33,7 @@ class TBLine(Line):
33
33
  Tinybird => This is overriden from the base Line because we want SQL inside a template to be indented
34
34
  https://github.com/tconbeer/sqlfmt/blob/c11775b92d8a45f0e91d871b81a88a894d620bec/src/sqlfmt/line.py#L92
35
35
  """
36
- prefix = INDENT * (self.depth[0] + self.depth[1])
37
- return prefix
36
+ return INDENT * (self.depth[0] + self.depth[1])
38
37
 
39
38
 
40
39
  def from_nodes(
@@ -106,8 +105,7 @@ def _format_jinja_node(self, node: Node, max_length: int) -> bool:
106
105
 
107
106
  return tag.is_blackened
108
107
 
109
- else:
110
- return False
108
+ return False
111
109
 
112
110
 
113
111
  # Some monkey patching
@@ -158,8 +156,7 @@ class TinybirdNodeManager(NodeManager):
158
156
  TokenType.SET_OPERATOR,
159
157
  ):
160
158
  return " ".join(token.token.split())
161
- else:
162
- return token.token
159
+ return token.token
163
160
 
164
161
 
165
162
  class TinybirdDialect(ClickHouse):
@@ -217,8 +214,8 @@ class TinybirdDialect(ClickHouse):
217
214
  ],
218
215
  }
219
216
 
220
- for section in override_rules:
221
- for rule in override_rules[section]:
217
+ for section, rules in override_rules.items():
218
+ for rule in rules:
222
219
  for rr in self.RULES[section]:
223
220
  if rr.name == rule.name:
224
221
  self.RULES[section].remove(rr)
@@ -230,12 +227,11 @@ class TinybirdDialect(ClickHouse):
230
227
  Creates and returns an analyzer that uses the Dialect's rules for lexing
231
228
  Custom NodeManager for Tinybird
232
229
  """
233
- analyzer = Analyzer(
230
+ return Analyzer(
234
231
  line_length=line_length,
235
232
  rules=self.get_rules(),
236
233
  node_manager=TinybirdNodeManager(self.case_sensitive_names, self.lower_keywords),
237
234
  )
238
- return analyzer
239
235
 
240
236
 
241
237
  @dataclass
@@ -253,11 +249,10 @@ class TBMode(Mode):
253
249
  def _calc_str(self) -> str:
254
250
  if self.is_multiline:
255
251
  return self.token.token + "\n"
256
- else:
257
- marker, comment_text = self._comment_parts()
258
- if comment_text == "":
259
- return marker + "\n"
260
- return marker + " " + comment_text + "\n"
252
+ marker, comment_text = self._comment_parts()
253
+ if comment_text == "":
254
+ return marker + "\n"
255
+ return marker + " " + comment_text + "\n"
261
256
 
262
257
 
263
258
  Comment._calc_str = property(_calc_str)
tinybird/sql_toolset.py CHANGED
@@ -314,9 +314,9 @@ def _sql_get_used_tables_impl(
314
314
  msg = str(e)
315
315
  if "is restricted. Contact support@tinybird.co" in msg:
316
316
  raise InvalidFunction(msg=msg) from e
317
- elif "Unknown function tb_secret" in msg:
317
+ if "Unknown function tb_secret" in msg:
318
318
  raise InvalidFunction(msg="Unknown function tb_secret. Usage: {{tb_secret('secret_name')}}") from e
319
- elif "Unknown function tb_var" in msg:
319
+ if "Unknown function tb_var" in msg:
320
320
  raise InvalidFunction(msg="Unknown function tb_var. Usage: {{tb_var('var_name')}}") from e
321
321
  raise
322
322
  # Do not cache this fallback result: the returned sql string can contain
@@ -487,13 +487,12 @@ def replacements_to_tuples(replacements: dict) -> dict:
487
487
  return parsed_replacements
488
488
 
489
489
 
490
- @lru_cache(maxsize=2**15)
490
+ @lru_cache(maxsize=2**13)
491
491
  def replace_tables_chquery_cached(
492
492
  sql: str,
493
493
  sorted_replacements: Optional[tuple] = None,
494
494
  default_database: str = "",
495
495
  output_one_line: bool = False,
496
- timestamp: Optional[datetime] = None,
497
496
  function_allow_list: Optional[FrozenSet[str]] = None,
498
497
  settings_allow_list: Optional[FrozenSet[str]] = None,
499
498
  ) -> str:
@@ -536,7 +535,6 @@ def replace_tables(
536
535
  sql,
537
536
  None,
538
537
  output_one_line=output_one_line,
539
- timestamp=timestamp,
540
538
  function_allow_list=hashable_list,
541
539
  settings_allow_list=hashable_settings_list,
542
540
  )
@@ -610,7 +608,6 @@ def replace_tables(
610
608
  sql,
611
609
  None,
612
610
  output_one_line=output_one_line,
613
- timestamp=timestamp,
614
611
  function_allow_list=hashable_list,
615
612
  settings_allow_list=hashable_settings_list,
616
613
  )
@@ -649,7 +646,6 @@ def replace_tables(
649
646
  r,
650
647
  default_database=default_database,
651
648
  output_one_line=output_one_line,
652
- timestamp=timestamp,
653
649
  function_allow_list=hashable_list,
654
650
  settings_allow_list=hashable_settings_list,
655
651
  )
@@ -658,16 +654,13 @@ def replace_tables(
658
654
  sql,
659
655
  None,
660
656
  output_one_line=output_one_line,
661
- timestamp=timestamp,
662
657
  function_allow_list=hashable_list,
663
658
  settings_allow_list=hashable_settings_list,
664
659
  )
665
660
 
666
661
  # Fix for empty database names in JOINs - remove empty backticks like ``.table_name
667
662
  # that are generated when chquery.replace_tables processes tuples with empty database names
668
- sql = sql.replace("``.", "")
669
-
670
- return sql
663
+ return sql.replace("``.", "")
671
664
 
672
665
 
673
666
  def is_invalid_resource(
@@ -706,3 +699,62 @@ def is_invalid_resource_from_current_workspace(
706
699
  valid_tables: Optional[Set[Tuple[str, str]]],
707
700
  ) -> bool:
708
701
  return bool(database == default_database and valid_tables and r not in valid_tables and r not in _replaced_with)
702
+
703
+
704
+ def validate_ttl_expression(
705
+ columns: list[dict[str, str]],
706
+ ttl: Optional[str],
707
+ sorting_key: str,
708
+ primary_key: Optional[str] = None,
709
+ partition_key: Optional[str] = None,
710
+ ) -> Optional[str]:
711
+ if not ttl:
712
+ return None
713
+
714
+ kwargs = {"partition_key": partition_key} if partition_key else {}
715
+ error = chquery.validate_ttl(
716
+ ttl=ttl,
717
+ columns=columns,
718
+ sorting_key=sorting_key,
719
+ primary_key=primary_key or sorting_key,
720
+ **kwargs,
721
+ )
722
+ if error is None:
723
+ return None
724
+ return error.removeprefix("DB::Exception: ")
725
+
726
+
727
+ TTL_ONLY_DROP_PARTS_SETTING = "ttl_only_drop_parts"
728
+
729
+
730
+ def should_default_ttl_only_drop_parts(
731
+ columns: list[dict[str, str]],
732
+ ttl: Optional[str],
733
+ sorting_key: str,
734
+ partition_key: Optional[str],
735
+ primary_key: Optional[str] = None,
736
+ ) -> bool:
737
+ """Whether ``ttl_only_drop_parts = 1`` should be defaulted for this table."""
738
+ if not ttl or not partition_key:
739
+ return False
740
+ result = chquery.check_ttl_partition_compatibility(
741
+ columns=columns,
742
+ ttl=ttl,
743
+ sorting_key=sorting_key,
744
+ primary_key=primary_key or sorting_key,
745
+ partition_key=partition_key,
746
+ )
747
+ return result["checked"] and result["error"] is None
748
+
749
+
750
+ def validate_partition_key_expression(
751
+ columns: list[dict[str, str]],
752
+ partition_key: Optional[str],
753
+ ) -> Optional[str]:
754
+ if not partition_key:
755
+ return None
756
+
757
+ error = chquery.validate_partition_key(columns=columns, partition_key=partition_key)
758
+ if error is None:
759
+ return None
760
+ return error.removeprefix("DB::Exception: ")