Repository: mathesar-foundation/mathesar

Issue:
DB Query joining for demo video broken
## Description
<!-- A clear and concise description of what the bug is. -->

Under certain circumstances, the auto-joining behavior of the DB query infrastructure doesn't work. The error thrown is:

```
NoReferencedTableError: 
Foreign key associated with column 'Publications.Publisher id' could not find table 'Library Management.Publishers' with which to generate a foreign key to target column 'id'
```

## Expected behavior
<!-- A clear and concise description of what you expected to happen. -->

The joining should always work.

## To Reproduce
<!-- How can we recreate this bug? Please try to provide a Minimal, Complete, and Verifiable (http://stackoverflow.com/help/mcve) example if code-related. -->

Using the library data set, Create a DB query that would create an overdue book report. In particular, you should join the `"Checkouts"` table with the `"Publications"` table via the `"Items"` table. Note that when you try to get records from such a query, it will throw the above error.

## Additional context
<!-- Add any other context about the problem or screenshots here. -->

It's important to solve this, since the described join is part of our demo video.


Discussion:

Resolving patch:
diff --git a/db/queries/base.py b/db/queries/base.py
--- a/db/queries/base.py
+++ b/db/queries/base.py
@@ -1,203 +1,145 @@
-from collections import namedtuple
-
-from sqlalchemy import select, join, Column as SAColumn, Table as SATable
-
-from django.utils.functional import cached_property
+from sqlalchemy import MetaData, select
 
 from db.records.operations import select as records_select
 from db.columns.base import MathesarColumn
+from db.columns.operations.select import get_column_name_from_attnum
+from db.tables.operations.select import reflect_table_from_oid
 from db.transforms.operations.apply import apply_transformations
 
 
 class DBQuery:
     def __init__(
-        self,
-        base_table,
-        initial_columns,
-        transformations=None,
-        name=None
+            self,
+            base_table_oid,
+            initial_columns,
+            engine,
+            transformations=None,
+            name=None,
     ):
-        assert isinstance(base_table, SATable)
-        self.base_table = base_table
+        self.base_table_oid = base_table_oid
         for initial_col in initial_columns:
             assert isinstance(initial_col, InitialColumn)
         self.initial_columns = initial_columns
+        self.engine = engine
         self.transformations = transformations
         self.name = name
 
     # mirrors a method in db.records.operations.select
     def get_records(self, **kwargs):
-        # NOTE how through this method you can perform a second batch of transformations.
-        # this reflects fact that we can form a query, and then apply temporary transforms on it,
-        # like how you can apply temporary transforms to a table when in a table view.
+        # NOTE how through this method you can perform a second batch of
+        # transformations.  this reflects fact that we can form a query, and
+        # then apply temporary transforms on it, like how you can apply
+        # temporary transforms to a table when in a table view.
         return records_select.get_records(
-            table=self.sa_relation,
-            **kwargs,
+            table=self.transformed_relation, engine=self.engine, **kwargs,
         )
 
     # mirrors a method in db.records.operations.select
     def get_count(self, **kwargs):
-        return records_select.get_count(table=self.sa_relation, **kwargs)
+        return records_select.get_count(
+            table=self.transformed_relation, engine=self.engine, **kwargs,
+        )
 
-    def sa_output_columns(self, engine):
+    @property
+    def sa_output_columns(self):
         """
-        Sequence of SQLAlchemy columns representing the output columns of the relation described
-        by this query.
+        Sequence of SQLAlchemy columns representing the output columns of the
+        relation described by this query.
         """
         return tuple(
-            MathesarColumn.from_column(sa_col, engine=engine)
+            MathesarColumn.from_column(sa_col, engine=self.engine)
             for sa_col
-            in self.sa_relation.columns
+            in self.transformed_relation.columns
         )
 
-    @cached_property
-    def sa_relation(self):
+    @property
+    def transformed_relation(self):
         """
-        A query describes a relation. This property is the result of parsing a query into a
-        relation.
+        A query describes a relation. This property is the result of parsing a
+        query into a relation.
         """
-        initial_relation = _get_initial_relation(self)
         transformations = self.transformations
         if transformations:
             transformed = apply_transformations(
-                initial_relation,
+                self.initial_relation,
                 transformations,
             )
             return transformed
         else:
-            return initial_relation
+            return self.initial_relation
+
+    @property
+    def initial_relation(self):
+        metadata = MetaData()
+        base_table = reflect_table_from_oid(
+            self.base_table_oid, self.engine, metadata=metadata
+        )
+        from_clause = base_table
+        # We cache this to avoid copies of the same join path to a given table
+        jp_path_alias_map = {(): base_table}
+
+        def _get_table(oid):
+            """
+            We use the function-scoped metadata so all involved tables are aware
+            of each other.
+            """
+            return reflect_table_from_oid(oid, self.engine, metadata=metadata)
+
+        def _get_column_name(oid, attnum):
+            return get_column_name_from_attnum(oid, attnum, self.engine)
+
+        def _process_initial_column(col):
+            nonlocal from_clause
+            col_name = _get_column_name(col.reloid, col.attnum)
+            # Make the path hashable so it can be a dict key
+            jp_path = _guarantee_jp_path_tuples(col.jp_path)
+            right = base_table
+
+            for i, jp in enumerate(jp_path):
+                left = jp_path_alias_map[jp_path[:i]]
+                right = _get_table(jp[1][0]).alias()
+                jp_path_alias_map[jp_path[:i + 1]] = right
+                left_col = left.columns[_get_column_name(jp[0][0], jp[0][1])]
+                right_col = right.columns[_get_column_name(jp[1][0], jp[1][1])]
+                from_clause = from_clause.join(
+                    right, onclause=left_col == right_col, isouter=True,
+                )
+
+            return right.columns[col_name].label(col.alias)
+
+        stmt = select(
+            [_process_initial_column(col) for col in self.initial_columns]
+        ).select_from(from_clause)
+        return stmt.cte()
+
+
+def _guarantee_jp_path_tuples(jp_path):
+    if jp_path is not None:
+        return tuple((tuple(edge[0]), tuple(edge[1])) for edge in jp_path)
+    else:
+        return ()
 
 
 class InitialColumn:
     def __init__(
-        self,
-        alias,
-        column,
-        jp_path=None,
+            self,
+            reloid,
+            attnum,
+            alias,
+            jp_path=None,
     ):
         # alias mustn't be an empty string
         assert isinstance(alias, str) and alias.strip() != ""
+        self.reloid = reloid
+        self.attnum = attnum
         self.alias = alias
-
         if jp_path is not None:
-            # jp_path must be made up of JoinParams
-            for jp in jp_path:
-                assert isinstance(jp, JoinParams)
-        self.jp_path = jp_path
-
-        # column must be SA Column, and not MathesarColumn (MathesarColumn is incompatible with some
-        # of SA's joining mechanics).
-        assert isinstance(column, SAColumn)
-        assert not isinstance(column, MathesarColumn)
-
-        # column must have a table with a schema associated.
-        table = column.table
-        assert table is not None
-        schema_name = table.schema
-        assert schema_name is not None
-
-        self.column = column
+            self.jp_path = tuple(
+                [tuple([tuple(edge[0]), tuple(edge[1])]) for edge in jp_path]
+            )
+        else:
+            self.jp_path = None
 
     @property
     def is_base_column(self):
         return self.jp_path is None
-
-
-class JoinParams(
-    namedtuple(
-        'JoinParams',
-        [
-            'left_column',
-            'right_column',
-        ]
-    )
-):
-    """
-    Describes parameters for a join. Namely, the table and column pairs on both sides of the join.
-    """
-    def flip(self):
-        return JoinParams(
-            left_column=self.right_column,
-            right_column=self.left_column,
-        )
-
-    @property
-    def left_table(self):
-        return self.left_column.table
-
-    @property
-    def right_table(self):
-        return self.right_column.table
-
-
-def _get_initial_relation(query):
-    """
-    The initial relation is the relation defined by the initial columns (`initial_columns`). It acts
-    as input to the transformation pipeline (that's defined by `transformations`).
-    """
-    sa_columns_to_select = []
-    from_clause = query.base_table
-    for initial_column in query.initial_columns:
-        from_clause, sa_column_to_select = _process_initial_column(
-            initial_column=initial_column,
-            from_clause=from_clause,
-        )
-        sa_columns_to_select.append(sa_column_to_select)
-    stmt = select(*sa_columns_to_select).select_from(from_clause)
-    return stmt.cte()
-
-
-def _process_initial_column(initial_column, from_clause):
-    if initial_column.is_base_column:
-        col_to_select = initial_column.column
-    else:
-        from_clause, col_to_select = _nest_a_join(
-            initial_column=initial_column,
-            from_clause=from_clause,
-        )
-    # Give an alias/label to this column, since that's how it will be referenced in transforms.
-    aliased_col_to_select = col_to_select.label(initial_column.alias)
-    return from_clause, aliased_col_to_select
-
-
-def _nest_a_join(from_clause, initial_column):
-    jp_path = initial_column.jp_path
-    target_sa_column = initial_column.column
-    rightmost_table_alias = None
-    ix_of_last_jp = len(jp_path) - 1
-    for i, jp in enumerate(jp_path):
-        is_last_jp = i == ix_of_last_jp
-        # We want to alias the right-most table in the JP path, so that we can select from it later
-        if is_last_jp:
-            rightmost_table_alias = jp.right_table.alias()
-            right_table = rightmost_table_alias
-            right_column_reference = (
-                # If we give the right table an alias, we have to use that alias whenever we
-                # reference it
-                _access_column_on_relation(
-                    rightmost_table_alias,
-                    jp.right_column,
-                )
-            )
-        else:
-            right_table = jp.right_table
-            right_column_reference = jp.right_column
-        left_table = from_clause
-        left_column_reference = jp.left_column
-        from_clause = join(
-            left_table, right_table,
-            left_column_reference == right_column_reference
-        )
-    # Here we produce the actual reference to the column we want to join in
-    rightmost_table_target_column_reference = (
-        _access_column_on_relation(
-            rightmost_table_alias,
-            target_sa_column,
-        )
-    )
-    return from_clause, rightmost_table_target_column_reference
-
-
-def _access_column_on_relation(relation, sa_column):
-    column_name = sa_column.name
-    return getattr(relation.c, column_name)
diff --git a/mathesar/models/query.py b/mathesar/models/query.py
--- a/mathesar/models/query.py
+++ b/mathesar/models/query.py
@@ -1,9 +1,9 @@
 from django.db import models
 from django.utils.functional import cached_property
 
-from db.queries.base import DBQuery, JoinParams, InitialColumn
+from db.queries.base import DBQuery, InitialColumn
 
-from mathesar.models.base import BaseModel, Column, Table
+from mathesar.models.base import BaseModel, Column
 from mathesar.models.relation import Relation
 from django.core.exceptions import ValidationError
 from db.transforms.operations.deserialize import deserialize_transformation
@@ -110,8 +110,6 @@ class UIQuery(BaseModel, Relation):
         validators=[_validate_dict],
     )
 
-    __table_cache = {}
-
     @property
     def not_partial(self):
         return (
@@ -120,19 +118,13 @@ def not_partial(self):
         )
 
     def get_records(self, **kwargs):
-        return self.db_query.get_records(
-            engine=self._sa_engine,
-            **kwargs,
-        )
+        return self.db_query.get_records(**kwargs)
 
     # TODO add engine from base_table.schema._sa_engine
     def sa_num_records(self, **kwargs):
-        return self.db_query.get_count(
-            engine=self._sa_engine,
-            **kwargs,
-        )
+        return self.db_query.get_count(**kwargs)
 
-    @cached_property
+    @property
     def output_columns_described(self):
         """
         Returns columns' description, which is to be returned verbatim by the
@@ -147,31 +139,27 @@ def output_columns_described(self):
                 'display_options': self._get_display_options_for_sa_col(sa_col),
             }
             for sa_col
-            in self.db_query.sa_output_columns(engine=self._sa_engine)
+            in self.db_query.sa_output_columns
         )
 
-    @cached_property
+    @property
     def db_query(self):
-        unique_base_table = _make_sure_table_is_unique(
-            self._table_cache,
-            self.base_table._sa_table,
-        )
         return DBQuery(
-            base_table=unique_base_table,
+            base_table_oid=self.base_table.oid,
             initial_columns=self._db_initial_columns,
+            engine=self._sa_engine,
             transformations=self._db_transformations,
             name=self.name,
         )
 
-    @cached_property
+    @property
     def _db_initial_columns(self):
         return tuple(
-            _db_initial_column_from_json(self._table_cache, json_initial_column)
-            for json_initial_column
-            in self.initial_columns
+            _db_initial_column_from_json(json_col)
+            for json_col in self.initial_columns
         )
 
-    @cached_property
+    @property
     def _db_transformations(self):
         """No processing necessary."""
         if self.transformations:
@@ -201,81 +189,24 @@ def _alias_to_display_name(self):
     def _sa_engine(self):
         return self.base_table._sa_engine
 
-    @property
-    def _table_cache(self):
-        """
-        Note the use of a UIQuery-wide _table_cache. Its purpose is that every Postgres-unique table
-        should only have a single SQLAlchemy Table when constructing a query. This is necessary,
-        because SQLAlchemy currently does not recognize duplicate SA Tables as such, and, for
-        example, can put the same table in the FROM clause multiple times.
-        """
-        return self.__table_cache
-
-    @_table_cache.deleter
-    def _table_cache(self):
-        self.__table_cache = {}
-
-
-def _db_initial_column_from_json(table_cache, json):
-    alias = json['alias']
-    sa_column = _get_sa_col_by_id(table_cache, json['id'])
-    json_jp_path = json.get('jp_path')
-    if json_jp_path:
-        jp_path = tuple(
-            _join_params_from_json(table_cache, json_jp)
-            for json_jp
-            in json_jp_path
-        )
-    else:
-        jp_path = None
-    return InitialColumn(
-        column=sa_column,
-        jp_path=jp_path,
-        alias=alias,
-    )
 
-
-def _join_params_from_json(table_cache, json_jp):
-    return JoinParams(
-        left_column=_get_sa_col_by_id(table_cache, json_jp[0]),
-        right_column=_get_sa_col_by_id(table_cache, json_jp[1]),
-    )
+def _get_column_pair_from_id(col_id):
+    col = Column.objects.get(id=col_id)
+    return col.table.oid, col.attnum
 
 
-def _get_sa_col_by_id(table_cache, dj_id):
-    """
-    Note that we have to do some shuffling with 2 goals:
-
-    1. reduce a MathesarColumn (enriched_column) to SA Column (regular_column), otherwise SA's
-    joining mechanism throws errors;
-    2. cache tables associated with columns so that there's only a single SA table for a referenced
-    Postgres table, otherwise SA produces invalid SQL with duplicate tables in the FROM clause.
-    """
-    dj_column = Column.objects.get(pk=dj_id)
-    enriched_column = dj_column._sa_column
-    regular_column = enriched_column.to_sa_column()
-    final_column = _make_sure_column_has_unique_table(
-        table_cache,
-        regular_column,
+def _db_initial_column_from_json(col_json):
+    column_pair = _get_column_pair_from_id(col_json["id"])
+    reloid = column_pair[0]
+    attnum = column_pair[1]
+    alias = col_json["alias"]
+    jp_path = [
+        [_get_column_pair_from_id(col_id) for col_id in edge]
+        for edge in col_json.get("jp_path", [])
+    ]
+    return InitialColumn(
+        reloid=reloid,
+        attnum=attnum,
+        alias=alias,
+        jp_path=jp_path if jp_path else None,
     )
-    return final_column
-
-
-def _make_sure_column_has_unique_table(table_cache, column):
-    assert isinstance(table_cache, dict)
-    table = column.table
-    unique_table = _make_sure_table_is_unique(table_cache, table)
-    column.table = unique_table
-    return column
-
-
-def _make_sure_table_is_unique(table_cache, table):
-    assert table is not None
-    schema_name = table.schema
-    assert schema_name is not None
-    cached_table = table_cache.setdefault((schema_name, table.name), table)
-    return cached_table
-
-
-def _get_sa_table_by_id(dj_id):
-    return Table.objects.get(pk=dj_id)._sa_table


Test patch:
diff --git a/db/tests/queries/conftest.py b/db/tests/queries/conftest.py
--- a/db/tests/queries/conftest.py
+++ b/db/tests/queries/conftest.py
@@ -1,29 +1,35 @@
 import pytest
-from db.queries.base import DBQuery, InitialColumn, JoinParams
+from db.columns.operations.select import get_column_attnum_from_name as get_attnum
+from db.tables.operations.select import get_oid_from_table
+from db.queries.base import DBQuery, InitialColumn
 
 
 @pytest.fixture
-def shallow_link_dbquery(academics_db_tables):
-    acad_table = academics_db_tables['academics']
-    uni_table = academics_db_tables['universities']
+def shallow_link_dbquery(engine_with_academics):
+    engine, schema = engine_with_academics
+    acad_oid = get_oid_from_table('academics', schema, engine)
+    uni_oid = get_oid_from_table('universities', schema, engine)
     initial_columns = [
         InitialColumn(
+            acad_oid,
+            get_attnum(acad_oid, 'id', engine),
             alias='id',
-            column=acad_table.c.id,
         ),
         InitialColumn(
+            uni_oid,
+            get_attnum(uni_oid, 'name', engine),
             alias='institution_name',
-            column=uni_table.c.name,
             jp_path=[
-                JoinParams(
-                    left_column=acad_table.c.institution,
-                    right_column=uni_table.c.id,
-                ),
+                [
+                    (acad_oid, get_attnum(acad_oid, 'institution', engine)),
+                    (uni_oid, get_attnum(uni_oid, 'id', engine)),
+                ],
             ],
         ),
     ]
     dbq = DBQuery(
-        base_table=acad_table,
-        initial_columns=initial_columns,
+        acad_oid,
+        initial_columns,
+        engine
     )
     return dbq
diff --git a/db/tests/queries/test_initial_relation.py b/db/tests/queries/test_initial_relation.py
--- a/db/tests/queries/test_initial_relation.py
+++ b/db/tests/queries/test_initial_relation.py
@@ -4,98 +4,117 @@
 # Initial columns is an ordered set of columns sourced either from the base table, or from linked
 # tables.
 
-from db.queries.base import DBQuery, InitialColumn, JoinParams
+from db.columns.operations.select import get_column_attnum_from_name as get_attnum
+from db.tables.operations.select import get_oid_from_table
+from db.queries.base import DBQuery, InitialColumn
 
 
-def test_local_columns(engine, academics_db_tables):
-    base_table = academics_db_tables['academics']
+def test_local_columns(engine_with_academics):
+    engine, schema = engine_with_academics
+    oid = get_oid_from_table("academics", schema, engine)
     initial_columns = [
         InitialColumn(
+            oid,
+            get_attnum(oid, 'id', engine),
             alias='id',
-            column=base_table.c.id,
         ),
         InitialColumn(
+            oid,
+            get_attnum(oid, 'institution', engine),
             alias='institution',
-            column=base_table.c.institution,
         ),
     ]
     dbq = DBQuery(
-        base_table=base_table,
-        initial_columns=initial_columns,
+        oid,
+        initial_columns,
+        engine
     )
-    records = dbq.get_records(engine=engine)
+    records = dbq.get_records()
     assert records == [(1, 1), (2, 1), (3, 2)]
 
 
-def test_shallow_link(engine, shallow_link_dbquery):
+def test_shallow_link(shallow_link_dbquery):
     dbq = shallow_link_dbquery
-    records = dbq.get_records(engine=engine)
+    records = dbq.get_records()
     assert records == [(1, 'uni1'), (2, 'uni1'), (3, 'uni2')]
 
 
-def test_deep_link(engine, academics_db_tables):
-    art_table = academics_db_tables['articles']
-    acad_table = academics_db_tables['academics']
-    uni_table = academics_db_tables['universities']
+def test_deep_link(engine_with_academics):
+    engine, schema = engine_with_academics
+    art_oid = get_oid_from_table("articles", schema, engine)
+    acad_oid = get_oid_from_table("academics", schema, engine)
+    uni_oid = get_oid_from_table("universities", schema, engine)
     initial_columns = [
         InitialColumn(
+            art_oid,
+            get_attnum(art_oid, 'title', engine),
             alias='title',
-            column=art_table.c.title,
         ),
         InitialColumn(
+            uni_oid,
+            get_attnum(uni_oid, 'name', engine),
             alias='primary_author_institution_name',
-            column=uni_table.c.name,
             jp_path=[
-                JoinParams(
-                    left_column=art_table.c.primary_author,
-                    right_column=acad_table.c.id,
-                ),
-                JoinParams(
-                    left_column=acad_table.c.institution,
-                    right_column=uni_table.c.id,
-                ),
+                [
+                    (art_oid, get_attnum(art_oid, 'primary_author', engine)),
+                    (acad_oid, get_attnum(acad_oid, 'id', engine)),
+                ],
+                [
+                    (acad_oid, get_attnum(acad_oid, 'institution', engine)),
+                    (uni_oid, get_attnum(uni_oid, 'id', engine)),
+                ]
             ],
         ),
     ]
     dbq = DBQuery(
-        base_table=art_table,
-        initial_columns=initial_columns,
+        art_oid,
+        initial_columns,
+        engine
     )
-    records = dbq.get_records(engine=engine)
+    records = dbq.get_records()
     assert records == [('article1', 'uni1'), ('article2', 'uni1')]
 
 
-def test_self_referencing_table(engine, academics_db_tables):
-    acad_table = academics_db_tables['academics']
+def test_self_referencing_table(engine_with_academics):
+    engine, schema = engine_with_academics
+    acad_oid = get_oid_from_table("academics", schema, engine)
     initial_columns = [
         InitialColumn(
+            acad_oid,
+            get_attnum(acad_oid, 'id', engine),
             alias='id',
-            column=acad_table.c.id,
         ),
         InitialColumn(
+            acad_oid,
+            get_attnum(acad_oid, 'name', engine),
             alias='advisor name',
-            column=acad_table.c.name,
             jp_path=[
-                JoinParams(
-                    left_column=acad_table.c.advisor,
-                    right_column=acad_table.c.id,
-                ),
+                [
+                    (acad_oid, get_attnum(acad_oid, 'advisor', engine)),
+                    (acad_oid, get_attnum(acad_oid, 'id', engine)),
+                ]
             ],
         ),
         InitialColumn(
+            acad_oid,
+            get_attnum(acad_oid, 'name', engine),
             alias='advisee name',
-            column=acad_table.c.name,
             jp_path=[
-                JoinParams(
-                    left_column=acad_table.c.id,
-                    right_column=acad_table.c.advisor,
-                ),
+                [
+                    (acad_oid, get_attnum(acad_oid, 'id', engine)),
+                    (acad_oid, get_attnum(acad_oid, 'advisor', engine)),
+                ]
             ],
         ),
     ]
     dbq = DBQuery(
-        base_table=acad_table,
-        initial_columns=initial_columns,
+        acad_oid,
+        initial_columns,
+        engine
     )
-    records = dbq.get_records(engine=engine)
-    assert records == [(2, 'academic3', 'academic1')]
+    records = dbq.get_records()
+    assert sorted(records, key=lambda t: t[0]) == [
+        (1, 'academic2', None),
+        (2, 'academic3', 'academic1'),
+        (3, None, 'academic2'),
+    ]
diff --git a/db/tests/queries/test_transformations.py b/db/tests/queries/test_transformations.py
--- a/db/tests/queries/test_transformations.py
+++ b/db/tests/queries/test_transformations.py
@@ -1,12 +1,12 @@
 from db.transforms import base as transforms_base
 
 
-def test_basic_transforms(engine, shallow_link_dbquery):
+def test_basic_transforms(shallow_link_dbquery):
     dbq = shallow_link_dbquery
     transformations = [
         transforms_base.Offset(1),
         transforms_base.Limit(1),
     ]
     dbq.transformations = transformations
-    records = dbq.get_records(engine=engine)
+    records = dbq.get_records()
     assert records == [(2, 'uni1')]
diff --git a/mathesar/tests/api/test_record_api.py b/mathesar/tests/api/test_record_api.py
--- a/mathesar/tests/api/test_record_api.py
+++ b/mathesar/tests/api/test_record_api.py
@@ -737,9 +737,12 @@ def test_foreign_key_record_api_all_column_previews(publication_tables, client):
     )
     assert response.status_code == 200
     publication_template_columns = publication_table.get_columns_by_name(['publisher', 'author', 'co_author', 'title', 'id'])
-    # TODO Uncomment once the bug with db explorer is fixed
-    # publication_preview_template = f'{{{publication_template_columns[3].id}}} Published By: {{{ publication_template_columns[0].id}}} and Authored by {{{publication_template_columns[1].id}}} along with {{{publication_template_columns[2].id}}}'
-    publication_preview_template = f'{{{publication_template_columns[3].id}}}'
+    publication_preview_template = (
+        f'{{{publication_template_columns[3].id}}}'
+        f' Published By: {{{publication_template_columns[0].id}}}'
+        f' and Authored by {{{publication_template_columns[1].id}}}'
+        f' along with {{{publication_template_columns[2].id}}}'
+    )
     publication_table_settings_id = publication_table.settings.id
     data = {
         "preview_settings": {
@@ -760,12 +763,16 @@ def test_foreign_key_record_api_all_column_previews(publication_tables, client):
         for preview in preview_data
         if preview['column'] == checkouts_table_publication_fk_column.id
     )
-    preview_column_alias = f'{checkouts_table_publication_fk_column.id}__{publication_template_columns[4].id}__col__{publication_template_columns[3].id}'
-    assert preview_column['template'] == f'{{{preview_column_alias}}}'
+    title = f'{checkouts_table_publication_fk_column.id}__{publication_template_columns[4].id}__col__{publication_template_columns[3].id}'
+    publisher = f'{checkouts_table_publication_fk_column.id}__{publication_template_columns[4].id}__col__{publication_template_columns[0].id}'
+    author = f'{checkouts_table_publication_fk_column.id}__{publication_template_columns[4].id}__col__{publication_template_columns[1].id}'
+    coauthor = f'{checkouts_table_publication_fk_column.id}__{publication_template_columns[4].id}__col__{publication_template_columns[2].id}'
+    preview_column_alias = f'{{{title}}} Published By: {{{publisher}}} and Authored by {{{author}}} along with {{{coauthor}}}'
+    assert preview_column['template'] == preview_column_alias
     preview_data = preview_column['data'][0]
-    assert preview_column_alias in preview_data
+    assert all([key in preview_data for key in [title, publisher, author, coauthor]])
     expected_preview_value = 'Pressure Should Old'
-    assert preview_data[preview_column_alias] == expected_preview_value
+    assert preview_data[title] == expected_preview_value
 
 
 def test_record_detail(create_patents_table, client):
diff --git a/mathesar/tests/query/test_base.py b/mathesar/tests/query/test_base.py
--- a/mathesar/tests/query/test_base.py
+++ b/mathesar/tests/query/test_base.py
@@ -19,15 +19,20 @@ def test_convert_to_db_query(create_patents_table, get_uid):
             'display_name': 'Column 2',
         },
     ]
+    oid = base_table_dj.oid
+    attnum1 = col1_dj.attnum
+    attnum2 = col2_dj.attnum
     initial_columns = [
         InitialColumn(
+            oid,
+            attnum1,
             alias='col1',
-            column=col1_dj._sa_column.to_sa_column(),
             jp_path=None,
         ),
         InitialColumn(
+            oid,
+            attnum2,
             alias='col2',
-            column=col2_dj._sa_column.to_sa_column(),
             jp_path=None,
         ),
     ]
@@ -52,31 +57,26 @@ def test_convert_to_db_query(create_patents_table, get_uid):
         initial_columns=initial_columns_json,
         transformations=transformations_json,
     )
-    base_table_sa = base_table_dj._sa_table
     wanted_db_query = DBQuery(
-        name=name,
-        base_table=base_table_sa,
+        base_table_oid=oid,
         initial_columns=initial_columns,
+        engine=ui_query._sa_engine,
         transformations=transformations,
+        name=name,
     )
     actual_db_query = ui_query.db_query
     assert actual_db_query.name == wanted_db_query.name
-    assert actual_db_query.base_table == wanted_db_query.base_table
+    assert actual_db_query.base_table_oid == wanted_db_query.base_table_oid
     for actual, wanted in zip(
         actual_db_query.initial_columns,
         wanted_db_query.initial_columns
     ):
         assert actual.alias == wanted.alias
         assert actual.jp_path == wanted.jp_path
-        _assert_sa_cols_equal(actual.column, wanted.column)
+        assert actual.reloid == wanted.reloid
+        assert actual.attnum == wanted.attnum
     for actual, wanted in zip(
         actual_db_query.transformations,
         wanted_db_query.transformations
     ):
         assert actual == wanted
-
-
-def _assert_sa_cols_equal(a, b):
-    assert a.name == b.name
-    assert a.table.schema == b.table.schema
-    assert a.table.name == b.table.name