#!/usr/bin/env python3 """Schema remapping helpers for e2e test runner.""" import os import re _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") def validate_schema_name(schema_name): """Validate a schema identifier that will be emitted into SQL.""" if not _IDENTIFIER_RE.match(schema_name): raise ValueError("Invalid schema name: {}".format(schema_name)) return schema_name def make_schema_name(prefix, worker_index, file_path): """Build a deterministic schema name for one test file.""" validate_schema_name(prefix.rstrip("_")) base_name = os.path.splitext(os.path.basename(file_path))[0] slug = re.sub(r"[^A-Za-z0-9_]+", "_", base_name).strip("_").lower() if not slug: slug = "case" schema_prefix = "{}_w{:02d}_".format(prefix.rstrip("_"), worker_index) max_slug_len = max(1, 64 - len(schema_prefix)) schema_name = schema_prefix + slug[:max_slug_len] return validate_schema_name(schema_name) class SchemaMapper(object): """Map logical test schema names to per-file physical schemas.""" def __init__(self, logical_schema, physical_schema): self.logical_schema = validate_schema_name(logical_schema) self.physical_schema = validate_schema_name(physical_schema) def remap_wait_params(self, params): """Return a copy of wait params with table schema remapped.""" new_params = dict(params) table = new_params.get("table", "") logical_prefix = self.logical_schema + "." if table.lower().startswith(logical_prefix.lower()): new_params["table"] = self.physical_schema + "." + table[len(logical_prefix):] return new_params def remap_sql(self, sql): """Remap logical schema references in SQL without touching literals.""" out = [] i = 0 while i < len(sql): ch = sql[i] if ch in ("'", '"'): literal, i = self._consume_quoted(sql, i, ch) out.append(literal) continue if ch == "`": identifier, next_i = self._consume_backtick_identifier(sql, i) if self._is_logical_backtick_identifier(identifier) and self._is_schema_dot(sql, next_i): out.append("`{}`".format(self.physical_schema)) i = next_i continue out.append(identifier) i = next_i continue if sql.startswith("--", i): comment, i = self._consume_line_comment(sql, i) out.append(comment) continue if ch == "#": comment, i = self._consume_line_comment(sql, i) out.append(comment) continue if sql.startswith("/*", i): comment, i = self._consume_block_comment(sql, i) out.append(comment) continue remapped, consumed = self._try_remap_table_schema_predicate(sql, i) if remapped is not None: out.append(remapped) i += consumed continue remapped, consumed = self._try_remap_database_statement(sql, i) if remapped is not None: out.append(remapped) i += consumed continue remapped, consumed = self._try_remap_schema_prefix(sql, i) if remapped is not None: out.append(remapped) i += consumed continue out.append(ch) i += 1 return "".join(out) def _consume_quoted(self, sql, start, quote): i = start + 1 while i < len(sql): if sql[i] == "\\": i += 2 continue if sql[i] == quote: if i + 1 < len(sql) and sql[i + 1] == quote: i += 2 continue return sql[start:i + 1], i + 1 i += 1 return sql[start:], len(sql) def _consume_backtick_identifier(self, sql, start): i = start + 1 while i < len(sql): if sql[i] == "`": if i + 1 < len(sql) and sql[i + 1] == "`": i += 2 continue return sql[start:i + 1], i + 1 i += 1 return sql[start:], len(sql) def _consume_line_comment(self, sql, start): newline = sql.find("\n", start) if newline == -1: return sql[start:], len(sql) return sql[start:newline], newline def _consume_block_comment(self, sql, start): end = sql.find("*/", start + 2) if end == -1: return sql[start:], len(sql) return sql[start:end + 2], end + 2 def _is_logical_backtick_identifier(self, identifier): if len(identifier) < 2 or not identifier.startswith("`") or not identifier.endswith("`"): return False content = identifier[1:-1].replace("``", "`") return content.lower() == self.logical_schema.lower() def _is_schema_dot(self, sql, index): return index < len(sql) and sql[index] == "." def _try_remap_schema_prefix(self, sql, start): logical = self.logical_schema end = start + len(logical) if sql[start:end].lower() != logical.lower(): return None, 0 before = sql[start - 1] if start > 0 else "" if before and (before.isalnum() or before == "_"): return None, 0 if end >= len(sql) or sql[end] != ".": return None, 0 return self.physical_schema + ".", len(logical) + 1 def _try_remap_table_schema_predicate(self, sql, start): keyword = "table_schema" end = start + len(keyword) if sql[start:end].lower() != keyword: return None, 0 before = sql[start - 1] if start > 0 else "" after = sql[end] if end < len(sql) else "" if before and (before.isalnum() or before == "_"): return None, 0 if after and (after.isalnum() or after == "_"): return None, 0 i = end while i < len(sql) and sql[i].isspace(): i += 1 if i >= len(sql) or sql[i] != "=": return None, 0 i += 1 while i < len(sql) and sql[i].isspace(): i += 1 if i >= len(sql) or sql[i] not in ("'", '"'): return None, 0 quote = sql[i] value_start = i + 1 value_end = value_start + len(self.logical_schema) if sql[value_start:value_end].lower() != self.logical_schema.lower(): return None, 0 if value_end >= len(sql) or sql[value_end] != quote: return None, 0 return sql[start:value_start] + self.physical_schema + quote, value_end + 1 - start def _try_remap_database_statement(self, sql, start): before = sql[start - 1] if start > 0 else "" if before and (before.isalnum() or before == "_"): return None, 0 logical = re.escape(self.logical_schema) patterns = [ r"((?:CREATE)\s+(?:DATABASE|SCHEMA)\s+(?:IF\s+NOT\s+EXISTS\s+)?)(`?)({})(`?)(?=\s|;|$)".format(logical), r"((?:DROP)\s+(?:DATABASE|SCHEMA)\s+(?:IF\s+EXISTS\s+)?)(`?)({})(`?)(?=\s|;|$)".format(logical), r"((?:USE)\s+)(`?)({})(`?)(?=\s|;|$)".format(logical), ] for pattern in patterns: match = re.match(pattern, sql[start:], re.IGNORECASE) if match is None: continue if match.group(2) != match.group(4): return None, 0 return ( match.group(1) + match.group(2) + self.physical_schema + match.group(4), len(match.group(0)), ) return None, 0