Forráskód Böngészése

[metastore] add config flag to fetch sample data for views (#4172)

- This commit introduces a new configuration flag, allow_sample_data_from_views, to control whether Hue attempts to fetch sample data for database views.

- Previously, fetching sample data from views was disabled by default due to potential performance concerns with complex or long-running views. This change makes this behavior configurable.

- Default value: false (maintains previous behavior)

- If set to true, Hue will attempt to fetch sample data for views.

---------

Co-authored-by: Harsh Gupta <42064744+Harshg999@users.noreply.github.com>
Bjorn Alm - personal 5 hónapja
szülő
commit
a3c23ab974

+ 7 - 7
apps/beeswax/src/beeswax/api.py

@@ -15,9 +15,9 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import re
 import json
 import logging
+import re
 from builtins import zip
 
 from django.http import Http404
@@ -33,7 +33,7 @@ from beeswax.design import HQLdesign
 from beeswax.forms import QueryForm
 from beeswax.models import QueryHistory, Session
 from beeswax.server import dbms
-from beeswax.server.dbms import QueryServerException, QueryServerTimeoutException, SubQueryTable, expand_exception, get_query_server_config
+from beeswax.server.dbms import expand_exception, get_query_server_config, QueryServerException, QueryServerTimeoutException, SubQueryTable
 from beeswax.views import (
   _get_query_handle_and_state,
   authorized_get_design,
@@ -52,9 +52,9 @@ from desktop.lib.exceptions_renderable import PopupException
 from desktop.lib.i18n import force_unicode
 from desktop.lib.parameterization import substitute_variables
 from metastore import parser
-from metastore.conf import FORCE_HS2_METADATA
+from metastore.conf import ALLOW_SAMPLE_DATA_FROM_VIEWS, FORCE_HS2_METADATA
 from metastore.views import _get_db, _get_servername
-from notebook.models import MockedDjangoRequest, escape_rows, make_notebook
+from notebook.models import escape_rows, make_notebook, MockedDjangoRequest
 from useradmin.models import User
 
 LOG = logging.getLogger()
@@ -606,7 +606,7 @@ def save_results_hdfs_file(request, query_history_id):
 
       try:
         handle, state = _get_query_handle_and_state(query_history)
-      except Exception as ex:
+      except Exception:
         response['message'] = _('Cannot find query handle and state: %s') % str(query_history)
         response['status'] = -2
         return JsonResponse(response)
@@ -669,7 +669,7 @@ def save_results_hive_table(request, query_history_id):
       try:
         handle, state = _get_query_handle_and_state(query_history)
         result_meta = db.get_results_metadata(handle)
-      except Exception as ex:
+      except Exception:
         response['message'] = _('Cannot find query handle and state: %s') % str(query_history)
         response['status'] = -2
         return JsonResponse(response)
@@ -733,7 +733,7 @@ def _get_sample_data(db, database, table, column, nested, is_async=False, cluste
       query_server = get_query_server_config('impala', connector=cluster)
       db = dbms.get(db.client.user, query_server, cluster=cluster)
 
-  if table_obj and table_obj.is_view:
+  if table_obj and table_obj.is_view and not ALLOW_SAMPLE_DATA_FROM_VIEWS.get():
     response = {'status': -1}
     response['message'] = _('Not getting sample data as this is a view which can be expensive when run.')
     return response

+ 80 - 59
apps/beeswax/src/beeswax/api_tests.py

@@ -16,25 +16,22 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import sys
 import logging
 from unittest.mock import Mock, patch
 
 import pytest
-from django.test import TestCase
 from requests.exceptions import ReadTimeout
 
-from beeswax.api import _autocomplete, get_functions
+from beeswax.api import _autocomplete, _get_functions, _get_sample_data
 from desktop.lib.django_test_util import make_logged_in_client
-from desktop.lib.test_utils import add_to_group, grant_access
+from metastore.conf import ALLOW_SAMPLE_DATA_FROM_VIEWS
 from useradmin.models import User
 
 LOG = logging.getLogger()
 
 
 @pytest.mark.django_db
-class TestApi():
-
+class TestApi:
   def setup_method(self):
     self.client = make_logged_in_client(username="test", groupname="default", recreate=True, is_superuser=False)
     self.user = User.objects.get(username="test")
@@ -43,75 +40,99 @@ class TestApi():
     get_tables_meta = Mock(
       side_effect=ReadTimeout("HTTPSConnectionPool(host='gethue.com', port=10001): Read timed out. (read timeout=120)")
     )
-    db = Mock(
-      get_tables_meta=get_tables_meta
-    )
+    db = Mock(get_tables_meta=get_tables_meta)
 
-    resp = _autocomplete(db, database='database')
+    resp = _autocomplete(db, database="database")
 
-    assert (
-      resp ==
-      {
-        'code': 500,
-        'error': "HTTPSConnectionPool(host='gethue.com', port=10001): Read timed out. (read timeout=120)"
-      })
+    assert resp == {"code": 500, "error": "HTTPSConnectionPool(host='gethue.com', port=10001): Read timed out. (read timeout=120)"}
 
   def test_get_functions(self):
-    db = Mock(
-      get_functions=Mock(
-        return_value=Mock(
-          rows=Mock(
-            return_value=[{'name': 'f1'}, {'name': 'f2'}]
-          )
-        )
-      )
-    )
+    # Mock db.get_functions() to return rows that escape_rows can process
+    # Each row should be a list where row[0] is the function name
+    db = Mock()
+    db.get_functions = Mock(return_value=[["f1"], ["f2"]])  # Return list of rows
+    db.client = Mock(query_server={"dialect": "hive"})  # Non-Impala dialect
 
-    resp = get_functions(db)
+    resp = _get_functions(db)  # Call the internal function
 
-    assert (
-      resp ==
-      [{'name': 'f1'}, {'name': 'f2'}])
+    assert resp == [{"name": "f1"}, {"name": "f2"}]
 
-  def test_get_functions(self):
-    with patch('beeswax.api._get_functions') as _get_functions:
+  def test_autocomplete_functions(self):
+    with patch("beeswax.api._get_functions") as _get_functions:
       db = Mock()
-      _get_functions.return_value = [
-        {'name': 'f1'}, {'name': 'f2'}, {'name': 'f3'}
-      ]
+      _get_functions.return_value = [{"name": "f1"}, {"name": "f2"}, {"name": "f3"}]
 
-      resp = _autocomplete(db, database='default', operation='functions')
+      resp = _autocomplete(db, database="default", operation="functions")
 
-      assert (
-        resp['functions'] ==
-        [{'name': 'f1'}, {'name': 'f2'}, {'name': 'f3'}])
+      assert resp["functions"] == [{"name": "f1"}, {"name": "f2"}, {"name": "f3"}]
 
   def test_get_function(self):
     db = Mock()
-    db.client = Mock(query_server={'dialect': 'hive'})
+    db.client = Mock(query_server={"dialect": "hive"})
     db.get_function = Mock(
       return_value=[
-        ['floor_month(param) - Returns the timestamp at a month granularity'],
-        ['param needs to be a timestamp value'],
-        ['Example:'],
+        ["floor_month(param) - Returns the timestamp at a month granularity"],
+        ["param needs to be a timestamp value"],
+        ["Example:"],
         ["> SELECT floor_month(CAST('yyyy-MM-dd HH:mm:ss' AS TIMESTAMP)) FROM src;"],
-        ['yyyy-MM-01 00:00:00']
+        ["yyyy-MM-01 00:00:00"],
       ]
     )
 
-    data = _autocomplete(db, database='floor_month', operation='function')
-
-    assert (
-      data['function'] ==
-      {
-        'name': 'floor_month',
-        'signature': 'floor_month(param)',
-        'description':
-            'Returns the timestamp at a month granularity\nparam needs to be a timestamp value\nExample:\n'
-            '> SELECT floor_month(CAST(\'yyyy-MM-dd HH:mm:ss\' AS TIMESTAMP)) FROM src;\nyyyy-MM-01 00:00:00'
-      })
-
-    db.client = Mock(query_server={'dialect': 'impala'})
-    data = _autocomplete(db, operation='function')
-
-    assert data['function'] == {}
+    data = _autocomplete(db, database="floor_month", operation="function")
+
+    assert data["function"] == {
+      "name": "floor_month",
+      "signature": "floor_month(param)",
+      "description": "Returns the timestamp at a month granularity\nparam needs to be a timestamp value\nExample:\n"
+      "> SELECT floor_month(CAST('yyyy-MM-dd HH:mm:ss' AS TIMESTAMP)) FROM src;\nyyyy-MM-01 00:00:00",
+    }
+
+    db.client = Mock(query_server={"dialect": "impala"})
+    data = _autocomplete(db, operation="function")
+
+    assert data["function"] == {}
+
+  @patch("beeswax.api.dbms.get")
+  def test_get_sample_data_for_views(self, mock_dbms_get):
+    # Mock table_obj
+    table_obj_mock = Mock(is_view=True, is_impala_only=False)
+
+    # Mock the db object that dbms.get() would return
+    db_mock = Mock(get_table=Mock(return_value=table_obj_mock))
+    mock_dbms_get.return_value = db_mock
+
+    # Scenario 1: allow_sample_data_from_views is False
+    reset = ALLOW_SAMPLE_DATA_FROM_VIEWS.set_for_testing(False)
+    try:
+      response = _get_sample_data(db_mock, "default_db", "test_view_table", None, None)
+
+      assert response == {
+        "status": -1,
+        "message": "Not getting sample data as this is a view which can be expensive when run.",
+      }
+    finally:
+      reset()
+
+    # Scenario 2: allow_sample_data_from_views is True
+    reset = ALLOW_SAMPLE_DATA_FROM_VIEWS.set_for_testing(True)
+    try:
+      # Mock db.get_sample to simulate successful data fetching past the view check
+      # We expect it to be called if the view check is passed.
+      db_mock.get_sample.return_value = Mock(
+        rows=Mock(return_value=[["col1_val", "col2_val"]]),
+        cols=Mock(return_value=["col1", "col2"]),
+        full_cols=Mock(return_value=[{"name": "col1"}, {"name": "col2"}]),
+      )
+      mock_dbms_get.return_value = db_mock
+
+      response = _get_sample_data(db_mock, "default_db", "test_view_table", None, None)
+      assert response == {
+        "status": 0,
+        "headers": ["col1", "col2"],
+        "full_headers": [{"name": "col1"}, {"name": "col2"}],
+        "rows": [["col1_val", "col2_val"]],
+      }
+      db_mock.get_sample.assert_called_once_with("default_db", table_obj_mock, None, None, generate_sql_only=False, operation=None)
+    finally:
+      reset()

+ 3 - 3
apps/beeswax/src/beeswax/conf.py

@@ -15,11 +15,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import math
 import logging
+import math
 import os.path
 
-from django.utils.translation import gettext as _, gettext_lazy as _t
+from django.utils.translation import gettext_lazy as _t
 
 from desktop.conf import (
   AUTH_PASSWORD as DEFAULT_AUTH_PASSWORD,
@@ -27,7 +27,7 @@ from desktop.conf import (
   default_ssl_cacerts,
   default_ssl_validate,
 )
-from desktop.lib.conf import Config, ConfigSection, coerce_bool, coerce_csv, coerce_password_from_script
+from desktop.lib.conf import coerce_bool, coerce_csv, coerce_password_from_script, Config, ConfigSection
 
 LOG = logging.getLogger()
 

+ 8 - 1
apps/metastore/src/metastore/conf.py

@@ -17,7 +17,7 @@
 
 from django.utils.translation import gettext_lazy as _
 
-from desktop.lib.conf import Config, coerce_bool
+from desktop.lib.conf import coerce_bool, Config
 
 ENABLE_NEW_CREATE_TABLE = Config(
   key="enable_new_create_table",
@@ -41,3 +41,10 @@ SHOW_TABLE_ERD = Config(
   type=coerce_bool,
   help=_('Choose whether to show the table ERD component.')
 )
+
+ALLOW_SAMPLE_DATA_FROM_VIEWS = Config(
+  key='allow_sample_data_from_views',
+  default=False,
+  type=coerce_bool,
+  help=_('Choose whether to allow fetching sample data from views. By default, this is false to prevent potentially expensive queries.')
+)

+ 4 - 0
desktop/conf.dist/hue.ini

@@ -1578,6 +1578,10 @@ submit_to=True
 # Choose whether to show the table ERD component. Default false
 ## show_table_erd=false
 
+# Choose whether to allow fetching sample data from views.
+# By default, this is false to prevent potentially expensive queries.
+## allow_sample_data_from_views=false
+
 ###########################################################################
 # Settings to configure Impala
 ###########################################################################

+ 3 - 1
desktop/conf/pseudo-distributed.ini.tmpl

@@ -1547,7 +1547,6 @@
     # Choose whether Hue should validate certificates received from the server.
     ## validate=true
 
-
 ###########################################################################
 # Settings to configure Metastore
 ###########################################################################
@@ -1556,6 +1555,9 @@
   # Flag to turn on the new version of the create table wizard.
   ## enable_new_create_table=true
 
+  # Allow fetching sample data from views. By default, this is false to prevent potentially expensive queries.
+  ## allow_sample_data_from_views=false
+
   # Flag to force all metadata calls (e.g. list tables, table or column details...) to happen via HiveServer2 if available instead of Impala.
   ## force_hs2_metadata=false
 

+ 2 - 0
desktop/core/src/desktop/api2.py

@@ -92,6 +92,7 @@ from metadata.catalog_api import (
   search_entities_interactive as metadata_search_entities_interactive,
 )
 from metadata.conf import has_catalog
+from metastore.conf import ALLOW_SAMPLE_DATA_FROM_VIEWS
 from notebook.connectors.base import get_interpreter, Notebook
 from notebook.management.commands import notebook_setup
 from pig.management.commands import pig_setup
@@ -146,6 +147,7 @@ def get_config(request):
     'is_yarn_enabled': is_yarn(),
     'enable_task_server': TASK_SERVER_V2.ENABLED.get(),
     'enable_workflow_creation_action': ENABLE_WORKFLOW_CREATION_ACTION.get(),
+    'allow_sample_data_from_views': ALLOW_SAMPLE_DATA_FROM_VIEWS.get(),
   }
 
   # Storage browser configuration

+ 3 - 1
desktop/core/src/desktop/js/catalog/DataCatalogEntry.ts

@@ -18,6 +18,7 @@ import * as ko from 'knockout';
 import KnockoutObservable from '@types/knockout';
 
 import { Cancellable, CancellablePromise } from 'api/cancellablePromise';
+import { getLastKnownConfig } from 'config/hueConfig';
 import {
   addNavTags,
   deleteNavTags,
@@ -1662,7 +1663,8 @@ export default class DataCatalogEntry {
       operation?: string;
     }
   ): CancellablePromise<Sample> {
-    if (this.isView()) {
+    const config = getLastKnownConfig();
+    if (this.isView() && (!config || !config.hue_config?.allow_sample_data_from_views)) {
       return CancellablePromise.reject();
     }
 

+ 1 - 0
desktop/core/src/desktop/js/config/types.ts

@@ -96,6 +96,7 @@ export interface HueConfig extends GenericApiResponse {
     enable_task_server: boolean;
     is_admin: boolean;
     is_yarn_enabled: boolean;
+    allow_sample_data_from_views: boolean;
   };
   storage_browser: StorageBrowserConfig;
   hue_version?: string;

+ 17 - 20
desktop/core/src/desktop/models.py

@@ -15,11 +15,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import os
+import calendar
 import json
-import uuid
 import logging
-import calendar
+import os
+import uuid
 from builtins import next, object
 from collections import OrderedDict
 from itertools import chain
@@ -35,43 +35,40 @@ from django.db.models.query import QuerySet
 from django.urls import NoReverseMatch, reverse
 from django.utils.translation import gettext as _, gettext_lazy as _t
 
-from dashboard.conf import HAS_REPORT_ENABLED, IS_ENABLED as DASHBOARD_ENABLED, get_engines
+from dashboard.conf import get_engines, HAS_REPORT_ENABLED, IS_ENABLED as DASHBOARD_ENABLED
 from desktop import appmanager
 from desktop.auth.backend import is_admin
 from desktop.conf import (
   APP_BLACKLIST,
   COLLECT_USAGE,
   DISABLE_SOURCE_AUTOCOMPLETE,
-  ENABLE_CONNECTORS,
   ENABLE_NEW_IMPORTER,
   ENABLE_NEW_STORAGE_BROWSER,
   ENABLE_ORGANIZATIONS,
-  ENABLE_PROMETHEUS,
   ENABLE_SHARING,
   ENABLE_UNIFIED_ANALYTICS,
+  get_clusters,
+  has_connectors,
   HUE_HOST_NAME,
   HUE_IMAGE_VERSION,
   IS_MULTICLUSTER_ONLY,
   RAZ,
   TASK_SERVER,
-  get_clusters,
-  has_connectors,
 )
 from desktop.lib import fsmanager
 from desktop.lib.connectors.api import _get_installed_connectors
 from desktop.lib.connectors.models import Connector
 from desktop.lib.exceptions_renderable import PopupException
 from desktop.lib.i18n import force_unicode
-from desktop.lib.paths import SAFE_CHARACTERS_URI_COMPONENTS, get_run_root
+from desktop.lib.paths import get_run_root, SAFE_CHARACTERS_URI_COMPONENTS
 from desktop.redaction import global_redaction_engine
 from desktop.settings import DOCUMENT2_SEARCH_MAX_LENGTH, HUE_DESKTOP_VERSION
 from filebrowser.conf import REMOTE_STORAGE_HOME
-from hadoop.core_site import get_raz_api_url, get_raz_s3_default_bucket
 from indexer.conf import ENABLE_DIRECT_UPLOAD
 from kafka.conf import has_kafka
 from metadata.conf import get_optimizer_mode
-from notebook.conf import DEFAULT_INTERPRETER, DEFAULT_LIMIT, SHOW_NOTEBOOKS, get_ordered_interpreters
-from useradmin.models import Group, User, get_organization
+from notebook.conf import DEFAULT_INTERPRETER, DEFAULT_LIMIT, get_ordered_interpreters, SHOW_NOTEBOOKS
+from useradmin.models import get_organization, Group, User
 from useradmin.organization import _fitered_queryset
 
 LOG = logging.getLogger()
@@ -484,7 +481,7 @@ class DocumentManager(models.Manager):
               if not job.managed:
                 doc.extra = 'jobsub'
                 doc.save()
-    except Exception as e:
+    except Exception:
       LOG.exception('error syncing oozie')
 
     try:
@@ -496,7 +493,7 @@ class DocumentManager(models.Manager):
             doc = Document.objects.link(job, owner=job.owner, name=job.name, description=job.desc, extra=job.type)
             if job.is_trashed:
               doc.send_to_trash()
-    except Exception as e:
+    except Exception:
       LOG.exception('error syncing beeswax')
 
     try:
@@ -506,7 +503,7 @@ class DocumentManager(models.Manager):
         with transaction.atomic():
           for job in find_jobs_with_no_doc(PigScript):
             Document.objects.link(job, owner=job.owner, name=job.dict['name'], description='')
-    except Exception as e:
+    except Exception:
       LOG.exception('error syncing pig')
 
     try:
@@ -531,7 +528,7 @@ class DocumentManager(models.Manager):
                 Document.objects.link(dashboard_doc, owner=owner, name=dashboard.label, description=dashboard.label,
                                       extra='search-dashboard')
                 dashboard.save()
-    except Exception as e:
+    except Exception:
       LOG.exception('error syncing search')
 
     try:
@@ -551,7 +548,7 @@ class DocumentManager(models.Manager):
             else:
               extra = ''
             doc = Document.objects.link(job, owner=job.owner, name=job.name, description=job.description, extra=extra)
-    except Exception as e:
+    except Exception:
       LOG.exception('error syncing Document2')
 
     if not doc2_only and Document._meta.db_table in table_names:
@@ -560,7 +557,7 @@ class DocumentManager(models.Manager):
         for doc in Document.objects.filter(tags=None):
           default_tag = DocumentTag.objects.get_default_tag(doc.owner)
           doc.tags.add(default_tag)
-      except Exception as e:
+      except Exception:
         LOG.exception('error adding at least one tag to docs')
 
       # Make sure all the sample user documents are shared.
@@ -575,7 +572,7 @@ class DocumentManager(models.Manager):
 
             doc.save()
             Document.objects.filter(id=doc.id).update(last_modified=doc_last_modified)
-      except Exception as e:
+      except Exception:
         LOG.exception('error sharing sample user documents')
 
       # For now remove the default tag from the examples
@@ -583,7 +580,7 @@ class DocumentManager(models.Manager):
         for doc in Document.objects.filter(tags__tag=DocumentTag.EXAMPLE):
           default_tag = DocumentTag.objects.get_default_tag(doc.owner)
           doc.tags.remove(default_tag)
-      except Exception as e:
+      except Exception:
         LOG.exception('error removing default tags')
 
       # ------------------------------------------------------------------------