diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb6274e58de7..5edccd70ccb0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,8 +15,24 @@ # limitations under the License. # repos: - - repo: https://github.com/ambv/black +- repo: https://github.com/ambv/black rev: stable hooks: - - id: black + - id: black language_version: python3.6 + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.2.3 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-docstring-first + - id: check-json + - id: check-added-large-files + - id: check-yaml + - id: debug-statements + +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.1 + hooks: + - id: flake8 diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index e90d5d3fcd56..f7b7248d733c 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -22,11 +22,11 @@ from sqlalchemy.orm import foreign, relationship from superset.models.core import Slice -from superset.models.helpers import AuditMixinNullable, ImportMixin +from superset.models.helpers import AuditMixinNullable, ExportImportMixin from superset.utils import core as utils -class BaseDatasource(AuditMixinNullable, ImportMixin): +class BaseDatasource(AuditMixinNullable, ExportImportMixin): """A common interface to objects that are queryable (tables and datasources)""" @@ -330,7 +330,7 @@ def update_from_object(self, obj): ) -class BaseColumn(AuditMixinNullable, ImportMixin): +class BaseColumn(AuditMixinNullable, ExportImportMixin): """Interface for column""" __tablename__ = None # {connector_name}_column @@ -347,6 +347,7 @@ class BaseColumn(AuditMixinNullable, ImportMixin): # [optional] Set this to support import/export functionality export_fields = [] + export_ordering = "column_name" def __repr__(self): return self.column_name @@ -399,7 +400,7 @@ def data(self): return {s: getattr(self, s) for s in attrs if hasattr(self, s)} -class BaseMetric(AuditMixinNullable, ImportMixin): +class BaseMetric(AuditMixinNullable, ExportImportMixin): """Interface for Metrics""" @@ -414,6 +415,8 @@ class BaseMetric(AuditMixinNullable, ImportMixin): d3format = Column(String(128)) warning_text = Column(Text) + export_ordering = "metric_name" + """ The interface should also declare a datasource relationship pointing to a derivative of BaseDatasource, along with a FK diff --git a/superset/connectors/connector_registry.py b/superset/connectors/connector_registry.py index be31a37ee070..a02d5441dba2 100644 --- a/superset/connectors/connector_registry.py +++ b/superset/connectors/connector_registry.py @@ -40,6 +40,11 @@ def get_datasource(cls, datasource_type, datasource_id, session): .first() ) + @classmethod + def get_datasource_by_uuid(cls, session, source_type, uuid): + source_class = ConnectorRegistry.sources[source_type] + return session.query(source_class).filter_by(uuid=uuid).one() + @classmethod def get_all_datasources(cls, session): datasources = [] diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 3f81ca7e57c0..42411707a425 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -63,7 +63,7 @@ from superset import conf, db, security_manager from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric from superset.exceptions import MetricPermException, SupersetException -from superset.models.helpers import AuditMixinNullable, ImportMixin, QueryResult +from superset.models.helpers import AuditMixinNullable, ExportImportMixin, QueryResult from superset.utils import core as utils, import_datasource from superset.utils.core import DimSelector, DTTM_ALIAS, flasher @@ -97,7 +97,7 @@ def __init__(self, name, post_aggregator): self.post_aggregator = post_aggregator -class DruidCluster(Model, AuditMixinNullable, ImportMixin): +class DruidCluster(Model, AuditMixinNullable, ExportImportMixin): """ORM object referencing the Druid clusters""" diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 6805777995ca..0a55dc317e31 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -331,6 +331,7 @@ class SqlaTable(Model, BaseDatasource): ] export_parent = "database" export_children = ["metrics", "columns"] + export_ordering = "table_name" sqla_aggregations = { "COUNT_DISTINCT": lambda column_name: sa.func.COUNT(sa.distinct(column_name)), diff --git a/superset/migrations/versions/38f7d2edcb84_uuids.py b/superset/migrations/versions/38f7d2edcb84_uuids.py new file mode 100644 index 000000000000..b2c28767f0a9 --- /dev/null +++ b/superset/migrations/versions/38f7d2edcb84_uuids.py @@ -0,0 +1,142 @@ +# 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. +"""Adds uuid columns to all classes with ImportExportMixin: dashboards, datasources, dbs, slices, tables +Revision ID: e5200a951e62 +Revises: e9df189e5c7e +Create Date: 2019-05-08 13:42:48.479145 +""" +import uuid + +from alembic import op +from sqlalchemy import CHAR, Column, Integer +from sqlalchemy.ext.declarative import declarative_base + +from superset import db +from superset.utils.sqla import get_uuid, uuid_sqla_column + +# revision identifiers, used by Alembic. +revision = "38f7d2edcb84" +down_revision = "b4a38aa87893" + +Base = declarative_base() + + +class ImportExportMixin: + id = Column(Integer, primary_key=True) + uuid = uuid_sqla_column + + +class Dashboard(Base, ImportExportMixin): + __tablename__ = "dashboards" + + +class Datasource(Base, ImportExportMixin): + __tablename__ = "datasources" + + +class Database(Base, ImportExportMixin): + __tablename__ = "dbs" + + +class DruidCluster(Base, ImportExportMixin): + __tablename__ = "clusters" + + +class DruidMetric(Base, ImportExportMixin): + __tablename__ = "metrics" + + +class DruidColumn(Base, ImportExportMixin): + __tablename__ = "columns" + + +class Slice(Base, ImportExportMixin): + __tablename__ = "slices" + + +class SqlaTable(Base, ImportExportMixin): + __tablename__ = "tables" + + +class SqlMetric(Base, ImportExportMixin): + __tablename__ = "sql_metrics" + + +class TableColumn(Base, ImportExportMixin): + __tablename__ = "table_columns" + + +def batch_commit(iterable, mutator, session, batch_size=100): + count = len(iterable) + for i, obj in enumerate(iterable): + mutator(obj) + session.merge(obj) + if i % 100 == 0: + session.commit() + print(f"uuid assigned to {i} out of {count}") + session.commit() + print(f"Done! Assigned {count} uuids") + + +def default_mutator(obj): + obj.uuid = get_uuid() + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + db_type = session.bind.dialect.name + + def add_uuid_column(tbl_name, _type): + """Add a uuid column to a given table""" + print(f"Add uuid column to table '{tbl_name}'") + with op.batch_alter_table(tbl_name) as batch_op: + batch_op.add_column(Column("uuid", CHAR(36), default=get_uuid)) + batch_commit(session.query(_type).all(), default_mutator, session) + + add_uuid_column("dashboards", Dashboard) + add_uuid_column("datasources", Datasource) + add_uuid_column("dbs", Database) + add_uuid_column("clusters", DruidCluster) + add_uuid_column("metrics", DruidMetric) + add_uuid_column("columns", DruidColumn) + add_uuid_column("slices", Slice) + add_uuid_column("sql_metrics", SqlMetric) + add_uuid_column("tables", SqlaTable) + add_uuid_column("table_columns", TableColumn) + + session.close() + + +def downgrade(): + for tbl in [ + "dashboards", + "datasources", + "dbs", + "clusters", + "metrics", + "columns", + "slices", + "sql_metrics", + "tables", + "table_columns", + ]: + try: + with op.batch_alter_table(tbl) as batch_op: + batch_op.drop_column("uuid") + except Exception: + pass diff --git a/superset/models/core.py b/superset/models/core.py index 258cf9b12b4b..a8463351e603 100644 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -56,7 +56,7 @@ from superset import app, db, db_engine_specs, security_manager from superset.connectors.connector_registry import ConnectorRegistry from superset.legacy import update_time_range -from superset.models.helpers import AuditMixinNullable, ImportMixin +from superset.models.helpers import AuditMixinNullable, ExportImportMixin from superset.models.tags import ChartUpdater, DashboardUpdater, FavStarUpdater from superset.models.user_attributes import UserAttribute from superset.utils import cache as cache_util, core as utils @@ -151,7 +151,7 @@ class CssTemplate(Model, AuditMixinNullable): ) -class Slice(Model, AuditMixinNullable, ImportMixin): +class Slice(Model, AuditMixinNullable, ExportImportMixin): """A slice is essentially a report or a view on data""" @@ -176,6 +176,8 @@ class Slice(Model, AuditMixinNullable, ImportMixin): "params", "cache_timeout", ) + export_fields_json = ["params"] + export_ordering = "slice_name" def __repr__(self): return self.slice_name or str(self.id) @@ -407,7 +409,7 @@ def url(self): ) -class Dashboard(Model, AuditMixinNullable, ImportMixin): +class Dashboard(Model, AuditMixinNullable, ExportImportMixin): """The dashboard object!""" @@ -430,6 +432,8 @@ class Dashboard(Model, AuditMixinNullable, ImportMixin): "css", "slug", ) + export_fields_json = ("position_json", "json_metadata") + export_ordering = "dashboard_title" def __repr__(self): return self.dashboard_title or str(self.id) @@ -695,7 +699,7 @@ def export_dashboards(cls, dashboard_ids): ) -class Database(Model, AuditMixinNullable, ImportMixin): +class Database(Model, AuditMixinNullable, ExportImportMixin): """An ORM object that stores Database related information""" @@ -744,6 +748,7 @@ class Database(Model, AuditMixinNullable, ImportMixin): "extra", ) export_children = ["tables"] + export_ordering = "database_name" def __repr__(self): return self.verbose_name if self.verbose_name else self.database_name diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 327cc21efb65..64bbe09cda7d 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -26,12 +26,12 @@ from flask_appbuilder.models.mixins import AuditMixin import humanize import sqlalchemy as sa -from sqlalchemy import and_, or_, UniqueConstraint +from sqlalchemy import UniqueConstraint from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm.exc import MultipleResultsFound import yaml from superset.utils.core import QueryStatus +from superset.utils.sqla import uuid_sqla_column def json_to_dict(json_str): @@ -43,18 +43,26 @@ def json_to_dict(json_str): return {} -class ImportMixin(object): +class ExportImportMixin(object): + uuid = uuid_sqla_column export_parent = None + # The name of the attribute # with the SQL Alchemy back reference - export_children = [] - # List of (str) names of attributes - # with the SQL Alchemy forward references - export_fields = [] # The names of the attributes - # that are available for import and export + # that are made available for import and export + export_fields = [] + + # Fields that are stored as json string in db, this abstraction + # will serialize/deserialize them so that it's a consistent data structure + export_fields_json = [] + + # If a collection is exported, this str represents by which argument + # to sort the collection achieve a deterministic order. + # Deterministic ordering is useful for diffing and unit tests + export_ordering = None @classmethod def _parent_foreign_key_mappings(cls): @@ -64,6 +72,10 @@ def _parent_foreign_key_mappings(cls): return {l.name: r.name for (l, r) in parent_rel.local_remote_pairs} return {} + @property + def export_fields_with_uuid(self): + return list(self.export_fields) + ["uuid"] + @classmethod def _unique_constrains(cls): """Get all (single column and multi column) unique constraints""" @@ -75,6 +87,22 @@ def _unique_constrains(cls): unique.extend({c.name} for c in cls.__table__.columns if c.unique) return unique + def as_json(self): + return json.dumps(self.export_to_dict(), indent=2, sort_keys=True) + + def as_yaml(self): + return yaml.safe_dump(self.export_to_dict()) + + @property + def as_json_wrapped(self): + s = escape(self.as_json()) + return Markup(f"
{s}")
+
+ @property
+ def as_yaml_wrapped(self):
+ s = escape(self.as_yaml())
+ return Markup(f"{s}")
+
@classmethod
def export_schema(cls, recursive=True, include_parent_ref=False):
"""Export schema as a dictionary"""
@@ -107,73 +135,41 @@ def formatter(c):
return schema
@classmethod
- def import_from_dict(cls, session, dict_rep, parent=None, recursive=True, sync=[]):
+ def import_from_dict(
+ cls, session, dict_rep, parent=None, recursive=True, sync=None
+ ):
"""Import obj from a dictionary"""
+ sync = sync or []
parent_refs = cls._parent_foreign_key_mappings()
export_fields = set(cls.export_fields) | set(parent_refs.keys())
- new_children = {
- c: dict_rep.get(c) for c in cls.export_children if c in dict_rep
- }
- unique_constrains = cls._unique_constrains()
- filters = [] # Using these filters to check if obj already exists
-
- # Remove fields that should not get imported
for k in list(dict_rep):
- if k not in export_fields:
+ # Remove fields that should not get imported
+ if k not in export_fields and k != "uuid":
del dict_rep[k]
+ # Serialize json fields that are stored as text in the db
+ if k in cls.export_fields_json:
+ dict_rep[k] = json.dumps(dict_rep[k])
- if not parent:
- if cls.export_parent:
- for p in parent_refs.keys():
- if p not in dict_rep:
- raise RuntimeError(
- "{0}: Missing field {1}".format(cls.__name__, p)
- )
- else:
+ if parent:
# Set foreign keys to parent obj
for k, v in parent_refs.items():
dict_rep[k] = getattr(parent, v)
-
- # Add filter for parent obj
- filters.extend([getattr(cls, k) == dict_rep.get(k) for k in parent_refs.keys()])
-
- # Add filter for unique constraints
- ucs = [
- and_(
- *[
- getattr(cls, k) == dict_rep.get(k)
- for k in cs
- if dict_rep.get(k) is not None
- ]
- )
- for cs in unique_constrains
- ]
- filters.append(or_(*ucs))
+ elif cls.export_parent:
+ for p in parent_refs.keys():
+ if p not in dict_rep:
+ raise RuntimeError(f"{cls.__name__}: Missing field {p}")
# Check if object already exists in DB, break if more than one is found
- try:
- obj_query = session.query(cls).filter(and_(*filters))
- obj = obj_query.one_or_none()
- except MultipleResultsFound as e:
- logging.error(
- "Error importing %s \n %s \n %s",
- cls.__name__,
- str(obj_query),
- yaml.safe_dump(dict_rep),
- )
- raise e
+ obj = session.query(cls).filter_by(uuid=dict_rep.get("uuid")).one_or_none()
if not obj:
- is_new_obj = True
- # Create new DB object
+ logging.info("Creating new %s %s", cls.__tablename__, str(obj))
obj = cls(**dict_rep)
- logging.info("Importing new %s %s", obj.__tablename__, str(obj))
if cls.export_parent and parent:
setattr(obj, cls.export_parent, parent)
session.add(obj)
else:
- is_new_obj = False
logging.info("Updating %s %s", obj.__tablename__, str(obj))
# Update columns
for k, v in dict_rep.items():
@@ -181,30 +177,15 @@ def import_from_dict(cls, session, dict_rep, parent=None, recursive=True, sync=[
# Recursively create children
if recursive:
- for c in cls.export_children:
- child_class = cls.__mapper__.relationships[c].argument.class_
- added = []
- for c_obj in new_children.get(c, []):
- added.append(
- child_class.import_from_dict(
- session=session, dict_rep=c_obj, parent=obj, sync=sync
- )
- )
- # If children should get synced, delete the ones that did not
- # get updated.
- if c in sync and not is_new_obj:
- back_refs = child_class._parent_foreign_key_mappings()
- delete_filters = [
- getattr(child_class, k) == getattr(obj, back_refs.get(k))
- for k in back_refs.keys()
- ]
- to_delete = set(
- session.query(child_class).filter(and_(*delete_filters))
- ).difference(set(added))
- for o in to_delete:
- logging.info("Deleting %s %s", c, str(obj))
- session.delete(o)
-
+ import_args = dict(session=session, parent=obj, sync=sync)
+ for rel in cls.export_children:
+ child_class = cls.__mapper__.relationships[rel].argument.class_
+ children = dict_rep.get(rel, [])
+ children_orm = [
+ child_class.import_from_dict(child, **import_args)
+ for child in children
+ ]
+ setattr(obj, rel, children_orm)
return obj
def export_to_dict(
@@ -217,35 +198,42 @@ def export_to_dict(
parent_ref = cls.__mapper__.relationships.get(cls.export_parent)
if parent_ref:
parent_excludes = {c.name for c in parent_ref.local_columns}
- dict_rep = {
- c.name: getattr(self, c.name)
- for c in cls.__table__.columns
+
+ dict_rep = dict()
+
+ for c in cls.__table__.columns:
+ key = c.name
+ value = getattr(self, key)
if (
- c.name in self.export_fields
- and c.name not in parent_excludes
+ key in self.export_fields_with_uuid
+ and key not in parent_excludes
and (
include_defaults
- or (
- getattr(self, c.name) is not None
- and (not c.default or getattr(self, c.name) != c.default.arg)
- )
+ or (value is not None and (not c.default or value != c.default.arg))
)
- )
- }
+ ):
+ if key in self.export_fields_json:
+ value = json.loads(value)
+ dict_rep[key] = value
if recursive:
- for c in self.export_children:
+ for relationship_name in self.export_children:
# sorting to make lists of children stable
- dict_rep[c] = sorted(
- [
+ orm_children = getattr(self, relationship_name)
+ sort_by = None
+ children = []
+ if orm_children:
+ children = [
child.export_to_dict(
recursive=recursive,
include_parent_ref=include_parent_ref,
include_defaults=include_defaults,
)
- for child in getattr(self, c)
- ],
- key=lambda k: sorted(k.items()),
- )
+ for child in orm_children
+ ]
+ sort_by = orm_children[0].export_ordering
+ if sort_by:
+ children = sorted(children, key=lambda x: x.get(sort_by))
+ dict_rep[relationship_name] = children
return dict_rep
diff --git a/superset/models/schedules.py b/superset/models/schedules.py
index dbcd56d83a27..93029555637c 100644
--- a/superset/models/schedules.py
+++ b/superset/models/schedules.py
@@ -25,7 +25,7 @@
from sqlalchemy.orm import relationship
from superset import security_manager
-from superset.models.helpers import AuditMixinNullable, ImportMixin
+from superset.models.helpers import AuditMixinNullable
metadata = Model.metadata # pylint: disable=no-member
@@ -73,7 +73,7 @@ def user(self):
delivery_type = Column(Enum(EmailDeliveryType))
-class DashboardEmailSchedule(Model, AuditMixinNullable, ImportMixin, EmailSchedule):
+class DashboardEmailSchedule(Model, AuditMixinNullable, EmailSchedule):
__tablename__ = "dashboard_email_schedules"
dashboard_id = Column(Integer, ForeignKey("dashboards.id"))
dashboard = relationship(
@@ -81,7 +81,7 @@ class DashboardEmailSchedule(Model, AuditMixinNullable, ImportMixin, EmailSchedu
)
-class SliceEmailSchedule(Model, AuditMixinNullable, ImportMixin, EmailSchedule):
+class SliceEmailSchedule(Model, AuditMixinNullable, EmailSchedule):
__tablename__ = "slice_email_schedules"
slice_id = Column(Integer, ForeignKey("slices.id"))
slice = relationship("Slice", backref="email_schedules", foreign_keys=[slice_id])
diff --git a/superset/utils/sqla.py b/superset/utils/sqla.py
new file mode 100644
index 000000000000..53d73b817889
--- /dev/null
+++ b/superset/utils/sqla.py
@@ -0,0 +1,26 @@
+# 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 uuid
+
+import sqlalchemy as sa
+
+
+def get_uuid():
+ return str(uuid.uuid4())
+
+
+uuid_sqla_column = sa.Column(sa.CHAR(36), unique=True, default=get_uuid)
diff --git a/superset/views/core.py b/superset/views/core.py
index 5cedfe1c185f..969199575b9c 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -303,6 +303,7 @@ class DatabaseView(SupersetModelView, DeleteMixin, YamlExportMixin): # noqa
"created_on",
"changed_by",
"changed_on",
+ "as_yaml_wrapped",
]
add_template = "superset/models/database/add.html"
edit_template = "superset/models/database/edit.html"
@@ -634,6 +635,15 @@ class SliceModelView(SupersetModelView, DeleteMixin): # noqa
"params",
"cache_timeout",
]
+ show_columns = [
+ "slice_name",
+ "description",
+ "viz_type",
+ "owners",
+ "dashboards",
+ "cache_timeout",
+ "as_yaml_wrapped",
+ ]
base_order = ("changed_on", "desc")
description_columns = {
"description": Markup(
@@ -655,6 +665,7 @@ class SliceModelView(SupersetModelView, DeleteMixin): # noqa
}
base_filters = [["id", SliceFilter, lambda: []]]
label_columns = {
+ "as_yaml_wrapped": _("YAML"),
"cache_timeout": _("Cache Timeout"),
"creator": _("Creator"),
"dashboards": _("Dashboards"),
@@ -666,6 +677,7 @@ class SliceModelView(SupersetModelView, DeleteMixin): # noqa
"slice_link": _("Chart"),
"slice_name": _("Name"),
"table": _("Table"),
+ "uuid": "UUID",
"viz_type": _("Visualization Type"),
}
@@ -770,7 +782,16 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa
"css",
"json_metadata",
]
- show_columns = edit_columns + ["table_names", "charts"]
+ show_columns = [
+ "dashboard_title",
+ "slug",
+ "uuid",
+ "owners",
+ "css",
+ "table_names",
+ "charts",
+ "as_yaml_wrapped",
+ ]
search_columns = ("dashboard_title", "slug", "owners")
add_columns = edit_columns
base_order = ("changed_on", "desc")
@@ -797,6 +818,7 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa
}
base_filters = [["slice", DashboardFilter, lambda: []]]
label_columns = {
+ "as_yaml_wrapped": _("YAML"),
"dashboard_link": _("Dashboard"),
"dashboard_title": _("Title"),
"slug": _("Slug"),
@@ -804,10 +826,10 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa
"owners": _("Owners"),
"creator": _("Creator"),
"modified": _("Modified"),
- "position_json": _("Position JSON"),
"css": _("CSS"),
"json_metadata": _("JSON Metadata"),
"table_names": _("Underlying Tables"),
+ "uuid": "UUID",
}
def pre_add(self, obj):
diff --git a/tests/dict_import_export_tests.py b/tests/dict_import_export_tests.py
index ba6766bbb9cf..fdaf4fd1a8dc 100644
--- a/tests/dict_import_export_tests.py
+++ b/tests/dict_import_export_tests.py
@@ -112,7 +112,20 @@ def get_datasource(self, datasource_id):
def get_table_by_name(self, name):
return db.session.query(SqlaTable).filter_by(table_name=name).first()
+ @classmethod
+ def drop_uuid(cls, obj):
+ if type(obj) == dict:
+ if "uuid" in obj:
+ obj.pop("uuid")
+ for k in list(obj):
+ cls.drop_uuid(obj[k])
+ if type(obj) == list:
+ for o in obj:
+ cls.drop_uuid(o)
+
def yaml_compare(self, obj_1, obj_2):
+ self.drop_uuid(obj_1)
+ self.drop_uuid(obj_2)
obj_1_str = yaml.safe_dump(obj_1, default_flow_style=False)
obj_2_str = yaml.safe_dump(obj_2, default_flow_style=False)
self.assertEquals(obj_1_str, obj_2_str)