diff --git a/tests/test_converter.py b/tests/test_converter.py index 30582c8..a055d2d 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -158,3 +158,58 @@ def test_missing_keys_across_rows(self, converter_postgres): result = converter_postgres.convert(data, table_name="users") assert "email" in result assert "age" in result + + def test_unsupported_root_type_raises(self, converter_postgres): + """A plain string at root raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported JSON root type"): + converter_postgres.convert('"just a string"', table_name="bad") + + def test_unsupported_root_number_raises(self, converter_postgres): + """A plain number at root raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported JSON root type"): + converter_postgres.convert("42", table_name="bad") + + def test_unsupported_root_bool_raises(self, converter_postgres): + """A plain boolean at root raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported JSON root type"): + converter_postgres.convert("true", table_name="bad") + + +class TestGenerateSchema: + """Tests for generate_schema method.""" + + def test_generate_schema_basic(self, converter_postgres): + data = json.dumps([{"name": "Alice", "age": 30}]) + result = converter_postgres.generate_schema(data, table_name="users") + assert "CREATE TABLE" in result + assert "INSERT INTO" not in result + + def test_generate_schema_single_object(self, converter_postgres): + """Single dict at root also works.""" + data = json.dumps({"name": "Alice"}) + result = converter_postgres.generate_schema(data, table_name="users") + assert "CREATE TABLE" in result + assert '"name"' in result + + def test_generate_schema_with_flatten_nested_array(self): + """generate_schema with flatten=True should produce CREATE TABLE for nested arrays.""" + conv = JSONToSQLConverter(dialect=Dialect.POSTGRES, flatten=True) + data = json.dumps({ + "id": 1, + "name": "Alice", + "orders": [ + {"product": "Widget", "qty": 3}, + {"product": "Gadget", "qty": 1}, + ], + }) + result = conv.generate_schema(data, table_name="users") + assert "CREATE TABLE" in result + # Should include the nested table schema + assert "users_orders" in result or "orders" in result + assert "INSERT INTO" not in result + + def test_generate_schema_primitives(self, converter_postgres): + """A primitive type should produce a simple schema.""" + data = json.dumps([1, 2, 3]) + result = converter_postgres.generate_schema(data, table_name="nums") + assert "CREATE TABLE" in result diff --git a/tests/test_dialects.py b/tests/test_dialects.py new file mode 100644 index 0000000..eb46ff4 --- /dev/null +++ b/tests/test_dialects.py @@ -0,0 +1,254 @@ +"""Standalone tests for json2sql dialects module.""" +import pytest +from json2sql.dialects import ( + Dialect, + sql_type_for, + quote_identifier, + format_value, + create_table_sql, + insert_sql, +) + + +# --- Dialect enum --- + +class TestDialectEnum: + """Dialect enum values and membership.""" + + def test_postgres_value(self): + assert Dialect.POSTGRES == "postgres" + + def test_mysql_value(self): + assert Dialect.MYSQL == "mysql" + + def test_sqlite_value(self): + assert Dialect.SQLITE == "sqlite" + + def test_all_members(self): + assert set(Dialect) == {Dialect.POSTGRES, Dialect.MYSQL, Dialect.SQLITE} + + +# --- sql_type_for --- + +class TestSQLTypeFor: + """Python type → SQL type mapping per dialect.""" + + @pytest.mark.parametrize("dialect", [Dialect.POSTGRES, Dialect.MYSQL, Dialect.SQLITE]) + def test_string_type(self, dialect): + assert "TEXT" in sql_type_for("hello", dialect).upper() or "VARCHAR" in sql_type_for("hello", dialect).upper() + + def test_postgres_int(self): + assert sql_type_for(42, Dialect.POSTGRES) == "INTEGER" + + def test_mysql_int(self): + assert sql_type_for(42, Dialect.MYSQL) == "INT" + + def test_sqlite_int(self): + assert sql_type_for(42, Dialect.SQLITE) == "INTEGER" + + def test_postgres_float(self): + assert sql_type_for(3.14, Dialect.POSTGRES) == "DOUBLE PRECISION" + + def test_mysql_float(self): + assert sql_type_for(3.14, Dialect.MYSQL) == "DOUBLE" + + def test_sqlite_float(self): + assert sql_type_for(3.14, Dialect.SQLITE) == "REAL" + + def test_postgres_bool(self): + assert sql_type_for(True, Dialect.POSTGRES) == "BOOLEAN" + + def test_mysql_bool(self): + assert sql_type_for(False, Dialect.MYSQL) == "TINYINT(1)" + + def test_sqlite_bool(self): + assert sql_type_for(True, Dialect.SQLITE) == "INTEGER" + + def test_none_type(self): + # None maps to the string type of each dialect + for dialect in Dialect: + result = sql_type_for(None, dialect) + assert isinstance(result, str) + assert len(result) > 0 + + def test_unknown_type(self): + # Unrecognized type falls back to TEXT + assert sql_type_for(b"bytes", Dialect.POSTGRES) == "TEXT" + + def test_bool_before_int_check(self): + # bool is subclass of int; must return bool type, not int + assert sql_type_for(True, Dialect.POSTGRES) == "BOOLEAN" + assert sql_type_for(True, Dialect.POSTGRES) != "INTEGER" + + +# --- quote_identifier --- + +class TestQuoteIdentifier: + """Identifier quoting per dialect.""" + + def test_mysql_backtick(self): + assert quote_identifier("users", Dialect.MYSQL) == "`users`" + + def test_postgres_double_quote(self): + assert quote_identifier("users", Dialect.POSTGRES) == '"users"' + + def test_sqlite_double_quote(self): + assert quote_identifier("users", Dialect.SQLITE) == '"users"' + + def test_special_chars_mysql(self): + assert quote_identifier("group", Dialect.MYSQL) == "`group`" + + def test_special_chars_postgres(self): + assert quote_identifier("group", Dialect.POSTGRES) == '"group"' + + def test_empty_name(self): + for dialect in Dialect: + result = quote_identifier("", dialect) + assert len(result) >= 2 + + +# --- format_value --- + +class TestFormatValue: + """Python value → SQL literal formatting.""" + + def test_null(self): + for dialect in Dialect: + assert format_value(None, dialect) == "NULL" + + def test_postgres_true(self): + assert format_value(True, Dialect.POSTGRES) == "TRUE" + + def test_postgres_false(self): + assert format_value(False, Dialect.POSTGRES) == "FALSE" + + def test_mysql_true(self): + assert format_value(True, Dialect.MYSQL) == "1" + + def test_mysql_false(self): + assert format_value(False, Dialect.MYSQL) == "0" + + def test_sqlite_true(self): + assert format_value(True, Dialect.SQLITE) == "1" + + def test_sqlite_false(self): + assert format_value(False, Dialect.SQLITE) == "0" + + def test_string_simple(self): + assert format_value("hello", Dialect.POSTGRES) == "'hello'" + + def test_string_with_single_quote(self): + assert format_value("it's a test", Dialect.POSTGRES) == "'it''s a test'" + + def test_string_with_double_quotes(self): + assert format_value('say "hi"', Dialect.POSTGRES) == """'say "hi"'""" + + def test_integer(self): + assert format_value(42, Dialect.POSTGRES) == "42" + + def test_negative_integer(self): + assert format_value(-5, Dialect.MYSQL) == "-5" + + def test_float(self): + assert format_value(3.14, Dialect.POSTGRES) == "3.14" + + def test_zero(self): + assert format_value(0, Dialect.SQLITE) == "0" + + def test_empty_string(self): + assert format_value("", Dialect.POSTGRES) == "''" + + +# --- create_table_sql --- + +class TestCreateTableSQL: + """CREATE TABLE statement generation.""" + + def test_single_column(self): + columns = {"name": "TEXT"} + sql = create_table_sql("users", columns, Dialect.POSTGRES) + assert sql.startswith("CREATE TABLE") + assert '"users"' in sql + assert '"name" TEXT' in sql + assert sql.endswith(";") + + def test_multiple_columns(self): + columns = {"id": "INTEGER", "name": "TEXT", "active": "BOOLEAN"} + sql = create_table_sql("users", columns, Dialect.POSTGRES) + assert sql.count('"') >= 6 # table + 3 columns each double-quoted + assert "INTEGER" in sql + assert "TEXT" in sql + assert "BOOLEAN" in sql + + def test_mysql_backtick_quoting(self): + columns = {"id": "INT"} + sql = create_table_sql("users", columns, Dialect.MYSQL) + assert "`users`" in sql + assert "`id`" in sql + + def test_sqlite(self): + columns = {"value": "TEXT"} + sql = create_table_sql("data", columns, Dialect.SQLITE) + assert '"data"' in sql + assert '"value" TEXT' in sql + + +# --- insert_sql --- + +class TestInsertSQL: + """INSERT statement generation.""" + + def test_postgres_single_row(self): + sql = insert_sql("users", ["name"], [["'Alice'"]], Dialect.POSTGRES) + assert sql.startswith("INSERT INTO") + assert '"users"' in sql + assert "'Alice'" in sql + + def test_postgres_multi_row(self): + """PostgreSQL uses multi-row VALUES syntax for >1 rows.""" + rows = [["'Alice'"], ["'Bob'"]] + sql = insert_sql("users", ["name"], rows, Dialect.POSTGRES) + assert sql.startswith("INSERT INTO") + assert "VALUES" in sql + # Should be a single statement with two value tuples + assert sql.count("VALUES") == 1 + assert "'Alice'" in sql + assert "'Bob'" in sql + + def test_mysql_multi_row(self): + """MySQL multi-row VALUES.""" + rows = [["1"], ["2"]] + sql = insert_sql("items", ["id"], rows, Dialect.MYSQL) + assert "VALUES" in sql + assert "1" in sql + assert "2" in sql + assert sql.count("VALUES") == 1 + + def test_sqlite_single_row_per_statement(self): + """SQLite uses one INSERT per row (multi-row not supported).""" + rows = [["'Alice'"], ["'Bob'"]] + sql = insert_sql("users", ["name"], rows, Dialect.SQLITE) + # SQLite generates individual INSERT statements + assert sql.count("INSERT INTO") == 2 + assert "VALUES" in sql + assert "'Alice'" in sql + assert "'Bob'" in sql + + def test_sqlite_single_row(self): + sql = insert_sql("users", ["name"], [["'Alice'"]], Dialect.SQLITE) + assert sql.startswith("INSERT INTO") + assert sql.count("INSERT INTO") == 1 + + def test_multi_column_postgres(self): + rows = [["1", "'Alice'"], ["2", "'Bob'"]] + sql = insert_sql("users", ["id", "name"], rows, Dialect.POSTGRES) + assert '"id"' in sql + assert '"name"' in sql + assert "'Alice'" in sql + assert "'Bob'" in sql + + def test_mysql_quoting(self): + rows = [["1"]] + sql = insert_sql("items", ["id"], rows, Dialect.MYSQL) + assert "`items`" in sql + assert "`id`" in sql