diff --git a/legendql/__init__.py b/legendql/__init__.py index c09658f6b..cf7901365 100644 --- a/legendql/__init__.py +++ b/legendql/__init__.py @@ -13,8 +13,8 @@ # limitations under the License. from legendql.ql import db, table, using, using_db, using_db_def -from legendql.ql import query, LegendQL +from legendql.ql import query, LegendQL, from_sql from legendql.store import store from legendql.functions import aggregate, over, unbounded, rows, range, left, avg, count, sum, rank, lead, lag, row_number -__all__ = ['db', 'table', 'using', 'using_db', 'using_db_def', 'query', 'LegendQL', 'store', 'aggregate', 'over', 'unbounded', 'rows', 'range', 'left', 'avg', 'count', 'sum', 'rank', 'lead', 'lag', 'row_number'] +__all__ = ['db', 'table', 'using', 'using_db', 'using_db_def', 'query', 'LegendQL', 'from_sql', 'store', 'aggregate', 'over', 'unbounded', 'rows', 'range', 'left', 'avg', 'count', 'sum', 'rank', 'lead', 'lag', 'row_number'] diff --git a/legendql/ql.py b/legendql/ql.py index 0f79299fd..c628cd1a8 100644 --- a/legendql/ql.py +++ b/legendql/ql.py @@ -61,6 +61,12 @@ def using_db(database: Database, table: str) -> LegendQL: return LegendQL.using_db_def(database, table, None) +def from_sql(sql: str, database: Database) -> LegendQL: + """Create a LegendQL query from a SQL statement.""" + query = Query.from_sql(sql, None, database) + return LegendQL.__new__(LegendQL)._initialize_with_query(query) + + class LegendQL: def __init__(self, database_definition: DatabaseDefinition, database: Database, table: str): @@ -70,6 +76,11 @@ def __init__(self, database_definition: DatabaseDefinition, database: Database, def using_db_def(cls, database: Database, table: str, database_definition: DatabaseDefinition = None) -> LegendQL: return LegendQL(database_definition, database, table) + def _initialize_with_query(self, query: Query) -> LegendQL: + """Initialize LegendQL instance with an existing Query.""" + self._query = query + return self + def execute(self, store_or_def: Union[Store | DatabaseDefinition] = None, client: LegendClient = None) -> Result: database_def = None if store_or_def: diff --git a/legendql/query.py b/legendql/query.py index 3a03b338c..3b401e9f6 100644 --- a/legendql/query.py +++ b/legendql/query.py @@ -46,6 +46,33 @@ def from_table(cls, database_definition: DatabaseDefinition, database: Database, cloned_table = Table(found_table.table, found_table.columns.copy(), found_table.schema) return Query(database_definition, database, [cloned_table], cloned_table, [DatabaseFromClause(database.name, cloned_table.table, cloned_table.schema.name if cloned_table.schema else None)]) + @classmethod + def from_sql(cls, sql: str, database_definition: DatabaseDefinition, database: Database) -> Query: + """Create a Query from a SQL statement.""" + from legendql.sql_parser import SQLParser + from legendql.model.metamodel import DatabaseFromClause + + parser = SQLParser(database) + clauses = parser.parse_sql(sql) + + table_name = None + for clause in clauses: + if isinstance(clause, DatabaseFromClause): + table_name = clause.table + break + + if not table_name: + raise ValueError("SQL statement must include a FROM clause with a table name") + + all_tables = [x for xs in map(lambda c: [c] if isinstance(c, Table) else c, database.children) for x in xs] + try: + found_table = next(t for t in all_tables if t.table == table_name) + except StopIteration: + raise ValueError(f"Table {table_name} not found in the database.") + + cloned_table = Table(found_table.table, found_table.columns.copy(), found_table.schema) + return Query(database_definition, database, [cloned_table], cloned_table, clauses) + def execute(self, database_definition: DatabaseDefinition = None, client: LegendClient = None) -> Result: if database_definition is None and self._database_definition is None: raise ValueError("Database definition is required to execute the query.") diff --git a/legendql/sql_parser.py b/legendql/sql_parser.py new file mode 100644 index 000000000..429321946 --- /dev/null +++ b/legendql/sql_parser.py @@ -0,0 +1,213 @@ +# Copyright 2025 Goldman Sachs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List, Optional, Union +import sqlglot +from sqlglot import expressions as exp + +from legendql.model.metamodel import ( + SelectionClause, FilterClause, LimitClause, OffsetClause, OrderByClause, + ColumnReferenceExpression, LiteralExpression, BinaryExpression, + IntegerLiteral, StringLiteral, BooleanLiteral, DateLiteral, + EqualsBinaryOperator, NotEqualsBinaryOperator, GreaterThanBinaryOperator, + LessThanBinaryOperator, GreaterThanEqualsBinaryOperator, LessThanEqualsBinaryOperator, + AndBinaryOperator, OrBinaryOperator, AddBinaryOperator, SubtractBinaryOperator, + MultiplyBinaryOperator, DivideBinaryOperator, OperandExpression, + OrderByExpression, AscendingOrderType, DescendingOrderType, + DatabaseFromClause, Clause, Expression +) +from legendql.model.schema import Database, Table +import pyarrow as pa + + +class SQLParser: + """Parser that converts SQL statements to LegendQL metamodel using SQLGlot.""" + + def __init__(self, database: Database): + self.database = database + + def parse_sql(self, sql: str) -> List[Clause]: + """Parse SQL statement and return list of LegendQL clauses.""" + try: + parsed = sqlglot.parse_one(sql) + + if not isinstance(parsed, exp.Select): + raise ValueError(f"Only SELECT statements are currently supported, got: {type(parsed)}") + + clauses = [] + + from_clause = self._parse_from_clause(parsed) + if from_clause: + clauses.append(from_clause) + + select_clause = self._parse_select_clause(parsed) + if select_clause: + clauses.append(select_clause) + + where_clause = self._parse_where_clause(parsed) + if where_clause: + clauses.append(where_clause) + + order_by_clause = self._parse_order_by_clause(parsed) + if order_by_clause: + clauses.append(order_by_clause) + + limit_clause = self._parse_limit_clause(parsed) + if limit_clause: + clauses.append(limit_clause) + + offset_clause = self._parse_offset_clause(parsed) + if offset_clause: + clauses.append(offset_clause) + + return clauses + + except Exception as e: + raise ValueError(f"Failed to parse SQL: {sql}. Error: {str(e)}") + + def _parse_from_clause(self, select_stmt: exp.Select) -> Optional[DatabaseFromClause]: + """Parse FROM clause and return DatabaseFromClause.""" + if not select_stmt.find(exp.From): + return None + + from_expr = select_stmt.find(exp.From) + table_expr = from_expr.find(exp.Table) + + if not table_expr: + raise ValueError("FROM clause must specify a table") + + table_name = table_expr.name + schema_name = table_expr.db if table_expr.db else None + + database_name = self.database.name + + return DatabaseFromClause(database_name, table_name, schema_name or "") + + def _parse_select_clause(self, select_stmt: exp.Select) -> Optional[SelectionClause]: + """Parse SELECT clause and return SelectionClause.""" + expressions = [] + + for select_expr in select_stmt.expressions: + if isinstance(select_expr, exp.Star): + raise ValueError("SELECT * is not currently supported. Please specify column names.") + elif isinstance(select_expr, exp.Column): + expressions.append(ColumnReferenceExpression(select_expr.name)) + elif hasattr(select_expr, 'alias') and select_expr.alias: + expressions.append(ColumnReferenceExpression(select_expr.alias)) + else: + expr = self._parse_expression(select_expr) + if isinstance(expr, ColumnReferenceExpression): + expressions.append(expr) + else: + if hasattr(select_expr, 'name'): + expressions.append(ColumnReferenceExpression(select_expr.name)) + + return SelectionClause(expressions) if expressions else None + + def _parse_where_clause(self, select_stmt: exp.Select) -> Optional[FilterClause]: + """Parse WHERE clause and return FilterClause.""" + where_expr = select_stmt.find(exp.Where) + if not where_expr: + return None + + condition = self._parse_expression(where_expr.this) + return FilterClause(condition) + + def _parse_order_by_clause(self, select_stmt: exp.Select) -> Optional[OrderByClause]: + """Parse ORDER BY clause and return OrderByClause.""" + order_by = select_stmt.find(exp.Order) + if not order_by: + return None + + ordering = [] + for ordered_expr in order_by.expressions: + column_expr = self._parse_expression(ordered_expr.this) + direction = DescendingOrderType() if ordered_expr.args.get('desc') else AscendingOrderType() + ordering.append(OrderByExpression(direction, column_expr)) + + return OrderByClause(ordering) + + def _parse_limit_clause(self, select_stmt: exp.Select) -> Optional[LimitClause]: + """Parse LIMIT clause and return LimitClause.""" + limit_expr = select_stmt.find(exp.Limit) + if not limit_expr: + return None + + limit_value = self._parse_expression(limit_expr.expression) + if isinstance(limit_value, LiteralExpression) and isinstance(limit_value.literal, IntegerLiteral): + return LimitClause(limit_value.literal) + + raise ValueError("LIMIT value must be an integer") + + def _parse_offset_clause(self, select_stmt: exp.Select) -> Optional[OffsetClause]: + """Parse OFFSET clause and return OffsetClause.""" + offset_expr = select_stmt.find(exp.Offset) + if not offset_expr: + return None + + offset_value = self._parse_expression(offset_expr.expression) + if isinstance(offset_value, LiteralExpression) and isinstance(offset_value.literal, IntegerLiteral): + return OffsetClause(offset_value.literal) + + raise ValueError("OFFSET value must be an integer") + + def _parse_expression(self, expr) -> Expression: + """Parse SQLGlot expression and return LegendQL Expression.""" + if isinstance(expr, exp.Column): + return ColumnReferenceExpression(expr.name) + elif isinstance(expr, exp.Literal): + return self._parse_literal(expr) + elif isinstance(expr, exp.Binary): + return self._parse_binary_expression(expr) + else: + if hasattr(expr, 'name'): + return ColumnReferenceExpression(expr.name) + raise ValueError(f"Unsupported expression type: {type(expr)}") + + def _parse_literal(self, literal: exp.Literal) -> LiteralExpression: + """Parse SQLGlot literal and return LiteralExpression.""" + if literal.is_int: + return LiteralExpression(IntegerLiteral(int(literal.this))) + elif literal.is_string: + return LiteralExpression(StringLiteral(literal.this)) + else: + return LiteralExpression(StringLiteral(str(literal.this))) + + def _parse_binary_expression(self, binary: exp.Binary) -> BinaryExpression: + """Parse SQLGlot binary expression and return BinaryExpression.""" + left = OperandExpression(self._parse_expression(binary.left)) + right = OperandExpression(self._parse_expression(binary.right)) + + operator_map = { + exp.EQ: EqualsBinaryOperator(), + exp.NEQ: NotEqualsBinaryOperator(), + exp.GT: GreaterThanBinaryOperator(), + exp.LT: LessThanBinaryOperator(), + exp.GTE: GreaterThanEqualsBinaryOperator(), + exp.LTE: LessThanEqualsBinaryOperator(), + exp.And: AndBinaryOperator(), + exp.Or: OrBinaryOperator(), + exp.Add: AddBinaryOperator(), + exp.Sub: SubtractBinaryOperator(), + exp.Mul: MultiplyBinaryOperator(), + exp.Div: DivideBinaryOperator(), + } + + operator_class = type(binary) + if operator_class in operator_map: + operator = operator_map[operator_class] + else: + raise ValueError(f"Unsupported binary operator: {operator_class}") + + return BinaryExpression(left, right, operator) diff --git a/pyproject.toml b/pyproject.toml index 981d3a354..df6923712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ python = ">=3.9,<3.14" requests = ">=2.27.1" ijson = ">=3.1.4" pyarrow = ">=19.0.1" +sqlglot = ">=25.0.0" pandas = [ { version = ">=1.0.0", python = "<3.12" }, { version = ">=2.1.1", python = ">=3.12" } diff --git a/tests/legendql/sql/__init__.py b/tests/legendql/sql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/legendql/sql/test_legendql_from_sql.py b/tests/legendql/sql/test_legendql_from_sql.py new file mode 100644 index 000000000..3f63cbddc --- /dev/null +++ b/tests/legendql/sql/test_legendql_from_sql.py @@ -0,0 +1,83 @@ +# Copyright 2025 Goldman Sachs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import pyarrow as pa +from legendql import from_sql +from legendql.ql import LegendQL +from legendql.model.schema import Database, Table + + +class TestLegendQLFromSQL(unittest.TestCase): + + def setUp(self): + """Set up test database.""" + columns = [ + pa.field("colA", pa.string()), + pa.field("colB", pa.int64()), + pa.field("colC", pa.float64()) + ] + table = Table("tableC", columns) + self.database = Database("test_db", [table]) + + def test_from_sql_basic(self): + """Test creating LegendQL from basic SQL statement.""" + sql = "SELECT colA, colB FROM tableC" + lql = from_sql(sql, self.database) + + self.assertIsInstance(lql, LegendQL) + self.assertIsNotNone(lql._query) + self.assertEqual(len(lql._query._clauses), 2) + + def test_from_sql_with_where(self): + """Test creating LegendQL from SQL statement with WHERE clause.""" + sql = "SELECT colA FROM tableC WHERE colB = 42" + lql = from_sql(sql, self.database) + + self.assertIsInstance(lql, LegendQL) + self.assertIsNotNone(lql._query) + self.assertEqual(len(lql._query._clauses), 3) + + def test_from_sql_to_string(self): + """Test that LegendQL created from SQL can be converted to string.""" + sql = "SELECT colA, colB FROM tableC" + lql = from_sql(sql, self.database) + + result_string = lql.to_string() + self.assertIsInstance(result_string, str) + self.assertIn("tableC", result_string) + self.assertIn("colA", result_string) + self.assertIn("colB", result_string) + + def test_from_sql_chaining(self): + """Test that LegendQL created from SQL supports method chaining.""" + sql = "SELECT colA FROM tableC" + lql = from_sql(sql, self.database) + + result = lql.limit(10) + self.assertIsInstance(result, LegendQL) + self.assertEqual(len(result._query._clauses), 3) # FROM, SELECT, LIMIT + + def test_from_sql_invalid_table(self): + """Test that from_sql raises error for non-existent table.""" + sql = "SELECT colA FROM nonexistent_table" + + with self.assertRaises(ValueError) as context: + from_sql(sql, self.database) + + self.assertIn("Table nonexistent_table not found", str(context.exception)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/legendql/sql/test_query_from_sql.py b/tests/legendql/sql/test_query_from_sql.py new file mode 100644 index 000000000..d8d67d5a5 --- /dev/null +++ b/tests/legendql/sql/test_query_from_sql.py @@ -0,0 +1,96 @@ +# Copyright 2025 Goldman Sachs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import pyarrow as pa +from legendql.query import Query +from legendql.model.schema import Database, Table +from legendql.model.metamodel import ( + SelectionClause, FilterClause, DatabaseFromClause, + ColumnReferenceExpression, BinaryExpression +) + + +class TestQueryFromSQL(unittest.TestCase): + + def setUp(self): + """Set up test database.""" + columns = [ + pa.field("colA", pa.string()), + pa.field("colB", pa.int64()), + pa.field("colC", pa.float64()) + ] + table = Table("tableC", columns) + self.database = Database("test_db", [table]) + + def test_query_from_sql_basic(self): + """Test creating Query from basic SQL statement.""" + sql = "SELECT colA, colB FROM tableC" + query = Query.from_sql(sql, None, self.database) + + self.assertIsNotNone(query) + self.assertEqual(len(query._clauses), 2) + + from_clause = query._clauses[0] + self.assertIsInstance(from_clause, DatabaseFromClause) + self.assertEqual(from_clause.table, "tableC") + + select_clause = query._clauses[1] + self.assertIsInstance(select_clause, SelectionClause) + self.assertEqual(len(select_clause.expressions), 2) + + def test_query_from_sql_with_where(self): + """Test creating Query from SQL statement with WHERE clause.""" + sql = "SELECT colA FROM tableC WHERE colB = 42" + query = Query.from_sql(sql, None, self.database) + + self.assertIsNotNone(query) + self.assertEqual(len(query._clauses), 3) + + where_clause = query._clauses[2] + self.assertIsInstance(where_clause, FilterClause) + self.assertIsInstance(where_clause.expression, BinaryExpression) + + def test_query_from_sql_table_not_found(self): + """Test that Query.from_sql raises error for non-existent table.""" + sql = "SELECT colA FROM nonexistent_table" + + with self.assertRaises(ValueError) as context: + Query.from_sql(sql, None, self.database) + + self.assertIn("Table nonexistent_table not found", str(context.exception)) + + def test_query_from_sql_no_from_clause(self): + """Test that Query.from_sql raises error when no FROM clause.""" + sql = "SELECT 1" + + with self.assertRaises(ValueError) as context: + Query.from_sql(sql, None, self.database) + + self.assertIn("SQL statement must include a FROM clause", str(context.exception)) + + def test_query_to_string_from_sql(self): + """Test that Query created from SQL can be converted to string.""" + sql = "SELECT colA, colB FROM tableC" + query = Query.from_sql(sql, None, self.database) + + result_string = query.to_string() + self.assertIsInstance(result_string, str) + self.assertIn("tableC", result_string) + self.assertIn("colA", result_string) + self.assertIn("colB", result_string) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/legendql/sql/test_sql_parser.py b/tests/legendql/sql/test_sql_parser.py new file mode 100644 index 000000000..17ab97609 --- /dev/null +++ b/tests/legendql/sql/test_sql_parser.py @@ -0,0 +1,173 @@ +# Copyright 2025 Goldman Sachs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import pyarrow as pa +from legendql.sql_parser import SQLParser +from legendql.model.schema import Database, Table +from legendql.model.metamodel import ( + SelectionClause, FilterClause, LimitClause, OffsetClause, OrderByClause, + DatabaseFromClause, ColumnReferenceExpression, LiteralExpression, + BinaryExpression, IntegerLiteral, StringLiteral, + EqualsBinaryOperator, GreaterThanBinaryOperator, OperandExpression, + OrderByExpression, AscendingOrderType, DescendingOrderType +) + + +class TestSQLParser(unittest.TestCase): + + def setUp(self): + """Set up test database and parser.""" + columns = [ + pa.field("colA", pa.string()), + pa.field("colB", pa.int64()), + pa.field("colC", pa.float64()) + ] + table = Table("tableC", columns) + self.database = Database("test_db", [table]) + self.parser = SQLParser(self.database) + + def test_basic_select_statement(self): + """Test parsing basic SELECT statement.""" + sql = "SELECT colA, colB FROM tableC" + clauses = self.parser.parse_sql(sql) + + self.assertEqual(len(clauses), 2) + + from_clause = clauses[0] + self.assertIsInstance(from_clause, DatabaseFromClause) + self.assertEqual(from_clause.database, "test_db") + self.assertEqual(from_clause.table, "tableC") + self.assertEqual(from_clause.schema, "") + + select_clause = clauses[1] + self.assertIsInstance(select_clause, SelectionClause) + self.assertEqual(len(select_clause.expressions), 2) + self.assertIsInstance(select_clause.expressions[0], ColumnReferenceExpression) + self.assertEqual(select_clause.expressions[0].name, "colA") + self.assertIsInstance(select_clause.expressions[1], ColumnReferenceExpression) + self.assertEqual(select_clause.expressions[1].name, "colB") + + def test_select_with_where_clause(self): + """Test parsing SELECT statement with WHERE clause.""" + sql = "SELECT colA FROM tableC WHERE colB = 42" + clauses = self.parser.parse_sql(sql) + + self.assertEqual(len(clauses), 3) + + where_clause = clauses[2] + self.assertIsInstance(where_clause, FilterClause) + self.assertIsInstance(where_clause.expression, BinaryExpression) + + binary_expr = where_clause.expression + self.assertIsInstance(binary_expr.left.expression, ColumnReferenceExpression) + self.assertEqual(binary_expr.left.expression.name, "colB") + self.assertIsInstance(binary_expr.right.expression, LiteralExpression) + self.assertIsInstance(binary_expr.right.expression.literal, IntegerLiteral) + self.assertEqual(binary_expr.right.expression.literal.value(), 42) + self.assertIsInstance(binary_expr.operator, EqualsBinaryOperator) + + def test_select_with_order_by(self): + """Test parsing SELECT statement with ORDER BY clause.""" + sql = "SELECT colA FROM tableC ORDER BY colB ASC, colC DESC" + clauses = self.parser.parse_sql(sql) + + self.assertEqual(len(clauses), 3) + + order_clause = clauses[2] + self.assertIsInstance(order_clause, OrderByClause) + self.assertEqual(len(order_clause.ordering), 2) + + first_order = order_clause.ordering[0] + self.assertIsInstance(first_order, OrderByExpression) + self.assertIsInstance(first_order.direction, AscendingOrderType) + self.assertIsInstance(first_order.expression, ColumnReferenceExpression) + self.assertEqual(first_order.expression.name, "colB") + + second_order = order_clause.ordering[1] + self.assertIsInstance(second_order, OrderByExpression) + self.assertIsInstance(second_order.direction, DescendingOrderType) + self.assertIsInstance(second_order.expression, ColumnReferenceExpression) + self.assertEqual(second_order.expression.name, "colC") + + def test_select_with_limit_and_offset(self): + """Test parsing SELECT statement with LIMIT and OFFSET clauses.""" + sql = "SELECT colA FROM tableC LIMIT 10 OFFSET 5" + clauses = self.parser.parse_sql(sql) + + self.assertEqual(len(clauses), 4) + + limit_clause = clauses[2] + self.assertIsInstance(limit_clause, LimitClause) + self.assertIsInstance(limit_clause.value, IntegerLiteral) + self.assertEqual(limit_clause.value.value(), 10) + + offset_clause = clauses[3] + self.assertIsInstance(offset_clause, OffsetClause) + self.assertIsInstance(offset_clause.value, IntegerLiteral) + self.assertEqual(offset_clause.value.value(), 5) + + def test_complex_where_condition(self): + """Test parsing SELECT statement with complex WHERE condition.""" + sql = "SELECT colA FROM tableC WHERE colB > 10 AND colC = 'test'" + clauses = self.parser.parse_sql(sql) + + self.assertEqual(len(clauses), 3) + + where_clause = clauses[2] + self.assertIsInstance(where_clause, FilterClause) + self.assertIsInstance(where_clause.expression, BinaryExpression) + + and_expr = where_clause.expression + self.assertIsInstance(and_expr.left.expression, BinaryExpression) + self.assertIsInstance(and_expr.right.expression, BinaryExpression) + + def test_unsupported_sql_statement(self): + """Test that unsupported SQL statements raise appropriate errors.""" + sql = "INSERT INTO tableC VALUES (1, 2, 3)" + + with self.assertRaises(ValueError) as context: + self.parser.parse_sql(sql) + + self.assertIn("Only SELECT statements are currently supported", str(context.exception)) + + def test_select_star_not_supported(self): + """Test that SELECT * raises appropriate error.""" + sql = "SELECT * FROM tableC" + + with self.assertRaises(ValueError) as context: + self.parser.parse_sql(sql) + + self.assertIn("SELECT * is not currently supported", str(context.exception)) + + def test_missing_from_clause(self): + """Test that SQL without FROM clause raises appropriate error.""" + sql = "SELECT 1" + clauses = self.parser.parse_sql(sql) + + self.assertEqual(len(clauses), 1) + self.assertIsInstance(clauses[0], SelectionClause) + + def test_invalid_sql_syntax(self): + """Test that invalid SQL syntax raises appropriate error.""" + sql = "SELECT colA FROM" + + with self.assertRaises(ValueError) as context: + self.parser.parse_sql(sql) + + self.assertIn("Failed to parse SQL", str(context.exception)) + + +if __name__ == '__main__': + unittest.main()