Skip to content

core: fix ambiguous column error when searching or sorting#983

Merged
mmzeynalli merged 3 commits intosmithyhq:mainfrom
nurikk:fix_join_search
Apr 18, 2026
Merged

core: fix ambiguous column error when searching or sorting#983
mmzeynalli merged 3 commits intosmithyhq:mainfrom
nurikk:fix_join_search

Conversation

@nurikk
Copy link
Copy Markdown
Contributor

@nurikk nurikk commented Dec 30, 2025

Fixes SQLite "ambiguous column name" error raised when column_searchable_list or column_sortable_list references multiple fields from the same relationship (e.g. ["user.name", "user.email"]).

Cause

_join_relationship_paths joined via the target model class, which caused SQLAlchemy to emit duplicate JOINs across fields — and also failed with AmbiguousForeignKeysError when a model had multiple foreign keys to the same target (e.g. billing_address + shipping_address both pointing at Address).

Fix

  • Join via the relationship attribute (stmt.join(Model.relation)) so SQLAlchemy picks the right FK automatically.
  • Track joined paths per call to avoid duplicates within a single query; SQLAlchemy dedupes joins on the same relationship attribute across calls, so the previous statement-introspection helper is no longer needed and has been deleted.
  • Guard search_query against or_() being called with no arguments.

Tests

  • test_search_multi_fields_no_duplicate_joins — search alone with multiple fields from same relationship
  • test_sort_multi_fields_no_duplicate_joins — sort alone with multiple fields from same relationship
  • test_sort_then_search_no_duplicate_joins — cross-method dedup (sort then search)
  • test_search_two_fks_to_same_model — model with two FKs to the same target
  • test_sort_and_search_together_no_ambigious_column_error — HTTP-level regression guard

@mmzeynalli
Copy link
Copy Markdown
Member

@nurikk could you give me minimal code example in which the current code (without your patch) fails? I understand your description, but would like to test it myself as well.

@mmzeynalli mmzeynalli added the waiting-for-feedback Waiting feedback/answer/updates from contributor label Apr 4, 2026
@nurikk
Copy link
Copy Markdown
Contributor Author

nurikk commented Apr 4, 2026

@nurikk could you give me minimal code example in which the current code (without your patch) fails? I understand your description, but would like to test it myself as well.

here you go:

from fastapi import FastAPI
from sqlalchemy import Column, ForeignKey, Integer, String, create_engine
from sqlalchemy.orm import declarative_base, relationship
from sqladmin import Admin, ModelView

# Database setup
Base = declarative_base()
engine = create_engine(
    "sqlite:///example.db",
    connect_args={"check_same_thread": False},
)


class Author(Base):
    __tablename__ = "authors"

    id = Column(Integer, primary_key=True)
    
    first_name = Column(String)
    last_name = Column(String)
    

    books = relationship("Book", back_populates="author")

    def __str__(self):
        return self.name


class Book(Base):
    __tablename__ = "books"

    id = Column(Integer, primary_key=True)
    title = Column(String, nullable=False)
    year = Column(Integer)
    author_id = Column(Integer, ForeignKey("authors.id"), nullable=False)

    author = relationship("Author", back_populates="books")

    def __str__(self):
        return self.title


Base.metadata.create_all(engine)

# App setup
app = FastAPI()
admin = Admin(app, engine)

class BookAdmin(ModelView, model=Book):
    column_list = [Book.id, Book.title, Book.year, Book.author]
    column_searchable_list = [Book.title, "author.first_name", "author.last_name"]
    column_sortable_list = [Book.id, Book.title, Book.year]

admin.add_view(BookAdmin)

so basically issue here:

column_searchable_list = [Book.title, "author.first_name", "author.last_name"]

results in

sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) ambiguous column name: authors.first_name
[SQL: SELECT count(*) AS count_1
FROM (SELECT books.id AS id, books.title AS title, books.year AS year, books.author_id AS author_id
FROM books JOIN authors ON authors.id = books.author_id JOIN authors ON authors.id = books.author_id
WHERE lower(CAST(books.title AS VARCHAR)) LIKE lower(?) OR lower(CAST(authors.first_name AS VARCHAR)) LIKE lower(?) OR lower(CAST(authors.last_name AS VARCHAR)) LIKE lower(?) ORDER BY books.id ASC) AS anon_1]
[parameters: ('%asd%', '%asd%', '%asd%')]
(Background on this error at: https://sqlalche.me/e/20/e3q8)

but if you allow search only by one field, it works fine

column_searchable_list = [Book.title, "author.first_name"]

@mmzeynalli mmzeynalli removed the waiting-for-feedback Waiting feedback/answer/updates from contributor label Apr 4, 2026
@mmzeynalli mmzeynalli linked an issue Apr 5, 2026 that may be closed by this pull request
2 tasks
Copy link
Copy Markdown
Member

@mmzeynalli mmzeynalli left a comment

Choose a reason for hiding this comment

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

I just checked on my local. Your patch (and current main) has a gap. If one model has 2 different FKs to another model, you get error:

# tests/test_models.py

class Order(Base):
    __tablename__ = "orders"

    id = Column(Integer, primary_key=True)
    billing_address_id = Column(Integer, ForeignKey("addresses.id"))
    shipping_address_id = Column(Integer, ForeignKey("addresses.id"))

    billing_address = relationship("Address", foreign_keys=[billing_address_id])
    shipping_address = relationship("Address", foreign_keys=[shipping_address_id])

def test_search_two_fks_to_same_model() -> None:
    """Searching on two different relationships to the same model should produce two JOINs."""

    class OrderAdmin(ModelView, model=Order):
        column_searchable_list = ["billing_address.name", "shipping_address.name"]

    stmt = OrderAdmin().search_query(select(Order), "example")
    stmt_str = str(stmt)
    # Must have two separate joins to addresses (via different FKs)
    assert stmt_str.count("JOIN") == 2

Gives FAILED tests/test_models.py::test_search_two_fks_to_same_model - sqlalchemy.exc.AmbiguousForeignKeysError: Can't determine join between 'orders' and 'addresses'; tables have more th...

What works though, is:

    def _join_relationship_paths(
        self,
        stmt: Select,
        field_path: str,
        joined_paths: Set[str],
    ) -> Tuple[Select, Any]:
        """Join relationship paths and return the statement and target model.

        This helper method navigates through relationship paths (e.g., 'user.profile')
        and joins each relationship only once, tracking which paths have been joined
        to avoid duplicate JOINs. Joins via the relationship attribute so SQLAlchemy
        uses the correct foreign key constraint.

        Args:
            stmt: The SQLAlchemy select statement to modify
            field_path: The field path (e.g., 'user.profile.role')
            joined_paths: Set tracking which relationship paths have been joined

        Returns:
            Tuple of (modified statement, target model class)
        """
        model = self.model
        parts = field_path.split(".")

        current_path = ""
        for part in parts[:-1]:
            current_path = f"{current_path}.{part}" if current_path else part
            relationship_attr = getattr(model, part)
            next_model = relationship_attr.mapper.class_

            if current_path not in joined_paths:
                stmt = stmt.join(relationship_attr)
                joined_paths.add(current_path)

            model = next_model

        return stmt, model

Comment thread sqladmin/models.py Outdated
Comment thread tests/test_views/test_view_sync.py Outdated
Comment on lines +859 to +911
def test_search_multi_fields_no_duplicate_joins(client: TestClient) -> None:
class AddressAdmin(ModelView, model=Address):
column_searchable_list = [User.id, User.name]

admin.add_view(AddressAdmin)

with session_maker() as session:
user = User(name="Alice")
address = Address(user=user)
session.add_all([user, address])
session.commit()

response = client.get("/admin/address/list?search=Alice")
assert response.status_code == 200


def test_sort_multi_fields_no_duplicate_joins(client: TestClient) -> None:
class AddressAdmin(ModelView, model=Address):
column_sortable_list = [Address.id, User.id, User.name]

admin.add_view(AddressAdmin)

with session_maker() as session:
user1 = User(name="Bob")
user2 = User(name="Alice")
address1 = Address(user=user1)
address2 = Address(user=user2)
session.add_all([user1, user2, address1, address2])
session.commit()

response = client.get("/admin/address/list?sortBy=user.name&sort=asc")
assert response.status_code == 200


def test_sort_and_search_together_no_duplicate_joins(client: TestClient) -> None:
class AddressAdmin(ModelView, model=Address):
column_searchable_list = [User.name, User.id]
column_sortable_list = [Address.id, User.id, User.name]

admin.add_view(AddressAdmin)

with session_maker() as session:
user1 = User(name="Alice")
user2 = User(name="Bob")
user3 = User(name="Charlie")
address1 = Address(user=user1)
address2 = Address(user=user2)
address3 = Address(user=user3)
session.add_all([user1, user2, user3, address1, address2, address3])
session.commit()

response = client.get("/admin/address/list?sortBy=user.name&sort=asc&search=o")
assert response.status_code == 200
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 names are a little misleading. This names should be in test_models file, as you check for non-multiple joins. Here I would name test_sort_and_search_together_no_ambigious_column_error

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved the unit-level no_duplicate_joins tests to test_models.py and kept a single HTTP integration test here, renamed to test_sort_and_search_together_no_ambigious_column_error.

@mmzeynalli
Copy link
Copy Markdown
Member

Also, there is such warning in tests:

tests/test_views/test_view_sync.py::test_search_multi_fields_no_duplicate_joins
tests/test_views/test_view_sync.py::test_sort_and_search_together_no_duplicate_joins
  /home/mzeynall/Projects/sqladmin/sqladmin/models.py:1209: SADeprecationWarning: Invoking or_() without arguments is deprecated, and will be disallowed in a future release.   For an empty or_() construct, use 'or_(false(), *args)' or 'or_(False, *args)'.
    return stmt.filter(or_(*expressions))

@mmzeynalli mmzeynalli added the waiting-for-feedback Waiting feedback/answer/updates from contributor label Apr 7, 2026
@mmzeynalli
Copy link
Copy Markdown
Member

@nurikk any updates? We would like to release new version soon.

nurikk added 2 commits April 11, 2026 23:28
…e fields from same relationship

Fixes SQLite 'ambiguous column name' error that occurred when users configured
column_searchable_list or column_sortable_list with multiple fields from the
same related model (e.g., ['user.id', 'user.name']).

The issue happened because:
- Each field was independently joining the same table multiple times
- sort_query runs before search_query, so duplicate joins could occur across methods
- SQLite cannot resolve ambiguous column references with duplicate table joins

Solution:
- Added _get_joined_entities() to inspect existing joins in the SQL statement
- Added _join_relationship_paths() as DRY helper to track and prevent duplicate joins
- Refactored both search_query and sort_query to use the helper
- Joins are now tracked both by relationship path (within method) and by inspecting
  the statement (cross-method), ensuring no duplicates

Users can now safely use multiple fields from the same relationship in searchable
and sortable columns without encountering database errors.
Join via the relationship attribute instead of the target class so
SQLAlchemy picks the right foreign key when a model has multiple
relationships to the same target (e.g. billing/shipping addresses).
This lets us drop the statement introspection helper since SQLAlchemy
already dedupes joins on the same relationship attribute across calls.
Also guards search_query against or_() being called with no arguments.
@nurikk
Copy link
Copy Markdown
Contributor Author

nurikk commented Apr 11, 2026

Addressed in latest commit:

  • Two FKs to same model — fixed by joining via the relationship attribute (stmt.join(Model.relation)) instead of the target class, so SQLAlchemy picks the right FK. Dropped _get_joined_entities entirely since SQLAlchemy already dedupes joins on the same relationship attribute across calls. Regression covered by test_search_two_fks_to_same_model.
  • or_() deprecation warningsearch_query now returns early when no expressions are collected.
  • Test names/location — unit-level dedup tests consolidated in test_models.py; test_view_sync.py keeps one HTTP-level test renamed to test_sort_and_search_together_no_ambigious_column_error.

Comment thread sqladmin/models.py Outdated
@mmzeynalli mmzeynalli added ready-to-merge Approved and ready to merge. Will be published with the next release. and removed waiting-for-feedback Waiting feedback/answer/updates from contributor labels Apr 12, 2026
Replace early-return guard with or_(false(), *expressions) as
requested in review — the canonical SQLAlchemy pattern for a
potentially empty or_() clause.
@mmzeynalli mmzeynalli merged commit c89fccd into smithyhq:main Apr 18, 2026
12 checks passed
@mmzeynalli mmzeynalli mentioned this pull request Apr 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Approved and ready to merge. Will be published with the next release.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error while searching by multiple columns of joined model

2 participants