Repository: mathesar-foundation/mathesar

Issue:
Type Inference 1:  Check column against a type
**Problem**
<!-- Please provide a clear and concise description of the problem that this feature request is designed to solve.-->

Different types in Mathesar will enable different operations; for example, strings could be aggregated by concatenating, but numeric types could be aggregated by summing or multiplying.  So far, while we can reflect different types, we have no way to determine the type most appropriate for a column.

**Proposed solution**
<!-- A clear and concise description of your proposed solution or feature. -->

Given a `schema`, `table_name`, `column_name`, and `type`, we need to be able to return a boolean giving whether the column can be cast to that type.

**Additional context**
<!-- Add any other context or screenshots about the feature request here.-->

We may need to take an optional sample size parameter to do this for large data.  Performance testing will be necessary.



Discussion:

Resolving patch:
diff --git a/db/types/base.py b/db/types/base.py
--- a/db/types/base.py
+++ b/db/types/base.py
@@ -1,5 +1,6 @@
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, MetaData, Table, DDL
 from db import constants
+from db.types import email
 
 SCHEMA = f"{constants.MATHESAR_PREFIX}types"
 # Since we want to have our identifiers quoted appropriately for use in
@@ -9,3 +10,41 @@
 
 def get_qualified_name(name):
     return ".".join([preparer.quote_schema(SCHEMA), name])
+
+
+def get_supported_alter_column_types(engine):
+    dialect_types = engine.dialect.ischema_names
+    type_map = {
+        # Default Postgres types
+        "boolean": dialect_types.get("boolean"),
+        "interval": dialect_types.get("interval"),
+        "numeric": dialect_types.get("numeric"),
+        "string": dialect_types.get("name"),
+        # Custom Mathesar types
+        "email": dialect_types.get(email.QUALIFIED_EMAIL)
+    }
+    return {k: v for k, v in type_map.items() if v is not None}
+
+
+def alter_column_type(
+        schema, table_name, column_name, target_type_str, engine
+):
+    _preparer = engine.dialect.identifier_preparer
+    supported_types = get_supported_alter_column_types(engine)
+    target_type = supported_types.get(target_type_str.lower())
+    with engine.begin() as conn:
+        metadata = MetaData(bind=engine, schema=schema)
+        table = Table(
+            table_name, metadata, schema=schema, autoload_with=engine
+        )
+        column = table.columns[column_name]
+        prepared_table_name = _preparer.format_table(table)
+        prepared_column_name = _preparer.format_column(column)
+        prepared_type_name = target_type().compile(dialect=engine.dialect)
+        alter_stmt = f"""
+        ALTER TABLE {prepared_table_name}
+          ALTER COLUMN {prepared_column_name}
+          TYPE {prepared_type_name}
+          USING {prepared_column_name}::{prepared_type_name};
+        """
+        conn.execute(DDL(alter_stmt))


Test patch:
diff --git a/db/tests/types/test_base.py b/db/tests/types/test_base.py
new file mode 100644
--- /dev/null
+++ b/db/tests/types/test_base.py
@@ -0,0 +1,188 @@
+from datetime import timedelta
+from decimal import Decimal
+import pytest
+from sqlalchemy import Table, Column, MetaData
+from sqlalchemy import String, Numeric
+from sqlalchemy.schema import CreateSchema, DropSchema
+from db import types
+from db.engine import _add_custom_types_to_engine
+from db.types import base, install
+
+TEST_SCHEMA = "test_schema"
+
+
+@pytest.fixture
+def engine_with_types(engine):
+    _add_custom_types_to_engine(engine)
+    return engine
+
+
+@pytest.fixture
+def temporary_testing_schema(engine_with_types):
+    schema = TEST_SCHEMA
+    with engine_with_types.begin() as conn:
+        conn.execute(CreateSchema(schema))
+    yield engine_with_types, schema
+    with engine_with_types.begin() as conn:
+        conn.execute(DropSchema(schema, cascade=True, if_exists=True))
+
+
+@pytest.fixture
+def engine_email_type(temporary_testing_schema):
+    engine, schema = temporary_testing_schema
+    install.install_mathesar_on_database(engine)
+    yield engine, schema
+    with engine.begin() as conn:
+        conn.execute(DropSchema(base.SCHEMA, cascade=True, if_exists=True))
+
+
+def test_get_alter_column_types_with_standard_engine(engine):
+    type_dict = base.get_supported_alter_column_types(engine)
+    assert len(type_dict) > 0
+    assert all([type_ not in type_dict for type_ in types.CUSTOM_TYPE_DICT])
+
+
+def test_get_alter_column_types_with_custom_engine(engine_with_types):
+    type_dict = base.get_supported_alter_column_types(engine_with_types)
+    assert all(
+        [
+            type_ in type_dict.values()
+            for type_ in types.CUSTOM_TYPE_DICT.values()
+        ]
+    )
+
+
+type_test_list = [
+    (String, "boolean", "BOOLEAN"),
+    (String, "interval", "INTERVAL"),
+    (String, "numeric", "NUMERIC"),
+    (String, "string", "VARCHAR"),
+    (String, "email", "mathesar_types.email"),
+]
+
+
+@pytest.mark.parametrize(
+    "type_,target_type,expect_type", type_test_list
+)
+def test_alter_column_type_alters_column_type(
+        engine_email_type, type_, target_type, expect_type
+):
+    engine, schema = engine_email_type
+    TABLE_NAME = "testtable"
+    COLUMN_NAME = "testcol"
+    metadata = MetaData(bind=engine)
+    input_table = Table(
+        TABLE_NAME,
+        metadata,
+        Column(COLUMN_NAME, type_),
+        schema=schema
+    )
+    input_table.create()
+    base.alter_column_type(
+        schema, TABLE_NAME, COLUMN_NAME, target_type, engine,
+    )
+    metadata = MetaData(bind=engine)
+    metadata.reflect()
+    actual_column = Table(
+        TABLE_NAME,
+        metadata,
+        schema=schema,
+        autoload_with=engine
+    ).columns[COLUMN_NAME]
+    actual_type = actual_column.type.compile(dialect=engine.dialect)
+    assert actual_type == expect_type
+
+
+type_test_data_list = [
+    (String, "boolean", "0", False),
+    (String, "boolean", "1", True),
+    (String, "boolean", "false", False),
+    (String, "boolean", "true", True),
+    (String, "boolean", "f", False),
+    (String, "boolean", "t", True),
+    (String, "interval", "1 day", timedelta(days=1)),
+    (String, "interval", "1 week", timedelta(days=7)),
+    (String, "interval", "3:30", timedelta(hours=3, minutes=30)),
+    (String, "interval", "00:03:30", timedelta(minutes=3, seconds=30)),
+    (String, "numeric", "1", 1.0),
+    (String, "numeric", "1.2", Decimal('1.2')),
+    (Numeric, "numeric", 1, 1.0),
+    (String, "numeric", "5", 5),
+    (String, "numeric", "500000", 500000),
+    (String, "numeric", "500000.134", Decimal("500000.134")),
+    (Numeric, "string", 3, "3"),
+    (String, "string", "abc", "abc"),
+    (String, "email", "alice@example.com", "alice@example.com"),
+]
+
+
+@pytest.mark.parametrize(
+    "type_,target_type,value,expect_value", type_test_data_list
+)
+def test_alter_column_type_casts_column_data(
+        engine_email_type, type_, target_type, value, expect_value,
+):
+    engine, schema = engine_email_type
+    TABLE_NAME = "testtable"
+    COLUMN_NAME = "testcol"
+    metadata = MetaData(bind=engine)
+    input_table = Table(
+        TABLE_NAME,
+        metadata,
+        Column(COLUMN_NAME, type_),
+        schema=schema
+    )
+    input_table.create()
+    ins = input_table.insert(values=(value,))
+    with engine.begin() as conn:
+        conn.execute(ins)
+    base.alter_column_type(
+        schema, TABLE_NAME, COLUMN_NAME, target_type, engine,
+    )
+    metadata = MetaData(bind=engine)
+    metadata.reflect()
+    actual_table = Table(
+        TABLE_NAME,
+        metadata,
+        schema=schema,
+        autoload_with=engine
+    )
+    sel = actual_table.select()
+    with engine.connect() as conn:
+        res = conn.execute(sel).fetchall()
+    actual_value = res[0][0]
+    assert actual_value == expect_value
+
+
+type_test_bad_data_list = [
+    (String, "boolean", "cat"),
+    (String, "interval", "1 potato"),
+    (String, "numeric", "abc"),
+    (String, "email", "alice-example.com"),
+]
+
+
+@pytest.mark.parametrize(
+    "type_,target_type,value", type_test_bad_data_list
+)
+def test_alter_column_type_raises_on_bad_column_data(
+        engine_email_type, type_, target_type, value,
+):
+    engine, schema = engine_email_type
+    TABLE_NAME = "testtable"
+    COLUMN_NAME = "testcol"
+    metadata = MetaData(bind=engine)
+    input_table = Table(
+        TABLE_NAME,
+        metadata,
+        Column(COLUMN_NAME, type_),
+        schema=schema
+    )
+    input_table.create()
+    ins = input_table.insert(values=(value,))
+    with engine.begin() as conn:
+        conn.execute(ins)
+    with pytest.raises(Exception):
+        base.alter_column_type(
+            schema, TABLE_NAME, COLUMN_NAME, target_type, engine,
+        )