Skip to content

Commit

Permalink
Merge pull request #1374 from centerofci/mathesar-972-link-table
Browse files Browse the repository at this point in the history
Add link Table API
  • Loading branch information
mathemancer authored May 31, 2022
2 parents 11a0b77 + 23d4ada commit f656572
Show file tree
Hide file tree
Showing 13 changed files with 376 additions and 6 deletions.
Empty file added db/links/__init__.py
Empty file.
Empty file added db/links/operations/__init__.py
Empty file.
65 changes: 65 additions & 0 deletions db/links/operations/create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from alembic.operations import Operations
from alembic.migration import MigrationContext
from sqlalchemy import ForeignKey, MetaData

from db.columns.base import MathesarColumn
from db.constraints.utils import naming_convention
from db.tables.operations.create import create_mathesar_table
from db.tables.operations.select import reflect_table_from_oid, reflect_tables_from_oids
from db.tables.utils import get_primary_key_column


def create_foreign_key_link(
engine,
schema,
referrer_column_name,
referrer_table_oid,
referent_table_oid,
unique_link=False
):
with engine.begin() as conn:
referent_table = reflect_table_from_oid(referent_table_oid, engine, conn)
referrer_table = reflect_table_from_oid(referrer_table_oid, engine, conn)
primary_key_column = get_primary_key_column(referent_table)
metadata = MetaData(bind=engine, schema=schema, naming_convention=naming_convention)
opts = {
'target_metadata': metadata
}
ctx = MigrationContext.configure(conn, opts=opts)
op = Operations(ctx)
column = MathesarColumn(
referrer_column_name, primary_key_column.type
)
op.add_column(referrer_table.name, column, schema=schema)
if unique_link:
op.create_unique_constraint(None, referrer_table.name, [referrer_column_name], schema=schema)
op.create_foreign_key(
None,
referrer_table.name,
referent_table.name,
[column.name],
[primary_key_column.name],
source_schema=schema,
referent_schema=schema
)


def create_many_to_many_link(engine, schema, map_table_name, referents):
with engine.begin() as conn:
referent_tables_oid = [referent['referent_table'] for referent in referents]
referent_tables = reflect_tables_from_oids(referent_tables_oid, engine, conn)
metadata = MetaData(bind=engine, schema=schema, naming_convention=naming_convention)
# Throws sqlalchemy.exc.NoReferencedTableError if metadata is not reflected.
metadata.reflect()
referrer_columns = []
for referent in referents:
referent_table_oid = referent['referent_table']
referent_table = referent_tables[referent_table_oid]
col_name = referent['column_name']
primary_key_column = get_primary_key_column(referent_table)
foreign_keys = {ForeignKey(primary_key_column)}
column = MathesarColumn(
col_name, primary_key_column.type, foreign_keys=foreign_keys,
)
referrer_columns.append(column)
create_mathesar_table(map_table_name, schema, referrer_columns, engine, metadata)
17 changes: 12 additions & 5 deletions db/tables/operations/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,33 @@ def reflect_table(name, schema, engine, metadata=None, connection_to_use=None):


def reflect_table_from_oid(oid, engine, connection_to_use=None):
tables = reflect_tables_from_oids([oid], engine, connection_to_use)
return tables.get(oid, None)


def reflect_tables_from_oids(oids, engine, connection_to_use=None):
metadata = MetaData()

with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="Did not recognize type")
pg_class = Table("pg_class", metadata, autoload_with=engine)
pg_namespace = Table("pg_namespace", metadata, autoload_with=engine)
sel = (
select(pg_namespace.c.nspname, pg_class.c.relname)
select(pg_namespace.c.nspname, pg_class.c.relname, pg_class.c.oid)
.select_from(
join(
pg_class,
pg_namespace,
pg_class.c.relnamespace == pg_namespace.c.oid
)
)
.where(pg_class.c.oid == oid)
.where(pg_class.c.oid.in_(oids))
)
result = execute_statement(engine, sel, connection_to_use)
schema, table_name = result.fetchall()[0]
return reflect_table(table_name, schema, engine, connection_to_use=connection_to_use)
results = execute_statement(engine, sel, connection_to_use).fetchall()
tables = {}
for (schema, table_name, table_oid) in results:
tables[table_oid] = reflect_table(table_name, schema, engine, connection_to_use=connection_to_use)
return tables


def get_table_oids_from_schema(schema_oid, engine):
Expand Down
Empty file added db/tests/links/__init__.py
Empty file.
1 change: 1 addition & 0 deletions mathesar/api/db/viewsets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from mathesar.api.db.viewsets.records import RecordViewSet # noqa
from mathesar.api.db.viewsets.schemas import SchemaViewSet # noqa
from mathesar.api.db.viewsets.tables import TableViewSet # noqa
from mathesar.api.db.viewsets.links import LinkViewSet # noqa
12 changes: 12 additions & 0 deletions mathesar/api/db/viewsets/links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from rest_framework.mixins import CreateModelMixin, ListModelMixin
from rest_framework.viewsets import GenericViewSet
from mathesar.api.pagination import DefaultLimitOffsetPagination
from mathesar.api.serializers.links import LinksMappingSerializer


class LinkViewSet(CreateModelMixin, ListModelMixin, GenericViewSet):
serializer_class = LinksMappingSerializer
pagination_class = DefaultLimitOffsetPagination

def get_queryset(self):
return []
1 change: 1 addition & 0 deletions mathesar/api/exceptions/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ class ErrorCodes(Enum):
URLNotReachableError = 4405
URLInvalidContentType = 4406
UnknownDBType = 4408
InvalidLinkChoice = 4409
18 changes: 17 additions & 1 deletion mathesar/api/exceptions/mixins.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from rest_framework.serializers import Serializer
from rest_framework.serializers import ListSerializer, Serializer
from rest_framework.utils.serializer_helpers import ReturnList
from rest_framework_friendly_errors.mixins import FriendlyErrorMessagesMixin
from django.core.exceptions import ValidationError as DjangoValidationError
Expand Down Expand Up @@ -40,6 +40,22 @@ def build_pretty_errors(self, errors, serializer=None):
child_errors = field.build_pretty_errors(errors[error_type])
pretty += child_errors
continue
if isinstance(field, ListSerializer) and type(errors[error_type]) == list:
pretty_child_errors = []
for index, child_error in enumerate(errors[error_type]):
child_field = field.child
initial_data = self.initial_data.get(error_type, None)
if initial_data is not None:
child_field.initial_data = self.initial_data[error_type][index]
else:
child_field.initial_data = None
if isinstance(child_error, str):
pretty_child_errors.append(self.get_field_error_entry(child_error, field))
else:
child_errors = child_field.build_pretty_errors(child_error)
pretty_child_errors.extend(child_errors)
pretty.extend(pretty_child_errors)
continue
if self.is_pretty(error):
if 'field' not in error or error['field'] is None or str(error['field']) == 'None':
error['field'] = error_type
Expand Down
12 changes: 12 additions & 0 deletions mathesar/api/exceptions/validation_exceptions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ def __init__(
super().__init__(None, self.error_code, message, field, details)


class InvalidLinkChoiceAPIException(MathesarValidationException):
error_code = ErrorCodes.InvalidLinkChoice.value

def __init__(
self,
message="Invalid Link type",
field=None,
details=None,
):
super().__init__(None, self.error_code, message, field, details)


class MultipleDataFileAPIException(MathesarValidationException):
error_code = ErrorCodes.MultipleDataFiles.value

Expand Down
89 changes: 89 additions & 0 deletions mathesar/api/serializers/links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from rest_framework import serializers

from db.links.operations.create import create_foreign_key_link, create_many_to_many_link
from mathesar.api.exceptions.mixins import MathesarErrorMessageMixin
from mathesar.api.exceptions.validation_exceptions.exceptions import (
InvalidLinkChoiceAPIException,
)
from mathesar.api.serializers.shared_serializers import (
MathesarPolymorphicErrorMixin,
ReadWritePolymorphicSerializerMappingMixin,
)
from mathesar.models import Table


class OneToOneSerializer(MathesarErrorMessageMixin, serializers.Serializer):
reference_column_name = serializers.CharField()
reference_table = serializers.PrimaryKeyRelatedField(queryset=Table.current_objects.all())
referent_table = serializers.PrimaryKeyRelatedField(queryset=Table.current_objects.all())
# TODO Fix hacky link_type detection by reflecting it correctly
link_type = serializers.CharField(default="one-to-one")

def is_link_unique(self):
return True

def create(self, validated_data):
reference_table = validated_data['reference_table']
create_foreign_key_link(
reference_table.schema._sa_engine,
reference_table._sa_table.schema,
validated_data.get('reference_column_name'),
reference_table.oid,
validated_data.get('referent_table').oid,
unique_link=self.is_link_unique()
)
return validated_data


class OneToManySerializer(OneToOneSerializer):
link_type = serializers.CharField(default="one-to-many")

def is_link_unique(self):
return False


class MapColumnSerializer(MathesarErrorMessageMixin, serializers.Serializer):
column_name = serializers.CharField()
referent_table = serializers.PrimaryKeyRelatedField(queryset=Table.current_objects.all())


class ManyToManySerializer(MathesarErrorMessageMixin, serializers.Serializer):
referents = MapColumnSerializer(many=True)
mapping_table_name = serializers.CharField()
link_type = serializers.CharField(default="many-to-many")

def create(self, validated_data):
referents = validated_data['referents']
referent_tables_oid = [
{'referent_table': map_table_obj['referent_table'].oid, 'column_name': map_table_obj['column_name']} for
map_table_obj in validated_data['referents']]
create_many_to_many_link(
referents[0]['referent_table'].schema._sa_engine,
referents[0]['referent_table']._sa_table.schema,
validated_data.get('mapping_table_name'),
referent_tables_oid,
)
return validated_data


class LinksMappingSerializer(
MathesarPolymorphicErrorMixin,
ReadWritePolymorphicSerializerMappingMixin,
serializers.Serializer
):
def create(self, validated_data):
serializer = self.serializers_mapping.get(self.get_mapping_field(validated_data))
return serializer.create(validated_data)

serializers_mapping = {
"one-to-one": OneToOneSerializer,
"one-to-many": OneToManySerializer,
"many-to-many": ManyToManySerializer
}
link_type = serializers.CharField(required=True)

def get_mapping_field(self, data):
link_type = data.get('link_type', None)
if link_type is None:
raise InvalidLinkChoiceAPIException()
return link_type
Loading

0 comments on commit f656572

Please sign in to comment.