From 2682e3088fa1e42fd04eef35af0e6648abf9a9c4 Mon Sep 17 00:00:00 2001 From: Erwan Bourre Date: Tue, 3 Feb 2026 13:05:21 +0100 Subject: [PATCH 1/2] fix: teradata adapter + resultset --- .../connections/providers/teradata/adapter.py | 59 +++++++++---------- sqlit/domains/query/app/query_service.py | 20 ++++--- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/sqlit/domains/connections/providers/teradata/adapter.py b/sqlit/domains/connections/providers/teradata/adapter.py index 29fe886f..abb03fc2 100644 --- a/sqlit/domains/connections/providers/teradata/adapter.py +++ b/sqlit/domains/connections/providers/teradata/adapter.py @@ -8,7 +8,6 @@ ColumnInfo, CursorBasedAdapter, IndexInfo, - SequenceInfo, TableInfo, TriggerInfo, ) @@ -49,10 +48,6 @@ def supports_cross_database_queries(self) -> bool: def supports_stored_procedures(self) -> bool: return True - @property - def supports_sequences(self) -> bool: - return True - def apply_database_override(self, config: ConnectionConfig, database: str) -> ConnectionConfig: """Apply a default database for unqualified queries.""" if not database: @@ -91,8 +86,9 @@ def connect(self, config: ConnectionConfig) -> Any: def get_databases(self, conn: Any) -> list[str]: cursor = conn.cursor() cursor.execute( + "lock row for access " "SELECT DatabaseName FROM DBC.DatabasesV " - "WHERE DatabaseKind IN ('D', 'U') " + "WHERE dbkind IN ('D', 'U') " "ORDER BY DatabaseName" ) return [row[0] for row in cursor.fetchall()] @@ -101,6 +97,7 @@ def get_tables(self, conn: Any, database: str | None = None) -> list[TableInfo]: cursor = conn.cursor() if database: cursor.execute( + "lock row for access " "SELECT DatabaseName, TableName FROM DBC.TablesV " "WHERE TableKind = 'T' AND DatabaseName = ? " "ORDER BY TableName", @@ -108,6 +105,7 @@ def get_tables(self, conn: Any, database: str | None = None) -> list[TableInfo]: ) else: cursor.execute( + "lock row for access " "SELECT DatabaseName, TableName FROM DBC.TablesV " "WHERE TableKind = 'T' " "ORDER BY DatabaseName, TableName" @@ -118,6 +116,7 @@ def get_views(self, conn: Any, database: str | None = None) -> list[TableInfo]: cursor = conn.cursor() if database: cursor.execute( + "lock row for access " "SELECT DatabaseName, TableName FROM DBC.TablesV " "WHERE TableKind = 'V' AND DatabaseName = ? " "ORDER BY TableName", @@ -125,6 +124,7 @@ def get_views(self, conn: Any, database: str | None = None) -> list[TableInfo]: ) else: cursor.execute( + "lock row for access " "SELECT DatabaseName, TableName FROM DBC.TablesV " "WHERE TableKind = 'V' " "ORDER BY DatabaseName, TableName" @@ -142,14 +142,13 @@ def get_columns( pk_columns: set[str] = set() try: cursor.execute( - "SELECT ic.ColumnName " - "FROM DBC.IndexConstraintsV c " - "JOIN DBC.IndexColumnsV ic " - " ON c.DatabaseName = ic.DatabaseName " - " AND c.TableName = ic.TableName " - " AND c.IndexNumber = ic.IndexNumber " - "WHERE c.ConstraintType = 'P' " - "AND c.DatabaseName = ? AND c.TableName = ?", + "lock row for access " + "select " + "COLUMNNAME " + "from DBC.INDICESV " + "where DATABASENAME = ? " + "and TABLENAME = ? " + "and INDEXTYPE = 'P' ", (schema_name, table), ) pk_columns = {row[0] for row in cursor.fetchall()} @@ -157,6 +156,7 @@ def get_columns( pk_columns = set() cursor.execute( + "lock row for access " "SELECT ColumnName, ColumnType FROM DBC.ColumnsV " "WHERE DatabaseName = ? AND TableName = ? " "ORDER BY ColumnId", @@ -171,6 +171,7 @@ def get_procedures(self, conn: Any, database: str | None = None) -> list[str]: cursor = conn.cursor() if database: cursor.execute( + "lock row for access " "SELECT TableName FROM DBC.TablesV " "WHERE TableKind = 'P' AND DatabaseName = ? " "ORDER BY TableName", @@ -178,6 +179,7 @@ def get_procedures(self, conn: Any, database: str | None = None) -> list[str]: ) else: cursor.execute( + "lock row for access " "SELECT TableName FROM DBC.TablesV " "WHERE TableKind = 'P' " "ORDER BY TableName" @@ -188,6 +190,7 @@ def get_indexes(self, conn: Any, database: str | None = None) -> list[IndexInfo] cursor = conn.cursor() if database: cursor.execute( + "lock row for access " "SELECT IndexName, TableName, UniqueFlag FROM DBC.IndicesV " "WHERE DatabaseName = ? " "ORDER BY TableName, IndexName", @@ -195,6 +198,7 @@ def get_indexes(self, conn: Any, database: str | None = None) -> list[IndexInfo] ) else: cursor.execute( + "lock row for access " "SELECT IndexName, TableName, UniqueFlag FROM DBC.IndicesV " "ORDER BY DatabaseName, TableName, IndexName" ) @@ -207,6 +211,7 @@ def get_triggers(self, conn: Any, database: str | None = None) -> list[TriggerIn cursor = conn.cursor() if database: cursor.execute( + "lock row for access " "SELECT TriggerName, TableName FROM DBC.TriggersV " "WHERE DatabaseName = ? " "ORDER BY TableName, TriggerName", @@ -214,26 +219,18 @@ def get_triggers(self, conn: Any, database: str | None = None) -> list[TriggerIn ) else: cursor.execute( + "lock row for access " "SELECT TriggerName, TableName FROM DBC.TriggersV " "ORDER BY DatabaseName, TableName, TriggerName" ) return [TriggerInfo(name=row[0], table_name=row[1]) for row in cursor.fetchall()] - def get_sequences(self, conn: Any, database: str | None = None) -> list[SequenceInfo]: - cursor = conn.cursor() - if database: - cursor.execute( - "SELECT SequenceName FROM DBC.SequencesV " - "WHERE DatabaseName = ? " - "ORDER BY SequenceName", - (database,), - ) - else: - cursor.execute( - "SELECT SequenceName FROM DBC.SequencesV " - "ORDER BY DatabaseName, SequenceName" - ) - return [SequenceInfo(name=row[0]) for row in cursor.fetchall()] + def get_sequences(self, conn: Any, database: str | None = None) -> list[str]: + """Teradata does not support standalone sequences. + + Auto-increment behaviour is provided by IDENTITY columns instead. + """ + return [] def quote_identifier(self, name: str) -> str: escaped = name.replace('"', '""') @@ -242,5 +239,5 @@ def quote_identifier(self, name: str) -> str: def build_select_query(self, table: str, limit: int, database: str | None = None, schema: str | None = None) -> str: schema_name = schema or database if schema_name: - return f'SELECT TOP {limit} * FROM "{schema_name}"."{table}"' - return f'SELECT TOP {limit} * FROM "{table}"' + return f'lock row for access select top {limit} * from "{schema_name}"."{table}"' + return f'lock row for access select top {limit} * from "{table}"' diff --git a/sqlit/domains/query/app/query_service.py b/sqlit/domains/query/app/query_service.py index b458a5bd..19131b3f 100644 --- a/sqlit/domains/query/app/query_service.py +++ b/sqlit/domains/query/app/query_service.py @@ -68,9 +68,10 @@ class KeywordQueryAnalyzer: def classify(self, query: str) -> QueryKind: """Classify query based on keyword of the last statement. - For multi-statement queries like 'BEGIN; INSERT...; SELECT * FROM t;', - we check the last statement to determine if results should be returned. - Uses the same splitting logic as multi_statement.split_statements. + Enhanced for Teradata: + - Supports SEL (Teradata abbreviation for SELECT) + - Supports HELP statements + - Handles LOCKING ... SELECT patterns (common in Teradata) """ from sqlit.domains.query.editing.comments import ( is_comment_line, @@ -87,17 +88,22 @@ def classify(self, query: str) -> QueryKind: for stmt in reversed(statements): if is_comment_only_statement(stmt): continue - # Found a statement with actual SQL - get first non-comment line + # Get first non-comment line lines = [line.strip() for line in stmt.split("\n") if line.strip()] non_comment_lines = [line for line in lines if not is_comment_line(line)] if non_comment_lines: - first_line = non_comment_lines[0].upper() - first_word = first_line.split()[0] if first_line else "" + first_line_upper = non_comment_lines[0].upper() + + # Teradata-specific patterns (word-boundary aware) + if re.search(r"\b(SELECT|WITH|SHOW|DESCRIBE|EXPLAIN|PRAGMA|SEL|HELP)\b", first_line_upper): + return QueryKind.RETURNS_ROWS + + # Fallback to original first-word check + first_word = first_line_upper.split()[0] if first_line_upper else "" return QueryKind.RETURNS_ROWS if first_word in SELECT_KEYWORDS else QueryKind.NON_QUERY return QueryKind.NON_QUERY - class DialectQueryAnalyzer: def __init__(self, dialect: Any, fallback: QueryAnalyzer | None = None) -> None: self._dialect = dialect From eef679fad157c9e5a963d2fa77c239d5639b631b Mon Sep 17 00:00:00 2001 From: Peter Adams <18162810+Maxteabag@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:55:20 +0100 Subject: [PATCH 2/2] Move Teradata LOCKING/SEL handling to adapter classify_query Instead of modifying the shared KeywordQueryAnalyzer (which would regress other adapters with false positives), override classify_query on TeradataAdapter to handle LOCKING/LOCK prefixes and the SEL keyword. This follows the same pattern used by osquery and surrealdb adapters. Reverts query_service.py to its original state. --- .../connections/providers/teradata/adapter.py | 23 +++++++++++++++++++ sqlit/domains/query/app/query_service.py | 20 ++++++---------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/sqlit/domains/connections/providers/teradata/adapter.py b/sqlit/domains/connections/providers/teradata/adapter.py index abb03fc2..aa8e0201 100644 --- a/sqlit/domains/connections/providers/teradata/adapter.py +++ b/sqlit/domains/connections/providers/teradata/adapter.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any from sqlit.domains.connections.providers.adapters.base import ( @@ -48,6 +49,28 @@ def supports_cross_database_queries(self) -> bool: def supports_stored_procedures(self) -> bool: return True + _TERADATA_SELECT_KEYWORDS = frozenset( + {"SELECT", "SEL", "WITH", "SHOW", "DESCRIBE", "EXPLAIN", "HELP"} + ) + + _LOCKING_RE = re.compile( + r"\bFOR\s+(?:ACCESS|READ|WRITE|EXCLUSIVE)(?:\s+NOWAIT)?\s+(\w+)", + re.IGNORECASE, + ) + + def classify_query(self, query: str) -> bool: + """Classify Teradata queries, handling LOCKING/LOCK prefix and SEL abbreviation.""" + query_upper = query.strip().upper() + first_word = query_upper.split()[0] if query_upper else "" + + # Strip LOCKING/LOCK request modifier to find the actual statement keyword + if first_word in ("LOCKING", "LOCK"): + match = self._LOCKING_RE.search(query_upper) + if match: + first_word = match.group(1) + + return first_word in self._TERADATA_SELECT_KEYWORDS + def apply_database_override(self, config: ConnectionConfig, database: str) -> ConnectionConfig: """Apply a default database for unqualified queries.""" if not database: diff --git a/sqlit/domains/query/app/query_service.py b/sqlit/domains/query/app/query_service.py index 19131b3f..b458a5bd 100644 --- a/sqlit/domains/query/app/query_service.py +++ b/sqlit/domains/query/app/query_service.py @@ -68,10 +68,9 @@ class KeywordQueryAnalyzer: def classify(self, query: str) -> QueryKind: """Classify query based on keyword of the last statement. - Enhanced for Teradata: - - Supports SEL (Teradata abbreviation for SELECT) - - Supports HELP statements - - Handles LOCKING ... SELECT patterns (common in Teradata) + For multi-statement queries like 'BEGIN; INSERT...; SELECT * FROM t;', + we check the last statement to determine if results should be returned. + Uses the same splitting logic as multi_statement.split_statements. """ from sqlit.domains.query.editing.comments import ( is_comment_line, @@ -88,22 +87,17 @@ def classify(self, query: str) -> QueryKind: for stmt in reversed(statements): if is_comment_only_statement(stmt): continue - # Get first non-comment line + # Found a statement with actual SQL - get first non-comment line lines = [line.strip() for line in stmt.split("\n") if line.strip()] non_comment_lines = [line for line in lines if not is_comment_line(line)] if non_comment_lines: - first_line_upper = non_comment_lines[0].upper() - - # Teradata-specific patterns (word-boundary aware) - if re.search(r"\b(SELECT|WITH|SHOW|DESCRIBE|EXPLAIN|PRAGMA|SEL|HELP)\b", first_line_upper): - return QueryKind.RETURNS_ROWS - - # Fallback to original first-word check - first_word = first_line_upper.split()[0] if first_line_upper else "" + first_line = non_comment_lines[0].upper() + first_word = first_line.split()[0] if first_line else "" return QueryKind.RETURNS_ROWS if first_word in SELECT_KEYWORDS else QueryKind.NON_QUERY return QueryKind.NON_QUERY + class DialectQueryAnalyzer: def __init__(self, dialect: Any, fallback: QueryAnalyzer | None = None) -> None: self._dialect = dialect