Repository: mathesar-foundation/mathesar

Issue:
Preview table creation fails for headerless csvs when first row's column value is empty
## Description
The post request to create the preview table fails when using a headerless csv which has empty values for some columns in the first row. While from a table creation standpoint this is expected, we should rather be generating column names ourselves for such cases.

## To Reproduce
* Upload the headerless file from `/mathesar/tests/data/headerless_patents.csv`
   * The first row for this has empty values for some columns
* Create a table using this file from the table POST endpoint: `/api/v0/tables/`
    * This currently fails.

## Expected
Table creation should not fail. The column names should be auto-generated in such scenarios.


Discussion:
I'd like to work on this issue.
Go ahead @A1O

Resolving patch:
diff --git a/db/columns/operations/create.py b/db/columns/operations/create.py
--- a/db/columns/operations/create.py
+++ b/db/columns/operations/create.py
@@ -18,8 +18,7 @@
 from db.constraints import utils as constraint_utils
 from db.tables.operations.select import reflect_table_from_oid
 from db.types.operations.cast import get_supported_alter_column_types
-
-COLUMN_NAME_TEMPLATE = 'Column'
+from db import constants
 
 
 def create_column(engine, table_oid, column_data):
@@ -74,9 +73,9 @@ def create_column(engine, table_oid, column_data):
 
 
 def gen_col_name(table):
-    base_name = COLUMN_NAME_TEMPLATE
+    base_name = constants.COLUMN_NAME_TEMPLATE
     col_num = len(table.c)
-    name = f'{base_name} {col_num}'
+    name = f'{base_name}{col_num}'
     return name
 
 
diff --git a/db/constants.py b/db/constants.py
--- a/db/constants.py
+++ b/db/constants.py
@@ -2,3 +2,4 @@
 ID = "id"
 ID_ORIGINAL = "id_original"
 INFERENCE_SCHEMA = f"{MATHESAR_PREFIX}inference_schema"
+COLUMN_NAME_TEMPLATE = 'Column '  # auto generated column name 'Column 1' (no undescore)
diff --git a/mathesar/imports/csv.py b/mathesar/imports/csv.py
--- a/mathesar/imports/csv.py
+++ b/mathesar/imports/csv.py
@@ -9,7 +9,7 @@
 from db.tables.operations.select import get_oid_from_table
 from db.tables.operations.drop import drop_table
 from mathesar.errors import InvalidTableError
-from db import constants
+from db.constants import ID, ID_ORIGINAL, COLUMN_NAME_TEMPLATE
 from psycopg2.errors import IntegrityError, DataError
 
 from mathesar.reflection import reflect_columns_from_table
@@ -100,7 +100,7 @@ def get_sv_reader(file, header, dialect=None):
         reader = csv.DictReader(file)
     if not header:
         reader.fieldnames = [
-            f"column_{i}" for i in range(len(reader.fieldnames))
+            f"{COLUMN_NAME_TEMPLATE}{i}" for i in range(len(reader.fieldnames))
         ]
         file.seek(0)
 
@@ -117,7 +117,8 @@ def create_db_table_from_data_file(data_file, name, schema):
     with open(sv_filename, 'rb') as sv_file:
         sv_reader = get_sv_reader(sv_file, header, dialect=dialect)
         column_names = [column_name.strip() for column_name in sv_reader.fieldnames]
-        column_names_alt = [fieldname.strip() if fieldname != constants.ID else constants.ID_ORIGINAL for fieldname in sv_reader.fieldnames]
+        column_names = [f"{COLUMN_NAME_TEMPLATE}{i}" if name == '' else name for i, name in enumerate(column_names)]
+        column_names_alt = [fieldname if fieldname != ID else ID_ORIGINAL for fieldname in column_names]
         table = create_string_column_table(
             name=name,
             schema=schema.name,


Test patch:
diff --git a/db/tests/columns/operations/test_create.py b/db/tests/columns/operations/test_create.py
--- a/db/tests/columns/operations/test_create.py
+++ b/db/tests/columns/operations/test_create.py
@@ -8,6 +8,7 @@
 from db.tests.columns.utils import create_test_table
 from db.tests.types import fixtures
 from db.types.operations.cast import get_supported_alter_column_db_types
+from db.constants import COLUMN_NAME_TEMPLATE
 
 
 engine_with_types = fixtures.engine_with_types
@@ -362,8 +363,8 @@ def test_duplicate_column_default(engine_with_schema, copy_data, copy_constraint
 def test_create_column_accepts_column_data_without_name_attribute(engine_email_type):
     engine, schema = engine_email_type
     table_name = "atableone"
-    initial_column_name = "Column 0"
-    expected_column_name = "Column 1"
+    initial_column_name = f"{COLUMN_NAME_TEMPLATE}0"
+    expected_column_name = f"{COLUMN_NAME_TEMPLATE}1"
     table = Table(
         table_name,
         MetaData(bind=engine, schema=schema),
@@ -381,8 +382,8 @@ def test_create_column_accepts_column_data_without_name_attribute(engine_email_t
 def test_create_column_accepts_column_data_with_name_as_empty_string(engine_email_type):
     engine, schema = engine_email_type
     table_name = "atableone"
-    initial_column_name = "Column 0"
-    expected_column_name = "Column 1"
+    initial_column_name = f"{COLUMN_NAME_TEMPLATE}0"
+    expected_column_name = f"{COLUMN_NAME_TEMPLATE}1"
     table = Table(
         table_name,
         MetaData(bind=engine, schema=schema),
@@ -425,7 +426,7 @@ def test_generate_column_name(engine_email_type):
     n = len(name_set) + 1
     # Expected column name should be 'Column n'
     # where n is length of number of columns already in the table
-    expected_column_name = f"Column {n}"
+    expected_column_name = f"{COLUMN_NAME_TEMPLATE}{n}"
     generated_column_name = gen_col_name(altered_table)
     assert len(altered_table.columns) == n
     assert generated_column_name == expected_column_name
diff --git a/mathesar/tests/api/test_column_api.py b/mathesar/tests/api/test_column_api.py
--- a/mathesar/tests/api/test_column_api.py
+++ b/mathesar/tests/api/test_column_api.py
@@ -14,6 +14,7 @@
 from mathesar.api.exceptions.error_codes import ErrorCodes
 from mathesar.models import Column as ServiceLayerColumn
 from mathesar.tests.api.test_table_api import check_columns_response
+from db.constants import COLUMN_NAME_TEMPLATE
 
 engine_with_types = fixtures.engine_with_types
 engine_email_type = fixtures.engine_email_type
@@ -389,7 +390,7 @@ def test_column_create_no_name_parameter(column_test_table, client):
     cache.clear()
     type_ = "BOOLEAN"
     num_columns = len(column_test_table.sa_columns)
-    generated_name = f"Column {num_columns}"
+    generated_name = f"{COLUMN_NAME_TEMPLATE}{num_columns}"
     data = {
         "type": type_
     }
@@ -411,7 +412,7 @@ def test_column_create_name_parameter_empty(column_test_table, client):
     name = ""
     type_ = "BOOLEAN"
     num_columns = len(column_test_table.sa_columns)
-    generated_name = f"Column {num_columns}"
+    generated_name = f"{COLUMN_NAME_TEMPLATE}{num_columns}"
     data = {
         "name": name, "type": type_
     }
diff --git a/mathesar/tests/conftest.py b/mathesar/tests/conftest.py
--- a/mathesar/tests/conftest.py
+++ b/mathesar/tests/conftest.py
@@ -106,6 +106,11 @@ def col_names_with_spaces_csv_filename():
     return 'mathesar/tests/data/col_names_with_spaces.csv'
 
 
+@pytest.fixture(scope='session')
+def col_headers_empty_csv_filename():
+    return 'mathesar/tests/data/col_headers_empty.csv'
+
+
 @pytest.fixture(scope='session')
 def non_unicode_csv_filename():
     return 'mathesar/tests/data/non_unicode_files/utf_16_le.csv'
diff --git a/mathesar/tests/data/col_headers_empty.csv b/mathesar/tests/data/col_headers_empty.csv
new file mode 100644
--- /dev/null
+++ b/mathesar/tests/data/col_headers_empty.csv
@@ -0,0 +1,3 @@
+, ,col2,
+aa,bb,cc,dd
+ee,ff,gg,hh
diff --git a/mathesar/tests/imports/test_csv.py b/mathesar/tests/imports/test_csv.py
--- a/mathesar/tests/imports/test_csv.py
+++ b/mathesar/tests/imports/test_csv.py
@@ -9,6 +9,7 @@
 from mathesar.imports.csv import create_table_from_csv, get_sv_dialect, get_sv_reader
 from db.schemas.operations.create import create_schema
 from db.schemas.utils import get_schema_oid_from_name
+from db.constants import COLUMN_NAME_TEMPLATE
 
 TEST_SCHEMA = "import_csv_schema"
 
@@ -34,6 +35,13 @@ def col_names_with_spaces_data_file(col_names_with_spaces_csv_filename):
     return data_file
 
 
+@pytest.fixture
+def col_headers_empty_data_file(col_headers_empty_csv_filename):
+    with open(col_headers_empty_csv_filename, "rb") as csv_file:
+        data_file = DataFile.objects.create(file=File(csv_file))
+    return data_file
+
+
 @pytest.fixture()
 def schema(engine, test_db_model):
     create_schema(TEST_SCHEMA, engine)
@@ -96,7 +104,7 @@ def test_headerless_csv_upload(headerless_data_file, schema):
         "Polyimide Wire Insulation Repair System",
         None,
     )
-    expected_cols = ["column_" + str(i) for i in range(7)]
+    expected_cols = [COLUMN_NAME_TEMPLATE + str(i) for i in range(7)]
 
     check_csv_upload(
         table, table_name, schema, num_records, expected_row, expected_cols
@@ -120,6 +128,19 @@ def test_col_names_with_spaces_csv(col_names_with_spaces_data_file, schema):
     )
 
 
+def test_col_headers_empty_csv(col_headers_empty_data_file, schema):
+    table_name = "Empty column header"
+    table = create_table_from_csv(col_headers_empty_data_file, table_name, schema)
+
+    num_records = 2
+    expected_row = (1, "aa", "bb", "cc", "dd")
+    expected_cols = ["id", "Column 0", "Column 1", "col2", "Column 3"]
+
+    check_csv_upload(
+        table, table_name, schema, num_records, expected_row, expected_cols
+    )
+
+
 def test_csv_upload_with_duplicate_table_name(data_file, schema):
     table_name = "NASA 2"
     already_defined_str = 'relation "NASA 2" already exists'