Skip to content

Commit 4038705

Browse files
authored
feat: struct-aware schema_dump default + values_from / set_from builder methods (#442)
## Summary - Flip `schema_dump` `wire_format` default from `True` to `False`. Cross-library consistency: `msgspec.Struct` now matches Pydantic / dataclass / attrs by default. Wire-aligned output (camelCase via `field.encode_name`) requires explicit `wire_format=True`. - Add `Insert.values_from`, `Insert.values_from_many`, `Update.set_from`. Each accepts any schema kind (dict, dataclass, msgspec, Pydantic, attrs) and dispatches through `schema_dump(wire_format=False)`. - Deprecate `Insert.values_from_dict` / `Insert.values_from_dicts`. Both emit `DeprecationWarning` pointing at the new methods. Removal target: 0.48.0. - `values_from_many` now uses `serialize_collection` (per-batch type-keyed cache) instead of a per-item `schema_dump` loop. ## Why `schema_dump`'s prior default (`wire_format=True`) silently emitted `field.encode_name` keys for `msgspec.Struct(rename=...)` instances — meaning `sql.insert(t).columns(*schema_dump(struct).keys()).values(...)` against snake_case Postgres tables blew up with `column "userId" does not exist` whenever the source schema declared `rename="camel"`. ## Behavior change | Caller pattern | Before | After | |---|---|---| | `schema_dump(struct)` on `msgspec.Struct(rename="camel")` | `{"userId": ...}` | `{"user_id": ...}` | | `schema_dump(struct, wire_format=True)` | `{"userId": ...}` | `{"userId": ...}` (unchanged) | | `schema_dump(pydantic_model)` / `dataclass` / `attrs` | Python names | Python names (unchanged) | | `Insert.values_from_dict({...})` | Silent | Emits `DeprecationWarning` |
1 parent ecc67be commit 4038705

9 files changed

Lines changed: 491 additions & 60 deletions

File tree

docs/changelog.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,36 @@ Recent Updates
1313
v0.46.2 - Framework Filter ``orderBy`` Aliases (Unreleased)
1414
-----------------------------------------------------------
1515

16+
**Changed (breaking default):**
17+
18+
* ``sqlspec.utils.serializers.schema_dump`` (and its helpers
19+
``serialize_collection`` / ``get_collection_serializer``) now default
20+
``wire_format=False``. Calling ``schema_dump(struct)`` on a ``msgspec.Struct``
21+
declared with ``rename=`` now returns Python attribute names by default
22+
(matching Pydantic, dataclass, and attrs output) instead of wire-aligned
23+
``field.encode_name`` keys. Pass ``wire_format=True`` explicitly to restore
24+
the prior wire-aligned output for JSON / API payloads. This default flip
25+
closes the silent footgun where ``sql.update(t).set(**schema_dump(struct))``
26+
emitted camelCase column names for snake_case database tables. The kwarg
27+
remains a no-op for non-msgspec inputs.
28+
29+
**Added:**
30+
31+
* ``Insert.values_from(data, *, exclude_unset=True)``,
32+
``Insert.values_from_many(items, *, exclude_unset=True)``, and
33+
``Update.set_from(data, *, exclude_unset=True)`` builder methods. Each accepts
34+
any schema kind (dict, dataclass, ``msgspec.Struct``, ``pydantic.BaseModel``,
35+
attrs class) and dispatches through ``schema_dump(wire_format=False)`` so SQL
36+
column names are always Python attribute names regardless of any wire-rename
37+
meta on the source schema.
38+
39+
**Deprecated:**
40+
41+
* ``Insert.values_from_dict`` and ``Insert.values_from_dicts`` now emit a
42+
``DeprecationWarning`` pointing at ``Insert.values_from`` /
43+
``Insert.values_from_many`` respectively. The dict-shaped methods continue to
44+
work; they are scheduled for removal in 0.48.0.
45+
1646
**Fixed:**
1747

1848
* Missing named SQL statements now raise ``SQLStatementNotFoundError``, a

sqlspec/builder/_dml.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sqlspec.builder._parsing_utils import extract_sql_object_expression
1212
from sqlspec.exceptions import SQLBuilderError
1313
from sqlspec.protocols import SQLBuilderProtocol
14+
from sqlspec.utils.serializers import schema_dump
1415
from sqlspec.utils.type_guards import has_expression_and_sql, has_parameter_builder, is_dict
1516

1617
__all__ = (
@@ -322,6 +323,26 @@ def set(self, *args: Any, **kwargs: Any) -> Self:
322323
current_expr.set("expressions", existing + assignments)
323324
return self
324325

326+
def set_from(self, data: Any, *, exclude_unset: bool = True) -> Self:
327+
"""Set columns from a dict, dataclass, msgspec.Struct, Pydantic model, or attrs class.
328+
329+
Schema instances are normalised via :func:`sqlspec.utils.serializers.schema_dump`
330+
with ``wire_format=False`` and dispatched to :meth:`set` via keyword unpack. The
331+
dict shape uses Python attribute names regardless of msgspec ``rename=`` or
332+
Pydantic ``Field(alias=...)``.
333+
334+
Args:
335+
data: A dict, dataclass instance, ``msgspec.Struct``, ``pydantic.BaseModel``,
336+
or ``attrs``-decorated class instance.
337+
exclude_unset: If True, exclude fields that were never set (msgspec UNSET,
338+
Pydantic ``model_fields_set``, dataclass empty defaults). No-op for attrs.
339+
340+
Returns:
341+
The current builder instance for method chaining.
342+
"""
343+
payload = schema_dump(data, exclude_unset=exclude_unset, wire_format=False)
344+
return self.set(**payload)
345+
325346

326347
@trait
327348
class UpdateFromClauseMixin:

sqlspec/builder/_insert.py

Lines changed: 107 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from sqlspec.builder._select import ReturningClauseMixin
1717
from sqlspec.core import SQLResult
1818
from sqlspec.exceptions import SQLBuilderError
19+
from sqlspec.utils.deprecation import warn_deprecation
20+
from sqlspec.utils.serializers import schema_dump, serialize_collection
1921
from sqlspec.utils.type_guards import has_expression_and_sql
2022

2123
if TYPE_CHECKING:
@@ -104,21 +106,8 @@ def get_insert_expression(self) -> exp.Insert:
104106
"""Get the insert expression (public API)."""
105107
return self._get_insert_expression()
106108

107-
def values_from_dict(self, data: "Mapping[str, Any]") -> "Self":
108-
"""Adds a row of values from a dictionary.
109-
110-
This is a convenience method that automatically sets columns based on
111-
the dictionary keys and values based on the dictionary values.
112-
113-
Args:
114-
data: A mapping of column names to values.
115-
116-
Returns:
117-
The current builder instance for method chaining.
118-
119-
Raises:
120-
SQLBuilderError: If `into()` has not been called to set the table.
121-
"""
109+
def _bind_values_from_dict(self, data: "Mapping[str, Any]") -> "Self":
110+
"""Bind a single dict row without deprecation warnings (internal)."""
122111
insert_expr = self._get_insert_expression()
123112
if insert_expr.args.get("this") is None:
124113
raise SQLBuilderError(ERR_MSG_TABLE_NOT_SET)
@@ -132,21 +121,8 @@ def values_from_dict(self, data: "Mapping[str, Any]") -> "Self":
132121

133122
return self.values(*[data[col] for col in self._columns])
134123

135-
def values_from_dicts(self, data: "Sequence[Mapping[str, Any]]") -> "Self":
136-
"""Adds multiple rows of values from a sequence of dictionaries.
137-
138-
This is a convenience method for bulk inserts from structured data.
139-
140-
Args:
141-
data: A sequence of mappings, each representing a row of data.
142-
143-
Returns:
144-
The current builder instance for method chaining.
145-
146-
Raises:
147-
SQLBuilderError: If `into()` has not been called to set the table,
148-
or if dictionaries have inconsistent keys.
149-
"""
124+
def _bind_values_from_dicts(self, data: "Sequence[Mapping[str, Any]]") -> "Self":
125+
"""Bind many dict rows without deprecation warnings (internal)."""
150126
if not data:
151127
return self
152128

@@ -168,6 +144,107 @@ def values_from_dicts(self, data: "Sequence[Mapping[str, Any]]") -> "Self":
168144

169145
return self
170146

147+
def values_from_dict(self, data: "Mapping[str, Any]") -> "Self":
148+
"""Adds a row of values from a dictionary.
149+
150+
.. deprecated:: 0.46.2
151+
Use :meth:`values_from` instead — it accepts dicts, msgspec Structs,
152+
Pydantic models, dataclasses, and attrs classes.
153+
154+
Args:
155+
data: A mapping of column names to values.
156+
157+
Returns:
158+
The current builder instance for method chaining.
159+
160+
Raises:
161+
SQLBuilderError: If `into()` has not been called to set the table.
162+
"""
163+
warn_deprecation(
164+
version="0.46.2",
165+
deprecated_name="Insert.values_from_dict",
166+
kind="method",
167+
alternative="Insert.values_from() — accepts dicts, msgspec Structs, Pydantic models, dataclasses, and attrs classes",
168+
removal_in="0.48.0",
169+
)
170+
return self._bind_values_from_dict(data)
171+
172+
def values_from_dicts(self, data: "Sequence[Mapping[str, Any]]") -> "Self":
173+
"""Adds multiple rows of values from a sequence of dictionaries.
174+
175+
.. deprecated:: 0.46.2
176+
Use :meth:`values_from_many` instead — it accepts dicts, msgspec Structs,
177+
Pydantic models, dataclasses, and attrs classes.
178+
179+
Args:
180+
data: A sequence of mappings, each representing a row of data.
181+
182+
Returns:
183+
The current builder instance for method chaining.
184+
185+
Raises:
186+
SQLBuilderError: If `into()` has not been called to set the table,
187+
or if dictionaries have inconsistent keys.
188+
"""
189+
warn_deprecation(
190+
version="0.46.2",
191+
deprecated_name="Insert.values_from_dicts",
192+
kind="method",
193+
alternative="Insert.values_from_many() — accepts dicts, msgspec Structs, Pydantic models, dataclasses, and attrs classes",
194+
removal_in="0.48.0",
195+
)
196+
return self._bind_values_from_dicts(data)
197+
198+
def values_from(self, data: Any, *, exclude_unset: bool = True) -> "Self":
199+
"""Add a row of values from a dict, dataclass, msgspec.Struct, Pydantic model, or attrs class.
200+
201+
Schema instances are normalised via :func:`sqlspec.utils.serializers.schema_dump`
202+
with ``wire_format=False`` then bound as a single row. The dict shape uses Python
203+
attribute names regardless of msgspec ``rename=`` or Pydantic ``Field(alias=...)``;
204+
wire-aligned names never make sense for SQL column binding.
205+
206+
Args:
207+
data: A dict, dataclass instance, ``msgspec.Struct``, ``pydantic.BaseModel``,
208+
or ``attrs``-decorated class instance.
209+
exclude_unset: If True, exclude fields that were never set. Honoured by
210+
msgspec (UNSET fields), Pydantic (``model_fields_set``), and dataclass
211+
(via empty default-field semantics). No-op for attrs.
212+
213+
Returns:
214+
The current builder instance for method chaining.
215+
216+
Raises:
217+
SQLBuilderError: If ``into()`` has not been called or the dict shape is
218+
incompatible with previously set columns.
219+
"""
220+
payload = schema_dump(data, exclude_unset=exclude_unset, wire_format=False)
221+
return self._bind_values_from_dict(payload)
222+
223+
def values_from_many(self, items: "Sequence[Any]", *, exclude_unset: bool = True) -> "Self":
224+
"""Add multiple rows from a sequence of dicts, dataclasses, msgspec Structs, Pydantic models, or attrs classes.
225+
226+
Each item is normalised via :func:`sqlspec.utils.serializers.schema_dump` with
227+
``wire_format=False`` then bound as a multi-row INSERT. Mixed schema kinds in the
228+
same sequence are supported (each item normalises independently); however, the
229+
resulting dict shapes must agree on key sets.
230+
231+
Args:
232+
items: A sequence of schema instances or dicts.
233+
exclude_unset: See :meth:`values_from` for per-library semantics.
234+
235+
Returns:
236+
The current builder instance for method chaining. Empty input returns the
237+
builder unchanged.
238+
239+
Raises:
240+
SQLBuilderError: If ``into()`` has not been called or item dict shapes
241+
disagree on key sets.
242+
"""
243+
if not items:
244+
return self
245+
payload = serialize_collection(items, exclude_unset=exclude_unset, wire_format=False)
246+
return self._bind_values_from_dicts(payload)
247+
171248
def on_conflict(self, *columns: str) -> "ConflictBuilder":
172249
"""Adds an ON CONFLICT clause with specified columns.
173250

sqlspec/utils/serializers/_schema.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def _build_dump_function(sample: Any, exclude_unset: bool, wire_format: bool) ->
201201

202202

203203
def get_collection_serializer(
204-
sample: Any, *, exclude_unset: bool = True, wire_format: bool = True
204+
sample: Any, *, exclude_unset: bool = True, wire_format: bool = False
205205
) -> "SchemaSerializer":
206206
"""Return cached serializer pipeline for the provided sample object."""
207207
key = _make_serializer_key(sample, exclude_unset, wire_format)
@@ -219,7 +219,7 @@ def get_collection_serializer(
219219

220220

221221
def serialize_collection(
222-
items: "Iterable[Any]", *, exclude_unset: bool = True, wire_format: bool = True
222+
items: "Iterable[Any]", *, exclude_unset: bool = True, wire_format: bool = False
223223
) -> "list[Any]":
224224
"""Serialize a collection using cached pipelines keyed by item type."""
225225
serialized: list[Any] = []
@@ -254,19 +254,20 @@ def get_serializer_metrics() -> "dict[str, int]":
254254
return metrics
255255

256256

257-
def schema_dump(data: Any, *, exclude_unset: bool = True, wire_format: bool = True) -> Any:
257+
def schema_dump(data: Any, *, exclude_unset: bool = True, wire_format: bool = False) -> Any:
258258
"""Dump a schema model or dict to a plain representation.
259259
260260
Args:
261261
data: A schema instance (msgspec.Struct, Pydantic BaseModel, dataclass, attrs class)
262262
or plain dict / primitive.
263263
exclude_unset: If True, exclude fields that were never set (msgspec UNSET, Pydantic
264264
model_fields_set semantics). No-op for attrs (attrs has no unset concept).
265-
wire_format: msgspec-only knob. Default True keeps the historical behavior of
266-
emitting ``field.encode_name`` (honours ``rename=`` on the Struct). Pass
267-
``wire_format=False`` to opt msgspec into Python attribute names (``field.name``)
268-
for cross-library consistency. Pydantic, dataclass, and attrs branches always
269-
use Python attribute names regardless of this flag.
265+
wire_format: msgspec-only knob. Default ``False`` emits Python attribute names
266+
(``field.name``) for cross-library consistency — keys match Pydantic, dataclass,
267+
and attrs output regardless of the Struct's ``rename=`` meta. Pass
268+
``wire_format=True`` to emit ``field.encode_name`` (honours ``rename=`` on the
269+
Struct) for wire-aligned JSON / API payloads. Pydantic, dataclass, and attrs
270+
branches always use Python attribute names regardless of this flag.
270271
"""
271272
if is_dict(data):
272273
return data

tests/unit/builder/test_insert_builder.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ def test_insert_with_table_in_constructor() -> None:
3333

3434

3535
def test_insert_values_from_dict() -> None:
36-
"""Test INSERT using values_from_dict method."""
36+
"""Test INSERT using values_from_dict method (deprecated; emits DeprecationWarning)."""
3737
data = {"id": 1, "name": "John", "status": "active"}
38-
query = sql.insert("users").values_from_dict(data)
38+
with pytest.warns(DeprecationWarning, match=r"values_from"):
39+
query = sql.insert("users").values_from_dict(data)
3940
stmt = query.build()
4041

4142
assert "INSERT INTO" in stmt.sql
@@ -46,9 +47,10 @@ def test_insert_values_from_dict() -> None:
4647

4748

4849
def test_insert_values_from_dicts_multiple_rows() -> None:
49-
"""Test INSERT using values_from_dicts for multiple rows."""
50+
"""Test INSERT using values_from_dicts for multiple rows (deprecated; emits DeprecationWarning)."""
5051
data = [{"id": 1, "name": "John"}, {"id": 2, "name": "Jane"}, {"id": 3, "name": "Bob"}]
51-
query = sql.insert("users").values_from_dicts(data)
52+
with pytest.warns(DeprecationWarning, match=r"values_from"):
53+
query = sql.insert("users").values_from_dicts(data)
5254
stmt = query.build()
5355

5456
assert "INSERT INTO" in stmt.sql
@@ -130,9 +132,12 @@ def test_insert_columns_and_values_consistency() -> None:
130132

131133

132134
def test_insert_inconsistent_dict_keys_error() -> None:
133-
"""Test that inconsistent dictionary keys in values_from_dicts raises error."""
135+
"""Test that inconsistent dictionary keys in values_from_dicts raises error (deprecated method)."""
134136
data = [{"id": 1, "name": "John"}, {"id": 2, "email": "jane@test.com"}]
135-
with pytest.raises(SQLBuilderError, match="do not match expected keys"):
137+
with (
138+
pytest.warns(DeprecationWarning, match=r"values_from"),
139+
pytest.raises(SQLBuilderError, match="do not match expected keys"),
140+
):
136141
sql.insert("users").values_from_dicts(data).build()
137142

138143

@@ -310,12 +315,42 @@ def test_on_conflict_parameter_merging() -> None:
310315

311316

312317
def test_on_conflict_with_values_from_dict() -> None:
313-
"""Test ON CONFLICT with values_from_dict."""
318+
"""Test ON CONFLICT with values_from_dict (deprecated method emits DeprecationWarning)."""
314319
data = {"id": 1, "name": "John", "email": "john@test.com"}
315-
query = sql.insert("users").values_from_dict(data).on_conflict("id").do_update(name="Updated")
320+
with pytest.warns(DeprecationWarning, match=r"values_from"):
321+
query = sql.insert("users").values_from_dict(data).on_conflict("id").do_update(name="Updated")
316322
stmt = query.build()
317323

318324
assert "ON CONFLICT" in stmt.sql
319325
assert "DO UPDATE" in stmt.sql
320326
assert "name_1" in stmt.parameters
321327
assert stmt.parameters["name_1"] == "Updated"
328+
329+
330+
def test_values_from_dict_warning_message_contains_migration_pointer() -> None:
331+
"""Warning text must name the replacement API explicitly."""
332+
import warnings
333+
334+
with warnings.catch_warnings(record=True) as caught:
335+
warnings.simplefilter("always")
336+
sql.insert("users").values_from_dict({"id": 1})
337+
338+
deprecation_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
339+
assert len(deprecation_warnings) == 1
340+
msg = str(deprecation_warnings[0].message)
341+
assert "values_from" in msg
342+
assert "msgspec" in msg or "Pydantic" in msg or "dataclass" in msg
343+
344+
345+
def test_values_from_dicts_warning_message_contains_migration_pointer() -> None:
346+
"""values_from_dicts warning must point at values_from_many()."""
347+
import warnings
348+
349+
with warnings.catch_warnings(record=True) as caught:
350+
warnings.simplefilter("always")
351+
sql.insert("users").values_from_dicts([{"id": 1}])
352+
353+
deprecation_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
354+
assert len(deprecation_warnings) == 1
355+
msg = str(deprecation_warnings[0].message)
356+
assert "values_from_many" in msg

tests/unit/builder/test_parameter_naming.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,8 @@ def test_insert_with_columns_uses_column_names() -> None:
173173

174174

175175
def test_insert_values_from_dict_preserves_keys() -> None:
176-
"""Test that INSERT values_from_dict preserves dictionary keys."""
177-
query = sql.insert("orders").values_from_dict({
176+
"""Test that INSERT values_from preserves dictionary keys."""
177+
query = sql.insert("orders").values_from({
178178
"customer_id": 12345,
179179
"product_name": "Widget",
180180
"quantity": 3,
@@ -314,7 +314,7 @@ def test_no_generic_param_names_in_update_operations() -> None:
314314
def test_no_generic_param_names_in_insert_operations() -> None:
315315
"""Test that INSERT operations use descriptive parameter names."""
316316
test_cases = [
317-
sql.insert("users").values_from_dict({"name": "John", "email": "john@test.com"}),
317+
sql.insert("users").values_from({"name": "John", "email": "john@test.com"}),
318318
sql.insert("posts").columns("title", "content").values("Hello", "World"),
319319
]
320320

0 commit comments

Comments
 (0)