Skip to content

fix(pretty_export): stringify related objects when joining list values - #1112

Open
Sanjays2402 wants to merge 7 commits into
smithyhq:mainfrom
Sanjays2402:fix-pretty-export-relationship
Open

fix(pretty_export): stringify related objects when joining list values#1112
Sanjays2402 wants to merge 7 commits into
smithyhq:mainfrom
Sanjays2402:fix-pretty-export-relationship

Conversation

@Sanjays2402

Copy link
Copy Markdown

Closes #1111

PrettyExport._base_export_cell joined a relationship's formatted value with ",".join(formatted_value), but for an InstrumentedList the default formatter returns the related model instances themselves, so exporting a column_list containing a to-many relationship raised TypeError: sequence item 0: expected str instance, Address found. Each item is now coerced with str() before joining, matching how the non-list relationship branch renders its value.

Regression test added in tests/test_pretty_export.py beside the existing _get_export_row_values tests; it fails with the reported TypeError without the fix and passes with it.

This change was prepared with AI assistance; the regression test was run locally and fails without the fix.

_base_export_cell joined a relationship's formatted value with
",".join(formatted_value). The default formatter returns the related
model instances themselves for an InstrumentedList, so exporting a
column_list containing a to-many relationship raised
TypeError: sequence item 0: expected str instance, Address found.

Each item is now coerced with str() before joining, matching how the
non-list relationship branch renders its value.

Closes smithyhq#1111
Copilot AI lite review requested due to automatic review settings July 31, 2026 05:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes PrettyExport._base_export_cell to correctly export to-many relationship values by coercing each formatted item to str before joining, preventing TypeError when the default formatter returns related model instances (e.g., SQLAlchemy InstrumentedList of Address objects).

Changes:

  • Update relationship list rendering to ",".join(str(item) for item in formatted_value) to avoid joining non-strings.
  • Add a regression test covering exporting a column_list that includes a to-many relationship.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
sqladmin/pretty_export.py Coerces relationship list items to strings before joining to prevent export-time TypeError.
tests/test_pretty_export.py Adds regression test verifying relationship list export no longer errors and returns a joined string.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sqladmin/pretty_export.py
Comment thread tests/test_pretty_export.py Outdated
assert len(values) == 3
assert values[0] == 1
assert values[1] == "John Doe"
assert values[2] == ",".join(str(address) for address in user.addresses)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add __str__ to Adress

@mmzeynalli mmzeynalli added the needs-update There are stuff that needs updated and reviewed again label Aug 4, 2026
@mmzeynalli

Copy link
Copy Markdown
Member

One more thing. This PR considers relationships rendered as QuerySelectField (scalar — uselist=False, a plain many-to-one). It does not fix relationships rendered as QuerySelectMultipleField.

column_import_list = [User.name, User.addresses]
CSV:  name,addresses
      Bob,adg34gfb13

branch result: {"ok": true, "imported": 1, "skipped": 0, "missed_rows": []}

This PR works by keeping errors that fallback_form.validate() already reported. It depends on WTForms actually reporting one. For a collection relationship, validate() returns True with errors == {} even though the value is garbage. There is no error to keep, so the new block at lines 130–135 has nothing to copy and falls through silently.

Copilot AI review requested due to automatic review settings August 5, 2026 05:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

sqladmin/pretty_export.py:33

  • The iterable-join decision is based on formatted_value, not the underlying relationship value. This can change behavior for to-one relationships if a custom formatter returns an iterable (e.g., dict/list/tuple): it will now be comma-joined instead of being passed through. Also, joining sets without sorting yields nondeterministic output order. Consider joining only when the relationship value is a collection, and sort set values for stable exports.
            if isinstance(formatted_value, str):
                cell_value = formatted_value
            elif isinstance(formatted_value, Iterable):
                cell_value = ",".join(str(item) for item in formatted_value)
            else:

Comment thread sqladmin/pretty_export.py Outdated
cell_value = ",".join(formatted_value)
if isinstance(formatted_value, str):
cell_value = formatted_value
elif isinstance(formatted_value, Iterable):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(list, set, tuple, frozenset)

Lets do this instead of Iterable. Apparently, formatted_value can be dict as well:

class Order(Base):
    items = relationship("Item", collection_class=attribute_keyed_dict("sku"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even, do like this:

if isinstance(value, bool):
    cell_value = "TRUE" if value else "FALSE"
elif isinstance(formatted_value, str):
    cell_value = formatted_value
elif isinstance(formatted_value, (set, frozenset)):
    cell_value = ",".join(sorted(str(item) for item in formatted_value))
elif isinstance(formatted_value, (list, tuple)):
    cell_value = ",".join(str(item) for item in formatted_value)
else:
    cell_value = formatted_value

Bcuz, set and frozenset are unodered. and each export might give different set of items.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied your ladder in d4b0691, with the explicit types instead of Iterable.

if isinstance(formatted_value, str):
    cell_value = formatted_value
elif isinstance(formatted_value, (set, frozenset)):
    cell_value = ",".join(sorted(str(item) for item in formatted_value))
elif isinstance(formatted_value, (list, tuple)):
    cell_value = ",".join(str(item) for item in formatted_value)
else:
    cell_value = formatted_value

I kept the bool check in the non-relationship branch where it already was rather than hoisting it above the relationship branch - a bool can't be a relationship value, so moving it only changes which branch a non-relationship bool takes, and it currently takes that one.

Two tests added: test_get_export_row_values_with_set_relationship_is_sorted (three tags inserted as vip/beta/alpha, asserts alpha,beta,vip, so it pins the ordering rather than just the membership) and test_get_export_row_values_with_dict_relationship using collection_class=attribute_keyed_dict("sku"). Both fail on the previous commit - the dict one used to come out as sku-1 because Iterable caught the dict and joined its keys.

One thing to decide: with the explicit tuple, an attribute_keyed_dict relationship now falls through to else and the raw dict reaches the writer, so the CSV cell is the dict repr. That is what your snippet does and it is at least not silently wrong the way key-joining was, but if you'd rather it render as ",".join(sorted(str(v) for v in formatted_value.values())) say so and I'll add the branch plus flip the test.

Also changed the _base_export_cell return annotation from str to Any - it was never accurate, the else arms return whatever the formatter gave (int, date, None), and now a dict too.

Comment thread sqladmin/pretty_export.py Outdated
elif isinstance(formatted_value, Iterable):
cell_value = ",".join(str(item) for item in formatted_value)
else:
cell_value = formatted_value

This comment was marked as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keyed collections (attribute_keyed_dict) aren't list/tuple/set/str, so they
fall through here and reach the CSV as a Python repr:

1,"Main St,Second St","alpha,vip","{'sku-1': <__main__.Item object at 0x7f55d58e03d0>, ...}"

Two runs of the same export over the same rows:

run 1: ...<__main__.Item object at 0x7f55d58e03d0>...
run 2: ...<__main__.Item object at 0x7fe63fae0390>...

Which is the opposite of the comment three lines up — sets became reproducible in
the same commit that left mappings not reproducible. It also leaks the module path
and ignores __str__ on the related model, which every other branch honours.

9a3e5a6 had this right via Iterable (it produced sku-1,sku-2). Narrowing away
from Iterable was a sound instinct, but could we name dict rather than let it
fall through?

elif isinstance(formatted_value, dict):
    cell_value = ",".join(str(item) for item in formatted_value.values())

Comment thread sqladmin/pretty_export.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return type is Any

Copilot AI review requested due to automatic review settings August 6, 2026 12:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

tests/test_pretty_export.py:190

  • This assertion assumes the addresses relationship is loaded in a deterministic order ("Main St" then "Second St"). Without an order_by on the relationship, most databases do not guarantee row ordering, so this test can be flaky depending on the backend/query plan. Since the intent is to verify stringification/joining, make the assertion order-insensitive (or add order_by to the relationship).
            assert len(values) == 3
            assert values[0] == 1
            assert values[1] == "John Doe"
            assert values[2] == "Main St,Second St"

@mmzeynalli mmzeynalli removed the needs-update There are stuff that needs updated and reviewed again label Sep 2, 2026
Comment thread sqladmin/pretty_export.py Outdated
elif isinstance(formatted_value, Iterable):
cell_value = ",".join(str(item) for item in formatted_value)
else:
cell_value = formatted_value

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keyed collections (attribute_keyed_dict) aren't list/tuple/set/str, so they
fall through here and reach the CSV as a Python repr:

1,"Main St,Second St","alpha,vip","{'sku-1': <__main__.Item object at 0x7f55d58e03d0>, ...}"

Two runs of the same export over the same rows:

run 1: ...<__main__.Item object at 0x7f55d58e03d0>...
run 2: ...<__main__.Item object at 0x7fe63fae0390>...

Which is the opposite of the comment three lines up — sets became reproducible in
the same commit that left mappings not reproducible. It also leaks the module path
and ignores __str__ on the related model, which every other branch honours.

9a3e5a6 had this right via Iterable (it produced sku-1,sku-2). Narrowing away
from Iterable was a sound instinct, but could we name dict rather than let it
fall through?

elif isinstance(formatted_value, dict):
    cell_value = ",".join(str(item) for item in formatted_value.values())

@mmzeynalli mmzeynalli added the needs-update There are stuff that needs updated and reviewed again label Sep 3, 2026

@mmzeynalli mmzeynalli left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect. Almost ready! We just need one more test)

Comment thread sqladmin/pretty_export.py
Comment on lines 39 to 41
else:
if isinstance(value, bool):
cell_value = "TRUE" if value else "FALSE"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test missing for this line.

What still lands here is a to-one relationship, and on a related model without
__str__ that exports as the same thing this commit just fixed for mappings:

column_export_list = ["id", "author", "publisher"]
  Author has no __str__  ->  <__main__.Author object at 0x7f...>
  Publisher has __str__  ->  P

CSV LINE -> 1,<__main__.Author object at 0x7f...>,P

Identical on current main, so not something this PR caused — but it's the same bug class,
so we might fix it in this PR

@mmzeynalli mmzeynalli added waiting-for-tests Feature is ready, but tests are missing and removed needs-update There are stuff that needs updated and reviewed again labels Sep 8, 2026
@Sanjays2402

Copy link
Copy Markdown
Author

Thanks @mmzeynalli — that last test is in (ed1f827).

The to-one fall-through now honours __str__ like every other branch: cell_value = None if formatted_value is None else str(formatted_value), so a related model with __str__ exports its string form and a missing relation still exports as an empty cell (not the string "None").

Added test_get_export_row_values_with_to_one_relationship with Book/Publisher models — Publisher defines __str__ returning its name, so the test pins publisher exporting as "P", plus the None case. Also verified no line exceeds 88 chars for ruff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-for-tests Feature is ready, but tests are missing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PrettyExport with relationship and _base_export_cell return error

3 participants