# #region SqlTableExtractorModule [C:2] [TYPE Module] [SEMANTICS sql, jinja, table, extraction, parsing] # @defgroup Services Module group. # @BRIEF Three-phase SQL+Jinja table name extractor for virtual datasets as per FR-003. # Phase 1: Detect Jinja spans vs SQL spans # Phase 2: In Jinja spans, extract "schema.table" from string values # Phase 3: In SQL spans, regex pattern + sqlparse filter to reject string literal false positives # @LAYER Service # @RELATION DEPENDS_ON -> [EXT:Library:sqlparse] # @INVARIANT Only exact schema.table matches (case-insensitive); unqualified references are NOT matched. # @INVARIANT Returns a set[str] of fully-qualified table names (lowercased for case-insensitive matching). > from collections.abc import Iterable > import re > import sqlparse > from sqlparse.sql import Token, TokenList > from sqlparse.tokens import Literal # ── Phase 1: Jinja span detection ─────────────────────────── # Minimal Jinja token detection: {% ... %}, {{ ... }}, {# ... #} > _JINJA_BLOCK_RE = re.compile(r"{%-?\s*.*?\s*-?%}", re.DOTALL) > _JINJA_EXPR_RE = re.compile(r"{{-?\s*.*?\s*-?}}", re.DOTALL) > _JINJA_COMMENT_RE = re.compile(r"{#.*?#}", re.DOTALL) # ── Phase 3: schema.table regex (case-insensitive) ─────────── > _SCHEMA_TABLE_RE = re.compile( > r""" > (? \b # word boundary > (?: # begin: non-capturing group for full match > (?:"([a-zA-Z_][\w]*)")? # optional quoted schema > (?:([a-zA-Z_][\w]*)) # schema (unquoted) > \. > (?:([a-zA-Z_][\w]*)) # table > ) > \b # word boundary > (?!['"`]) # not followed by a string delimiter > """, > re.VERBOSE | re.IGNORECASE, > ) # #region detect_jinja_spans [C:2] [TYPE Function] # @ingroup Services # @BRIEF Phase 1: Split raw SQL text into Jinja spans and SQL spans. # @PRE raw_sql is a string (possibly empty). # @POST Returns a list of (span_type: str, text: str) tuples. # span_type is "jinja" or "sql". > def detect_jinja_spans(raw_sql: str) -> list[tuple[str, str]]: > """Split raw SQL+Jinja text into alternating Jinja and SQL spans. > Phase 1 detects Jinja blocks ({%%}, {{}}, {##}) and returns the > remaining text as SQL spans. > """ > if not raw_sql or not raw_sql.strip(): ! return [("sql", raw_sql or "")] > spans: list[tuple[str, str]] = [] # Collect all Jinja match positions > jinja_matches: list[tuple[int, int]] = [] > for pattern in (_JINJA_BLOCK_RE, _JINJA_EXPR_RE, _JINJA_COMMENT_RE): > for m in pattern.finditer(raw_sql): > jinja_matches.append((m.start(), m.end())) # Merge overlapping Jinja spans > if jinja_matches: > jinja_matches.sort() > merged: list[tuple[int, int]] = [jinja_matches[0]] > for start, end in jinja_matches[1:]: ! if start <= merged[-1][1]: ! merged[-1] = (merged[-1][0], max(merged[-1][1], end)) ! else: ! merged.append((start, end)) # Build alternating sql/jinja spans > cursor = 0 > for start, end in merged: > if cursor < start: > sql_chunk = raw_sql[cursor:start].strip() > if sql_chunk: > spans.append(("sql", sql_chunk)) > jinja_chunk = raw_sql[start:end].strip() > if jinja_chunk: > spans.append(("jinja", jinja_chunk)) > cursor = end > if cursor < len(raw_sql): > remaining = raw_sql[cursor:].strip() > if remaining: > spans.append(("sql", remaining)) > else: > spans.append(("sql", raw_sql.strip())) > return spans # #endregion detect_jinja_spans # #region extract_tables_from_jinja [C:2] [TYPE Function] # @ingroup Services # @BRIEF Phase 2: Extract "schema.table" references from Jinja string values. # Looks for patterns like "raw.sales" inside Jinja text. # @PRE jinja_text is a string from a Jinja span. # @POST Returns a set of lowercased schema.table strings. > def extract_tables_from_jinja(jinja_text: str) -> set[str]: > """Extract ``schema.table`` references from within Jinja template blocks. > Looks for double-quoted or single-quoted string values that match > the ``schema.table`` pattern, e.g. ``"raw.sales"`` or ``'raw.inventory'``. > """ > tables: set[str] = set() # Match string values: "schema.table" or 'schema.table' > string_pattern = re.compile( > r"""["']([a-zA-Z_][\w]*\.[a-zA-Z_][\w]*)["']""" > ) > for m in string_pattern.finditer(jinja_text): > tables.add(m.group(1).lower()) > return tables # #endregion extract_tables_from_jinja # #region is_string_literal [C:1] [TYPE Function] # @BRIEF Check if a sqlparse Token is a string literal (not a schema.table identifier). # @PRE token is a sqlparse Token. # @POST Returns True if the token is a string/Single/Literal.String. > def is_string_literal(token: Token) -> bool: > """Return True if token is a string literal type in sqlparse.""" > return token.ttype is Literal.String.Single or token.ttype is Literal.String # #endregion is_string_literal # #region extract_tables_from_sql_span [C:2] [TYPE Function] # @ingroup Services # @BRIEF Phase 3: Extract schema.table references from SQL text using regex + sqlparse filtering. # Uses regex to find all schema.table candidates, then sqlparse to filter out # false positives inside string literals. # @PRE sql_text is a string from a SQL span (non-Jinja). # @POST Returns a set of lowercased schema.table strings. > def extract_tables_from_sql_span(sql_text: str) -> set[str]: > """Extract ``schema.table`` references from plain SQL text. > Uses regex to find all ``schema.table`` candidates, then sqlparse > to reject matches that fall inside string literals. > """ > tables: set[str] = set() > raw_matches = _SCHEMA_TABLE_RE.findall(sql_text) > if not raw_matches: > return tables # Use sqlparse to identify string literal positions > parsed = sqlparse.parse(sql_text) > string_literal_ranges: list[tuple[int, int]] = [] > def walk_tokens(tokens: Iterable[Token], base_offset: int = 0) -> None: > offset = base_offset > for token in tokens: > if isinstance(token, TokenList): ! walk_tokens(token.flatten(), offset) > else: > ttype = token.ttype > val = token.value > if is_string_literal(token): > string_literal_ranges.append( > (offset, offset + len(val)) > ) > offset += len(val) > for stmt in parsed: > if stmt is None: ! continue > walk_tokens(stmt.flatten(), base_offset=0) > def is_in_string(pos: int) -> bool: > for s_start, s_end in string_literal_ranges: > if s_start <= pos <= s_end: ! return True > return False # Re-scan with full match positions to filter > for m in _SCHEMA_TABLE_RE.finditer(sql_text): > if not is_in_string(m.start()): # Extract schema and table from match groups > schema = m.group(2) or m.group(1) or "" > table = m.group(3) or "" # Filter out column aliases (single-char schemas like "a.id", "o.id") > if len(schema) < 2 and schema.isalpha() and schema.islower(): > continue > if schema and table: > tables.add(f"{schema.lower()}.{table.lower()}") > return tables # #endregion extract_tables_from_sql_span # #region extract_tables_from_sql [C:2] [TYPE Function] # @ingroup Services # @BRIEF Three-phase entry point: extract all schema.table references from raw SQL+Jinja text. # @PRE raw_sql is a string (possibly empty). # @POST Returns a set of lowercased fully-qualified table names (schema.table format). # @RELATION CALLS -> [detect_jinja_spans] # @RELATION CALLS -> [extract_tables_from_jinja] # @RELATION CALLS -> [extract_tables_from_sql_span] > def extract_tables_from_sql(raw_sql: str) -> set[str]: > """Extract all ``schema.table`` references from raw SQL + Jinja text. > Three-phase approach per FR-003: > 1. Detect Jinja spans vs SQL spans > 2. In Jinja spans, extract ``schema.table`` from quoted string values > 3. In SQL spans, regex + sqlparse filter to reject string literal false positives > Returns a set of lowercased ``schema.table`` strings for case-insensitive matching. > Example: > >>> extract_tables_from_sql("SELECT * FROM raw.sales JOIN raw.inventory ON ...") > {'raw.sales', 'raw.inventory'} > """ > if not raw_sql or not raw_sql.strip(): > return set() > all_tables: set[str] = set() > spans = detect_jinja_spans(raw_sql) > for span_type, text in spans: > if span_type == "jinja": > all_tables.update(extract_tables_from_jinja(text)) > else: > all_tables.update(extract_tables_from_sql_span(text)) > return all_tables # #endregion extract_tables_from_sql # #endregion SqlTableExtractorModule