Repository: mathesar-foundation/mathesar

Issue:
Table Creation fails if called more than once
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
If `mathesar.database.tables.create_table` is called more than once, it causes an error.

**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
It should be possible to create more than one table.

**To Reproduce**
Please try to provide a [Minimal, Complete, and Verifiable](http://stackoverflow.com/help/mcve) example.
Follow the instructions to setup the web app.  Try to upload CSVs to two different tables.

**Additional context**
<!-- Add any other context about the problem or screenshots here. -->
Traceback:
```
Environment:


Request Method: POST
Request URL: http://localhost:8000/

Django Version: 3.1.7
Python Version: 3.9.2
Installed Applications:
['django.contrib.admin',
 'django.contrib.auth',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.messages',
 'django.contrib.staticfiles',
 'mathesar']
Installed Middleware:
['django.middleware.security.SecurityMiddleware',
 'django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.common.CommonMiddleware',
 'django.middleware.csrf.CsrfViewMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware',
 'django.contrib.messages.middleware.MessageMiddleware',
 'django.middleware.clickjacking.XFrameOptionsMiddleware']



Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/code/mathesar/views.py", line 16, in index
    collection = create_collection_from_csv(
  File "/code/mathesar/imports/csv.py", line 25, in create_collection_from_csv
    table = create_table_from_csv(name, schema, csv_reader, engine)
  File "/code/mathesar/imports/csv.py", line 18, in create_table_from_csv
    table = create_table(name, schema, csv_reader.fieldnames, engine)
  File "/code/mathesar/database/tables.py", line 19, in create_table
    table = Table(
  File "<string>", line 2, in __new__
    <source code not available>
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/util/deprecations.py", line 298, in warned
    return fn(*args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/sql/schema.py", line 597, in __new__
    metadata._remove_table(name, schema)
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/util/langhelpers.py", line 70, in __exit__
    compat.raise_(
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/util/compat.py", line 198, in raise_
    raise exception
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/sql/schema.py", line 592, in __new__
    table._init(name, metadata, *args, **kw)
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/sql/schema.py", line 678, in _init
    self._init_items(
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/sql/schema.py", line 134, in _init_items
    spwd(self, **kw)
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/sql/base.py", line 1014, in _set_parent_with_dispatch
    self._set_parent(parent, **kw)
  File "/usr/local/lib/python3.9/site-packages/sqlalchemy/sql/schema.py", line 1753, in _set_parent
    raise exc.ArgumentError(

Exception Type: ArgumentError at /
Exception Value: Column object 'mathesar_id' already assigned to Table 'Domains'
```

The error is caused because the `create_table` function tries to reuse the same default column, rather than creating copies of it.  This will be fixed while bringing code over from the prototype repo.

**Have a nice day!**



Discussion:

Resolving patch:
diff --git a/db/columns.py b/db/columns.py
new file mode 100644
--- /dev/null
+++ b/db/columns.py
@@ -0,0 +1,68 @@
+from sqlalchemy import Column, Integer
+from db import constants
+
+
+PRIMARY_KEY = "primary_key"
+TYPE = "type"
+
+DEFAULT_COLUMNS = {
+    constants.ID: {TYPE: Integer, PRIMARY_KEY: True}
+}
+
+
+# TODO replicate ForeignKey wrangling from prototype
+class MathesarColumn(Column):
+    """
+    This class constrains the possible arguments, enabling us to include
+    a copy method (which has been deprecated in upstream SQLAlchemy since
+    1.4).  The idea is that we can faithfully copy the subset of the
+    column definition that we care about, and this class defines that
+    subset.
+    """
+    def __init__(self, name, sa_type, primary_key=False):
+        """
+        Construct a new ``MathesarColumn`` object.
+
+        Required arguments:
+        name -- String giving the name of the column in the database.
+        sa_type -- the SQLAlchemy type of the column.
+
+        Optional keyword arguments:
+        primary_key -- Boolean giving whether the column is a primary key.
+        """
+        super().__init__(name=name, type_=sa_type, primary_key=primary_key)
+
+    @classmethod
+    def from_column(cls, column):
+        """
+        This alternate init method creates a new column (a copy) of the
+        given column.  It respects only the properties in the __init__
+        of the MathesarColumn.
+        """
+        return cls(column.name, column.type)
+
+    @property
+    def is_default(self):
+        default_def = DEFAULT_COLUMNS.get(self.name, False)
+        return (
+            default_def
+            and self.type.__class__ == default_def[TYPE]
+            and self.primary_key == default_def[PRIMARY_KEY]
+        )
+
+
+def get_default_mathesar_column_list():
+    return [
+        MathesarColumn(
+            c,
+            DEFAULT_COLUMNS[c][TYPE],
+            DEFAULT_COLUMNS[c][PRIMARY_KEY]
+        )
+        for c in DEFAULT_COLUMNS
+    ]
+
+
+def init_mathesar_table_column_list_with_defaults(column_list):
+    default_columns = get_default_mathesar_column_list()
+    given_columns = [MathesarColumn.from_column(c) for c in column_list]
+    return default_columns + given_columns
diff --git a/db/tables.py b/db/tables.py
--- a/db/tables.py
+++ b/db/tables.py
@@ -1,20 +1,27 @@
-from sqlalchemy import Column, Integer, String, Table, MetaData, select
+from sqlalchemy import Column, String, Table, MetaData, select
 
-from db import constants, schemas
+from db import schemas
+from db.columns import init_mathesar_table_column_list_with_defaults
 
-DEFAULT_COLUMNS = [
-    Column(constants.ID, Integer, primary_key=True),
-]
 
+def create_string_column_table(name, schema, column_names, engine):
+    """
+    This method creates a Postgres table in the specified schema, with all
+    columns being String type.
+    """
+    columns = [Column(column_name, String) for column_name in column_names]
+    table = create_mathesar_table(name, schema, columns, engine)
+    return table
 
-def create_table(name, schema, column_names, engine):
+
+def create_mathesar_table(name, schema, columns, engine):
     """
-    This method creates a Postgres table.
+    This method creates a Postgres table in the specified schema using the
+    given name and column list.  It adds internal mathesar columns to the
+    table.
     """
+    columns = init_mathesar_table_column_list_with_defaults(columns)
     schemas.create_schema(schema, engine)
-    columns = DEFAULT_COLUMNS + [
-        Column(column_name, String) for column_name in column_names
-    ]
     metadata = MetaData(bind=engine)
     table = Table(
         name,
diff --git a/mathesar/imports/csv.py b/mathesar/imports/csv.py
--- a/mathesar/imports/csv.py
+++ b/mathesar/imports/csv.py
@@ -16,7 +16,9 @@ def get_csv_reader(csv_file):
 
 
 def create_db_table_from_csv(name, schema, csv_reader, engine):
-    table = tables.create_table(name, schema, csv_reader.fieldnames, engine)
+    table = tables.create_string_column_table(
+        name, schema, csv_reader.fieldnames, engine,
+    )
     tables.insert_rows_into_table(table, [row for row in csv_reader], engine)
     return table
 


Test patch:
diff --git a/db/tests/test_columns.py b/db/tests/test_columns.py
new file mode 100644
--- /dev/null
+++ b/db/tests/test_columns.py
@@ -0,0 +1,74 @@
+from sqlalchemy import String, Integer, Column
+from db import columns
+
+
+def test_MC_inits_with_name():
+    name = "mycol"
+    c = columns.MathesarColumn(name, String)
+    assert c.name == name
+
+
+def test_MC_inits_with_sa_type():
+    sa_type = String
+    c = columns.MathesarColumn("anycol", sa_type)
+    actual_cls = c.type.__class__
+    assert actual_cls == sa_type
+
+
+def test_MC_inits_default_not_pk():
+    c = columns.MathesarColumn("anycol", String)
+    assert not c.primary_key
+
+
+def test_MC_inits_with_pk_true():
+    c = columns.MathesarColumn("anycol", String, primary_key=True)
+    assert c.primary_key
+
+
+def test_MC_is_default_when_true():
+    for default_col in columns.DEFAULT_COLUMNS:
+        dc_definition = columns.DEFAULT_COLUMNS[default_col]
+        c = columns.MathesarColumn(
+            default_col, dc_definition["type"], dc_definition["primary_key"]
+        )
+        assert c.is_default
+
+
+def test_MC_is_default_when_false_for_name():
+    for default_col in columns.DEFAULT_COLUMNS:
+        dc_definition = columns.DEFAULT_COLUMNS[default_col]
+        c = columns.MathesarColumn(
+            "definitely_not_a_default",
+            dc_definition["type"],
+            dc_definition["primary_key"]
+        )
+        assert not c.is_default
+
+
+def test_MC_is_default_when_false_for_type():
+    for default_col in columns.DEFAULT_COLUMNS:
+        dc_definition = columns.DEFAULT_COLUMNS[default_col]
+        changed_type = Integer if dc_definition["type"] == String else String
+        c = columns.MathesarColumn(
+            default_col,
+            changed_type,
+            dc_definition["primary_key"]
+        )
+        assert not c.is_default
+
+
+def test_MC_is_default_when_false_for_pk():
+    for default_col in columns.DEFAULT_COLUMNS:
+        dc_definition = columns.DEFAULT_COLUMNS[default_col]
+        c = columns.MathesarColumn(
+            default_col,
+            dc_definition["type"],
+            not dc_definition["primary_key"]
+        )
+        assert not c.is_default
+
+
+def test_MC_from_column():
+    orig_col = Column("testcol", String)
+    new_col = columns.MathesarColumn.from_column(orig_col)
+    assert new_col.name == orig_col.name and new_col.type == orig_col.type
diff --git a/db/tests/test_tables.py b/db/tests/test_tables.py
new file mode 100644
--- /dev/null
+++ b/db/tests/test_tables.py
@@ -0,0 +1,26 @@
+import pytest
+from sqlalchemy import text
+from db import tables
+
+
+@pytest.fixture
+def engine_with_schema(engine):
+    app_schema = "test_schema"
+    with engine.begin() as conn:
+        conn.execute(text(f"CREATE SCHEMA {app_schema};"))
+    yield engine, app_schema
+    with engine.begin() as conn:
+        conn.execute(text(f"DROP SCHEMA {app_schema} CASCADE;"))
+
+
+def test_table_creation_doesnt_reuse_defaults(engine_with_schema):
+    columns = []
+    engine, schema = engine_with_schema
+    t1 = tables.create_mathesar_table("t1", schema, columns, engine)
+    t2 = tables.create_mathesar_table("t2", schema, columns, engine)
+    assert all(
+        [
+            c1.name == c2.name and c1 != c2
+            for c1, c2 in zip(t1.columns, t2.columns)
+        ]
+    )