Skip to content

Commit 0aba96f

Browse files
Coding-Dev-Toolscowork-bot
andcommitted
cowork-bot: fix mixed-type column inference to produce valid SQL
A column whose values mix a string with a numeric/boolean type was either kept at the first-seen (often numeric) type or widened the wrong direction, producing INSERT statements with a quoted string literal in a numeric column (invalid SQL - rejected by PostgreSQL/MySQL). Mixed types now collapse to TEXT, the only safe universal type. A null value remains "unset" during inference, so a column that first sees null can still upgrade to a concrete type when a non-null value arrives (e.g. [null, 42] -> INTEGER). All-null columns default to TEXT. - src/json2sql/dialects.py: clarify merge_type widening semantics - src/json2sql/converter.py: treat null as unset; default all-null cols to TEXT - tests/test_type_inference.py: regression tests for mixed-type columns - tests/test_edge_cases.py: fix buggy assertion expecting INTEGER for a [string, int] column (that INSERT is invalid SQL) Co-Authored-By: cowork-bot <noreply@coding-dev-tools.local>
1 parent b62f3a9 commit 0aba96f

4 files changed

Lines changed: 120 additions & 17 deletions

File tree

src/json2sql/converter.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
create_table_sql,
88
format_value,
99
insert_sql,
10+
merge_type,
1011
sql_type_for,
1112
)
1213

@@ -137,12 +138,18 @@ def _infer_columns(self, objects: list[dict]) -> dict[str, str]:
137138
for obj in objects:
138139
for key, value in obj.items():
139140
if key not in columns:
140-
columns[key] = sql_type_for(value, self.dialect)
141+
# A null does not constrain the column yet: keep it unset so a
142+
# later non-null value can still pick a more specific type.
143+
columns[key] = sql_type_for(value, self.dialect) if value is not None else None
141144
elif value is not None:
142-
# Upgrade type if we find a non-null value
143-
inferred = sql_type_for(value, self.dialect)
144-
if columns[key] == "TEXT" and inferred != "TEXT":
145-
columns[key] = inferred
145+
cur = columns[key]
146+
columns[key] = (
147+
sql_type_for(value, self.dialect) if cur is None else merge_type(cur, value, self.dialect)
148+
)
149+
# Columns that only ever held nulls default to TEXT.
150+
for key, t in columns.items():
151+
if t is None:
152+
columns[key] = sql_type_for(None, self.dialect)
146153
return columns
147154

148155
def _infer_columns_flattened(
@@ -163,22 +170,28 @@ def _infer_columns_flattened(
163170
for sub_key, sub_value in value.items():
164171
flat_key = f"{key}_{sub_key}"
165172
if flat_key not in columns:
166-
columns[flat_key] = sql_type_for(sub_value, self.dialect)
173+
columns[flat_key] = sql_type_for(sub_value, self.dialect) if sub_value is not None else None
167174
flat_map[flat_key] = (key, sub_key)
168175
elif sub_value is not None:
169-
inferred = sql_type_for(sub_value, self.dialect)
170-
if columns[flat_key] == "TEXT" and inferred != "TEXT":
171-
columns[flat_key] = inferred
176+
cur = columns[flat_key]
177+
columns[flat_key] = (
178+
sql_type_for(sub_value, self.dialect) if cur is None else merge_type(cur, sub_value, self.dialect)
179+
)
172180
elif isinstance(value, list) and value and isinstance(value[0], dict) and self.flatten:
173181
# Skip - goes to separate table
174182
pass
175183
else:
176184
if key not in columns:
177-
columns[key] = sql_type_for(value, self.dialect)
185+
columns[key] = sql_type_for(value, self.dialect) if value is not None else None
178186
elif value is not None:
179-
inferred = sql_type_for(value, self.dialect)
180-
if columns[key] == "TEXT" and inferred != "TEXT":
181-
columns[key] = inferred
187+
cur = columns[key]
188+
columns[key] = (
189+
sql_type_for(value, self.dialect) if cur is None else merge_type(cur, value, self.dialect)
190+
)
191+
# Columns that only ever held nulls default to TEXT.
192+
for key, t in columns.items():
193+
if t is None:
194+
columns[key] = sql_type_for(None, self.dialect)
182195
return columns, flat_map
183196

184197
def _flatten_nested(

src/json2sql/dialects.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,40 @@ def sql_type_for(value: Any, dialect: Dialect) -> str:
4646
return _TYPE_MAP[dialect].get(py_type, "TEXT")
4747

4848

49+
_TYPE_RANK = {
50+
"BOOLEAN": 0,
51+
"TINYINT(1)": 0,
52+
"INTEGER": 1,
53+
"INT": 1,
54+
"DOUBLE PRECISION": 2,
55+
"DOUBLE": 2,
56+
"REAL": 2,
57+
"TEXT": 3,
58+
"VARCHAR(255)": 3,
59+
}
60+
61+
62+
def merge_type(current: str, value: Any, dialect: Dialect) -> str:
63+
"""Merge a column's current inferred SQL type with a new value's type.
64+
65+
Widens toward the most general compatible type so a column holding mixed
66+
values produces valid SQL. Any string in a column forces TEXT (a numeric
67+
column cannot hold a quoted string literal). A boolean mixed with a numeric
68+
type also widens to TEXT because a boolean literal is not assignable to
69+
INTEGER/REAL in strict SQL dialects.
70+
"""
71+
new_type = sql_type_for(value, dialect)
72+
if new_type == "TEXT" or current == "TEXT":
73+
return "TEXT"
74+
cur_rank = _TYPE_RANK.get(current, 3)
75+
new_rank = _TYPE_RANK.get(new_type, 3)
76+
# A boolean mixed with a numeric type is unsafe to keep numeric.
77+
if 0 in (cur_rank, new_rank) and max(cur_rank, new_rank) > 0:
78+
return "TEXT"
79+
# Otherwise widen to the broader numeric type.
80+
return current if cur_rank >= new_rank else new_type
81+
82+
4983
def quote_identifier(name: str, dialect: Dialect) -> str:
5084
"""Quote an identifier (table/column name) for the given dialect."""
5185
if dialect == Dialect.MYSQL:

tests/test_edge_cases.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,17 @@ def test_flatten_type_upgrade_for_flattened_column(self):
6565
result = converter.convert(json.dumps(data))
6666
assert "meta_count" in result
6767
# After upgrade, type should be INTEGER not TEXT
68-
assert "INTEGER" in result
68+
assert "TEXT" in result
6969
assert "'five'" in result
7070
assert "42" in result
7171

7272
def test_type_upgrade_for_top_level_column(self):
73-
"""Top-level column type upgrades from TEXT to more specific type (line 175)."""
73+
"""Mixed string/int column falls back to TEXT so generated SQL stays valid (line 175).
74+
75+
A column that holds both a string ("hello") and an int (100) cannot be
76+
INTEGER: the string literal would be rejected by the database. The safe,
77+
correct type is TEXT (a numeric literal fits a text column).
78+
"""
7479
from json2sql.converter import JSONToSQLConverter
7580

7681
data = [
@@ -79,13 +84,13 @@ def test_type_upgrade_for_top_level_column(self):
7984
]
8085
# Non-flatten path (_infer_columns)
8186
result = JSONToSQLConverter().convert(json.dumps(data))
82-
assert "INTEGER" in result
87+
assert "TEXT" in result
8388
assert "'hello'" in result
8489
assert "100" in result
8590

8691
# Flatten path (_infer_columns_flattened else branch, line 175)
8792
result2 = JSONToSQLConverter(flatten=True).convert(json.dumps(data))
88-
assert "INTEGER" in result2
93+
assert "TEXT" in result2
8994
assert "'hello'" in result2
9095
assert "100" in result2
9196

tests/test_type_inference.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Tests for robust column type inference across mixed values.
2+
3+
Regression coverage for the bug where a column holding mixed value types
4+
(e.g. a string and an integer) was inferred as the non-text type, producing
5+
SQL that inserts a quoted string literal into a numeric column -- which is
6+
rejected by databases such as PostgreSQL.
7+
"""
8+
9+
import json
10+
11+
from json2sql.converter import JSONToSQLConverter
12+
from json2sql.dialects import Dialect
13+
14+
15+
class TestMixedTypeInference:
16+
def test_string_then_int_falls_back_to_text(self):
17+
conv = JSONToSQLConverter(dialect=Dialect.POSTGRES)
18+
data = json.dumps([{"val": "string"}, {"val": 42}])
19+
result = conv.convert(data, table_name="mixed")
20+
assert '"val" TEXT' in result, "mixed string/int column must be TEXT"
21+
assert "'string'" in result
22+
23+
def test_int_then_string_falls_back_to_text(self):
24+
conv = JSONToSQLConverter(dialect=Dialect.POSTGRES)
25+
data = json.dumps([{"val": 42}, {"val": "string"}])
26+
result = conv.convert(data, table_name="mixed")
27+
assert '"val" TEXT' in result
28+
29+
def test_int_float_column_widens_to_float(self):
30+
conv = JSONToSQLConverter(dialect=Dialect.POSTGRES)
31+
data = json.dumps([{"n": 1}, {"n": 2.5}])
32+
result = conv.convert(data, table_name="nums")
33+
assert '"n" DOUBLE PRECISION' in result
34+
35+
def test_bool_int_mixed_falls_back_to_text(self):
36+
conv = JSONToSQLConverter(dialect=Dialect.POSTGRES)
37+
data = json.dumps([{"flag": True}, {"flag": 1}])
38+
result = conv.convert(data, table_name="flags")
39+
assert '"flag" TEXT' in result
40+
41+
def test_uniform_types_unaffected(self):
42+
conv = JSONToSQLConverter(dialect=Dialect.POSTGRES)
43+
data = json.dumps([{"age": 30}, {"age": 25}])
44+
result = conv.convert(data, table_name="users")
45+
assert '"age" INTEGER' in result
46+
47+
def test_trailing_null_does_not_widen(self):
48+
conv = JSONToSQLConverter(dialect=Dialect.POSTGRES)
49+
data = json.dumps([{"age": 30}, {"age": None}])
50+
result = conv.convert(data, table_name="users")
51+
assert '"age" INTEGER' in result

0 commit comments

Comments
 (0)