瀏覽代碼

HUE-1933 [impala] Export query result

Remove empty directories to export to.
Allow creating and choosing a directory to export to.
Impersonation issue fixed by adding "server_name" to design initialization.
use setInterval to force browser redirection.
Abraham Elmahrek 11 年之前
父節點
當前提交
c4d41251ce

+ 129 - 38
apps/beeswax/src/beeswax/api.py

@@ -379,9 +379,11 @@ def cancel_query(request, query_history_id):
 
 
 @error_handler
-def save_results(request, query_history_id):
+def save_results_hdfs_directory(request, query_history_id):
   """
-  Save the results of a query to an HDFS directory or Hive table.
+  Save the results of a query to an HDFS directory.
+
+  Rerun the query.
   """
   response = {'status': 0, 'message': ''}
 
@@ -398,60 +400,149 @@ def save_results(request, query_history_id):
       response['status'] = -1
       return HttpResponse(json.dumps(response), mimetype="application/json")
 
-    # massage data to work with old forms
-    data = {}
-    if request.POST.get('type') == 'hive-table':
-      data['save_target'] = 'to a new table'
-      data['target_table'] = request.POST.get('path', None)
-    elif request.POST.get('type') == 'hdfs':
-      data['save_target'] = 'to HDFS directory'
-      data['target_dir'] = request.POST.get('path', None)
+    db = dbms.get(request.user, query_history.get_query_server_config())
+
+    form = beeswax.forms.SaveResultsDirectoryForm({
+      'target_dir': request.POST.get('path')
+    }, fs=request.fs)
+
+    if form.is_valid():
+      target_dir = request.POST.get('path')
+      try:
+        response['type'] = 'hdfs-dir'
+        response['id'] = query_history.id
+        response['query'] = query_history.query
+        response['path'] = target_dir
+        response['success_url'] = '/filebrowser/view%s' % target_dir
+        query_history = db.insert_query_into_directory(query_history, target_dir)
+        response['watch_url'] = reverse(get_app_name(request) + ':api_watch_query_refresh_json', kwargs={'id': query_history.id})
+      except Exception, ex:
+        error_msg, log = expand_exception(ex, db)
+        response['message'] = _('The result could not be saved: %s.') % error_msg
+        response['status'] = -3
     else:
-      data['save_target'] = None
-      data['target_table'] = request.POST.get('path', None)
-      data['target_dir'] = request.POST.get('path', None)
+      response['status'] = 1
+      response['errors'] = form.errors
+
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
+@error_handler
+def save_results_hdfs_file(request, query_history_id):
+  """
+  Save the results of a query to an HDFS file.
+
+  Do not rerun the query.
+  """
+  response = {'status': 0, 'message': ''}
+
+  query_history = authorized_get_query_history(request, query_history_id, must_exist=True)
+  server_id, state = _get_query_handle_and_state(query_history)
+  query_history.save_state(state)
+  error_msg, log = None, None
+
+  if request.method != 'POST':
+    response['message'] = _('A POST request is required.')
+  else:
+    if not query_history.is_success():
+      response['message'] = _('This query is %(state)s. Results unavailable.') % {'state': state}
+      response['status'] = -1
+      return HttpResponse(json.dumps(response), mimetype="application/json")
+
+    db = dbms.get(request.user, query_history.get_query_server_config())
+
+    form = beeswax.forms.SaveResultsFileForm({
+      'target_file': request.POST.get('path'),
+      'overwrite': request.POST.get('overwrite', False),
+    })
+
+    if form.is_valid():
+      target_file = form.cleaned_data['target_file']
+      overwrite = form.cleaned_data['overwrite']
+
+      try:
+        handle, state = _get_query_handle_and_state(query_history)
+      except Exception, ex:
+        response['message'] = _('Cannot find query handle and state: %s') % str(query_history)
+        response['status'] = -2
+        return HttpResponse(json.dumps(response), mimetype="application/json")
+
+      try:
+        if overwrite and request.fs.exists(target_file):
+          if request.fs.isfile(target_file):
+            request.fs.do_as_user(request.user.username, request.fs.rmtree, target_file)
+          else:
+            raise PopupException(_("The target path is a directory"))
+
+        upload(target_file, handle, request.user, db, request.fs)
+
+        response['type'] = 'hdfs-file'
+        response['id'] = query_history.id
+        response['query'] = query_history.query
+        response['path'] = target_file
+        response['success_url'] = '/filebrowser/view%s' % target_file
+        response['watch_url'] = reverse(get_app_name(request) + ':api_watch_query_refresh_json', kwargs={'id': query_history.id})
+      except Exception, ex:
+        error_msg, log = expand_exception(ex, db)
+        response['message'] = _('The result could not be saved: %s.') % error_msg
+        response['status'] = -3
+    else:
+      response['status'] = 1
+      response['errors'] = form.errors
+
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
+@error_handler
+def save_results_hive_table(request, query_history_id):
+  """
+  Save the results of a query to a hive table.
+
+  Rerun the query.
+  """
+  response = {'status': 0, 'message': ''}
+
+  query_history = authorized_get_query_history(request, query_history_id, must_exist=True)
+  server_id, state = _get_query_handle_and_state(query_history)
+  query_history.save_state(state)
+  error_msg, log = None, None
+
+  if request.method != 'POST':
+    response['message'] = _('A POST request is required.')
+  else:
+    if not query_history.is_success():
+      response['message'] = _('This query is %(state)s. Results unavailable.') % {'state': state}
+      response['status'] = -1
+      return HttpResponse(json.dumps(response), mimetype="application/json")
 
     db = dbms.get(request.user, query_history.get_query_server_config())
     database = query_history.design.get_design().query.get('database', 'default')
-    form = beeswax.forms.SaveResultsForm(data, db=db, fs=request.fs, database=database)
+    form = beeswax.forms.SaveResultsTableForm({
+      'target_table': request.POST.get('table')
+    }, db=db, database=database)
 
     if form.is_valid():
       try:
         handle, state = _get_query_handle_and_state(query_history)
         result_meta = db.get_results_metadata(handle)
       except Exception, ex:
-        response['message'] = _('Cannot find query: %s') % {'state': state}
+        response['message'] = _('Cannot find query handle and state: %s') % str(query_history)
         response['status'] = -2
         return HttpResponse(json.dumps(response), mimetype="application/json")
 
       try:
-        if form.cleaned_data['save_target'] == form.SAVE_TYPE_DIR:
-          target_dir = form.cleaned_data['target_dir']
-          response['type'] = 'hdfs'
-          response['id'] = query_history.id
-          response['query'] = query_history.query
-          response['path'] = target_dir
-          response['success_url'] = '/filebrowser/view%s' % target_dir
-          if form.cleaned_data['rerun']:
-            query_history = db.insert_query_into_directory(query_history, target_dir)
-            response['watch_url'] = reverse(get_app_name(request) + ':api_watch_query_refresh_json', kwargs={'id': query_history.id})
-          else:
-            request.fs.do_as_user(request.user.username, request.fs.mkdir, form.cleaned_data['target_dir'])
-            path = os.path.join(form.cleaned_data['target_dir'], 'results')
-            upload(path, handle, request.user, db, request.fs)
-            response['watch_url'] = reverse(get_app_name(request) + ':api_watch_query_refresh_json', kwargs={'id': query_history.id})
-        elif form.cleaned_data['save_target'] == form.SAVE_TYPE_TBL:
-          query_history = db.create_table_as_a_select(request, query_history, form.target_database, form.cleaned_data['target_table'], result_meta)
-          response['id'] = query_history.id
-          response['query'] = query_history.query
-          response['type'] = 'hive-table'
-          response['path'] = form.cleaned_data['target_table']
-          response['success_url'] = reverse('metastore:describe_table', kwargs={'database': form.target_database, 'table': form.cleaned_data['target_table']})
-          response['watch_url'] = reverse(get_app_name(request) + ':api_watch_query_refresh_json', kwargs={'id': query_history.id})
+        query_history = db.create_table_as_a_select(request, query_history, form.target_database, form.cleaned_data['target_table'], result_meta)
+        response['id'] = query_history.id
+        response['query'] = query_history.query
+        response['type'] = 'hive-table'
+        response['path'] = form.cleaned_data['target_table']
+        response['success_url'] = reverse('metastore:describe_table', kwargs={'database': form.target_database, 'table': form.cleaned_data['target_table']})
+        response['watch_url'] = reverse(get_app_name(request) + ':api_watch_query_refresh_json', kwargs={'id': query_history.id})
       except Exception, ex:
         error_msg, log = expand_exception(ex, db)
         response['message'] = _('The result could not be saved: %s.') % error_msg
         response['status'] = -3
+
     else:
       response['status'] = 1
       response['errors'] = form.errors

+ 6 - 1
apps/beeswax/src/beeswax/data_export.py

@@ -20,6 +20,8 @@
 import logging
 import time
 
+from django.utils.translation import ugettext as _
+
 from desktop.lib import export_csvxls
 
 from beeswax import common, conf
@@ -54,7 +56,10 @@ def upload(path, handle, user, db, fs):
   has_more = True
   start_over = True
 
-  fs.do_as_user(user.username, fs.create, path, overwrite=True)
+  if fs.do_as_user(user.username, fs.exists, path):
+    raise Exception(_("%s already exists.") % path)
+  else:
+    fs.do_as_user(user.username, fs.create, path)
 
   while has_more:
     data, has_more = HS2DataAdapter(handle, db, conf.DOWNLOAD_ROW_LIMIT.get(), start_over=start_over)

+ 3 - 1
apps/beeswax/src/beeswax/design.py

@@ -37,7 +37,7 @@ LOG = logging.getLogger(__name__)
 SERIALIZATION_VERSION = '0.4.1'
 
 
-def hql_query(hql, database='default'):
+def hql_query(hql, database='default', query_type=None):
   data_dict = json.loads('{"query": {"email_notify": false, "query": null, "type": 0, "is_parameterized": true, "database": "default"}, '
                                '"functions": [], "VERSION": "0.4.1", "file_resources": [], "settings": []}')
   if not (isinstance(hql, str) or isinstance(hql, unicode)):
@@ -45,6 +45,8 @@ def hql_query(hql, database='default'):
 
   data_dict['query']['query'] = strip_trailing_semicolon(hql)
   data_dict['query']['database'] = database
+  if query_type:
+    data_dict['query']['type'] = query_type
   hql_design = HQLdesign()
   hql_design._data_dict = data_dict
 

+ 59 - 49
apps/beeswax/src/beeswax/forms.py

@@ -77,65 +77,75 @@ class SaveForm(forms.Form):
     self.data = data2
 
 
-class SaveResultsForm(DependencyAwareForm):
-  """Used for saving the query result data"""
-
-  SAVE_TYPES = (SAVE_TYPE_TBL, SAVE_TYPE_DIR) = ('to a new table', 'to HDFS directory')
-  save_target = forms.ChoiceField(required=True,
-                                  choices=common.to_choices(SAVE_TYPES),
-                                  widget=forms.RadioSelect,
-                                  initial=SAVE_TYPE_TBL)
+class SaveResultsDirectoryForm(forms.Form):
+  """Used for saving the query result data to hdfs directory"""
+
+  target_dir = forms.CharField(label=_t("Directory"),
+                               required=True,
+                               help_text=_t("Path to directory"))
+
+  def __init__(self, *args, **kwargs):
+    self.fs = kwargs.pop('fs', None)
+    super(SaveResultsDirectoryForm, self).__init__(*args, **kwargs)
+
+  def clean_target_dir(self):
+    if not self.cleaned_data['target_dir'].startswith('/'):
+      raise forms.ValidationError(_("Target directory should begin with a /"))
+    elif self.fs.exists(self.cleaned_data['target_dir']):
+      raise forms.ValidationError(_('Directory already exists.'))
+    return self.cleaned_data['target_dir']
+
+
+class SaveResultsFileForm(forms.Form):
+  """Used for saving the query result data to hdfs file"""
+
+  target_file = forms.CharField(label=_t("File path"),
+                                required=True,
+                                help_text=_t("Path to file"))
+  overwrite = forms.BooleanField(label=_t('Overwrite'),
+                                 required=False,
+                                 help_text=_t("Overwrite the selected filed"))
+
+  def clean_target_file(self):
+    if not self.cleaned_data['target_file'].startswith('/'):
+      raise forms.ValidationError("Target file should begin with a /")
+
+    return self.cleaned_data['target_file']
+
+
+class SaveResultsTableForm(forms.Form):
+  """Used for saving the query result data to hive table"""
+
   target_table = common.HiveIdentifierField(
                                   label=_t("Table Name"),
-                                  required=False,
+                                  required=True,
                                   help_text=_t("Name of the new table")) # Can also contain a DB prefixed table name, e.g. DB_NAME.TABLE_NAME
-  target_dir = PathField(label=_t("Results Location"),
-                         required=False,
-                         help_text=_t("Empty directory in HDFS to store results."))
-  rerun = forms.BooleanField(label=_t("Run an export query"),
-                             initial=False,
-                             required=False)
-  dependencies = [
-    ('save_target', SAVE_TYPE_TBL, 'target_table'),
-    ('save_target', SAVE_TYPE_DIR, 'target_dir'),
-  ]
 
   def __init__(self, *args, **kwargs):
     self.db = kwargs.pop('db', None)
-    self.fs = kwargs.pop('fs', None)
     self.target_database = kwargs.pop('database', 'default')
-    super(SaveResultsForm, self).__init__(*args, **kwargs)
+    super(SaveResultsTableForm, self).__init__(*args, **kwargs)
 
   def clean(self):
-    cleaned_data = super(SaveResultsForm, self).clean()
-
-    if cleaned_data:
-      if cleaned_data.get('save_target') == SaveResultsForm.SAVE_TYPE_TBL:
-        target_table = cleaned_data.get('target_table')
-        if target_table:
-          try:
-            if self.db is not None:
-              name_parts = target_table.split(".")
-              if len(name_parts) == 1:
-                pass
-              elif len(name_parts) == 2:
-                self.target_database, target_table = name_parts
-              else:
-                self._errors['target_table'] = self.error_class([_('Invalid table prefix name')])
-              cleaned_data['target_table'] = target_table # Update table name without the DB prefix
-              self.db.get_table(self.target_database, target_table)
-            self._errors['target_table'] = self.error_class([_('Table already exists')])
-            del cleaned_data['target_table']
-          except Exception:
+    cleaned_data = super(SaveResultsTableForm, self).clean()
+
+    target_table = cleaned_data.get('target_table')
+    if target_table:
+      try:
+        if self.db is not None:
+          name_parts = target_table.split(".")
+          if len(name_parts) == 1:
             pass
-      elif cleaned_data['save_target'] == SaveResultsForm.SAVE_TYPE_DIR:
-        target_dir = cleaned_data['target_dir']
-        if not target_dir.startswith('/'):
-          self._errors['target_dir'] = self.error_class([_('Directory should start with /')])
-        elif self.fs.exists(target_dir):
-          self._errors['target_dir'] = self.error_class([_('Directory already exists.')]) # Overwrite destination directory content
-    else:
-      self._errors['save_target'] = self.error_class([_('Please select a save target.')])
+          elif len(name_parts) == 2:
+            self.target_database, target_table = name_parts
+          else:
+            self._errors['target_table'] = self.error_class([_('Invalid table prefix name')])
+          cleaned_data['target_table'] = target_table # Update table name without the DB prefix
+          self.db.get_table(self.target_database, target_table)
+        self._errors['target_table'] = self.error_class([_('Table already exists')])
+        del cleaned_data['target_table']
+      except Exception:
+        pass
 
     return cleaned_data
 

+ 6 - 2
apps/beeswax/src/beeswax/server/dbms.py

@@ -27,7 +27,7 @@ from beeswax import hive_site
 from beeswax.conf import HIVE_SERVER_HOST, HIVE_SERVER_PORT,\
   BROWSE_PARTITIONED_TABLE_LIMIT
 from beeswax.design import hql_query
-from beeswax.models import QueryHistory, HIVE_SERVER2, BEESWAX
+from beeswax.models import QueryHistory, HIVE_SERVER2, BEESWAX, QUERY_TYPES
 
 from filebrowser.views import location_to_url
 from desktop.lib.django_util import format_preserving_redirect
@@ -91,6 +91,7 @@ class HiveServer2Dbms(object):
   def __init__(self, client, server_type):
     self.client = client
     self.server_type = server_type
+    self.server_name = self.client.query_server['server_name']
 
   def get_table(self, database, table_name):
     # DB name not supported in SHOW PARTITIONS required in Table
@@ -117,7 +118,10 @@ class HiveServer2Dbms(object):
 
 
   def execute_statement(self, hql):
-    query = hql_query(hql)
+    if self.server_name == 'impala':
+      query = hql_query(hql, QUERY_TYPES[1])
+    else:
+      query = hql_query(hql, QUERY_TYPES[0])
     return self.execute_and_watch(query)
 
 

+ 51 - 16
apps/beeswax/src/beeswax/templates/execute.mako

@@ -536,7 +536,7 @@ ${layout.menubar(section='query')}
   <div class="modal-header">
     <a href="#" class="close" data-dismiss="modal">&times;</a>
 
-    <h3>${_('Choose an empty folder')}</h3>
+    <h3>${_('Choose a folder')}</h3>
   </div>
   <div class="modal-body">
     <div id="folderchooser">
@@ -593,38 +593,53 @@ ${layout.menubar(section='query')}
     <!-- /ko -->
     <form id="saveResultsForm" method="POST" class="form form-inline">
       <fieldset>
-        <div data-bind="css: {'error': $root.design.results.save.targetTableError()}" class="control-group">
+        <div data-bind="css: {'error': $root.design.results.save.targetFileError()}" class="control-group">
           <div class="controls">
             <label class="radio">
-              <input data-bind="checked: $root.design.results.save.type" type="radio" name="save-results-type" value="hive-table">
-              &nbsp;${ _('In a new table') }
+              <input data-bind="checked: $root.design.results.save.type" type="radio" name="save-results-type" value="hdfs-file">
+              &nbsp;${ _('In an HDFS file') }
             </label>
-            <span data-bind="visible: $root.design.results.save.type() == 'hive-table'">
-              <input data-bind="value: $root.design.results.save.path" type="text" name="target_table" class="span4" placeholder="${_('Table name or <database name>.<table name>')}">
+            <span data-bind="visible: $root.design.results.save.type() == 'hdfs-file'">
+              <input data-bind="value: $root.design.results.save.path" type="text" name="target_file" placeholder="${_('Results location')}" class="fileChooser">
             </span>
+            <label class="radio" data-bind="visible: $root.design.results.save.type() == 'hdfs-file'">
+              <input data-bind="checked: $root.design.results.save.overwrite" type="checkbox" name="overwrite">
+              ${ _('Overwrite') }
+            </label>
           </div>
         </div>
-        <div data-bind="css: {'error': $root.design.results.save.targetDirectoryError()}" class="control-group">
+        <div data-bind="css: {'error': $root.design.results.save.targetDirectoryError()}" class="control-group hide advanced">
           <div class="controls">
             <label class="radio">
-              <input data-bind="checked: $root.design.results.save.type" type="radio" name="save-results-type" value="hdfs">
+              <input data-bind="checked: $root.design.results.save.type" type="radio" name="save-results-type" value="hdfs-directory">
               &nbsp;${ _('In an HDFS directory') }
             </label>
-            <span data-bind="visible: $root.design.results.save.type() == 'hdfs'">
-              <input data-bind="value: $root.design.results.save.path" type="text" name="target_dir" placeholder="${_('Results location')}" class="pathChooser">
+            <span data-bind="visible: $root.design.results.save.type() == 'hdfs-directory'">
+              <input data-bind="value: $root.design.results.save.path" type="text" name="target_dir" placeholder="${_('Results location')}" class="folderChooser">
+              <i class="fa fa-question-circle" id="hdfs-directory-help"></i>
             </span>
-            <label class="radio" data-bind="visible: $root.design.results.save.type() == 'hdfs'">
-              % if app_name != 'impala':
-                <input data-bind="checked: $root.design.results.save.rerun" type="checkbox" name="rerun">
-                ${ _('Run an export query') }
-              % endif
+          </div>
+        </div>
+        <div data-bind="css: {'error': $root.design.results.save.targetTableError()}" class="control-group">
+          <div class="controls">
+            <label class="radio">
+              <input data-bind="checked: $root.design.results.save.type" type="radio" name="save-results-type" value="hive-table">
+              &nbsp;${ _('In a new table') }
             </label>
+            <span data-bind="visible: $root.design.results.save.type() == 'hive-table'">
+              <input data-bind="value: $root.design.results.save.path" type="text" name="target_table" class="span4" placeholder="${_('Table name or <database name>.<table name>')}">
+            </span>
           </div>
         </div>
       </fieldset>
     </form>
+    <div id="hdfs-directory-help-content" class="hide">
+      <p>${ _("Using this option will rerun the query. Use the 'save as file' option to skip rerunning the query.") }</p>
+    </div>
   </div>
   <div class="modal-footer">
+    <a id="save-results-advanced" href="javascript:void(0)" class="pull-left">${ _('Show advanced fields') }</a>
+    <a id="save-results-simple" href="javascript:void(0)" class="pull-left hide">${ _('Hide advanced fields') }</a>
     <button class="btn" data-dismiss="modal">${_('Cancel')}</button>
     <button data-bind="click: trySaveResults" class="btn btn-primary">${_('Save')}</button>
   </div>
@@ -2013,6 +2028,25 @@ $(document).ready(function () {
     'html': true
   });
 
+  $("#hdfs-directory-help").popover({
+    'title': "${_('Did you know?')}",
+    'content': $("#hdfs-directory-help-content").html(),
+    'trigger': 'hover',
+    'placement': 'right',
+    'html': true
+  });
+
+  $(document).on('click', '#save-results-simple', function() {
+    $('#save-results-advanced').removeClass('hide');
+    $('#save-results-simple').addClass('hide');
+    $('#saveResultsForm .advanced').addClass('hide');
+  });
+  $(document).on('click', '#save-results-advanced', function() {
+    $('#save-results-advanced').addClass('hide');
+    $('#save-results-simple').removeClass('hide');
+    $('#saveResultsForm .advanced').removeClass('hide');
+  });
+
   $(document).on("change", ".settingsField", function(){
     updateSidebarTooltips(".settingsField");
   });
@@ -2092,7 +2126,7 @@ $(window).data('beforeunload', window.onbeforeunload);
 
 % endif
 
-$(".pathChooser:not(:has(~ button))").after(getFileAndFolderBrowseButton($(".pathChooser:not(:has(~ button))"), true));
+$(".folderChooser:not(:has(~ button))").after(getFolderBrowseButton($(".folderChooser:not(:has(~ button))"), true));
 
 
 // Routie
@@ -2384,3 +2418,4 @@ ko.applyBindings(viewModel);
 </script>
 
 ${ commonfooter(messages) | n,unicode }
+

+ 65 - 16
apps/beeswax/src/beeswax/tests.py

@@ -846,15 +846,14 @@ for x in sys.stdin:
 
   def test_save_results_to_dir(self):
 
-    def save_and_verify(select_resp, target_dir, rerun=True, verify=True):
+    def save_and_verify(select_resp, target_dir, verify=True):
       content = json.loads(select_resp.content)
       qid = content['id']
       save_data = {
-        'type': 'hdfs',
-        'path': target_dir,
-        'rerun': rerun
+        'type': 'hdfs-directory',
+        'path': target_dir
       }
-      resp = self.client.post('/beeswax/api/query/%s/results/save' % qid, save_data, follow=True)
+      resp = self.client.post('/beeswax/api/query/%s/results/save/hdfs/directory' % qid, save_data, follow=True)
       content = json.loads(resp.content)
 
       if content['status'] == 0:
@@ -869,10 +868,8 @@ for x in sys.stdin:
         assert_true(len(target_ls) >= 1)
         data_buf = ""
 
-        if not rerun:
-          assert_equal(len(target_ls), 1)
-          # filename is 'results'
-          assert_equal(target_ls[0], 'results')
+
+        assert_equal(len(target_ls), 1)
 
         for target in target_ls:
           target_file = self.cluster.fs.open(target_dir + '/' + target)
@@ -884,12 +881,12 @@ for x in sys.stdin:
 
       return resp
 
-    TARGET_DIR_ROOT = '/tmp/beeswax.test_save_results'
+    TARGET_DIR_ROOT = '/tmp/beeswax.test_save_directory_results'
+
+    # Already existing dir
     if not self.cluster.fs.exists(TARGET_DIR_ROOT):
       self.cluster.fs.mkdir(TARGET_DIR_ROOT)
       self.cluster.fs.chown(TARGET_DIR_ROOT, user='test')
-
-    # Already existing dir
     hql = "SELECT * FROM test"
     resp = _make_query(self.client, hql, wait=True, local=False, max=180.0)
     resp = save_and_verify(resp, TARGET_DIR_ROOT, verify=False)
@@ -917,11 +914,63 @@ for x in sys.stdin:
     resp = self.client.get(resp.success_url)
     assert_true('File Browser' in resp.content, resp.content)
 
+
+  def test_save_results_to_file(self):
+
+    def save_and_verify(select_resp, target_file, overwrite=True, verify=True):
+      content = json.loads(select_resp.content)
+      qid = content['id']
+      save_data = {
+        'type': 'hdfs',
+        'path': target_file,
+        'overwrite': overwrite
+      }
+      resp = self.client.post('/beeswax/api/query/%s/results/save/hdfs/file' % qid, save_data, follow=True)
+      content = json.loads(resp.content)
+
+      if content['status'] == 0:
+        success_url = content['success_url']
+        resp = self.client.get(content['watch_url'], follow=True)
+        resp = wait_for_query_to_finish(self.client, resp, max=60)
+        resp.success_url = success_url # Hack until better API
+
+      # Check that data is right
+      if verify:
+        assert_true(self.cluster.fs.exists(target_file))
+        assert_true(self.cluster.fs.isfile(target_file))
+        data_buf = ""
+
+        _file = self.cluster.fs.open(target_file)
+        data_buf += _file.read()
+        _file.close()
+
+        assert_equal(256, len(data_buf.strip().split('\n')))
+        assert_true('255' in data_buf)
+
+      return resp
+
+    TARGET_FILE = '/tmp/beeswax.test_save_file_results'
+    if self.cluster.fs.exists(TARGET_FILE):
+      self.cluster.fs.rmtree(TARGET_FILE)
+
     # SELECT columns. (Result dir is in /tmp.)
-    # Do not rerun
     hql = "SELECT foo, bar FROM test"
     resp = _make_query(self.client, hql, wait=True, local=False, max=180.0)
-    resp = save_and_verify(resp, TARGET_DIR_ROOT + '/4', rerun=False, verify=True)
+    resp = save_and_verify(resp, TARGET_FILE)
+    resp = self.client.get(resp.success_url)
+    assert_true('File Browser' in resp.content, resp.content)
+
+    # overwrite = false
+    hql = "SELECT foo, bar FROM test"
+    resp = _make_query(self.client, hql, wait=True, local=False, max=180.0)
+    resp = save_and_verify(resp, TARGET_FILE, overwrite=False, verify=False)
+    assert_true('-3' in resp.content)
+    assert_true('already exists' in resp.content)
+
+    # Partition tables
+    hql = "SELECT * FROM test_partitions"
+    resp = _make_query(self.client, hql, wait=True, local=False, max=180.0)
+    resp = save_and_verify(resp, TARGET_FILE, verify=False)
     resp = self.client.get(resp.success_url)
     assert_true('File Browser' in resp.content, resp.content)
 
@@ -934,9 +983,9 @@ for x in sys.stdin:
       qid = content['id']
       save_data = {
         'type': 'hive-table',
-        'path': target_tbl
+        'table': target_tbl
       }
-      resp = self.client.post('/beeswax/api/query/%s/results/save' % qid, save_data, follow=True)
+      resp = self.client.post('/beeswax/api/query/%s/results/save/hive/table' % qid, save_data, follow=True)
       content = json.loads(resp.content)
       resp = self.client.get(content['watch_url'], follow=True)
       wait_for_query_to_finish(self.client, resp, max=120)

+ 3 - 1
apps/beeswax/src/beeswax/urls.py

@@ -67,7 +67,9 @@ urlpatterns += patterns(
   url(r'^api/query/execute/(?P<design_id>\d+)?$', 'execute', name='api_execute'),
   url(r'^api/query/(?P<query_history_id>\d+)/cancel$', 'cancel_query', name='api_cancel_query'),
   url(r'^api/query/(?P<query_history_id>\d+)/close/?$', 'close_operation', name='api_close_operation'),
-  url(r'^api/query/(?P<query_history_id>\d+)/results/save$', 'save_results', name='api_save_results'),
+  url(r'^api/query/(?P<query_history_id>\d+)/results/save/hive/table$', 'save_results_hive_table', name='api_save_results_hive_table'),
+  url(r'^api/query/(?P<query_history_id>\d+)/results/save/hdfs/file$', 'save_results_hdfs_file', name='api_save_results_hdfs_file'),
+  url(r'^api/query/(?P<query_history_id>\d+)/results/save/hdfs/directory$', 'save_results_hdfs_directory', name='api_save_results_hdfs_directory'),
   url(r'^api/watch/json/(?P<id>\d+)$', 'watch_query_refresh_json', name='api_watch_query_refresh_json'),
 
   url(r'^api/table/(?P<database>\w+)/(?P<table>\w+)$', 'describe_table', name='describe_table'),

+ 6 - 11
apps/beeswax/src/beeswax/views.py

@@ -497,7 +497,6 @@ def view_results(request, id, first_row=0):
       for format in common.DL_FORMATS:
         download_urls[format] = reverse(app_name + ':download', kwargs=dict(id=str(id), format=format))
 
-    save_form = beeswax.forms.SaveResultsForm()
     results.start_row = first_row
 
     context.update({
@@ -509,7 +508,6 @@ def view_results(request, id, first_row=0):
       'expected_first_row': first_row,
       'columns': columns,
       'download_urls': download_urls,
-      'save_form': save_form,
       'can_save': query_history.owner == request.user,
       'next_json_set':
         reverse(get_app_name(request) + ':view_results', kwargs={
@@ -520,15 +518,12 @@ def view_results(request, id, first_row=0):
         + ('?context=' + context_param or '') + '&format=json'
     })
 
-  if request.GET.get('format') == 'json':
-    context['columns'] = massage_columns_for_json(columns)
-    if 'save_form' in context:
-      del context['save_form']
-    if 'query' in context:
-      del context['query']
-    return HttpResponse(json.dumps(context), mimetype="application/json")
-  else:
-    return render('watch_results.mako', request, context)
+  context['columns'] = massage_columns_for_json(columns)
+  if 'save_form' in context:
+    del context['save_form']
+  if 'query' in context:
+    del context['query']
+  return HttpResponse(json.dumps(context), mimetype="application/json")
 
 
 def configuration(request):

+ 60 - 29
apps/beeswax/static/js/beeswax.vm.js

@@ -56,8 +56,7 @@ function BeeswaxViewModel(server) {
       'save': {
         'errors': null,
         'type': 'hive-table',
-        'path': null,
-        'rerun': false
+        'path': null
       }
     },
     'watch': {
@@ -99,6 +98,10 @@ function BeeswaxViewModel(server) {
     return (self.design.results.save.errors() && 'target_dir' in self.design.results.save.errors()) ? self.design.results.save.errors()['target_dir'] : null;
   });
 
+  self.design.results.save.targetFileError = ko.computed(function() {
+    return (self.design.results.save.errors() && 'target_file' in self.design.results.save.errors()) ? self.design.results.save.errors()['target_file'] : null;
+  });
+
   self.database = ko.computed({
     'read': function() {
       if (self.databases()) {
@@ -756,14 +759,35 @@ function BeeswaxViewModel(server) {
     self.design.isRunning(true);
     self.resetErrors();
     if (self.design.id()) {
-      var data = {
-        'database': self.database(),
-        'server': self.server(),
-        'type': self.design.results.save.type(),
-        'path': self.design.results.save.path(),
-        'rerun': self.design.results.save.rerun()
-      };
-      var url = '/' + self.server() + '/api/query/' + self.design.history.id() + '/results/save';
+      var data, url;
+
+      switch(self.design.results.save.type()) {
+        case 'hdfs-directory':
+        data = {
+          'server': self.server(),
+          'path': self.design.results.save.path()
+        };
+        url = '/' + self.server() + '/api/query/' + self.design.history.id() + '/results/save/hdfs/directory';
+        break;
+
+        case 'hdfs-file':
+        data = {
+          'server': self.server(),
+          'path': self.design.results.save.path()
+        };
+        url = '/' + self.server() + '/api/query/' + self.design.history.id() + '/results/save/hdfs/file';
+        break;
+
+        // case 'hive-table':
+        default:
+        data = {
+          'database': self.database(),
+          'server': self.server(),
+          'table': self.design.results.save.path()
+        };
+        url = '/' + self.server() + '/api/query/' + self.design.history.id() + '/results/save/hive/table';
+        break;
+      }
       var request = {
         url: url,
         dataType: 'json',
@@ -771,17 +795,19 @@ function BeeswaxViewModel(server) {
         success: function(data) {
           if (data.status == 0) {
             self.design.results.save.errors(null);
+
+            var redirect_fn = function() {
+              window.location.href = data.success_url;
+              self.design.isRunning(false);
+            };
             if (data.id) {
               // watch this ID.
               self.design.watch.url(data.watch_url);
               self.design.watch.logs.removeAll();
-              self.watchQueryLoop(function() {
-                window.location.href = data.success_url;
-              });
+              self.watchQueryLoop(redirect_fn);
             } else {
               // redirect to metastore app.
-              window.location.href = data.success_url;
-              self.design.isRunning(false);
+              redirect_fn();
             }
             $(document).trigger('saved.results', data);
           } else {
@@ -793,7 +819,7 @@ function BeeswaxViewModel(server) {
         error: function(data) {
           self.design.isRunning(false);
           self.design.results.save.errors(data);
-          $(document).trigger('error_save.results');
+          $(document).trigger('error_save.results', [data]);
         },
         data: data,
         cache: false
@@ -835,21 +861,26 @@ function getFileBrowseButton(inputElement) {
   });
 }
 
-function getFileAndFolderBrowseButton(inputElement) {
+function folderChooser(inputElement) {
+  $("#folderchooser").jHueFileChooser({
+    initialPath: inputElement.val(),
+    onFolderChoose:function (folderPath) {
+      inputElement.val(folderPath);
+      inputElement.trigger("change");
+      $("#chooseFolder").modal("hide");
+    },
+    selectFolder: true,
+    createFolder: true,
+    selectFile: false,
+    uploadFile: false
+  });
+  $("#chooseFolder").modal("show");
+}
+
+function getFolderBrowseButton(inputElement) {
   return $("<button>").addClass("btn").addClass("fileChooserBtn").text("..").click(function (e) {
     e.preventDefault();
-    $("#folderchooser").jHueFileChooser({
-      initialPath: inputElement.val(),
-      onFolderChoose:function (folderPath) {
-        inputElement.val(folderPath);
-        inputElement.trigger("change");
-        $("#chooseFolder").modal("hide");
-      },
-      selectFolder: true,
-      createFolder: false,
-      uploadFile:false
-    });
-    $("#chooseFolder").modal("show");
+    folderChooser(inputElement);
   });
 }