From 7a71dcb89d8e6637f8237facd6f20ce6d947150a Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:34:30 -0700 Subject: [PATCH 1/4] Handle ragged multi-row INSERT in values_dict values_dict indexed every row by the first row width, so shorter VALUES tuples raised IndexError. Pad missing cells with None. --- sql_metadata/parser.py | 4 +++- test/test_values.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/sql_metadata/parser.py b/sql_metadata/parser.py index c92538fc..162465f3 100644 --- a/sql_metadata/parser.py +++ b/sql_metadata/parser.py @@ -487,8 +487,10 @@ def values_dict(self) -> dict[str, Any] | None: ) if is_multi: + # Pad short rows with None so ragged VALUES tuples do not IndexError. self._values_dict = { - col: [row[i] for row in values] for i, col in enumerate(columns) + col: [row[i] if i < len(row) else None for row in values] + for i, col in enumerate(columns) } else: self._values_dict = dict(zip(columns, values)) diff --git a/test/test_values.py b/test/test_values.py index 07668a53..34bd65c7 100644 --- a/test/test_values.py +++ b/test/test_values.py @@ -151,6 +151,13 @@ def test_insert_multi_row_values(): assert p.values_dict == {"field1": [1, 3], "field2": [2, 4]} +def test_insert_multi_row_ragged_values_dict(): + """Ragged multi-row VALUES must not IndexError in values_dict.""" + p = Parser("INSERT INTO t (a, b) VALUES (1, 2), (3)") + assert p.values == [[1, 2], [3]] + assert p.values_dict == {"a": [1, 3], "b": [2, None]} + + def test_insert_with_expression_value(): """INSERT with a function call in VALUES uses str(val) fallback.""" p = Parser("INSERT INTO t (a) VALUES (CURRENT_TIMESTAMP)") From 30166965437fe5b3d76f8d4ab4140420d86405b1 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:09:50 -0700 Subject: [PATCH 2/4] refactor: build ragged values_dict via zip_longest Replace per-index padding with zip_longest so short INSERT VALUE rows fill missing columns with None without manual bounds checks. --- sql_metadata/parser.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sql_metadata/parser.py b/sql_metadata/parser.py index 162465f3..c80c337a 100644 --- a/sql_metadata/parser.py +++ b/sql_metadata/parser.py @@ -13,6 +13,7 @@ import logging import re +from itertools import zip_longest from typing import Any from sqlglot import exp @@ -487,10 +488,10 @@ def values_dict(self) -> dict[str, Any] | None: ) if is_multi: - # Pad short rows with None so ragged VALUES tuples do not IndexError. + # zip_longest pads short VALUES rows with None (avoids IndexError). self._values_dict = { - col: [row[i] if i < len(row) else None for row in values] - for i, col in enumerate(columns) + col: list(col_vals) + for col, col_vals in zip(columns, zip_longest(*values, fillvalue=None)) } else: self._values_dict = dict(zip(columns, values)) From b9f6ad3bf00dd8c45c743fcf844304270ead6d7a Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:00:13 -0700 Subject: [PATCH 3/4] fix: pad values_dict to full column width on short rows zip_longest alone sized the dict by the longest VALUES row, dropping declared columns when every row was shorter. Align to len(columns) and cover INSERT ... VALUES (1), (2). --- sql_metadata/parser.py | 12 +++++++++--- test/test_values.py | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/sql_metadata/parser.py b/sql_metadata/parser.py index c80c337a..26c69729 100644 --- a/sql_metadata/parser.py +++ b/sql_metadata/parser.py @@ -488,10 +488,16 @@ def values_dict(self) -> dict[str, Any] | None: ) if is_multi: - # zip_longest pads short VALUES rows with None (avoids IndexError). + # Pad short rows to column width so trailing columns stay present. + transposed = list(zip_longest(*values, fillvalue=None)) + n_rows = len(values) self._values_dict = { - col: list(col_vals) - for col, col_vals in zip(columns, zip_longest(*values, fillvalue=None)) + col: ( + list(transposed[i]) + if i < len(transposed) + else [None] * n_rows + ) + for i, col in enumerate(columns) } else: self._values_dict = dict(zip(columns, values)) diff --git a/test/test_values.py b/test/test_values.py index 34bd65c7..b8652311 100644 --- a/test/test_values.py +++ b/test/test_values.py @@ -158,6 +158,13 @@ def test_insert_multi_row_ragged_values_dict(): assert p.values_dict == {"a": [1, 3], "b": [2, None]} +def test_insert_multi_row_all_rows_shorter_than_columns(): + """Every VALUES row shorter than the column list still yields all keys.""" + p = Parser("INSERT INTO t (a, b) VALUES (1), (2)") + assert p.values == [[1], [2]] + assert p.values_dict == {"a": [1, 2], "b": [None, None]} + + def test_insert_with_expression_value(): """INSERT with a function call in VALUES uses str(val) fallback.""" p = Parser("INSERT INTO t (a) VALUES (CURRENT_TIMESTAMP)") From a698d428e39f08de5497f2735b9411e1096014fa Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:13:10 -0700 Subject: [PATCH 4/4] refactor: column-driven padding for values_dict short rows Replace zip_longest transpose with per-column indexing so multi-row and single-row INSERT paths both keep every declared column. --- sql_metadata/parser.py | 15 +++++---------- test/test_values.py | 7 +++++++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/sql_metadata/parser.py b/sql_metadata/parser.py index 26c69729..bb1ccc58 100644 --- a/sql_metadata/parser.py +++ b/sql_metadata/parser.py @@ -13,7 +13,6 @@ import logging import re -from itertools import zip_longest from typing import Any from sqlglot import exp @@ -488,19 +487,15 @@ def values_dict(self) -> dict[str, Any] | None: ) if is_multi: - # Pad short rows to column width so trailing columns stay present. - transposed = list(zip_longest(*values, fillvalue=None)) - n_rows = len(values) self._values_dict = { - col: ( - list(transposed[i]) - if i < len(transposed) - else [None] * n_rows - ) + col: [row[i] if i < len(row) else None for row in values] for i, col in enumerate(columns) } else: - self._values_dict = dict(zip(columns, values)) + self._values_dict = { + col: (values[i] if i < len(values) else None) + for i, col in enumerate(columns) + } return self._values_dict @property diff --git a/test/test_values.py b/test/test_values.py index b8652311..5d883a4e 100644 --- a/test/test_values.py +++ b/test/test_values.py @@ -165,6 +165,13 @@ def test_insert_multi_row_all_rows_shorter_than_columns(): assert p.values_dict == {"a": [1, 2], "b": [None, None]} +def test_insert_single_row_shorter_than_columns(): + """Single-row VALUES shorter than the column list still yields all keys.""" + p = Parser("INSERT INTO t (a, b) VALUES (1)") + assert p.values == [1] + assert p.values_dict == {"a": 1, "b": None} + + def test_insert_with_expression_value(): """INSERT with a function call in VALUES uses str(val) fallback.""" p = Parser("INSERT INTO t (a) VALUES (CURRENT_TIMESTAMP)")