Repository: mathesar-foundation/mathesar

Issue:
`psycopg2.errors.UndefinedTable` when joining in DE
When testing Arxiv dataset (#2203), found a bug where DE would crash with `psycopg2.errors.UndefinedTable`.

## To reproduce

1. Load up the Arxiv dataset (by following instructions in linked PR);
2. Go into DE, with `Papers` table as base table;
3. Select `Papers.id` as first initial column;
4. Select `Paper-Authors` -> `Authors` -> `Name` as second initial column;
      - doesn't matter whether you choose to aggregate;
5. Select `Paper-Category` -> `Categories` -> `Name` as third initial column;
6. CRASH!

Request:

```json
{
  "base_table": 336,
  "initial_columns": [
    {
      "alias": "Papers_id",
      "id": 1565
    },
    {
      "alias": "Authors_Name",
      "id": 1562,
      "jp_path": [
        [
          1565,
          1574
        ],
        [
          1575,
          1562
        ]
      ]
    },
    {
      "alias": "Categories_Name",
      "id": 1563,
      "jp_path": [
        [
          1565,
          1576
        ],
        [
          1577,
          1563
        ]
      ]
    }
  ],
  "transformations": [],
  "display_names": {
    "Papers_id": "Papers_id",
    "Authors_Name": "Authors_Name",
    "Categories_Name": "Categories_Name"
  },
  "parameters": {
    "limit": 100,
    "offset": 0
  }
}
```

Response:

```json
[
  {
    "code": 4101,
    "message": "(psycopg2.errors.UndefinedTable) missing FROM-clause entry for table \"Paper-Category_1\"\nLINE 3: ...ER JOIN \"Arxiv\".\"Categories\" AS \"Categories_1\" ON \"Paper-Cat...\n                                                             ^\n\n[SQL: WITH anon_2 AS \n(SELECT \"Arxiv\".\"Papers\".id AS \"Papers_id\", \"Authors_1\".\"Name\" AS \"Authors_Name\", \"Categories_1\".\"Name\" AS \"Categories_Name\" \nFROM \"Arxiv\".\"Papers\" LEFT OUTER JOIN \"Arxiv\".\"Paper-Author\" AS \"Paper-Author_1\" ON \"Arxiv\".\"Papers\".id = \"Paper-Author_1\".paper_id LEFT OUTER JOIN \"Arxiv\".\"Authors\" AS \"Authors_1\" ON \"Paper-Author_1\".author_id = \"Authors_1\".\"Name\" LEFT OUTER JOIN \"Arxiv\".\"Categories\" AS \"Categories_1\" ON \"Paper-Category_1\".category_id = \"Categories_1\".\"Name\"), \nanon_1 AS \n(SELECT count(%(count_1)s) AS _count \nFROM anon_2)\n SELECT anon_1._count \nFROM anon_1]\n[parameters: {'count_1': 1}]\n(Background on this error at: http://sqlalche.me/e/14/f405)",
    "field": null,
    "detail": null,
    "stacktrace": [
      "1. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py\", line 1770, in _execute_context",
      "2. self.dialect.do_execute(",
      "3. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/default.py\", line 717, in do_execute",
      "4. cursor.execute(statement, parameters)",
      "5. psycopg2.errors.UndefinedTable: missing FROM-clause entry for table \"Paper-Category_1\"",
      "6. LINE 3: ...ER JOIN \"Arxiv\".\"Categories\" AS \"Categories_1\" ON \"Paper-Cat...",
      "7. ^",
      "8. ",
      "9. ",
      "10. The above exception was the direct cause of the following exception:",
      "11. ",
      "12. Traceback (most recent call last):",
      "13. File \"/usr/local/lib/python3.9/site-packages/rest_framework/views.py\", line 506, in dispatch",
      "14. response = handler(request, *args, **kwargs)",
      "15. File \"/code/mathesar/api/db/viewsets/queries.py\", line 108, in run",
      "16. records = paginator.paginate_queryset(",
      "17. File \"/code/mathesar/api/pagination.py\", line 81, in paginate_queryset",
      "18. self.count = table.sa_num_records(filter=filters, search=search)",
      "19. File \"/code/mathesar/models/query.py\", line 179, in sa_num_records",
      "20. return self.db_query.get_count(**kwargs)",
      "21. File \"/code/db/queries/base.py\", line 114, in get_count",
      "22. return records_select.get_count(",
      "23. File \"/code/db/records/operations/select.py\", line 106, in get_count",
      "24. return execute_pg_query(engine, relation)[0][col_name]",
      "25. File \"/code/db/utils.py\", line 32, in execute_pg_query",
      "26. return execute_statement(engine, executable, connection_to_use=connection_to_use).fetchall()",
      "27. File \"/code/db/utils.py\", line 24, in execute_statement",
      "28. raise e",
      "29. File \"/code/db/utils.py\", line 18, in execute_statement",
      "30. return conn.execute(statement)",
      "31. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/future/engine.py\", line 280, in execute",
      "32. return self._execute_20(",
      "33. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py\", line 1582, in _execute_20",
      "34. return meth(self, args_10style, kwargs_10style, execution_options)",
      "35. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/sql/elements.py\", line 324, in _execute_on_connection",
      "36. return connection._execute_clauseelement(",
      "37. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py\", line 1451, in _execute_clauseelement",
      "38. ret = self._execute_context(",
      "39. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py\", line 1813, in _execute_context",
      "40. self._handle_dbapi_exception(",
      "41. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py\", line 1994, in _handle_dbapi_exception",
      "42. util.raise_(",
      "43. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/util/compat.py\", line 207, in raise_",
      "44. raise exception",
      "45. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/base.py\", line 1770, in _execute_context",
      "46. self.dialect.do_execute(",
      "47. File \"/usr/local/lib/python3.9/site-packages/sqlalchemy/engine/default.py\", line 717, in do_execute",
      "48. cursor.execute(statement, parameters)",
      "49. sqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedTable) missing FROM-clause entry for table \"Paper-Category_1\"",
      "50. LINE 3: ...ER JOIN \"Arxiv\".\"Categories\" AS \"Categories_1\" ON \"Paper-Cat...",
      "51. ^",
      "52. ",
      "53. [SQL: WITH anon_2 AS",
      "54. (SELECT \"Arxiv\".\"Papers\".id AS \"Papers_id\", \"Authors_1\".\"Name\" AS \"Authors_Name\", \"Categories_1\".\"Name\" AS \"Categories_Name\"",
      "55. FROM \"Arxiv\".\"Papers\" LEFT OUTER JOIN \"Arxiv\".\"Paper-Author\" AS \"Paper-Author_1\" ON \"Arxiv\".\"Papers\".id = \"Paper-Author_1\".paper_id LEFT OUTER JOIN \"Arxiv\".\"Authors\" AS \"Authors_1\" ON \"Paper-Author_1\".author_id = \"Authors_1\".\"Name\" LEFT OUTER JOIN \"Arxiv\".\"Categories\" AS \"Categories_1\" ON \"Paper-Category_1\".category_id = \"Categories_1\".\"Name\"),",
      "56. anon_1 AS",
      "57. (SELECT count(%(count_1)s) AS _count",
      "58. FROM anon_2)",
      "59. SELECT anon_1._count",
      "60. FROM anon_1]",
      "61. [parameters: {'count_1': 1}]",
      "62. (Background on this error at: http://sqlalche.me/e/14/f405)"
    ]
  }
]
```

The `message` field of response, after being passed through `python3 -c "import sys; print(sys.stdin.read().encode('utf-8').decode('unicode-escape'))"` (to improve readability):

```
(psycopg2.errors.UndefinedTable) missing FROM-clause entry for table "Paper-Category_1"
LINE 3: ...ER JOIN "Arxiv"."Categories" AS "Categories_1" ON "Paper-Cat...
                                                             ^

[SQL: WITH anon_2 AS 
(SELECT "Arxiv"."Papers".id AS "Papers_id", "Authors_1"."Name" AS "Authors_Name", "Categories_1"."Name" AS "Categories_Name" 
FROM "Arxiv"."Papers" LEFT OUTER JOIN "Arxiv"."Paper-Author" AS "Paper-Author_1" ON "Arxiv"."Papers".id = "Paper-Author_1".paper_id LEFT OUTER JOIN "Arxiv"."Authors" AS "Authors_1" ON "Paper-Author_1".author_id = "Authors_1"."Name" LEFT OUTER JOIN "Arxiv"."Categories" AS "Categories_1" ON "Paper-Category_1".category_id = "Categories_1"."Name"), 
anon_1 AS 
(SELECT count(%(count_1)s) AS _count 
FROM anon_2)
 SELECT anon_1._count 
FROM anon_1]
[parameters: {'count_1': 1}]
(Background on this error at: http://sqlalche.me/e/14/f405)
```

And, the pretty printed SQL query from above psycopg exception:

```sql
WITH anon_2 AS (
  SELECT 
    "Arxiv"."Papers".id AS "Papers_id", 
    "Authors_1"."Name" AS "Authors_Name", 
    "Categories_1"."Name" AS "Categories_Name" 
  FROM 
    "Arxiv"."Papers" 
    LEFT OUTER JOIN "Arxiv"."Paper-Author" AS "Paper-Author_1" ON "Arxiv"."Papers".id = "Paper-Author_1".paper_id 
    LEFT OUTER JOIN "Arxiv"."Authors" AS "Authors_1" ON "Paper-Author_1".author_id = "Authors_1"."Name" 
    LEFT OUTER JOIN "Arxiv"."Categories" AS "Categories_1" ON "Paper-Category_1".category_id = "Categories_1"."Name"
), 
anon_1 AS (
  SELECT 
    count(
      %(count_1) s
    ) AS _count 
  FROM 
    anon_2
) 
SELECT 
  anon_1._count 
FROM 
  anon_1
```


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
@@ -177,51 +177,61 @@ def initial_relation(self):
             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}
-        jp_path_unique_set = set()
 
-        def _get_table(oid):
+        # We cache aliases, because we want a given join-param subpath to have only one alias.
+        map_of_jp_subpath_to_alias = {}
+
+        # We keep track of created joins, so that we don't perform the same join more than once.
+        created_joins = set()
+
+        def _get_join_id(previous_and_this_jps):
             """
-            We use the function-scoped metadata so all involved tables are aware
-            of each other.
+            A join (given a base table) can be uniquely identified by the JoinParameter path used to create it.
             """
-            return reflect_table_from_oid(oid, self.engine, metadata=metadata, keep_existing=True)
-
-        def _get_column_name(oid, attnum):
-            return get_column_name_from_attnum(oid, attnum, self.engine, metadata=metadata)
+            return tuple(previous_and_this_jps)
 
-        def _process_initial_column(col):
+        def _process_initial_column(initial_col):
+            """
+            Mutably performs joins on `from_clause`, if this is not a base table initial column,
+            and returns the SA column that the initial column represents.
+            """
             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)
+            nonlocal base_table
+            nonlocal map_of_jp_subpath_to_alias
+            nonlocal created_joins
+            jp_path = initial_col.jp_path
             right = base_table
-
             for i, jp in enumerate(jp_path):
-                left = jp_path_alias_map[jp_path[:i]]
-                right_table = jp_path[:i + 1]
-                if right_table in jp_path_alias_map:
-                    right = jp_path_alias_map[right_table]
+                previous_jps = tuple(jp_path[:i])
+                is_first_jp = len(previous_jps) == 0
+                if is_first_jp:
+                    left = base_table
+                else:
+                    left = map_of_jp_subpath_to_alias[previous_jps]
+                previous_and_this_jps = tuple(previous_jps) + (jp,)
+                if previous_and_this_jps in map_of_jp_subpath_to_alias:
+                    right = map_of_jp_subpath_to_alias[previous_and_this_jps]
                 else:
-                    right = _get_table(jp[1][0]).alias()
-                    jp_path_alias_map[jp_path[:i + 1]] = right
-                left_col_name = _get_column_name(jp[0][0], jp[0][1])
-                right_col_name = _get_column_name(jp[1][0], jp[1][1])
-                left_col = left.columns[left_col_name]
-                right_col = right.columns[right_col_name]
-                join_columns = f"{left_col}, {right_col}"
-                if join_columns not in jp_path_unique_set:
-                    jp_path_unique_set.add(join_columns)
+                    right = reflect_table_from_oid(
+                        jp.right_oid, self.engine, metadata=metadata
+                    ).alias()
+                    map_of_jp_subpath_to_alias[previous_and_this_jps] = right
+                left_col, right_col = jp._get_sa_cols(left, right, self.engine, metadata)
+                join_id = _get_join_id(previous_and_this_jps)
+                if join_id not in created_joins:
+                    created_joins.add(join_id)
                     from_clause = from_clause.join(
                         right, onclause=left_col == right_col, isouter=True,
                     )
+            initial_col_name = initial_col.get_name(self.engine, metadata)
+            return right.columns[initial_col_name].label(initial_col.alias)
 
-            return right.columns[col_name].label(col.alias)
-
-        stmt = select(
-            [_process_initial_column(col) for col in self.initial_columns]
-        ).select_from(from_clause)
+        processed_initial_columns = [
+            _process_initial_column(initial_col)
+            for initial_col
+            in self.initial_columns
+        ]
+        stmt = select(processed_initial_columns).select_from(from_clause)
         return stmt.cte()
 
     def get_input_alias_for_output_alias(self, output_alias):
@@ -238,6 +248,48 @@ def map_of_output_alias_to_input_alias(self):
         return m
 
 
+class JoinParameter:
+    def __init__(
+            self,
+            left_oid,
+            left_attnum,
+            right_oid,
+            right_attnum,
+    ):
+        self.left_oid = left_oid
+        self.left_attnum = left_attnum
+        self.right_oid = right_oid
+        self.right_attnum = right_attnum
+
+    def _get_sa_cols(self, left, right, engine, metadata):
+        """
+        Returns the left and right SA columns represented by this JoinParameter.
+
+        It takes left and right SA from-clauses, because a JoinParameter on its own is not enough to
+        identify columns. You need the context of the base table and the JoinParameter path
+        (up to this JoinParameter), which are here embodied in the left and right SA from-clauses.
+        """
+        left_col_name = get_column_name_from_attnum(
+            self.left_oid, self.left_attnum, engine, metadata=metadata
+        )
+        right_col_name = get_column_name_from_attnum(
+            self.right_oid, self.right_attnum, engine, metadata=metadata
+        )
+        left_col = left.columns[left_col_name]
+        right_col = right.columns[right_col_name]
+        return left_col, right_col
+
+    def __eq__(self, other):
+        """Instances are equal when attributes are equal."""
+        if type(other) is type(self):
+            return self.__dict__ == other.__dict__
+        return False
+
+    def __hash__(self):
+        """Hashes are equal when attributes are equal."""
+        return hash(frozendict(self.__dict__))
+
+
 class InitialColumn:
     def __init__(
             self,
@@ -253,7 +305,17 @@ def __init__(
         self.reloid = reloid
         self.attnum = attnum
         self.alias = alias
-        self.jp_path = _guarantee_jp_path_tuples(jp_path)
+        if jp_path:
+            for join_parameter in jp_path:
+                assert type(join_parameter) is JoinParameter
+        else:
+            jp_path = tuple()
+        self.jp_path = jp_path
+
+    def get_name(self, engine, metadata):
+        return get_column_name_from_attnum(
+            self.reloid, self.attnum, engine, metadata=metadata
+        )
 
     @property
     def is_base_column(self):
@@ -271,20 +333,3 @@ def __eq__(self, other):
     def __hash__(self):
         """Hashes are equal when attributes are equal."""
         return hash(frozendict(self.__dict__))
-
-
-def _guarantee_jp_path_tuples(jp_path):
-    """
-    Makes sure that jp_path is made up of tuples or is an empty tuple.
-    """
-    if jp_path is not None:
-        return tuple(
-            (
-                tuple(edge[0]),
-                tuple(edge[1]),
-            )
-            for edge
-            in jp_path
-        )
-    else:
-        return tuple()
diff --git a/mathesar/models/query.py b/mathesar/models/query.py
--- a/mathesar/models/query.py
+++ b/mathesar/models/query.py
@@ -2,7 +2,7 @@
 from django.db import models
 from frozendict import frozendict
 
-from db.queries.base import DBQuery, InitialColumn
+from db.queries.base import DBQuery, InitialColumn, JoinParameter
 from db.queries.operations.process import get_transforms_with_summarizes_speced
 from db.transforms.operations.deserialize import deserialize_transformation
 from db.transforms.operations.serialize import serialize_transformation
@@ -481,8 +481,9 @@ def _db_initial_column_from_json(col_json):
     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", [])
+        _join_parameter_from_json(jp_json)
+        for jp_json
+        in col_json.get("jp_path", [])
     ]
     return InitialColumn(
         reloid=reloid,
@@ -492,6 +493,19 @@ def _db_initial_column_from_json(col_json):
     )
 
 
+def _join_parameter_from_json(jp_json):
+    left_col_id = jp_json[0]
+    left_oid, left_attnum = _get_column_pair_from_id(left_col_id)
+    right_col_id = jp_json[1]
+    right_oid, right_attnum = _get_column_pair_from_id(right_col_id)
+    return JoinParameter(
+        left_oid=left_oid,
+        left_attnum=left_attnum,
+        right_oid=right_oid,
+        right_attnum=right_attnum,
+    )
+
+
 def _get_default_display_name_for_agg_output_alias(
     output_alias,
     input_alias,


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,7 +1,7 @@
 import pytest
 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
+from db.queries.base import DBQuery, InitialColumn, JoinParameter
 from db.metadata import get_empty_metadata
 
 
@@ -11,7 +11,7 @@ def shallow_link_dbquery(engine_with_academics):
     metadata = get_empty_metadata()
     acad_oid = get_oid_from_table('academics', schema, engine)
     acad_id_attnum = get_attnum(acad_oid, 'id', engine, metadata=metadata)
-    acad_insitution_attnum = get_attnum(acad_oid, 'institution', engine, metadata=metadata)
+    acad_institution_attnum = get_attnum(acad_oid, 'institution', engine, metadata=metadata)
     uni_oid = get_oid_from_table('universities', schema, engine)
     uni_name_attnum = get_attnum(uni_oid, 'name', engine, metadata=metadata)
     uni_id_attnum = get_attnum(uni_oid, 'id', engine, metadata=metadata)
@@ -26,10 +26,10 @@ def shallow_link_dbquery(engine_with_academics):
             uni_name_attnum,
             alias='institution_name',
             jp_path=[
-                [
-                    (acad_oid, acad_insitution_attnum),
-                    (uni_oid, uni_id_attnum),
-                ],
+                JoinParameter(
+                    acad_oid, acad_institution_attnum,
+                    uni_oid, uni_id_attnum,
+                ),
             ],
         ),
     ]
diff --git a/db/tests/queries/test_base.py b/db/tests/queries/test_base.py
--- a/db/tests/queries/test_base.py
+++ b/db/tests/queries/test_base.py
@@ -1,4 +1,4 @@
-from db.queries.base import DBQuery, InitialColumn
+from db.queries.base import DBQuery, InitialColumn, JoinParameter
 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.transforms import base as tbase
@@ -15,37 +15,38 @@ def _extract_col_properties_dict(col):
 def test_DBQuery_all_sa_columns_map_initial_columns(engine_with_academics):
     engine, schema = engine_with_academics
     acad_oid = get_oid_from_table("academics", schema, engine)
+    metadata = get_empty_metadata()
     initial_columns = [
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'id', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'id', engine, metadata=metadata),
             alias='id',
         ),
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'institution', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'institution', engine, metadata=metadata),
             alias='institution',
         ),
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'name', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'name', engine, metadata=metadata),
             alias='advisor name',
             jp_path=[
-                [
-                    (acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=get_empty_metadata())),
-                    (acad_oid, get_attnum(acad_oid, 'id', engine, metadata=get_empty_metadata())),
-                ]
+                JoinParameter(
+                    acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata),
+                    acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata),
+                )
             ],
         ),
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'name', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'name', engine, metadata=metadata),
             alias='advisee name',
             jp_path=[
-                [
-                    (acad_oid, get_attnum(acad_oid, 'id', engine, metadata=get_empty_metadata())),
-                    (acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=get_empty_metadata())),
-                ]
+                JoinParameter(
+                    acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata),
+                    acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata),
+                )
             ],
         ),
     ]
@@ -70,37 +71,38 @@ def test_DBQuery_all_sa_columns_map_initial_columns(engine_with_academics):
 def test_DBQuery_all_sa_columns_map_output_columns(engine_with_academics):
     engine, schema = engine_with_academics
     acad_oid = get_oid_from_table("academics", schema, engine)
+    metadata = get_empty_metadata()
     initial_columns = [
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'id', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'id', engine, metadata=metadata),
             alias='id',
         ),
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'institution', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'institution', engine, metadata=metadata),
             alias='institution',
         ),
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'name', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'name', engine, metadata=metadata),
             alias='advisor name',
             jp_path=[
-                [
-                    (acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=get_empty_metadata())),
-                    (acad_oid, get_attnum(acad_oid, 'id', engine, metadata=get_empty_metadata())),
-                ]
+                JoinParameter(
+                    acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata),
+                    acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata),
+                )
             ],
         ),
         InitialColumn(
             acad_oid,
-            get_attnum(acad_oid, 'name', engine, metadata=get_empty_metadata()),
+            get_attnum(acad_oid, 'name', engine, metadata=metadata),
             alias='advisee name',
             jp_path=[
-                [
-                    (acad_oid, get_attnum(acad_oid, 'id', engine, metadata=get_empty_metadata())),
-                    (acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=get_empty_metadata())),
-                ]
+                JoinParameter(
+                    acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata),
+                    acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata),
+                )
             ],
         ),
     ]
@@ -135,35 +137,36 @@ def test_DBQuery_all_sa_columns_map_output_columns(engine_with_academics):
 def test_DBQuery_all_sa_columns_map_summarized_columns(engine_with_library):
     engine, schema = engine_with_library
     checkouts_oid = get_oid_from_table("Checkouts", schema, engine)
+    metadata = get_empty_metadata()
     initial_columns = [
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'id', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'id', engine, metadata=metadata),
             alias='Checkouts id'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Item', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Item', engine, metadata=metadata),
             alias='Collection items'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Patron', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Patron', engine, metadata=metadata),
             alias='Library patron'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Due Date', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Due Date', engine, metadata=metadata),
             alias='Due Date'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Checkout Time', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Checkout Time', engine, metadata=metadata),
             alias='Checkout Time'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Check In Time', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Check In Time', engine, metadata=metadata),
             alias='Check In Time'
         )
     ]
@@ -219,35 +222,36 @@ def test_DBQuery_all_sa_columns_map_summarized_columns(engine_with_library):
 def test_DBQuery_all_sa_columns_map_overwriting(engine_with_library):
     engine, schema = engine_with_library
     checkouts_oid = get_oid_from_table("Checkouts", schema, engine)
+    metadata = get_empty_metadata()
     initial_columns = [
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'id', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'id', engine, metadata=metadata),
             alias='Checkout'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Item', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Item', engine, metadata=metadata),
             alias='Collection items'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Patron', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Patron', engine, metadata=metadata),
             alias='Library patron'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Due Date', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Due Date', engine, metadata=metadata),
             alias='Due Date'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Checkout Time', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Checkout Time', engine, metadata=metadata),
             alias='Checkout Time'
         ),
         InitialColumn(
             checkouts_oid,
-            get_attnum(checkouts_oid, 'Check In Time', engine, metadata=get_empty_metadata()),
+            get_attnum(checkouts_oid, 'Check In Time', engine, metadata=metadata),
             alias='Check In Time'
         )
     ]
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
@@ -7,7 +7,7 @@
 
 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
+from db.queries.base import DBQuery, InitialColumn, JoinParameter
 from db.metadata import get_empty_metadata
 
 
@@ -61,14 +61,14 @@ def test_deep_link(engine_with_academics):
             get_attnum(uni_oid, 'name', engine, metadata=metadata),
             alias='primary_author_institution_name',
             jp_path=[
-                [
-                    (art_oid, get_attnum(art_oid, 'primary_author', engine, metadata=metadata)),
-                    (acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata)),
-                ],
-                [
-                    (acad_oid, get_attnum(acad_oid, 'institution', engine, metadata=metadata)),
-                    (uni_oid, get_attnum(uni_oid, 'id', engine, metadata=metadata)),
-                ]
+                JoinParameter(
+                    art_oid, get_attnum(art_oid, 'primary_author', engine, metadata=metadata),
+                    acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata),
+                ),
+                JoinParameter(
+                    acad_oid, get_attnum(acad_oid, 'institution', engine, metadata=metadata),
+                    uni_oid, get_attnum(uni_oid, 'id', engine, metadata=metadata),
+                ),
             ],
         ),
     ]
@@ -96,10 +96,10 @@ def test_self_referencing_table(engine_with_academics):
             get_attnum(acad_oid, 'name', engine, metadata=metadata),
             alias='advisor name',
             jp_path=[
-                [
-                    (acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata)),
-                    (acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata)),
-                ]
+                JoinParameter(
+                    acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata),
+                    acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata),
+                ),
             ],
         ),
         InitialColumn(
@@ -107,10 +107,10 @@ def test_self_referencing_table(engine_with_academics):
             get_attnum(acad_oid, 'name', engine, metadata=metadata),
             alias='advisee name',
             jp_path=[
-                [
-                    (acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata)),
-                    (acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata)),
-                ]
+                JoinParameter(
+                    acad_oid, get_attnum(acad_oid, 'id', engine, metadata=metadata),
+                    acad_oid, get_attnum(acad_oid, 'advisor', engine, metadata=metadata),
+                ),
             ],
         ),
     ]
diff --git a/db/tests/transforms/test_finish_specifying_summarization.py b/db/tests/transforms/test_finish_specifying_summarization.py
--- a/db/tests/transforms/test_finish_specifying_summarization.py
+++ b/db/tests/transforms/test_finish_specifying_summarization.py
@@ -3,7 +3,7 @@
 import functools
 
 from db.tables.operations.select import get_oid_from_table
-from db.queries.base import DBQuery, InitialColumn
+from db.queries.base import DBQuery, InitialColumn, JoinParameter
 from db.metadata import get_empty_metadata
 from db.transforms.base import Summarize, SelectSubsetOfColumns, Limit
 from db.columns.operations.select import get_column_attnum_from_name
@@ -132,10 +132,10 @@ def initial_columns(academics_ids):
             attnum=ids.attnum.universities.name,
             alias=gen_alias.universities.name,
             jp_path=[
-                [
-                    (ids.oid.academics, ids.attnum.academics.institution),
-                    (ids.oid.universities, ids.attnum.universities.id),
-                ],
+                JoinParameter(
+                    ids.oid.academics, ids.attnum.academics.institution,
+                    ids.oid.universities, ids.attnum.universities.id,
+                ),
             ],
         ),
         # Serves as a "multiple result" initial column
@@ -144,10 +144,10 @@ def initial_columns(academics_ids):
             attnum=ids.attnum.articles.title,
             alias=gen_alias.articles.title,
             jp_path=[
-                [
-                    (ids.oid.academics, ids.attnum.academics.id),
-                    (ids.oid.articles, ids.attnum.articles.primary_author),
-                ],
+                JoinParameter(
+                    ids.oid.academics, ids.attnum.academics.id,
+                    ids.oid.articles, ids.attnum.articles.primary_author,
+                ),
             ],
         ),
     ]