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

beto 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 9785667  feat: add UUID column to ImportMixin (#11098)
9785667 is described below

commit 9785667a0dc970fd7b7033f172c2760ee0c22a6e
Author: Beto Dealmeida <[email protected]>
AuthorDate: Wed Oct 7 09:00:55 2020 -0700

    feat: add UUID column to ImportMixin (#11098)
    
    * Add UUID column to ImportMixin
    
    * Fix default value
    
    * Fix lint
    
    * Fix order of downgrade
    
    * Add logging when downgrade fails
    
    * Migrate position_json to contain UUIDs, and add schedule tables
    
    * Save UUID when adding charts to dashboard
    
    * Fix heads
    
    * Rename migration file
    
    * Fix dashboard serialization
    
    * Fix migration script with Postgres
    
    * Fix unique contraint name
    
    * Handle UUID when exporting dashboard
    
    * Fix Dataset PUT
    
    * Add UUID JSON serialization
    
    * Fix tests
    
    * Simplify logic
    
    * Try binary=True
---
 superset/dashboards/dao.py                         |  11 ++
 superset/datasets/schemas.py                       |   1 +
 ...b56500de1855_add_uuid_column_to_import_mixin.py | 154 +++++++++++++++++++++
 superset/models/helpers.py                         |   6 +
 superset/models/slice.py                           |   6 +-
 superset/utils/core.py                             |   4 +-
 superset/views/core.py                             |   2 +-
 tests/databases/api_tests.py                       |   2 +-
 tests/dict_import_export_tests.py                  |  20 ++-
 tests/import_export_tests.py                       |   4 +-
 10 files changed, 198 insertions(+), 12 deletions(-)

diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py
index bddc467..46ce63a 100644
--- a/superset/dashboards/dao.py
+++ b/superset/dashboards/dao.py
@@ -99,6 +99,17 @@ class DashboardDAO(BaseDAO):
 
         dashboard.slices = current_slices
 
+        # add UUID to positions
+        uuid_map = {slice.id: str(slice.uuid) for slice in current_slices}
+        for obj in positions.values():
+            if (
+                isinstance(obj, dict)
+                and obj["type"] == "CHART"
+                and obj["meta"]["chartId"]
+            ):
+                chart_id = obj["meta"]["chartId"]
+                obj["meta"]["uuid"] = uuid_map[chart_id]
+
         # remove leading and trailing white spaces in the dumped json
         dashboard.position_json = json.dumps(
             positions, indent=None, separators=(",", ":"), sort_keys=True
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index 46618d7..a4341db 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -52,6 +52,7 @@ class DatasetColumnsPutSchema(Schema):
     python_date_format = fields.String(
         allow_none=True, validate=[Length(1, 255), validate_python_date_format]
     )
+    uuid = fields.String(allow_none=True)
 
 
 class DatasetMetricsPutSchema(Schema):
diff --git 
a/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py 
b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py
new file mode 100644
index 0000000..19903b9
--- /dev/null
+++ 
b/superset/migrations/versions/b56500de1855_add_uuid_column_to_import_mixin.py
@@ -0,0 +1,154 @@
+# 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.
+"""add_uuid_column_to_import_mixin
+
+Revision ID: b56500de1855
+Revises: 18532d70ab98
+Create Date: 2020-09-28 17:57:23.128142
+
+"""
+import json
+import logging
+import uuid
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy_utils import UUIDType
+
+from superset import db
+from superset.utils import core as utils
+
+# revision identifiers, used by Alembic.
+revision = "b56500de1855"
+down_revision = "18532d70ab98"
+
+
+Base = declarative_base()
+
+
+class ImportMixin:
+    id = sa.Column(sa.Integer, primary_key=True)
+    uuid = sa.Column(UUIDType(binary=True), primary_key=False, 
default=uuid.uuid4)
+
+
+table_names = [
+    # Core models
+    "dbs",
+    "dashboards",
+    "slices",
+    # SQLAlchemy connectors
+    "tables",
+    "table_columns",
+    "sql_metrics",
+    # Druid connector
+    "clusters",
+    "datasources",
+    "columns",
+    "metrics",
+    # Dashboard email schedules
+    "dashboard_email_schedules",
+    "slice_email_schedules",
+]
+models = {
+    table_name: type(table_name, (Base, ImportMixin), {"__tablename__": 
table_name})
+    for table_name in table_names
+}
+
+models["dashboards"].position_json = sa.Column(utils.MediumText())
+
+
+def add_uuids(objects, session, batch_size=100):
+    uuid_map = {}
+    count = len(objects)
+    for i, object_ in enumerate(objects):
+        object_.uuid = uuid.uuid4()
+        uuid_map[object_.id] = object_.uuid
+        session.merge(object_)
+        if (i + 1) % batch_size == 0:
+            session.commit()
+            print(f"uuid assigned to {i + 1} out of {count}")
+
+    session.commit()
+    print(f"Done! Assigned {count} uuids")
+
+    return uuid_map
+
+
+def update_position_json(dashboard, session, uuid_map):
+    layout = json.loads(dashboard.position_json or "{}")
+    for object_ in layout.values():
+        if (
+            isinstance(object_, dict)
+            and object_["type"] == "CHART"
+            and object_["meta"]["chartId"]
+        ):
+            chart_id = object_["meta"]["chartId"]
+            if chart_id in uuid_map:
+                object_["meta"]["uuid"] = str(uuid_map[chart_id])
+            elif object_["meta"].get("uuid"):
+                del object_["meta"]["uuid"]
+
+    dashboard.position_json = json.dumps(layout, indent=4)
+    session.merge(dashboard)
+    session.commit()
+
+
+def upgrade():
+    bind = op.get_bind()
+    session = db.Session(bind=bind)
+
+    uuid_maps = {}
+    for table_name, model in models.items():
+        with op.batch_alter_table(table_name) as batch_op:
+            batch_op.add_column(
+                sa.Column(
+                    "uuid",
+                    UUIDType(binary=True),
+                    primary_key=False,
+                    default=uuid.uuid4,
+                )
+            )
+
+        # populate column
+        objects = session.query(model).all()
+        uuid_maps[table_name] = add_uuids(objects, session)
+
+        # add uniqueness constraint
+        with op.batch_alter_table(table_name) as batch_op:
+            batch_op.create_unique_constraint(f"uq_{table_name}_uuid", 
["uuid"])
+
+    # add UUID to Dashboard.position_json
+    Dashboard = models["dashboards"]
+    for dashboard in session.query(Dashboard).all():
+        update_position_json(dashboard, session, uuid_maps["slices"])
+
+
+def downgrade():
+    bind = op.get_bind()
+    session = db.Session(bind=bind)
+
+    # remove uuid from position_json
+    Dashboard = models["dashboards"]
+    for dashboard in session.query(Dashboard).all():
+        update_position_json(dashboard, session, {})
+
+    # remove uuid column
+    for table_name, model in models.items():
+        with op.batch_alter_table(model) as batch_op:
+            batch_op.drop_constraint(f"uq_{table_name}_uuid")
+            batch_op.drop_column("uuid")
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index dd3a068..08301a7 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -18,6 +18,7 @@
 import json
 import logging
 import re
+import uuid
 from datetime import datetime, timedelta
 from json.decoder import JSONDecodeError
 from typing import Any, Dict, List, Optional, Set, Union
@@ -35,6 +36,7 @@ from sqlalchemy import and_, or_, UniqueConstraint
 from sqlalchemy.ext.declarative import declared_attr
 from sqlalchemy.orm import Mapper, Session
 from sqlalchemy.orm.exc import MultipleResultsFound
+from sqlalchemy_utils import UUIDType
 
 from superset.utils.core import QueryStatus
 
@@ -51,6 +53,10 @@ def json_to_dict(json_str: str) -> Dict[Any, Any]:
 
 
 class ImportMixin:
+    uuid = sa.Column(
+        UUIDType(binary=True), primary_key=False, unique=True, 
default=uuid.uuid4
+    )
+
     export_parent: Optional[str] = None
     # The name of the attribute
     # with the SQL Alchemy back reference
diff --git a/superset/models/slice.py b/superset/models/slice.py
index a442d8e..23187d9 100644
--- a/superset/models/slice.py
+++ b/superset/models/slice.py
@@ -198,15 +198,15 @@ class Slice(
     @property
     def digest(self) -> str:
         """
-            Returns a MD5 HEX digest that makes this dashboard unique
+        Returns a MD5 HEX digest that makes this dashboard unique
         """
         return utils.md5_hex(self.params)
 
     @property
     def thumbnail_url(self) -> str:
         """
-            Returns a thumbnail URL with a HEX digest. We want to avoid 
browser cache
-            if the dashboard has changed
+        Returns a thumbnail URL with a HEX digest. We want to avoid browser 
cache
+        if the dashboard has changed
         """
         return f"/api/v1/chart/{self.id}/thumbnail/{self.digest}/"
 
diff --git a/superset/utils/core.py b/superset/utils/core.py
index 15e2678..541d24b 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -310,7 +310,9 @@ class DashboardEncoder(json.JSONEncoder):
         super().__init__(*args, **kwargs)
         self.sort_keys = True
 
-    def default(self, o: Any) -> Dict[Any, Any]:
+    def default(self, o: Any) -> Union[Dict[Any, Any], str]:
+        if isinstance(o, uuid.UUID):
+            return str(o)
         try:
             vals = {k: v for k, v in o.__dict__.items() if k != 
"_sa_instance_state"}
             return {"__{}__".format(o.__class__.__name__): vals}
diff --git a/superset/views/core.py b/superset/views/core.py
index 9d9caaf..fd6a113 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -1784,7 +1784,7 @@ class Superset(BaseSupersetView):  # pylint: 
disable=too-many-public-methods
     @expose("/get_or_create_table/", methods=["POST"])
     @event_logger.log_this
     def sqllab_table_viz(self) -> FlaskResponse:  # pylint: disable=no-self-use
-        """ Gets or creates a table object with attributes passed to the API.
+        """Gets or creates a table object with attributes passed to the API.
 
         It expects the json with params:
         * datasourceName - e.g. table name, required
diff --git a/tests/databases/api_tests.py b/tests/databases/api_tests.py
index b0f853b..a5ce0b4 100644
--- a/tests/databases/api_tests.py
+++ b/tests/databases/api_tests.py
@@ -328,8 +328,8 @@ class TestDatabaseApi(SupersetTestCase):
                 ]
             }
         }
-        self.assertEqual(response.status_code, 400)
         self.assertEqual(response_data, expected_response)
+        self.assertEqual(response.status_code, 400)
 
     def test_create_database_conn_fail(self):
         """
diff --git a/tests/dict_import_export_tests.py 
b/tests/dict_import_export_tests.py
index dc0b8d8..176ecd0 100644
--- a/tests/dict_import_export_tests.py
+++ b/tests/dict_import_export_tests.py
@@ -18,6 +18,7 @@
 """Unit tests for Superset"""
 import json
 import unittest
+from uuid import uuid4
 
 import yaml
 
@@ -64,26 +65,33 @@ class TestDictImportExport(SupersetTestCase):
     def tearDownClass(cls):
         cls.delete_imports()
 
-    def create_table(self, name, schema="", id=0, cols_names=[], 
metric_names=[]):
+    def create_table(
+        self, name, schema="", id=0, cols_names=[], cols_uuids=None, 
metric_names=[]
+    ):
         database_name = "main"
         name = "{0}{1}".format(NAME_PREFIX, name)
         params = {DBREF: id, "database_name": database_name}
 
+        if cols_uuids is None:
+            cols_uuids = [None] * len(cols_names)
+
         dict_rep = {
             "database_id": get_example_database().id,
             "table_name": name,
             "schema": schema,
             "id": id,
             "params": json.dumps(params),
-            "columns": [{"column_name": c} for c in cols_names],
+            "columns": [
+                {"column_name": c, "uuid": u} for c, u in zip(cols_names, 
cols_uuids)
+            ],
             "metrics": [{"metric_name": c, "expression": ""} for c in 
metric_names],
         }
 
         table = SqlaTable(
             id=id, schema=schema, table_name=name, params=json.dumps(params)
         )
-        for col_name in cols_names:
-            table.columns.append(TableColumn(column_name=col_name))
+        for col_name, uuid in zip(cols_names, cols_uuids):
+            table.columns.append(TableColumn(column_name=col_name, uuid=uuid))
         for metric_name in metric_names:
             table.metrics.append(SqlMetric(metric_name=metric_name, 
expression=""))
         return table, dict_rep
@@ -171,6 +179,7 @@ class TestDictImportExport(SupersetTestCase):
             "table_1_col_1_met",
             id=ID_PREFIX + 2,
             cols_names=["col1"],
+            cols_uuids=[uuid4()],
             metric_names=["metric1"],
         )
         imported_table = SqlaTable.import_from_dict(db.session, dict_table)
@@ -187,6 +196,7 @@ class TestDictImportExport(SupersetTestCase):
             "table_2_col_2_met",
             id=ID_PREFIX + 3,
             cols_names=["c1", "c2"],
+            cols_uuids=[uuid4(), uuid4()],
             metric_names=["m1", "m2"],
         )
         imported_table = SqlaTable.import_from_dict(db.session, dict_table)
@@ -217,6 +227,7 @@ class TestDictImportExport(SupersetTestCase):
             id=ID_PREFIX + 3,
             metric_names=["new_metric1", "m1"],
             cols_names=["col1", "new_col1", "col2", "col3"],
+            cols_uuids=[col.uuid for col in imported_over.columns],
         )
         self.assert_table_equals(expected_table, imported_over)
         self.yaml_compare(
@@ -247,6 +258,7 @@ class TestDictImportExport(SupersetTestCase):
             id=ID_PREFIX + 3,
             metric_names=["new_metric1"],
             cols_names=["new_col1", "col2", "col3"],
+            cols_uuids=[col.uuid for col in imported_over.columns],
         )
         self.assert_table_equals(expected_table, imported_over)
         self.yaml_compare(
diff --git a/tests/import_export_tests.py b/tests/import_export_tests.py
index e772d16..ed6ae78 100644
--- a/tests/import_export_tests.py
+++ b/tests/import_export_tests.py
@@ -225,8 +225,8 @@ class TestImportExport(SupersetTestCase):
         self.assertEqual(exp_params, actual_params)
 
     def assert_only_exported_slc_fields(self, expected_dash, actual_dash):
-        """ only exported json has this params
-            imported/created dashboard has relationships to other models 
instead
+        """only exported json has this params
+        imported/created dashboard has relationships to other models instead
         """
         expected_slices = sorted(expected_dash.slices, key=lambda s: 
s.slice_name or "")
         actual_slices = sorted(actual_dash.slices, key=lambda s: s.slice_name 
or "")

Reply via email to