You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by dp...@apache.org on 2020/03/08 09:13:23 UTC

[incubator-superset] branch master updated: [datasets] new, API using command pattern (#9129)

This is an automated email from the ASF dual-hosted git repository.

dpgaspar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 52c59d6  [datasets] new, API using command pattern (#9129)
52c59d6 is described below

commit 52c59d689063bfac9cba73b416d2b1bdc6a1275e
Author: Daniel Vaz Gaspar <da...@gmail.com>
AuthorDate: Sun Mar 8 09:13:08 2020 +0000

    [datasets] new, API using command pattern (#9129)
    
    * [datasets] new, API using command pattern
    
    * [datasets] tests and improvements
    
    * [datasets] lint
    
    * [database] address comments
    
    * [datasets] lint
    
    * [datasets] Address PR comments
    
    * [dataset] Fix, dataset expects a Dict now
    
    * [dataset] lint and optional commits
    
    * [dataset] mypy
    
    * [dataset] Fix, license and parent class
    
    * [dataset] Make CRUD DAO raise exceptions
---
 superset/app.py                          |   3 +-
 superset/commands/__init__.py            |  16 ++
 superset/commands/base.py                |  39 +++
 superset/commands/exceptions.py          |  71 +++++
 superset/datasets/__init__.py            |  16 ++
 superset/datasets/api.py                 | 265 ++++++++++++++++++
 superset/datasets/commands/__init__.py   |  16 ++
 superset/datasets/commands/base.py       |  43 +++
 superset/datasets/commands/create.py     |  83 ++++++
 superset/datasets/commands/delete.py     |  61 +++++
 superset/datasets/commands/exceptions.py | 103 +++++++
 superset/datasets/commands/update.py     |  90 +++++++
 superset/datasets/dao.py                 | 125 +++++++++
 superset/datasets/schemas.py             |  42 +++
 superset/views/base_api.py               |  15 +-
 tests/dataset_api_tests.py               | 450 +++++++++++++++++++++++++++++++
 16 files changed, 1435 insertions(+), 3 deletions(-)

diff --git a/superset/app.py b/superset/app.py
index 29141c2..dc706fd 100644
--- a/superset/app.py
+++ b/superset/app.py
@@ -130,6 +130,7 @@ class SupersetAppInitializer:
             DruidColumnInlineView,
             Druid,
         )
+        from superset.datasets.api import DatasetRestApi
         from superset.connectors.sqla.views import (
             TableColumnInlineView,
             SqlMetricInlineView,
@@ -182,7 +183,7 @@ class SupersetAppInitializer:
         appbuilder.add_api(ChartRestApi)
         appbuilder.add_api(DashboardRestApi)
         appbuilder.add_api(DatabaseRestApi)
-
+        appbuilder.add_api(DatasetRestApi)
         #
         # Setup regular views
         #
diff --git a/superset/commands/__init__.py b/superset/commands/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/superset/commands/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/commands/base.py b/superset/commands/base.py
new file mode 100644
index 0000000..9889d6f
--- /dev/null
+++ b/superset/commands/base.py
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from abc import ABC, abstractmethod
+
+
+class BaseCommand(ABC):
+    """
+        Base class for all Command like Superset Logic objects
+    """
+
+    @abstractmethod
+    def run(self):
+        """
+        Run executes the command. Can raise command exceptions
+        :return:
+        """
+        pass
+
+    @abstractmethod
+    def validate(self) -> None:
+        """
+        Validate is normally called by run to validate data.
+        Will raise exception if validation fails
+        """
+        pass
diff --git a/superset/commands/exceptions.py b/superset/commands/exceptions.py
new file mode 100644
index 0000000..83b3e1d
--- /dev/null
+++ b/superset/commands/exceptions.py
@@ -0,0 +1,71 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import List, Optional
+
+from marshmallow import ValidationError
+
+
+class CommandException(Exception):
+    """ Common base class for Command exceptions. """
+
+    message = ""
+
+    def __init__(self, message: str = "", exception: Optional[Exception] = None):
+        if message:
+            self.message = message
+        self._exception = exception
+        super().__init__(self.message)
+
+    @property
+    def exception(self):
+        return self._exception
+
+
+class CommandInvalidError(CommandException):
+    """ Common base class for Command Invalid errors. """
+
+    def __init__(self, message=""):
+        self._invalid_exceptions = list()
+        super().__init__(self.message)
+
+    def add(self, exception: ValidationError):
+        self._invalid_exceptions.append(exception)
+
+    def add_list(self, exceptions: List[ValidationError]):
+        self._invalid_exceptions.extend(exceptions)
+
+    def normalized_messages(self):
+        errors = {}
+        for exception in self._invalid_exceptions:
+            errors.update(exception.normalized_messages())
+        return errors
+
+
+class UpdateFailedError(CommandException):
+    message = "Command update failed"
+
+
+class CreateFailedError(CommandException):
+    message = "Command create failed"
+
+
+class DeleteFailedError(CommandException):
+    message = "Command delete failed"
+
+
+class ForbiddenError(CommandException):
+    message = "Action is forbidden"
diff --git a/superset/datasets/__init__.py b/superset/datasets/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/superset/datasets/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
new file mode 100644
index 0000000..64821db
--- /dev/null
+++ b/superset/datasets/api.py
@@ -0,0 +1,265 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+
+from flask import g, request, Response
+from flask_appbuilder.api import expose, protect, safe
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+
+from superset.connectors.sqla.models import SqlaTable
+from superset.constants import RouteMethod
+from superset.datasets.commands.create import CreateDatasetCommand
+from superset.datasets.commands.delete import DeleteDatasetCommand
+from superset.datasets.commands.exceptions import (
+    DatasetCreateFailedError,
+    DatasetDeleteFailedError,
+    DatasetForbiddenError,
+    DatasetInvalidError,
+    DatasetNotFoundError,
+    DatasetUpdateFailedError,
+)
+from superset.datasets.commands.update import UpdateDatasetCommand
+from superset.datasets.schemas import DatasetPostSchema, DatasetPutSchema
+from superset.views.base import DatasourceFilter
+from superset.views.base_api import BaseSupersetModelRestApi
+from superset.views.database.filters import DatabaseFilter
+
+logger = logging.getLogger(__name__)
+
+
+class DatasetRestApi(BaseSupersetModelRestApi):
+    datamodel = SQLAInterface(SqlaTable)
+    base_filters = [["id", DatasourceFilter, lambda: []]]
+
+    resource_name = "dataset"
+    allow_browser_login = True
+
+    class_permission_name = "TableModelView"
+    include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {RouteMethod.RELATED}
+
+    list_columns = [
+        "database_name",
+        "changed_by.username",
+        "changed_on",
+        "table_name",
+        "schema",
+    ]
+    show_columns = [
+        "database.database_name",
+        "database.id",
+        "table_name",
+        "sql",
+        "filter_select_enabled",
+        "fetch_values_predicate",
+        "schema",
+        "description",
+        "main_dttm_col",
+        "offset",
+        "default_endpoint",
+        "cache_timeout",
+        "is_sqllab_view",
+        "template_params",
+        "owners.id",
+        "owners.username",
+    ]
+    add_model_schema = DatasetPostSchema()
+    edit_model_schema = DatasetPutSchema()
+    add_columns = ["database", "schema", "table_name", "owners"]
+    edit_columns = [
+        "table_name",
+        "sql",
+        "filter_select_enabled",
+        "fetch_values_predicate",
+        "schema",
+        "description",
+        "main_dttm_col",
+        "offset",
+        "default_endpoint",
+        "cache_timeout",
+        "is_sqllab_view",
+        "template_params",
+        "owners",
+    ]
+    openapi_spec_tag = "Datasets"
+
+    filter_rel_fields_field = {"owners": "first_name", "database": "database_name"}
+    filter_rel_fields = {"database": [["id", DatabaseFilter, lambda: []]]}
+
+    @expose("/", methods=["POST"])
+    @protect()
+    @safe
+    def post(self) -> Response:
+        """Creates a new Dataset
+        ---
+        post:
+          description: >-
+            Create a new Dataset
+          requestBody:
+            description: Dataset schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+          responses:
+            201:
+              description: Dataset added
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        item = self.add_model_schema.load(request.json)
+        # This validates custom Schema with custom validations
+        if item.errors:
+            return self.response_400(message=item.errors)
+        try:
+            new_model = CreateDatasetCommand(g.user, item.data).run()
+            return self.response(201, id=new_model.id, result=item.data)
+        except DatasetInvalidError as e:
+            return self.response_422(message=e.normalized_messages())
+        except DatasetCreateFailedError as e:
+            logger.error(f"Error creating model {self.__class__.__name__}: {e}")
+            return self.response_422(message=str(e))
+
+    @expose("/<pk>", methods=["PUT"])
+    @protect()
+    @safe
+    def put(  # pylint: disable=too-many-return-statements, arguments-differ
+        self, pk: int
+    ) -> Response:
+        """Changes a Dataset
+        ---
+        put:
+          description: >-
+            Changes a Dataset
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          requestBody:
+            description: Dataset schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+          responses:
+            200:
+              description: Dataset changed
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        item = self.edit_model_schema.load(request.json)
+        # This validates custom Schema with custom validations
+        if item.errors:
+            return self.response_400(message=item.errors)
+        try:
+            changed_model = UpdateDatasetCommand(g.user, pk, item.data).run()
+            return self.response(200, id=changed_model.id, result=item.data)
+        except DatasetNotFoundError:
+            return self.response_404()
+        except DatasetForbiddenError:
+            return self.response_403()
+        except DatasetInvalidError as e:
+            return self.response_422(message=e.normalized_messages())
+        except DatasetUpdateFailedError as e:
+            logger.error(f"Error updating model {self.__class__.__name__}: {e}")
+            return self.response_422(message=str(e))
+
+    @expose("/<pk>", methods=["DELETE"])
+    @protect()
+    @safe
+    def delete(self, pk: int) -> Response:  # pylint: disable=arguments-differ
+        """Deletes a Dataset
+        ---
+        delete:
+          description: >-
+            Deletes a Dataset
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          responses:
+            200:
+              description: Dataset delete
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            DeleteDatasetCommand(g.user, pk).run()
+            return self.response(200, message="OK")
+        except DatasetNotFoundError:
+            return self.response_404()
+        except DatasetForbiddenError:
+            return self.response_403()
+        except DatasetDeleteFailedError as e:
+            logger.error(f"Error deleting model {self.__class__.__name__}: {e}")
+            return self.response_422(message=str(e))
diff --git a/superset/datasets/commands/__init__.py b/superset/datasets/commands/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/superset/datasets/commands/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/datasets/commands/base.py b/superset/datasets/commands/base.py
new file mode 100644
index 0000000..646dfc3
--- /dev/null
+++ b/superset/datasets/commands/base.py
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import List, Optional
+
+from flask_appbuilder.security.sqla.models import User
+
+from superset.datasets.commands.exceptions import OwnersNotFoundValidationError
+from superset.datasets.dao import DatasetDAO
+
+
+def populate_owners(user: User, owners_ids: Optional[List[int]] = None) -> List[User]:
+    """
+    Helper function for commands, will fetch all users from owners id's
+    Can raise ValidationError
+
+    :param user: The current user
+    :param owners_ids: A List of owners by id's
+    """
+    owners = list()
+    if not owners_ids:
+        return [user]
+    if user.id not in owners_ids:
+        owners.append(user)
+    for owner_id in owners_ids:
+        owner = DatasetDAO.get_owner_by_id(owner_id)
+        if not owner:
+            raise OwnersNotFoundValidationError()
+        owners.append(owner)
+    return owners
diff --git a/superset/datasets/commands/create.py b/superset/datasets/commands/create.py
new file mode 100644
index 0000000..344770b
--- /dev/null
+++ b/superset/datasets/commands/create.py
@@ -0,0 +1,83 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+from typing import Dict, List, Optional
+
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset.commands.base import BaseCommand
+from superset.commands.exceptions import CreateFailedError
+from superset.datasets.commands.base import populate_owners
+from superset.datasets.commands.exceptions import (
+    DatabaseNotFoundValidationError,
+    DatasetCreateFailedError,
+    DatasetExistsValidationError,
+    DatasetInvalidError,
+    TableNotFoundValidationError,
+)
+from superset.datasets.dao import DatasetDAO
+
+logger = logging.getLogger(__name__)
+
+
+class CreateDatasetCommand(BaseCommand):
+    def __init__(self, user: User, data: Dict):
+        self._actor = user
+        self._properties = data.copy()
+
+    def run(self):
+        self.validate()
+        try:
+            dataset = DatasetDAO.create(self._properties)
+        except CreateFailedError as e:
+            logger.exception(e.exception)
+            raise DatasetCreateFailedError()
+        return dataset
+
+    def validate(self) -> None:
+        exceptions = list()
+        database_id = self._properties["database"]
+        table_name = self._properties["table_name"]
+        schema = self._properties.get("schema", "")
+        owner_ids: Optional[List[int]] = self._properties.get("owners")
+
+        # Validate uniqueness
+        if not DatasetDAO.validate_uniqueness(database_id, table_name):
+            exceptions.append(DatasetExistsValidationError(table_name))
+
+        # Validate/Populate database
+        database = DatasetDAO.get_database_by_id(database_id)
+        if not database:
+            exceptions.append(DatabaseNotFoundValidationError())
+        self._properties["database"] = database
+
+        # Validate table exists on dataset
+        if database and not DatasetDAO.validate_table_exists(
+            database, table_name, schema
+        ):
+            exceptions.append(TableNotFoundValidationError(table_name))
+
+        try:
+            owners = populate_owners(self._actor, owner_ids)
+            self._properties["owners"] = owners
+        except ValidationError as e:
+            exceptions.append(e)
+        if exceptions:
+            exception = DatasetInvalidError()
+            exception.add_list(exceptions)
+            raise exception
diff --git a/superset/datasets/commands/delete.py b/superset/datasets/commands/delete.py
new file mode 100644
index 0000000..d61c56a
--- /dev/null
+++ b/superset/datasets/commands/delete.py
@@ -0,0 +1,61 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+from typing import Optional
+
+from flask_appbuilder.security.sqla.models import User
+
+from superset.commands.base import BaseCommand
+from superset.commands.exceptions import DeleteFailedError
+from superset.connectors.sqla.models import SqlaTable
+from superset.datasets.commands.exceptions import (
+    DatasetDeleteFailedError,
+    DatasetForbiddenError,
+    DatasetNotFoundError,
+)
+from superset.datasets.dao import DatasetDAO
+from superset.exceptions import SupersetSecurityException
+from superset.views.base import check_ownership
+
+logger = logging.getLogger(__name__)
+
+
+class DeleteDatasetCommand(BaseCommand):
+    def __init__(self, user: User, model_id: int):
+        self._actor = user
+        self._model_id = model_id
+        self._model: Optional[SqlaTable] = None
+
+    def run(self):
+        self.validate()
+        try:
+            dataset = DatasetDAO.delete(self._model)
+        except DeleteFailedError as e:
+            logger.exception(e.exception)
+            raise DatasetDeleteFailedError()
+        return dataset
+
+    def validate(self) -> None:
+        # Validate/populate model exists
+        self._model = DatasetDAO.find_by_id(self._model_id)
+        if not self._model:
+            raise DatasetNotFoundError()
+        # Check ownership
+        try:
+            check_ownership(self._model)
+        except SupersetSecurityException:
+            raise DatasetForbiddenError()
diff --git a/superset/datasets/commands/exceptions.py b/superset/datasets/commands/exceptions.py
new file mode 100644
index 0000000..a6d0ed7
--- /dev/null
+++ b/superset/datasets/commands/exceptions.py
@@ -0,0 +1,103 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from flask_babel import lazy_gettext as _
+from marshmallow.validate import ValidationError
+
+from superset.commands.exceptions import (
+    CommandException,
+    CommandInvalidError,
+    CreateFailedError,
+    DeleteFailedError,
+    ForbiddenError,
+    UpdateFailedError,
+)
+from superset.views.base import get_datasource_exist_error_msg
+
+
+class DatabaseNotFoundValidationError(ValidationError):
+    """
+    Marshmallow validation error for database does not exist
+    """
+
+    def __init__(self):
+        super().__init__(_("Database does not exist"), field_names=["database"])
+
+
+class DatabaseChangeValidationError(ValidationError):
+    """
+    Marshmallow validation error database changes are not allowed on update
+    """
+
+    def __init__(self):
+        super().__init__(_("Database not allowed to change"), field_names=["database"])
+
+
+class DatasetExistsValidationError(ValidationError):
+    """
+    Marshmallow validation error for dataset already exists
+    """
+
+    def __init__(self, table_name: str):
+        super().__init__(
+            get_datasource_exist_error_msg(table_name), field_names=["table_name"]
+        )
+
+
+class TableNotFoundValidationError(ValidationError):
+    """
+    Marshmallow validation error when a table does not exist on the database
+    """
+
+    def __init__(self, table_name: str):
+        super().__init__(
+            _(
+                f"Table [{table_name}] could not be found, "
+                "please double check your "
+                "database connection, schema, and "
+                f"table name"
+            ),
+            field_names=["table_name"],
+        )
+
+
+class OwnersNotFoundValidationError(ValidationError):
+    def __init__(self):
+        super().__init__(_("Owners are invalid"), field_names=["owners"])
+
+
+class DatasetNotFoundError(CommandException):
+    message = "Dataset not found."
+
+
+class DatasetInvalidError(CommandInvalidError):
+    message = _("Dataset parameters are invalid.")
+
+
+class DatasetCreateFailedError(CreateFailedError):
+    message = _("Dataset could not be created.")
+
+
+class DatasetUpdateFailedError(UpdateFailedError):
+    message = _("Dataset could not be updated.")
+
+
+class DatasetDeleteFailedError(DeleteFailedError):
+    message = _("Dataset could not be deleted.")
+
+
+class DatasetForbiddenError(ForbiddenError):
+    message = _("Changing this dataset is forbidden")
diff --git a/superset/datasets/commands/update.py b/superset/datasets/commands/update.py
new file mode 100644
index 0000000..b3deeab
--- /dev/null
+++ b/superset/datasets/commands/update.py
@@ -0,0 +1,90 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+from typing import Dict, List, Optional
+
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset.commands.base import BaseCommand
+from superset.commands.exceptions import UpdateFailedError
+from superset.connectors.sqla.models import SqlaTable
+from superset.datasets.commands.base import populate_owners
+from superset.datasets.commands.exceptions import (
+    DatabaseChangeValidationError,
+    DatasetExistsValidationError,
+    DatasetForbiddenError,
+    DatasetInvalidError,
+    DatasetNotFoundError,
+    DatasetUpdateFailedError,
+)
+from superset.datasets.dao import DatasetDAO
+from superset.exceptions import SupersetSecurityException
+from superset.views.base import check_ownership
+
+logger = logging.getLogger(__name__)
+
+
+class UpdateDatasetCommand(BaseCommand):
+    def __init__(self, user: User, model_id: int, data: Dict):
+        self._actor = user
+        self._model_id = model_id
+        self._properties = data.copy()
+        self._model: Optional[SqlaTable] = None
+
+    def run(self):
+        self.validate()
+        try:
+            dataset = DatasetDAO.update(self._model, self._properties)
+        except UpdateFailedError as e:
+            logger.exception(e.exception)
+            raise DatasetUpdateFailedError()
+        return dataset
+
+    def validate(self) -> None:
+        exceptions = list()
+        owner_ids: Optional[List[int]] = self._properties.get("owners")
+        # Validate/populate model exists
+        self._model = DatasetDAO.find_by_id(self._model_id)
+        if not self._model:
+            raise DatasetNotFoundError()
+        # Check ownership
+        try:
+            check_ownership(self._model)
+        except SupersetSecurityException:
+            raise DatasetForbiddenError()
+
+        database_id = self._properties.get("database", None)
+        table_name = self._properties.get("table_name", None)
+        # Validate uniqueness
+        if not DatasetDAO.validate_update_uniqueness(
+            self._model.database_id, self._model_id, table_name
+        ):
+            exceptions.append(DatasetExistsValidationError(table_name))
+        # Validate/Populate database not allowed to change
+        if database_id and database_id != self._model:
+            exceptions.append(DatabaseChangeValidationError())
+        # Validate/Populate owner
+        try:
+            owners = populate_owners(self._actor, owner_ids)
+            self._properties["owners"] = owners
+        except ValidationError as e:
+            exceptions.append(e)
+        if exceptions:
+            exception = DatasetInvalidError()
+            exception.add_list(exceptions)
+            raise exception
diff --git a/superset/datasets/dao.py b/superset/datasets/dao.py
new file mode 100644
index 0000000..7e08ce8
--- /dev/null
+++ b/superset/datasets/dao.py
@@ -0,0 +1,125 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+from typing import Dict, Optional
+
+from flask import current_app
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.commands.exceptions import (
+    CreateFailedError,
+    DeleteFailedError,
+    UpdateFailedError,
+)
+from superset.connectors.sqla.models import SqlaTable
+from superset.extensions import db
+from superset.models.core import Database
+from superset.views.base import DatasourceFilter
+
+logger = logging.getLogger(__name__)
+
+
+class DatasetDAO:
+    @staticmethod
+    def get_owner_by_id(owner_id: int) -> Optional[object]:
+        return (
+            db.session.query(current_app.appbuilder.sm.user_model)
+            .filter_by(id=owner_id)
+            .one_or_none()
+        )
+
+    @staticmethod
+    def get_database_by_id(database_id) -> Optional[Database]:
+        try:
+            return db.session.query(Database).filter_by(id=database_id).one_or_none()
+        except SQLAlchemyError as e:  # pragma: no cover
+            logger.error(f"Could not get database by id: {e}")
+            return None
+
+    @staticmethod
+    def validate_table_exists(database: Database, table_name: str, schema: str) -> bool:
+        try:
+            database.get_table(table_name, schema=schema)
+            return True
+        except SQLAlchemyError as e:  # pragma: no cover
+            logger.error(f"Got an error {e} validating table: {table_name}")
+            return False
+
+    @staticmethod
+    def validate_uniqueness(database_id: int, name: str) -> bool:
+        dataset_query = db.session.query(SqlaTable).filter(
+            SqlaTable.table_name == name, SqlaTable.database_id == database_id
+        )
+        return not db.session.query(dataset_query.exists()).scalar()
+
+    @staticmethod
+    def validate_update_uniqueness(
+        database_id: int, dataset_id: int, name: str
+    ) -> bool:
+        dataset_query = db.session.query(SqlaTable).filter(
+            SqlaTable.table_name == name,
+            SqlaTable.database_id == database_id,
+            SqlaTable.id != dataset_id,
+        )
+        return not db.session.query(dataset_query.exists()).scalar()
+
+    @staticmethod
+    def find_by_id(model_id: int) -> SqlaTable:
+        data_model = SQLAInterface(SqlaTable, db.session)
+        query = db.session.query(SqlaTable)
+        query = DatasourceFilter("id", data_model).apply(query, None)
+        return query.filter_by(id=model_id).one_or_none()
+
+    @staticmethod
+    def create(properties: Dict, commit=True) -> Optional[SqlaTable]:
+        model = SqlaTable()
+        for key, value in properties.items():
+            setattr(model, key, value)
+        try:
+            db.session.add(model)
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError as e:  # pragma: no cover
+            db.session.rollback()
+            raise CreateFailedError(exception=e)
+        return model
+
+    @staticmethod
+    def update(model: SqlaTable, properties: Dict, commit=True) -> Optional[SqlaTable]:
+        for key, value in properties.items():
+            setattr(model, key, value)
+        try:
+            db.session.merge(model)
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError as e:  # pragma: no cover
+            db.session.rollback()
+            raise UpdateFailedError(exception=e)
+        return model
+
+    @staticmethod
+    def delete(model: SqlaTable, commit=True):
+        try:
+            db.session.delete(model)
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError as e:  # pragma: no cover
+            logger.error(f"Failed to delete dataset: {e}")
+            db.session.rollback()
+            raise DeleteFailedError(exception=e)
+        return model
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
new file mode 100644
index 0000000..370550d
--- /dev/null
+++ b/superset/datasets/schemas.py
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from marshmallow import fields, Schema
+from marshmallow.validate import Length
+
+
+class DatasetPostSchema(Schema):
+    database = fields.Integer(required=True)
+    schema = fields.String(validate=Length(0, 250))
+    table_name = fields.String(required=True, allow_none=False, validate=Length(1, 250))
+    owners = fields.List(fields.Integer())
+
+
+class DatasetPutSchema(Schema):
+    table_name = fields.String(allow_none=True, validate=Length(1, 250))
+    sql = fields.String(allow_none=True)
+    filter_select_enabled = fields.Boolean(allow_none=True)
+    fetch_values_predicate = fields.String(allow_none=True, validate=Length(0, 1000))
+    schema = fields.String(allow_none=True, validate=Length(1, 255))
+    description = fields.String(allow_none=True)
+    main_dttm_col = fields.String(allow_none=True)
+    offset = fields.Integer(allow_none=True)
+    default_endpoint = fields.String(allow_none=True)
+    cache_timeout = fields.Integer(allow_none=True)
+    is_sqllab_view = fields.Boolean(allow_none=True)
+    template_params = fields.String(allow_none=True)
+    owners = fields.List(fields.Integer())
diff --git a/superset/views/base_api.py b/superset/views/base_api.py
index ea9286e..86a86a6 100644
--- a/superset/views/base_api.py
+++ b/superset/views/base_api.py
@@ -21,7 +21,7 @@ from typing import Dict, Tuple
 from flask import request
 from flask_appbuilder import ModelRestApi
 from flask_appbuilder.api import expose, protect, rison, safe
-from flask_appbuilder.models.filters import Filters
+from flask_appbuilder.models.filters import BaseFilter, Filters
 from sqlalchemy.exc import SQLAlchemyError
 
 from superset.exceptions import SupersetSecurityException
@@ -90,7 +90,15 @@ class BaseSupersetModelRestApi(ModelRestApi):
     Declare the related field field for filtering::
 
         filter_rel_fields_field = {
-            "<RELATED_FIELD>": "<RELATED_FIELD_FIELD>", "<asc|desc>")
+            "<RELATED_FIELD>": "<RELATED_FIELD_FIELD>")
+        }
+    """  # pylint: disable=pointless-string-statement
+    filter_rel_fields: Dict[str, BaseFilter] = {}
+    """
+    Declare the related field base filter::
+
+        filter_rel_fields_field = {
+            "<RELATED_FIELD>": "<FILTER>")
         }
     """  # pylint: disable=pointless-string-statement
 
@@ -117,6 +125,9 @@ class BaseSupersetModelRestApi(ModelRestApi):
     def _get_related_filter(self, datamodel, column_name: str, value: str) -> Filters:
         filter_field = self.filter_rel_fields_field.get(column_name)
         filters = datamodel.get_filters([filter_field])
+        base_filters = self.filter_rel_fields.get(column_name)
+        if base_filters:
+            filters = filters.add_filter_list(base_filters)
         if value:
             filters.rest_add_filters(
                 [{"opr": "sw", "col": filter_field, "value": value}]
diff --git a/tests/dataset_api_tests.py b/tests/dataset_api_tests.py
new file mode 100644
index 0000000..3f765b7
--- /dev/null
+++ b/tests/dataset_api_tests.py
@@ -0,0 +1,450 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for Superset"""
+import json
+from typing import List
+from unittest.mock import patch
+
+import prison
+
+from superset import db, security_manager
+from superset.commands.exceptions import (
+    CreateFailedError,
+    DeleteFailedError,
+    UpdateFailedError,
+)
+from superset.connectors.sqla.models import SqlaTable
+from superset.models.core import Database
+from superset.utils.core import get_example_database
+
+from .base_tests import SupersetTestCase
+
+
+class DatasetApiTests(SupersetTestCase):
+    @staticmethod
+    def insert_dataset(
+        table_name: str, schema: str, owners: List[int], database: Database
+    ) -> SqlaTable:
+        obj_owners = list()
+        for owner in owners:
+            user = db.session.query(security_manager.user_model).get(owner)
+            obj_owners.append(user)
+        table = SqlaTable(
+            table_name=table_name, schema=schema, owners=obj_owners, database=database
+        )
+        db.session.add(table)
+        db.session.commit()
+        return table
+
+    def test_get_dataset_list(self):
+        """
+            Dataset API: Test get dataset list
+        """
+        example_db = get_example_database()
+        self.login(username="admin")
+        arguments = {
+            "filters": [
+                {"col": "database", "opr": "rel_o_m", "value": f"{example_db.id}"},
+                {"col": "table_name", "opr": "eq", "value": f"birth_names"},
+            ]
+        }
+        uri = f"api/v1/dataset/?q={prison.dumps(arguments)}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        response = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(response["count"], 1)
+        expected_columns = [
+            "changed_by",
+            "changed_on",
+            "database_name",
+            "schema",
+            "table_name",
+        ]
+        self.assertEqual(sorted(list(response["result"][0].keys())), expected_columns)
+
+    def test_get_dataset_list_gamma(self):
+        """
+            Dataset API: Test get dataset list gamma
+        """
+        example_db = get_example_database()
+        self.login(username="gamma")
+        uri = "api/v1/dataset/"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        response = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(response["result"], [])
+
+    def test_get_dataset_related_database_gamma(self):
+        """
+            Dataset API: Test get dataset related databases gamma
+        """
+        example_db = get_example_database()
+        self.login(username="gamma")
+        uri = "api/v1/dataset/related/database"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        response = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(response["count"], 0)
+        self.assertEqual(response["result"], [])
+
+    def test_get_dataset_item(self):
+        """
+            Dataset API: Test get dataset item
+        """
+        example_db = get_example_database()
+        table = (
+            db.session.query(SqlaTable)
+            .filter_by(database=example_db, table_name="birth_names")
+            .one()
+        )
+        self.login(username="admin")
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        response = json.loads(rv.data.decode("utf-8"))
+        expected_result = {
+            "cache_timeout": None,
+            "database": {"database_name": "examples", "id": 1},
+            "default_endpoint": None,
+            "description": None,
+            "fetch_values_predicate": None,
+            "filter_select_enabled": True,
+            "is_sqllab_view": False,
+            "main_dttm_col": "ds",
+            "offset": 0,
+            "owners": [],
+            "schema": None,
+            "sql": None,
+            "table_name": "birth_names",
+            "template_params": None,
+        }
+        self.assertEqual(response["result"], expected_result)
+
+    def test_get_dataset_info(self):
+        """
+            Dataset API: Test get dataset info
+        """
+        self.login(username="admin")
+        uri = "api/v1/dataset/_info"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+
+    def test_create_dataset_item(self):
+        """
+            Dataset API: Test create dataset item
+        """
+        example_db = get_example_database()
+        self.login(username="admin")
+        table_data = {
+            "database": example_db.id,
+            "schema": "",
+            "table_name": "ab_permission",
+        }
+        uri = "api/v1/dataset/"
+        rv = self.client.post(uri, json=table_data)
+        self.assertEqual(rv.status_code, 201)
+        data = json.loads(rv.data.decode("utf-8"))
+        model = db.session.query(SqlaTable).get(data.get("id"))
+        self.assertEqual(model.table_name, table_data["table_name"])
+        self.assertEqual(model.database_id, table_data["database"])
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_create_dataset_item_gamma(self):
+        """
+            Dataset API: Test create dataset item gamma
+        """
+        self.login(username="gamma")
+        example_db = get_example_database()
+        table_data = {
+            "database": example_db.id,
+            "schema": "",
+            "table_name": "ab_permission",
+        }
+        uri = "api/v1/dataset/"
+        rv = self.client.post(uri, json=table_data)
+        self.assertEqual(rv.status_code, 401)
+
+    def test_create_dataset_item_owner(self):
+        """
+            Dataset API: Test create item owner
+        """
+        example_db = get_example_database()
+        self.login(username="alpha")
+        admin = self.get_user("admin")
+        alpha = self.get_user("alpha")
+
+        table_data = {
+            "database": example_db.id,
+            "schema": "",
+            "table_name": "ab_permission",
+            "owners": [admin.id],
+        }
+        uri = "api/v1/dataset/"
+        rv = self.client.post(uri, json=table_data)
+        self.assertEqual(rv.status_code, 201)
+        data = json.loads(rv.data.decode("utf-8"))
+        model = db.session.query(SqlaTable).get(data.get("id"))
+        self.assertIn(admin, model.owners)
+        self.assertIn(alpha, model.owners)
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_create_dataset_item_owners_invalid(self):
+        """
+            Dataset API: Test create dataset item owner invalid
+        """
+        admin = self.get_user("admin")
+        example_db = get_example_database()
+        self.login(username="admin")
+        table_data = {
+            "database": example_db.id,
+            "schema": "",
+            "table_name": "ab_permission",
+            "owners": [admin.id, 1000],
+        }
+        uri = f"api/v1/dataset/"
+        rv = self.client.post(uri, json=table_data)
+        self.assertEqual(rv.status_code, 422)
+        data = json.loads(rv.data.decode("utf-8"))
+        expected_result = {"message": {"owners": ["Owners are invalid"]}}
+        self.assertEqual(data, expected_result)
+
+    def test_create_dataset_validate_uniqueness(self):
+        """
+            Dataset API: Test create dataset validate table uniqueness
+        """
+        example_db = get_example_database()
+        self.login(username="admin")
+        table_data = {
+            "database": example_db.id,
+            "schema": "",
+            "table_name": "birth_names",
+        }
+        uri = "api/v1/dataset/"
+        rv = self.client.post(uri, json=table_data)
+        self.assertEqual(rv.status_code, 422)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(
+            data, {"message": {"table_name": ["Datasource birth_names already exists"]}}
+        )
+
+    def test_create_dataset_validate_database(self):
+        """
+            Dataset API: Test create dataset validate database exists
+        """
+        self.login(username="admin")
+        table_data = {"database": 1000, "schema": "", "table_name": "birth_names"}
+        uri = "api/v1/dataset/"
+        rv = self.client.post(uri, json=table_data)
+        self.assertEqual(rv.status_code, 422)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(data, {"message": {"database": ["Database does not exist"]}})
+
+    def test_create_dataset_validate_tables_exists(self):
+        """
+            Dataset API: Test create dataset validate table exists
+        """
+        example_db = get_example_database()
+        self.login(username="admin")
+        table_data = {
+            "database": example_db.id,
+            "schema": "",
+            "table_name": "does_not_exist",
+        }
+        uri = "api/v1/dataset/"
+        rv = self.client.post(uri, json=table_data)
+        self.assertEqual(rv.status_code, 422)
+
+    @patch("superset.datasets.dao.DatasetDAO.create")
+    def test_create_dataset_sqlalchemy_error(self, mock_dao_create):
+        """
+            Dataset API: Test create dataset sqlalchemy error
+        """
+        mock_dao_create.side_effect = CreateFailedError()
+        self.login(username="admin")
+        example_db = get_example_database()
+        dataset_data = {
+            "database": example_db.id,
+            "schema": "",
+            "table_name": "ab_permission",
+        }
+        uri = "api/v1/dataset/"
+        rv = self.client.post(uri, json=dataset_data)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(data, {"message": "Dataset could not be created."})
+
+    def test_update_dataset_item(self):
+        """
+            Dataset API: Test update dataset item
+        """
+        table = self.insert_dataset("ab_permission", "", [], get_example_database())
+        self.login(username="admin")
+        table_data = {"description": "changed_description"}
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.put(uri, json=table_data)
+        self.assertEqual(rv.status_code, 200)
+        model = db.session.query(SqlaTable).get(table.id)
+        self.assertEqual(model.description, table_data["description"])
+        db.session.delete(table)
+        db.session.commit()
+
+    def test_update_dataset_item_gamma(self):
+        """
+            Dataset API: Test update dataset item gamma
+        """
+        table = self.insert_dataset("ab_permission", "", [], get_example_database())
+        self.login(username="gamma")
+        table_data = {"description": "changed_description"}
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.put(uri, json=table_data)
+        self.assertEqual(rv.status_code, 401)
+        db.session.delete(table)
+        db.session.commit()
+
+    def test_update_dataset_item_not_owned(self):
+        """
+            Dataset API: Test update dataset item not owned
+        """
+        admin = self.get_user("admin")
+        table = self.insert_dataset(
+            "ab_permission", "", [admin.id], get_example_database()
+        )
+        self.login(username="alpha")
+        table_data = {"description": "changed_description"}
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.put(uri, json=table_data)
+        self.assertEqual(rv.status_code, 403)
+        db.session.delete(table)
+        db.session.commit()
+
+    def test_update_dataset_item_owners_invalid(self):
+        """
+            Dataset API: Test update dataset item owner invalid
+        """
+        admin = self.get_user("admin")
+        table = self.insert_dataset(
+            "ab_permission", "", [admin.id], get_example_database()
+        )
+        self.login(username="admin")
+        table_data = {"description": "changed_description", "owners": [1000]}
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.put(uri, json=table_data)
+        self.assertEqual(rv.status_code, 422)
+        db.session.delete(table)
+        db.session.commit()
+
+    def test_update_dataset_item_uniqueness(self):
+        """
+            Dataset API: Test update dataset uniqueness
+        """
+        admin = self.get_user("admin")
+        table = self.insert_dataset(
+            "ab_permission", "", [admin.id], get_example_database()
+        )
+        self.login(username="admin")
+        table_data = {"table_name": "birth_names"}
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.put(uri, json=table_data)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(rv.status_code, 422)
+        expected_response = {
+            "message": {"table_name": ["Datasource birth_names already exists"]}
+        }
+        self.assertEqual(data, expected_response)
+        db.session.delete(table)
+        db.session.commit()
+
+    @patch("superset.datasets.dao.DatasetDAO.update")
+    def test_update_dataset_sqlalchemy_error(self, mock_dao_update):
+        """
+            Dataset API: Test update dataset sqlalchemy error
+        """
+        mock_dao_update.side_effect = UpdateFailedError()
+
+        table = self.insert_dataset("ab_permission", "", [], get_example_database())
+        self.login(username="admin")
+        table_data = {"description": "changed_description"}
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.put(uri, json=table_data)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(data, {"message": "Dataset could not be updated."})
+
+    def test_delete_dataset_item(self):
+        """
+            Dataset API: Test delete dataset item
+        """
+        admin = self.get_user("admin")
+        table = self.insert_dataset(
+            "ab_permission", "", [admin.id], get_example_database()
+        )
+        self.login(username="admin")
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.delete(uri)
+        self.assertEqual(rv.status_code, 200)
+
+    def test_delete_item_dataset_not_owned(self):
+        """
+            Dataset API: Test delete item not owned
+        """
+        admin = self.get_user("admin")
+        table = self.insert_dataset(
+            "ab_permission", "", [admin.id], get_example_database()
+        )
+        self.login(username="alpha")
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.delete(uri)
+        self.assertEqual(rv.status_code, 403)
+        db.session.delete(table)
+        db.session.commit()
+
+    def test_delete_dataset_item_not_authorized(self):
+        """
+            Dataset API: Test delete item not authorized
+        """
+        admin = self.get_user("admin")
+        table = self.insert_dataset(
+            "ab_permission", "", [admin.id], get_example_database()
+        )
+        self.login(username="gamma")
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.delete(uri)
+        self.assertEqual(rv.status_code, 401)
+        db.session.delete(table)
+        db.session.commit()
+
+    @patch("superset.datasets.dao.DatasetDAO.delete")
+    def test_delete_dataset_sqlalchemy_error(self, mock_dao_delete):
+        """
+            Dataset API: Test delete dataset sqlalchemy error
+        """
+        mock_dao_delete.side_effect = DeleteFailedError()
+
+        admin = self.get_user("admin")
+        table = self.insert_dataset(
+            "ab_permission", "", [admin.id], get_example_database()
+        )
+        self.login(username="admin")
+        uri = f"api/v1/dataset/{table.id}"
+        rv = self.client.delete(uri)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(data, {"message": "Dataset could not be deleted."})
+        db.session.delete(table)
+        db.session.commit()