Skip to content

Fix: Populate FK columns for SQLModel/SQLAlchemy relationships - #728

Open
sachin-gracious wants to merge 5 commits into
jowilf:mainfrom
sachin-gracious:fix/sqlmodel-fk-validation
Open

Fix: Populate FK columns for SQLModel/SQLAlchemy relationships#728
sachin-gracious wants to merge 5 commits into
jowilf:mainfrom
sachin-gracious:fix/sqlmodel-fk-validation

Conversation

@sachin-gracious

Copy link
Copy Markdown

Fixes validation errors when creating/editing records with required foreign key relationships by using SQLAlchemy introspection to automatically populate FK columns.

Problem:

  • _arrange_data loaded relationship objects but didn't populate FK columns
  • Pydantic validation failed with 'Field required' error
  • Only affected MANYTOONE (HasOne) relationships with required FKs

Solution:

  • Use SQLAlchemy's inspect() and synchronize_pairs to discover FK columns
  • Works with ANY FK naming (user_id, owner_id, created_by, etc.)
  • Works with ANY PK naming (id, uuid, etc.)
  • Handles composite keys, None relationships, self-referential FKs
  • Does NOT affect ONETOMANY or MANYTOMANY relationships

Changes:

  • Modified: starlette_admin/contrib/sqla/view.py (_arrange_data method)
  • Added: tests/sqla/test_sqlmodel_custom_fk.py (custom FK naming tests)
  • Added: tests/sqla/test_sqlmodel_manytomany.py (association table tests)
  • All 94 tests pass (87 existing + 7 new)

Fixes: #485, #687

@sachin-gracious
sachin-gracious marked this pull request as draft January 27, 2026 10:07
Fixes validation errors when creating/editing records with required foreign key relationships by using SQLAlchemy introspection to automatically populate FK columns.

Problem:
- _arrange_data loaded relationship objects but didn't populate FK columns
- Pydantic validation failed with 'Field required' error
- Only affected MANYTOONE (HasOne) relationships with required FKs

Solution:
- Use SQLAlchemy's inspect() and synchronize_pairs to discover FK columns
- Works with ANY FK naming (user_id, owner_id, created_by, etc.)
- Works with ANY PK naming (id, uuid, etc.)
- Handles composite keys, None relationships, self-referential FKs
- Does NOT affect ONETOMANY or MANYTOMANY relationships

Changes:
- Modified: starlette_admin/contrib/sqla/view.py (_arrange_data method)
- Added: tests/sqla/test_sqlmodel_custom_fk.py (custom FK naming tests)
- Added: tests/sqla/test_sqlmodel_manytomany.py (association table tests)
- All 94 tests pass (87 existing + 7 new)

Fixes: jowilf#485, jowilf#687
@sachin-gracious
sachin-gracious force-pushed the fix/sqlmodel-fk-validation branch from 26fb98f to b4102f5 Compare January 28, 2026 09:28
@codecov

codecov Bot commented Jan 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (0f684fe) to head (3cda126).

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #728    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           86        88     +2     
  Lines         6848      7024   +176     
==========================================
+ Hits          6848      7024   +176     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sachin-gracious
sachin-gracious marked this pull request as ready for review January 28, 2026 09:57
@jowilf jowilf added this to the 0.17.0 milestone Apr 16, 2026

@jowilf jowilf left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

how is this setting the foreign columns?

- Remove '# pragma: no cover' from else branch
- Add test_nonexistent_relationship_id to cover the case where
  find_by_pk returns None for a non-existent relationship ID
- Add Project model and fixture to support the new test
@sachin-gracious

Copy link
Copy Markdown
Author

how is this setting the foreign columns?

Consider this model:

class Employee(SQLModel, table=True):
    id:         Optional[int] = Field(None, primary_key=True)
    name:       str
    dept_id:    int            = Field(foreign_key="dept.id")  # required, custom name
    department: Optional[Department] = Relationship()

Step 1 — Admin excludes FK columns from its fields list

converters.py:133 skips any column with foreign_keys set when building the admin fields list:

if not column.foreign_keys:
    converted_fields.append(converted_field)

Running against the real ModelView(Employee):

id         IntegerField   ← included
name       StringField    ← included
dept_id    (excluded)     ← has foreign_keys=True, skipped
department HasOne         ← included

This is intentional — the FK column is excluded so users are not shown a raw integer input when the relationship field already handles the association.


Step 2 — _arrange_data only iterates over the admin fields list

The form submits department=1. _arrange_data loads the related object via find_by_pk and writes it into arranged_data. Because dept_id is not in the fields list, it is never iterated over and never written into arranged_data.

Without the fix, tracing the actual arranged_data:

arranged_data keys: ['name', 'department']
  'name':       'Alice'
  'department': Department(id=1, name='Engineering')

Step 3 — Where validation fails

sqlmodel/view.py calls Pydantic validation on arranged_data before any model instance is created:

self.model.validate(
    {k: v for k, v in data.items() if k not in fields_to_exclude}
)

fields_to_exclude is built from RelationField names — so department is stripped. Pydantic receives:

fields_to_exclude: ['department']
dict sent to Pydantic: {'name': 'Alice'}
→ Response: 422

Pydantic validates this dict against the full model class definition. dept_id: int has no default — it is required. It is not in the dict. Pydantic raises Field required.

The critical point: fields_to_exclude only covers RelationFields. dept_id was excluded from the admin fields list entirely — it was never written into arranged_data — so it never reached fields_to_exclude either. It simply does not exist in the dict Pydantic receives.


Step 4 — What the fix does

After find_by_pk loads the related object, _populate_fk_columns uses synchronize_pairs to discover the FK column that backs the relationship and writes the real value into arranged_data:

for remote_col, local_col in rel_prop.synchronize_pairs:
    pk_value = getattr(related_obj, remote_col.name)
    arranged_data[local_col.name] = pk_value

synchronize_pairs returns the exact mapping from SQLAlchemy's own introspection:

remote: dept.id  →  local: emp.dept_id
writes: arranged_data["dept_id"] = 1

Tracing the actual arranged_data and validate() with the fix:

arranged_data: {'name': 'Alice', 'department': Department(id=1, ...), 'dept_id': 1}

fields_to_exclude: ['department']
dict sent to Pydantic: {'name': 'Alice', 'dept_id': 1}
→ Response: 303

The value written is read directly from the already-loaded related object. The actual persistence of the FK column still happens through SQLAlchemy's normal relationship synchronization during session.commit(). This fix only ensures the Pydantic validation step receives a complete dict.

Using synchronize_pairs rather than naming conventions means this works for any FK name (dept_id, owner_id, created_by), any PK name (id, uuid), and composite keys.


Note on the else branch in _populate_fk_columns

The else branch handles the case where find_by_pk returns None — meaning the user submitted a relationship ID that no longer exists in the DB:

POST department=999 (non-existent ID)
  find_by_pk(pk='999') -> None
  _populate_fk_columns: ELSE branch fires
  arranged_data: {'name': 'Alice', 'department': None, 'dept_id': None}
  → Response: 422

The FK is set to None, Pydantic rejects it because dept_id: int is required, and the record is never saved in an invalid state.

I have added test_nonexistent_relationship_id which submits a non-existent ID (organization=99999) — find_by_pk returns None and the else branch is exercised. The # pragma: no cover can be removed since the branch is now covered.

@jowilf

jowilf commented Jun 5, 2026

Copy link
Copy Markdown
Owner

is this generated by AI ?

@jowilf jowilf removed this from the 0.17.0 milestone Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Required foreign key is empty

2 participants