diff --git a/db/columns.py b/db/columns.py index 1baea4796a..a943e7a60c 100644 --- a/db/columns.py +++ b/db/columns.py @@ -1,7 +1,17 @@ -from sqlalchemy import Column, Integer, ForeignKey, Table, DDL, MetaData -from db import constants +import logging +import warnings +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import ( + Column, Integer, ForeignKey, Table, MetaData, and_, select +) +from db import constants, tables +from db.types import alteration +logger = logging.getLogger(__name__) + +NAME = "name" NULLABLE = "nullable" PRIMARY_KEY = "primary_key" TYPE = "type" @@ -77,8 +87,7 @@ def get_default_mathesar_column_list(): return [ MathesarColumn( c, - DEFAULT_COLUMNS[c][TYPE], - primary_key=DEFAULT_COLUMNS[c][PRIMARY_KEY] + DEFAULT_COLUMNS[c][TYPE], primary_key=DEFAULT_COLUMNS[c][PRIMARY_KEY] ) for c in DEFAULT_COLUMNS ] @@ -90,17 +99,110 @@ def init_mathesar_table_column_list_with_defaults(column_list): return default_columns + given_columns -def rename_column(schema, table_name, column_name, new_column_name, engine): - _preparer = engine.dialect.identifier_preparer +def get_column_index_from_name(table_oid, column_name, engine): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="Did not recognize type") + pg_attribute = Table("pg_attribute", MetaData(), autoload_with=engine) + sel = select(pg_attribute.c.attnum).where( + and_( + pg_attribute.c.attrelid == table_oid, + pg_attribute.c.attname == column_name + ) + ) 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_new_column_name = _preparer.quote(new_column_name) - alter_stmt = f""" - ALTER TABLE {prepared_table_name} - RENAME {prepared_column_name} TO {prepared_new_column_name} - """ - conn.execute(DDL(alter_stmt)) + result = conn.execute(sel).fetchone()[0] + return result - 1 + + +def create_column(engine, table_oid, column_data): + column_type = column_data[TYPE] + column_nullable = column_data.get(NULLABLE, True) + supported_types = alteration.get_supported_alter_column_types(engine) + sa_type = supported_types.get(column_type.lower()) + if sa_type is None: + logger.warning("Requested type not supported. falling back to String") + sa_type = supported_types[alteration.STRING] + table = tables.reflect_table_from_oid(table_oid, engine) + column = MathesarColumn( + column_data[NAME], sa_type, nullable=column_nullable, + ) + with engine.begin() as conn: + ctx = MigrationContext.configure(conn) + op = Operations(ctx) + op.add_column(table.name, column, schema=table.schema) + return tables.reflect_table_from_oid(table_oid, engine).columns[column_data[NAME]] + + +def alter_column( + engine, + table_oid, + column_index, + column_definition_dict, +): + assert len(column_definition_dict) == 1 + column_def_key = list(column_definition_dict.keys())[0] + column_index = int(column_index) + attribute_alter_map = { + NAME: rename_column, + TYPE: retype_column, + NULLABLE: change_column_nullable, + } + return attribute_alter_map[column_def_key]( + table_oid, column_index, column_definition_dict[column_def_key], engine, + ) + + +def rename_column(table_oid, column_index, new_column_name, engine): + table = tables.reflect_table_from_oid(table_oid, engine) + column = table.columns[column_index] + with engine.begin() as conn: + ctx = MigrationContext.configure(conn) + op = Operations(ctx) + op.alter_column( + table.name, + column.name, + new_column_name=new_column_name, + schema=table.schema + ) + return tables.reflect_table_from_oid(table_oid, engine).columns[column_index] + + +def retype_column(table_oid, column_index, new_type, engine): + table = tables.reflect_table_from_oid(table_oid, engine) + alteration.alter_column_type( + table.schema, + table.name, + table.columns[column_index].name, + new_type, + engine, + ) + return tables.reflect_table_from_oid(table_oid, engine).columns[column_index] + + +def change_column_nullable(table_oid, column_index, nullable, engine): + table = tables.reflect_table_from_oid(table_oid, engine) + column = table.columns[column_index] + with engine.begin() as conn: + ctx = MigrationContext.configure(conn) + op = Operations(ctx) + op.alter_column( + table.name, + column.name, + nullable=nullable, + schema=table.schema + ) + return tables.reflect_table_from_oid(table_oid, engine).columns[column_index] + + +def drop_column( + engine, + table_oid, + column_index, +): + column_index = int(column_index) + table = tables.reflect_table_from_oid(table_oid, engine) + column = table.columns[column_index] + with engine.begin() as conn: + ctx = MigrationContext.configure(conn) + op = Operations(ctx) + op.drop_column(table.name, column.name, schema=table.schema) diff --git a/db/tests/test_columns.py b/db/tests/test_columns.py index ead07c57a0..ad78bfa51c 100644 --- a/db/tests/test_columns.py +++ b/db/tests/test_columns.py @@ -1,6 +1,11 @@ import re +from unittest.mock import patch +from psycopg2.errors import NotNullViolation import pytest -from sqlalchemy import String, Integer, ForeignKey, Column, select, Table, MetaData +from sqlalchemy import ( + String, Integer, ForeignKey, Column, select, Table, MetaData, create_engine +) +from sqlalchemy.exc import IntegrityError from db import columns, tables, constants @@ -22,7 +27,9 @@ def _rename_column(schema, table_name, old_col_name, new_col_name, engine): """ Renames the colum of a table and assert the change went through """ - columns.rename_column(schema, table_name, old_col_name, new_col_name, engine) + table_oid = tables.get_oid_from_table(table_name, schema, engine) + column_index = columns.get_column_index_from_name(table_oid, old_col_name, engine) + columns.rename_column(table_oid, column_index, new_col_name, engine) table = tables.reflect_table(table_name, schema, engine) assert new_col_name in table.columns assert old_col_name not in table.columns @@ -137,6 +144,31 @@ def test_MC_is_default_when_false_for_pk(): assert not col.is_default +@pytest.mark.parametrize( + "column_dict,func_name", + [ + ({"name": "blah"}, "rename_column"), + ({"type": "blah"}, "retype_column"), + ({"nullable": True}, "change_column_nullable"), + ] +) +def test_alter_column_chooses_wisely(column_dict, func_name): + engine = create_engine("postgresql://") + with patch.object(columns, func_name) as mock_alterer: + columns.alter_column( + engine, + 1234, + 5678, + column_dict + ) + mock_alterer.assert_called_with( + 1234, + 5678, + list(column_dict.values())[0], + engine, + ) + + def test_rename_column(engine_with_schema): old_col_name = "col1" new_col_name = "col2" @@ -202,6 +234,175 @@ def test_rename_column_index(engine_with_schema): assert new_col_name in index_columns +def test_get_column_index_from_name(engine_with_schema): + engine, schema = engine_with_schema + table_name = "table_with_columns" + zero_name = "colzero" + one_name = "colone" + table = Table( + table_name, + MetaData(bind=engine, schema=schema), + Column(zero_name, Integer), + Column(one_name, String), + ) + table.create() + table_oid = tables.get_oid_from_table(table_name, schema, engine) + assert columns.get_column_index_from_name(table_oid, zero_name, engine) == 0 + assert columns.get_column_index_from_name(table_oid, one_name, engine) == 1 + + +def test_retype_column_correct_column(engine_with_schema): + engine, schema = engine_with_schema + table_name = "atableone" + target_type = "boolean" + target_column_name = "thecolumntochange" + nontarget_column_name = "notthecolumntochange" + table = Table( + table_name, + MetaData(bind=engine, schema=schema), + Column(target_column_name, Integer), + Column(nontarget_column_name, String), + ) + table.create() + table_oid = tables.get_oid_from_table(table_name, schema, engine) + with patch.object(columns.alteration, "alter_column_type") as mock_retyper: + columns.retype_column(table_oid, 0, target_type, engine) + mock_retyper.assert_called_with( + schema, + table_name, + target_column_name, + "boolean", + engine + ) + + +def test_create_column(engine_with_schema): + engine, schema = engine_with_schema + table_name = "atableone" + target_type = "boolean" + initial_column_name = "original_column" + new_column_name = "added_column" + table = Table( + table_name, + MetaData(bind=engine, schema=schema), + Column(initial_column_name, Integer), + ) + table.create() + table_oid = tables.get_oid_from_table(table_name, schema, engine) + column_data = {"name": new_column_name, "type": target_type} + created_col = columns.create_column(engine, table_oid, column_data) + altered_table = tables.reflect_table_from_oid(table_oid, engine) + assert len(altered_table.columns) == 2 + assert created_col.name == new_column_name + assert created_col.type.compile(engine.dialect) == "BOOLEAN" + + +nullable_changes = [(True, True), (False, False), (True, False), (False, True)] + + +@pytest.mark.parametrize("nullable_tup", nullable_changes) +def test_change_column_nullable_changes(engine_with_schema, nullable_tup): + engine, schema = engine_with_schema + table_name = "atablefornulling" + target_column_name = "thecolumntochange" + nontarget_column_name = "notthecolumntochange" + table = Table( + table_name, + MetaData(bind=engine, schema=schema), + Column(target_column_name, Integer, nullable=nullable_tup[0]), + Column(nontarget_column_name, String), + ) + table.create() + table_oid = tables.get_oid_from_table(table_name, schema, engine) + changed_column = columns.change_column_nullable( + table_oid, + 0, + nullable_tup[1], + engine + ) + assert changed_column.nullable is nullable_tup[1] + + +@pytest.mark.parametrize("nullable_tup", nullable_changes) +def test_change_column_nullable_with_data(engine_with_schema, nullable_tup): + engine, schema = engine_with_schema + table_name = "atablefornulling" + target_column_name = "thecolumntochange" + table = Table( + table_name, + MetaData(bind=engine, schema=schema), + Column(target_column_name, Integer, nullable=nullable_tup[0]), + ) + table.create() + ins = table.insert().values( + [ + {target_column_name: 1}, + {target_column_name: 2}, + {target_column_name: 3}, + ] + ) + with engine.begin() as conn: + conn.execute(ins) + table_oid = tables.get_oid_from_table(table_name, schema, engine) + changed_column = columns.change_column_nullable( + table_oid, + 0, + nullable_tup[1], + engine + ) + assert changed_column.nullable is nullable_tup[1] + + +def test_change_column_nullable_changes_raises_with_null_data(engine_with_schema): + engine, schema = engine_with_schema + table_name = "atablefornulling" + target_column_name = "thecolumntochange" + table = Table( + table_name, + MetaData(bind=engine, schema=schema), + Column(target_column_name, Integer, nullable=True), + ) + table.create() + ins = table.insert().values( + [ + {target_column_name: 1}, + {target_column_name: 2}, + {target_column_name: None}, + ] + ) + with engine.begin() as conn: + conn.execute(ins) + table_oid = tables.get_oid_from_table(table_name, schema, engine) + with pytest.raises(IntegrityError) as e: + columns.change_column_nullable( + table_oid, + 0, + False, + engine + ) + assert type(e.orig) == NotNullViolation + + +def test_drop_column_correct_column(engine_with_schema): + engine, schema = engine_with_schema + table_name = "atable" + target_column_name = "thecolumntodrop" + nontarget_column_name = "notthecolumntodrop" + table = Table( + table_name, + MetaData(bind=engine, schema=schema), + Column(target_column_name, Integer), + Column(nontarget_column_name, String), + ) + table.create() + table_oid = tables.get_oid_from_table(table_name, schema, engine) + columns.drop_column(engine, table_oid, 0) + altered_table = tables.reflect_table_from_oid(table_oid, engine) + assert len(altered_table.columns) == 1 + assert nontarget_column_name in altered_table.columns + assert target_column_name not in altered_table.columns + + def get_mathesar_column_init_args(): init_code = columns.MathesarColumn.__init__.__code__ return init_code.co_varnames[1:init_code.co_argcount] diff --git a/mathesar/models.py b/mathesar/models.py index f7fa83d231..7c9b14ec60 100644 --- a/mathesar/models.py +++ b/mathesar/models.py @@ -5,7 +5,7 @@ from mathesar.database.base import create_mathesar_engine from mathesar.utils import models as model_utils -from db import tables, records, schemas +from db import tables, records, schemas, columns NAME_CACHE_INTERVAL = 60 * 5 @@ -96,6 +96,28 @@ def sa_columns(self): def sa_column_names(self): return self.sa_columns.keys() + def add_column(self, column_data): + return columns.create_column( + self.schema._sa_engine, + self.oid, + column_data, + ) + + def alter_column(self, column_index, column_data): + return columns.alter_column( + self.schema._sa_engine, + self.oid, + column_index, + column_data, + ) + + def drop_column(self, column_index): + columns.drop_column( + self.schema._sa_engine, + self.oid, + column_index, + ) + @property def sa_num_records(self): return tables.get_count(self._sa_table, self.schema._sa_engine) diff --git a/mathesar/pagination.py b/mathesar/pagination.py index b628ec619b..ac854a561b 100644 --- a/mathesar/pagination.py +++ b/mathesar/pagination.py @@ -15,6 +15,19 @@ def get_paginated_response(self, data): ])) +class ColumnLimitOffsetPagination(DefaultLimitOffsetPagination): + + def paginate_queryset(self, queryset, request, table_id): + self.limit = self.get_limit(request) + if self.limit is None: + self.limit = self.default_limit + self.offset = self.get_offset(request) + table = queryset.get(id=table_id) + self.count = len(table.sa_columns) + self.request = request + return list(table.sa_columns)[self.offset:self.offset + self.limit] + + class TableLimitOffsetPagination(DefaultLimitOffsetPagination): def paginate_queryset(self, queryset, request, table_id): diff --git a/mathesar/serializers.py b/mathesar/serializers.py index 6289adf2bf..3d7ab002de 100644 --- a/mathesar/serializers.py +++ b/mathesar/serializers.py @@ -25,13 +25,18 @@ class Meta: fields = ['id', 'name', 'database', 'tables'] -class ColumnSerializer(serializers.Serializer): +class SimpleColumnSerializer(serializers.Serializer): name = serializers.CharField() type = serializers.CharField() +class ColumnSerializer(SimpleColumnSerializer): + nullable = serializers.BooleanField(default=True) + primary_key = serializers.BooleanField(default=False) + + class TableSerializer(serializers.ModelSerializer): - columns = ColumnSerializer(many=True, read_only=True, source='sa_columns') + columns = SimpleColumnSerializer(many=True, read_only=True, source='sa_columns') records = serializers.SerializerMethodField() name = serializers.CharField() @@ -44,7 +49,7 @@ def get_records(self, obj): if isinstance(obj, Table): # Only get records if we are serializing an existing table request = self.context['request'] - return request.build_absolute_uri(reverse('table-records-list', kwargs={'table_pk': obj.pk})) + return request.build_absolute_uri(reverse('table-record-list', kwargs={'table_pk': obj.pk})) else: return None diff --git a/mathesar/tests/views/api/conftest.py b/mathesar/tests/views/api/conftest.py index 81ce895ed6..5975f90c7c 100644 --- a/mathesar/tests/views/api/conftest.py +++ b/mathesar/tests/views/api/conftest.py @@ -3,6 +3,7 @@ from sqlalchemy import Column, String, MetaData, text from sqlalchemy import Table as SATable +from db.types import base, install from db.schemas import create_schema, get_schema_oid_from_name from db.tables import get_oid_from_table from mathesar.models import Schema, Table @@ -37,6 +38,7 @@ def _create_table(table_name, schema='Patents'): @pytest.fixture def patent_schema(test_db_name): engine = create_mathesar_engine(test_db_name) + install.install_mathesar_on_database(engine) with engine.begin() as conn: conn.execute(text(f'DROP SCHEMA IF EXISTS "{PATENT_SCHEMA}" CASCADE;')) create_schema(PATENT_SCHEMA, engine) @@ -44,6 +46,7 @@ def patent_schema(test_db_name): yield Schema.objects.create(oid=schema_oid, database=test_db_name) with engine.begin() as conn: conn.execute(text(f'DROP SCHEMA "{PATENT_SCHEMA}" CASCADE;')) + conn.execute(text(f'DROP SCHEMA {base.SCHEMA} CASCADE;')) @pytest.fixture diff --git a/mathesar/tests/views/api/test_column_api.py b/mathesar/tests/views/api/test_column_api.py new file mode 100644 index 0000000000..252d73b1ce --- /dev/null +++ b/mathesar/tests/views/api/test_column_api.py @@ -0,0 +1,171 @@ +from django.core.cache import cache +import pytest +from sqlalchemy import Column, Integer, String, MetaData +from sqlalchemy import Table as SATable + +from db.tables import get_oid_from_table +from mathesar.models import Table + + +@pytest.fixture +def column_test_table(patent_schema): + engine = patent_schema._sa_engine + column_list_in = [ + Column("mycolumn0", Integer, primary_key=True), + Column("mycolumn1", Integer, nullable=False), + Column("mycolumn2", Integer), + Column("mycolumn3", String), + ] + db_table = SATable( + "anewtable", + MetaData(bind=engine), + *column_list_in, + schema=patent_schema.name + ) + db_table.create() + db_table_oid = get_oid_from_table(db_table.name, db_table.schema, engine) + table = Table.objects.create(oid=db_table_oid, schema=patent_schema) + return table + + +def test_column_list(column_test_table, client): + cache.clear() + response = client.get(f"/api/v0/tables/{column_test_table.id}/columns/") + response_data = response.json() + assert response_data['count'] == len(column_test_table.sa_columns) + expect_results = [ + {'name': 'mycolumn0', 'type': 'INTEGER', 'nullable': False, 'primary_key': True}, + {'name': 'mycolumn1', 'type': 'INTEGER', 'nullable': False, 'primary_key': False}, + {'name': 'mycolumn2', 'type': 'INTEGER', 'nullable': True, 'primary_key': False}, + {'name': 'mycolumn3', 'type': 'VARCHAR', 'nullable': True, 'primary_key': False} + ] + assert response_data['results'] == expect_results + + +@pytest.mark.parametrize( + "index,expect_data", + [ + (0, {'name': 'mycolumn0', 'type': 'INTEGER', 'nullable': False, 'primary_key': True}), + (2, {'name': 'mycolumn2', 'type': 'INTEGER', 'nullable': True, 'primary_key': False}), + ] +) +def test_column_retrieve(index, expect_data, column_test_table, client): + cache.clear() + response = client.get( + f"/api/v0/tables/{column_test_table.id}/columns/{index}/" + ) + response_data = response.json() + assert response_data == expect_data + + +def test_column_retrieve_when_missing(column_test_table, client): + cache.clear() + response = client.get( + f"/api/v0/tables/{column_test_table.id}/columns/15/" + ) + response_data = response.json() + assert response_data == {"detail": "Not found."} + assert response.status_code == 404 + + +def test_column_create(column_test_table, client): + name = "anewcolumn" + type_ = "NUMERIC" + cache.clear() + num_columns = len(column_test_table.sa_columns) + data = { + "name": name, "type": type_ + } + response = client.post( + f"/api/v0/tables/{column_test_table.id}/columns/", data=data + ) + assert response.status_code == 201 + new_columns_response = client.get( + f"/api/v0/tables/{column_test_table.id}/columns/" + ) + assert new_columns_response.json()["count"] == num_columns + 1 + actual_new_col = new_columns_response.json()["results"][-1] + assert actual_new_col["name"] == name + assert actual_new_col["type"] == type_ + + +def test_column_create_duplicate(column_test_table, client): + column = column_test_table.sa_columns[0] + name = column.name + type_ = "NUMERIC" + cache.clear() + data = { + "name": name, "type": type_ + } + response = client.post( + f"/api/v0/tables/{column_test_table.id}/columns/", data=data + ) + assert response.status_code == 400 + + +def test_column_update_name(column_test_table, client): + cache.clear() + name = "updatedname" + data = {"name": name} + response = client.patch( + f"/api/v0/tables/{column_test_table.id}/columns/1/", data=data + ) + assert response.json()["name"] == name + + +def test_column_update_type(column_test_table, client): + cache.clear() + type_ = "BOOLEAN" + data = {"type": type_} + response = client.patch( + f"/api/v0/tables/{column_test_table.id}/columns/3/", data=data + ) + assert response.json()["type"] == type_ + + +def test_column_update_type_invalid_cast(column_test_table, client): + cache.clear() + type_ = "email" + data = {"type": type_} + response = client.patch( + f"/api/v0/tables/{column_test_table.id}/columns/1/", data=data + ) + assert response.status_code == 400 + + +def test_column_update_when_missing(column_test_table, client): + cache.clear() + name = "updatedname" + data = {"name": name} + response = client.patch( + f"/api/v0/tables/{column_test_table.id}/columns/15/", data=data + ) + response_data = response.json() + assert response_data == {"detail": "Not found."} + assert response.status_code == 404 + + +def test_column_destroy(column_test_table, client): + cache.clear() + num_columns = len(column_test_table.sa_columns) + col_one_name = column_test_table.sa_columns[1].name + response = client.delete( + f"/api/v0/tables/{column_test_table.id}/columns/1/" + ) + assert response.status_code == 204 + new_columns_response = client.get( + f"/api/v0/tables/{column_test_table.id}/columns/" + ) + new_data = new_columns_response.json() + assert col_one_name not in [col["name"] for col in new_data["results"]] + assert new_data["count"] == num_columns - 1 + + +def test_column_destroy_when_missing(column_test_table, client): + cache.clear() + response = client.delete( + f"/api/v0/tables/{column_test_table.id}/columns/15/" + ) + response_data = response.json() + assert response_data == {"detail": "Not found."} + assert response.status_code == 404 diff --git a/mathesar/urls.py b/mathesar/urls.py index 2f7cd109bd..80a94ab1da 100644 --- a/mathesar/urls.py +++ b/mathesar/urls.py @@ -7,16 +7,17 @@ router = routers.DefaultRouter() router.register(r'tables', api.TableViewSet, basename='table') router.register(r'schemas', api.SchemaViewSet, basename='schema') -router.register(r'database_keys', api.DatabaseKeyViewSet, basename='database_keys') +router.register(r'database_keys', api.DatabaseKeyViewSet, basename='database-key') router.register(r'data_files', api.DataFileViewSet) -records_router = routers.NestedSimpleRouter(router, r'tables', lookup='table') -records_router.register(r'records', api.RecordViewSet, basename='table-records') +table_router = routers.NestedSimpleRouter(router, r'tables', lookup='table') +table_router.register(r'records', api.RecordViewSet, basename='table-record') +table_router.register(r'columns', api.ColumnViewSet, basename='table-column') urlpatterns = [ path('', frontend.index, name="index"), path('api/v0/', include(router.urls)), - path('api/v0/', include(records_router.urls)), + path('api/v0/', include(table_router.urls)), # TODO: Handle known urls like /favicon.ico etc., # Currenty, this catches all path('', frontend.index, name="index"), diff --git a/mathesar/views/api.py b/mathesar/views/api.py index 6ec6405568..aa15c7e208 100644 --- a/mathesar/views/api.py +++ b/mathesar/views/api.py @@ -1,17 +1,23 @@ import logging from rest_framework import status, viewsets -from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.exceptions import NotFound, ValidationError, APIException from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, CreateModelMixin from rest_framework.response import Response from django.core.cache import cache from rest_framework.decorators import action from django_filters import rest_framework as filters +from psycopg2.errors import DuplicateColumn, UndefinedFunction +from sqlalchemy.exc import ProgrammingError from mathesar.database.utils import get_non_default_database_keys from mathesar.models import Table, Schema, DataFile -from mathesar.pagination import DefaultLimitOffsetPagination, TableLimitOffsetPagination -from mathesar.serializers import TableSerializer, SchemaSerializer, RecordSerializer, DataFileSerializer +from mathesar.pagination import ( + ColumnLimitOffsetPagination, DefaultLimitOffsetPagination, TableLimitOffsetPagination +) +from mathesar.serializers import ( + TableSerializer, SchemaSerializer, RecordSerializer, DataFileSerializer, ColumnSerializer, +) from mathesar.utils.schemas import create_schema_and_object, reflect_schemas_from_database from mathesar.utils.tables import reflect_tables_from_schema, get_table_column_types from mathesar.utils.datafiles import create_table_from_datafile, create_datafile @@ -77,6 +83,67 @@ def type_suggestions(self, request, pk=None): return Response(col_types) +class ColumnViewSet(viewsets.ViewSet): + queryset = Table.objects.all().order_by('-created_at') + + def list(self, request, table_pk=None): + paginator = ColumnLimitOffsetPagination() + columns = paginator.paginate_queryset(self.queryset, request, table_pk) + serializer = ColumnSerializer(columns, many=True) + return paginator.get_paginated_response(serializer.data) + + def retrieve(self, request, pk=None, table_pk=None): + table = Table.objects.get(id=table_pk) + try: + column = table.sa_columns[int(pk)] + except IndexError: + raise NotFound + serializer = ColumnSerializer(column) + return Response(serializer.data) + + def create(self, request, table_pk=None): + table = Table.objects.get(id=table_pk) + # We only support adding a single column through the API. + serializer = ColumnSerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + try: + column = table.add_column(request.data) + except ProgrammingError as e: + if type(e.orig) == DuplicateColumn: + raise ValidationError( + f"Column {request.data['name']} already exists" + ) + else: + raise APIException(e) + else: + raise ValidationError(serializer.errors) + out_serializer = ColumnSerializer(column) + return Response(out_serializer.data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, pk=None, table_pk=None): + table = Table.objects.get(id=table_pk) + assert isinstance((request.data), dict) + try: + column = table.alter_column(pk, request.data) + except ProgrammingError as e: + if type(e.orig) == UndefinedFunction: + raise ValidationError("This type cast is not implemented") + else: + raise ValidationError + except IndexError: + raise NotFound + serializer = ColumnSerializer(column) + return Response(serializer.data) + + def destroy(self, request, pk=None, table_pk=None): + table = Table.objects.get(id=table_pk) + try: + table.drop_column(pk) + except IndexError: + raise NotFound + return Response(status=status.HTTP_204_NO_CONTENT) + + class RecordViewSet(viewsets.ViewSet): # There is no "update" method. # We're not supporting PUT requests because there aren't a lot of use cases diff --git a/requirements.txt b/requirements.txt index 782fd226a6..be84cf78f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ +alembic==1.6.5 +clevercsv==0.6.8 Django==3.1.7 dj-database-url==0.5.0 +django-filter==2.4.0 +django-property-filter==1.1.0 djangorestframework==3.12.4 drf-nested-routers==0.93.3 psycopg2==2.8.6 SQLAlchemy==1.4.18 -clevercsv==0.6.8 -django-filter==2.4.0 -django-property-filter==1.1.0